mirror of
https://github.com/openai/codex.git
synced 2026-02-25 18:23:47 +00:00
Compare commits
36 Commits
dev/cc/new
...
feat/synta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a281880f37 | ||
|
|
dd654ca0b2 | ||
|
|
6444115730 | ||
|
|
55c961040c | ||
|
|
486f5850ca | ||
|
|
44a7ba0dda | ||
|
|
18d62ac80f | ||
|
|
75c819e54a | ||
|
|
5986719f96 | ||
|
|
801ae5e979 | ||
|
|
89891de55a | ||
|
|
46021a3591 | ||
|
|
c510eec9f8 | ||
|
|
8c4bfa78f7 | ||
|
|
f72e7bf529 | ||
|
|
987db6aaf6 | ||
|
|
0066e85f0f | ||
|
|
197131762b | ||
|
|
85e1e7bf5a | ||
|
|
f4ac1f09b3 | ||
|
|
8ca13e5d56 | ||
|
|
ea57beb959 | ||
|
|
976dd6831e | ||
|
|
d32e05d4e5 | ||
|
|
06d4c95d35 | ||
|
|
3cd1301409 | ||
|
|
e7d4e828c3 | ||
|
|
32ff9e89ed | ||
|
|
f00f7d49dc | ||
|
|
82fc47e317 | ||
|
|
9878b734a0 | ||
|
|
5f92cb4e58 | ||
|
|
d97bd689dd | ||
|
|
252b574c65 | ||
|
|
1ddc3d7b00 | ||
|
|
776e4b0aa8 |
107
codex-rs/Cargo.lock
generated
107
codex-rs/Cargo.lock
generated
@@ -852,6 +852,15 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1"
|
||||
|
||||
[[package]]
|
||||
name = "bincode"
|
||||
version = "1.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.5.3"
|
||||
@@ -2297,6 +2306,7 @@ dependencies = [
|
||||
"strum 0.27.2",
|
||||
"strum_macros 0.27.2",
|
||||
"supports-color 3.0.2",
|
||||
"syntect",
|
||||
"tempfile",
|
||||
"textwrap 0.16.2",
|
||||
"thiserror 2.0.18",
|
||||
@@ -2307,8 +2317,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"tree-sitter-bash",
|
||||
"tree-sitter-highlight",
|
||||
"two-face",
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.2.1",
|
||||
"url",
|
||||
@@ -5157,6 +5166,12 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linked-hash-map"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
|
||||
|
||||
[[package]]
|
||||
name = "linux-keyutils"
|
||||
version = "0.2.4"
|
||||
@@ -5964,6 +5979,28 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "onig"
|
||||
version = "6.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"onig_sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "onig_sys"
|
||||
version = "69.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
@@ -6381,6 +6418,19 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.13.0",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.0"
|
||||
@@ -8886,6 +8936,27 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syntect"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
|
||||
dependencies = [
|
||||
"bincode",
|
||||
"flate2",
|
||||
"fnv",
|
||||
"once_cell",
|
||||
"onig",
|
||||
"plist",
|
||||
"regex-syntax 0.8.8",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"walkdir",
|
||||
"yaml-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sys-locale"
|
||||
version = "0.3.2"
|
||||
@@ -9622,18 +9693,6 @@ dependencies = [
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-highlight"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adc5f880ad8d8f94e88cb81c3557024cf1a8b75e3b504c50481ed4f5a6006ff3"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"streaming-iterator",
|
||||
"thiserror 2.0.18",
|
||||
"tree-sitter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-language"
|
||||
version = "0.1.7"
|
||||
@@ -9701,6 +9760,17 @@ dependencies = [
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "two-face"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b285c51f8a6ade109ed4566d33ac4fb289fb5d6cf87ed70908a5eaf65e948e34"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"syntect",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "type-map"
|
||||
version = "0.5.1"
|
||||
@@ -10965,6 +11035,15 @@ dependencies = [
|
||||
"lzma-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaml-rust"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
|
||||
dependencies = [
|
||||
"linked-hash-map",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yansi"
|
||||
version = "1.0.1"
|
||||
|
||||
@@ -276,7 +276,7 @@ tracing-subscriber = "0.3.22"
|
||||
tracing-test = "0.2.5"
|
||||
tree-sitter = "0.25.10"
|
||||
tree-sitter-bash = "0.25"
|
||||
tree-sitter-highlight = "0.25.10"
|
||||
syntect = "5"
|
||||
ts-rs = "11"
|
||||
tungstenite = { version = "0.27.0", features = ["deflate", "proxy"] }
|
||||
uds_windows = "1.1.0"
|
||||
|
||||
@@ -49,23 +49,17 @@
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Auto-detect: disable alternate screen in Zellij, enable elsewhere.",
|
||||
"enum": [
|
||||
"auto"
|
||||
],
|
||||
"enum": ["auto"],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Always use alternate screen (original behavior).",
|
||||
"enum": [
|
||||
"always"
|
||||
],
|
||||
"enum": ["always"],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Never use alternate screen (inline mode only).",
|
||||
"enum": [
|
||||
"never"
|
||||
],
|
||||
"enum": ["never"],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
@@ -122,11 +116,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"AppToolApproval": {
|
||||
"enum": [
|
||||
"auto",
|
||||
"prompt",
|
||||
"approve"
|
||||
],
|
||||
"enum": ["auto", "prompt", "approve"],
|
||||
"type": "string"
|
||||
},
|
||||
"AppToolConfig": {
|
||||
@@ -197,23 +187,17 @@
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.",
|
||||
"enum": [
|
||||
"untrusted"
|
||||
],
|
||||
"enum": ["untrusted"],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
|
||||
"enum": [
|
||||
"on-failure"
|
||||
],
|
||||
"enum": ["on-failure"],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The model decides when to ask the user for approval.",
|
||||
"enum": [
|
||||
"on-request"
|
||||
],
|
||||
"enum": ["on-request"],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
@@ -224,16 +208,12 @@
|
||||
"$ref": "#/definitions/RejectConfig"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"reject"
|
||||
],
|
||||
"required": ["reject"],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.",
|
||||
"enum": [
|
||||
"never"
|
||||
],
|
||||
"enum": ["never"],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
@@ -243,30 +223,22 @@
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Persist credentials in CODEX_HOME/auth.json.",
|
||||
"enum": [
|
||||
"file"
|
||||
],
|
||||
"enum": ["file"],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Persist credentials in the keyring. Fail if unavailable.",
|
||||
"enum": [
|
||||
"keyring"
|
||||
],
|
||||
"enum": ["keyring"],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Use keyring when available; otherwise, fall back to a file in CODEX_HOME.",
|
||||
"enum": [
|
||||
"auto"
|
||||
],
|
||||
"enum": ["auto"],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Store credentials in memory only for the current process.",
|
||||
"enum": [
|
||||
"ephemeral"
|
||||
],
|
||||
"enum": ["ephemeral"],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
@@ -520,10 +492,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"ForcedLoginMethod": {
|
||||
"enum": [
|
||||
"chatgpt",
|
||||
"api"
|
||||
],
|
||||
"enum": ["chatgpt", "api"],
|
||||
"type": "string"
|
||||
},
|
||||
"GhostSnapshotToml": {
|
||||
@@ -565,25 +534,19 @@
|
||||
"description": "If true, history entries will not be written to disk."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"persistence"
|
||||
],
|
||||
"required": ["persistence"],
|
||||
"type": "object"
|
||||
},
|
||||
"HistoryPersistence": {
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Save all history entries to disk.",
|
||||
"enum": [
|
||||
"save-all"
|
||||
],
|
||||
"enum": ["save-all"],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Do not write history to disk.",
|
||||
"enum": [
|
||||
"none"
|
||||
],
|
||||
"enum": ["none"],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
@@ -708,16 +671,11 @@
|
||||
"description": "Which wire protocol this provider expects."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"required": ["name"],
|
||||
"type": "object"
|
||||
},
|
||||
"NetworkModeSchema": {
|
||||
"enum": [
|
||||
"limited",
|
||||
"full"
|
||||
],
|
||||
"enum": ["limited", "full"],
|
||||
"type": "string"
|
||||
},
|
||||
"NetworkToml": {
|
||||
@@ -815,11 +773,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"NotificationMethod": {
|
||||
"enum": [
|
||||
"auto",
|
||||
"osc9",
|
||||
"bel"
|
||||
],
|
||||
"enum": ["auto", "osc9", "bel"],
|
||||
"type": "string"
|
||||
},
|
||||
"Notifications": {
|
||||
@@ -840,23 +794,17 @@
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "`Keyring` when available; otherwise, `File`. Credentials stored in the keyring will only be readable by Codex unless the user explicitly grants access via OS-level keyring access.",
|
||||
"enum": [
|
||||
"auto"
|
||||
],
|
||||
"enum": ["auto"],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "CODEX_HOME/.credentials.json This file will be readable to Codex and other applications running as the same user.",
|
||||
"enum": [
|
||||
"file"
|
||||
],
|
||||
"enum": ["file"],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Keyring when available, otherwise fail.",
|
||||
"enum": [
|
||||
"keyring"
|
||||
],
|
||||
"enum": ["keyring"],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
@@ -904,10 +852,7 @@
|
||||
"description": "Which OTEL exporter to use.",
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"none",
|
||||
"statsig"
|
||||
],
|
||||
"enum": ["none", "statsig"],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
@@ -938,16 +883,11 @@
|
||||
"default": null
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"endpoint",
|
||||
"protocol"
|
||||
],
|
||||
"required": ["endpoint", "protocol"],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"otlp-http"
|
||||
],
|
||||
"required": ["otlp-http"],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
@@ -975,15 +915,11 @@
|
||||
"default": null
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"endpoint"
|
||||
],
|
||||
"required": ["endpoint"],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"otlp-grpc"
|
||||
],
|
||||
"required": ["otlp-grpc"],
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
@@ -992,16 +928,12 @@
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Binary payload",
|
||||
"enum": [
|
||||
"binary"
|
||||
],
|
||||
"enum": ["binary"],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "JSON payload",
|
||||
"enum": [
|
||||
"json"
|
||||
],
|
||||
"enum": ["json"],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
@@ -1036,11 +968,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"Personality": {
|
||||
"enum": [
|
||||
"none",
|
||||
"friendly",
|
||||
"pragmatic"
|
||||
],
|
||||
"enum": ["none", "friendly", "pragmatic"],
|
||||
"type": "string"
|
||||
},
|
||||
"ProjectConfig": {
|
||||
@@ -1155,32 +1083,19 @@
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"enum": ["none", "minimal", "low", "medium", "high", "xhigh"],
|
||||
"type": "string"
|
||||
},
|
||||
"ReasoningSummary": {
|
||||
"description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries",
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"auto",
|
||||
"concise",
|
||||
"detailed"
|
||||
],
|
||||
"enum": ["auto", "concise", "detailed"],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Option to disable reasoning summaries.",
|
||||
"enum": [
|
||||
"none"
|
||||
],
|
||||
"enum": ["none"],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
@@ -1200,19 +1115,11 @@
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"mcp_elicitations",
|
||||
"rules",
|
||||
"sandbox_approval"
|
||||
],
|
||||
"required": ["mcp_elicitations", "rules", "sandbox_approval"],
|
||||
"type": "object"
|
||||
},
|
||||
"SandboxMode": {
|
||||
"enum": [
|
||||
"read-only",
|
||||
"workspace-write",
|
||||
"danger-full-access"
|
||||
],
|
||||
"enum": ["read-only", "workspace-write", "danger-full-access"],
|
||||
"type": "string"
|
||||
},
|
||||
"SandboxWorkspaceWrite": {
|
||||
@@ -1244,23 +1151,17 @@
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "\"Core\" environment variables for the platform. On UNIX, this would include HOME, LOGNAME, PATH, SHELL, and USER, among others.",
|
||||
"enum": [
|
||||
"core"
|
||||
],
|
||||
"enum": ["core"],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Inherits the full environment from the parent process.",
|
||||
"enum": [
|
||||
"all"
|
||||
],
|
||||
"enum": ["all"],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Do not inherit any environment variables from the parent process.",
|
||||
"enum": [
|
||||
"none"
|
||||
],
|
||||
"enum": ["none"],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
@@ -1311,10 +1212,7 @@
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"path"
|
||||
],
|
||||
"required": ["enabled", "path"],
|
||||
"type": "object"
|
||||
},
|
||||
"SkillsConfig": {
|
||||
@@ -1346,10 +1244,7 @@
|
||||
},
|
||||
"TrustLevel": {
|
||||
"description": "Represents the trust level for a project directory. This determines the approval policy and sandbox mode applied.",
|
||||
"enum": [
|
||||
"trusted",
|
||||
"untrusted"
|
||||
],
|
||||
"enum": ["trusted", "untrusted"],
|
||||
"type": "string"
|
||||
},
|
||||
"Tui": {
|
||||
@@ -1400,6 +1295,11 @@
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"theme": {
|
||||
"default": null,
|
||||
"description": "Syntax highlighting theme name.\n\nWhen set, overrides automatic light/dark theme detection. Use `/theme` in the TUI or see `$CODEX_HOME/themes` for custom themes.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -1407,45 +1307,27 @@
|
||||
"UriBasedFileOpener": {
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"vscode",
|
||||
"vscode-insiders",
|
||||
"windsurf",
|
||||
"cursor"
|
||||
],
|
||||
"enum": ["vscode", "vscode-insiders", "windsurf", "cursor"],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Option to disable the URI-based file opener.",
|
||||
"enum": [
|
||||
"none"
|
||||
],
|
||||
"enum": ["none"],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Verbosity": {
|
||||
"description": "Controls output length/detail on GPT-5 models via the Responses API. Serialized with lowercase values to match the OpenAI API.",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high"
|
||||
],
|
||||
"enum": ["low", "medium", "high"],
|
||||
"type": "string"
|
||||
},
|
||||
"WebSearchMode": {
|
||||
"enum": [
|
||||
"disabled",
|
||||
"cached",
|
||||
"live"
|
||||
],
|
||||
"enum": ["disabled", "cached", "live"],
|
||||
"type": "string"
|
||||
},
|
||||
"WindowsSandboxModeToml": {
|
||||
"enum": [
|
||||
"elevated",
|
||||
"unelevated"
|
||||
],
|
||||
"enum": ["elevated", "unelevated"],
|
||||
"type": "string"
|
||||
},
|
||||
"WindowsToml": {
|
||||
@@ -1462,9 +1344,7 @@
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "The Responses API exposed by OpenAI at `/v1/responses`.",
|
||||
"enum": [
|
||||
"responses"
|
||||
],
|
||||
"enum": ["responses"],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
@@ -2063,4 +1943,5 @@
|
||||
},
|
||||
"title": "ConfigToml",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,14 @@ pub enum ConfigEdit {
|
||||
ClearPath { segments: Vec<String> },
|
||||
}
|
||||
|
||||
/// Produces a config edit that sets `[tui] theme = "<name>"`.
|
||||
pub fn syntax_theme_edit(name: &str) -> ConfigEdit {
|
||||
ConfigEdit::SetPath {
|
||||
segments: vec!["tui".to_string(), "theme".to_string()],
|
||||
value: value(name.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status_line_items_edit(items: &[String]) -> ConfigEdit {
|
||||
let mut array = toml_edit::Array::new();
|
||||
for item in items {
|
||||
|
||||
@@ -278,6 +278,9 @@ pub struct Config {
|
||||
/// `current-dir`.
|
||||
pub tui_status_line: Option<Vec<String>>,
|
||||
|
||||
/// Syntax highlighting theme override (kebab-case name).
|
||||
pub tui_theme: Option<String>,
|
||||
|
||||
/// The directory that should be treated as the current working directory
|
||||
/// for the session. All relative paths inside the business-logic layer are
|
||||
/// resolved against this path.
|
||||
@@ -2120,6 +2123,7 @@ impl Config {
|
||||
.map(|t| t.alternate_screen)
|
||||
.unwrap_or_default(),
|
||||
tui_status_line: cfg.tui.as_ref().and_then(|t| t.status_line.clone()),
|
||||
tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()),
|
||||
otel: {
|
||||
let t: OtelConfigToml = cfg.otel.unwrap_or_default();
|
||||
let log_user_prompt = t.log_user_prompt.unwrap_or(false);
|
||||
@@ -2518,6 +2522,30 @@ allowed_domains = ["openai.com"]
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tui_theme_deserializes_from_toml() {
|
||||
let cfg = r#"
|
||||
[tui]
|
||||
theme = "dracula"
|
||||
"#;
|
||||
let parsed =
|
||||
toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed");
|
||||
assert_eq!(
|
||||
parsed.tui.as_ref().and_then(|t| t.theme.as_deref()),
|
||||
Some("dracula"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tui_theme_defaults_to_none() {
|
||||
let cfg = r#"
|
||||
[tui]
|
||||
"#;
|
||||
let parsed =
|
||||
toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed");
|
||||
assert_eq!(parsed.tui.as_ref().and_then(|t| t.theme.as_deref()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tui_config_missing_notifications_field_defaults_to_enabled() {
|
||||
let cfg = r#"
|
||||
@@ -2537,6 +2565,7 @@ allowed_domains = ["openai.com"]
|
||||
show_tooltips: true,
|
||||
alternate_screen: AltScreenMode::Auto,
|
||||
status_line: None,
|
||||
theme: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -4646,6 +4675,7 @@ model_verbosity = "high"
|
||||
feedback_enabled: true,
|
||||
tui_alternate_screen: AltScreenMode::Auto,
|
||||
tui_status_line: None,
|
||||
tui_theme: None,
|
||||
otel: OtelConfig::default(),
|
||||
},
|
||||
o3_profile_config
|
||||
@@ -4768,6 +4798,7 @@ model_verbosity = "high"
|
||||
feedback_enabled: true,
|
||||
tui_alternate_screen: AltScreenMode::Auto,
|
||||
tui_status_line: None,
|
||||
tui_theme: None,
|
||||
otel: OtelConfig::default(),
|
||||
};
|
||||
|
||||
@@ -4888,6 +4919,7 @@ model_verbosity = "high"
|
||||
feedback_enabled: true,
|
||||
tui_alternate_screen: AltScreenMode::Auto,
|
||||
tui_status_line: None,
|
||||
tui_theme: None,
|
||||
otel: OtelConfig::default(),
|
||||
};
|
||||
|
||||
@@ -4994,6 +5026,7 @@ model_verbosity = "high"
|
||||
feedback_enabled: true,
|
||||
tui_alternate_screen: AltScreenMode::Auto,
|
||||
tui_status_line: None,
|
||||
tui_theme: None,
|
||||
otel: OtelConfig::default(),
|
||||
};
|
||||
|
||||
|
||||
@@ -681,6 +681,13 @@ pub struct Tui {
|
||||
/// `current-dir`.
|
||||
#[serde(default)]
|
||||
pub status_line: Option<Vec<String>>,
|
||||
|
||||
/// Syntax highlighting theme name (kebab-case).
|
||||
///
|
||||
/// When set, overrides automatic light/dark theme detection.
|
||||
/// Use `/theme` in the TUI or see `$CODEX_HOME/themes` for custom themes.
|
||||
#[serde(default)]
|
||||
pub theme: Option<String>,
|
||||
}
|
||||
|
||||
const fn default_true() -> bool {
|
||||
|
||||
@@ -73,6 +73,8 @@ ignore = [
|
||||
{ id = "RUSTSEC-2024-0388", reason = "derivative is unmaintained; pulled in via starlark v0.13.0 used by execpolicy/cli/core; no fixed release yet" },
|
||||
{ id = "RUSTSEC-2025-0057", reason = "fxhash is unmaintained; pulled in via starlark_map/starlark v0.13.0 used by execpolicy/cli/core; no fixed release yet" },
|
||||
{ id = "RUSTSEC-2024-0436", reason = "paste is unmaintained; pulled in via ratatui/rmcp/starlark used by tui/execpolicy; no fixed release yet" },
|
||||
{ id = "RUSTSEC-2024-0320", reason = "yaml-rust is unmaintained; pulled in via syntect/two-face used by tui syntax highlighting; no safe upgrade is available" },
|
||||
{ id = "RUSTSEC-2025-0141", reason = "bincode is unmaintained; pulled in via syntect/two-face used by tui syntax highlighting; no safe upgrade is available" },
|
||||
# TODO(joshka, nornagon): remove this exception when once we update the ratatui fork to a version that uses lru 0.13+.
|
||||
{ id = "RUSTSEC-2026-0002", reason = "lru 0.12.5 is pulled in via ratatui fork; cannot upgrade until the fork is updated" },
|
||||
]
|
||||
|
||||
@@ -93,8 +93,8 @@ toml = { workspace = true }
|
||||
tracing = { workspace = true, features = ["log"] }
|
||||
tracing-appender = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
tree-sitter-bash = { workspace = true }
|
||||
tree-sitter-highlight = { workspace = true }
|
||||
syntect = "5"
|
||||
two-face = { version = "0.5", default-features = false, features = ["syntect-default-onig"] }
|
||||
unicode-segmentation = { workspace = true }
|
||||
unicode-width = { workspace = true }
|
||||
url = { workspace = true }
|
||||
|
||||
@@ -2605,6 +2605,34 @@ impl App {
|
||||
AppEvent::StatusLineSetupCancelled => {
|
||||
self.chat_widget.cancel_status_line_setup();
|
||||
}
|
||||
AppEvent::SyntaxThemeSelected { name } => {
|
||||
let edit = codex_core::config::edit::syntax_theme_edit(&name);
|
||||
let apply_result = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
.with_edits([edit])
|
||||
.apply()
|
||||
.await;
|
||||
match apply_result {
|
||||
Ok(()) => {
|
||||
// Ensure the selected theme is active in the current
|
||||
// session. The preview callback covers arrow-key
|
||||
// navigation, but if the user presses Enter without
|
||||
// navigating, the runtime theme must still be applied.
|
||||
if let Some(theme) = crate::render::highlight::resolve_theme_by_name(
|
||||
&name,
|
||||
Some(&self.config.codex_home),
|
||||
) {
|
||||
crate::render::highlight::set_syntax_theme(theme);
|
||||
}
|
||||
self.sync_tui_theme_selection(name);
|
||||
}
|
||||
Err(err) => {
|
||||
self.restore_runtime_theme_from_config();
|
||||
tracing::error!(error = %err, "failed to persist theme selection");
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to save theme: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(AppRunControl::Continue)
|
||||
}
|
||||
@@ -2783,6 +2811,29 @@ impl App {
|
||||
self.chat_widget.set_personality(personality);
|
||||
}
|
||||
|
||||
fn sync_tui_theme_selection(&mut self, name: String) {
|
||||
self.config.tui_theme = Some(name.clone());
|
||||
self.chat_widget.set_tui_theme(Some(name));
|
||||
}
|
||||
|
||||
fn restore_runtime_theme_from_config(&self) {
|
||||
if let Some(name) = self.config.tui_theme.as_deref()
|
||||
&& let Some(theme) =
|
||||
crate::render::highlight::resolve_theme_by_name(name, Some(&self.config.codex_home))
|
||||
{
|
||||
crate::render::highlight::set_syntax_theme(theme);
|
||||
return;
|
||||
}
|
||||
|
||||
let auto_theme_name = crate::render::highlight::adaptive_default_theme_name();
|
||||
if let Some(theme) = crate::render::highlight::resolve_theme_by_name(
|
||||
auto_theme_name,
|
||||
Some(&self.config.codex_home),
|
||||
) {
|
||||
crate::render::highlight::set_syntax_theme(theme);
|
||||
}
|
||||
}
|
||||
|
||||
fn personality_label(personality: Personality) -> &'static str {
|
||||
match personality {
|
||||
Personality::None => "None",
|
||||
@@ -3655,6 +3706,19 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_tui_theme_selection_updates_chat_widget_config_copy() {
|
||||
let mut app = make_test_app().await;
|
||||
|
||||
app.sync_tui_theme_selection("dracula".to_string());
|
||||
|
||||
assert_eq!(app.config.tui_theme.as_deref(), Some("dracula"));
|
||||
assert_eq!(
|
||||
app.chat_widget.config_ref().tui_theme.as_deref(),
|
||||
Some("dracula")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backtrack_selection_with_duplicate_history_targets_unique_turn() {
|
||||
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
||||
|
||||
@@ -360,6 +360,11 @@ pub(crate) enum AppEvent {
|
||||
},
|
||||
/// Dismiss the status-line setup UI without changing config.
|
||||
StatusLineSetupCancelled,
|
||||
|
||||
/// Apply a user-confirmed syntax theme selection.
|
||||
SyntaxThemeSelected {
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// The exit strategy requested by the UI layer.
|
||||
|
||||
@@ -33,9 +33,76 @@ use super::selection_popup_common::render_rows_stable_col_widths;
|
||||
use super::selection_popup_common::render_rows_with_col_width_mode;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// Minimum list width (in content columns) required before the side-by-side
|
||||
/// layout is activated. Keeps the list usable even when sharing horizontal
|
||||
/// space with the side content panel.
|
||||
const MIN_LIST_WIDTH_FOR_SIDE: u16 = 40;
|
||||
|
||||
/// Horizontal gap (in columns) between the list area and the side content
|
||||
/// panel when side-by-side layout is active.
|
||||
const SIDE_CONTENT_GAP: u16 = 2;
|
||||
|
||||
/// Shared menu-surface horizontal inset (2 cells per side) used by selection popups.
|
||||
const MENU_SURFACE_HORIZONTAL_INSET: u16 = 4;
|
||||
|
||||
/// Controls how the side content panel is sized relative to the popup width.
|
||||
///
|
||||
/// When the computed side width falls below `side_content_min_width` or the
|
||||
/// remaining list area would be narrower than [`MIN_LIST_WIDTH_FOR_SIDE`], the
|
||||
/// side-by-side layout is abandoned and the stacked fallback is used instead.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum SideContentWidth {
|
||||
/// Fixed number of columns. `Fixed(0)` disables side content entirely.
|
||||
Fixed(u16),
|
||||
/// Exact 50/50 split of the content area (minus the inter-column gap).
|
||||
Half,
|
||||
}
|
||||
|
||||
impl Default for SideContentWidth {
|
||||
fn default() -> Self {
|
||||
Self::Fixed(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the popup content width after subtracting the shared menu-surface
|
||||
/// horizontal inset (2 columns on each side).
|
||||
pub(crate) fn popup_content_width(total_width: u16) -> u16 {
|
||||
total_width.saturating_sub(MENU_SURFACE_HORIZONTAL_INSET)
|
||||
}
|
||||
|
||||
/// Returns side-by-side layout widths as `(list_width, side_width)` when the
|
||||
/// layout can fit. Returns `None` when the side panel is disabled/too narrow or
|
||||
/// when the remaining list width would become unusably small.
|
||||
pub(crate) fn side_by_side_layout_widths(
|
||||
content_width: u16,
|
||||
side_content_width: SideContentWidth,
|
||||
side_content_min_width: u16,
|
||||
) -> Option<(u16, u16)> {
|
||||
let side_width = match side_content_width {
|
||||
SideContentWidth::Fixed(0) => return None,
|
||||
SideContentWidth::Fixed(width) => width,
|
||||
SideContentWidth::Half => content_width.saturating_sub(SIDE_CONTENT_GAP) / 2,
|
||||
};
|
||||
if side_width < side_content_min_width {
|
||||
return None;
|
||||
}
|
||||
let list_width = content_width.saturating_sub(SIDE_CONTENT_GAP + side_width);
|
||||
(list_width >= MIN_LIST_WIDTH_FOR_SIDE).then_some((list_width, side_width))
|
||||
}
|
||||
|
||||
/// One selectable item in the generic selection list.
|
||||
pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
|
||||
|
||||
/// Callback invoked whenever the highlighted item changes (arrow keys, search
|
||||
/// filter, number-key jump). Receives the *actual* index into the unfiltered
|
||||
/// `items` list and the event sender. Used by the theme picker for live preview.
|
||||
pub(crate) type OnSelectionChangedCallback =
|
||||
Option<Box<dyn Fn(usize, &AppEventSender) + Send + Sync>>;
|
||||
|
||||
/// Callback invoked when the picker is dismissed without accepting (Esc or
|
||||
/// Ctrl+C). Used by the theme picker to restore the pre-open theme.
|
||||
pub(crate) type OnCancelCallback = Option<Box<dyn Fn(&AppEventSender) + Send + Sync>>;
|
||||
|
||||
/// One row in a [`ListSelectionView`] selection list.
|
||||
///
|
||||
/// This is the source-of-truth model for row state before filtering and
|
||||
@@ -79,6 +146,28 @@ pub(crate) struct SelectionViewParams {
|
||||
pub col_width_mode: ColumnWidthMode,
|
||||
pub header: Box<dyn Renderable>,
|
||||
pub initial_selected_idx: Option<usize>,
|
||||
|
||||
/// Rich content rendered beside (wide terminals) or below (narrow terminals)
|
||||
/// the list items, inside the bordered menu surface. Used by the theme picker
|
||||
/// to show a syntax-highlighted preview.
|
||||
pub side_content: Box<dyn Renderable>,
|
||||
|
||||
/// Width mode for side content when side-by-side layout is active.
|
||||
pub side_content_width: SideContentWidth,
|
||||
|
||||
/// Minimum side panel width required before side-by-side layout activates.
|
||||
pub side_content_min_width: u16,
|
||||
|
||||
/// Optional fallback content rendered when side-by-side does not fit.
|
||||
/// When absent, `side_content` is reused.
|
||||
pub stacked_side_content: Option<Box<dyn Renderable>>,
|
||||
|
||||
/// Called when the highlighted item changes (navigation, filter, number-key).
|
||||
/// Receives the *actual* item index, not the filtered/visible index.
|
||||
pub on_selection_changed: OnSelectionChangedCallback,
|
||||
|
||||
/// Called when the picker is dismissed via Esc/Ctrl+C without selecting.
|
||||
pub on_cancel: OnCancelCallback,
|
||||
}
|
||||
|
||||
impl Default for SelectionViewParams {
|
||||
@@ -95,6 +184,12 @@ impl Default for SelectionViewParams {
|
||||
col_width_mode: ColumnWidthMode::AutoVisible,
|
||||
header: Box::new(()),
|
||||
initial_selected_idx: None,
|
||||
side_content: Box::new(()),
|
||||
side_content_width: SideContentWidth::default(),
|
||||
side_content_min_width: 0,
|
||||
stacked_side_content: None,
|
||||
on_selection_changed: None,
|
||||
on_cancel: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,6 +215,16 @@ pub(crate) struct ListSelectionView {
|
||||
last_selected_actual_idx: Option<usize>,
|
||||
header: Box<dyn Renderable>,
|
||||
initial_selected_idx: Option<usize>,
|
||||
side_content: Box<dyn Renderable>,
|
||||
side_content_width: SideContentWidth,
|
||||
side_content_min_width: u16,
|
||||
stacked_side_content: Option<Box<dyn Renderable>>,
|
||||
|
||||
/// Called when the highlighted item changes (navigation, filter, number-key).
|
||||
on_selection_changed: OnSelectionChangedCallback,
|
||||
|
||||
/// Called when the picker is dismissed via Esc/Ctrl+C without selecting.
|
||||
on_cancel: OnCancelCallback,
|
||||
}
|
||||
|
||||
impl ListSelectionView {
|
||||
@@ -161,6 +266,12 @@ impl ListSelectionView {
|
||||
last_selected_actual_idx: None,
|
||||
header,
|
||||
initial_selected_idx: params.initial_selected_idx,
|
||||
side_content: params.side_content,
|
||||
side_content_width: params.side_content_width,
|
||||
side_content_min_width: params.side_content_min_width,
|
||||
stacked_side_content: params.stacked_side_content,
|
||||
on_selection_changed: params.on_selection_changed,
|
||||
on_cancel: params.on_cancel,
|
||||
};
|
||||
s.apply_filter();
|
||||
s
|
||||
@@ -174,11 +285,15 @@ impl ListSelectionView {
|
||||
MAX_POPUP_ROWS.min(len.max(1))
|
||||
}
|
||||
|
||||
fn apply_filter(&mut self) {
|
||||
let previously_selected = self
|
||||
.state
|
||||
fn selected_actual_idx(&self) -> Option<usize> {
|
||||
self.state
|
||||
.selected_idx
|
||||
.and_then(|visible_idx| self.filtered_indices.get(visible_idx).copied())
|
||||
}
|
||||
|
||||
fn apply_filter(&mut self) {
|
||||
let previously_selected = self
|
||||
.selected_actual_idx()
|
||||
.or_else(|| {
|
||||
(!self.is_searchable)
|
||||
.then(|| self.items.iter().position(|item| item.is_current))
|
||||
@@ -222,6 +337,13 @@ impl ListSelectionView {
|
||||
let visible = Self::max_visible_rows(len);
|
||||
self.state.clamp_selection(len);
|
||||
self.state.ensure_visible(len, visible);
|
||||
|
||||
// Notify the callback when filtering changes the selected actual item
|
||||
// so live preview stays in sync (e.g. typing in the theme picker).
|
||||
let new_actual = self.selected_actual_idx();
|
||||
if new_actual != previously_selected {
|
||||
self.fire_selection_changed();
|
||||
}
|
||||
}
|
||||
|
||||
fn build_rows(&self) -> Vec<GenericDisplayRow> {
|
||||
@@ -273,19 +395,35 @@ impl ListSelectionView {
|
||||
}
|
||||
|
||||
fn move_up(&mut self) {
|
||||
let before = self.selected_actual_idx();
|
||||
let len = self.visible_len();
|
||||
self.state.move_up_wrap(len);
|
||||
let visible = Self::max_visible_rows(len);
|
||||
self.state.ensure_visible(len, visible);
|
||||
self.skip_disabled_up();
|
||||
if self.selected_actual_idx() != before {
|
||||
self.fire_selection_changed();
|
||||
}
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
let before = self.selected_actual_idx();
|
||||
let len = self.visible_len();
|
||||
self.state.move_down_wrap(len);
|
||||
let visible = Self::max_visible_rows(len);
|
||||
self.state.ensure_visible(len, visible);
|
||||
self.skip_disabled_down();
|
||||
if self.selected_actual_idx() != before {
|
||||
self.fire_selection_changed();
|
||||
}
|
||||
}
|
||||
|
||||
fn fire_selection_changed(&self) {
|
||||
if let Some(cb) = &self.on_selection_changed
|
||||
&& let Some(actual) = self.selected_actual_idx()
|
||||
{
|
||||
cb(actual, &self.app_event_tx);
|
||||
}
|
||||
}
|
||||
|
||||
fn accept(&mut self) {
|
||||
@@ -310,6 +448,9 @@ impl ListSelectionView {
|
||||
self.complete = true;
|
||||
}
|
||||
} else if selected_item.is_none() {
|
||||
if let Some(cb) = &self.on_cancel {
|
||||
cb(&self.app_event_tx);
|
||||
}
|
||||
self.complete = true;
|
||||
}
|
||||
}
|
||||
@@ -328,6 +469,63 @@ impl ListSelectionView {
|
||||
total_width.saturating_sub(2)
|
||||
}
|
||||
|
||||
fn clear_to_terminal_bg(buf: &mut Buffer, area: Rect) {
|
||||
let buf_area = buf.area();
|
||||
let min_x = area.x.max(buf_area.x);
|
||||
let min_y = area.y.max(buf_area.y);
|
||||
let max_x = area
|
||||
.x
|
||||
.saturating_add(area.width)
|
||||
.min(buf_area.x.saturating_add(buf_area.width));
|
||||
let max_y = area
|
||||
.y
|
||||
.saturating_add(area.height)
|
||||
.min(buf_area.y.saturating_add(buf_area.height));
|
||||
for y in min_y..max_y {
|
||||
for x in min_x..max_x {
|
||||
buf[(x, y)]
|
||||
.set_symbol(" ")
|
||||
.set_style(ratatui::style::Style::reset());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn force_bg_to_terminal_bg(buf: &mut Buffer, area: Rect) {
|
||||
let buf_area = buf.area();
|
||||
let min_x = area.x.max(buf_area.x);
|
||||
let min_y = area.y.max(buf_area.y);
|
||||
let max_x = area
|
||||
.x
|
||||
.saturating_add(area.width)
|
||||
.min(buf_area.x.saturating_add(buf_area.width));
|
||||
let max_y = area
|
||||
.y
|
||||
.saturating_add(area.height)
|
||||
.min(buf_area.y.saturating_add(buf_area.height));
|
||||
for y in min_y..max_y {
|
||||
for x in min_x..max_x {
|
||||
buf[(x, y)].set_bg(ratatui::style::Color::Reset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stacked_side_content(&self) -> &dyn Renderable {
|
||||
self.stacked_side_content
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| self.side_content.as_ref())
|
||||
}
|
||||
|
||||
/// Returns `Some(side_width)` when the content area is wide enough for a
|
||||
/// side-by-side layout (list + gap + side panel), `None` otherwise.
|
||||
fn side_layout_width(&self, content_width: u16) -> Option<u16> {
|
||||
side_by_side_layout_widths(
|
||||
content_width,
|
||||
self.side_content_width,
|
||||
self.side_content_min_width,
|
||||
)
|
||||
.map(|(_, side_width)| side_width)
|
||||
}
|
||||
|
||||
fn skip_disabled_down(&mut self) {
|
||||
let len = self.visible_len();
|
||||
for _ in 0..len {
|
||||
@@ -469,6 +667,9 @@ impl BottomPaneView for ListSelectionView {
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
if let Some(cb) = &self.on_cancel {
|
||||
cb(&self.app_event_tx);
|
||||
}
|
||||
self.complete = true;
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
@@ -476,38 +677,59 @@ impl BottomPaneView for ListSelectionView {
|
||||
|
||||
impl Renderable for ListSelectionView {
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
// Measure wrapped height for up to MAX_POPUP_ROWS items at the given width.
|
||||
// Build the same display rows used by the renderer so wrapping math matches.
|
||||
// Inner content width after menu surface horizontal insets (2 per side).
|
||||
let inner_width = popup_content_width(width);
|
||||
|
||||
// When side-by-side is active, measure the list at the reduced width
|
||||
// that accounts for the gap and side panel.
|
||||
let effective_rows_width = if let Some(side_w) = self.side_layout_width(inner_width) {
|
||||
Self::rows_width(width).saturating_sub(SIDE_CONTENT_GAP + side_w)
|
||||
} else {
|
||||
Self::rows_width(width)
|
||||
};
|
||||
|
||||
// Measure wrapped height for up to MAX_POPUP_ROWS items.
|
||||
let rows = self.build_rows();
|
||||
let rows_width = Self::rows_width(width);
|
||||
let rows_height = match self.col_width_mode {
|
||||
ColumnWidthMode::AutoVisible => measure_rows_height(
|
||||
&rows,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
rows_width.saturating_add(1),
|
||||
effective_rows_width.saturating_add(1),
|
||||
),
|
||||
ColumnWidthMode::AutoAllRows => measure_rows_height_stable_col_widths(
|
||||
&rows,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
rows_width.saturating_add(1),
|
||||
effective_rows_width.saturating_add(1),
|
||||
),
|
||||
ColumnWidthMode::Fixed => measure_rows_height_with_col_width_mode(
|
||||
&rows,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
rows_width.saturating_add(1),
|
||||
effective_rows_width.saturating_add(1),
|
||||
ColumnWidthMode::Fixed,
|
||||
),
|
||||
};
|
||||
|
||||
// Subtract 4 for the padding on the left and right of the header.
|
||||
let mut height = self.header.desired_height(width.saturating_sub(4));
|
||||
let mut height = self.header.desired_height(inner_width);
|
||||
height = height.saturating_add(rows_height + 3);
|
||||
if self.is_searchable {
|
||||
height = height.saturating_add(1);
|
||||
}
|
||||
|
||||
// Side content: when the terminal is wide enough the panel sits beside
|
||||
// the list and shares vertical space; otherwise it stacks below.
|
||||
if self.side_layout_width(inner_width).is_some() {
|
||||
// Side-by-side — side content shares list rows vertically so it
|
||||
// doesn't add to total height.
|
||||
} else {
|
||||
let side_h = self.stacked_side_content().desired_height(inner_width);
|
||||
if side_h > 0 {
|
||||
height = height.saturating_add(1 + side_h);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(note) = &self.footer_note {
|
||||
let note_width = width.saturating_sub(2);
|
||||
let note_lines = wrap_styled_line(note, note_width);
|
||||
@@ -538,41 +760,60 @@ impl Renderable for ListSelectionView {
|
||||
// Paint the shared menu surface and then layout inside the returned inset.
|
||||
let content_area = render_menu_surface(outer_content_area, buf);
|
||||
|
||||
let header_height = self
|
||||
.header
|
||||
// Subtract 4 for the padding on the left and right of the header.
|
||||
.desired_height(outer_content_area.width.saturating_sub(4));
|
||||
let inner_width = popup_content_width(outer_content_area.width);
|
||||
let side_w = self.side_layout_width(inner_width);
|
||||
|
||||
// When side-by-side is active, shrink the list to make room.
|
||||
let full_rows_width = Self::rows_width(outer_content_area.width);
|
||||
let effective_rows_width = if let Some(sw) = side_w {
|
||||
full_rows_width.saturating_sub(SIDE_CONTENT_GAP + sw)
|
||||
} else {
|
||||
full_rows_width
|
||||
};
|
||||
|
||||
let header_height = self.header.desired_height(inner_width);
|
||||
let rows = self.build_rows();
|
||||
let rows_width = Self::rows_width(outer_content_area.width);
|
||||
let rows_height = match self.col_width_mode {
|
||||
ColumnWidthMode::AutoVisible => measure_rows_height(
|
||||
&rows,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
rows_width.saturating_add(1),
|
||||
effective_rows_width.saturating_add(1),
|
||||
),
|
||||
ColumnWidthMode::AutoAllRows => measure_rows_height_stable_col_widths(
|
||||
&rows,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
rows_width.saturating_add(1),
|
||||
effective_rows_width.saturating_add(1),
|
||||
),
|
||||
ColumnWidthMode::Fixed => measure_rows_height_with_col_width_mode(
|
||||
&rows,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
rows_width.saturating_add(1),
|
||||
effective_rows_width.saturating_add(1),
|
||||
ColumnWidthMode::Fixed,
|
||||
),
|
||||
};
|
||||
let [header_area, _, search_area, list_area] = Layout::vertical([
|
||||
|
||||
// Stacked (fallback) side content height — only used when not side-by-side.
|
||||
let stacked_side_h = if side_w.is_none() {
|
||||
self.stacked_side_content().desired_height(inner_width)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let stacked_gap = if stacked_side_h > 0 { 1 } else { 0 };
|
||||
|
||||
let [header_area, _, search_area, list_area, _, stacked_side_area] = Layout::vertical([
|
||||
Constraint::Max(header_height),
|
||||
Constraint::Max(1),
|
||||
Constraint::Length(if self.is_searchable { 1 } else { 0 }),
|
||||
Constraint::Length(rows_height),
|
||||
Constraint::Length(stacked_gap),
|
||||
Constraint::Length(stacked_side_h),
|
||||
])
|
||||
.areas(content_area);
|
||||
|
||||
// -- Header --
|
||||
if header_area.height < header_height {
|
||||
let [header_area, elision_area] =
|
||||
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(header_area);
|
||||
@@ -585,6 +826,7 @@ impl Renderable for ListSelectionView {
|
||||
self.header.render(header_area, buf);
|
||||
}
|
||||
|
||||
// -- Search bar --
|
||||
if self.is_searchable {
|
||||
Line::from(self.search_query.clone()).render(search_area, buf);
|
||||
let query_span: Span<'static> = if self.search_query.is_empty() {
|
||||
@@ -598,11 +840,12 @@ impl Renderable for ListSelectionView {
|
||||
Line::from(query_span).render(search_area, buf);
|
||||
}
|
||||
|
||||
// -- List rows --
|
||||
if list_area.height > 0 {
|
||||
let render_area = Rect {
|
||||
x: list_area.x.saturating_sub(2),
|
||||
y: list_area.y,
|
||||
width: rows_width.max(1),
|
||||
width: effective_rows_width.max(1),
|
||||
height: list_area.height,
|
||||
};
|
||||
match self.col_width_mode {
|
||||
@@ -634,6 +877,53 @@ impl Renderable for ListSelectionView {
|
||||
};
|
||||
}
|
||||
|
||||
// -- Side content (preview panel) --
|
||||
if let Some(sw) = side_w {
|
||||
// Side-by-side: render to the right half of the popup content
|
||||
// area so preview content can center vertically in that panel.
|
||||
let side_x = content_area.x + content_area.width - sw;
|
||||
let side_area = Rect::new(side_x, content_area.y, sw, content_area.height);
|
||||
|
||||
// Clear the menu-surface background behind the side panel so the
|
||||
// preview appears on the terminal's own background.
|
||||
let clear_x = side_x.saturating_sub(SIDE_CONTENT_GAP);
|
||||
let clear_w = outer_content_area
|
||||
.x
|
||||
.saturating_add(outer_content_area.width)
|
||||
.saturating_sub(clear_x);
|
||||
Self::clear_to_terminal_bg(
|
||||
buf,
|
||||
Rect::new(
|
||||
clear_x,
|
||||
outer_content_area.y,
|
||||
clear_w,
|
||||
outer_content_area.height,
|
||||
),
|
||||
);
|
||||
self.side_content.render(side_area, buf);
|
||||
Self::force_bg_to_terminal_bg(
|
||||
buf,
|
||||
Rect::new(
|
||||
clear_x,
|
||||
outer_content_area.y,
|
||||
clear_w,
|
||||
outer_content_area.height,
|
||||
),
|
||||
);
|
||||
} else if stacked_side_area.height > 0 {
|
||||
// Stacked fallback: render below the list (same as old footer_content).
|
||||
let clear_height = (outer_content_area.y + outer_content_area.height)
|
||||
.saturating_sub(stacked_side_area.y);
|
||||
let clear_area = Rect::new(
|
||||
outer_content_area.x,
|
||||
stacked_side_area.y,
|
||||
outer_content_area.width,
|
||||
clear_height,
|
||||
);
|
||||
Self::clear_to_terminal_bg(buf, clear_area);
|
||||
self.stacked_side_content().render(stacked_side_area, buf);
|
||||
}
|
||||
|
||||
if footer_area.height > 0 {
|
||||
let [note_area, hint_area] = Layout::vertical([
|
||||
Constraint::Length(note_height),
|
||||
@@ -683,9 +973,33 @@ mod tests {
|
||||
use crossterm::event::KeyCode;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Style;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
struct MarkerRenderable {
|
||||
marker: &'static str,
|
||||
height: u16,
|
||||
}
|
||||
|
||||
impl Renderable for MarkerRenderable {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
for y in area.y..area.y.saturating_add(area.height) {
|
||||
for x in area.x..area.x.saturating_add(area.width) {
|
||||
if x < buf.area().width && y < buf.area().height {
|
||||
buf[(x, y)].set_symbol(self.marker);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
self.height
|
||||
}
|
||||
}
|
||||
|
||||
fn make_selection_view(subtitle: Option<&str>) -> ListSelectionView {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
@@ -722,7 +1036,10 @@ mod tests {
|
||||
}
|
||||
|
||||
fn render_lines_with_width(view: &ListSelectionView, width: u16) -> String {
|
||||
let height = view.desired_height(width);
|
||||
render_lines_in_area(view, width, view.desired_height(width))
|
||||
}
|
||||
|
||||
fn render_lines_in_area(view: &ListSelectionView, width: u16, height: u16) -> String {
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
view.render(area, &mut buf);
|
||||
@@ -808,6 +1125,20 @@ mod tests {
|
||||
assert_snapshot!("list_selection_spacing_with_subtitle", render_lines(&view));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_picker_subtitle_uses_fallback_text_in_94x35_terminal() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let home = dirs::home_dir().expect("home directory should be available");
|
||||
let codex_home = home.join(".codex");
|
||||
let params =
|
||||
crate::theme_picker::build_theme_picker_params(None, Some(&codex_home), Some(94));
|
||||
let view = ListSelectionView::new(params, tx);
|
||||
|
||||
let rendered = render_lines_in_area(&view, 94, 35);
|
||||
assert!(rendered.contains("Move up/down to live preview themes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_footer_note_wraps() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
@@ -871,6 +1202,66 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_with_no_matches_triggers_cancel_callback() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
items: vec![SelectionItem {
|
||||
name: "Read Only".to_string(),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}],
|
||||
is_searchable: true,
|
||||
on_cancel: Some(Box::new(|tx: &_| {
|
||||
tx.send(AppEvent::OpenApprovalsPopup);
|
||||
})),
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
);
|
||||
view.set_search_query("no-matches".to_string());
|
||||
|
||||
view.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
|
||||
assert!(view.is_complete());
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::OpenApprovalsPopup) => {}
|
||||
Ok(other) => panic!("expected OpenApprovalsPopup cancel event, got {other:?}"),
|
||||
Err(err) => panic!("expected cancel callback event, got {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_down_without_selection_change_does_not_fire_callback() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
items: vec![SelectionItem {
|
||||
name: "Only choice".to_string(),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}],
|
||||
on_selection_changed: Some(Box::new(|_idx, tx: &_| {
|
||||
tx.send(AppEvent::OpenApprovalsPopup);
|
||||
})),
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
while rx.try_recv().is_ok() {}
|
||||
|
||||
view.handle_key_event(KeyEvent::from(KeyCode::Down));
|
||||
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"moving down in a single-item list should not fire on_selection_changed",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraps_long_option_without_overflowing_columns() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
@@ -1159,4 +1550,194 @@ mod tests {
|
||||
"fixed description column changed across scroll:\nbefore:\n{before_scroll}\nafter:\n{after_scroll}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_layout_width_half_uses_exact_split() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
items: vec![SelectionItem {
|
||||
name: "Item 1".to_string(),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}],
|
||||
side_content: Box::new(MarkerRenderable {
|
||||
marker: "W",
|
||||
height: 1,
|
||||
}),
|
||||
side_content_width: SideContentWidth::Half,
|
||||
side_content_min_width: 10,
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
let content_width: u16 = 120;
|
||||
let expected = content_width.saturating_sub(SIDE_CONTENT_GAP) / 2;
|
||||
assert_eq!(view.side_layout_width(content_width), Some(expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_layout_width_half_falls_back_when_list_would_be_too_narrow() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
items: vec![SelectionItem {
|
||||
name: "Item 1".to_string(),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}],
|
||||
side_content: Box::new(MarkerRenderable {
|
||||
marker: "W",
|
||||
height: 1,
|
||||
}),
|
||||
side_content_width: SideContentWidth::Half,
|
||||
side_content_min_width: 50,
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
assert_eq!(view.side_layout_width(80), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_side_content_is_used_when_side_by_side_does_not_fit() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
title: Some("Debug".to_string()),
|
||||
items: vec![SelectionItem {
|
||||
name: "Item 1".to_string(),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}],
|
||||
side_content: Box::new(MarkerRenderable {
|
||||
marker: "W",
|
||||
height: 1,
|
||||
}),
|
||||
stacked_side_content: Some(Box::new(MarkerRenderable {
|
||||
marker: "N",
|
||||
height: 1,
|
||||
})),
|
||||
side_content_width: SideContentWidth::Half,
|
||||
side_content_min_width: 60,
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
let rendered = render_lines_with_width(&view, 70);
|
||||
assert!(
|
||||
rendered.contains('N'),
|
||||
"expected stacked marker to be rendered:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains('W'),
|
||||
"wide marker should not render in stacked mode:\n{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_content_clearing_resets_symbols_and_style() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
title: Some("Debug".to_string()),
|
||||
items: vec![SelectionItem {
|
||||
name: "Item 1".to_string(),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}],
|
||||
side_content: Box::new(MarkerRenderable {
|
||||
marker: "W",
|
||||
height: 1,
|
||||
}),
|
||||
side_content_width: SideContentWidth::Half,
|
||||
side_content_min_width: 10,
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
let width = 120;
|
||||
let height = view.desired_height(width);
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
buf[(x, y)]
|
||||
.set_symbol("X")
|
||||
.set_style(Style::default().bg(Color::Red));
|
||||
}
|
||||
}
|
||||
view.render(area, &mut buf);
|
||||
|
||||
let cell = &buf[(width - 1, 0)];
|
||||
assert_eq!(cell.symbol(), " ");
|
||||
let style = cell.style();
|
||||
assert_eq!(style.fg, Some(Color::Reset));
|
||||
assert_eq!(style.bg, Some(Color::Reset));
|
||||
assert_eq!(style.underline_color, Some(Color::Reset));
|
||||
|
||||
let mut saw_marker = false;
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let cell = &buf[(x, y)];
|
||||
if cell.symbol() == "W" {
|
||||
saw_marker = true;
|
||||
assert_eq!(cell.style().bg, Some(Color::Reset));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
saw_marker,
|
||||
"expected side marker renderable to draw into buffer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_content_clearing_handles_non_zero_buffer_origin() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
title: Some("Debug".to_string()),
|
||||
items: vec![SelectionItem {
|
||||
name: "Item 1".to_string(),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}],
|
||||
side_content: Box::new(MarkerRenderable {
|
||||
marker: "W",
|
||||
height: 1,
|
||||
}),
|
||||
side_content_width: SideContentWidth::Half,
|
||||
side_content_min_width: 10,
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
let width = 120;
|
||||
let height = view.desired_height(width);
|
||||
let area = Rect::new(0, 20, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
for y in area.y..area.y + height {
|
||||
for x in area.x..area.x + width {
|
||||
buf[(x, y)]
|
||||
.set_symbol("X")
|
||||
.set_style(Style::default().bg(Color::Red));
|
||||
}
|
||||
}
|
||||
view.render(area, &mut buf);
|
||||
|
||||
let cell = &buf[(area.x + width - 1, area.y)];
|
||||
assert_eq!(cell.symbol(), " ");
|
||||
assert_eq!(cell.style().bg, Some(Color::Reset));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,9 @@ mod slash_commands;
|
||||
pub(crate) use footer::CollaborationModeIndicator;
|
||||
pub(crate) use list_selection_view::ColumnWidthMode;
|
||||
pub(crate) use list_selection_view::SelectionViewParams;
|
||||
pub(crate) use list_selection_view::SideContentWidth;
|
||||
pub(crate) use list_selection_view::popup_content_width;
|
||||
pub(crate) use list_selection_view::side_by_side_layout_widths;
|
||||
mod feedback_view;
|
||||
pub(crate) use feedback_view::FeedbackAudience;
|
||||
pub(crate) use feedback_view::feedback_disabled_params;
|
||||
|
||||
@@ -3482,6 +3482,9 @@ impl ChatWidget {
|
||||
SlashCommand::Statusline => {
|
||||
self.open_status_line_setup();
|
||||
}
|
||||
SlashCommand::Theme => {
|
||||
self.open_theme_picker();
|
||||
}
|
||||
SlashCommand::Ps => {
|
||||
self.add_ps_output();
|
||||
}
|
||||
@@ -4431,6 +4434,20 @@ impl ChatWidget {
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
}
|
||||
|
||||
fn open_theme_picker(&mut self) {
|
||||
let codex_home = codex_core::config::find_codex_home().ok();
|
||||
let terminal_width = self
|
||||
.last_rendered_width
|
||||
.get()
|
||||
.and_then(|width| u16::try_from(width).ok());
|
||||
let params = crate::theme_picker::build_theme_picker_params(
|
||||
self.config.tui_theme.as_deref(),
|
||||
codex_home.as_deref(),
|
||||
terminal_width,
|
||||
);
|
||||
self.bottom_pane.show_selection_view(params);
|
||||
}
|
||||
|
||||
/// Parses configured status-line ids into known items and collects unknown ids.
|
||||
///
|
||||
/// Unknown ids are deduplicated in insertion order for warning messages.
|
||||
@@ -6338,6 +6355,11 @@ impl ChatWidget {
|
||||
self.config.personality = Some(personality);
|
||||
}
|
||||
|
||||
/// Set the syntax theme override in the widget's config copy.
|
||||
pub(crate) fn set_tui_theme(&mut self, theme: Option<String>) {
|
||||
self.config.tui_theme = theme;
|
||||
}
|
||||
|
||||
/// Set the model in the widget's config copy and stored collaboration mode.
|
||||
pub(crate) fn set_model(&mut self, model: &str) {
|
||||
self.current_collaboration_mode =
|
||||
|
||||
@@ -27,6 +27,9 @@ Buffer {
|
||||
x: 73, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC,
|
||||
x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 4, y: 7, fg: Rgb(137, 180, 250), bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 8, y: 7, fg: Rgb(205, 214, 244), bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 20, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
|
||||
x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -105,6 +105,7 @@ mod streaming;
|
||||
mod style;
|
||||
mod terminal_palette;
|
||||
mod text_formatting;
|
||||
mod theme_picker;
|
||||
mod tooltips;
|
||||
mod tui;
|
||||
mod ui_consts;
|
||||
@@ -545,6 +546,7 @@ async fn run_ratatui_app(
|
||||
} else {
|
||||
initial_config
|
||||
};
|
||||
|
||||
let mut missing_session_exit = |id_str: &str, action: &str| {
|
||||
error!("Error finding conversation path: {id_str}");
|
||||
restore();
|
||||
@@ -693,7 +695,7 @@ async fn run_ratatui_app(
|
||||
None => None,
|
||||
};
|
||||
|
||||
let config = match &session_selection {
|
||||
let mut config = match &session_selection {
|
||||
resume_picker::SessionSelection::Resume(_) | resume_picker::SessionSelection::Fork(_) => {
|
||||
load_config_or_exit_with_fallback_cwd(
|
||||
cli_kv_overrides.clone(),
|
||||
@@ -705,6 +707,17 @@ async fn run_ratatui_app(
|
||||
}
|
||||
_ => config,
|
||||
};
|
||||
|
||||
// Configure syntax highlighting theme from the final config — onboarding
|
||||
// and resume/fork can both reload config with a different tui_theme, so
|
||||
// this must happen after the last possible reload.
|
||||
if let Some(w) = crate::render::highlight::set_theme_override(
|
||||
config.tui_theme.clone(),
|
||||
find_codex_home().ok(),
|
||||
) {
|
||||
config.startup_warnings.push(w);
|
||||
}
|
||||
|
||||
set_default_client_residency_requirement(config.enforce_residency.value());
|
||||
let active_profile = config.active_profile.clone();
|
||||
let should_show_trust_screen = should_show_trust_screen(&config);
|
||||
@@ -1186,6 +1199,50 @@ trust_level = "untrusted"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Regression: theme must be configured from the *final* config.
|
||||
///
|
||||
/// `run_ratatui_app` can reload config during onboarding and again
|
||||
/// during session resume/fork. The syntax theme override (stored in
|
||||
/// a `OnceLock`) must use the final config's `tui_theme`, not the
|
||||
/// initial one — otherwise users resuming a thread in a project with
|
||||
/// a different theme get the wrong highlighting.
|
||||
///
|
||||
/// We verify the invariant indirectly: `validate_theme_name` (the
|
||||
/// pure validation core of `set_theme_override`) must be called with
|
||||
/// the *final* config's theme, and its warning must land in the
|
||||
/// final config's `startup_warnings`.
|
||||
#[tokio::test]
|
||||
async fn theme_warning_uses_final_config() -> std::io::Result<()> {
|
||||
use crate::render::highlight::validate_theme_name;
|
||||
|
||||
let temp_dir = TempDir::new()?;
|
||||
|
||||
// initial_config has a valid theme — no warning.
|
||||
let initial_config = build_config(&temp_dir).await?;
|
||||
assert!(initial_config.tui_theme.is_none());
|
||||
|
||||
// Simulate resume/fork reload: the final config has an invalid theme.
|
||||
let mut config = build_config(&temp_dir).await?;
|
||||
config.tui_theme = Some("bogus-theme".into());
|
||||
|
||||
// Theme override must use the final config (not initial_config).
|
||||
// This mirrors the real call site in run_ratatui_app.
|
||||
if let Some(w) = validate_theme_name(config.tui_theme.as_deref(), Some(temp_dir.path())) {
|
||||
config.startup_warnings.push(w);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
config.startup_warnings.len(),
|
||||
1,
|
||||
"warning from final config's invalid theme should be present"
|
||||
);
|
||||
assert!(
|
||||
config.startup_warnings[0].contains("bogus-theme"),
|
||||
"warning should reference the final config's theme name"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_session_cwd_falls_back_to_session_meta() -> std::io::Result<()> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::render::highlight::highlight_code_to_lines;
|
||||
use crate::render::line_utils::line_to_static;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_line;
|
||||
@@ -99,6 +100,8 @@ where
|
||||
pending_marker_line: bool,
|
||||
in_paragraph: bool,
|
||||
in_code_block: bool,
|
||||
code_block_lang: Option<String>,
|
||||
code_block_buffer: String,
|
||||
wrap_width: Option<usize>,
|
||||
current_line_content: Option<Line<'static>>,
|
||||
current_initial_indent: Vec<Span<'static>>,
|
||||
@@ -124,6 +127,8 @@ where
|
||||
pending_marker_line: false,
|
||||
in_paragraph: false,
|
||||
in_code_block: false,
|
||||
code_block_lang: None,
|
||||
code_block_buffer: String::new(),
|
||||
wrap_width,
|
||||
current_line_content: None,
|
||||
current_initial_indent: Vec::new(),
|
||||
@@ -278,6 +283,16 @@ where
|
||||
self.push_line(Line::default());
|
||||
}
|
||||
self.pending_marker_line = false;
|
||||
|
||||
// When inside a fenced code block with a known language, accumulate
|
||||
// text into the buffer for batch highlighting in end_codeblock().
|
||||
// Append verbatim — pulldown-cmark text events already contain the
|
||||
// original line breaks, so inserting separators would double them.
|
||||
if self.in_code_block && self.code_block_lang.is_some() {
|
||||
self.code_block_buffer.push_str(&text);
|
||||
return;
|
||||
}
|
||||
|
||||
if self.in_code_block && !self.needs_newline {
|
||||
let has_content = self
|
||||
.current_line_content
|
||||
@@ -394,12 +409,25 @@ where
|
||||
self.needs_newline = false;
|
||||
}
|
||||
|
||||
fn start_codeblock(&mut self, _lang: Option<String>, indent: Option<Span<'static>>) {
|
||||
fn start_codeblock(&mut self, lang: Option<String>, indent: Option<Span<'static>>) {
|
||||
self.flush_current_line();
|
||||
if !self.text.lines.is_empty() {
|
||||
self.push_blank_line();
|
||||
}
|
||||
self.in_code_block = true;
|
||||
|
||||
// Extract the language token from the info string. CommonMark info
|
||||
// strings can contain metadata after the language, separated by commas,
|
||||
// spaces, or other delimiters (e.g. "rust,no_run", "rust title=demo").
|
||||
// Take only the first token so the syntax lookup succeeds.
|
||||
let lang = lang
|
||||
.as_deref()
|
||||
.and_then(|s| s.split([',', ' ', '\t']).next())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(std::string::ToString::to_string);
|
||||
self.code_block_lang = lang;
|
||||
self.code_block_buffer.clear();
|
||||
|
||||
self.indent_stack.push(IndentContext::new(
|
||||
vec![indent.unwrap_or_default()],
|
||||
None,
|
||||
@@ -409,6 +437,20 @@ where
|
||||
}
|
||||
|
||||
fn end_codeblock(&mut self) {
|
||||
// If we buffered code for a known language, syntax-highlight it now.
|
||||
if let Some(lang) = self.code_block_lang.take() {
|
||||
let code = std::mem::take(&mut self.code_block_buffer);
|
||||
if !code.is_empty() {
|
||||
let highlighted = highlight_code_to_lines(&code, &lang);
|
||||
for hl_line in highlighted {
|
||||
self.push_line(Line::default());
|
||||
for span in hl_line.spans {
|
||||
self.push_span(span);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.needs_newline = true;
|
||||
self.in_code_block = false;
|
||||
self.indent_stack.pop();
|
||||
@@ -675,4 +717,39 @@ mod tests {
|
||||
vec!["fn main() { println!(\"hi from a long line\"); }".to_string(),]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fenced_code_info_string_with_metadata_highlights() {
|
||||
// CommonMark info strings like "rust,no_run" or "rust title=demo"
|
||||
// contain metadata after the language token. The language must be
|
||||
// extracted (first word / comma-separated token) so highlighting works.
|
||||
for info in &["rust,no_run", "rust no_run", "rust title=\"demo\""] {
|
||||
let markdown = format!("```{info}\nfn main() {{}}\n```\n");
|
||||
let rendered = render_markdown_text(&markdown);
|
||||
let has_rgb = rendered.lines.iter().any(|line| {
|
||||
line.spans
|
||||
.iter()
|
||||
.any(|s| matches!(s.style.fg, Some(ratatui::style::Color::Rgb(..))))
|
||||
});
|
||||
assert!(
|
||||
has_rgb,
|
||||
"info string \"{info}\" should still produce syntax highlighting"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crlf_code_block_no_extra_blank_lines() {
|
||||
// pulldown-cmark can split CRLF code blocks into multiple Text events.
|
||||
// The buffer must concatenate them verbatim — no inserted separators.
|
||||
let markdown = "```rust\r\nfn main() {}\r\n line2\r\n```\r\n";
|
||||
let rendered = render_markdown_text(markdown);
|
||||
let lines = lines_to_strings(&rendered);
|
||||
// Should be exactly two code lines; no spurious blank line between them.
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec!["fn main() {}".to_string(), " line2".to_string()],
|
||||
"CRLF code block should not produce extra blank lines: {lines:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -652,10 +652,83 @@ fn link() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_block_unhighlighted() {
|
||||
fn code_block_known_lang_has_syntax_colors() {
|
||||
let text = render_markdown_text("```rust\nfn main() {}\n```\n");
|
||||
let expected = Text::from_iter([Line::from_iter(["", "fn main() {}"])]);
|
||||
assert_eq!(text, expected);
|
||||
let content: Vec<String> = text
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|s| s.content.clone())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect();
|
||||
// Content should be preserved; ignore trailing empty line from highlighting.
|
||||
let content: Vec<&str> = content
|
||||
.iter()
|
||||
.map(std::string::String::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
assert_eq!(content, vec!["fn main() {}"]);
|
||||
|
||||
// At least one span should have non-default style (syntax highlighting).
|
||||
let has_colored_span = text
|
||||
.lines
|
||||
.iter()
|
||||
.flat_map(|l| l.spans.iter())
|
||||
.any(|sp| sp.style.fg.is_some());
|
||||
assert!(has_colored_span, "expected syntax-highlighted spans with color");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_block_unknown_lang_plain() {
|
||||
let text = render_markdown_text("```xyzlang\nhello world\n```\n");
|
||||
let content: Vec<String> = text
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|s| s.content.clone())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect();
|
||||
let content: Vec<&str> = content
|
||||
.iter()
|
||||
.map(std::string::String::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
assert_eq!(content, vec!["hello world"]);
|
||||
|
||||
// No syntax coloring for unknown language — all spans have default style.
|
||||
let has_colored_span = text
|
||||
.lines
|
||||
.iter()
|
||||
.flat_map(|l| l.spans.iter())
|
||||
.any(|sp| sp.style.fg.is_some());
|
||||
assert!(!has_colored_span, "expected no syntax coloring for unknown lang");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_block_no_lang_plain() {
|
||||
let text = render_markdown_text("```\nno lang specified\n```\n");
|
||||
let content: Vec<String> = text
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|s| s.content.clone())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect();
|
||||
let content: Vec<&str> = content
|
||||
.iter()
|
||||
.map(std::string::String::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
assert_eq!(content, vec!["no lang specified"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -721,16 +794,25 @@ Here is a code block that shows another fenced block:
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect();
|
||||
// Filter empty trailing lines for stability; the code block may or may
|
||||
// not emit a trailing blank depending on the highlighting path.
|
||||
let trimmed: Vec<&str> = {
|
||||
let mut v: Vec<&str> = lines.iter().map(std::string::String::as_str).collect();
|
||||
while v.last() == Some(&"") {
|
||||
v.pop();
|
||||
}
|
||||
v
|
||||
};
|
||||
assert_eq!(
|
||||
lines,
|
||||
trimmed,
|
||||
vec![
|
||||
"Here is a code block that shows another fenced block:".to_string(),
|
||||
String::new(),
|
||||
"```md".to_string(),
|
||||
"# Inside fence".to_string(),
|
||||
"- bullet".to_string(),
|
||||
"- `inline code`".to_string(),
|
||||
"```".to_string(),
|
||||
"Here is a code block that shows another fenced block:",
|
||||
"",
|
||||
"```md",
|
||||
"# Inside fence",
|
||||
"- bullet",
|
||||
"- `inline code`",
|
||||
"```",
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -993,3 +1075,36 @@ fn nested_item_continuation_paragraph_is_indented() {
|
||||
]);
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_block_preserves_trailing_blank_lines() {
|
||||
// A fenced code block with an intentional trailing blank line must keep it.
|
||||
let md = "```rust\nfn main() {}\n\n```\n";
|
||||
let text = render_markdown_text(md);
|
||||
let content: Vec<String> = text
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|s| s.content.clone())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect();
|
||||
// Should have: "fn main() {}" then "" (the blank line).
|
||||
// Filter only to content lines (skip leading/trailing empty from rendering).
|
||||
assert!(
|
||||
content.iter().any(|c| c == "fn main() {}"),
|
||||
"expected code line, got {content:?}"
|
||||
);
|
||||
// The trailing blank line inside the fence should be preserved.
|
||||
let code_start = content.iter().position(|c| c == "fn main() {}").unwrap();
|
||||
assert!(
|
||||
content.len() > code_start + 1,
|
||||
"expected a line after 'fn main() {{}}' but content ends: {content:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
content[code_start + 1], "",
|
||||
"trailing blank line inside code fence was lost: {content:?}"
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,7 @@ pub enum SlashCommand {
|
||||
Status,
|
||||
DebugConfig,
|
||||
Statusline,
|
||||
Theme,
|
||||
Mcp,
|
||||
Apps,
|
||||
Logout,
|
||||
@@ -75,6 +76,7 @@ impl SlashCommand {
|
||||
SlashCommand::Status => "show current session configuration and token usage",
|
||||
SlashCommand::DebugConfig => "show config layers and requirement sources for debugging",
|
||||
SlashCommand::Statusline => "configure which items appear in the status line",
|
||||
SlashCommand::Theme => "choose a syntax highlighting theme",
|
||||
SlashCommand::Ps => "list background terminals",
|
||||
SlashCommand::Clean => "stop all background terminals",
|
||||
SlashCommand::MemoryDrop => "DO NOT USE",
|
||||
@@ -155,6 +157,7 @@ impl SlashCommand {
|
||||
SlashCommand::Collab => true,
|
||||
SlashCommand::Agent => true,
|
||||
SlashCommand::Statusline => false,
|
||||
SlashCommand::Theme => false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
source: tui/src/diff_render.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"• Edited 6 files (+9 -9) "
|
||||
" └ assets/banner.txt (+3 -0) "
|
||||
" 1 +HEADER VALUE "
|
||||
" 2 +rocket 🚀 " Hidden by multi-width symbols: [(15, " ")]
|
||||
" 3 +city 東京 " Hidden by multi-width symbols: [(13, " "), (15, " ")]
|
||||
" "
|
||||
" └ examples/new_sample.rs (+3 -0) "
|
||||
" 1 +pub fn greet(name: &str) { "
|
||||
" 2 + println!("Hello, {name}!"); "
|
||||
" 3 +} "
|
||||
" "
|
||||
" └ legacy/old_script.py (+0 -3) "
|
||||
" 1 -def legacy(x): "
|
||||
" 2 - return x + 1 "
|
||||
" 3 -print(legacy(3)) "
|
||||
" "
|
||||
" └ scripts/calc.txt → scripts/calc.py (+1 -1) "
|
||||
" 1 def add(a, b): "
|
||||
" 2 - return a + b "
|
||||
" 2 + return a + b + 42 "
|
||||
" 3 "
|
||||
" 4 print(add(1, 2)) "
|
||||
" "
|
||||
" └ src/lib.rs (+2 -2) "
|
||||
" 1 fn greet(name: &str) { "
|
||||
" 2 - println!("hello"); "
|
||||
" 3 - println!("bye"); "
|
||||
" 2 + println!("hello {name}"); "
|
||||
" 3 + println!("emoji: 🚀✨ and CJK: 你好世界"); " Hidden by multi-width symbols: [(29, " "), (31, " "), (43, " "), (45, " "), (47, " "), (49, " ")]
|
||||
" 4 } "
|
||||
" "
|
||||
" └ tmp/obsolete.log (+0 -3) "
|
||||
" 1 -old line 1 "
|
||||
" 2 -old line 2 "
|
||||
" 3 -old line 3 "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
source: tui/src/diff_render.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"• Edited 6 files (+9 -9) "
|
||||
" └ assets/banner.txt (+3 -0) "
|
||||
" 1 +HEADER VALUE "
|
||||
" 2 +rocket 🚀 " Hidden by multi-width symbols: [(15, " ")]
|
||||
" 3 +city 東京 " Hidden by multi-width symbols: [(13, " "), (15, " ")]
|
||||
" "
|
||||
" └ examples/new_sample.rs (+3 -0) "
|
||||
" 1 +pub fn greet(name: &str) { "
|
||||
" 2 + println!("Hello, {name}!"); "
|
||||
" 3 +} "
|
||||
" "
|
||||
" └ legacy/old_script.py (+0 -3) "
|
||||
" 1 -def legacy(x): "
|
||||
" 2 - return x + 1 "
|
||||
" 3 -print(legacy(3)) "
|
||||
" "
|
||||
" └ scripts/calc.txt → scripts/calc.py (+1 -1) "
|
||||
" 1 def add(a, b): "
|
||||
" 2 - return a + b "
|
||||
" 2 + return a + b + 42 "
|
||||
" 3 "
|
||||
" 4 print(add(1, 2)) "
|
||||
" "
|
||||
" └ src/lib.rs (+2 -2) "
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
source: tui/src/diff_render.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"• Edited 6 files (+9 -9) "
|
||||
" └ assets/banner.txt (+3 -0) "
|
||||
" 1 +HEADER VALUE "
|
||||
" 2 +rocket 🚀 " Hidden by multi-width symbols: [(15, " ")]
|
||||
" 3 +city 東京 " Hidden by multi-width symbols: [(13, " "), (15, " ")]
|
||||
" "
|
||||
" └ examples/new_sample.rs (+3 -0) "
|
||||
" 1 +pub fn greet(name: &str) { "
|
||||
" 2 + println!("Hello, {name}!"); "
|
||||
" 3 +} "
|
||||
" "
|
||||
" └ legacy/old_script.py (+0 -3) "
|
||||
" 1 -def legacy(x): "
|
||||
" 2 - return x + 1 "
|
||||
" 3 -print(legacy(3)) "
|
||||
" "
|
||||
" └ scripts/calc.txt → scripts/calc.py (+1 -1) "
|
||||
" 1 def add(a, b): "
|
||||
" 2 - return a + b "
|
||||
" 2 + return a + b + 42 "
|
||||
" 3 "
|
||||
" 4 print(add(1, 2)) "
|
||||
" "
|
||||
" └ src/lib.rs (+2 -2) "
|
||||
" 1 fn greet(name: &str) { "
|
||||
" 2 - println!("hello"); "
|
||||
" 3 - println!("bye"); "
|
||||
" 2 + println!("hello {name}"); "
|
||||
" 3 + println!("emoji: 🚀✨ and CJK: 你好世界"); " Hidden by multi-width symbols: [(29, " "), (31, " "), (43, " "), (45, " "), (47, " "), (49, " ")]
|
||||
" 4 } "
|
||||
" "
|
||||
" └ tmp/obsolete.log (+0 -3) "
|
||||
" 1 -old line 1 "
|
||||
" 2 -old line 2 "
|
||||
" 3 -old line 3 "
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/diff_render.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"1 +fn very_long_function_name(arg_one: String, arg_two: String, arg_three: Strin "
|
||||
" g, arg_four: String) -> Result<String, Box<dyn std::error::Error>> { Ok(arg_o "
|
||||
" ne) } "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/diff_render.rs
|
||||
expression: text
|
||||
---
|
||||
1 +fn very_long_function_name(arg_one: String, arg_two: String, arg_three: Strin
|
||||
g, arg_four: String) -> Result<String, Box<dyn std::error::Error>> { Ok(arg_o
|
||||
ne) }
|
||||
620
codex-rs/tui/src/theme_picker.rs
Normal file
620
codex-rs/tui/src/theme_picker.rs
Normal file
@@ -0,0 +1,620 @@
|
||||
//! Builds the `/theme` picker dialog for the TUI.
|
||||
//!
|
||||
//! The picker lists all bundled themes plus any custom `.tmTheme` files found
|
||||
//! under `{CODEX_HOME}/themes/`. It provides:
|
||||
//!
|
||||
//! - **Live preview:** the `on_selection_changed` callback swaps the runtime
|
||||
//! syntax theme as the user navigates, giving instant visual feedback in both
|
||||
//! the preview panel and any visible code blocks.
|
||||
//! - **Cancel-restore:** on dismiss (Esc / Ctrl+C) the `on_cancel` callback
|
||||
//! restores the theme snapshot taken when the picker opened.
|
||||
//! - **Persist on confirm:** the `AppEvent::SyntaxThemeSelected` action persists
|
||||
//! `[tui] theme = "..."` to `config.toml` via `ConfigEditsBuilder`.
|
||||
//!
|
||||
//! Two preview renderables adapt to terminal width:
|
||||
//!
|
||||
//! - `ThemePreviewWideRenderable` -- vertically centered, inset by 2 columns,
|
||||
//! shown in the side panel when the terminal is wide enough for side-by-side
|
||||
//! layout (>= 44-column side panel and >= 40-column list).
|
||||
//! - `ThemePreviewNarrowRenderable` -- compact 4-line snippet stacked below the
|
||||
//! list when side-by-side does not fit.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
use crate::bottom_pane::SelectionViewParams;
|
||||
use crate::bottom_pane::SideContentWidth;
|
||||
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
|
||||
use crate::bottom_pane::popup_content_width;
|
||||
use crate::bottom_pane::side_by_side_layout_widths;
|
||||
use crate::diff_render::DiffLineType;
|
||||
use crate::diff_render::line_number_width;
|
||||
use crate::diff_render::push_wrapped_diff_line;
|
||||
use crate::diff_render::push_wrapped_diff_line_with_syntax;
|
||||
use crate::render::highlight;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::status::format_directory_display;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Widget;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum PreviewDiffKind {
|
||||
Context,
|
||||
Added,
|
||||
Removed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct PreviewRow {
|
||||
line_no: usize,
|
||||
kind: PreviewDiffKind,
|
||||
code: &'static str,
|
||||
}
|
||||
|
||||
/// Compact fallback preview used in stacked (narrow) mode.
|
||||
/// Keep exactly one removed and one added line visible at all times.
|
||||
const NARROW_PREVIEW_ROWS: [PreviewRow; 4] = [
|
||||
PreviewRow {
|
||||
line_no: 12,
|
||||
kind: PreviewDiffKind::Context,
|
||||
code: "fn greet(name: &str) -> String {",
|
||||
},
|
||||
PreviewRow {
|
||||
line_no: 13,
|
||||
kind: PreviewDiffKind::Removed,
|
||||
code: " format!(\"Hello, {}!\", name)",
|
||||
},
|
||||
PreviewRow {
|
||||
line_no: 13,
|
||||
kind: PreviewDiffKind::Added,
|
||||
code: " format!(\"Hello, {name}!\")",
|
||||
},
|
||||
PreviewRow {
|
||||
line_no: 14,
|
||||
kind: PreviewDiffKind::Context,
|
||||
code: "}",
|
||||
},
|
||||
];
|
||||
|
||||
/// Wider diff preview used in side-by-side mode.
|
||||
/// This sample intentionally mixes context, additions, and removals.
|
||||
const WIDE_PREVIEW_ROWS: [PreviewRow; 8] = [
|
||||
PreviewRow {
|
||||
line_no: 31,
|
||||
kind: PreviewDiffKind::Context,
|
||||
code: "fn summarize(users: &[User]) -> String {",
|
||||
},
|
||||
PreviewRow {
|
||||
line_no: 32,
|
||||
kind: PreviewDiffKind::Removed,
|
||||
code: " let active = users.iter().filter(|u| u.is_active).count();",
|
||||
},
|
||||
PreviewRow {
|
||||
line_no: 32,
|
||||
kind: PreviewDiffKind::Added,
|
||||
code: " let active = users.iter().filter(|u| u.is_active()).count();",
|
||||
},
|
||||
PreviewRow {
|
||||
line_no: 33,
|
||||
kind: PreviewDiffKind::Context,
|
||||
code: " let names: Vec<&str> = users.iter().map(User::name).take(3).collect();",
|
||||
},
|
||||
PreviewRow {
|
||||
line_no: 34,
|
||||
kind: PreviewDiffKind::Removed,
|
||||
code: " format!(\"{} active: {}\", active, names.join(\", \"))",
|
||||
},
|
||||
PreviewRow {
|
||||
line_no: 34,
|
||||
kind: PreviewDiffKind::Added,
|
||||
code: " format!(\"{active} active users: {}\", names.join(\", \"))",
|
||||
},
|
||||
PreviewRow {
|
||||
line_no: 35,
|
||||
kind: PreviewDiffKind::Added,
|
||||
code: " .trim()",
|
||||
},
|
||||
PreviewRow {
|
||||
line_no: 36,
|
||||
kind: PreviewDiffKind::Context,
|
||||
code: "}",
|
||||
},
|
||||
];
|
||||
|
||||
/// Minimum side-panel width for side-by-side theme preview.
|
||||
const WIDE_PREVIEW_MIN_WIDTH: u16 = 44;
|
||||
|
||||
/// Left inset used for wide preview content.
|
||||
const WIDE_PREVIEW_LEFT_INSET: u16 = 2;
|
||||
|
||||
/// Minimum frame padding used for vertically centered wide preview.
|
||||
const PREVIEW_FRAME_PADDING: u16 = 1;
|
||||
|
||||
const PREVIEW_FALLBACK_SUBTITLE: &str = "Move up/down to live preview themes";
|
||||
|
||||
/// Side-by-side preview: syntax-highlighted Rust diff snippet, vertically
|
||||
/// centered with a 2-column left inset. Fills the entire side panel height.
|
||||
struct ThemePreviewWideRenderable;
|
||||
|
||||
/// Stacked preview: compact 4-line Rust diff snippet shown below the list
|
||||
/// when the terminal is too narrow for side-by-side layout.
|
||||
struct ThemePreviewNarrowRenderable;
|
||||
|
||||
fn preview_diff_line_type(kind: PreviewDiffKind) -> DiffLineType {
|
||||
match kind {
|
||||
PreviewDiffKind::Context => DiffLineType::Context,
|
||||
PreviewDiffKind::Added => DiffLineType::Insert,
|
||||
PreviewDiffKind::Removed => DiffLineType::Delete,
|
||||
}
|
||||
}
|
||||
|
||||
fn centered_offset(available: u16, content: u16, min_frame: u16) -> u16 {
|
||||
let free = available.saturating_sub(content);
|
||||
let frame = if free >= min_frame.saturating_mul(2) {
|
||||
min_frame
|
||||
} else {
|
||||
0
|
||||
};
|
||||
frame + free.saturating_sub(frame.saturating_mul(2)) / 2
|
||||
}
|
||||
|
||||
fn render_preview(
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
preview_rows: &[PreviewRow],
|
||||
center_vertically: bool,
|
||||
left_inset: u16,
|
||||
) {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
if preview_rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
let preview_code = preview_rows
|
||||
.iter()
|
||||
.map(|row| row.code)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let syntax_lines = highlight::highlight_code_to_styled_spans(&preview_code, "rust");
|
||||
|
||||
let max_line_no = preview_rows
|
||||
.iter()
|
||||
.map(|row| row.line_no)
|
||||
.max()
|
||||
.unwrap_or(1);
|
||||
let ln_width = line_number_width(max_line_no);
|
||||
|
||||
let content_height = (preview_rows.len() as u16).min(area.height);
|
||||
|
||||
let left_pad = left_inset.min(area.width.saturating_sub(1));
|
||||
let top_pad = if center_vertically {
|
||||
centered_offset(area.height, content_height, PREVIEW_FRAME_PADDING)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let mut y = area.y.saturating_add(top_pad);
|
||||
let render_width = area.width.saturating_sub(left_pad);
|
||||
for (idx, row) in preview_rows.iter().enumerate() {
|
||||
if y >= area.y + area.height {
|
||||
break;
|
||||
}
|
||||
let diff_type = preview_diff_line_type(row.kind);
|
||||
let wrapped = if let Some(syn) = syntax_lines.as_ref().and_then(|sl| sl.get(idx)) {
|
||||
push_wrapped_diff_line_with_syntax(
|
||||
row.line_no,
|
||||
diff_type,
|
||||
row.code,
|
||||
render_width as usize,
|
||||
ln_width,
|
||||
syn,
|
||||
)
|
||||
} else {
|
||||
push_wrapped_diff_line(
|
||||
row.line_no,
|
||||
diff_type,
|
||||
row.code,
|
||||
render_width as usize,
|
||||
ln_width,
|
||||
)
|
||||
};
|
||||
let first_line = wrapped.into_iter().next().unwrap_or_else(|| Line::from(""));
|
||||
first_line.render(
|
||||
Rect::new(area.x.saturating_add(left_pad), y, render_width, 1),
|
||||
buf,
|
||||
);
|
||||
y += 1;
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for ThemePreviewWideRenderable {
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
u16::MAX
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
render_preview(area, buf, &WIDE_PREVIEW_ROWS, true, WIDE_PREVIEW_LEFT_INSET);
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for ThemePreviewNarrowRenderable {
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
NARROW_PREVIEW_ROWS.len() as u16
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
render_preview(area, buf, &NARROW_PREVIEW_ROWS, false, 0);
|
||||
}
|
||||
}
|
||||
|
||||
fn subtitle_available_width(terminal_width: Option<u16>) -> usize {
|
||||
let width = terminal_width.unwrap_or(80);
|
||||
let content_width = popup_content_width(width);
|
||||
if let Some((list_width, _side_width)) = side_by_side_layout_widths(
|
||||
content_width,
|
||||
SideContentWidth::Half,
|
||||
WIDE_PREVIEW_MIN_WIDTH,
|
||||
) {
|
||||
list_width as usize
|
||||
} else {
|
||||
content_width as usize
|
||||
}
|
||||
}
|
||||
|
||||
fn theme_picker_subtitle(codex_home: Option<&Path>, terminal_width: Option<u16>) -> String {
|
||||
let themes_dir = codex_home.map(|home| home.join("themes"));
|
||||
let themes_dir_display = themes_dir
|
||||
.as_deref()
|
||||
.map(|path| format_directory_display(path, None));
|
||||
let available_width = subtitle_available_width(terminal_width);
|
||||
|
||||
if let Some(path) = themes_dir_display
|
||||
&& path.starts_with('~')
|
||||
{
|
||||
let subtitle = format!("Custom .tmTheme files can be added to the {path} directory.");
|
||||
if UnicodeWidthStr::width(subtitle.as_str()) <= available_width {
|
||||
return subtitle;
|
||||
}
|
||||
}
|
||||
|
||||
PREVIEW_FALLBACK_SUBTITLE.to_string()
|
||||
}
|
||||
|
||||
/// Builds [`SelectionViewParams`] for the `/theme` picker dialog.
|
||||
///
|
||||
/// Lists all bundled themes plus custom `.tmTheme` files, with live preview
|
||||
/// on cursor movement and cancel-restore.
|
||||
///
|
||||
/// `current_name` should be the value of `Config::tui_theme` (the persisted
|
||||
/// preference). When it names a theme that is currently available the picker
|
||||
/// pre-selects it; otherwise the picker falls back to the configured name (or
|
||||
/// adaptive default) so opening the picker without a persisted preference still
|
||||
/// highlights the most likely intended entry.
|
||||
pub(crate) fn build_theme_picker_params(
|
||||
current_name: Option<&str>,
|
||||
codex_home: Option<&Path>,
|
||||
terminal_width: Option<u16>,
|
||||
) -> SelectionViewParams {
|
||||
// Snapshot the current theme so we can restore on cancel.
|
||||
let original_theme = highlight::current_syntax_theme();
|
||||
|
||||
let entries = highlight::list_available_themes(codex_home);
|
||||
let codex_home_owned = codex_home.map(Path::to_path_buf);
|
||||
|
||||
// Resolve the effective theme name: honor explicit config only when it is
|
||||
// currently available; otherwise fall back to configured/default selection
|
||||
// so opening `/theme` does not auto-preview an unrelated first entry.
|
||||
let effective_name = if let Some(name) = current_name
|
||||
&& entries.iter().any(|entry| entry.name == name)
|
||||
{
|
||||
name.to_string()
|
||||
} else {
|
||||
highlight::configured_theme_name()
|
||||
};
|
||||
|
||||
// Track the index of the current theme so we can pre-select it.
|
||||
let mut initial_idx = None;
|
||||
|
||||
let items: Vec<SelectionItem> = entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, entry)| {
|
||||
let display_name = if entry.is_custom {
|
||||
format!("{} (custom)", entry.name)
|
||||
} else {
|
||||
entry.name.clone()
|
||||
};
|
||||
let is_current = entry.name == effective_name;
|
||||
if is_current {
|
||||
initial_idx = Some(idx);
|
||||
}
|
||||
let name_for_action = entry.name.clone();
|
||||
SelectionItem {
|
||||
name: display_name,
|
||||
is_current,
|
||||
dismiss_on_select: true,
|
||||
search_value: Some(entry.name.clone()),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::SyntaxThemeSelected {
|
||||
name: name_for_action.clone(),
|
||||
});
|
||||
})],
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Derive preview targets from the final `items` list (not from `entries`)
|
||||
// so preview ordering stays aligned if item construction/sorting changes.
|
||||
let preview_theme_names: Vec<Option<String>> =
|
||||
items.iter().map(|item| item.search_value.clone()).collect();
|
||||
let preview_home = codex_home_owned.clone();
|
||||
let on_selection_changed = Some(Box::new(move |idx: usize, _tx: &_| {
|
||||
if let Some(Some(name)) = preview_theme_names.get(idx)
|
||||
&& let Some(theme) = highlight::resolve_theme_by_name(name, preview_home.as_deref())
|
||||
{
|
||||
highlight::set_syntax_theme(theme);
|
||||
}
|
||||
})
|
||||
as Box<dyn Fn(usize, &crate::app_event_sender::AppEventSender) + Send + Sync>);
|
||||
|
||||
// Restore original theme on cancel.
|
||||
let on_cancel = Some(Box::new(move |_tx: &_| {
|
||||
highlight::set_syntax_theme(original_theme.clone());
|
||||
})
|
||||
as Box<dyn Fn(&crate::app_event_sender::AppEventSender) + Send + Sync>);
|
||||
SelectionViewParams {
|
||||
title: Some("Select Syntax Theme".to_string()),
|
||||
subtitle: Some(theme_picker_subtitle(
|
||||
codex_home_owned.as_deref(),
|
||||
terminal_width,
|
||||
)),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
is_searchable: true,
|
||||
search_placeholder: Some("Type to filter themes...".to_string()),
|
||||
initial_selected_idx: initial_idx,
|
||||
side_content: Box::new(ThemePreviewWideRenderable),
|
||||
side_content_width: SideContentWidth::Half,
|
||||
side_content_min_width: WIDE_PREVIEW_MIN_WIDTH,
|
||||
stacked_side_content: Some(Box::new(ThemePreviewNarrowRenderable)),
|
||||
on_selection_changed,
|
||||
on_cancel,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::style::Modifier;
|
||||
|
||||
fn render_buffer(renderable: &dyn Renderable, width: u16, height: u16) -> Buffer {
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
renderable.render(area, &mut buf);
|
||||
buf
|
||||
}
|
||||
|
||||
fn render_lines(renderable: &dyn Renderable, width: u16, height: u16) -> Vec<String> {
|
||||
let buf = render_buffer(renderable, width, height);
|
||||
(0..height)
|
||||
.map(|row| {
|
||||
let mut line = String::new();
|
||||
for col in 0..width {
|
||||
let symbol = buf[(col, row)].symbol();
|
||||
if symbol.is_empty() {
|
||||
line.push(' ');
|
||||
} else {
|
||||
line.push_str(symbol);
|
||||
}
|
||||
}
|
||||
line
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn first_non_space_style_after_marker(buf: &Buffer, row: u16, width: u16) -> Option<Modifier> {
|
||||
let marker_col = (0..width)
|
||||
.find(|&col| buf[(col, row)].symbol() == "-" || buf[(col, row)].symbol() == "+")?;
|
||||
for col in marker_col + 1..width {
|
||||
if buf[(col, row)].symbol() != " " {
|
||||
return Some(buf[(col, row)].style().add_modifier);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn preview_line_number(line: &str) -> Option<usize> {
|
||||
let trimmed = line.trim_start();
|
||||
let digits_len = trimmed.chars().take_while(char::is_ascii_digit).count();
|
||||
if digits_len == 0 {
|
||||
return None;
|
||||
}
|
||||
let digits = &trimmed[..digits_len];
|
||||
if !trimmed[digits_len..].starts_with(' ') {
|
||||
return None;
|
||||
}
|
||||
digits.parse::<usize>().ok()
|
||||
}
|
||||
|
||||
fn preview_line_marker(line: &str) -> Option<char> {
|
||||
let trimmed = line.trim_start();
|
||||
let digits_len = trimmed.chars().take_while(char::is_ascii_digit).count();
|
||||
if digits_len == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut chars = trimmed[digits_len..].chars();
|
||||
if chars.next()? != ' ' {
|
||||
return None;
|
||||
}
|
||||
chars.next()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_picker_uses_half_width_with_stacked_fallback_preview() {
|
||||
let params = build_theme_picker_params(None, None, None);
|
||||
assert_eq!(params.side_content_width, SideContentWidth::Half);
|
||||
assert_eq!(params.side_content_min_width, WIDE_PREVIEW_MIN_WIDTH);
|
||||
assert!(params.stacked_side_content.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_picker_items_include_search_values_for_preview_mapping() {
|
||||
let params = build_theme_picker_params(None, None, None);
|
||||
assert!(
|
||||
params.items.iter().all(|item| item.search_value.is_some()),
|
||||
"theme picker preview mapping relies on item search_value to stay aligned with final item order"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_preview_renders_all_lines_with_vertical_center_and_left_inset() {
|
||||
let lines = render_lines(&ThemePreviewWideRenderable, 80, 20);
|
||||
let numbered_rows: Vec<usize> = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, line)| preview_line_number(line).map(|_| idx))
|
||||
.collect();
|
||||
let total_preview_lines = WIDE_PREVIEW_ROWS.len();
|
||||
|
||||
assert_eq!(numbered_rows.len(), total_preview_lines);
|
||||
let first_row = *numbered_rows
|
||||
.first()
|
||||
.expect("expected at least one preview row");
|
||||
let last_row = *numbered_rows
|
||||
.last()
|
||||
.expect("expected at least one preview row");
|
||||
assert!(
|
||||
first_row > 0,
|
||||
"expected top padding before centered preview"
|
||||
);
|
||||
assert!(
|
||||
last_row < 19,
|
||||
"expected bottom padding after centered preview"
|
||||
);
|
||||
|
||||
let first_line = &lines[first_row];
|
||||
assert!(
|
||||
first_line.starts_with(" 31 fn summarize"),
|
||||
"expected wide preview to start after a 2-char inset"
|
||||
);
|
||||
|
||||
let markers: Vec<char> = lines
|
||||
.iter()
|
||||
.filter_map(|line| preview_line_marker(line))
|
||||
.collect();
|
||||
assert!(
|
||||
markers.contains(&'+'),
|
||||
"expected wide preview to include at least one addition line"
|
||||
);
|
||||
assert!(
|
||||
markers.contains(&'-'),
|
||||
"expected wide preview to include at least one removal line"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_preview_renders_single_add_and_single_remove_in_four_lines() {
|
||||
let lines = render_lines(&ThemePreviewNarrowRenderable, 80, 6);
|
||||
let numbered_lines: Vec<usize> = lines
|
||||
.iter()
|
||||
.filter_map(|line| preview_line_number(line))
|
||||
.collect();
|
||||
let markers: Vec<char> = lines
|
||||
.iter()
|
||||
.filter_map(|line| preview_line_marker(line))
|
||||
.collect();
|
||||
|
||||
assert_eq!(numbered_lines, vec![12, 13, 13, 14]);
|
||||
assert_eq!(markers.len(), 4);
|
||||
assert_eq!(markers.iter().filter(|&&m| m == '+').count(), 1);
|
||||
assert_eq!(markers.iter().filter(|&&m| m == '-').count(), 1);
|
||||
let first_numbered = lines
|
||||
.iter()
|
||||
.find(|line| preview_line_number(line).is_some())
|
||||
.expect("expected at least one rendered preview row");
|
||||
assert!(
|
||||
first_numbered.starts_with("12 fn greet"),
|
||||
"expected narrow preview line numbers to start at the left edge"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_preview_code_uses_dim_overlay_like_real_diff_renderer() {
|
||||
let width = 80;
|
||||
let height = 6;
|
||||
let buf = render_buffer(&ThemePreviewNarrowRenderable, width, height);
|
||||
let lines = render_lines(&ThemePreviewNarrowRenderable, width, height);
|
||||
let deleted_row = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(row, line)| (preview_line_marker(line) == Some('-')).then_some(row as u16))
|
||||
.expect("expected a deleted preview row");
|
||||
let modifiers = first_non_space_style_after_marker(&buf, deleted_row, width)
|
||||
.expect("expected code text after diff marker");
|
||||
assert!(
|
||||
modifiers.contains(Modifier::DIM),
|
||||
"expected deleted preview code to be dimmed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtitle_uses_tilde_path_when_codex_home_under_home_directory() {
|
||||
let home = dirs::home_dir().expect("home directory should be available");
|
||||
let codex_home = home.join(".codex");
|
||||
|
||||
let subtitle = theme_picker_subtitle(Some(&codex_home), Some(200));
|
||||
|
||||
assert!(subtitle.contains("~"));
|
||||
assert!(subtitle.contains("directory"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtitle_falls_back_when_tilde_path_subtitle_is_too_wide() {
|
||||
let home = dirs::home_dir().expect("home directory should be available");
|
||||
let long_segment = "a".repeat(120);
|
||||
let codex_home = home.join(long_segment).join(".codex");
|
||||
|
||||
let subtitle = theme_picker_subtitle(Some(&codex_home), Some(140));
|
||||
|
||||
assert_eq!(subtitle, PREVIEW_FALLBACK_SUBTITLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtitle_falls_back_to_preview_instructions_without_tilde_path() {
|
||||
let subtitle = theme_picker_subtitle(None, None);
|
||||
assert_eq!(subtitle, PREVIEW_FALLBACK_SUBTITLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtitle_falls_back_for_94_column_terminal_side_by_side_layout() {
|
||||
let home = dirs::home_dir().expect("home directory should be available");
|
||||
let codex_home = home.join(".codex");
|
||||
|
||||
let subtitle = theme_picker_subtitle(Some(&codex_home), Some(94));
|
||||
|
||||
assert_eq!(subtitle, PREVIEW_FALLBACK_SUBTITLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_configured_theme_falls_back_to_configured_or_default_selection() {
|
||||
let configured_or_default_theme = highlight::configured_theme_name();
|
||||
let params = build_theme_picker_params(Some("not-a-real-theme"), None, Some(120));
|
||||
let selected_idx = params
|
||||
.initial_selected_idx
|
||||
.expect("expected selected index for active fallback theme");
|
||||
let selected_name = params.items[selected_idx]
|
||||
.search_value
|
||||
.as_deref()
|
||||
.expect("expected search value to contain canonical theme name");
|
||||
|
||||
assert_eq!(selected_name, configured_or_default_theme);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user