carthagent 1.0.7 → 1.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (303) hide show
  1. package/README.md +140 -83
  2. package/bin/ctg.mjs +14 -732
  3. package/docs/beta-rewards-e2e-plan.md +48 -0
  4. package/docs/beta-rewards-e2e-results.md +84 -0
  5. package/docs/cloud-onboarding-acceptance.md +65 -0
  6. package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
  7. package/docs/glyph-review/test-isolation-v1.md +26 -0
  8. package/docs/glyph-review/typecheck-base.log +1 -0
  9. package/docs/glyph-review/typecheck-v4.log +1 -0
  10. package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
  11. package/docs/help-dispatch-review.md +69 -0
  12. package/docs/launch-readiness-live-results.md +65 -0
  13. package/docs/legacy-cli.md +150 -0
  14. package/docs/live-theme-plugin-api.md +18 -0
  15. package/docs/native-cli.md +102 -0
  16. package/docs/native-compatibility.md +16 -0
  17. package/docs/native-daily-quests.md +132 -0
  18. package/docs/native-glyph-pty.md +59 -0
  19. package/docs/native-manual-testing.md +336 -0
  20. package/docs/native-slash-acceptance-handoff.md +49 -0
  21. package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
  22. package/docs/native-terminal-performance.md +310 -0
  23. package/docs/native-terminal-semantics-evidence/README.md +25 -0
  24. package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
  25. package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
  26. package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
  27. package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
  28. package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
  29. package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
  30. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
  31. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
  32. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
  33. package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
  34. package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
  35. package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
  36. package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
  37. package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
  38. package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
  39. package/docs/native-terminal-semantics.md +89 -0
  40. package/docs/native-upgrade.md +175 -0
  41. package/docs/opencode-composed-source-review.md +89 -0
  42. package/docs/opencode-migration.md +225 -9
  43. package/docs/opencode-owned-runtime.md +241 -0
  44. package/docs/opencode-runtime.md +12 -0
  45. package/docs/opencode-theme-provider-proof/README.md +55 -0
  46. package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
  47. package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
  48. package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
  49. package/docs/owned-host-controls-proof.md +39 -0
  50. package/docs/owned-runtime-release-manifest.md +42 -0
  51. package/docs/owned-visual-clear-proof.md +40 -0
  52. package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
  53. package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
  54. package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
  55. package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
  56. package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
  57. package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
  58. package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
  59. package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
  60. package/docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch +13965 -0
  61. package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
  62. package/docs/release-notes-beta.1.md +60 -0
  63. package/docs/release-notes-beta.2.md +60 -0
  64. package/docs/release-notes-native-draft.md +73 -0
  65. package/docs/release-notes-preview.10.md +121 -0
  66. package/docs/release-notes-preview.11.md +76 -0
  67. package/docs/release-notes-preview.12.md +55 -0
  68. package/docs/release-notes-preview.13.md +68 -0
  69. package/docs/release-notes-preview.9.md +130 -0
  70. package/docs/source-preview.md +120 -0
  71. package/evaluate.mjs +28 -1
  72. package/lib/budget.mjs +1 -1
  73. package/lib/cloud/client.mjs +13 -7
  74. package/lib/codegraph.mjs +1 -1
  75. package/lib/delivery-review.mjs +19 -6
  76. package/lib/delivery.mjs +3 -2
  77. package/lib/environment.mjs +8 -0
  78. package/lib/guidance.mjs +1 -1
  79. package/lib/opencode/cli-runtime.mjs +1188 -0
  80. package/lib/opencode/cli.mjs +253 -0
  81. package/lib/opencode/credentials.mjs +63 -8
  82. package/lib/opencode/demo.mjs +53 -0
  83. package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
  84. package/lib/opencode/evaluation.mjs +32 -12
  85. package/lib/opencode/headless.mjs +22 -5
  86. package/lib/opencode/provider-policy.mjs +23 -0
  87. package/lib/opencode/review.mjs +37 -16
  88. package/lib/opencode/runtime-artifact.mjs +335 -0
  89. package/lib/opencode/runtime-github-transport.mjs +121 -0
  90. package/lib/opencode/runtime-release.json +36 -0
  91. package/lib/opencode/runtime.mjs +299 -20
  92. package/lib/opencode/service.mjs +77 -35
  93. package/lib/opencode/session-options.mjs +6 -2
  94. package/lib/opencode/update.mjs +222 -0
  95. package/lib/opencode/workspace.mjs +1 -1
  96. package/lib/planning-advice.mjs +2 -2
  97. package/lib/providers/experiential.mjs +2 -2
  98. package/lib/providers/names.mjs +1 -1
  99. package/lib/tui/framing.mjs +1 -80
  100. package/lib/tui/models.mjs +1 -1
  101. package/lib/update.mjs +10 -1
  102. package/package.json +33 -7
  103. package/plugins/carthagent/README.md +23 -0
  104. package/plugins/carthagent/src/bootstrap.ts +1 -1
  105. package/plugins/carthagent/src/daily-evidence.mjs +57 -0
  106. package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
  107. package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
  108. package/plugins/carthagent/src/index.ts +71 -24
  109. package/plugins/carthagent/src/rpc.ts +51 -6
  110. package/plugins/carthagent/src/server/codegraph.ts +12 -5
  111. package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
  112. package/plugins/carthagent/src/server/daily-source-rpc.ts +243 -0
  113. package/plugins/carthagent/src/server/delivery.ts +227 -112
  114. package/plugins/carthagent/src/server/framing.mjs +80 -0
  115. package/plugins/carthagent/src/server/policy.ts +1 -1
  116. package/plugins/carthagent/src/server/providers.ts +65 -36
  117. package/plugins/carthagent/src/server/session-controls.ts +3 -1
  118. package/plugins/carthagent/src/server/ship-agents.ts +77 -0
  119. package/plugins/carthagent/src/server/ship-context.mjs +60 -0
  120. package/plugins/carthagent/src/server/ship-guidance.ts +41 -0
  121. package/plugins/carthagent/src/server/ship-rpc.ts +71 -0
  122. package/plugins/carthagent/src/server/voyages-rpc.ts +280 -0
  123. package/plugins/carthagent/src/testing.ts +5 -0
  124. package/plugins/carthagent/src/tui.tsx +506 -0
  125. package/plugins/carthagent/src/ui/client.ts +127 -0
  126. package/plugins/carthagent/src/ui/commands.ts +70 -0
  127. package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
  128. package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
  129. package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
  130. package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
  131. package/plugins/carthagent/src/ui/daily-quests.tsx +235 -0
  132. package/plugins/carthagent/src/ui/host-state.ts +42 -0
  133. package/plugins/carthagent/src/ui/model-command.ts +32 -0
  134. package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
  135. package/plugins/carthagent/src/ui/native-review.d.mts +33 -0
  136. package/plugins/carthagent/src/ui/native-review.mjs +159 -0
  137. package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
  138. package/plugins/carthagent/src/ui/select-mouse.ts +17 -0
  139. package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
  140. package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
  141. package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
  142. package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
  143. package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
  144. package/plugins/carthagent/src/ui/voyages-navigation.tsx +131 -0
  145. package/plugins/carthagent/src/ui/voyages-state.mjs +165 -0
  146. package/plugins/carthagent/src/ui/voyages.tsx +131 -0
  147. package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
  148. package/plugins/carthagent/src/ui/workspace.tsx +379 -0
  149. package/plugins/carthagent/src/voyages-rpc.ts +64 -0
  150. package/plugins/carthagent/src/workspace-rpc.ts +36 -0
  151. package/plugins/carthagent/tests/commands.test.ts +107 -0
  152. package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
  153. package/plugins/carthagent/tests/voyages-navigation.test.tsx +269 -0
  154. package/plugins/carthagent/themes/carthage.json +67 -0
  155. package/plugins/carthagent/tui.ts +26 -1
  156. package/script/ctg/build-owned-runtime.sh +166 -0
  157. package/tests/budget-engine.test.mjs +4 -3
  158. package/tests/cli.test.mjs +52 -96
  159. package/tests/cloud-client.test.mjs +2 -2
  160. package/tests/daily-evidence.test.mjs +62 -0
  161. package/tests/delivery-review.test.mjs +57 -2
  162. package/tests/delivery.test.mjs +22 -1
  163. package/tests/experiential-extension.test.mjs +2 -2
  164. package/tests/experiential.test.mjs +1 -1
  165. package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
  166. package/tests/generate-owned-runtime-release.test.mjs +354 -0
  167. package/tests/installed-runtime-contract.test.mjs +43 -0
  168. package/tests/native-cli-host-smoke.mjs +457 -0
  169. package/tests/native-daily-editor-focus.test.mjs +213 -0
  170. package/tests/native-daily-editor.test.mjs +43 -0
  171. package/tests/native-daily-quests-layout.test.mjs +55 -0
  172. package/tests/native-daily-quests-rpc.test.mjs +456 -0
  173. package/tests/native-daily-quests-ui.test.mjs +778 -0
  174. package/tests/native-daily-source.test.mjs +525 -0
  175. package/tests/native-demo-ui.test.mjs +48 -0
  176. package/tests/native-evaluation-admission.test.mjs +24 -0
  177. package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
  178. package/tests/native-fixture-runtime.test.mjs +111 -0
  179. package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
  180. package/tests/native-glyph-pty.test.mjs +107 -0
  181. package/tests/native-manual-projects.test.mjs +170 -0
  182. package/tests/native-model-command.test.mjs +67 -0
  183. package/tests/native-owned-runtime-env.test.mjs +140 -0
  184. package/tests/native-paired-guards.test.mjs +464 -0
  185. package/tests/native-provider-policy.test.mjs +33 -0
  186. package/tests/native-pty-invocation.test.mjs +114 -0
  187. package/tests/native-pty-runtime.test.mjs +45 -0
  188. package/tests/native-resume-session.test.mjs +46 -0
  189. package/tests/native-review-consumer.test.mjs +286 -0
  190. package/tests/native-runtime-identity.test.mjs +214 -0
  191. package/tests/native-select-mouse.test.mjs +23 -0
  192. package/tests/native-ship-guidance.test.mjs +16 -0
  193. package/tests/native-ship-parity.test.mjs +526 -0
  194. package/tests/native-ship-plan.test.mjs +85 -0
  195. package/tests/native-ship-rpc.test.mjs +83 -0
  196. package/tests/native-ui-commands.test.mjs +6 -0
  197. package/tests/native-voyages-layout.test.mjs +28 -0
  198. package/tests/native-voyages-navigation.test.mjs +169 -0
  199. package/tests/native-voyages-rpc.test.mjs +397 -0
  200. package/tests/native-voyages-ui.test.mjs +307 -0
  201. package/tests/opencode-atomic-write.test.mjs +55 -0
  202. package/tests/opencode-cli.test.mjs +1312 -0
  203. package/tests/opencode-codegraph-plugin.test.mjs +47 -0
  204. package/tests/opencode-delivery.test.mjs +16 -0
  205. package/tests/opencode-providers.test.mjs +248 -20
  206. package/tests/opencode-runtime.test.mjs +597 -8
  207. package/tests/opencode-session-controls.test.mjs +15 -0
  208. package/tests/opencode-ship-rpc.test.mjs +71 -0
  209. package/tests/opencode-update.test.mjs +475 -0
  210. package/tests/opencode-workspace.test.mjs +51 -2
  211. package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
  212. package/tests/release-workflow.test.mjs +46 -0
  213. package/tests/runtime-artifact.test.mjs +269 -0
  214. package/tests/runtime-development-trust.test.mjs +231 -0
  215. package/tests/runtime-github-transport.test.mjs +132 -0
  216. package/tests/runtime-token-env.test.mjs +23 -0
  217. package/tests/server.test.mjs +3 -1
  218. package/tests/ship-telemetry.test.mjs +1 -1
  219. package/tests/source-preview.test.mjs +253 -0
  220. package/tests/test_native_terminal_probe.py +76 -0
  221. package/tests/test_native_terminal_semantics.py +232 -0
  222. package/tests/tui.test.mjs +2 -2
  223. package/tests/update.test.mjs +42 -5
  224. package/tests/verify-draft-runtime-gate.test.mjs +280 -0
  225. package/tests/verify-owned-runtime-release.test.mjs +371 -0
  226. package/tests/workspace-live.test.mjs +127 -0
  227. package/tools/benchmark/diagnose-request.mjs +59 -36
  228. package/tools/benchmark/eval-common.mjs +3 -3
  229. package/tools/benchmark/evaluators.test.mjs +4 -4
  230. package/tools/benchmark/guard.test.mjs +35 -2
  231. package/tools/benchmark/guard.ts +20 -7
  232. package/tools/benchmark/planning-startup.test.mjs +30 -34
  233. package/tools/benchmark/runner.mjs +12 -7
  234. package/tools/benchmark/runner.test.mjs +23 -7
  235. package/tools/benchmark/upstream-error.test.mjs +16 -2
  236. package/tools/generate-owned-runtime-release.mjs +217 -0
  237. package/tools/installed-runtime-contract.mjs +16 -0
  238. package/tools/manual-runtime-manifest.mjs +39 -0
  239. package/tools/native-fixture-browser-cleanup.mjs +59 -0
  240. package/tools/native-fixture-stop-diagnostics.mjs +45 -0
  241. package/tools/native-fixture.mjs +199 -40
  242. package/tools/native-glyph-helpers.py +127 -0
  243. package/tools/native-glyph-pty.py +144 -0
  244. package/tools/native-manual-projects.mjs +259 -0
  245. package/tools/native-manual.mjs +338 -0
  246. package/tools/native-model-picker-acceptance.py +306 -0
  247. package/tools/native-owned-runtime-env.sh +47 -0
  248. package/tools/native-paired-guards.mjs +282 -0
  249. package/tools/native-paired-manifest.mjs +72 -0
  250. package/tools/native-paired-orchestration.sh +51 -0
  251. package/tools/native-plugin-readiness.mjs +33 -0
  252. package/tools/native-plugin-readiness.test.mjs +53 -0
  253. package/tools/native-pty-invocation.mjs +26 -0
  254. package/tools/native-pty-runtime.mjs +83 -0
  255. package/tools/native-pty-smoke.py +147 -24
  256. package/tools/native-public-repo-shell-result.mjs +13 -0
  257. package/tools/native-public-repo-shell-result.test.mjs +28 -0
  258. package/tools/native-runtime-identity.mjs +201 -0
  259. package/tools/native-slash-acceptance-env.py +38 -0
  260. package/tools/native-terminal-semantics.py +882 -0
  261. package/tools/owned-shell-observer/index.ts +16 -0
  262. package/tools/owned-shell-observer/package.json +1 -0
  263. package/tools/owned-visual-clear-env.py +48 -0
  264. package/tools/owned-visual-clear-model.py +21 -0
  265. package/tools/pinned-owned-runtime-inputs.mjs +46 -0
  266. package/tools/prepare-native-manual.mjs +122 -0
  267. package/tools/run-native-terminal-paired.sh +403 -0
  268. package/tools/runtime-token-env.mjs +8 -0
  269. package/tools/seed-native-switch-fixture.mjs +79 -0
  270. package/tools/test-native-controls.mjs +3 -1
  271. package/tools/test-native-daily-quests.mjs +65 -0
  272. package/tools/test-native-default-cli.mjs +106 -0
  273. package/tools/test-native-delivery-host.mjs +102 -20
  274. package/tools/test-native-evaluation.mjs +7 -3
  275. package/tools/test-native-glyph-pty.mjs +94 -0
  276. package/tools/test-native-host.mjs +91 -65
  277. package/tools/test-native-outcomes.mjs +3 -1
  278. package/tools/test-native-package.mjs +206 -38
  279. package/tools/test-native-performance.mjs +26 -0
  280. package/tools/test-native-product.mjs +90 -0
  281. package/tools/test-native-pty.mjs +34 -9
  282. package/tools/test-native-public-repo.mjs +224 -0
  283. package/tools/test-native-refresh-reentry.mjs +106 -0
  284. package/tools/test-native-report.mjs +27 -20
  285. package/tools/test-native-review.mjs +75 -0
  286. package/tools/test-native-runtime.mjs +120 -0
  287. package/tools/test-native-ship-models.mjs +176 -0
  288. package/tools/test-native-ship-walkthrough.mjs +96 -0
  289. package/tools/test-native-slash-commands.mjs +125 -0
  290. package/tools/test-native-slash-commands.py +242 -0
  291. package/tools/test-native-swe-host.mjs +224 -55
  292. package/tools/test-native-tasks.mjs +77 -0
  293. package/tools/test-native-terminal-performance.py +887 -0
  294. package/tools/test-native-terminal-readiness.sh +85 -0
  295. package/tools/test-native-terminal-semantics-installed.mjs +38 -0
  296. package/tools/test-native-visual-clear-installed.mjs +48 -0
  297. package/tools/test-owned-host-controls.mjs +238 -0
  298. package/tools/test-owned-opencode-runtime.mjs +35 -0
  299. package/tools/test-owned-visual-clear-env.py +120 -0
  300. package/tools/test-owned-visual-clear.py +373 -0
  301. package/tools/verify-draft-runtime-gate.mjs +138 -0
  302. package/tools/verify-owned-runtime-release.mjs +114 -0
  303. package/tools/verify-release-version.mjs +21 -1
@@ -0,0 +1,3670 @@
1
+ diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx
2
+ index 88939ebc852824e6ef2371c719a528ab75092a7c..6c16e376aa10f28e767e4fba17c2220158330b53 100644
3
+ --- a/packages/tui/src/app.tsx
4
+ +++ b/packages/tui/src/app.tsx
5
+ @@ -2,6 +2,7 @@ import { existsSync } from "fs"
6
+ import path from "path"
7
+ import { render, useRenderer, useTerminalDimensions } from "@opentui/solid"
8
+ import { registerOpencodeSpinner } from "./component/register-spinner"
9
+ +import { chromeEllipsis, chromeLabel } from "./ui/glyphs"
10
+ import { Effect, Latch } from "effect"
11
+ import { Service, type Endpoint } from "@opencode/client/effect/service"
12
+ import { OpenCode, type SessionInfo } from "@opencode/client"
13
+ @@ -618,7 +619,7 @@ function App() {
14
+ return
15
+ }
16
+
17
+ - renderer.setTerminalTitle(`OC | ${title.length > 40 ? title.slice(0, 37) + "…" : title}`)
18
+ + renderer.setTerminalTitle(`OC | ${title.length > 40 ? title.slice(0, 37) + chromeEllipsis() : title}`)
19
+ return
20
+ }
21
+
22
+ @@ -1046,7 +1047,7 @@ function App() {
23
+ const restart = client.restart
24
+ if (!restart) return
25
+ dialog.clear()
26
+ - toast.show({ variant: "info", message: "Restarting service…", duration: 30000 })
27
+ + toast.show({ variant: "info", message: chromeLabel("Restarting service…"), duration: 30000 })
28
+ // restart resolves once the replacement service is healthy; the
29
+ // event stream reattaches through the reconnect loop.
30
+ await restart()
31
+ @@ -1063,7 +1064,7 @@ function App() {
32
+ slash: { name: "reload" },
33
+ run: async () => {
34
+ dialog.clear()
35
+ - toast.show({ variant: "info", message: "Reloading configuration…", duration: 30000 })
36
+ + toast.show({ variant: "info", message: chromeLabel("Reloading configuration…"), duration: 30000 })
37
+ await client.api.location
38
+ .reload()
39
+ .then(() => {
40
+ diff --git a/packages/tui/src/component/command-palette.tsx b/packages/tui/src/component/command-palette.tsx
41
+ index 4f46db3253589b4cc05bc32cb1c12087f2663cab..51390b70b51378d4de227a285870d909c4faf2b7 100644
42
+ --- a/packages/tui/src/component/command-palette.tsx
43
+ +++ b/packages/tui/src/component/command-palette.tsx
44
+ @@ -3,6 +3,7 @@ import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
45
+ import { type DialogContext } from "../ui/dialog"
46
+ import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "../context/keymap"
47
+ import { DialogConfig, settingID, settings } from "./dialog-config"
48
+ +import { chromeStatusSeparator } from "../ui/glyphs"
49
+
50
+ function isSuggestedPaletteCommand(command: KeymapCommand) {
51
+ const suggested = command.suggested
52
+ @@ -23,7 +24,7 @@ export function CommandPaletteDialog() {
53
+ description: command.description,
54
+ category: command.group,
55
+ searchText: [command.id, command.description].filter(Boolean).join(" "),
56
+ - searchFooter: [command.group, footer].filter(Boolean).join(" · "),
57
+ + searchFooter: [command.group, footer].filter(Boolean).join(` ${chromeStatusSeparator()} `),
58
+ footer,
59
+ value: command.id,
60
+ suggested: isSuggestedPaletteCommand(command),
61
+ @@ -38,7 +39,7 @@ export function CommandPaletteDialog() {
62
+ title: setting.title,
63
+ category: setting.category,
64
+ searchText: setting.keywords?.join(" "),
65
+ - searchFooter: `Settings · ${setting.category}`,
66
+ + searchFooter: `Settings ${chromeStatusSeparator()} ${setting.category}`,
67
+ value: `setting:${settingID(setting)}`,
68
+ onSelect: (dialog: DialogContext) => {
69
+ dialog.replace(() => <DialogConfig current={settingID(setting)} />)
70
+ diff --git a/packages/tui/src/component/devtools-bar.tsx b/packages/tui/src/component/devtools-bar.tsx
71
+ index 10af2019d600530eb9275a05216fe658e255c066..259b561364c8b9fd5c71afe9d647991ed9ff6817 100644
72
+ --- a/packages/tui/src/component/devtools-bar.tsx
73
+ +++ b/packages/tui/src/component/devtools-bar.tsx
74
+ @@ -17,6 +17,7 @@ import { useDialog } from "../ui/dialog"
75
+ import { DialogExperiments } from "./dialog-experiments"
76
+ import { usePlugin } from "../plugin/context"
77
+ import { errorMessage } from "../util/error"
78
+ +import { chromeGlyph, chromeLabel } from "../ui/glyphs"
79
+
80
+ const graphWidth = 23
81
+ const sampleIntervalMilliseconds = 2_000
82
+ @@ -254,7 +255,7 @@ export function DevToolsBar() {
83
+ : theme.text.base
84
+ }
85
+ >
86
+ - {serverIndicator().icon}
87
+ + {chromeGlyph(serverIndicator().icon)}
88
+ </text>
89
+ <text
90
+ fg={
91
+ @@ -360,7 +361,7 @@ export function DevToolsBar() {
92
+ <PanelBox>
93
+ <PanelTitle>Tools</PanelTitle>
94
+ <Action onClick={() => void dump()} disabled={dumping()} hoverBackground>
95
+ - {dumping() ? "Writing debug snapshot…" : "Write debug snapshot"}
96
+ + {dumping() ? chromeLabel("Writing debug snapshot…") : "Write debug snapshot"}
97
+ </Action>
98
+ <Show when={dumpPath()}>
99
+ {(file) => (
100
+ @@ -579,15 +580,15 @@ export function runtimeStatus(samples: readonly Readonly<{ delay: number; time:
101
+ }
102
+
103
+ export function statusIcon(status: RuntimeStatus) {
104
+ - if (status === "high") return "●"
105
+ - if (status === "medium") return "⦿"
106
+ - return "○"
107
+ + if (status === "high") return chromeGlyph("●")
108
+ + if (status === "medium") return chromeGlyph("⦿")
109
+ + return chromeGlyph("○")
110
+ }
111
+
112
+ export function connectionIndicator(status: "connected" | "connecting" | "reconnecting", attempt: number) {
113
+ - if (status === "connected") return { state: "connected" as const, icon: "✓" }
114
+ - if (status === "reconnecting" && attempt >= 3) return { state: "disconnected" as const, icon: "×" }
115
+ - return { state: "reconnecting" as const, icon: "↻" }
116
+ + if (status === "connected") return { state: "connected" as const, icon: chromeGlyph("✓") }
117
+ + if (status === "reconnecting" && attempt >= 3) return { state: "disconnected" as const, icon: chromeGlyph("×") }
118
+ + return { state: "reconnecting" as const, icon: chromeGlyph("↻") }
119
+ }
120
+
121
+ export function brailleGraph(values: readonly number[], width = graphWidth) {
122
+ diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx
123
+ index f2e3f28b837d0af38f5ada1dc7b4fe3856462bcd..2e0d04234e730cb10c5a468647d456ea364391db 100644
124
+ --- a/packages/tui/src/component/dialog-config.tsx
125
+ +++ b/packages/tui/src/component/dialog-config.tsx
126
+ @@ -2,6 +2,7 @@ import { createMemo, createSignal } from "solid-js"
127
+ import { useConfig, useNewDesigns } from "../config"
128
+ import { useThemes } from "../context/theme"
129
+ import { DialogSelect } from "../ui/dialog-select"
130
+ +import { chromeGlyph } from "../ui/glyphs"
131
+ import { useToast } from "../ui/toast"
132
+
133
+ type Setting = {
134
+ @@ -402,7 +403,7 @@ export function DialogConfig(props: { current?: string }) {
135
+ filterThreshold={0.7}
136
+ onMove={(option) => setSelected(option.value)}
137
+ onSelect={(option) => void change(1, option.value)}
138
+ - footerHints={newDesigns() ? [] : [{ title: "change", label: "←/→" }]}
139
+ + footerHints={newDesigns() ? [] : [{ title: "change", label: `${chromeGlyph("←")}/${chromeGlyph("→")}` }]}
140
+ bindings={[
141
+ {
142
+ bind: "left",
143
+ diff --git a/packages/tui/src/component/dialog-debug.tsx b/packages/tui/src/component/dialog-debug.tsx
144
+ index 546f47c56c64eef5d5f2954d36dfb7ce4231e43d..1c1d4b81ab37461dc56bbb8c32571a0af928fadb 100644
145
+ --- a/packages/tui/src/component/dialog-debug.tsx
146
+ +++ b/packages/tui/src/component/dialog-debug.tsx
147
+ @@ -9,6 +9,7 @@ import { useToast } from "../ui/toast"
148
+ import { describeOS, describeTerminal } from "../util/system"
149
+ import { useTuiApp } from "../context/runtime"
150
+ import { useNewDesigns } from "../config"
151
+ +import { chromeGlyph } from "../ui/glyphs"
152
+
153
+ export function DialogDebug() {
154
+ const theme = useTheme()
155
+ @@ -54,7 +55,7 @@ export function DialogDebug() {
156
+ }))
157
+
158
+ const copyAction = () => ({
159
+ - title: copied() ? "✓ copied" : "copy",
160
+ + title: copied() ? `${chromeGlyph("✓")} copied` : "copy",
161
+ key: newDesigns() ? "c" : "enter",
162
+ color: copied() ? theme.text.feedback.success.base : undefined,
163
+ onTrigger: copy,
164
+ diff --git a/packages/tui/src/component/dialog-error-details.tsx b/packages/tui/src/component/dialog-error-details.tsx
165
+ index fe8f122903e22b2b1cc20c07cdcf52fef044c783..ff8d521c992dbc219dc1ecf6f120e42936179c50 100644
166
+ --- a/packages/tui/src/component/dialog-error-details.tsx
167
+ +++ b/packages/tui/src/component/dialog-error-details.tsx
168
+ @@ -11,6 +11,7 @@ import { useTheme } from "../context/theme"
169
+ import { emptyPrompt } from "../prompt/history"
170
+ import { DIALOG_INSET, DialogFooter, DialogHeader, DialogLayout, dialogWidth, useDialog } from "../ui/dialog"
171
+ import { FilePath } from "../ui/file-path"
172
+ +import { chromeGlyph } from "../ui/glyphs"
173
+ import { useToast } from "../ui/toast"
174
+ import { errorDetails } from "../util/error-details"
175
+
176
+ @@ -64,7 +65,7 @@ export function DialogErrorDetails(props: {
177
+ const actions = () => [
178
+ { title: "investigate", key: "i", onTrigger: investigate },
179
+ copied()
180
+ - ? { title: "✓ copied", color: theme.text.feedback.success.base, onTrigger: copy }
181
+ + ? { title: `${chromeGlyph("✓")} copied`, color: theme.text.feedback.success.base, onTrigger: copy }
182
+ : { title: "copy details", key: "c", onTrigger: copy },
183
+ ]
184
+
185
+ @@ -109,7 +110,9 @@ export function DialogErrorDetails(props: {
186
+ </box>
187
+ }
188
+ footer={{ actions: { primary: actions() } }}
189
+ - legacyFooter={<DialogFooter actions={{ secondary: actions(), primary: [{ title: "scroll", key: "↑/↓" }] }} />}
190
+ + legacyFooter={
191
+ + <DialogFooter actions={{ secondary: actions(), primary: [{ title: "scroll", key: `${chromeGlyph("↑")}/${chromeGlyph("↓")}` }] }} />
192
+ + }
193
+ >
194
+ <scrollbox
195
+ ref={(element: ScrollBoxRenderable) => (scroll = element)}
196
+ diff --git a/packages/tui/src/component/dialog-experiments.tsx b/packages/tui/src/component/dialog-experiments.tsx
197
+ index a8830000f88043ddaaafb100cf080932536cebcc..db00317858710f7e8ae08225b858e1d825591c43 100644
198
+ --- a/packages/tui/src/component/dialog-experiments.tsx
199
+ +++ b/packages/tui/src/component/dialog-experiments.tsx
200
+ @@ -3,6 +3,7 @@ import { useConfig, useNewDesigns } from "../config"
201
+ import { DIALOG_LIST_INSET } from "../ui/dialog"
202
+ import { DialogSelect } from "../ui/dialog-select"
203
+ import { useTheme } from "../context/theme"
204
+ +import { chromeGlyph } from "../ui/glyphs"
205
+ import { useToast } from "../ui/toast"
206
+
207
+ type Experiment = {
208
+ @@ -68,7 +69,11 @@ export function DialogExperiments() {
209
+ <text fg={theme.text.muted}>No experiments available</text>
210
+ </box>
211
+ }
212
+ - footerHints={experiments.length > 0 && !newDesigns() ? [{ title: "change", label: "←/→" }] : []}
213
+ + footerHints={
214
+ + experiments.length > 0 && !newDesigns()
215
+ + ? [{ title: "change", label: `${chromeGlyph("←")}/${chromeGlyph("→")}` }]
216
+ + : []
217
+ + }
218
+ bindings={
219
+ experiments.length > 0
220
+ ? [
221
+ diff --git a/packages/tui/src/component/dialog-highlights.tsx b/packages/tui/src/component/dialog-highlights.tsx
222
+ index d11506eae7ababa0b7b7f8cbda571cc330296252..b04827d3b4ed44733931c2ea3d1ae1ef8f1dbd3e 100644
223
+ --- a/packages/tui/src/component/dialog-highlights.tsx
224
+ +++ b/packages/tui/src/component/dialog-highlights.tsx
225
+ @@ -6,6 +6,7 @@ import { Keymap } from "../context/keymap"
226
+ import type { TuiHighlight } from "../context/runtime"
227
+ import { useTheme } from "../context/theme"
228
+ import { DIALOG_INSET, DialogBleed, DialogFooter, DialogHeader, DialogLayout, useDialog } from "../ui/dialog"
229
+ +import { chromeGlyph } from "../ui/glyphs"
230
+ import { getScrollAcceleration } from "../util/scroll"
231
+
232
+ export function DialogHighlights(props: { version: string; items: ReadonlyArray<TuiHighlight> }) {
233
+ @@ -38,7 +39,7 @@ export function DialogHighlights(props: { version: string; items: ReadonlyArray<
234
+ footer={{ actions: { primary: [{ title: "ok", key: "enter", onTrigger: () => dialog.clear() }] } }}
235
+ legacyFooter={
236
+ <box flexDirection="row" justifyContent="space-between" alignItems="center">
237
+ - <DialogFooter actions={{ secondary: [{ title: "scroll", key: "↑/↓" }] }} />
238
+ + <DialogFooter actions={{ secondary: [{ title: "scroll", key: `${chromeGlyph("↑")}/${chromeGlyph("↓")}` }] }} />
239
+ <box
240
+ paddingLeft={3}
241
+ paddingRight={3}
242
+ diff --git a/packages/tui/src/component/dialog-image-preview.tsx b/packages/tui/src/component/dialog-image-preview.tsx
243
+ index f2bdd1d9e1b642dcd8707d585735f45fa6996a76..ae7811480ed9c02dbc9de58c1b38bc536d36ad23 100644
244
+ --- a/packages/tui/src/component/dialog-image-preview.tsx
245
+ +++ b/packages/tui/src/component/dialog-image-preview.tsx
246
+ @@ -2,6 +2,7 @@ import { useTerminalDimensions } from "@opentui/solid"
247
+ import { createMemo, createSignal } from "solid-js"
248
+ import { Keymap } from "../context/keymap"
249
+ import { useTheme } from "../context/theme"
250
+ +import { chromeGlyph } from "../ui/glyphs"
251
+ import { DialogHeader, DialogLayout, useDialog } from "../ui/dialog"
252
+
253
+ type ImagePreviewItem = Readonly<{
254
+ @@ -44,8 +45,8 @@ export function DialogImagePreview(props: { images: readonly ImagePreviewItem[];
255
+ primary:
256
+ props.images.length > 1
257
+ ? [
258
+ - { title: "previous", key: "←", onTrigger: () => move(-1) },
259
+ - { title: "next", key: "→", onTrigger: () => move(1) },
260
+ + { title: "previous", key: chromeGlyph("←"), onTrigger: () => move(-1) },
261
+ + { title: "next", key: chromeGlyph("→"), onTrigger: () => move(1) },
262
+ ]
263
+ : [],
264
+ },
265
+ @@ -60,13 +61,13 @@ export function DialogImagePreview(props: { images: readonly ImagePreviewItem[];
266
+ legacyFooter={
267
+ <box flexDirection="row" justifyContent="space-between">
268
+ <text fg={theme.text.muted} onMouseUp={() => move(-1)}>
269
+ - {props.images.length > 1 ? "← previous" : ""}
270
+ + {props.images.length > 1 ? `${chromeGlyph("←")} previous` : ""}
271
+ </text>
272
+ <text fg={failed() ? theme.text.feedback.error.base : theme.text.muted} wrapMode="none" truncate>
273
+ {failed() ? "No preview" : (current().mention?.text ?? `Image ${index() + 1}`)}
274
+ </text>
275
+ <text fg={theme.text.muted} onMouseUp={() => move(1)}>
276
+ - {props.images.length > 1 ? "next →" : ""}
277
+ + {props.images.length > 1 ? `next ${chromeGlyph("→")}` : ""}
278
+ </text>
279
+ </box>
280
+ }
281
+ diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx
282
+ index 183e8df3af9f5449fd4e7dfc66f157071df31fb4..b346ae61839431fa60dc3e82dcf62aeb28778ac9 100644
283
+ --- a/packages/tui/src/component/dialog-integration.tsx
284
+ +++ b/packages/tui/src/component/dialog-integration.tsx
285
+ @@ -33,6 +33,7 @@ import {
286
+ } from "../ui/dialog"
287
+ import { DialogPrompt } from "../ui/dialog-prompt"
288
+ import { DialogSelect } from "../ui/dialog-select"
289
+ +import { chromeGlyph, chromeLabel } from "../ui/glyphs"
290
+ import { Link } from "../ui/link"
291
+ import { useToast } from "../ui/toast"
292
+ import { errorMessage } from "../util/error"
293
+ @@ -135,13 +136,13 @@ export function DialogIntegration(
294
+ title: integration.name,
295
+ value: integration.id,
296
+ description: methods.length === 0 ? "Environment only" : undefined,
297
+ - footer: status ? "Sign in required →" : connectionSummary(integration) || undefined,
298
+ + footer: status ? `Sign in required ${chromeGlyph("→")}` : connectionSummary(integration) || undefined,
299
+ footerColor: status ? theme.text.feedback.warning.base : undefined,
300
+ category,
301
+ disabled: methods.length === 0 && credentials.length === 0,
302
+ gutter:
303
+ integration.connections.length > 0 && !status
304
+ - ? () => <text fg={theme.text.feedback.success.base}>✓</text>
305
+ + ? () => <text fg={theme.text.feedback.success.base}>{chromeGlyph("✓")}</text>
306
+ : undefined,
307
+ onSelect: () => {
308
+ if (credentials.length) return manageConnections(integration, methods, location, dialog, props.onConnected)
309
+ @@ -218,7 +219,7 @@ function manageConnections(
310
+ ? `Press ${shortcuts.get("dialog.integration.delete")} again to confirm`
311
+ : connection.label,
312
+ value: connection.id,
313
+ - footer: connection.status ? "Sign in required →" : undefined,
314
+ + footer: connection.status ? `Sign in required ${chromeGlyph("→")}` : undefined,
315
+ footerColor: connection.status ? theme.text.feedback.warning.base : undefined,
316
+ category: "Connected accounts",
317
+ bg: confirming ? theme.background.action.destructive.focused : undefined,
318
+ @@ -381,7 +382,7 @@ function ExternalStarting(props: {
319
+ dialog.clear()
320
+ })
321
+ })
322
+ - return <OAuthView title={props.method.label} message="Connecting…" />
323
+ + return <OAuthView title={props.method.label} message={chromeLabel("Connecting…")} />
324
+ }
325
+
326
+ async function beginKey(
327
+ @@ -455,7 +456,7 @@ function CommandStarting(props: {
328
+ if (!handedOff) closed = true
329
+ })
330
+
331
+ - return <CommandView title={props.method.label} output="" message="Starting command…" />
332
+ + return <CommandView title={props.method.label} output="" message={chromeLabel("Starting command…")} />
333
+ }
334
+
335
+ function CommandPending(props: {
336
+ @@ -516,7 +517,7 @@ function CommandPending(props: {
337
+ })
338
+ })
339
+
340
+ - return <CommandView title={props.title} output={output()} message="Waiting for command to finish…" />
341
+ + return <CommandView title={props.title} output={output()} message={chromeLabel("Waiting for command to finish…")} />
342
+ }
343
+
344
+ function CommandView(props: { title: string; output: string; message: string }) {
345
+ @@ -649,7 +650,7 @@ function OAuthStarting(props: {
346
+ })
347
+ })
348
+
349
+ - return <OAuthView title={props.method.label} message="Starting authorization…" />
350
+ + return <OAuthView title={props.method.label} message={chromeLabel("Starting authorization…")} />
351
+ }
352
+
353
+ function OAuthAuto(props: {
354
+ @@ -737,7 +738,7 @@ function OAuthAuto(props: {
355
+ title={props.title}
356
+ url={props.attempt.url}
357
+ instructions={props.attempt.instructions}
358
+ - message="Waiting for authorization…"
359
+ + message={chromeLabel("Waiting for authorization…")}
360
+ onCopy={copy}
361
+ onOpen={open}
362
+ />
363
+ @@ -1013,7 +1014,7 @@ function StringChoiceField(props: {
364
+ { title: "back", key: "esc", onTrigger: back },
365
+ ]
366
+ : [
367
+ - { title: "select", key: "↑/↓" },
368
+ + { title: "select", key: `${chromeGlyph("↑")}/${chromeGlyph("↓")}` },
369
+ { title: "confirm", key: "enter", onTrigger: () => select(store.selected) },
370
+ { title: "cancel", key: "esc", onTrigger: () => dialog.clear() },
371
+ ],
372
+ @@ -1232,7 +1233,7 @@ async function externalAnswer(
373
+ if (choice === true) return true
374
+ const result = await new Promise<boolean | typeof CANCELLED>((resolve) => {
375
+ dialog.replace(
376
+ - () => <OAuthView title={formLabel(field) || title} message="Opening link…" />,
377
+ + () => <OAuthView title={formLabel(field) || title} message={chromeLabel("Opening link…")} />,
378
+ () => resolve(CANCELLED),
379
+ )
380
+ void openUrl(field.url).then(
381
+ diff --git a/packages/tui/src/component/dialog-mcp.tsx b/packages/tui/src/component/dialog-mcp.tsx
382
+ index 68e3778b42ffebbdf0138a05016a58c06cb20dda..aa0582b778e0fb93ad32bcaad13f3357302ed73c 100644
383
+ --- a/packages/tui/src/component/dialog-mcp.tsx
384
+ +++ b/packages/tui/src/component/dialog-mcp.tsx
385
+ @@ -5,6 +5,7 @@ import { pipe, sortBy } from "remeda"
386
+ import { DialogSelect } from "../ui/dialog-select"
387
+ import { useDialog } from "../ui/dialog"
388
+ import { useTheme } from "../context/theme"
389
+ +import { chromeGlyph, chromeLabel } from "../ui/glyphs"
390
+ import { TextAttributes } from "@opentui/core"
391
+ import type { McpServer } from "@opencode/client"
392
+ import { useToast } from "../ui/toast"
393
+ @@ -19,18 +20,18 @@ function statusError(status: McpServer["status"]) {
394
+
395
+ function Status(props: { status: McpServer["status"]; loading: boolean }) {
396
+ if (props.loading || props.status.status === "pending") {
397
+ - return <>Connecting …</>
398
+ + return <>{chromeLabel("Connecting …")}</>
399
+ }
400
+ if (props.status.status === "connected") {
401
+ - return <span style={{ attributes: TextAttributes.BOLD }}>Connected ✓</span>
402
+ + return <span style={{ attributes: TextAttributes.BOLD }}>Connected {chromeGlyph("✓")}</span>
403
+ }
404
+ if (props.status.status === "failed") {
405
+ - return <>Failed !</>
406
+ + return <>Failed {chromeGlyph("✗")}</>
407
+ }
408
+ if (props.status.status === "needs_auth") {
409
+ - return <>Sign in required →</>
410
+ + return <>Sign in required {chromeGlyph("→")}</>
411
+ }
412
+ - return <>Disabled ○</>
413
+ + return <>Disabled {chromeGlyph("○")}</>
414
+ }
415
+
416
+ export function DialogMcp(props: { initialServer?: string; details?: boolean } = {}) {
417
+ diff --git a/packages/tui/src/component/dialog-open.tsx b/packages/tui/src/component/dialog-open.tsx
418
+ index f6d6412c1883ad5a7e61c6904fb17e653c0ea86c..ddab0c6ff55cb6dd1d68cd3015aaea9d0ce8d3ee 100644
419
+ --- a/packages/tui/src/component/dialog-open.tsx
420
+ +++ b/packages/tui/src/component/dialog-open.tsx
421
+ @@ -23,6 +23,7 @@ import { withTimestampedFallback } from "@opencode/util/session-title-fallback"
422
+ import { sessionStatusGutter } from "./dialog-session-list"
423
+ import { Spinner } from "./spinner"
424
+ import { projectName } from "../util/project"
425
+ +import { chromeEllipsis, chromeGlyph, chromeLabel, chromeStatusSeparator } from "../ui/glyphs"
426
+
427
+ const RECENT_LIMIT = 8
428
+ export const DialogOpenKey = Symbol("DialogOpen")
429
+ @@ -211,7 +212,7 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
430
+ const basename = path.basename(session.location.directory)
431
+ const label =
432
+ name && session.location.directory !== project?.canonical && name.toLowerCase() !== basename.toLowerCase()
433
+ - ? `${name} · ${basename}`
434
+ + ? `${name} ${chromeGlyph("·")} ${basename}`
435
+ : name || basename
436
+ const state = attention(session.id)
437
+ const running =
438
+ @@ -223,13 +224,13 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
439
+ searchText: `${session.id} ${session.location.directory}`,
440
+ value: { type: "session", sessionID: session.id } as OpenTarget,
441
+ category: "Sessions",
442
+ - footer: `${label ? `${Locale.truncate(label, 30)} · ` : ""}${timeAgo(session.time.updated)}`,
443
+ + footer: `${label ? `${Locale.truncate(label, 30, chromeEllipsis())} ${chromeGlyph("·")} ` : ""}${timeAgo(session.time.updated)}`,
444
+ onSelect: () => location.set(session.location),
445
+ gutter: sessionStatusGutter(
446
+ theme,
447
+ state,
448
+ running,
449
+ - tabs.has(session.id) ? () => <text fg={theme.hue.accent[200]}>▪</text> : undefined,
450
+ + tabs.has(session.id) ? () => <text fg={theme.hue.accent[200]}>{chromeGlyph("▪")}</text> : undefined,
451
+ ),
452
+ }
453
+ })
454
+ @@ -254,7 +255,7 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
455
+ (git ? 2 : 0)
456
+ return {
457
+ title,
458
+ - footer: `${truncateFilePath(footer, width)}${git ? " →" : ""}`,
459
+ + footer: `${truncateFilePath(footer, width)}${git ? ` ${chromeGlyph("→")}` : ""}`,
460
+ searchText: `${footer} ${projectName(item.project) ?? ""}`,
461
+ value: {
462
+ type: "project",
463
+ @@ -265,7 +266,7 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
464
+ gutter:
465
+ item.directory === current.directory ||
466
+ item.directory === location.current?.project.canonical
467
+ - ? () => <text fg={theme.text.formfield.selected}>●</text>
468
+ + ? () => <text fg={theme.text.formfield.selected}>{chromeGlyph("●")}</text>
469
+ : undefined,
470
+ }
471
+ })
472
+ @@ -316,7 +317,7 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
473
+ footer: footer + " ".repeat(Math.max(0, width - stringWidth(footer))),
474
+ value: { type: "project", directory } as OpenTarget,
475
+ gutter:
476
+ - directory === current.directory ? () => <text fg={theme.text.formfield.selected}>●</text> : undefined,
477
+ + directory === current.directory ? () => <text fg={theme.text.formfield.selected}>{chromeGlyph("●")}</text> : undefined,
478
+ }
479
+ })
480
+ })
481
+ @@ -336,7 +337,7 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
482
+ restore(previous)
483
+ }}
484
+ title={projectID() ? `${projectName(data.project.get(projectID()!)) ?? "Project"} / Worktrees` : "Open"}
485
+ - placeholder={projectID() ? "Search worktrees…" : "Search sessions and projects…"}
486
+ + placeholder={projectID() ? chromeLabel("Search worktrees…") : chromeLabel("Search sessions and projects…")}
487
+ options={projectID() ? worktreeOptions() : options()}
488
+ current={
489
+ currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined
490
+ @@ -367,10 +368,10 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
491
+ >
492
+ <box>
493
+ <Show when={projectID() && worktrees.loading}>
494
+ - <Spinner color={theme.text.muted}>Loading worktrees…</Spinner>
495
+ + <Spinner color={theme.text.muted}>{chromeLabel("Loading worktrees…")}</Spinner>
496
+ </Show>
497
+ <Show when={!projectID() && (recent.loading || projects.loading)}>
498
+ - <Spinner color={theme.text.muted}>Refreshing sessions and projects…</Spinner>
499
+ + <Spinner color={theme.text.muted}>{chromeLabel("Refreshing sessions and projects…")}</Spinner>
500
+ </Show>
501
+ <Show when={!projectID() && (recent() === false || projects() === false)}>
502
+ <text fg={theme.text.feedback.error.base}>
503
+ @@ -422,12 +423,12 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
504
+ <text fg={theme.text.muted}>
505
+ {projectID()
506
+ ? worktrees.loading
507
+ - ? "Loading worktrees…"
508
+ + ? chromeLabel("Loading worktrees…")
509
+ : "No matching worktrees"
510
+ : recent.loading || projects.loading || matched.loading
511
+ - ? "Searching sessions and projects…"
512
+ + ? chromeLabel("Searching sessions and projects…")
513
+ : shortcuts.get("session.list")
514
+ - ? `No matches · search all sessions with ${shortcuts.get("session.list")}`
515
+ + ? `No matches ${chromeStatusSeparator()} search all sessions with ${shortcuts.get("session.list")}`
516
+ : "No matches"}
517
+ </text>
518
+ </box>
519
+ @@ -453,7 +454,7 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
520
+ placeholder="Worktree name (optional)"
521
+ description={() => <text fg={theme.text.muted}>Leave blank for a random name.</text>}
522
+ busy={creating()}
523
+ - busyText="Creating worktree…"
524
+ + busyText={chromeLabel("Creating worktree…")}
525
+ onCancel={cancelCreation}
526
+ onConfirm={(value) => {
527
+ const id = projectID()!
528
+ diff --git a/packages/tui/src/component/dialog-pair.tsx b/packages/tui/src/component/dialog-pair.tsx
529
+ index 0bc0ce44b49ee4e332cbdf001ae8b02764c93973..5e9d875450ea17d1636083216bb94950f22d36f8 100644
530
+ --- a/packages/tui/src/component/dialog-pair.tsx
531
+ +++ b/packages/tui/src/component/dialog-pair.tsx
532
+ @@ -7,6 +7,7 @@ import { useTheme } from "../context/theme"
533
+ import { DialogHeader, DialogLayout, useDialog } from "../ui/dialog"
534
+ import { Link } from "../ui/link"
535
+ import { errorMessage } from "../util/error"
536
+ +import { chromeLabel } from "../ui/glyphs"
537
+
538
+ export function DialogPair() {
539
+ const client = useClient()
540
+ @@ -91,7 +92,7 @@ export function DialogPair() {
541
+ <Show
542
+ when={loadError()}
543
+ fallback={
544
+ - <Show when={info()} fallback={<text fg={theme.text.muted}>Loading server information…</text>}>
545
+ + <Show when={info()} fallback={<text fg={theme.text.muted}>{chromeLabel("Loading server information…")}</text>}>
546
+ <Show
547
+ when={dimensions().height >= 36}
548
+ fallback={
549
+ diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx
550
+ index d5fb5ac4f9b0cb717f649d4d43ed121f3aff66f6..5a59c284734c104248784af91bd8a4758682b6ac 100644
551
+ --- a/packages/tui/src/component/dialog-session-list.tsx
552
+ +++ b/packages/tui/src/component/dialog-session-list.tsx
553
+ @@ -23,6 +23,7 @@ import { useConfig, useNewDesigns } from "../config"
554
+ import { withTimestampedFallback } from "@opencode/util/session-title-fallback"
555
+ import { projectName } from "../util/project"
556
+ import { useLocation } from "../context/location"
557
+ +import { chromeEllipsis, chromeLabel } from "../ui/glyphs"
558
+
559
+ export function DialogSessionList() {
560
+ const dialog = useDialog()
561
+ @@ -111,7 +112,7 @@ export function DialogSessionList() {
562
+ const searchState = createMemo(() => {
563
+ const query = filter().trim()
564
+ if (query !== search().trim() || searchResults.loading)
565
+ - return { message: query ? "Searching sessions…" : "Loading sessions…", error: false }
566
+ + return { message: query ? chromeLabel("Searching sessions…") : chromeLabel("Loading sessions…"), error: false }
567
+ const result = searchResults()
568
+ if (result?.query === query && result.error)
569
+ return {
570
+ @@ -155,10 +156,7 @@ export function DialogSessionList() {
571
+ const project = data.project.get(session.projectID)
572
+ const root = session.subpath ? path.resolve(directory, ...session.subpath.split("/").map(() => "..")) : directory
573
+ const relative = path.relative(project?.canonical ?? root, root)
574
+ - const footer =
575
+ - relative.startsWith("..") || path.isAbsolute(relative)
576
+ - ? Locale.truncate(path.basename(relative), 25)
577
+ - : undefined
578
+ + const footer = sessionListRelativePathFooter(relative)
579
+ const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
580
+ const deleting = toDelete() === session.id
581
+ const attention = sessionTabs.status(session.id).attention
582
+ @@ -298,6 +296,12 @@ export function DialogSessionList() {
583
+ )
584
+ }
585
+
586
+ +export function sessionListRelativePathFooter(relative: string) {
587
+ + return relative.startsWith("..") || path.isAbsolute(relative)
588
+ + ? Locale.truncate(path.basename(relative), 25, chromeEllipsis())
589
+ + : undefined
590
+ +}
591
+ +
592
+ function quickSwitchRange(first: string, last: string) {
593
+ const prefix = first.slice(0, -1)
594
+ if (first.endsWith("1") && last === `${prefix}9`) return `${prefix}1-9`
595
+ diff --git a/packages/tui/src/component/dialog-shell-output.tsx b/packages/tui/src/component/dialog-shell-output.tsx
596
+ index 42f0c98d1b0cd7e0f8244f71da4db6748e1621d3..4ba60790d24040227d4bcdc32e611330db8bb842 100644
597
+ --- a/packages/tui/src/component/dialog-shell-output.tsx
598
+ +++ b/packages/tui/src/component/dialog-shell-output.tsx
599
+ @@ -7,9 +7,14 @@ import { useClient } from "../context/client"
600
+ import { Keymap } from "../context/keymap"
601
+ import { useTheme } from "../context/theme"
602
+ import { DialogFooter, DialogHeader, DialogLayout, useDialog } from "../ui/dialog"
603
+ +import { chromeGlyph, chromeLabel } from "../ui/glyphs"
604
+
605
+ const PAGE_BYTES = 64 * 1024
606
+
607
+ +export function shellOutputOmittedLabel() {
608
+ + return `Earlier output omitted ${chromeGlyph("·")} showing recent output`
609
+ +}
610
+ +
611
+ export function DialogShellOutput(props: { shell: ShellInfo; location: LocationRef }) {
612
+ const client = useClient()
613
+ const dialog = useDialog()
614
+ @@ -65,7 +70,7 @@ export function DialogShellOutput(props: { shell: ShellInfo; location: LocationR
615
+ .catch((cause: unknown) => {
616
+ if (disposed) return
617
+ missing = isShellNotFoundError(cause)
618
+ - setError(missing ? "Shell output is no longer available." : "Unable to read shell output. Retrying…")
619
+ + setError(missing ? "Shell output is no longer available." : chromeLabel("Unable to read shell output. Retrying…"))
620
+ })
621
+ .then((more) => {
622
+ // Poll only while the viewer is open, including after exit so the final
623
+ @@ -84,7 +89,7 @@ export function DialogShellOutput(props: { shell: ShellInfo; location: LocationR
624
+ if (info().status === "running") return "Running"
625
+ if (info().status === "timeout") return "Timed out"
626
+ if (info().status === "killed") return "Killed"
627
+ - return info().exit === undefined ? "Exited" : `Exited · code ${info().exit}`
628
+ + return info().exit === undefined ? "Exited" : `Exited ${chromeGlyph("·")} code ${info().exit}`
629
+ }
630
+
631
+ Keymap.createLayer(() => ({
632
+ @@ -117,7 +122,7 @@ export function DialogShellOutput(props: { shell: ShellInfo; location: LocationR
633
+ <DialogFooter
634
+ actions={{
635
+ secondary: [
636
+ - { title: "scroll", key: "↑/↓" },
637
+ + { title: "scroll", key: `${chromeGlyph("↑")}/${chromeGlyph("↓")}` },
638
+ { title: "follow", key: "end" },
639
+ { title: "back", key: "esc" },
640
+ ],
641
+ @@ -130,7 +135,7 @@ export function DialogShellOutput(props: { shell: ShellInfo; location: LocationR
642
+ {props.shell.command}
643
+ </text>
644
+ <Show when={omitted()}>
645
+ - <text fg={theme.text.muted}>Earlier output omitted · showing recent output</text>
646
+ + <text fg={theme.text.muted}>{shellOutputOmittedLabel()}</text>
647
+ </Show>
648
+ <scrollbox
649
+ id="shell-output-scroll"
650
+ @@ -143,7 +148,7 @@ export function DialogShellOutput(props: { shell: ShellInfo; location: LocationR
651
+ <text fg={theme.text.base} wrapMode="word">
652
+ {text() ||
653
+ (output() === undefined
654
+ - ? "Loading output…"
655
+ + ? "Loading output..."
656
+ : "No captured output. Output redirected to files is not shown here.")}
657
+ </text>
658
+ </scrollbox>
659
+ diff --git a/packages/tui/src/component/dialog-skill.tsx b/packages/tui/src/component/dialog-skill.tsx
660
+ index 5e45e628b1d72178fd6574922d0ef4016bb2b8f3..6d3d8ebb6cb6c0461f9789ae712b2dbdf0fa2435 100644
661
+ --- a/packages/tui/src/component/dialog-skill.tsx
662
+ +++ b/packages/tui/src/component/dialog-skill.tsx
663
+ @@ -6,6 +6,7 @@ import { useTheme } from "../context/theme"
664
+ import { errorMessage } from "../util/error"
665
+ import { useData } from "../context/data"
666
+ import type { LocationRef } from "@opencode/client"
667
+ +import { chromeLabel } from "../ui/glyphs"
668
+
669
+ export type DialogSkillProps = {
670
+ location?: LocationRef
671
+ @@ -78,7 +79,7 @@ export function DialogSkill(props: DialogSkillProps) {
672
+ </Match>
673
+ <Match when={skills.loading}>
674
+ <box paddingLeft={DIALOG_LIST_INSET} paddingRight={DIALOG_LIST_INSET}>
675
+ - <text fg={theme.text.muted}>Loading skills…</text>
676
+ + <text fg={theme.text.muted}>{chromeLabel("Loading skills…")}</text>
677
+ </box>
678
+ </Match>
679
+ </Switch>
680
+ diff --git a/packages/tui/src/component/dialog-stash.tsx b/packages/tui/src/component/dialog-stash.tsx
681
+ index 4113c7fd969f4d79709b049cada13a3faf7b8832..f80c7e9f35da2f2bb82083f0da4ada1ca965be19 100644
682
+ --- a/packages/tui/src/component/dialog-stash.tsx
683
+ +++ b/packages/tui/src/component/dialog-stash.tsx
684
+ @@ -5,6 +5,7 @@ import { Locale } from "../util/locale"
685
+ import { Keymap } from "../context/keymap"
686
+ import { useTheme } from "../context/theme"
687
+ import { usePromptStash, type StashEntry } from "../prompt/stash"
688
+ +import { chromeEllipsis } from "../ui/glyphs"
689
+
690
+ function getRelativeTime(timestamp: number): string {
691
+ const now = Date.now()
692
+ @@ -23,7 +24,7 @@ function getRelativeTime(timestamp: number): string {
693
+
694
+ function getStashPreview(input: string, maxLength: number = 50): string {
695
+ const firstLine = input.split("\n")[0].trim()
696
+ - return Locale.truncate(firstLine, maxLength)
697
+ + return Locale.truncate(firstLine, maxLength, chromeEllipsis())
698
+ }
699
+
700
+ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
701
+ diff --git a/packages/tui/src/component/dialog-status.tsx b/packages/tui/src/component/dialog-status.tsx
702
+ index f94f906cdf9d187234c24f575450d8ad13e3a559..2495eaf55ba47680be16e64a4110a99c0d11e116 100644
703
+ --- a/packages/tui/src/component/dialog-status.tsx
704
+ +++ b/packages/tui/src/component/dialog-status.tsx
705
+ @@ -2,6 +2,7 @@ import { useTheme } from "../context/theme"
706
+ import { DialogHeader, DialogLayout } from "../ui/dialog"
707
+ import { useData } from "../context/data"
708
+ import { For, Match, Switch, Show, createMemo } from "solid-js"
709
+ +import { chromeGlyph } from "../ui/glyphs"
710
+
711
+ export function DialogStatus() {
712
+ const data = useData()
713
+ @@ -25,7 +26,7 @@ export function DialogStatus() {
714
+ {(item) => (
715
+ <box flexDirection="row" gap={1}>
716
+ <text flexShrink={0} style={{ fg: color(item.status.status) }}>
717
+ - •
718
+ + {chromeGlyph("•")}
719
+ </text>
720
+ <text fg={theme.text.base} wrapMode="word">
721
+ <b>{item.name}</b>{" "}
722
+ diff --git a/packages/tui/src/component/dialog-update.tsx b/packages/tui/src/component/dialog-update.tsx
723
+ index 9dcf07b21402bea0548420e0b53cd84f08f90cc3..54469a0b69bdb306414819c50b65b52d4356b08d 100644
724
+ --- a/packages/tui/src/component/dialog-update.tsx
725
+ +++ b/packages/tui/src/component/dialog-update.tsx
726
+ @@ -6,6 +6,7 @@ import type { UpdateState } from "../context/update-notification"
727
+ import { DialogHeader, DialogLayout, useDialog } from "../ui/dialog"
728
+ import { errorMessage } from "../util/error"
729
+ import { Spinner } from "./spinner"
730
+ +import { chromeEllipsis, chromeLabel } from "../ui/glyphs"
731
+
732
+ export function DialogUpdate(props: {
733
+ check?: (signal: AbortSignal) => Promise<string | undefined>
734
+ @@ -133,7 +134,7 @@ export function DialogUpdate(props: {
735
+ {(current) => (
736
+ <Switch>
737
+ <Match when={current.type === "checking"}>
738
+ - <Spinner shimmer={theme.text.base}>Checking for updates…</Spinner>
739
+ + <Spinner shimmer={theme.text.base}>{chromeLabel("Checking for updates…")}</Spinner>
740
+ </Match>
741
+ <Match when={current.type === "available"}>
742
+ <text fg={theme.text.muted}>
743
+ @@ -142,7 +143,7 @@ export function DialogUpdate(props: {
744
+ </Match>
745
+ <Match when={current.type === "installing"}>
746
+ <Spinner shimmer={theme.text.base}>
747
+ - {current.type === "installing" ? `Installing OpenCode ${current.version}…` : ""}
748
+ + {current.type === "installing" ? `Installing OpenCode ${current.version}${chromeEllipsis()}` : ""}
749
+ </Spinner>
750
+ </Match>
751
+ <Match when={current.type === "installed"}>
752
+ diff --git a/packages/tui/src/component/dialog-workspaces.tsx b/packages/tui/src/component/dialog-workspaces.tsx
753
+ index 70aaa8ff4bf2da36c787e59235af509391030dbe..4f3a04971459af5e93b10268573629a5a0ebf165 100644
754
+ --- a/packages/tui/src/component/dialog-workspaces.tsx
755
+ +++ b/packages/tui/src/component/dialog-workspaces.tsx
756
+ @@ -15,6 +15,7 @@ import { errorMessage } from "../util/error"
757
+ import { isRecord } from "../util/record"
758
+ import { useToast } from "../ui/toast"
759
+ import { Spinner } from "./spinner"
760
+ +import { chromeEllipsis, chromeLabel } from "../ui/glyphs"
761
+ import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
762
+ import type { WorktreeListOutput } from "@opencode/client"
763
+ import { useRoute } from "../context/route"
764
+ @@ -168,7 +169,7 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
765
+ const title = abbreviateHome(item.location, paths.home)
766
+ const suffix =
767
+ item.location === item.root.directory ? undefined : path.sep + path.relative(item.root.directory, item.location)
768
+ - const visible = Locale.truncateLeft(title, titleWidth)
769
+ + const visible = Locale.truncateLeft(title, titleWidth, chromeEllipsis())
770
+ const split = suffix ? Math.max(0, visible.length - suffix.length) : visible.length
771
+ const deleting = toDelete() === item.location
772
+ const isRemoving = removing() === item.location
773
+ @@ -350,7 +351,7 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
774
+ </box>
775
+ ) : directories.loading || loadedProject.loading ? (
776
+ <box paddingLeft={DIALOG_LIST_INSET} paddingRight={DIALOG_LIST_INSET}>
777
+ - <text fg={theme.text.muted}>Loading worktrees…</text>
778
+ + <text fg={theme.text.muted}>{chromeLabel("Loading worktrees…")}</text>
779
+ </box>
780
+ ) : (
781
+ <box paddingLeft={DIALOG_LIST_INSET} paddingRight={DIALOG_LIST_INSET}>
782
+ diff --git a/packages/tui/src/component/error-component.tsx b/packages/tui/src/component/error-component.tsx
783
+ index 48ad4b107d755a296266c89ceb7479b6bd771b18..b4e46b9d8ebc3396009e9093be88f71a6c4967bc 100644
784
+ --- a/packages/tui/src/component/error-component.tsx
785
+ +++ b/packages/tui/src/component/error-component.tsx
786
+ @@ -6,6 +6,7 @@ import { useClipboard } from "../context/clipboard"
787
+ import { useExit } from "../context/exit"
788
+ import { useTuiApp } from "../context/runtime"
789
+ import { describeOS, describeTerminal } from "../util/system"
790
+ +import { chromeBorderChars, chromeGlyph, chromeLabel } from "../ui/glyphs"
791
+
792
+ export function ErrorComponent(props: { error: Error; reset: () => void; mode?: "dark" | "light" }) {
793
+ const term = useTerminalDimensions()
794
+ @@ -55,7 +56,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
795
+ const actions = [
796
+ {
797
+ key: "c",
798
+ - label: () => ({ idle: "Copy report", copied: "✓ Copied", failed: "Copy failed" })[copyState()],
799
+ + label: () => ({ idle: "Copy report", copied: `${chromeGlyph("✓")} Copied`, failed: "Copy failed" })[copyState()],
800
+ copy: true,
801
+ onUse: copyReport,
802
+ },
803
+ @@ -129,6 +130,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
804
+ flexShrink={0}
805
+ border
806
+ borderStyle="rounded"
807
+ + customBorderChars={chromeBorderChars()}
808
+ borderColor={colors.error}
809
+ title=" Error "
810
+ titleColor={colors.error}
811
+ @@ -183,10 +185,11 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
812
+ minHeight={3}
813
+ border
814
+ borderStyle="rounded"
815
+ + customBorderChars={chromeBorderChars()}
816
+ borderColor={colors.borderSubtle}
817
+ title=" Stack trace "
818
+ titleColor={colors.muted}
819
+ - bottomTitle=" ↑↓ scroll "
820
+ + bottomTitle={` ${chromeGlyph("↑")}${chromeGlyph("↓")} scroll `}
821
+ bottomTitleAlignment="right"
822
+ paddingLeft={1}
823
+ paddingRight={1}
824
+ @@ -205,7 +208,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
825
+ <box flexDirection="column" alignItems="center" flexShrink={0}>
826
+ <text fg={colors.muted}>
827
+ {copyState() === "copied"
828
+ - ? "Report copied — paste it into a new GitHub issue."
829
+ + ? chromeLabel("Report copied — paste it into a new GitHub issue.")
830
+ : copyState() === "failed"
831
+ ? "Clipboard write failed. Try again or report the crash manually."
832
+ : "Copy the report and open a GitHub issue to help us fix this."}
833
+ @@ -237,7 +240,7 @@ function buildIssueURL(message: string, stack: string, version: string) {
834
+ // clipped trace is obvious. searchParams.set handles encoding without throwing,
835
+ // so measuring url.toString() is both correct and safe on any input.
836
+ const MAX_URL_LENGTH = 6000
837
+ - const marker = "\n… (truncated)"
838
+ + const marker = `\n${chromeGlyph("…")} (truncated)`
839
+ const head = `The OpenCode TUI crashed with an unexpected error.\n\n**Error:** ${message}\n\n**Stack trace:**\n`
840
+ const setBody = (body: string) => url.searchParams.set("description", head + "```\n" + body + "\n```")
841
+
842
+ diff --git a/packages/tui/src/component/logo.tsx b/packages/tui/src/component/logo.tsx
843
+ index 2c3abc3cb0e8081ad654802d18e4eb93a08362a8..e9e4e3bf9567d423d68239c5157bda858ae530b4 100644
844
+ --- a/packages/tui/src/component/logo.tsx
845
+ +++ b/packages/tui/src/component/logo.tsx
846
+ @@ -4,10 +4,18 @@ import { useTerminalDimensions } from "@opentui/solid"
847
+ import { useTheme } from "../context/theme"
848
+ import { tint } from "../theme/color"
849
+ import { go, logo } from "../logo"
850
+ +import { ASCII_LOGO, asciiGlyphs } from "../ui/glyphs"
851
+
852
+ export function Logo() {
853
+ const theme = useTheme()
854
+ const dimensions = useTerminalDimensions()
855
+ + const artwork = () => {
856
+ + if (!asciiGlyphs()) return { logo, go }
857
+ + return {
858
+ + logo: { left: ASCII_LOGO.left, right: ASCII_LOGO.right },
859
+ + go: { left: ASCII_LOGO.compact, right: ASCII_LOGO.compact },
860
+ + }
861
+ + }
862
+
863
+ const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
864
+ const shadow = tint(theme.background.base, fg, 0.25)
865
+ @@ -52,24 +60,24 @@ export function Logo() {
866
+ return (
867
+ <box>
868
+ {dimensions().height < 12 ? null : dimensions().width < 22 ? (
869
+ - <For each={go.right.slice(1)}>
870
+ + <For each={artwork().go.right.slice(1)}>
871
+ {(line) => <box flexDirection="row">{renderLine(line, theme.text.base, true)}</box>}
872
+ </For>
873
+ ) : dimensions().width < 44 ? (
874
+ <>
875
+ - <For each={logo.left.slice(1)}>
876
+ + <For each={artwork().logo.left.slice(1)}>
877
+ {(line) => <box flexDirection="row">{renderLine(line, theme.text.muted, false)}</box>}
878
+ </For>
879
+ - <For each={logo.right}>
880
+ + <For each={artwork().logo.right}>
881
+ {(line) => <box flexDirection="row">{renderLine(line, theme.text.base, true)}</box>}
882
+ </For>
883
+ </>
884
+ ) : (
885
+ - <For each={logo.left}>
886
+ + <For each={artwork().logo.left}>
887
+ {(line, index) => (
888
+ <box flexDirection="row" gap={1}>
889
+ <box flexDirection="row">{renderLine(line, theme.text.muted, false)}</box>
890
+ - <box flexDirection="row">{renderLine(logo.right[index()], theme.text.base, true)}</box>
891
+ + <box flexDirection="row">{renderLine(artwork().logo.right[index()] ?? "", theme.text.base, true)}</box>
892
+ </box>
893
+ )}
894
+ </For>
895
+ diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx
896
+ index c669491c096edd5435ea8c976f1300282f35f334..a2b19c7ed3555c35d3b0ac2f1401760ed30eeee5 100644
897
+ --- a/packages/tui/src/component/prompt/autocomplete.tsx
898
+ +++ b/packages/tui/src/component/prompt/autocomplete.tsx
899
+ @@ -14,6 +14,7 @@ import { useConfig } from "../../config"
900
+ import { useLocation } from "../../context/location"
901
+ import { useTheme } from "../../context/theme"
902
+ import { SplitBorder } from "../../ui/border"
903
+ +import { chromeEllipsis, chromeLabel } from "../../ui/glyphs"
904
+ import { useTerminalDimensions } from "@opentui/solid"
905
+ import { Locale } from "../../util/locale"
906
+ import type { PromptInfo, PromptPartRef } from "../../prompt/history"
907
+ @@ -345,7 +346,7 @@ export function Autocomplete(props: {
908
+ : { options: [], failed: true, mode: input.visible, query: input.query, resolved: false }
909
+ return {
910
+ options: result.map((item) => ({
911
+ - display: Locale.truncateMiddle(item.value, width),
912
+ + display: Locale.truncateMiddle(item.value, width, chromeEllipsis()),
913
+ value: item.value,
914
+ isDirectory: true,
915
+ path: item.value,
916
+ @@ -374,7 +375,7 @@ export function Autocomplete(props: {
917
+ ...result.data.map((item): AutocompleteOption => {
918
+ const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange)
919
+ return {
920
+ - display: Locale.truncateMiddle(filename, width),
921
+ + display: Locale.truncateMiddle(filename, width, chromeEllipsis()),
922
+ value: filename,
923
+ isDirectory: item.type === "directory",
924
+ path: item.path,
925
+ @@ -511,7 +512,7 @@ export function Autocomplete(props: {
926
+ const value = item.value
927
+ return {
928
+ ...item,
929
+ - display: Locale.truncateMiddle(item.display, width),
930
+ + display: Locale.truncateMiddle(item.display, width, chromeEllipsis()),
931
+ onSelect: item.onSelect ?? (value ? () => insertDirectory(value) : undefined),
932
+ }
933
+ })
934
+ @@ -833,11 +834,11 @@ export function Autocomplete(props: {
935
+ const fileSearch = visibleFiles()
936
+ if (store.visible === "command") return "No matching commands"
937
+ if (store.visible === "directory") {
938
+ - if (files.loading) return "Searching…"
939
+ + if (files.loading) return chromeLabel("Searching…")
940
+ if (fileSearch.failed) return "Could not search directories. Keep typing to try again."
941
+ return "No matching directories"
942
+ }
943
+ - if (files.loading) return "Searching…"
944
+ + if (files.loading) return chromeLabel("Searching…")
945
+ if (fileSearch.failed) return "Could not search files. Keep typing to try again."
946
+ return "No matching files, agents, or references"
947
+ })
948
+ @@ -922,6 +923,7 @@ export function Autocomplete(props: {
949
+ {Locale.truncateMiddle(
950
+ confirmingAction() ? (destructive()?.confirm ?? "") : option().display,
951
+ contentWidth(),
952
+ + chromeEllipsis(),
953
+ )}
954
+ </text>
955
+ <Show when={!confirmingAction() && option().description}>
956
+ diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx
957
+ index a9f699c08ad2c2d50da1ae8b8a47c1997dc512b8..3cce36c2390c3de084411cf822d1eb6211893f0f 100644
958
+ --- a/packages/tui/src/component/prompt/index.tsx
959
+ +++ b/packages/tui/src/component/prompt/index.tsx
960
+ @@ -14,6 +14,7 @@ import { useTheme, useThemes } from "../../context/theme"
961
+ import { tint } from "../../theme/color"
962
+ import { createAnimatable, tween } from "../../ui/animation"
963
+ import { EmptyBorder, SplitBorder } from "../../ui/border"
964
+ +import { asciiGlyphs, chromeEllipsis, chromeGlyph, chromeLabel } from "../../ui/glyphs"
965
+ import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
966
+ import { useClipboard } from "../../context/clipboard"
967
+ import { Spinner } from "../spinner"
968
+ @@ -249,7 +250,7 @@ export function Prompt(props: PromptProps) {
969
+ const editorFileLabelDisplay = createMemo(() => {
970
+ const file = editorFileLabel()
971
+ if (!file) return
972
+ - return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3))))
973
+ + return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3))), chromeEllipsis())
974
+ })
975
+ const editorContextLabelState = createMemo(() => editor.labelState())
976
+ const [auto, setAuto] = createSignal<AutocompleteRef>()
977
+ @@ -1595,10 +1596,10 @@ export function Prompt(props: PromptProps) {
978
+ const value = (() => {
979
+ if (store.mode === "shell") {
980
+ if (!shell().length) return undefined
981
+ - return `Run a command… "${shell()[store.placeholder % shell().length]}"`
982
+ + return `${chromeLabel("Run a command…")} "${shell()[store.placeholder % shell().length]}"`
983
+ }
984
+ if (!list().length) return undefined
985
+ - return `Ask anything… "${list()[store.placeholder % list().length]}"`
986
+ + return `${chromeLabel("Ask anything…")} "${list()[store.placeholder % list().length]}"`
987
+ })()
988
+ if (!value) return undefined
989
+ const width = dimensions().width < 44 ? dimensions().width - 5 : Math.min(75, dimensions().width - 4) - 5
990
+ @@ -1663,7 +1664,7 @@ export function Prompt(props: PromptProps) {
991
+ borderColor={borderHighlight()}
992
+ customBorderChars={{
993
+ ...SplitBorder.customBorderChars,
994
+ - bottomLeft: "╹",
995
+ + bottomLeft: asciiGlyphs() ? "+" : "╹",
996
+ }}
997
+ >
998
+ <box
999
+ @@ -1854,7 +1855,7 @@ export function Prompt(props: PromptProps) {
1000
+ borderColor={borderHighlight()}
1001
+ customBorderChars={{
1002
+ ...EmptyBorder,
1003
+ - vertical: promptBg().a !== 0 ? "╹" : " ",
1004
+ + vertical: promptBg().a !== 0 ? (asciiGlyphs() ? "|" : "╹") : " ",
1005
+ }}
1006
+ >
1007
+ <box
1008
+ @@ -1865,7 +1866,7 @@ export function Prompt(props: PromptProps) {
1009
+ promptBg().a !== 0
1010
+ ? {
1011
+ ...EmptyBorder,
1012
+ - horizontal: "▀",
1013
+ + horizontal: asciiGlyphs() ? "-" : "▀",
1014
+ }
1015
+ : {
1016
+ ...EmptyBorder,
1017
+ @@ -1890,7 +1891,7 @@ export function Prompt(props: PromptProps) {
1018
+ <Match when={status() === "running"}>
1019
+ <box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
1020
+ <box marginLeft={1}>
1021
+ - <Show when={config.animations ?? true} fallback={<text fg={theme.text.muted}>[⋯]</text>}>
1022
+ + <Show when={config.animations ?? true} fallback={<text fg={theme.text.muted}>[{chromeGlyph("…")}]</text>}>
1023
+ <spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
1024
+ </Show>
1025
+ </box>
1026
+ diff --git a/packages/tui/src/component/prompt/metadata.tsx b/packages/tui/src/component/prompt/metadata.tsx
1027
+ index 60c47397dcc7856912e2ed5cd4bdeb0a6a31692c..d9fddeb91e9a6bb6dec65711652b6ce2579e9b21 100644
1028
+ --- a/packages/tui/src/component/prompt/metadata.tsx
1029
+ +++ b/packages/tui/src/component/prompt/metadata.tsx
1030
+ @@ -2,6 +2,7 @@ import { RGBA, TextAttributes } from "@opentui/core"
1031
+ import { useTerminalDimensions } from "@opentui/solid"
1032
+ import { createMemo, Show } from "solid-js"
1033
+ import { useTheme } from "../../context/theme"
1034
+ +import { chromeEllipsis, chromeGlyph } from "../../ui/glyphs"
1035
+ import { Locale } from "../../util/locale"
1036
+ import { stringWidth } from "../../util/string-width"
1037
+
1038
+ @@ -48,7 +49,7 @@ export function PromptMetadataRow(props: {
1039
+ <Show when={props.mode === "normal" && layout().model}>
1040
+ <box flexDirection="row" gap={1} flexGrow={1} flexShrink={1} minWidth={0}>
1041
+ <Show when={layout().agent}>
1042
+ - <text fg={fade(theme.text.muted, props.modelAlpha)}>·</text>
1043
+ + <text fg={fade(theme.text.muted, props.modelAlpha)}>{chromeGlyph("·")}</text>
1044
+ </Show>
1045
+ <text
1046
+ flexShrink={1}
1047
+ @@ -69,7 +70,7 @@ export function PromptMetadataRow(props: {
1048
+ <Show when={layout().variant}>
1049
+ {(variant) => (
1050
+ <>
1051
+ - <text fg={fade(theme.text.muted, props.variantAlpha)}>·</text>
1052
+ + <text fg={fade(theme.text.muted, props.variantAlpha)}>{chromeGlyph("·")}</text>
1053
+ <text
1054
+ fg={fade(theme.text.feedback.warning.base, props.variantAlpha)}
1055
+ attributes={TextAttributes.BOLD}
1056
+ @@ -98,7 +99,7 @@ type Layout = {
1057
+ variant?: string
1058
+ }
1059
+
1060
+ -function promptMetadataLayout(input: {
1061
+ +export function promptMetadataLayout(input: {
1062
+ width: number
1063
+ terminalWidth: number
1064
+ agent: string
1065
+ @@ -123,14 +124,16 @@ function promptMetadataLayout(input: {
1066
+ const fit = candidates.find((candidate) => stringWidth(text(candidate)) <= input.width)
1067
+ if (fit) return fit
1068
+
1069
+ - const prefix = agent ? `${agent} · ` : ""
1070
+ - const suffix = input.variant ? ` · ${input.variant}` : ""
1071
+ + const separator = chromeGlyph("·")
1072
+ + const prefix = agent ? `${agent} ${separator} ` : ""
1073
+ + const suffix = input.variant ? ` ${separator} ` : ""
1074
+ return {
1075
+ agent,
1076
+ model: Locale.truncateWidth(
1077
+ input.model,
1078
+ Math.max(9, input.width - stringWidth(prefix) - stringWidth(suffix)),
1079
+ - ).replace(/\s+…$/, "…"),
1080
+ + chromeEllipsis(),
1081
+ + ),
1082
+ variant: input.variant,
1083
+ }
1084
+ }
1085
+ @@ -139,8 +142,8 @@ function text(input: Layout) {
1086
+ return [
1087
+ ...(input.agent ? [input.agent] : []),
1088
+ ...(input.auto ? ["auto"] : []),
1089
+ - ...(input.model ? [...(input.agent ? ["·"] : []), input.model] : []),
1090
+ + ...(input.model ? [...(input.agent ? [chromeGlyph("·")] : []), input.model] : []),
1091
+ ...(input.provider ? [input.provider] : []),
1092
+ - ...(input.variant ? ["·", input.variant] : []),
1093
+ + ...(input.variant ? [chromeGlyph("·"), input.variant] : []),
1094
+ ].join(" ")
1095
+ }
1096
+ diff --git a/packages/tui/src/component/reconnecting.tsx b/packages/tui/src/component/reconnecting.tsx
1097
+ index 773eadaaeb1b5e1d0889ba032ae7e4edb4ea76ee..f612976132bba1d17585a6cbadd5c7c01e637757 100644
1098
+ --- a/packages/tui/src/component/reconnecting.tsx
1099
+ +++ b/packages/tui/src/component/reconnecting.tsx
1100
+ @@ -1,6 +1,7 @@
1101
+ import { RGBA } from "@opentui/core"
1102
+ import { useTheme } from "../context/theme"
1103
+ import { Spinner } from "./spinner"
1104
+ +import { chromeLabel } from "../ui/glyphs"
1105
+
1106
+ export function Reconnecting(props: { managed?: boolean }) {
1107
+ const theme = useTheme()
1108
+ @@ -28,7 +29,9 @@ export function Reconnecting(props: { managed?: boolean }) {
1109
+ paddingRight={2}
1110
+ gap={1}
1111
+ >
1112
+ - <Spinner color={theme.text.base}>{props.managed ? "Restarting service…" : "Connection lost…"}</Spinner>
1113
+ + <Spinner color={theme.text.base}>
1114
+ + {props.managed ? chromeLabel("Restarting service…") : chromeLabel("Connection lost…")}
1115
+ + </Spinner>
1116
+ <text fg={theme.text.muted}>
1117
+ {props.managed
1118
+ ? "Your session will resume automatically."
1119
+ diff --git a/packages/tui/src/component/session-tabs-rail.tsx b/packages/tui/src/component/session-tabs-rail.tsx
1120
+ index 8ecaa95787e844368671800ac787ed9bd2f53852..65c0626d6a035797712d2a7bc26bb0db920634af 100644
1121
+ --- a/packages/tui/src/component/session-tabs-rail.tsx
1122
+ +++ b/packages/tui/src/component/session-tabs-rail.tsx
1123
+ @@ -2,6 +2,7 @@ import { createMemo, createSignal } from "solid-js"
1124
+ import type { RGBA } from "@opentui/core"
1125
+ import { Keymap } from "../context/keymap"
1126
+ import { useTheme } from "../context/theme"
1127
+ +import { asciiGlyphs, chromeGlyph } from "../ui/glyphs"
1128
+ import { tint } from "../theme/color"
1129
+ import type { SessionTabsController } from "./session-tabs"
1130
+
1131
+ @@ -48,7 +49,7 @@ export function SessionTabsRailControls(props: {
1132
+ onMouseDragEnd={() => (pressed = false)}
1133
+ >
1134
+ <text width={1} height={1} fg={theme.text.action.secondary.base} selectable={false} wrapMode="none">
1135
+ - ⌕
1136
+ + {chromeGlyph("⌕")}
1137
+ </text>
1138
+ </box>
1139
+ <text
1140
+ @@ -62,7 +63,7 @@ export function SessionTabsRailControls(props: {
1141
+ bg={hovered() && props.belowHighlighted ? hoverColor() : theme.background.raised.base}
1142
+ selectable={false}
1143
+ >
1144
+ - {(hovered() ? "▀" : props.belowHighlighted ? "▄" : " ").repeat(props.width)}
1145
+ + {(asciiGlyphs() ? "-" : hovered() ? "▀" : props.belowHighlighted ? "▄" : " ").repeat(props.width)}
1146
+ </text>
1147
+ </box>
1148
+ )
1149
+ @@ -88,7 +89,7 @@ export function SessionTabHalfRow(props: {
1150
+ selectable={false}
1151
+ wrapMode="none"
1152
+ >
1153
+ - {(props.edge === "top" ? "▄" : "▀").repeat(props.width)}
1154
+ + {(asciiGlyphs() ? "-" : props.edge === "top" ? "▄" : "▀").repeat(props.width)}
1155
+ </text>
1156
+ )
1157
+ }
1158
+ diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx
1159
+ index fdd23a43cbd4c3e318f211b7a8dc4fe2ab816fc1..540f63463e6e3120d6c7b374b4a545d2bf0b8092 100644
1160
+ --- a/packages/tui/src/component/session-tabs.tsx
1161
+ +++ b/packages/tui/src/component/session-tabs.tsx
1162
+ @@ -51,6 +51,7 @@ import { DialogSessionRename } from "./dialog-session-rename"
1163
+ import { Keymap } from "../context/keymap"
1164
+ import { registerOpencodeSpinner } from "./register-spinner"
1165
+ import { SPINNER_FRAMES } from "./spinner-frames"
1166
+ +import { asciiGlyphs, chromeEllipsis, chromeGlyph } from "../ui/glyphs"
1167
+ import { SessionTabsRailControls, SessionTabHalfRow } from "./session-tabs-rail"
1168
+ import "./title-shimmer"
1169
+
1170
+ @@ -58,8 +59,14 @@ registerOpencodeSpinner()
1171
+
1172
+ export const TAB_SPINNERS = {
1173
+ dots: { frames: SPINNER_FRAMES, interval: 80 },
1174
+ - arcs: { frames: ["◜", "◝", "◞", "◟"], interval: 120 },
1175
+ - quadrants: { frames: ["◴", "◷", "◶", "◵"], interval: 120 },
1176
+ + arcs: {
1177
+ + frames: asciiGlyphs() ? ["/", "\\", "\\", "/"] : ["◜", "◝", "◞", "◟"],
1178
+ + interval: 120,
1179
+ + },
1180
+ + quadrants: {
1181
+ + frames: asciiGlyphs() ? ["|", "/", "-", "\\"] : ["◴", "◷", "◶", "◵"],
1182
+ + interval: 120,
1183
+ + },
1184
+ line: { frames: ["|", "/", "-", "\\"], interval: 120 },
1185
+ }
1186
+ export type TabSpinner = keyof typeof TAB_SPINNERS
1187
+ @@ -71,6 +78,9 @@ export const TAB_UNREAD_MARKERS = {
1188
+ "large-square": "■",
1189
+ }
1190
+ export type TabUnreadMarker = keyof typeof TAB_UNREAD_MARKERS
1191
+ +export function tabUnreadMarker(marker: TabUnreadMarker = "small-dot") {
1192
+ + return chromeGlyph(TAB_UNREAD_MARKERS[marker])
1193
+ +}
1194
+
1195
+ // A long title fades out over its last cells instead of cutting hard.
1196
+ const FADE_WIDTH = 4
1197
+ @@ -170,14 +180,16 @@ function TabIndicator(props: {
1198
+ const flash = Math.max(0, 1 - Math.abs(opacity - 0.8) / 0.2)
1199
+ return tint(props.backgroundColor, tint(unreadColor(), props.flashColor, flash * 0.3), Math.min(1, opacity / 0.8))
1200
+ }
1201
+ - const spinner = () => TAB_SPINNERS[props.spinner ?? "dots"]
1202
+ + const spinner = () => {
1203
+ + return TAB_SPINNERS[props.spinner ?? "dots"]
1204
+ + }
1205
+ const label = () => {
1206
+ if (props.numbers) return props.label
1207
+ if (props.status.attention === "permission") return "!"
1208
+ if (props.status.attention === "question") return "?"
1209
+ if (runs()) return spinner().frames[0]
1210
+ if (props.label === "+") return "+"
1211
+ - if (props.status.unread || fading()) return TAB_UNREAD_MARKERS[props.unreadMarker ?? "small-dot"]
1212
+ + if (props.status.unread || fading()) return tabUnreadMarker(props.unreadMarker)
1213
+ return props.idleLabel ?? ""
1214
+ }
1215
+ return (
1216
+ @@ -1106,7 +1118,7 @@ function VerticalSessionTabs(props: {
1217
+ tabs.close(tab.sessionID)
1218
+ }}
1219
+ >
1220
+ - {hovered() === tab.sessionID ? "✕" : ""}
1221
+ + {hovered() === tab.sessionID ? chromeGlyph("✕") : ""}
1222
+ </text>
1223
+ </box>
1224
+ </box>
1225
+ @@ -1230,7 +1242,7 @@ function VerticalSessionTabs(props: {
1226
+ tabs.close()
1227
+ }}
1228
+ >
1229
+ - {addHovered() ? "×" : ""}
1230
+ + {addHovered() ? chromeGlyph("×") : ""}
1231
+ </text>
1232
+ </Show>
1233
+ </box>
1234
+ @@ -1262,6 +1274,7 @@ function VerticalSessionTabs(props: {
1235
+ items().find((tab) => tab.sessionID === sessionID())?.title ??
1236
+ "Untitled session",
1237
+ tooltipWidth() - 2,
1238
+ + chromeEllipsis(),
1239
+ )}
1240
+ </text>
1241
+ <text fg={theme.text.muted} wrapMode="none" selectable={false}>
1242
+ @@ -1556,7 +1569,7 @@ function HorizontalSessionTabs(props: {
1243
+ >
1244
+ <Show when={layout().before > 0}>
1245
+ <text width={sessionTabOverflowWidth(layout().before)} fg={theme.text.muted} selectable={false}>
1246
+ - ‹{layout().before}
1247
+ + {chromeGlyph("‹")}{layout().before}
1248
+ </text>
1249
+ </Show>
1250
+ <For each={layout().tabs}>
1251
+ @@ -1753,7 +1766,7 @@ function HorizontalSessionTabs(props: {
1252
+ tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
1253
+ }}
1254
+ >
1255
+ - {hovered() === tab.sessionID ? "✕" : ""}
1256
+ + {hovered() === tab.sessionID ? chromeGlyph("✕") : ""}
1257
+ </text>
1258
+ </box>
1259
+ </box>
1260
+ @@ -1762,7 +1775,7 @@ function HorizontalSessionTabs(props: {
1261
+ </For>
1262
+ <Show when={layout().after > 0}>
1263
+ <text width={sessionTabOverflowWidth(layout().after)} fg={theme.text.muted} selectable={false}>
1264
+ - {" " + layout().after}›
1265
+ + {" " + layout().after}{chromeGlyph("›")}
1266
+ </text>
1267
+ </Show>
1268
+ <Show when={showPlus()}>
1269
+ diff --git a/packages/tui/src/component/spinner-frames.ts b/packages/tui/src/component/spinner-frames.ts
1270
+ index dca99b642a727b9846b0bb7b2b995cd2c7636683..3869068d90958c89ea97f8e3e4caccb6cf490db9 100644
1271
+ --- a/packages/tui/src/component/spinner-frames.ts
1272
+ +++ b/packages/tui/src/component/spinner-frames.ts
1273
+ @@ -1 +1,7 @@
1274
+ -export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
1275
+ +import { ASCII_SPINNER_FRAMES, asciiGlyphs } from "../ui/glyphs"
1276
+ +
1277
+ +const UNICODE_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
1278
+ +export function spinnerFrames() {
1279
+ + return asciiGlyphs() ? ASCII_SPINNER_FRAMES : UNICODE_SPINNER_FRAMES
1280
+ +}
1281
+ +export const SPINNER_FRAMES = spinnerFrames()
1282
+ diff --git a/packages/tui/src/component/spinner.tsx b/packages/tui/src/component/spinner.tsx
1283
+ index 3207dcb6e4f4588297c24f88329e339d45df9b98..08a92ee9cfb22e8c94e61d36cedbc1a394b9c283 100644
1284
+ --- a/packages/tui/src/component/spinner.tsx
1285
+ +++ b/packages/tui/src/component/spinner.tsx
1286
+ @@ -6,6 +6,7 @@ import type { RGBA } from "@opentui/core"
1287
+ import { registerOpencodeSpinner } from "./register-spinner"
1288
+ import { SPINNER_FRAMES } from "./spinner-frames"
1289
+ import { ShimmerText } from "./shimmer-text"
1290
+ +import { chromeGlyph } from "../ui/glyphs"
1291
+
1292
+ export { SPINNER_FRAMES } from "./spinner-frames"
1293
+
1294
+ @@ -26,7 +27,7 @@ export function Spinner(props: { children?: JSX.Element; color?: RGBA; shimmer?:
1295
+ return (
1296
+ <Show
1297
+ when={config.animations ?? true}
1298
+ - fallback={<text fg={color()}>{props.children ? <>⋯ {props.children}</> : "⋯"}</text>}
1299
+ + fallback={<text fg={color()}>{props.children ? <>{chromeGlyph("…")} {props.children}</> : chromeGlyph("…")}</text>}
1300
+ >
1301
+ <Show
1302
+ when={props.shimmer}
1303
+ diff --git a/packages/tui/src/component/startup-loading.tsx b/packages/tui/src/component/startup-loading.tsx
1304
+ index 3df79782c63433021722986242d05dddd138a508..c1e437df95e8439f5fa82071e17cf8072a7d6157 100644
1305
+ --- a/packages/tui/src/component/startup-loading.tsx
1306
+ +++ b/packages/tui/src/component/startup-loading.tsx
1307
+ @@ -1,11 +1,14 @@
1308
+ import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
1309
+ import { useTheme } from "../context/theme"
1310
+ import { Spinner } from "./spinner"
1311
+ +import { chromeLabel } from "../ui/glyphs"
1312
+
1313
+ export function StartupLoading(props: { ready: () => boolean }) {
1314
+ const theme = useTheme()
1315
+ const [show, setShow] = createSignal(false)
1316
+ - const text = createMemo(() => (props.ready() ? "Finishing startup…" : "Loading plugins…"))
1317
+ + const text = createMemo(() =>
1318
+ + props.ready() ? chromeLabel("Finishing startup…") : chromeLabel("Loading plugins…"),
1319
+ + )
1320
+ let wait: NodeJS.Timeout | undefined
1321
+ let hold: NodeJS.Timeout | undefined
1322
+ let stamp = 0
1323
+ diff --git a/packages/tui/src/component/tab-pulse.tsx b/packages/tui/src/component/tab-pulse.tsx
1324
+ index a6072fe109bde1a8eaf72b34c32208ce26806a9d..59e891b8682c0d2d3f9b580c5b09d19e5c274be7 100644
1325
+ --- a/packages/tui/src/component/tab-pulse.tsx
1326
+ +++ b/packages/tui/src/component/tab-pulse.tsx
1327
+ @@ -1,5 +1,6 @@
1328
+ import { OptimizedBuffer, Renderable, RGBA, type RenderableOptions, type RenderContext } from "@opentui/core"
1329
+ import { extend } from "@opentui/solid"
1330
+ +import { asciiGlyphs } from "../ui/glyphs"
1331
+
1332
+ type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
1333
+ edge?: "above" | "below"
1334
+ @@ -739,7 +740,7 @@ class TabPulseRenderable extends Renderable {
1335
+ buffer.setCell(
1336
+ this.screenX + index,
1337
+ this.screenY,
1338
+ - this._edge === "above" ? "▄" : "▀",
1339
+ + asciiGlyphs() ? "-" : this._edge === "above" ? "▄" : "▀",
1340
+ this.renderColor,
1341
+ this.outerRenderColor,
1342
+ )
1343
+ diff --git a/packages/tui/src/context/keymap.tsx b/packages/tui/src/context/keymap.tsx
1344
+ index 521b3aeac86f97675ff7076c34a6ea278293db32..6d6ae30290ce6a6511b60dc3cb23523fbcdedf0e 100644
1345
+ --- a/packages/tui/src/context/keymap.tsx
1346
+ +++ b/packages/tui/src/context/keymap.tsx
1347
+ @@ -25,6 +25,7 @@ import {
1348
+ } from "solid-js"
1349
+ import { useConfig } from "../config"
1350
+ import { TuiKeybind } from "../config/keybind"
1351
+ +import { asciiGlyphs } from "../ui/glyphs"
1352
+ import { resolveInteractivity, useInteractivity } from "./interactivity"
1353
+
1354
+ declare module "@opentui/keymap" {
1355
+ @@ -459,10 +460,10 @@ function formatOptions(config: KeymapConfig) {
1356
+ leader: leader ? (typeof leader === "string" ? leader : stringifyKeyStroke(leader)) : TuiKeybind.LeaderDefault,
1357
+ },
1358
+ keyNameAliases: {
1359
+ - up: "↑",
1360
+ - down: "↓",
1361
+ - left: "←",
1362
+ - right: "→",
1363
+ + up: asciiGlyphs() ? "up" : "↑",
1364
+ + down: asciiGlyphs() ? "down" : "↓",
1365
+ + left: asciiGlyphs() ? "left" : "←",
1366
+ + right: asciiGlyphs() ? "right" : "→",
1367
+ pageup: "pgup",
1368
+ pagedown: "pgdn",
1369
+ delete: "del",
1370
+ diff --git a/packages/tui/src/context/session-tabs-model.ts b/packages/tui/src/context/session-tabs-model.ts
1371
+ index 856f9961e7fa8d7b880035f01c3c32a48551fffd..0a1356cedc938921f7dba1b985179a57b7848ca5 100644
1372
+ --- a/packages/tui/src/context/session-tabs-model.ts
1373
+ +++ b/packages/tui/src/context/session-tabs-model.ts
1374
+ @@ -1,3 +1,5 @@
1375
+ +import { chromeGlyph } from "../ui/glyphs"
1376
+ +
1377
+ export type SessionTab = {
1378
+ sessionID: string
1379
+ title?: string
1380
+ @@ -18,7 +20,7 @@ export function sessionTabDetail(
1381
+ worktree: boolean,
1382
+ ) {
1383
+ const branch = worktree && current !== defaultBranch ? current : undefined
1384
+ - return branch && project ? `${project} ⎇ ${branch}` : (branch ?? project)
1385
+ + return branch && project ? `${project} ${chromeGlyph("⎇")} ${branch}` : (branch ?? project)
1386
+ }
1387
+
1388
+ export type SessionTabHistory = {
1389
+ diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx
1390
+ index d31888c2770f27bc7143edc258f6446407f8278e..eb939a1b51ff0fb5d7d69923b13de0fe04eba971 100644
1391
+ --- a/packages/tui/src/feature-plugins/home/footer.tsx
1392
+ +++ b/packages/tui/src/feature-plugins/home/footer.tsx
1393
+ @@ -1,6 +1,7 @@
1394
+ import { Plugin } from "@opencode/plugin/tui"
1395
+ import { createMemo, Match, Show, Switch } from "solid-js"
1396
+ import { useTerminalDimensions } from "@opentui/solid"
1397
+ +import { chromeGlyph } from "../../ui/glyphs"
1398
+ import { usePlugin } from "../../plugin/context"
1399
+ import { Slot } from "../../plugin/render"
1400
+
1401
+ @@ -25,7 +26,7 @@ function Mcp(props: { context: Plugin.Context }) {
1402
+ <text fg={props.context.theme.text.base}>
1403
+ <Switch>
1404
+ <Match when={failed()}>
1405
+ - <span style={{ fg: props.context.theme.text.feedback.error.base }}>⊙ </span>
1406
+ + <span style={{ fg: props.context.theme.text.feedback.error.base }}>{chromeGlyph("⊙")} </span>
1407
+ {failed()} MCP failed
1408
+ </Match>
1409
+ <Match when={true}>
1410
+ @@ -35,7 +36,7 @@ function Mcp(props: { context: Plugin.Context }) {
1411
+ count() > 0 ? props.context.theme.text.feedback.success.base : props.context.theme.text.muted,
1412
+ }}
1413
+ >
1414
+ - ⊙{" "}
1415
+ + {chromeGlyph("⊙")} {" "}
1416
+ </span>
1417
+ {count()} MCP
1418
+ </Match>
1419
+ @@ -63,7 +64,7 @@ function Plugins(props: { context: Plugin.Context }) {
1420
+ <Show when={failed()}>
1421
+ <box gap={1} flexDirection="row" flexShrink={0} onMouseUp={() => props.context.keymap.dispatch("plugins.list")}>
1422
+ <text fg={props.context.theme.text.base}>
1423
+ - <span style={{ fg: props.context.theme.text.feedback.error.base }}>⊙ </span>
1424
+ + <span style={{ fg: props.context.theme.text.feedback.error.base }}>{chromeGlyph("⊙")} </span>
1425
+ {failed()} plugin{failed() === 1 ? "" : "s"} failed
1426
+ </text>
1427
+ <Show when={visibility().pluginCommand}>
1428
+ diff --git a/packages/tui/src/feature-plugins/prompt/btw.tsx b/packages/tui/src/feature-plugins/prompt/btw.tsx
1429
+ index 6aca5607022df6b4a68acdda644928e70698e41d..1cc488b4eacaef463d422230f4e6469c63005fa2 100644
1430
+ --- a/packages/tui/src/feature-plugins/prompt/btw.tsx
1431
+ +++ b/packages/tui/src/feature-plugins/prompt/btw.tsx
1432
+ @@ -3,6 +3,7 @@ import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
1433
+ import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
1434
+ import { createMemo, createSignal, Show } from "solid-js"
1435
+ import { Spinner } from "../../component/spinner"
1436
+ +import { chromeGlyph } from "../../ui/glyphs"
1437
+ import { useConfig } from "../../config"
1438
+ import { useClipboard } from "../../context/clipboard"
1439
+ import { Keymap } from "../../context/keymap"
1440
+ @@ -143,7 +144,7 @@ export function Answer(props: {
1441
+ actions: {
1442
+ primary: [
1443
+ copied()
1444
+ - ? { title: "✓ copied", color: theme.text.feedback.success.base, onTrigger: copy }
1445
+ + ? { title: `${chromeGlyph("✓")} copied`, color: theme.text.feedback.success.base, onTrigger: copy }
1446
+ : { title: "copy response", key: "c", onTrigger: copy },
1447
+ ],
1448
+ },
1449
+ @@ -153,9 +154,9 @@ export function Answer(props: {
1450
+ actions={{
1451
+ secondary: [
1452
+ copied()
1453
+ - ? { title: "✓ copied", color: theme.text.feedback.success.base, onTrigger: copy }
1454
+ + ? { title: `${chromeGlyph("✓")} copied`, color: theme.text.feedback.success.base, onTrigger: copy }
1455
+ : { title: "copy", key: "c", onTrigger: copy },
1456
+ - { title: "scroll", key: "j/k ↑/↓" },
1457
+ + { title: "scroll", key: `j/k ${chromeGlyph("↑")}/${chromeGlyph("↓")}` },
1458
+ ],
1459
+ }}
1460
+ />
1461
+ diff --git a/packages/tui/src/feature-plugins/prompt/footer.tsx b/packages/tui/src/feature-plugins/prompt/footer.tsx
1462
+ index f8fafdce21e086f156ead73edf1b3b88a1683b3f..b2bca2b046c74c8a7818a3b975679cb224856afc 100644
1463
+ --- a/packages/tui/src/feature-plugins/prompt/footer.tsx
1464
+ +++ b/packages/tui/src/feature-plugins/prompt/footer.tsx
1465
+ @@ -5,6 +5,7 @@ import { useTerminalDimensions } from "@opentui/solid"
1466
+ import { TextAttributes } from "@opentui/core"
1467
+ import { useInteractivity } from "../../context/interactivity"
1468
+ import { stringWidth } from "../../util/string-width"
1469
+ +import { chromeGlyph } from "../../ui/glyphs"
1470
+
1471
+ const money = new Intl.NumberFormat("en-US", {
1472
+ style: "currency",
1473
+ @@ -83,15 +84,15 @@ export function PromptFooter(props: {
1474
+ {(value) => <span style={{ fg: props.context.theme.text.base }}>{value()} </span>}
1475
+ </Show>
1476
+ <Show when={subagents()}>{(value) => <>{value()}</>}</Show>
1477
+ - <Show when={subagents() && shells()}> · </Show>
1478
+ + <Show when={subagents() && shells()}> {chromeGlyph("·")} </Show>
1479
+ <Show when={shells()}>{(value) => <>{value()}</>}</Show>
1480
+ </text>
1481
+ </box>
1482
+ </Show>
1483
+ <Show when={props.showDetails && layout().usage && status().length > 0}>
1484
+ <text fg={props.context.theme.text.muted} wrapMode="none" flexShrink={0}>
1485
+ - <Show when={live()}> · </Show>
1486
+ - {status().join(" · ")}
1487
+ + <Show when={live()}> {chromeGlyph("·")} </Show>
1488
+ + {joinPromptFooterStatus(status())}
1489
+ </text>
1490
+ </Show>
1491
+ </box>
1492
+ @@ -143,11 +144,16 @@ export default Plugin.define({
1493
+ },
1494
+ })
1495
+
1496
+ -function promptFooterLayout(input: { width: number; usage: string[]; shortcuts: string[] }) {
1497
+ - const usage = input.usage.join(" · ")
1498
+ - const shortcuts = input.shortcuts.join(" · ")
1499
+ +export function joinPromptFooterStatus(status: string[]) {
1500
+ + return status.join(` ${chromeGlyph("·")} `)
1501
+ +}
1502
+ +
1503
+ +export function promptFooterLayout(input: { width: number; usage: string[]; shortcuts: string[] }) {
1504
+ + const separator = ` ${chromeGlyph("·")} `
1505
+ + const usage = input.usage.join(separator)
1506
+ + const shortcuts = input.shortcuts.join(separator)
1507
+ const available = Math.max(0, input.width - Math.min(28, Math.floor(input.width / 2)))
1508
+ - if (usage && shortcuts && stringWidth(`${usage} · ${shortcuts}`) <= available) {
1509
+ + if (usage && shortcuts && stringWidth(`${usage}${separator}${shortcuts}`) <= available) {
1510
+ return { usage: true, shortcuts: true }
1511
+ }
1512
+ if (usage && stringWidth(usage) <= available) return { usage: true, shortcuts: false }
1513
+ diff --git a/packages/tui/src/feature-plugins/sidebar/footer.tsx b/packages/tui/src/feature-plugins/sidebar/footer.tsx
1514
+ index b875d5ffe803c6b8eefa50460c55ce5c762d4629..75609b21f7c57e53851ee95a10b42592a7103635 100644
1515
+ --- a/packages/tui/src/feature-plugins/sidebar/footer.tsx
1516
+ +++ b/packages/tui/src/feature-plugins/sidebar/footer.tsx
1517
+ @@ -5,6 +5,7 @@ import { FilePath } from "../../ui/file-path"
1518
+ import { useWorkingDirectoryActions } from "../../ui/working-directory-actions"
1519
+ import { usePromptMove } from "../../component/prompt/move"
1520
+ import { hasConnectedProvider } from "../../util/connected-provider"
1521
+ +import { chromeGlyph } from "../../ui/glyphs"
1522
+
1523
+ export function SidebarOnboarding(props: { context: Plugin.Context; sessionID: string }) {
1524
+ const dimensions = useTerminalDimensions()
1525
+ @@ -35,7 +36,7 @@ export function SidebarOnboarding(props: { context: Plugin.Context; sessionID: s
1526
+ gap={1}
1527
+ >
1528
+ <text flexShrink={0} fg={props.context.theme.text.base}>
1529
+ - ⬖
1530
+ + {chromeGlyph("⬖")}
1531
+ </text>
1532
+ <box flexGrow={1} gap={1}>
1533
+ <box flexDirection="row" justifyContent="space-between">
1534
+ @@ -51,7 +52,7 @@ export function SidebarOnboarding(props: { context: Plugin.Context; sessionID: s
1535
+ }).catch((error) => console.error("Failed to dismiss sidebar onboarding", error))
1536
+ }}
1537
+ >
1538
+ - ✕
1539
+ + {chromeGlyph("✕")}
1540
+ </text>
1541
+ </box>
1542
+ <text fg={props.context.theme.text.muted}>OpenCode includes free models so you can start immediately.</text>
1543
+ diff --git a/packages/tui/src/feature-plugins/sidebar/mcp.tsx b/packages/tui/src/feature-plugins/sidebar/mcp.tsx
1544
+ index 6018ff0ccb4334db84a00ef6c35c37c4dbfbb393..f192f9ebe118e05ecdc02879f2b3e93c1dcf54f6 100644
1545
+ --- a/packages/tui/src/feature-plugins/sidebar/mcp.tsx
1546
+ +++ b/packages/tui/src/feature-plugins/sidebar/mcp.tsx
1547
+ @@ -1,6 +1,7 @@
1548
+ import { Plugin } from "@opencode/plugin/tui"
1549
+ import { createMemo, For, Match, Show, Switch } from "solid-js"
1550
+ import { DialogMcp } from "../../component/dialog-mcp"
1551
+ +import { chromeGlyph } from "../../ui/glyphs"
1552
+
1553
+ export function SidebarMcp(props: { context: Plugin.Context; sessionID: string }) {
1554
+ const [view, updateView] = props.context.storage.store("view", { initial: { open: true } })
1555
+ @@ -34,7 +35,7 @@ export function SidebarMcp(props: { context: Plugin.Context; sessionID: string }
1556
+ }}
1557
+ >
1558
+ <Show when={list().length > 2}>
1559
+ - <text fg={theme.text.base}>{view.open ? "▼" : "▶"}</text>
1560
+ + <text fg={theme.text.base}>{view.open ? chromeGlyph("▼") : chromeGlyph("▶")}</text>
1561
+ </Show>
1562
+ <text fg={theme.text.base}>
1563
+ <b>MCP</b>
1564
+ @@ -65,7 +66,7 @@ export function SidebarMcp(props: { context: Plugin.Context; sessionID: string }
1565
+ fg: dot(item.status.status),
1566
+ }}
1567
+ >
1568
+ - •
1569
+ + {chromeGlyph("•")}
1570
+ </text>
1571
+ <text fg={theme.text.base} wrapMode="none" truncate flexGrow={1} flexShrink={1} minWidth={0}>
1572
+ <b>{item.name}</b>
1573
+ diff --git a/packages/tui/src/feature-plugins/system/diff-viewer-file-tree.tsx b/packages/tui/src/feature-plugins/system/diff-viewer-file-tree.tsx
1574
+ index 4a2c4dc06bc5be04057a97d152c2f07e2834f38c..7d0a7d4df510cf5001f4dca6b6a03e2f11241251 100644
1575
+ --- a/packages/tui/src/feature-plugins/system/diff-viewer-file-tree.tsx
1576
+ +++ b/packages/tui/src/feature-plugins/system/diff-viewer-file-tree.tsx
1577
+ @@ -3,6 +3,7 @@ import { MouseButton, TextAttributes, type MouseEvent, type ScrollBoxRenderable
1578
+ import { truncateFilePath } from "../../ui/file-path"
1579
+ import { stringWidth } from "../../util/string-width"
1580
+ import { useTheme } from "../../context/theme"
1581
+ +import { chromeGlyph, chromeEllipsis } from "../../ui/glyphs"
1582
+ import { tint } from "../../theme/color"
1583
+ import { createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
1584
+ import { buildFileTree, flattenFileTree, type FileTreeItem, type FileTreeRow } from "./diff-viewer-file-tree-utils"
1585
+ @@ -97,7 +98,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
1586
+ </text>
1587
+ <Show when={props.sourceDetail}>
1588
+ <text fg={theme.text.muted} selectable={false} flexGrow={1} minWidth={0} wrapMode="none" truncate>
1589
+ - {` · ${props.sourceDetail}`}
1590
+ + {` ${chromeGlyph("·")} ${props.sourceDetail}`}
1591
+ </text>
1592
+ </Show>
1593
+ </box>
1594
+ @@ -141,13 +142,15 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
1595
+ return theme.background.raised.base
1596
+ }
1597
+ const marker = () => {
1598
+ - if (row.kind !== "directory") return "≡ "
1599
+ - return props.expandedNodes && !props.expandedNodes.has(row.id) ? "▸ " : "▾ "
1600
+ + if (row.kind !== "directory") return `${chromeGlyph("≡")} `
1601
+ + return props.expandedNodes && !props.expandedNodes.has(row.id)
1602
+ + ? `${chromeGlyph("▸")} `
1603
+ + : `${chromeGlyph("▾")} `
1604
+ }
1605
+ // Rails run straight down from each ancestor folder; no end hooks.
1606
+ const indent = createMemo(() => {
1607
+ if (list()) return ""
1608
+ - return "│ ".repeat(Math.max(0, Math.min(row.depth, Math.floor((contentWidth() - 3) / 2))))
1609
+ + return `${chromeGlyph("│")} `.repeat(Math.max(0, Math.min(row.depth, Math.floor((contentWidth() - 3) / 2))))
1610
+ })
1611
+ const status = () => fileTreeRowStatus(row, props.files, reviewed())
1612
+ const statusColor = () => {
1613
+ @@ -249,7 +252,7 @@ function scrollFileTreeRowIntoView(scroll: ScrollBoxRenderable | undefined, top:
1614
+
1615
+ function fileTreeRowStatus(row: FileTreeRow, files: readonly FileTreeItem[], reviewed: boolean) {
1616
+ if (row.fileIndex === undefined) return ""
1617
+ - if (reviewed) return "✓"
1618
+ + if (reviewed) return chromeGlyph("✓")
1619
+ const status = files[row.fileIndex]?.status
1620
+ return status === "modified" ? "M" : status === "added" ? "A" : status === "deleted" ? "D" : "?"
1621
+ }
1622
+ @@ -259,7 +262,7 @@ function fileTreeRowStatus(row: FileTreeRow, files: readonly FileTreeItem[], rev
1623
+ function truncateDirectoryChain(name: string, maxWidth: number) {
1624
+ if (stringWidth(name) <= maxWidth) return name
1625
+ const kept: string[] = []
1626
+ - let width = stringWidth("…/")
1627
+ + let width = stringWidth(chromeEllipsis() + "/")
1628
+ for (const segment of name.split("/").toReversed()) {
1629
+ const next = stringWidth(segment) + (kept.length ? 1 : 0)
1630
+ if (width + next > maxWidth) break
1631
+ @@ -267,5 +270,5 @@ function truncateDirectoryChain(name: string, maxWidth: number) {
1632
+ width += next
1633
+ }
1634
+ if (kept.length === 0) return truncateFilePath(name, maxWidth)
1635
+ - return `…/${kept.join("/")}`
1636
+ + return `${chromeEllipsis()}/${kept.join("/")}`
1637
+ }
1638
+ diff --git a/packages/tui/src/feature-plugins/system/diff-viewer-image.tsx b/packages/tui/src/feature-plugins/system/diff-viewer-image.tsx
1639
+ index cb7f0801790b0c5c3bd6ac4900bddaa84056df03..7dc0b46ff826b7c522c01c6e90e04eb52f50ce80 100644
1640
+ --- a/packages/tui/src/feature-plugins/system/diff-viewer-image.tsx
1641
+ +++ b/packages/tui/src/feature-plugins/system/diff-viewer-image.tsx
1642
+ @@ -5,6 +5,7 @@ import { createResource, createSignal, Match, onCleanup, Show, Switch } from "so
1643
+ import { DialogImagePreview } from "../../component/dialog-image-preview"
1644
+ import { useTheme } from "../../context/theme"
1645
+ import { useDialog } from "../../ui/dialog"
1646
+ +import { chromeLabel } from "../../ui/glyphs"
1647
+
1648
+ export function isDiffImageFile(file: string) {
1649
+ return /\.(png|jpe?g|webp|gif)$/i.test(file)
1650
+ @@ -37,7 +38,7 @@ export function DiffViewerImage(props: {
1651
+ <text fg={theme.text.feedback.error.base}>Could not load image</text>
1652
+ </Match>
1653
+ <Match when={image.loading}>
1654
+ - <text fg={theme.text.muted}>Loading image…</text>
1655
+ + <text fg={theme.text.muted}>{chromeLabel("Loading image…")}</text>
1656
+ </Match>
1657
+ <Match when={!image.error && image()} keyed>
1658
+ {(bytes) => {
1659
+ diff --git a/packages/tui/src/feature-plugins/system/diff-viewer.tsx b/packages/tui/src/feature-plugins/system/diff-viewer.tsx
1660
+ index 0e333fffcca30f73aeb2781cf7102236d93125c5..18fa75543bff1ceca79b639eb49849b1cf2eadda 100644
1661
+ --- a/packages/tui/src/feature-plugins/system/diff-viewer.tsx
1662
+ +++ b/packages/tui/src/feature-plugins/system/diff-viewer.tsx
1663
+ @@ -20,6 +20,7 @@ import { DiffViewerImage, isDiffImageFile } from "./diff-viewer-image"
1664
+ import { DIALOG_LIST_INSET, DialogHeader, DialogLayout } from "../../ui/dialog"
1665
+ import { DialogSelect } from "../../ui/dialog-select"
1666
+ import { EmptyBorder } from "../../ui/border"
1667
+ +import { asciiGlyphs, chromeGlyph, chromeLabel } from "../../ui/glyphs"
1668
+ import { SESSION_PANEL_SPLIT_MIN_WIDTH } from "../../ui/layout"
1669
+ import { FilePath } from "../../ui/file-path"
1670
+ import { getScrollAcceleration } from "../../util/scroll"
1671
+ @@ -185,7 +186,7 @@ function DiffViewer(props: {
1672
+ if (mode() === "turn") return diff.error ? "Diff unavailable" : undefined
1673
+ if (mode() === "working") return "vs HEAD"
1674
+ if (diff.error) return "Base or diff unavailable"
1675
+ - if (!result()) return "Resolving diff…"
1676
+ + if (!result()) return chromeLabel("Resolving diff…")
1677
+ const base = result()?.base
1678
+ if (!base) return "Base not reported"
1679
+ return `vs ${base.name}`
1680
+ @@ -271,7 +272,7 @@ function DiffBaseDialog(props: {
1681
+ <box paddingLeft={DIALOG_LIST_INSET} paddingRight={DIALOG_LIST_INSET}>
1682
+ <text fg={branches.error ? theme.text.feedback.error.base : theme.text.muted}>
1683
+ {branches.loading
1684
+ - ? "Loading branches…"
1685
+ + ? chromeLabel("Loading branches…")
1686
+ : branches.error
1687
+ ? "Could not load branches. Reopen the picker to try again."
1688
+ : "No branches found"}
1689
+ @@ -804,7 +805,7 @@ export function DiffViewerContent(props: {
1690
+ title: "Base",
1691
+ titleView: "Base".padEnd(11),
1692
+ value: "base" as const,
1693
+ - description: props.sourceBase?.name ?? "Choose…",
1694
+ + description: props.sourceBase?.name ?? chromeLabel("Choose…"),
1695
+ onSelect: props.onChooseBase,
1696
+ },
1697
+ ]
1698
+ @@ -892,7 +893,7 @@ export function DiffViewerContent(props: {
1699
+ </text>
1700
+ <Show when={props.sourceDetail}>
1701
+ <text fg={theme.text.muted} selectable={false} flexGrow={1} minWidth={0} wrapMode="none" truncate>
1702
+ - {` · ${props.sourceDetail}`}
1703
+ + {` ${chromeGlyph("·")} ${props.sourceDetail}`}
1704
+ </text>
1705
+ </Show>
1706
+ </box>
1707
+ @@ -905,7 +906,7 @@ export function DiffViewerContent(props: {
1708
+ <Switch>
1709
+ <Match when={props.loading}>
1710
+ <box flexGrow={1} padding={2} paddingTop={compact() ? 1 : 2}>
1711
+ - <text fg={theme.text.muted}>Loading diff…</text>
1712
+ + <text fg={theme.text.muted}>{chromeLabel("Loading diff…")}</text>
1713
+ </box>
1714
+ </Match>
1715
+ <Match when={!props.loading && props.error}>
1716
+ @@ -997,7 +998,7 @@ export function DiffViewerContent(props: {
1717
+ return (
1718
+ <box ref={(element: BoxRenderable) => registerPatchNode(entry.fileIndex, element)}>
1719
+ <Show when={index() > 0}>
1720
+ - <CardGap glyph="▄" card={background()} surface={surface()} inset={inset()} />
1721
+ + <CardGap edge="top" glyph={asciiGlyphs() ? "-" : "▄"} card={background()} surface={surface()} inset={inset()} />
1722
+ </Show>
1723
+ <box backgroundColor={background()} marginLeft={inset()} marginRight={inset()}>
1724
+ <box
1725
+ @@ -1038,7 +1039,7 @@ export function DiffViewerContent(props: {
1726
+ </box>
1727
+ <Show when={reviewed()}>
1728
+ <text fg={theme.text.muted} flexShrink={0}>
1729
+ - ✓
1730
+ + {chromeGlyph("✓")}
1731
+ </text>
1732
+ </Show>
1733
+ <Show when={!image()} fallback={<text fg={theme.text.muted}>Image</text>}>
1734
+ @@ -1051,7 +1052,7 @@ export function DiffViewerContent(props: {
1735
+ </Show>
1736
+ <Show when={reviewed()}>
1737
+ <box position="absolute" left={-inset()} right={-inset()} bottom={0} height={1}>
1738
+ - <CardGap glyph="▀" card={background()} surface={surface()} inset={inset()} />
1739
+ + <CardGap edge="bottom" glyph={asciiGlyphs() ? "-" : "▀"} card={background()} surface={surface()} inset={inset()} />
1740
+ </box>
1741
+ </Show>
1742
+ </box>
1743
+ @@ -1138,7 +1139,7 @@ export function DiffViewerContent(props: {
1744
+ border={["bottom"]}
1745
+ borderColor={surface()}
1746
+ backgroundColor={theme.background.base}
1747
+ - customBorderChars={{ ...EmptyBorder, horizontal: "▀" }}
1748
+ + customBorderChars={{ ...EmptyBorder, horizontal: asciiGlyphs() ? "-" : "▀" }}
1749
+ />
1750
+ </Show>
1751
+ <box height={1} flexShrink={0} flexDirection="row" paddingLeft={2} paddingRight={1}>
1752
+ @@ -1190,17 +1191,17 @@ export function DiffViewerContent(props: {
1753
+ }
1754
+
1755
+ /** A half-row of base background between cards: the card's color over its width, the pane surface in the insets. */
1756
+ -function CardGap(props: { glyph: "▄" | "▀"; card: RGBA; surface: RGBA; inset: number }) {
1757
+ +function CardGap(props: { edge: "top" | "bottom"; glyph: string; card: RGBA; surface: RGBA; inset: number }) {
1758
+ const theme = useTheme()
1759
+ const half = (color: RGBA, width?: number) => (
1760
+ <box
1761
+ height={1}
1762
+ flexShrink={0}
1763
+ {...(width === undefined ? { flexGrow: 1 } : { width })}
1764
+ - border={[props.glyph === "▄" ? "top" : "bottom"]}
1765
+ + border={[props.edge]}
1766
+ borderColor={color}
1767
+ backgroundColor={theme.background.base}
1768
+ - customBorderChars={{ ...EmptyBorder, horizontal: props.glyph }}
1769
+ + customBorderChars={{ ...EmptyBorder, horizontal: asciiGlyphs() ? "-" : props.glyph }}
1770
+ />
1771
+ )
1772
+ return (
1773
+ diff --git a/packages/tui/src/feature-plugins/system/stats.tsx b/packages/tui/src/feature-plugins/system/stats.tsx
1774
+ index 950d322422dc6bc14ee66ef8b6483f2d0c57319b..ec6fb8625c6b8db28e3ae00dd7c72224f107688d 100644
1775
+ --- a/packages/tui/src/feature-plugins/system/stats.tsx
1776
+ +++ b/packages/tui/src/feature-plugins/system/stats.tsx
1777
+ @@ -6,6 +6,7 @@ import { useTerminalDimensions } from "@opentui/solid"
1778
+ import { createMemo, createResource, createSignal, For, Show } from "solid-js"
1779
+ import { Logo } from "../../component/logo"
1780
+ import { useTheme } from "../../context/theme"
1781
+ +import { chromeLabel } from "../../ui/glyphs"
1782
+ import { tint } from "../../theme/color"
1783
+ import { statsMetrics, statsNumber } from "./stats-data"
1784
+
1785
+ @@ -170,7 +171,7 @@ function StatsPage(props: { context: Plugin.Context; onClose: () => void }) {
1786
+ <text fg={theme.text.feedback.error.base}>Could not load stats. Reopen /stats to try again.</text>
1787
+ }
1788
+ >
1789
+ - <Show when={result()} fallback={<text fg={theme.text.muted}>Gathering your stats…</text>}>
1790
+ + <Show when={result()} fallback={<text fg={theme.text.muted}>{chromeLabel("Gathering your stats…")}</text>}>
1791
+ {(value) => <StatsPoster stats={value()} />}
1792
+ </Show>
1793
+ </Show>
1794
+ diff --git a/packages/tui/src/feature-plugins/system/storybook/footer.tsx b/packages/tui/src/feature-plugins/system/storybook/footer.tsx
1795
+ index 8b1fe2a51959f523f35cae10d223bfb55245934f..bead551cb82db977f41271877c51835bb0d524ea 100644
1796
+ --- a/packages/tui/src/feature-plugins/system/storybook/footer.tsx
1797
+ +++ b/packages/tui/src/feature-plugins/system/storybook/footer.tsx
1798
+ @@ -1,5 +1,6 @@
1799
+ import type { Plugin } from "@opencode/plugin/tui"
1800
+ import { For, Show } from "solid-js"
1801
+ +import { chromeGlyph } from "../../../ui/glyphs"
1802
+
1803
+ export type StoryFooterControl = {
1804
+ shortcut: string
1805
+ @@ -21,7 +22,7 @@ export function StoryFooter(props: {
1806
+ <box height={1} paddingLeft={1} paddingRight={1} flexDirection="row">
1807
+ <text fg={theme.text.base}>{props.title}</text>
1808
+ <Show when={props.details?.length}>
1809
+ - <text fg={theme.text.muted}> · {props.details?.join(" · ")}</text>
1810
+ + <text fg={theme.text.muted}> {chromeGlyph("·")} {props.details?.join(` ${chromeGlyph("·")} `)}</text>
1811
+ </Show>
1812
+ </box>
1813
+ <Show when={props.status || props.message}>
1814
+ @@ -29,7 +30,7 @@ export function StoryFooter(props: {
1815
+ <text fg={theme.text.base} wrapMode="none">
1816
+ {props.status ?? ""}
1817
+ <span style={{ fg: theme.text.muted }}>
1818
+ - {props.status && props.message ? " · " : ""}
1819
+ + {props.status && props.message ? ` ${chromeGlyph("·")} ` : ""}
1820
+ {props.message ?? ""}
1821
+ </span>
1822
+ </text>
1823
+ diff --git a/packages/tui/src/feature-plugins/system/storybook/index.tsx b/packages/tui/src/feature-plugins/system/storybook/index.tsx
1824
+ index f69e3da68b8b14905443d7e77539d7db013e5599..aab75d8fc8242619d758ee95393af04e0aefdf0d 100644
1825
+ --- a/packages/tui/src/feature-plugins/system/storybook/index.tsx
1826
+ +++ b/packages/tui/src/feature-plugins/system/storybook/index.tsx
1827
+ @@ -8,6 +8,7 @@ import { sessionLocationMissingStory } from "./session-location-missing"
1828
+ import { oneCellSpinnerStory } from "./one-cell-spinner"
1829
+ import { linkClicksStory } from "./link-clicks"
1830
+ import { highlightsStory } from "./highlights"
1831
+ +import { chromeGlyph } from "../../../ui/glyphs"
1832
+
1833
+ /**
1834
+ * A story is a full-screen, fixture-driven simulation of a real production component. Stories own
1835
+ @@ -115,7 +116,7 @@ function StorybookIndex(props: { context: Plugin.Context }) {
1836
+ <For each={stories}>
1837
+ {(story, index) => (
1838
+ <text fg={index() === selected() ? theme.text.base : theme.text.muted}>
1839
+ - {index() === selected() ? "› " : " "}
1840
+ + {index() === selected() ? `${chromeGlyph("›")} ` : " "}
1841
+ {index() + 1} {story.title}
1842
+ </text>
1843
+ )}
1844
+ @@ -126,7 +127,7 @@ function StorybookIndex(props: { context: Plugin.Context }) {
1845
+ context={props.context}
1846
+ title="storybook"
1847
+ controls={[
1848
+ - { shortcut: "↑/↓", label: "select" },
1849
+ + { shortcut: `${chromeGlyph("↑")}/${chromeGlyph("↓")}`, label: "select" },
1850
+ { shortcut: "enter", label: "open" },
1851
+ { shortcut: "esc", label: "home" },
1852
+ ]}
1853
+ diff --git a/packages/tui/src/feature-plugins/system/storybook/merman-layouts.tsx b/packages/tui/src/feature-plugins/system/storybook/merman-layouts.tsx
1854
+ index 19c6ed906b936220047063ff1598b8d9e8d04717..d7348f8e3b10f387480eb10283fe71bec8b65765 100644
1855
+ --- a/packages/tui/src/feature-plugins/system/storybook/merman-layouts.tsx
1856
+ +++ b/packages/tui/src/feature-plugins/system/storybook/merman-layouts.tsx
1857
+ @@ -5,6 +5,7 @@ import { useTheme, useThemes } from "../../../context/theme"
1858
+ import { usePlugin } from "../../../plugin/context"
1859
+ import type { Story } from "./index"
1860
+ import { StoryFooter } from "./footer"
1861
+ +import { chromeGlyph } from "../../../ui/glyphs"
1862
+
1863
+ const fixtures = [
1864
+ {
1865
+ @@ -185,7 +186,7 @@ function MermanLayoutsStory(props: { context: Plugin.Context }) {
1866
+ title="storybook / Mermaid layouts"
1867
+ details={[`${selected() + 1}/${fixtures.length}`, fixture().id, `${dimensions().width}x${dimensions().height}`]}
1868
+ controls={[
1869
+ - { shortcut: "j/k or ←/→", label: "fixture" },
1870
+ + { shortcut: `j/k or ${chromeGlyph("←")}/${chromeGlyph("→")}`, label: "fixture" },
1871
+ { shortcut: "r", label: "reset" },
1872
+ { shortcut: "esc", label: "back" },
1873
+ ]}
1874
+ diff --git a/packages/tui/src/feature-plugins/system/storybook/session-location-missing.tsx b/packages/tui/src/feature-plugins/system/storybook/session-location-missing.tsx
1875
+ index c62d07b95414c2d9cb0b2ecf60e20c939081cf12..fd17fd9a0e5e8c280c441d79f27fe2cd7d9b7e81 100644
1876
+ --- a/packages/tui/src/feature-plugins/system/storybook/session-location-missing.tsx
1877
+ +++ b/packages/tui/src/feature-plugins/system/storybook/session-location-missing.tsx
1878
+ @@ -6,6 +6,7 @@ import { DialogWorkspaces } from "../../../component/dialog-workspaces"
1879
+ import { SessionLocationUnavailable } from "../../../routes/session/location-missing"
1880
+ import type { Story } from "./index"
1881
+ import { StoryFooter } from "./footer"
1882
+ +import { chromeGlyph } from "../../../ui/glyphs"
1883
+
1884
+ const directory = "/Users/kit/code/open-source/opencode-workerd-profile"
1885
+
1886
+ @@ -50,12 +51,12 @@ function SessionLocationMissingStory(props: { context: Plugin.Context }) {
1887
+ <text fg={theme.text.base} attributes={TextAttributes.BOLD}>
1888
+ Workerd Modal workspace driver
1889
+ </text>
1890
+ - <text fg={theme.text.muted}>build · GPT-5.6 Sol (high)</text>
1891
+ + <text fg={theme.text.muted}>build {chromeGlyph("·")} GPT-5.6 Sol (high)</text>
1892
+ <box height={1} />
1893
+ <text fg={theme.text.base}>You</text>
1894
+ <text fg={theme.text.muted}>Test the mounted workspace and verify the deployment.</text>
1895
+ <box height={1} />
1896
+ - <text fg={theme.text.base}>Build · GPT-5.6 Sol (high)</text>
1897
+ + <text fg={theme.text.base}>Build {chromeGlyph("·")} GPT-5.6 Sol (high)</text>
1898
+ <text fg={theme.text.muted}>The deployment is verified and the worktree is clean.</text>
1899
+ <box flexGrow={1} />
1900
+ <SessionLocationUnavailable directory={directory} onMove={open} />
1901
+ diff --git a/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx b/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx
1902
+ index 81a854320c883fdcdf2dcb46c0920cc63e4517bf..69321995d903d39133e782c042ad6b9354e6fe43 100644
1903
+ --- a/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx
1904
+ +++ b/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx
1905
+ @@ -13,6 +13,7 @@ import {
1906
+ } from "../../../component/session-tabs"
1907
+ import { closeSessionTab, cycleSessionTab, moveSessionTab } from "../../../context/session-tabs-model"
1908
+ import { StoryFooter } from "./footer"
1909
+ +import { chromeGlyph, chromeLabel } from "../../../ui/glyphs"
1910
+ import { DialogPrompt } from "../../../ui/dialog-prompt"
1911
+ import { useDialog } from "../../../ui/dialog"
1912
+ import { DialogSelect } from "../../../ui/dialog-select"
1913
+ @@ -276,26 +277,26 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
1914
+ return lines
1915
+ }
1916
+ lines.push(
1917
+ - { text: "● Taking a look — reading the relevant code first.", color: theme.text.base },
1918
+ + { text: `${chromeGlyph("●")} Taking a look — reading the relevant code first.`, color: theme.text.base },
1919
+ { text: "", color: theme.text.base },
1920
+ - { text: ` ✱ Read ${file}`, color: theme.text.muted },
1921
+ - { text: ` ✱ Edit ${file}`, color: theme.text.muted },
1922
+ - { text: ` ✱ Bash bun run test`, color: theme.text.muted },
1923
+ + { text: ` ${chromeGlyph("✱")} Read ${file}`, color: theme.text.muted },
1924
+ + { text: ` ${chromeGlyph("✱")} Edit ${file}`, color: theme.text.muted },
1925
+ + { text: ` ${chromeGlyph("✱")} Bash bun run test`, color: theme.text.muted },
1926
+ { text: "", color: theme.text.base },
1927
+ )
1928
+ if (status.attention === "question")
1929
+ lines.push({ text: "? Which approach should I take?", color: theme.hue.accent[200] })
1930
+ else if (status.attention === "permission")
1931
+ lines.push({ text: "! Waiting for permission to run the command", color: theme.hue.accent[200] })
1932
+ - else if (status.busy) lines.push({ text: "● Working…", color: theme.hue.interactive[200] })
1933
+ + else if (status.busy) lines.push({ text: `${chromeGlyph("●")} ${chromeLabel("Working…")}`, color: theme.hue.interactive[200] })
1934
+ else if (outcome === "failed")
1935
+ lines.push({
1936
+ - text: `✗ bun run test failed — 3 tests failing in ${file}`,
1937
+ + text: `${chromeGlyph("✗")} bun run test failed — 3 tests failing in ${file}`,
1938
+ color: theme.text.feedback.error.base,
1939
+ })
1940
+ else
1941
+ lines.push({
1942
+ - text: `✓ Done — updated ${file} and the tests pass.`,
1943
+ + text: `${chromeGlyph("✓")} Done — updated ${file} and the tests pass.`,
1944
+ color: theme.text.feedback.success.base,
1945
+ })
1946
+ return lines
1947
+ @@ -307,7 +308,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
1948
+ const waiting = values.filter((status) => status.attention).length
1949
+ const unread = values.filter((status) => status.unread !== undefined).length
1950
+ return [`selected ${number(active() ?? "")}`, `${running} running`, `${waiting} waiting`, `${unread} unread`].join(
1951
+ - " · ",
1952
+ + ` ${chromeGlyph("·")} `,
1953
+ )
1954
+ }
1955
+
1956
+ @@ -529,7 +530,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
1957
+ context={props.context}
1958
+ title="storybook / tabs"
1959
+ details={[
1960
+ - vertical() ? `${railCompact() ? "compact rail" : "expanded rail"} · ${resize.size()} cols` : "top strip",
1961
+ + vertical() ? `${railCompact() ? "compact rail" : "expanded rail"} ${chromeGlyph("·")} ${resize.size()} cols` : "top strip",
1962
+ spinner(),
1963
+ indicators(),
1964
+ `${TAB_UNREAD_MARKERS[marker()]} ${marker()}`,
1965
+ @@ -552,7 +553,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
1966
+ { shortcut: "g", label: "status/numbers" },
1967
+ { shortcut: "u", label: "unread marker" },
1968
+ { shortcut: "m", label: "motion" },
1969
+ - { shortcut: "↑/↓", label: "select" },
1970
+ + { shortcut: `${chromeGlyph("↑")}/${chromeGlyph("↓")}`, label: "select" },
1971
+ { shortcut: "o", label: "layout" },
1972
+ { shortcut: "drag edge", label: "resize / double-click reset" },
1973
+ { shortcut: "r", label: "reset idle" },
1974
+ diff --git a/packages/tui/src/routes/session/activity-summary.ts b/packages/tui/src/routes/session/activity-summary.ts
1975
+ index 6578f50d632ae57f4c15069dac23131664d95691..a9676c9031424d80969ee45d0b5b09a812e487a1 100644
1976
+ --- a/packages/tui/src/routes/session/activity-summary.ts
1977
+ +++ b/packages/tui/src/routes/session/activity-summary.ts
1978
+ @@ -9,6 +9,7 @@ import { visitEntries } from "./anchor-view"
1979
+ import { instructionPaths, type PartRef, type SessionEntry, type SessionNode } from "./grouping/session"
1980
+ import { reasoningContent } from "./message-parts"
1981
+ import { resolvePart } from "./rows"
1982
+ +import { chromeEllipsis, chromeLabel } from "../../ui/glyphs"
1983
+
1984
+ type Item = {
1985
+ message: SessionMessageAssistant
1986
+ @@ -45,10 +46,12 @@ export function summarizeActivity(
1987
+
1988
+ /** Status for a running item, independent of its details, so it doesn't flicker as they arrive. */
1989
+ export function busyLabel(part: Item["part"]) {
1990
+ - if (part.type === "reasoning") return "Thinking…"
1991
+ + if (part.type === "reasoning") return chromeLabel("Thinking…")
1992
+ const name = canonicalToolName(part.name)
1993
+ const noun = name === "shell" ? "command" : name === "execute" ? "code" : name
1994
+ - return `${part.state.status === "streaming" ? "Preparing" : "Running"} ${noun}…`
1995
+ + const verb = part.state.status === "streaming" ? "Preparing" : "Running"
1996
+ + if (noun === "command" || noun === "code") return `${verb} ${noun}${chromeEllipsis()}`
1997
+ + return `${verb} ${noun}${chromeEllipsis()}`
1998
+ }
1999
+
2000
+ /**
2001
+ diff --git a/packages/tui/src/routes/session/composer/index.tsx b/packages/tui/src/routes/session/composer/index.tsx
2002
+ index 2af8bfb521a6327967c1c57c1fdb13842518fa71..4427c90c996b0ce00b2120707c52202f71eb02db 100644
2003
+ --- a/packages/tui/src/routes/session/composer/index.tsx
2004
+ +++ b/packages/tui/src/routes/session/composer/index.tsx
2005
+ @@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
2006
+ import { TextAttributes } from "@opentui/core"
2007
+ import { useTheme } from "../../../context/theme"
2008
+ import { SplitBorder } from "../../../ui/border"
2009
+ +import { chromeGlyph } from "../../../ui/glyphs"
2010
+ import { Keymap } from "../../../context/keymap"
2011
+ import { SubagentsTab } from "./subagents-tab"
2012
+ import { ShellTab } from "./shell-tab"
2013
+ @@ -145,7 +146,7 @@ export function Composer(props: ComposerProps) {
2014
+ <span style={{ fg: theme.text.base }}>
2015
+ <b>tabs</b>{" "}
2016
+ </span>
2017
+ - <span style={{ fg: theme.text.muted }}>←/→</span>
2018
+ + <span style={{ fg: theme.text.muted }}>{`${chromeGlyph("←")}/${chromeGlyph("→")}`}</span>
2019
+ </text>
2020
+ </Show>
2021
+ </box>
2022
+ diff --git a/packages/tui/src/routes/session/dialog-execute.tsx b/packages/tui/src/routes/session/dialog-execute.tsx
2023
+ index 86f9c8f1c52161f1135debb74cc2dc96e81c23e8..b8d17400714805aa8c9607a34fe6582e379242a5 100644
2024
+ --- a/packages/tui/src/routes/session/dialog-execute.tsx
2025
+ +++ b/packages/tui/src/routes/session/dialog-execute.tsx
2026
+ @@ -17,6 +17,7 @@ import {
2027
+ dialogWidth,
2028
+ useDialog,
2029
+ } from "../../ui/dialog"
2030
+ +import { chromeGlyph, chromeLabel } from "../../ui/glyphs"
2031
+ import { useToast } from "../../ui/toast"
2032
+ import { Locale } from "../../util/locale"
2033
+ import { getScrollAcceleration } from "../../util/scroll"
2034
+ @@ -67,10 +68,10 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) {
2035
+ })
2036
+ const status = createMemo(() => {
2037
+ const state = props.part.state
2038
+ - if (state.status === "streaming") return "Receiving code…"
2039
+ + if (state.status === "streaming") return chromeLabel("Receiving code…")
2040
+ if (state.status === "running") return "Running"
2041
+ const duration = props.part.time.completed
2042
+ - ? ` · ${Locale.duration(props.part.time.completed - (props.part.time.ran ?? props.part.time.created))}`
2043
+ + ? ` ${chromeGlyph("·")} ${Locale.duration(props.part.time.completed - (props.part.time.ran ?? props.part.time.created))}`
2044
+ : ""
2045
+ if (failed()) return `Failed${duration}`
2046
+ return `Completed${duration}`
2047
+ @@ -101,10 +102,10 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) {
2048
+
2049
+ const copyActions = () => [
2050
+ copied() === "code"
2051
+ - ? { title: "✓ copied", color: theme.text.feedback.success.base, onTrigger: () => copy("code") }
2052
+ + ? { title: `${chromeGlyph("✓")} copied`, color: theme.text.feedback.success.base, onTrigger: () => copy("code") }
2053
+ : { title: "copy code", key: "c", onTrigger: () => copy("code") },
2054
+ copied() === "output"
2055
+ - ? { title: "✓ copied", color: theme.text.feedback.success.base, onTrigger: () => copy("output") }
2056
+ + ? { title: `${chromeGlyph("✓")} copied`, color: theme.text.feedback.success.base, onTrigger: () => copy("output") }
2057
+ : { title: "copy output", key: "o", onTrigger: () => copy("output") },
2058
+ ]
2059
+
2060
+ @@ -127,7 +128,7 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) {
2061
+ <DialogFooter
2062
+ actions={{
2063
+ secondary: [
2064
+ - { title: "scroll", key: "↑/↓" },
2065
+ + { title: "scroll", key: `${chromeGlyph("↑")}/${chromeGlyph("↓")}` },
2066
+ ...copyActions(),
2067
+ { title: "back", key: "esc", onTrigger: () => dialog.clear() },
2068
+ ],
2069
+ @@ -150,7 +151,7 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) {
2070
+ <text fg={contentTheme.text.muted} attributes={TextAttributes.BOLD}>
2071
+ Code
2072
+ </text>
2073
+ - <Show when={code()} fallback={<text fg={contentTheme.text.muted}>Waiting for code…</text>}>
2074
+ + <Show when={code()} fallback={<text fg={contentTheme.text.muted}>{chromeLabel("Waiting for code…")}</text>}>
2075
+ {(value) => <GutteredCode content={value()} filetype="typescript" digits={digits()} />}
2076
+ </Show>
2077
+ </box>
2078
+ @@ -171,7 +172,7 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) {
2079
+ }
2080
+ wrapMode="word"
2081
+ >
2082
+ - {text() ?? (props.part.state.status === "completed" ? "No output" : "Waiting for output…")}
2083
+ + {text() ?? (props.part.state.status === "completed" ? "No output" : chromeLabel("Waiting for output…"))}
2084
+ </text>
2085
+ }
2086
+ >
2087
+ diff --git a/packages/tui/src/routes/session/dialog-fork.tsx b/packages/tui/src/routes/session/dialog-fork.tsx
2088
+ index 2ba1a27fe144989498a33a5f113d4a9e41c0f25f..df2666114f13caa68e5c34a9b3c891d94b277fb7 100644
2089
+ --- a/packages/tui/src/routes/session/dialog-fork.tsx
2090
+ +++ b/packages/tui/src/routes/session/dialog-fork.tsx
2091
+ @@ -3,6 +3,7 @@ import { useData } from "../../context/data"
2092
+ import { useRoute } from "../../context/route"
2093
+ import { useClient } from "../../context/client"
2094
+ import { Spinner } from "../../component/spinner"
2095
+ +import { chromeLabel } from "../../ui/glyphs"
2096
+ import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
2097
+ import { DIALOG_LIST_INSET, DialogHeader, DialogLayout, useDialog } from "../../ui/dialog"
2098
+ import { useToast } from "../../ui/toast"
2099
+ @@ -75,7 +76,7 @@ export function DialogFork(props: { sessionID: string; messageID?: string; onMov
2100
+ when={!pending()}
2101
+ fallback={
2102
+ <DialogLayout inset={DIALOG_LIST_INSET} header={<DialogHeader title="Fork session" />}>
2103
+ - <Spinner>Forking session…</Spinner>
2104
+ + <Spinner>{chromeLabel("Forking session…")}</Spinner>
2105
+ </DialogLayout>
2106
+ }
2107
+ >
2108
+ diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx
2109
+ index cba007198f026230d5c2051379fa7a9056c6aa52..220b491c7eb10a6407f127f6b9afae4e14a01716 100644
2110
+ --- a/packages/tui/src/routes/session/form.tsx
2111
+ +++ b/packages/tui/src/routes/session/form.tsx
2112
+ @@ -15,6 +15,7 @@ import type { FormAnswer, FormField, FormValue } from "@opencode/client"
2113
+ import { useData, type FormWithLocation } from "../../context/data"
2114
+ import { useClipboard } from "../../context/clipboard"
2115
+ import { SplitBorder } from "../../ui/border"
2116
+ +import { chromeGlyph, chromeEllipsis } from "../../ui/glyphs"
2117
+ import { useToast } from "../../ui/toast"
2118
+ import { Keymap } from "../../context/keymap"
2119
+ import { useInteractivity } from "../../context/interactivity"
2120
+ @@ -38,7 +39,7 @@ import type { FormAnswerField } from "../../util/form"
2121
+ export const FORM_MODE = "form"
2122
+
2123
+ function truncate(label: string, max: number) {
2124
+ - return label.length > max ? label.slice(0, max - 1).trimEnd() + "…" : label
2125
+ + return label.length > max ? label.slice(0, max - 1).trimEnd() + chromeEllipsis() : label
2126
+ }
2127
+
2128
+ type FormDraft = {
2129
+ @@ -787,7 +788,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
2130
+ </text>
2131
+ <Show when={fields().length > 0}>
2132
+ <text fg={theme.text.muted}>
2133
+ - · {answered()}/{fields().length} completed
2134
+ + {chromeGlyph("·")} {answered()}/{fields().length} completed
2135
+ </text>
2136
+ </Show>
2137
+ </box>
2138
+ @@ -868,7 +869,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
2139
+ fg={store.answers[external().key] === true ? theme.text.feedback.success.base : theme.text.muted}
2140
+ >
2141
+ {store.answers[external().key] === true
2142
+ - ? "✓ Acknowledged"
2143
+ + ? `${chromeGlyph("✓")} Acknowledged`
2144
+ : store.externalReady[external().key]
2145
+ ? "Complete the external action, then press enter to confirm."
2146
+ : "Open or copy the URL, complete the external action, then confirm."}
2147
+ @@ -952,7 +953,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
2148
+ : theme.text.muted
2149
+ }
2150
+ >
2151
+ - [{picked() ? "✓" : " "}]
2152
+ + [{picked() ? chromeGlyph("✓") : " "}]
2153
+ </text>
2154
+ </Show>
2155
+ <text fg={active() ? theme.text.formfield.focused : theme.text.formfield.base}>
2156
+ @@ -960,7 +961,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
2157
+ </text>
2158
+ </box>
2159
+ <Show when={!multi()}>
2160
+ - <text fg={theme.text.formfield.selected}>{picked() ? " ✓" : ""}</text>
2161
+ + <text fg={theme.text.formfield.selected}>{picked() ? ` ${chromeGlyph("✓")}` : ""}</text>
2162
+ </Show>
2163
+ </box>
2164
+ <Show when={row.description}>
2165
+ @@ -1007,7 +1008,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
2166
+ : theme.text.muted
2167
+ }
2168
+ >
2169
+ - [{customChecked() ? "✓" : " "}]
2170
+ + [{customChecked() ? chromeGlyph("✓") : " "}]
2171
+ </text>
2172
+ </Show>
2173
+ <Show
2174
+ @@ -1018,7 +1019,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
2175
+ {input() || "Type your own answer"}
2176
+ </text>
2177
+ <Show when={!multi() && customPicked()}>
2178
+ - <text fg={theme.text.formfield.selected}>✓</text>
2179
+ + <text fg={theme.text.formfield.selected}>{chromeGlyph("✓")}</text>
2180
+ </Show>
2181
+ </>
2182
+ }
2183
+ @@ -1134,17 +1135,17 @@ export function FormPrompt(props: { form: FormWithLocation }) {
2184
+ <box flexDirection="row" gap={2}>
2185
+ <Show when={!single()}>
2186
+ <text fg={theme.text.base}>
2187
+ - {"⇆"} <span style={{ fg: theme.text.muted }}>tab</span>
2188
+ + {chromeGlyph("⇆")} <span style={{ fg: theme.text.muted }}>tab</span>
2189
+ </text>
2190
+ </Show>
2191
+ <Show when={!confirm() && !textual() && !externalField() && !store.editing}>
2192
+ <text fg={theme.text.base}>
2193
+ - {"↑↓"} <span style={{ fg: theme.text.muted }}>select</span>
2194
+ + {`${chromeGlyph("↑")}${chromeGlyph("↓")}`} <span style={{ fg: theme.text.muted }}>select</span>
2195
+ </text>
2196
+ </Show>
2197
+ <Show when={confirm() && reviewContentHeight() > reviewMaxHeight()}>
2198
+ <text fg={theme.text.base}>
2199
+ - {"↑↓"} <span style={{ fg: theme.text.muted }}>scroll</span>
2200
+ + {`${chromeGlyph("↑")}${chromeGlyph("↓")}`} <span style={{ fg: theme.text.muted }}>scroll</span>
2201
+ </text>
2202
+ </Show>
2203
+ <text
2204
+ diff --git a/packages/tui/src/routes/session/group-view.tsx b/packages/tui/src/routes/session/group-view.tsx
2205
+ index 2e09fc5e84974454fdc4b2106ab89f68c99f13da..bcf83e0c9cbd66ac9e51791c4e880ca6e2b0f791 100644
2206
+ --- a/packages/tui/src/routes/session/group-view.tsx
2207
+ +++ b/packages/tui/src/routes/session/group-view.tsx
2208
+ @@ -5,6 +5,7 @@ import type { SessionMessageAssistantTool, SessionMessageInfo } from "@opencode/
2209
+ import { createSyntaxStyleMemo, useTheme, useThemes } from "../../context/theme"
2210
+ import { reasoningSummary } from "../../context/thinking"
2211
+ import { SplitBorder } from "../../ui/border"
2212
+ +import { chromeGlyph } from "../../ui/glyphs"
2213
+ import { Locale } from "../../util/locale"
2214
+ import { EntryAnchor, GroupAnchor, visitEntries } from "./anchor-view"
2215
+ import { groupID } from "./anchors"
2216
+ @@ -142,7 +143,7 @@ function GroupContent(props: GroupProps) {
2217
+ <Show when={grouped()} fallback={children("normal")}>
2218
+ <Show when={tools().length > 0}>
2219
+ <InlineToolRow
2220
+ - icon={completed() ? "→" : "✱"}
2221
+ + icon={chromeGlyph(completed() ? "→" : "✱")}
2222
+ color={hover() ? theme.text.base : theme.text.muted}
2223
+ complete={completed()}
2224
+ pending={label()}
2225
+ @@ -184,8 +185,8 @@ function GroupContent(props: GroupProps) {
2226
+ >
2227
+ {props.completed ? "Thought" : latest() ? `Thinking: ${latest()}` : "Thinking"}
2228
+ <Show when={props.completed && !expanded() && latest()}>: {latest()}</Show>
2229
+ - <Show when={props.completed && thoughts().length > 1}> · {thoughts().length} steps</Show>
2230
+ - <Show when={props.completed && duration()}> · {Locale.duration(duration())}</Show>
2231
+ + <Show when={props.completed && thoughts().length > 1}> {chromeGlyph("·")} {thoughts().length} steps</Show>
2232
+ + <Show when={props.completed && duration()}> {chromeGlyph("·")} {Locale.duration(duration())}</Show>
2233
+ </InlineToolRow>
2234
+ <Show when={expanded()}>
2235
+ <box paddingLeft={3}>{children("thought")}</box>
2236
+ @@ -209,7 +210,7 @@ function ActivityGroup(props: GroupProps) {
2237
+ <GroupAnchor groupID={disclosure.id()} active={summary().label !== ""}>
2238
+ <Show when={summary().label}>
2239
+ <InlineToolRow
2240
+ - icon={disclosure.expanded() ? "−" : "+"}
2241
+ + icon={activityGroupDisclosureIcon(disclosure.expanded())}
2242
+ color={hover() ? theme.text.base : theme.text.muted}
2243
+ complete={true}
2244
+ pending={summary().label}
2245
+ @@ -247,7 +248,7 @@ function InstructionsGroup(props: GroupProps) {
2246
+ return (
2247
+ <GroupAnchor groupID={disclosure.id()} active={files() > 0}>
2248
+ <InlineToolRow
2249
+ - icon="◈"
2250
+ + icon={chromeGlyph("◈")}
2251
+ color={hover() ? theme.text.base : theme.text.muted}
2252
+ complete={true}
2253
+ pending=""
2254
+ @@ -290,6 +291,10 @@ function descendants(node: Extract<SessionNode, { type: "group" }>) {
2255
+ }
2256
+
2257
+ /** Permission-blocked tools stay visible below the root group, even when collapsed. */
2258
+ +export function activityGroupDisclosureIcon(expanded: boolean) {
2259
+ + return expanded ? chromeGlyph("−") : "+"
2260
+ +}
2261
+ +
2262
+ function PendingEntries(props: GroupProps & { entries: readonly SessionEntry[] }) {
2263
+ return (
2264
+ <Show when={!props.pendingOutside}>
2265
+ diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx
2266
+ index 0b8228ef8714b05a4e675b6f9e58a116fb9367ba..e7077d07a8897c79659c7ba61d39113616886017 100644
2267
+ --- a/packages/tui/src/routes/session/index.tsx
2268
+ +++ b/packages/tui/src/routes/session/index.tsx
2269
+ @@ -19,6 +19,7 @@ import { useRoute, useRouteData } from "../../context/route"
2270
+ import { createStore } from "solid-js/store"
2271
+ import { useData } from "../../context/data"
2272
+ import { SplitBorder } from "../../ui/border"
2273
+ +import { chromeEllipsis, chromeGlyph, chromeLabel } from "../../ui/glyphs"
2274
+ import { useTuiTerminalEnvironment } from "../../context/runtime"
2275
+ import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
2276
+ import { PatchDiff } from "../../component/patch-diff"
2277
+ @@ -191,7 +192,7 @@ export function Session(props: {
2278
+ createEffect(() => currentLocation.set(location()))
2279
+
2280
+ createEffect(() => {
2281
+ - const title = Locale.truncate(session()?.title ?? "", 50)
2282
+ + const title = Locale.truncate(session()?.title ?? "", 50, chromeEllipsis())
2283
+ setEpilogue(sessionEpilogue({ title, sessionID: session()?.id }))
2284
+ })
2285
+ onCleanup(() => setEpilogue())
2286
+ @@ -1441,7 +1442,7 @@ export function Session(props: {
2287
+ </box>
2288
+ <box height={1} flexShrink={0} flexDirection="row" justifyContent="flex-end">
2289
+ <Show when={firstJump()}>
2290
+ - <text fg={theme.text.feedback.info.base}>Loading session history…</text>
2291
+ + <text fg={theme.text.feedback.info.base}>{chromeLabel("Loading session history…")}</text>
2292
+ </Show>
2293
+ <Show when={!firstJump() && awayFromBottom()}>
2294
+ <box
2295
+ @@ -1454,7 +1455,7 @@ export function Session(props: {
2296
+ <text
2297
+ fg={latestHovered() ? theme.text.action.secondary.hovered : theme.text.action.secondary.base}
2298
+ >
2299
+ - Jump to latest ↓
2300
+ + Jump to latest {chromeGlyph("↓")}
2301
+ </text>
2302
+ </box>
2303
+ </Show>
2304
+ @@ -1677,14 +1678,14 @@ function TurnTokenUsage(props: {
2305
+ <span>{expanded() ? "- " : "+ "}</span>
2306
+ <span style={{ attributes: TextAttributes.BOLD }}>Tokens</span>
2307
+ <span>
2308
+ - : {summary().count} {summary().count === 1 ? "step" : "steps"} · latest:{" "}
2309
+ - {summary().latestNewTokens.toLocaleString()} new · {summary().latestCached.toLocaleString()} cached ·{" "}
2310
+ + : {summary().count} {summary().count === 1 ? "step" : "steps"} {chromeGlyph("·")} latest:{" "}
2311
+ + {summary().latestNewTokens.toLocaleString()} new {chromeGlyph("·")} {summary().latestCached.toLocaleString()} cached {chromeGlyph("·")}{" "}
2312
+ {summary().latestTotal.toLocaleString()} total
2313
+ </span>
2314
+ <Show when={summary().reuseDrops > 0}>
2315
+ <span style={{ fg: theme.text.feedback.warning.base }}>
2316
+ {" "}
2317
+ - · ! {summary().reuseDrops} likely cache {summary().reuseDrops === 1 ? "bust" : "busts"}
2318
+ + {chromeGlyph("·")} ! {summary().reuseDrops} likely cache {summary().reuseDrops === 1 ? "bust" : "busts"}
2319
+ </span>
2320
+ </Show>
2321
+ </text>
2322
+ @@ -1914,16 +1915,16 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
2323
+ {Locale.titlecase(props.message.agent)}
2324
+ </span>
2325
+ <Show when={ctx.terminal.width >= 28}>
2326
+ - <span style={{ fg: theme.text.muted }}> · {model()}</span>
2327
+ + <span style={{ fg: theme.text.muted }}> {chromeGlyph("·")} {model()}</span>
2328
+ </Show>
2329
+ <Show when={duration() && (ctx.terminal.width < 28 || ctx.terminal.width >= 36)}>
2330
+ - <span style={{ fg: theme.text.muted }}> · {Locale.duration(duration())}</span>
2331
+ + <span style={{ fg: theme.text.muted }}> {chromeGlyph("·")} {Locale.duration(duration())}</span>
2332
+ </Show>
2333
+ <Show when={config.data.session.tps && tokensPerSecond()}>
2334
+ - {(value) => <span style={{ fg: theme.text.muted }}> · {value().toFixed(1)} tok/s</span>}
2335
+ + {(value) => <span style={{ fg: theme.text.muted }}> {chromeGlyph("·")} {value().toFixed(1)} tok/s</span>}
2336
+ </Show>
2337
+ <Show when={interrupted()}>
2338
+ - <span style={{ fg: theme.text.muted }}> · interrupted</span>
2339
+ + <span style={{ fg: theme.text.muted }}> {chromeGlyph("·")} interrupted</span>
2340
+ </Show>
2341
+ </text>
2342
+ </box>
2343
+ @@ -1938,7 +1939,7 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
2344
+ return (
2345
+ <box paddingLeft={3}>
2346
+ <text>
2347
+ - <span style={{ fg: theme.text.muted }}>↳ Moved to </span>
2348
+ + <span style={{ fg: theme.text.muted }}>{chromeGlyph("↳")} Moved to </span>
2349
+ <span style={{ fg: theme.text.feedback.info.base }}>{props.message.location.directory}</span>
2350
+ </text>
2351
+ </box>
2352
+ @@ -1984,8 +1985,9 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
2353
+ if (state() === "error") return "failed"
2354
+ return state() ?? "finished"
2355
+ }
2356
+ - const heading = () => `${state() === "completed" ? "↳" : "!"} ${actor()} ${status()}`
2357
+ - const suffix = () => Locale.truncateWidth(` · ${description()}`, Math.max(0, ctx.width - 3 - stringWidth(heading())))
2358
+ + const heading = () => `${chromeGlyph(state() === "completed" ? "↳" : "!")} ${actor()} ${status()}`
2359
+ + const suffix = () =>
2360
+ + Locale.truncateWidth(` ${chromeGlyph("·")} ${description()}`, Math.max(0, ctx.width - 3 - stringWidth(heading())), chromeEllipsis())
2361
+ const color = () => {
2362
+ if (state() === "error") return theme.text.feedback.error.base
2363
+ if (state() === "cancelled") return theme.text.feedback.warning.base
2364
+ @@ -1996,7 +1998,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
2365
+ <Show
2366
+ when={completion()}
2367
+ fallback={
2368
+ - <InlineToolRow icon="◈" color={theme.text.muted} pending="Notice" complete={true}>
2369
+ + <InlineToolRow icon={chromeGlyph("◈")} color={theme.text.muted} pending="Notice" complete={true}>
2370
+ {text()}
2371
+ </InlineToolRow>
2372
+ }
2373
+ @@ -2023,7 +2025,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
2374
+ function SessionSkillMessage(props: { message: Extract<SessionMessageInfo, { type: "skill" }> }) {
2375
+ const theme = useTheme()
2376
+ return (
2377
+ - <InlineToolRow icon="→" color={theme.text.muted} pending="Skill" complete={true}>
2378
+ + <InlineToolRow icon={chromeGlyph("→")} color={theme.text.muted} pending="Skill" complete={true}>
2379
+ Skill {props.message.name}
2380
+ </InlineToolRow>
2381
+ )
2382
+ @@ -2047,7 +2049,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
2383
+ const input = tokens.input + tokens.cache.read + tokens.cache.write
2384
+ const output = tokens.output + tokens.reasoning
2385
+ if (input + output <= 0) return
2386
+ - return `${Locale.number(input)} in · ${Locale.number(output)} out`
2387
+ + return `${Locale.number(input)} in ${chromeGlyph("·")} ${Locale.number(output)} out`
2388
+ }
2389
+ return (
2390
+ <box>
2391
+ @@ -2056,12 +2058,12 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
2392
+ <box flexDirection="row" gap={1} paddingLeft={1} paddingRight={1}>
2393
+ <Switch>
2394
+ <Match when={status() === "running"}>
2395
+ - <Show when={ctx.config.animations ?? true} fallback={<text fg={color()}>⋯</text>}>
2396
+ + <Show when={ctx.config.animations ?? true} fallback={<text fg={color()}>{chromeGlyph("…")}</text>}>
2397
+ <spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
2398
+ </Show>
2399
+ </Match>
2400
+ <Match when={status() === "failed" && !cancelled()}>
2401
+ - <text fg={color()}>✗</text>
2402
+ + <text fg={color()}>{chromeGlyph("✗")}</text>
2403
+ </Match>
2404
+ </Switch>
2405
+ <text fg={color()}>
2406
+ @@ -2070,10 +2072,10 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
2407
+ : "Compaction"}
2408
+ </text>
2409
+ <Show when={cancelled()}>
2410
+ - <text fg={color()}>· cancelled</text>
2411
+ + <text fg={color()}>{chromeGlyph("·")} cancelled</text>
2412
+ </Show>
2413
+ <Show when={usage()}>
2414
+ - <text fg={color()}>· {usage()}</text>
2415
+ + <text fg={color()}>{chromeGlyph("·")} {usage()}</text>
2416
+ </Show>
2417
+ </box>
2418
+ <box border={["top"]} borderColor={color()} flexGrow={1} />
2419
+ @@ -2103,7 +2105,7 @@ function CompactionQueued() {
2420
+ <box flexDirection="row" alignItems="center">
2421
+ <box border={["top"]} borderColor={theme.border.base} flexGrow={1} />
2422
+ <box flexDirection="row" gap={1} paddingLeft={1} paddingRight={1}>
2423
+ - <text fg={theme.text.muted}>◇</text>
2424
+ + <text fg={theme.text.muted}>{chromeGlyph("◇")}</text>
2425
+ <text fg={theme.text.muted}>Compaction queued</text>
2426
+ </box>
2427
+ <box border={["top"]} borderColor={theme.border.base} flexGrow={1} />
2428
+ @@ -2369,7 +2371,7 @@ function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOp
2429
+ >
2430
+ <text fg={theme.text.muted} wrapMode="none" truncate flexGrow={1} flexShrink={1} minWidth={0}>
2431
+ <span style={{ fg: theme.text.base }}>{props.prompts.length} queued</span>
2432
+ - <Show when={next()}>{(text) => <> · {text()}</>}</Show>
2433
+ + <Show when={next()}>{(text) => <> {chromeGlyph("·")} {text()}</>}</Show>
2434
+ </text>
2435
+ </box>
2436
+ </box>
2437
+ @@ -2394,7 +2396,7 @@ function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
2438
+ {(retry) => (
2439
+ <box paddingLeft={3}>
2440
+ <text fg={theme.text.feedback.warning.base}>
2441
+ - ⚠ {seconds() > 0 ? `Retrying in ${seconds()}s` : "Retry due"} · attempt {retry().attempt} ·{" "}
2442
+ + {chromeGlyph("⚠")} {seconds() > 0 ? `Retrying in ${seconds()}s` : "Retry due"} {chromeGlyph("·")} attempt {retry().attempt} {chromeGlyph("·")} {" "}
2443
+ {retry().error.message}
2444
+ </text>
2445
+ </box>
2446
+ @@ -2575,7 +2577,7 @@ function GenericTool(props: ToolProps) {
2447
+ return (
2448
+ <>
2449
+ <InlineTool
2450
+ - icon={props.part.state.status === "error" ? "✗" : "✓"}
2451
+ + icon={chromeGlyph(props.part.state.status === "error" ? "✗" : "✓")}
2452
+ complete={props.part.state.status === "completed"}
2453
+ pending={props.tool}
2454
+ spinner={loading()}
2455
+ @@ -2943,9 +2945,9 @@ function ShellDisplay(props: {
2456
+ when={props.command}
2457
+ fallback={
2458
+ isRunning() || props.status === "streaming" ? (
2459
+ - <Spinner color={color()}>Writing command…</Spinner>
2460
+ + <Spinner color={color()}>{chromeLabel("Writing command…")}</Spinner>
2461
+ ) : (
2462
+ - <text fg={theme.text.muted}>Writing command…</text>
2463
+ + <text fg={theme.text.muted}>{chromeLabel("Writing command…")}</text>
2464
+ )
2465
+ }
2466
+ >
2467
+ @@ -3014,7 +3016,7 @@ function Write(props: ToolProps) {
2468
+ </BlockTool>
2469
+ </Match>
2470
+ <Match when={true}>
2471
+ - <InlineTool icon="←" pending="Preparing write…" complete={stringValue(props.input.path)} part={props.part}>
2472
+ + <InlineTool icon={chromeGlyph("←")} pending={chromeLabel("Preparing write…")} complete={stringValue(props.input.path)} part={props.part}>
2473
+ Write {pathFormatter.format(stringValue(props.input.path))}
2474
+ </InlineTool>
2475
+ </Match>
2476
+ @@ -3025,7 +3027,7 @@ function Write(props: ToolProps) {
2477
+ function Glob(props: ToolProps) {
2478
+ const pathFormatter = usePathFormatter()
2479
+ return (
2480
+ - <InlineTool icon="✱" pending="Finding files…" complete={stringValue(props.input.pattern)} part={props.part}>
2481
+ + <InlineTool icon={chromeGlyph("✱")} pending={chromeLabel("Finding files…")} complete={stringValue(props.input.pattern)} part={props.part}>
2482
+ Glob "{stringValue(props.input.pattern)}"{" "}
2483
+ <Show when={stringValue(props.input.path)}>in {pathFormatter.format(stringValue(props.input.path))} </Show>
2484
+ <Show when={finiteNumber(props.metadata.count)}>
2485
+ @@ -3048,8 +3050,8 @@ function Read(props: ToolProps) {
2486
+ return (
2487
+ <>
2488
+ <InlineTool
2489
+ - icon="→"
2490
+ - pending="Reading file…"
2491
+ + icon={chromeGlyph("→")}
2492
+ + pending={chromeLabel("Reading file…")}
2493
+ complete={stringValue(props.input.path)}
2494
+ spinner={isRunning()}
2495
+ part={props.part}
2496
+ @@ -3064,7 +3066,7 @@ function Read(props: ToolProps) {
2497
+ {(filepath) => (
2498
+ <box paddingLeft={3}>
2499
+ <text paddingLeft={3} fg={theme.text.muted}>
2500
+ - ↳ Loaded {pathFormatter.format(filepath)}
2501
+ + {chromeGlyph("↳")} Loaded {pathFormatter.format(filepath)}
2502
+ </text>
2503
+ </box>
2504
+ )}
2505
+ @@ -3076,7 +3078,7 @@ function Read(props: ToolProps) {
2506
+ function Grep(props: ToolProps) {
2507
+ const pathFormatter = usePathFormatter()
2508
+ return (
2509
+ - <InlineTool icon="✱" pending="Searching content…" complete={stringValue(props.input.pattern)} part={props.part}>
2510
+ + <InlineTool icon={chromeGlyph("✱")} pending={chromeLabel("Searching content…")} complete={stringValue(props.input.pattern)} part={props.part}>
2511
+ Grep "{stringValue(props.input.pattern)}"{" "}
2512
+ <Show when={stringValue(props.input.path)}>in {pathFormatter.format(stringValue(props.input.path))} </Show>
2513
+ <Show when={finiteNumber(props.metadata.matches)}>
2514
+ @@ -3088,7 +3090,7 @@ function Grep(props: ToolProps) {
2515
+
2516
+ function WebFetch(props: ToolProps) {
2517
+ return (
2518
+ - <InlineTool icon="%" pending="Fetching from the web…" complete={stringValue(props.input.url)} part={props.part}>
2519
+ + <InlineTool icon="%" pending={chromeLabel("Fetching from the web…")} complete={stringValue(props.input.url)} part={props.part}>
2520
+ WebFetch {stringValue(props.input.url)}
2521
+ </InlineTool>
2522
+ )
2523
+ @@ -3098,7 +3100,7 @@ function WebSearch(props: ToolProps) {
2524
+ const ctx = use()
2525
+ const provider = createMemo(() => stringValue(props.metadata.provider))
2526
+ return (
2527
+ - <InlineTool icon="◈" pending="Searching web…" complete={stringValue(props.input.query)} part={props.part}>
2528
+ + <InlineTool icon={chromeGlyph("◈")} pending={chromeLabel("Searching web…")} complete={stringValue(props.input.query)} part={props.part}>
2529
+ <Show when={provider()} fallback="Web Search">
2530
+ {(value) => (
2531
+ <>
2532
+ @@ -3133,11 +3135,11 @@ function Subagent(props: ToolProps) {
2533
+
2534
+ return (
2535
+ <InlineTool
2536
+ - icon={continuation() ? "↳" : isRunning() ? "│" : props.part.state.status === "completed" ? "✓" : "│"}
2537
+ + icon={chromeGlyph(continuation() ? "↳" : isRunning() ? "│" : props.part.state.status === "completed" ? "✓" : "│")}
2538
+ spinner={!continuation() && isRunning()}
2539
+ running={isRunning()}
2540
+ complete={description()}
2541
+ - pending="Delegating…"
2542
+ + pending={chromeLabel("Delegating…")}
2543
+ part={props.part}
2544
+ onClick={() => {
2545
+ const id = sessionID()
2546
+ @@ -3149,7 +3151,7 @@ function Subagent(props: ToolProps) {
2547
+ ) : undefined
2548
+ }
2549
+ >
2550
+ - {`${continuation() ? "Continue subagent" : `${Locale.titlecase(stringValue(props.input.agent) ?? stringValue(props.input.subagent_type) ?? "General")} Subagent`} — ${description() ?? "Subagent"}${model() ? ` · ${model()}` : ""}`}
2551
+ + {`${continuation() ? "Continue subagent" : `${Locale.titlecase(stringValue(props.input.agent) ?? stringValue(props.input.subagent_type) ?? "General")} Subagent`} ${chromeGlyph("—")} ${description() ?? "Subagent"}${model() ? ` ${chromeGlyph("·")} ${model()}` : ""}`}
2552
+ </InlineTool>
2553
+ )
2554
+ }
2555
+ @@ -3202,7 +3204,7 @@ function ExecuteCallView(props: { call: Accessor<ExecuteCall> }) {
2556
+ >
2557
+ <box flexDirection="row">
2558
+ <box width={INLINE_TOOL_ICON_WIDTH} flexShrink={0}>
2559
+ - <text fg={color()}>{props.call().status === "error" ? "✗" : "›"}</text>
2560
+ + <text fg={color()}>{chromeGlyph(props.call().status === "error" ? "✗" : "›")}</text>
2561
+ </box>
2562
+ <text flexGrow={1} wrapMode="none" truncate fg={color()}>
2563
+ {expanded() ? props.call().tool : executeCallSummary(props.call())}
2564
+ @@ -3248,7 +3250,7 @@ function Execute(props: ToolProps) {
2565
+ return (
2566
+ <>
2567
+ <InlineTool
2568
+ - icon={hasRuntimeError() ? "✗" : props.part.state.status === "completed" ? "✓" : "│"}
2569
+ + icon={chromeGlyph(hasRuntimeError() ? "✗" : props.part.state.status === "completed" ? "✓" : "│")}
2570
+ color={hasRuntimeError() ? theme.text.feedback.error.base : undefined}
2571
+ spinner={isLoading()}
2572
+ pending="execute"
2573
+ @@ -3265,7 +3267,7 @@ function Execute(props: ToolProps) {
2574
+ <For each={outputPreview().split("\n")}>
2575
+ {(line, index) => (
2576
+ <text paddingLeft={3} fg={theme.text.feedback.error.base}>
2577
+ - {index() === 0 ? "↳ " : " "}
2578
+ + {index() === 0 ? `${chromeGlyph("↳")} ` : " "}
2579
+ {line}
2580
+ </text>
2581
+ )}
2582
+ @@ -3297,7 +3299,7 @@ function Edit(props: ToolProps) {
2583
+ <Switch>
2584
+ <Match when={file()}>
2585
+ {(item) => (
2586
+ - <BlockTool path={{ label: "← Edit", value: pathFormatter.format(path()) }} part={props.part}>
2587
+ + <BlockTool path={{ label: `${chromeGlyph("←")} Edit`, value: pathFormatter.format(path()) }} part={props.part}>
2588
+ <box paddingLeft={1}>
2589
+ <PatchDiff
2590
+ diff={item().patch}
2591
+ @@ -3328,10 +3330,10 @@ function Edit(props: ToolProps) {
2592
+ <BlockTool
2593
+ path={
2594
+ stringValue(props.input.path)
2595
+ - ? { label: "← Edit", value: pathFormatter.format(stringValue(props.input.path)) }
2596
+ + ? { label: `${chromeGlyph("←")} Edit`, value: pathFormatter.format(stringValue(props.input.path)) }
2597
+ : undefined
2598
+ }
2599
+ - title={stringValue(props.input.path) ? undefined : "# Preparing edit…"}
2600
+ + title={stringValue(props.input.path) ? undefined : `# ${chromeLabel("Preparing edit…")}`}
2601
+ part={props.part}
2602
+ spinner={props.part.state.status === "streaming"}
2603
+ />
2604
+ @@ -3375,7 +3377,7 @@ function ApplyPatch(props: ToolProps) {
2605
+ {(file) => (
2606
+ <BlockTool
2607
+ path={{
2608
+ - label: file.type === "add" ? "# Created" : file.type === "delete" ? "# Deleted" : "← Patched",
2609
+ + label: file.type === "add" ? "# Created" : file.type === "delete" ? "# Deleted" : `${chromeGlyph("←")} Patched`,
2610
+ value: pathFormatter.format(file.relativePath),
2611
+ }}
2612
+ part={props.part}
2613
+ @@ -3422,7 +3424,7 @@ function ApplyPatch(props: ToolProps) {
2614
+ {(file) => (
2615
+ <BlockTool
2616
+ path={{
2617
+ - label: file.type === "add" ? "# Created" : file.type === "delete" ? "# Deleted" : "← Patched",
2618
+ + label: file.type === "add" ? "# Created" : file.type === "delete" ? "# Deleted" : `${chromeGlyph("←")} Patched`,
2619
+ value: pathFormatter.format(file.resource),
2620
+ }}
2621
+ part={props.part}
2622
+ @@ -3488,7 +3490,7 @@ function Question(props: ToolProps) {
2623
+ </BlockTool>
2624
+ </Match>
2625
+ <Match when={true}>
2626
+ - <InlineTool icon="→" pending="Asking questions…" complete={count()} part={props.part}>
2627
+ + <InlineTool icon={chromeGlyph("→")} pending={chromeLabel("Asking questions…")} complete={count()} part={props.part}>
2628
+ Asked {count()} question{count() !== 1 ? "s" : ""}
2629
+ </InlineTool>
2630
+ </Match>
2631
+ @@ -3499,7 +3501,7 @@ function Question(props: ToolProps) {
2632
+ function Skill(props: ToolProps) {
2633
+ const name = createMemo(() => stringValue(props.metadata.name) ?? stringValue(props.input.id))
2634
+ return (
2635
+ - <InlineTool icon="→" pending="Loading skill…" complete={name()} part={props.part}>
2636
+ + <InlineTool icon={chromeGlyph("→")} pending={chromeLabel("Loading skill…")} complete={name()} part={props.part}>
2637
+ Skill "{name()}"
2638
+ </InlineTool>
2639
+ )
2640
+ diff --git a/packages/tui/src/routes/session/location-missing.tsx b/packages/tui/src/routes/session/location-missing.tsx
2641
+ index d42aada29fac26b1dec5372f03941be08aed14d5..15f1d0ec37179a74e1c8d35c1930cc8a97a4d6d5 100644
2642
+ --- a/packages/tui/src/routes/session/location-missing.tsx
2643
+ +++ b/packages/tui/src/routes/session/location-missing.tsx
2644
+ @@ -10,6 +10,7 @@ import { useToast } from "../../ui/toast"
2645
+ import { errorMessage } from "../../util/error"
2646
+ import { DialogWorkspaces, type WorkspaceSelection } from "../../component/dialog-workspaces"
2647
+ import { useData } from "../../context/data"
2648
+ +import { chromeEllipsis } from "../../ui/glyphs"
2649
+
2650
+ export function SessionLocationMissing(props: { directory: string; projectID: string; sessionID: string }) {
2651
+ const dialog = useDialog()
2652
+ @@ -59,7 +60,7 @@ export function SessionLocationMissing(props: { directory: string; projectID: st
2653
+ export function SessionLocationUnavailable(props: { directory: string; onMove: () => void }) {
2654
+ const paths = useTuiPaths()
2655
+ const theme = useTheme()
2656
+ - const directory = createMemo(() => Locale.truncateMiddle(abbreviateHome(props.directory, paths.home), 72))
2657
+ + const directory = createMemo(() => Locale.truncateMiddle(abbreviateHome(props.directory, paths.home), 72, chromeEllipsis()))
2658
+
2659
+ return (
2660
+ <SessionQuestion
2661
+ diff --git a/packages/tui/src/routes/session/message-parts.tsx b/packages/tui/src/routes/session/message-parts.tsx
2662
+ index f63ba65250e854f423e0b624e1f3629c750d4a6b..a4b361664eab3078b055f5669bf2a6baf19f5ec2 100644
2663
+ --- a/packages/tui/src/routes/session/message-parts.tsx
2664
+ +++ b/packages/tui/src/routes/session/message-parts.tsx
2665
+ @@ -11,6 +11,7 @@ import { createSyntaxStyleMemo, useTheme, useThemes } from "../../context/theme"
2666
+ import { reasoningSummary } from "../../context/thinking"
2667
+ import { usePlugin } from "../../plugin/context"
2668
+ import { SplitBorder } from "../../ui/border"
2669
+ +import { chromeGlyph } from "../../ui/glyphs"
2670
+ import { Locale } from "../../util/locale"
2671
+ import { use } from "./render-context"
2672
+ import { generateThinkingSyntax } from "./thinking-syntax"
2673
+ @@ -156,7 +157,7 @@ function ReasoningHeader(props: {
2674
+ </Show>
2675
+ <Show when={props.duration}>
2676
+ <span>
2677
+ - {props.title ? " · " : ""}
2678
+ + {props.title ? ` ${chromeGlyph("·")} ` : ""}
2679
+ {props.duration}
2680
+ </span>
2681
+ </Show>
2682
+ @@ -237,7 +238,7 @@ export function InlineToolRow(props: {
2683
+ fg={props.failed ? props.errorColor : (props.iconColor ?? props.color)}
2684
+ attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
2685
+ >
2686
+ - {props.icon}
2687
+ + {chromeGlyph(props.icon)}
2688
+ </text>
2689
+ <Show
2690
+ when={props.status}
2691
+ diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx
2692
+ index 07338aba69d81e27d5213e61131f7d2db0e2ff0a..812d3e97e6fa3085113e9af310b03fd2b5d64161 100644
2693
+ --- a/packages/tui/src/routes/session/permission.tsx
2694
+ +++ b/packages/tui/src/routes/session/permission.tsx
2695
+ @@ -5,6 +5,7 @@ import type { TextareaRenderable } from "@opentui/core"
2696
+ import { useTheme, useThemes } from "../../context/theme"
2697
+ import type { PermissionReply, PermissionRequest } from "@opencode/client"
2698
+ import { SplitBorder } from "../../ui/border"
2699
+ +import { chromeGlyph } from "../../ui/glyphs"
2700
+ import { useData } from "../../context/data"
2701
+ import { filetype } from "../../util/filetype"
2702
+ import { permissionAlwaysLines, permissionOptionLabel, permissionPresentation } from "../../util/permission"
2703
+ @@ -200,7 +201,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
2704
+ const header = () => (
2705
+ <box flexDirection="column" gap={0}>
2706
+ <box flexDirection="row" gap={1} flexShrink={0}>
2707
+ - <text fg={theme.text.feedback.warning.base}>{"△"}</text>
2708
+ + <text fg={theme.text.feedback.warning.base}>{chromeGlyph("△")}</text>
2709
+ <text fg={theme.text.base}>Permission required</text>
2710
+ </box>
2711
+ <Show when={props.request.action !== "shell" && current.title}>
2712
+ @@ -321,7 +322,7 @@ function RejectPrompt(props: {
2713
+ >
2714
+ <box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
2715
+ <box flexDirection="row" gap={1} paddingLeft={1}>
2716
+ - <text fg={theme.text.feedback.error.base}>{"△"}</text>
2717
+ + <text fg={theme.text.feedback.error.base}>{chromeGlyph("△")}</text>
2718
+ <text fg={theme.text.base}>Reject permission</text>
2719
+ </box>
2720
+ <box paddingLeft={1}>
2721
+ @@ -527,7 +528,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
2722
+ when={props.header}
2723
+ fallback={
2724
+ <box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
2725
+ - <text fg={theme.text.feedback.warning.base}>{"△"}</text>
2726
+ + <text fg={theme.text.feedback.warning.base}>{chromeGlyph("△")}</text>
2727
+ <text fg={theme.text.base}>{props.title}</text>
2728
+ </box>
2729
+ }
2730
+ @@ -603,7 +604,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
2731
+ </Show>
2732
+ <Show when={keys.length > 1}>
2733
+ <text fg={theme.text.base}>
2734
+ - {"⇆"} <span style={{ fg: theme.text.muted }}>select</span>
2735
+ + {chromeGlyph("⇆")} <span style={{ fg: theme.text.muted }}>select</span>
2736
+ </text>
2737
+ </Show>
2738
+ <text fg={theme.text.base}>
2739
+ diff --git a/packages/tui/src/ui/border.ts b/packages/tui/src/ui/border.ts
2740
+ index 333071020c4b3ce74aad47ec9ba758a566761565..d03cce890838bae1df70d0b9bdab06bbf8131e46 100644
2741
+ --- a/packages/tui/src/ui/border.ts
2742
+ +++ b/packages/tui/src/ui/border.ts
2743
+ @@ -1,3 +1,5 @@
2744
+ +import { chromeBorderChars } from "./glyphs"
2745
+ +
2746
+ export const EmptyBorder = {
2747
+ topLeft: "",
2748
+ bottomLeft: "",
2749
+ @@ -14,8 +16,8 @@ export const EmptyBorder = {
2750
+
2751
+ export const SplitBorder = {
2752
+ border: ["left" as const, "right" as const],
2753
+ - customBorderChars: {
2754
+ - ...EmptyBorder,
2755
+ - vertical: "┃",
2756
+ + get customBorderChars() {
2757
+ + const chars = chromeBorderChars()
2758
+ + return chars ? { ...EmptyBorder, ...chars } : { ...EmptyBorder, vertical: "┃" }
2759
+ },
2760
+ }
2761
+ diff --git a/packages/tui/src/ui/dialog-export-options.tsx b/packages/tui/src/ui/dialog-export-options.tsx
2762
+ index 2a38f6292279460734430cd74894b8ed53056422..a60018289a09ce7c68f1e782a44cb408b35101f1 100644
2763
+ --- a/packages/tui/src/ui/dialog-export-options.tsx
2764
+ +++ b/packages/tui/src/ui/dialog-export-options.tsx
2765
+ @@ -1,6 +1,7 @@
2766
+ import { Keymap } from "../context/keymap"
2767
+ import { useNewDesigns } from "../config"
2768
+ import { useTheme } from "../context/theme"
2769
+ +import { chromeGlyph } from "./glyphs"
2770
+ import { DialogHeader, DialogLayout, type DialogContext } from "./dialog"
2771
+ import { createStore } from "solid-js/store"
2772
+ import { For, Show } from "solid-js"
2773
+ @@ -153,7 +154,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
2774
+ : theme.text.formfield.base
2775
+ }
2776
+ >
2777
+ - {store.format === format ? "◉" : "○"} {format === "markdown" ? "Markdown" : "JSON"}
2778
+ + {store.format === format ? chromeGlyph("◉") : "○"} {format === "markdown" ? "Markdown" : "JSON"}
2779
+ </text>
2780
+ </box>
2781
+ )}
2782
+ diff --git a/packages/tui/src/ui/dialog-export-result.tsx b/packages/tui/src/ui/dialog-export-result.tsx
2783
+ index 6304fbdb307b218f13945547a81ff9faed281975..4751cc8d57c60f60729cba79acc21851852948a1 100644
2784
+ --- a/packages/tui/src/ui/dialog-export-result.tsx
2785
+ +++ b/packages/tui/src/ui/dialog-export-result.tsx
2786
+ @@ -3,6 +3,7 @@ import { useNewDesigns } from "../config"
2787
+ import { useClipboard } from "../context/clipboard"
2788
+ import { Keymap } from "../context/keymap"
2789
+ import { useTheme } from "../context/theme"
2790
+ +import { chromeGlyph } from "./glyphs"
2791
+ import { DialogHeader, DialogLayout, useDialog, type DialogContext } from "./dialog"
2792
+ import { useToast } from "./toast"
2793
+
2794
+ @@ -51,7 +52,7 @@ export function DialogExportResult(props: { path: string; onClose?: () => void }
2795
+ actions: {
2796
+ primary: [
2797
+ copied()
2798
+ - ? { title: "✓ copied", color: theme.text.feedback.success.base, onTrigger: copy }
2799
+ + ? { title: `${chromeGlyph("✓")} copied`, color: theme.text.feedback.success.base, onTrigger: copy }
2800
+ : { title: "copy path", key: "c", onTrigger: copy },
2801
+ ],
2802
+ },
2803
+ diff --git a/packages/tui/src/ui/dialog-prompt.tsx b/packages/tui/src/ui/dialog-prompt.tsx
2804
+ index b85074c491e86c1e34b646db5b09697df43b9950..00badea28607a4cc3471c906d6588f7d13cd8e20 100644
2805
+ --- a/packages/tui/src/ui/dialog-prompt.tsx
2806
+ +++ b/packages/tui/src/ui/dialog-prompt.tsx
2807
+ @@ -6,6 +6,7 @@ import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
2808
+ import { Spinner } from "../component/spinner"
2809
+ import { useConfig, useNewDesigns } from "../config"
2810
+ import { useRenderer } from "@opentui/solid"
2811
+ +import { chromeLabel } from "./glyphs"
2812
+
2813
+ export type DialogPromptAction = {
2814
+ title: string
2815
+ @@ -148,7 +149,7 @@ export function DialogPrompt(props: DialogPromptProps) {
2816
+ },
2817
+ }}
2818
+ legacyFooter={
2819
+ - <Show when={!props.busy} fallback={<text fg={theme.text.muted}>processing…</text>}>
2820
+ + <Show when={!props.busy} fallback={<text fg={theme.text.muted}>{chromeLabel("processing…")}</text>}>
2821
+ <DialogFooter
2822
+ actions={{
2823
+ secondary: [{ title: "submit", command: "dialog.prompt.submit", onTrigger: confirm }, ...extraActions()],
2824
+ @@ -175,7 +176,7 @@ export function DialogPrompt(props: DialogPromptProps) {
2825
+ cursorStyle={config.cursor}
2826
+ />
2827
+ <Show when={props.busy}>
2828
+ - <Spinner color={theme.text.muted}>{props.busyText ?? "Working…"}</Spinner>
2829
+ + <Spinner color={theme.text.muted}>{props.busyText ?? chromeLabel("Working…")}</Spinner>
2830
+ </Show>
2831
+ </box>
2832
+ </DialogLayout>
2833
+ diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx
2834
+ index f86d8b507f79920ac75de168f1cd1b1076a70380..acf4b6d5285c7478182c845f6847fc95d4a17c58 100644
2835
+ --- a/packages/tui/src/ui/dialog-select.tsx
2836
+ +++ b/packages/tui/src/ui/dialog-select.tsx
2837
+ @@ -22,6 +22,7 @@ import { Locale } from "../util/locale"
2838
+ import { getScrollAcceleration } from "../util/scroll"
2839
+ import { useConfig, useNewDesigns } from "../config"
2840
+ import { moveSelection, reconcileSelection } from "./select-controller"
2841
+ +import { chromeEllipsis, chromeGlyph } from "./glyphs"
2842
+
2843
+ export interface DialogSelectProps<T> {
2844
+ title: string
2845
+ @@ -789,7 +790,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
2846
+ </scrollbox>
2847
+ <Show when={overflows()}>
2848
+ <box paddingLeft={DIALOG_LIST_INSET} flexShrink={0}>
2849
+ - <text fg={theme.text.faint}>{moreBelow() ? "…" : " "}</text>
2850
+ + <text fg={theme.text.faint}>{moreBelow() ? chromeGlyph("…") : " "}</text>
2851
+ </box>
2852
+ </Show>
2853
+ </Show>
2854
+ @@ -855,7 +856,7 @@ function DialogSelectRow(props: {
2855
+ </Show>
2856
+ <Show when={props.current && !props.gutter}>
2857
+ <text flexShrink={0} fg={newDesigns() ? theme.text.formfield.selected : text()}>
2858
+ - {newDesigns() ? "▪" : "●"}
2859
+ + {chromeGlyph(newDesigns() ? "▪" : "●")}
2860
+ </text>
2861
+ </Show>
2862
+ <Show when={props.gutter}>
2863
+ @@ -873,8 +874,8 @@ function DialogSelectRow(props: {
2864
+ (props.truncateTitle === false
2865
+ ? props.title
2866
+ : props.truncateTitle === "left"
2867
+ - ? Locale.truncateLeft(props.title, props.titleWidth ?? 61)
2868
+ - : Locale.truncate(props.title, props.titleWidth ?? 61))}
2869
+ + ? Locale.truncateLeft(props.title, props.titleWidth ?? 61, chromeEllipsis())
2870
+ + : Locale.truncate(props.title, props.titleWidth ?? 61, chromeEllipsis()))}
2871
+ <Show when={props.description}>
2872
+ <span style={{ fg: props.active ? secondary() : theme.text.muted }}>{" " + props.description}</span>
2873
+ </Show>
2874
+ @@ -891,7 +892,7 @@ function DialogSelectRow(props: {
2875
+ <Show when={newDesigns()} fallback={row()}>
2876
+ <box flexDirection="row">
2877
+ <text width={1} flexShrink={0} fg={theme.text.formfield.focused}>
2878
+ - {props.active ? "┃" : " "}
2879
+ + {props.active ? chromeGlyph("┃") : " "}
2880
+ </text>
2881
+ {row()}
2882
+ </box>
2883
+ @@ -913,7 +914,7 @@ function DialogSelectDetails(props: { details?: string[] }) {
2884
+ paddingRight={DIALOG_LIST_INSET - (newDesigns() ? 0 : 1)}
2885
+ >
2886
+ <text fg={theme.text.muted} wrapMode="none">
2887
+ - {Locale.truncateMiddle(detail, Math.max(1, width()))}
2888
+ + {Locale.truncateMiddle(detail, Math.max(1, width()), chromeEllipsis())}
2889
+ </text>
2890
+ </box>
2891
+ )}
2892
+ diff --git a/packages/tui/src/ui/file-path.tsx b/packages/tui/src/ui/file-path.tsx
2893
+ index 09b38066707cdfcf1aa3e8dd55cee47236b5888c..5fc5dd57257c9fc88855b1760761308404f51eff 100644
2894
+ --- a/packages/tui/src/ui/file-path.tsx
2895
+ +++ b/packages/tui/src/ui/file-path.tsx
2896
+ @@ -1,6 +1,7 @@
2897
+ import type { RGBA } from "@opentui/core"
2898
+ import { createMemo } from "solid-js"
2899
+ import { stringWidth } from "../util/string-width"
2900
+ +import { chromeEllipsis } from "./glyphs"
2901
+
2902
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
2903
+
2904
+ @@ -29,6 +30,7 @@ export function FilePath(props: FilePathProps) {
2905
+ }
2906
+
2907
+ export function truncateFilePath(value: string, maxWidth: number) {
2908
+ + const ellipsis = chromeEllipsis()
2909
+ if (maxWidth <= 0) return ""
2910
+ if (stringWidth(value) <= maxWidth) return value
2911
+
2912
+ @@ -52,7 +54,7 @@ export function truncateFilePath(value: string, maxWidth: number) {
2913
+ return root + truncateBasename(basename, maxWidth - rootWidth)
2914
+ }
2915
+
2916
+ - const prefix = `${root}…${separator}`
2917
+ + const prefix = `${root}${ellipsis}${separator}`
2918
+ const basenameWidth = maxWidth - stringWidth(prefix)
2919
+ if (basenameWidth <= 0) return takeStart(prefix, maxWidth)
2920
+ const compact = truncateBasename(basename, basenameWidth)
2921
+ @@ -66,7 +68,7 @@ export function truncateFilePath(value: string, maxWidth: number) {
2922
+ const next = stringWidth(segment) + separatorWidth
2923
+ if (width + next > maxWidth) {
2924
+ const available = maxWidth - width - separatorWidth
2925
+ - if (available > 1) selected.unshift(takeStart(segment, available - 1) + "…")
2926
+ + if (available > stringWidth(ellipsis)) selected.unshift(takeStart(segment, available - stringWidth(ellipsis)) + ellipsis)
2927
+ break
2928
+ }
2929
+ selected.unshift(segment)
2930
+ @@ -77,15 +79,17 @@ export function truncateFilePath(value: string, maxWidth: number) {
2931
+
2932
+ function truncateBasename(value: string, maxWidth: number) {
2933
+ if (stringWidth(value) <= maxWidth) return value
2934
+ - if (maxWidth <= 1) return takeStart("…", maxWidth)
2935
+ + const ellipsis = chromeEllipsis()
2936
+ + const markerWidth = stringWidth(ellipsis)
2937
+ + if (maxWidth <= markerWidth) return takeStart(ellipsis, maxWidth)
2938
+
2939
+ const dot = value.lastIndexOf(".")
2940
+ const extension = dot > 0 ? value.slice(dot) : ""
2941
+ const extensionWidth = stringWidth(extension)
2942
+ - if (extensionWidth >= maxWidth) return "…" + takeEnd(extension, maxWidth - 1)
2943
+ + if (extensionWidth + markerWidth >= maxWidth) return ellipsis + takeEnd(extension, maxWidth - markerWidth)
2944
+
2945
+ const stem = extension ? value.slice(0, dot) : value
2946
+ - return takeStart(stem, maxWidth - extensionWidth - 1) + "…" + extension
2947
+ + return takeStart(stem, maxWidth - extensionWidth - markerWidth) + ellipsis + extension
2948
+ }
2949
+
2950
+ function takeStart(value: string, maxWidth: number) {
2951
+ diff --git a/packages/tui/src/ui/glyphs.ts b/packages/tui/src/ui/glyphs.ts
2952
+ new file mode 100644
2953
+ index 0000000000000000000000000000000000000000..f20313f4ce974158bdcca422dc7fc812a6091308
2954
+ --- /dev/null
2955
+ +++ b/packages/tui/src/ui/glyphs.ts
2956
+ @@ -0,0 +1,188 @@
2957
+ +/** Host chrome glyphs. This deliberately does not transform arbitrary text. */
2958
+ +export type GlyphMode = "auto" | "unicode" | "ascii"
2959
+ +
2960
+ +export function glyphMode(value = process.env.CTG_TUI_GLYPHS): GlyphMode {
2961
+ + if (value === "ascii" || value === "unicode" || value === "auto") return value
2962
+ + return "unicode"
2963
+ +}
2964
+ +
2965
+ +export function asciiGlyphs() {
2966
+ + return glyphMode() === "ascii"
2967
+ +}
2968
+ +
2969
+ +/** Replace known OpenCode chrome icons, leaving unknown/user supplied strings unchanged. */
2970
+ +export function chromeGlyph(value: string) {
2971
+ + if (!asciiGlyphs()) return value
2972
+ + const replacements: Record<string, string> = {
2973
+ + "→": ">",
2974
+ + "←": "<",
2975
+ + "↳": "`",
2976
+ + "↻": "~",
2977
+ + "⟳": "~",
2978
+ + "✱": "*",
2979
+ + "◈": "@",
2980
+ + "⚙": "#",
2981
+ + "✓": "+",
2982
+ + "✗": "x",
2983
+ + "×": "x",
2984
+ + "•": "*",
2985
+ + "●": "*",
2986
+ + "▪": "*",
2987
+ + "■": "#",
2988
+ + "◉": "o",
2989
+ + "○": "o",
2990
+ + "⇆": "<>",
2991
+ + "✕": "x",
2992
+ + "⦿": "o",
2993
+ + "‹": "<",
2994
+ + "⌕": "?",
2995
+ + "⊙": "*",
2996
+ + "⬖": "*",
2997
+ + "⎇": "*",
2998
+ + "≡": "=",
2999
+ + "▸": ">",
3000
+ + "▾": "v",
3001
+ + "▼": "v",
3002
+ + "▶": ">",
3003
+ + "⚠": "!",
3004
+ + "◇": "o",
3005
+ + "△": "!",
3006
+ + "›": ">",
3007
+ + "┃": "|",
3008
+ + "│": "|",
3009
+ + "↑": "^",
3010
+ + "↓": "v",
3011
+ + "├": "|-",
3012
+ + "└": "`-",
3013
+ + "─": "-",
3014
+ + "…": "...",
3015
+ + "·": ".",
3016
+ + "−": "-",
3017
+ + "—": "-",
3018
+ + "’": "'",
3019
+ + }
3020
+ + return replacements[value] ?? value
3021
+ +}
3022
+ +
3023
+ +/** Static host copy can opt in separately; dynamic/user text is deliberately not normalized. */
3024
+ +const ASCII_CHROME_LABELS: Record<string, string> = {
3025
+ + "Searching…": "Searching...",
3026
+ + "Run a command…": "Run a command...",
3027
+ + "Ask anything…": "Ask anything...",
3028
+ + "Search worktrees…": "Search worktrees...",
3029
+ + "Search sessions and projects…": "Search sessions and projects...",
3030
+ + "Connecting …": "Connecting ...",
3031
+ + "Connecting…": "Connecting...",
3032
+ + "Searching sessions…": "Searching sessions...",
3033
+ + "Loading sessions…": "Loading sessions...",
3034
+ + "Writing debug snapshot…": "Writing debug snapshot...",
3035
+ + "Checking for updates…": "Checking for updates...",
3036
+ + "Loading skills…": "Loading skills...",
3037
+ + "Gathering your stats…": "Gathering your stats...",
3038
+ + "Restarting service…": "Restarting service...",
3039
+ + "Connection lost…": "Connection lost...",
3040
+ + "Receiving code…": "Receiving code...",
3041
+ + "Waiting for code…": "Waiting for code...",
3042
+ + "Waiting for output…": "Waiting for output...",
3043
+ + "Loading worktrees…": "Loading worktrees...",
3044
+ + "Refreshing sessions and projects…": "Refreshing sessions and projects...",
3045
+ + "Searching sessions and projects…": "Searching sessions and projects...",
3046
+ + "Creating worktree…": "Creating worktree...",
3047
+ + "Loading server information…": "Loading server information...",
3048
+ + "Starting command…": "Starting command...",
3049
+ + "Waiting for command to finish…": "Waiting for command to finish...",
3050
+ + "Starting authorization…": "Starting authorization...",
3051
+ + "Waiting for authorization…": "Waiting for authorization...",
3052
+ + "Opening link…": "Opening link...",
3053
+ + "Finishing startup…": "Finishing startup...",
3054
+ + "Loading plugins…": "Loading plugins...",
3055
+ + "Loading image…": "Loading image...",
3056
+ + "Loading session history…": "Loading session history...",
3057
+ + "Writing command…": "Writing command...",
3058
+ + "Fetching from the web…": "Fetching from the web...",
3059
+ + "Delegating…": "Delegating...",
3060
+ + "Preparing edit…": "Preparing edit...",
3061
+ + "Forking session…": "Forking session...",
3062
+ + "Installing OpenCode …": "Installing OpenCode ...",
3063
+ + "Resolving diff…": "Resolving diff...",
3064
+ + "Loading branches…": "Loading branches...",
3065
+ + "Choose…": "Choose...",
3066
+ + "Loading diff…": "Loading diff...",
3067
+ + "processing…": "processing...",
3068
+ + "Working…": "Working...",
3069
+ + "Search sessions…": "Search sessions...",
3070
+ + "Loading output…": "Loading output...",
3071
+ + "Searching content…": "Searching content...",
3072
+ + "Reading file…": "Reading file...",
3073
+ + "Preparing write…": "Preparing write...",
3074
+ + "Finding files…": "Finding files...",
3075
+ + "Searching web…": "Searching web...",
3076
+ + "Asking questions…": "Asking questions...",
3077
+ + "Loading skill…": "Loading skill...",
3078
+ + "Waiting for permission event…": "Waiting for permission event...",
3079
+ + "Waiting…": "Waiting...",
3080
+ + "No matching files…": "No matching files...",
3081
+ + "Reloading configuration…": "Reloading configuration...",
3082
+ + "Unable to read shell output. Retrying…": "Unable to read shell output. Retrying...",
3083
+ + "Thinking…": "Thinking...",
3084
+ + "Running command…": "Running command...",
3085
+ + "Preparing command…": "Preparing command...",
3086
+ + "Running code…": "Running code...",
3087
+ + "Preparing code…": "Preparing code...",
3088
+ + "Report copied — paste it into a new GitHub issue.": "Report copied - paste it into a new GitHub issue.",
3089
+ +}
3090
+ +
3091
+ +export function chromeLabel(value: string) {
3092
+ + if (!asciiGlyphs()) return value
3093
+ + return ASCII_CHROME_LABELS[value] ?? value
3094
+ +}
3095
+ +
3096
+ +export function chromeEllipsis() {
3097
+ + return asciiGlyphs() ? "..." : "…"
3098
+ +}
3099
+ +
3100
+ +export function chromeStatusSeparator() {
3101
+ + return asciiGlyphs() ? "|" : "·"
3102
+ +}
3103
+ +
3104
+ +export const ASCII_SPINNER_FRAMES = ["-", "\\", "|", "/"]
3105
+ +
3106
+ +export const ASCII_BORDER_CHARS = {
3107
+ + topLeft: "+",
3108
+ + bottomLeft: "+",
3109
+ + vertical: "|",
3110
+ + topRight: "+",
3111
+ + bottomRight: "+",
3112
+ + horizontal: "-",
3113
+ + bottomT: "+",
3114
+ + topT: "+",
3115
+ + cross: "+",
3116
+ + leftT: "+",
3117
+ + rightT: "+",
3118
+ +}
3119
+ +
3120
+ +export function chromeBorderChars() {
3121
+ + return asciiGlyphs() ? { ...ASCII_BORDER_CHARS } : undefined
3122
+ +}
3123
+ +
3124
+ +export const CHROME_GLYPHS = {
3125
+ + action: "→",
3126
+ + back: "←",
3127
+ + continuation: "↳",
3128
+ + reconnect: "↻",
3129
+ + search: "✱",
3130
+ + web: "◈",
3131
+ + tool: "⚙",
3132
+ + success: "✓",
3133
+ + error: "✗",
3134
+ + pending: "•",
3135
+ + selection: "▪",
3136
+ + permission: "△",
3137
+ + border: "┃",
3138
+ +} as const
3139
+ +
3140
+ +export const ASCII_LOGO = {
3141
+ + left: [" ", " O P E N C O D E ", " O P E N C O D E ", " O P E N C O D E "],
3142
+ + right: [" ", " O P E N C O D E ", " O P E N C O D E ", " O P E N C O D E "],
3143
+ + compact: [" ", " OC ", " OC ", " OC "],
3144
+ +} as const
3145
+ diff --git a/packages/tui/src/ui/spinner.ts b/packages/tui/src/ui/spinner.ts
3146
+ index c185ea7b833f2087b4c925aa40feb0b72239a19e..83aa3e97230f3522832f2125bb43c292ae8d4951 100644
3147
+ --- a/packages/tui/src/ui/spinner.ts
3148
+ +++ b/packages/tui/src/ui/spinner.ts
3149
+ @@ -1,5 +1,6 @@
3150
+ import type { ColorInput } from "@opentui/core"
3151
+ import { RGBA } from "@opentui/core"
3152
+ +import { asciiGlyphs } from "./glyphs"
3153
+ import type { ColorGenerator } from "opentui-spinner"
3154
+
3155
+ interface AdvancedGradientOptions {
3156
+ @@ -311,17 +312,17 @@ export function createFrames(options: KnightRiderOptions = {}): string[] {
3157
+ const index = calculateColorIndex(frameIndex, charIndex, width, trailOptions)
3158
+
3159
+ if (style === "diamonds") {
3160
+ - const shapes = ["⬥", "◆", "⬩", "⬪"]
3161
+ + const shapes = asciiGlyphs() ? ["*", "#", "o", "+"] : ["⬥", "◆", "⬩", "⬪"]
3162
+ if (index >= 0 && index < trailOptions.colors.length) {
3163
+ return shapes[Math.min(index, shapes.length - 1)]
3164
+ }
3165
+ - return "·"
3166
+ + return asciiGlyphs() ? "." : "·"
3167
+ }
3168
+
3169
+ // Default to blocks
3170
+ // It's active if we have a valid color index that is within our colors array
3171
+ const isActive = index >= 0 && index < trailOptions.colors.length
3172
+ - return isActive ? "■" : "⬝"
3173
+ + return isActive ? (asciiGlyphs() ? "#" : "■") : asciiGlyphs() ? "." : "⬝"
3174
+ }).join("")
3175
+ })
3176
+
3177
+ diff --git a/packages/tui/src/ui/toast.tsx b/packages/tui/src/ui/toast.tsx
3178
+ index 8f44284c2fae13fb1a897d4950416fa3b918c578..b6da5b2f676f2e0502044e69f365c4e557c73f3c 100644
3179
+ --- a/packages/tui/src/ui/toast.tsx
3180
+ +++ b/packages/tui/src/ui/toast.tsx
3181
+ @@ -1,6 +1,7 @@
3182
+ import { createContext, createSignal, onCleanup, useContext, type ParentProps, Show } from "solid-js"
3183
+ import { createStore } from "solid-js/store"
3184
+ import { useTheme } from "../context/theme"
3185
+ +import { chromeGlyph } from "./glyphs"
3186
+ import { useRenderer, useTerminalDimensions } from "@opentui/solid"
3187
+ import { SplitBorder } from "./border"
3188
+ import { TextAttributes } from "@opentui/core"
3189
+ @@ -39,7 +40,7 @@ function ToastSurface(props: {
3190
+ attributes={hovered() && props.toast.action ? TextAttributes.BOLD : undefined}
3191
+ fg={hovered() ? theme.text.action.primary.base : theme.text.muted}
3192
+ >
3193
+ - {props.toast.action ? `› ${props.toast.action.label}` : "x"}
3194
+ + {props.toast.action ? `${chromeGlyph("›")} ${props.toast.action.label}` : "x"}
3195
+ </text>
3196
+ )
3197
+
3198
+ diff --git a/packages/tui/src/util/collapse-tool-output.ts b/packages/tui/src/util/collapse-tool-output.ts
3199
+ index dbdf6e5a1908cae9afe2ceb0c51f5a57f82dcfd3..f8c07cacf0ff15e1f42ca8612f425b929ccf286b 100644
3200
+ --- a/packages/tui/src/util/collapse-tool-output.ts
3201
+ +++ b/packages/tui/src/util/collapse-tool-output.ts
3202
+ @@ -1,3 +1,4 @@
3203
+ +import { chromeEllipsis } from "../ui/glyphs"
3204
+ import { Locale } from "./locale"
3205
+ import { stringWidth } from "./string-width"
3206
+
3207
+ @@ -8,14 +9,11 @@ export function collapseToolOutput(output: string, maxLines: number, maxChars: n
3208
+ }
3209
+
3210
+ const visible = lines.slice(0, maxLines)
3211
+ - if (lines.length > maxLines && visible.length > 0) visible[visible.length - 1] += "…"
3212
+ + if (lines.length > maxLines && visible.length > 0) visible[visible.length - 1] += chromeEllipsis()
3213
+ const preview = visible.join("\n")
3214
+ if (Array.from(preview).length > maxChars) {
3215
+ return {
3216
+ - output:
3217
+ - Array.from(preview)
3218
+ - .slice(0, Math.max(0, maxChars - 1))
3219
+ - .join("") + "…",
3220
+ + output: Array.from(preview).slice(0, Math.max(0, maxChars - chromeEllipsis().length)).join("") + chromeEllipsis(),
3221
+ overflow: true,
3222
+ }
3223
+ }
3224
+ @@ -77,7 +75,7 @@ function collapseShellCommand(input: string, maxLines: number, lineWidth: number
3225
+ const removed = visible.pop()
3226
+ if (removed !== undefined && removed !== "\n") width -= stringWidth(removed)
3227
+ }
3228
+ - return { output: visible.join("") + "…", overflow, lines }
3229
+ + return { output: visible.join("") + chromeEllipsis(), overflow, lines }
3230
+ }
3231
+
3232
+ function collapseTail(output: string, maxLines: number, maxChars: number) {
3233
+ diff --git a/packages/tui/src/util/error.ts b/packages/tui/src/util/error.ts
3234
+ index 33dff7be582228dc7b6b3c634c3bd387f035881f..96cd6c31ca3a82a7d53a3b147f102e04974999c6 100644
3235
+ --- a/packages/tui/src/util/error.ts
3236
+ +++ b/packages/tui/src/util/error.ts
3237
+ @@ -1,4 +1,5 @@
3238
+ import { isRecord } from "./record"
3239
+ +import { chromeGlyph } from "../ui/glyphs"
3240
+
3241
+ type ConfigIssue = { message: string; path: string[] }
3242
+
3243
+ @@ -63,7 +64,7 @@ export function cliErrorMessage(input: unknown): string | undefined {
3244
+ : []
3245
+ return [
3246
+ `Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""),
3247
+ - ...issues.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")),
3248
+ + ...issues.map((issue) => `${chromeGlyph("↳")} ` + issue.message + " " + issue.path.join(".")),
3249
+ ].join("\n")
3250
+ }
3251
+
3252
+ diff --git a/packages/tui/src/util/locale.ts b/packages/tui/src/util/locale.ts
3253
+ index ac3371eb07959e77e44ab1a7eb3776e8a199642b..4ed4225fbf0147ab9be2eaf0699ccd513b26974d 100644
3254
+ --- a/packages/tui/src/util/locale.ts
3255
+ +++ b/packages/tui/src/util/locale.ts
3256
+ @@ -1,4 +1,5 @@
3257
+ import { stringWidth } from "./string-width"
3258
+ +import { chromeEllipsis, chromeGlyph } from "../ui/glyphs"
3259
+
3260
+ export function titlecase(str: string) {
3261
+ return str.replace(/\b\w/g, (c) => c.toUpperCase())
3262
+ @@ -13,7 +14,7 @@ export function datetime(input: number): string {
3263
+ const date = new Date(input)
3264
+ const localTime = time(input)
3265
+ const localDate = date.toLocaleDateString()
3266
+ - return `${localTime} · ${localDate}`
3267
+ + return `${localTime} ${chromeGlyph("·")} ${localDate}`
3268
+ }
3269
+
3270
+ export function number(num: number): string {
3271
+ @@ -48,9 +49,10 @@ export function duration(input: number) {
3272
+ return `${days}d ${hours}h`
3273
+ }
3274
+
3275
+ -export function truncate(str: string, len: number): string {
3276
+ +export function truncate(str: string, len: number, ellipsis = chromeEllipsis()): string {
3277
+ if (str.length <= len) return str
3278
+ - return str.slice(0, len - 1) + "…"
3279
+ + if (len <= ellipsis.length) return ellipsis.slice(0, Math.max(0, len))
3280
+ + return str.slice(0, len - ellipsis.length) + ellipsis
3281
+ }
3282
+
3283
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
3284
+ @@ -74,22 +76,24 @@ export function takeWidth(str: string, width: number) {
3285
+ return result.join("")
3286
+ }
3287
+
3288
+ -export function truncateWidth(str: string, width: number): string {
3289
+ +export function truncateWidth(str: string, width: number, ellipsis = chromeEllipsis()): string {
3290
+ if (width <= 0) return ""
3291
+ if (stringWidth(str) <= width) return str
3292
+ - if (width === 1) return "…"
3293
+ - return takeWidth(str, width - 1) + "…"
3294
+ + const markerWidth = stringWidth(ellipsis)
3295
+ + if (width <= markerWidth) return takeWidth(ellipsis, width)
3296
+ + return takeWidth(str, width - markerWidth) + ellipsis
3297
+ }
3298
+
3299
+ -export function truncateLeft(str: string, len: number): string {
3300
+ +export function truncateLeft(str: string, len: number, ellipsis = chromeEllipsis()): string {
3301
+ if (str.length <= len) return str
3302
+ - return "…" + str.slice(-(len - 1))
3303
+ + if (len <= ellipsis.length) return ellipsis.slice(0, Math.max(0, len))
3304
+ + return ellipsis + str.slice(-(len - ellipsis.length))
3305
+ }
3306
+
3307
+ -export function truncateMiddle(str: string, maxLength: number = 35): string {
3308
+ +export function truncateMiddle(str: string, maxLength: number = 35, ellipsis = chromeEllipsis()): string {
3309
+ if (str.length <= maxLength) return str
3310
+
3311
+ - const ellipsis = "…"
3312
+ + if (maxLength <= ellipsis.length) return ellipsis.slice(0, maxLength)
3313
+ const keepStart = Math.ceil((maxLength - ellipsis.length) / 2)
3314
+ const keepEnd = Math.floor((maxLength - ellipsis.length) / 2)
3315
+
3316
+ diff --git a/packages/tui/src/util/marquee.ts b/packages/tui/src/util/marquee.ts
3317
+ index 8b9428ed4bde4d3da0a00c9daff970564846a4bc..798fa67117f931c425ebfc2cd5f999fc76945e00 100644
3318
+ --- a/packages/tui/src/util/marquee.ts
3319
+ +++ b/packages/tui/src/util/marquee.ts
3320
+ @@ -1,7 +1,8 @@
3321
+ import { Locale } from "./locale"
3322
+ import { stringWidth } from "./string-width"
3323
+ +import { chromeGlyph } from "../ui/glyphs"
3324
+
3325
+ -const GAP = " · "
3326
+ +const GAP = ` ${chromeGlyph("·")} `
3327
+
3328
+ export type MarqueeTextPart = {
3329
+ value: string
3330
+ @@ -29,7 +30,7 @@ export function marqueeTextParts(value: string, width: number, offset: number):
3331
+
3332
+ const loop = [
3333
+ ...Locale.graphemes(value).map((value) => ({ value, separator: false })),
3334
+ - ...Locale.graphemes(GAP).map((value) => ({ value, separator: value === "·" })),
3335
+ + ...Locale.graphemes(GAP).map((value) => ({ value, separator: value === chromeGlyph("·") })),
3336
+ ]
3337
+ const cursor = offset % marqueeCycleWidth(value)
3338
+ const parts = [...loop, ...loop]
3339
+ diff --git a/packages/tui/src/util/permission.ts b/packages/tui/src/util/permission.ts
3340
+ index 09d607ff373ea1075a024b2a14a3aaf522713840..f9bc32c7b380a4bb1e86abfe102c32ba7a7eeeed 100644
3341
+ --- a/packages/tui/src/util/permission.ts
3342
+ +++ b/packages/tui/src/util/permission.ts
3343
+ @@ -1,5 +1,6 @@
3344
+ import { Locale } from "./locale"
3345
+ import { canonicalToolName, finiteNumber, webSearchProviderLabel } from "./tool-display"
3346
+ +import { chromeGlyph } from "../ui/glyphs"
3347
+
3348
+ type Dict = Record<string, unknown>
3349
+
3350
+ @@ -34,7 +35,7 @@ export function permissionPresentation(
3351
+ const first = dict(Array.isArray(metadata.files) ? metadata.files[0] : undefined)
3352
+ const diff = text(first.patch) || text(first.diff) || text(metadata.diff) || undefined
3353
+ return {
3354
+ - icon: "→",
3355
+ + icon: chromeGlyph("→"),
3356
+ title: `Edit ${formatPath(file)}`,
3357
+ lines: [],
3358
+ diff,
3359
+ @@ -47,7 +48,7 @@ export function permissionPresentation(
3360
+ const value = text(input.path) || resources[0] || ""
3361
+ const title = action === "read" ? "Read" : "List"
3362
+ return {
3363
+ - icon: "→",
3364
+ + icon: chromeGlyph("→"),
3365
+ title: `${title} ${formatPath(value)}`,
3366
+ lines: value ? [`Path: ${formatPath(value)}`] : [],
3367
+ }
3368
+ @@ -57,7 +58,7 @@ export function permissionPresentation(
3369
+ const pattern = text(input.pattern) || resources[0] || ""
3370
+ const title = action === "glob" ? "Glob" : "Grep"
3371
+ return {
3372
+ - icon: "✱",
3373
+ + icon: chromeGlyph("✱"),
3374
+ title: `${title} "${pattern}"`,
3375
+ lines: pattern ? [`Pattern: ${pattern}`] : [],
3376
+ }
3377
+ @@ -78,7 +79,7 @@ export function permissionPresentation(
3378
+ return {
3379
+ icon: "#",
3380
+ title: `${Locale.titlecase(agent)} Subagent`,
3381
+ - lines: description ? [`◉ ${description}`] : [],
3382
+ + lines: description ? [`${chromeGlyph("◉")} ${description}`] : [],
3383
+ }
3384
+ }
3385
+
3386
+ @@ -95,7 +96,7 @@ export function permissionPresentation(
3387
+ const query = text(input.query) || text(metadata.query)
3388
+ const title = webSearchProviderLabel(metadata.provider)
3389
+ return {
3390
+ - icon: "◈",
3391
+ + icon: chromeGlyph("◈"),
3392
+ title: query ? `${title} "${query}"` : title,
3393
+ lines: query ? [`Query: ${query}`] : [],
3394
+ }
3395
+ @@ -108,7 +109,7 @@ export function permissionPresentation(
3396
+ const character = finiteNumber(input.character)
3397
+ const position = line !== undefined && character !== undefined ? `${line}:${character}` : undefined
3398
+ return {
3399
+ - icon: "→",
3400
+ + icon: chromeGlyph("→"),
3401
+ title: `LSP ${operation}${file ? ` ${formatPath(file)}${position ? `:${position}` : ""}` : ""}`,
3402
+ lines: [
3403
+ ...(input.operation ? [`Operation: ${operation}`] : []),
3404
+ @@ -122,7 +123,7 @@ export function permissionPresentation(
3405
+ const raw = text(metadata.parentDir) || text(metadata.filepath) || resources[0] || ""
3406
+ const directory = wildcardDirectory(raw)
3407
+ return {
3408
+ - icon: "←",
3409
+ + icon: chromeGlyph("←"),
3410
+ title: `Access external directory ${formatPath(directory)}`,
3411
+ lines: resources.map((item) => `- ${item}`),
3412
+ }
3413
+ @@ -130,14 +131,14 @@ export function permissionPresentation(
3414
+
3415
+ if (action === "doom_loop") {
3416
+ return {
3417
+ - icon: "⟳",
3418
+ + icon: chromeGlyph("⟳"),
3419
+ title: "Continue after repeated failures",
3420
+ lines: ["This keeps the session running despite repeated failures."],
3421
+ }
3422
+ }
3423
+
3424
+ return {
3425
+ - icon: "⚙",
3426
+ + icon: chromeGlyph("⚙"),
3427
+ title: `Call tool ${source.action}`,
3428
+ lines: [`Tool: ${source.action}`],
3429
+ }
3430
+ diff --git a/packages/tui/src/util/session.ts b/packages/tui/src/util/session.ts
3431
+ index c7213e2205edc6d3bf1e068998527722b83fb75a..95f4dd09b2c3cb0e571d3e8ea6b4d673b0ae652a 100644
3432
+ --- a/packages/tui/src/util/session.ts
3433
+ +++ b/packages/tui/src/util/session.ts
3434
+ @@ -1,6 +1,7 @@
3435
+ import type { ModelInfo, SessionMessageInfo } from "@opencode/client"
3436
+ import { lastAssistantWithUsage } from "@opencode/client/solid"
3437
+ import { Locale } from "./locale"
3438
+ +import { asciiGlyphs, chromeGlyph } from "../ui/glyphs"
3439
+
3440
+ type SessionNode = {
3441
+ id: string
3442
+ @@ -32,10 +33,12 @@ export function sessionFamily<T extends SessionNode>(sessions: readonly T[], ses
3443
+ const prefix =
3444
+ ancestors.length === 0
3445
+ ? ""
3446
+ - : ancestors
3447
+ + : asciiGlyphs()
3448
+ + ? ancestors.slice(1).map((ancestor) => (ancestor ? " " : "| ")).join("") + (last ? "`- " : "|- ")
3449
+ + : ancestors
3450
+ .slice(1)
3451
+ - .map((ancestor) => (ancestor ? " " : "│ "))
3452
+ - .join("") + (last ? "└─ " : "├─ ")
3453
+ + .map((ancestor) => (ancestor ? " " : `${chromeGlyph("│")} `))
3454
+ + .join("") + (last ? `${chromeGlyph("└")}─ ` : `${chromeGlyph("├")}─ `)
3455
+ return [{ session, prefix }, ...walk(session.id, [...ancestors, last])]
3456
+ })
3457
+ }
3458
+ diff --git a/packages/tui/test/glyphs.test.ts b/packages/tui/test/glyphs.test.ts
3459
+ new file mode 100644
3460
+ index 0000000000000000000000000000000000000000..d89b7b9eb18810db774560c3c864a562cb8dc31d
3461
+ --- /dev/null
3462
+ +++ b/packages/tui/test/glyphs.test.ts
3463
+ @@ -0,0 +1,207 @@
3464
+ +import { describe, expect, test } from "bun:test"
3465
+ +
3466
+ +describe("host chrome glyph compatibility", () => {
3467
+ + test("maps known chrome symbols only in ASCII mode", async () => {
3468
+ + process.env.CTG_TUI_GLYPHS = "ascii"
3469
+ + const { chromeGlyph, chromeLabel, glyphMode } = await import("../src/ui/glyphs")
3470
+ + expect(glyphMode()).toBe("ascii")
3471
+ + expect(chromeGlyph("✓")).toBe("+")
3472
+ + expect(chromeGlyph("✗")).toBe("x")
3473
+ + expect(chromeGlyph("◈")).toBe("@")
3474
+ + expect(chromeGlyph("┃")).toBe("|")
3475
+ + expect(chromeGlyph("·")).toBe(".")
3476
+ + expect(chromeGlyph("−")).toBe("-")
3477
+ + expect(chromeGlyph("—")).toBe("-")
3478
+ + expect(chromeLabel("Searching…")).toBe("Searching...")
3479
+ + expect(chromeLabel("literal user title…")).toBe("literal user title…")
3480
+ + for (const label of [
3481
+ + "Run a command…", "Ask anything…", "Searching…", "Search worktrees…",
3482
+ + "Search sessions and projects…", "Connecting …", "Searching sessions…", "Loading sessions…",
3483
+ + "Writing debug snapshot…", "Checking for updates…", "Loading skills…", "Gathering your stats…",
3484
+ + "Restarting service…", "Connection lost…", "Receiving code…", "Waiting for code…",
3485
+ + "Waiting for output…", "Loading worktrees…", "Refreshing sessions and projects…", "Creating worktree…",
3486
+ + "Loading server information…", "Starting command…", "Waiting for command to finish…",
3487
+ + "Starting authorization…", "Waiting for authorization…", "Opening link…", "Finishing startup…",
3488
+ + "Loading plugins…", "Loading image…", "Loading session history…", "Writing command…",
3489
+ + "Fetching from the web…", "Delegating…", "Preparing edit…", "Forking session…", "Resolving diff…",
3490
+ + "Loading branches…", "Choose…", "Loading diff…", "processing…", "Working…", "Search sessions…",
3491
+ + "Loading output…", "Searching content…", "Reading file…", "Preparing write…", "Finding files…",
3492
+ + "Searching web…", "Asking questions…", "Loading skill…", "Waiting for permission event…", "Waiting…",
3493
+ + "No matching files…",
3494
+ + ]) expect(chromeLabel(label)).not.toContain("…")
3495
+ + expect(chromeGlyph("model text ✓ stays intact")).toBe("model text ✓ stays intact")
3496
+ + for (const glyph of ["→", "←", "↳", "↻", "⟳", "✱", "◈", "⚙", "✓", "✗", "×", "•", "●", "▪", "■", "◉", "△", "›", "┃", "│", "…", "↑", "↓", "├", "└", "─"])
3497
+ + expect(chromeGlyph(glyph)).toMatch(/^[\x00-\x7f]+$/)
3498
+ + })
3499
+ +
3500
+ + test("selects ASCII spinner and border chrome without changing Unicode defaults", async () => {
3501
+ + process.env.CTG_TUI_GLYPHS = "ascii"
3502
+ + const { SPINNER_FRAMES } = await import("../src/component/spinner-frames")
3503
+ + const { SplitBorder, EmptyBorder } = await import("../src/ui/border")
3504
+ + expect(SPINNER_FRAMES).toEqual(["-", "\\", "|", "/"])
3505
+ + expect(SplitBorder.customBorderChars).toEqual({
3506
+ + topLeft: "+", bottomLeft: "+", vertical: "|", topRight: "+", bottomRight: "+",
3507
+ + horizontal: "-", bottomT: "+", topT: "+", cross: "+", leftT: "+", rightT: "+",
3508
+ + })
3509
+ +
3510
+ + process.env.CTG_TUI_GLYPHS = "unicode"
3511
+ + // Border characters are resolved at render time, so explicit Unicode remains intact.
3512
+ + expect(SplitBorder.customBorderChars).toEqual({ ...EmptyBorder, vertical: "┃" })
3513
+ + })
3514
+ +
3515
+ + test("keeps Unicode by default and supports explicit Unicode", async () => {
3516
+ + process.env.CTG_TUI_GLYPHS = "unicode"
3517
+ + const { chromeGlyph, glyphMode } = await import("../src/ui/glyphs")
3518
+ + expect(glyphMode()).toBe("unicode")
3519
+ + expect(chromeGlyph("✓")).toBe("✓")
3520
+ + const { spinnerFrames } = await import("../src/component/spinner-frames")
3521
+ + expect(spinnerFrames()).toEqual(["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
3522
+ + delete process.env.CTG_TUI_GLYPHS
3523
+ + expect(glyphMode()).toBe("unicode")
3524
+ + })
3525
+ +
3526
+ + test("auto is Unicode until the launcher supplies a probe result", async () => {
3527
+ + process.env.CTG_TUI_GLYPHS = "auto"
3528
+ + const { glyphMode, chromeGlyph } = await import("../src/ui/glyphs")
3529
+ + expect(glyphMode()).toBe("auto")
3530
+ + expect(chromeGlyph("✓")).toBe("✓")
3531
+ + delete process.env.CTG_TUI_GLYPHS
3532
+ + })
3533
+ +
3534
+ + test("permission and session-tree chrome follow ASCII mode", async () => {
3535
+ + process.env.CTG_TUI_GLYPHS = "ascii"
3536
+ + const { permissionPresentation } = await import("../src/util/permission")
3537
+ + expect(permissionPresentation({ action: "edit", resources: ["file.ts"] }).icon).toBe(">")
3538
+ + const { sessionFamily } = await import("../src/util/session")
3539
+ + const family = sessionFamily(
3540
+ + [
3541
+ + { id: "root" },
3542
+ + { id: "child", parentID: "root" },
3543
+ + { id: "grandchild", parentID: "child" },
3544
+ + ],
3545
+ + "grandchild",
3546
+ + )
3547
+ + expect(family.map((item) => item.prefix)).toEqual(["", "`- "])
3548
+ + process.env.CTG_TUI_GLYPHS = "unicode"
3549
+ + expect(
3550
+ + sessionFamily(
3551
+ + [{ id: "root" }, { id: "child", parentID: "root" }, { id: "grandchild", parentID: "child" }],
3552
+ + "grandchild",
3553
+ + )[1]?.prefix,
3554
+ + ).toBe("└─ ")
3555
+ + delete process.env.CTG_TUI_GLYPHS
3556
+ + })
3557
+ +
3558
+ + test("full TUI status, truncation, and secondary chrome have ASCII consumers", async () => {
3559
+ + process.env.CTG_TUI_GLYPHS = "ascii"
3560
+ + const { chromeGlyph, chromeStatusSeparator } = await import("../src/ui/glyphs")
3561
+ + expect(chromeGlyph("⇆")).toBe("<>")
3562
+ + expect(chromeGlyph("✕")).toBe("x")
3563
+ + expect(chromeGlyph("⦿")).toBe("o")
3564
+ + expect(chromeGlyph("⬖")).toBe("*")
3565
+ + expect(chromeGlyph("⌕")).toBe("?")
3566
+ + expect(chromeGlyph("↑")).toBe("^")
3567
+ + expect(chromeGlyph("↓")).toBe("v")
3568
+ + expect(chromeGlyph("⎇")).toBe("*")
3569
+ + expect(chromeStatusSeparator()).toBe("|")
3570
+ + const { tabUnreadMarker } = await import("../src/component/session-tabs")
3571
+ + expect(tabUnreadMarker("small-dot")).toBe("*")
3572
+ + expect(tabUnreadMarker("large-square")).toBe("#")
3573
+ + const { sessionTabDetail } = await import("../src/context/session-tabs-model")
3574
+ + expect(sessionTabDetail("project", "feature", "main", true)).toBe("project * feature")
3575
+ + process.env.CTG_TUI_GLYPHS = "unicode"
3576
+ + expect(sessionTabDetail("project", "feature", "main", true)).toBe("project ⎇ feature")
3577
+ + process.env.CTG_TUI_GLYPHS = "ascii"
3578
+ +
3579
+ + const { chromeEllipsis, chromeBorderChars } = await import("../src/ui/glyphs")
3580
+ + expect(chromeEllipsis()).toBe("...")
3581
+ + expect(chromeBorderChars()).toEqual({
3582
+ + topLeft: "+", bottomLeft: "+", vertical: "|", topRight: "+", bottomRight: "+",
3583
+ + horizontal: "-", bottomT: "+", topT: "+", cross: "+", leftT: "+", rightT: "+",
3584
+ + })
3585
+ + delete process.env.CTG_TUI_GLYPHS
3586
+ + })
3587
+ +
3588
+ + test("ASCII mode changes generated truncation markers, not source text", async () => {
3589
+ + process.env.CTG_TUI_GLYPHS = "ascii"
3590
+ + const { truncate, truncateWidth, truncateMiddle } = await import("../src/util/locale")
3591
+ + const { truncateFilePath } = await import("../src/ui/file-path")
3592
+ + const { collapseToolOutput } = await import("../src/util/collapse-tool-output")
3593
+ + expect(truncate("αβγδε", 4)).toBe("α...")
3594
+ + expect(truncateWidth("αβγδε", 4)).toBe("α...")
3595
+ + expect(truncateMiddle("αβγδεζηθ", 7)).toBe("αβ...ηθ")
3596
+ + expect(truncateFilePath("/home/alex/projects/éclair.ts", 16)).toContain("...")
3597
+ + expect(collapseToolOutput("first\nsecond\nthird", 2, 100).output).toBe("first\nsecond...")
3598
+ + process.env.CTG_TUI_GLYPHS = "unicode"
3599
+ + expect(truncate("αβγδε", 4)).toBe("αβγ…")
3600
+ + expect(truncateWidth("αβγδε", 4)).toBe("αβγ…")
3601
+ + expect(truncateFilePath("/home/alex/projects/éclair.ts", 16)).toContain("…")
3602
+ + delete process.env.CTG_TUI_GLYPHS
3603
+ + })
3604
+ +
3605
+ + test("ASCII logo artwork is selected by the mounted full-TUI logo consumer", async () => {
3606
+ + process.env.CTG_TUI_GLYPHS = "ascii"
3607
+ + const { ASCII_LOGO, asciiGlyphs } = await import("../src/ui/glyphs")
3608
+ + expect(asciiGlyphs()).toBe(true)
3609
+ + expect(ASCII_LOGO.left.join("") + ASCII_LOGO.right.join("") + ASCII_LOGO.compact.join("")).toMatch(/^[\x00-\x7f]+$/)
3610
+ + process.env.CTG_TUI_GLYPHS = "unicode"
3611
+ + expect(asciiGlyphs()).toBe(false)
3612
+ + delete process.env.CTG_TUI_GLYPHS
3613
+ + })
3614
+ +
3615
+ + test("runtime host separators, shell omitted marker, and disclosures are ASCII while values stay intact", async () => {
3616
+ + process.env.CTG_TUI_GLYPHS = "ascii"
3617
+ + const { chromeGlyph } = await import("../src/ui/glyphs")
3618
+ + const { shellOutputOmittedLabel } = await import("../src/component/dialog-shell-output")
3619
+ + const { activityGroupDisclosureIcon } = await import("../src/routes/session/group-view")
3620
+ + const { promptMetadataLayout } = await import("../src/component/prompt/metadata")
3621
+ + const { joinPromptFooterStatus } = await import("../src/feature-plugins/prompt/footer")
3622
+ + const { datetime } = await import("../src/util/locale")
3623
+ + expect(shellOutputOmittedLabel()).toBe("Earlier output omitted . showing recent output")
3624
+ + expect(activityGroupDisclosureIcon(true)).toBe("-")
3625
+ + expect(activityGroupDisclosureIcon(false)).toBe("+")
3626
+ + const metadata = promptMetadataLayout({
3627
+ + width: 60, terminalWidth: 80, agent: "agent·name", model: "model−name", provider: "provider", variant: "variant",
3628
+ + })
3629
+ + expect(metadata.agent).toBe("agent·name")
3630
+ + expect(metadata.model).toBe("model−name")
3631
+ + expect(joinPromptFooterStatus(["shell·label", "user−value"])).toBe("shell·label . user−value")
3632
+ + expect(datetime(Date.UTC(2024, 0, 1))).toContain(" . ")
3633
+ + expect("Earlier output omitted · showing recent output".replace("·", chromeGlyph("·"))).toBe("Earlier output omitted . showing recent output")
3634
+ + delete process.env.CTG_TUI_GLYPHS
3635
+ + })
3636
+ +
3637
+ + test("visible prompt metadata and marquee separator map chrome without rewriting dynamic values", async () => {
3638
+ + process.env.CTG_TUI_GLYPHS = "ascii"
3639
+ + const { promptMetadataLayout } = await import("../src/component/prompt/metadata")
3640
+ + const { marqueeText } = await import("../src/util/marquee")
3641
+ + const metadata = promptMetadataLayout({
3642
+ + width: 60, terminalWidth: 80, agent: "Agent·Name", model: "Model−Name", provider: "Provider", variant: "Variant",
3643
+ + })
3644
+ + expect(metadata.agent).toBe("Agent·Name")
3645
+ + expect(metadata.model).toBe("Model−Name")
3646
+ + expect(marqueeText("input·text", 30, 1)).toBe("input·text")
3647
+ + expect(marqueeText("input·text", 5, 7)).toBe("ext .")
3648
+ + process.env.CTG_TUI_GLYPHS = "unicode"
3649
+ + const unicode = promptMetadataLayout({
3650
+ + width: 60, terminalWidth: 80, agent: "Agent", model: "Model", provider: "Provider", variant: "Variant",
3651
+ + })
3652
+ + expect(unicode.agent).toBe("Agent")
3653
+ + delete process.env.CTG_TUI_GLYPHS
3654
+ + })
3655
+ +
3656
+ + test("session-list relative-path footer executes its truncation branch in both modes", async () => {
3657
+ + process.env.CTG_TUI_GLYPHS = "ascii"
3658
+ + const { sessionListRelativePathFooter } = await import("../src/component/dialog-session-list")
3659
+ + const relative = "../folder·model−name/feature·with-a-long-path-segment-that-needs-truncation.ts"
3660
+ + const ascii = sessionListRelativePathFooter(relative)
3661
+ + expect(ascii).toContain("...")
3662
+ + expect(ascii).toContain("feature·")
3663
+ + expect(ascii).not.toContain("−")
3664
+ + process.env.CTG_TUI_GLYPHS = "unicode"
3665
+ + const unicode = sessionListRelativePathFooter(relative)
3666
+ + expect(unicode).toContain("…")
3667
+ + expect(unicode).toContain("feature·")
3668
+ + delete process.env.CTG_TUI_GLYPHS
3669
+ + })
3670
+ +})