@visual-z/xeno 0.2.1 → 0.4.0

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 (282) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +126 -34
  3. package/docs/app-server.md +84 -0
  4. package/docs/install.md +51 -13
  5. package/docs/providers.md +24 -3
  6. package/package.json +7 -5
  7. package/src/apps/app-server.ts +28 -0
  8. package/src/apps/approve.ts +3 -3
  9. package/src/apps/cli.ts +25 -10
  10. package/src/apps/commands.ts +45 -8
  11. package/src/apps/completions.ts +78 -22
  12. package/src/apps/control-transport.ts +89 -0
  13. package/src/apps/image-draft.ts +46 -0
  14. package/src/apps/model-catalog.ts +2 -1
  15. package/src/apps/project-trust.ts +2 -42
  16. package/src/apps/provider-connect.ts +346 -0
  17. package/src/apps/render.ts +22 -47
  18. package/src/apps/resources.ts +5 -2
  19. package/src/apps/shell.ts +333 -1542
  20. package/src/apps/startup-input.ts +45 -9
  21. package/src/apps/startup.ts +149 -94
  22. package/src/apps/ui/app.ts +1553 -0
  23. package/src/apps/ui/control-model.ts +26 -0
  24. package/src/apps/ui/extension-host.ts +194 -0
  25. package/src/apps/ui/image-cache.ts +78 -0
  26. package/src/apps/ui/palette.ts +88 -0
  27. package/src/apps/ui/project.ts +127 -0
  28. package/src/apps/ui/standalone.ts +146 -0
  29. package/src/apps/ui/status-model.ts +68 -0
  30. package/src/apps/ui/surface.ts +86 -0
  31. package/src/apps/ui/theme.ts +86 -0
  32. package/src/core/acceptance.ts +107 -0
  33. package/src/core/action-defaults.ts +47 -295
  34. package/src/core/benchmark.ts +2 -2
  35. package/src/core/branch-receipt.ts +24 -0
  36. package/src/core/checkpoints.ts +64 -0
  37. package/src/core/churn.ts +2 -0
  38. package/src/core/command-artifacts.ts +94 -0
  39. package/src/core/command-contract.ts +35 -0
  40. package/src/core/completion-action.ts +20 -0
  41. package/src/core/completion-review.ts +37 -21
  42. package/src/core/context-detail.ts +37 -0
  43. package/src/core/context-policy.ts +74 -1
  44. package/src/core/continuation.ts +17 -2
  45. package/src/core/control-protocol.ts +62 -0
  46. package/src/core/control-view.ts +42 -0
  47. package/src/core/derive.ts +29 -3
  48. package/src/core/draft-images.ts +56 -0
  49. package/src/core/environment.ts +145 -0
  50. package/src/core/evaluation-coverage.ts +74 -0
  51. package/src/core/event-validation.ts +81 -6
  52. package/src/core/events.ts +99 -3
  53. package/src/core/evidence-links.ts +7 -0
  54. package/src/core/evidence.ts +13 -3
  55. package/src/core/execution.ts +8 -2
  56. package/src/core/experiment.ts +133 -0
  57. package/src/core/export.ts +9 -0
  58. package/src/core/failure-recovery.ts +173 -0
  59. package/src/core/file-precondition.ts +72 -0
  60. package/src/core/handoff.ts +65 -4
  61. package/src/core/harness-diagnostics.ts +206 -0
  62. package/src/core/history-query.ts +38 -2
  63. package/src/core/history.ts +38 -1
  64. package/src/core/integration-health.ts +34 -0
  65. package/src/core/keymap.ts +8 -20
  66. package/src/core/model-picker.ts +64 -0
  67. package/src/core/native-eval.ts +25 -0
  68. package/src/core/note-guidance.ts +34 -0
  69. package/src/core/oauth-fields.ts +44 -0
  70. package/src/core/observation-view.ts +49 -8
  71. package/src/core/permissions.ts +24 -2
  72. package/src/core/process-receipt.ts +105 -0
  73. package/src/core/process-state.ts +37 -0
  74. package/src/core/program-status.ts +31 -0
  75. package/src/core/project.ts +70 -19
  76. package/src/core/prompt-policy.ts +37 -0
  77. package/src/core/query-eval-contract.ts +14 -0
  78. package/src/core/read-observation.ts +82 -0
  79. package/src/core/recovery-corpus.ts +17 -0
  80. package/src/core/recovery-evaluation.ts +60 -0
  81. package/src/core/recovery-sources.ts +74 -0
  82. package/src/core/request-progress.ts +87 -0
  83. package/src/core/request-state.ts +114 -0
  84. package/src/core/runtime-target.ts +94 -0
  85. package/src/core/search-observation.ts +97 -0
  86. package/src/core/session-tree.ts +147 -0
  87. package/src/core/settings.ts +33 -10
  88. package/src/core/skills.ts +44 -0
  89. package/src/core/sql-query.ts +211 -0
  90. package/src/core/theme.ts +36 -58
  91. package/src/core/tool-contract.ts +51 -2
  92. package/src/core/tool-discovery.ts +87 -0
  93. package/src/core/tool-failure.ts +60 -0
  94. package/src/core/tool-input.ts +6 -0
  95. package/src/core/tool-mode-protocol.ts +25 -0
  96. package/src/core/transcript.ts +19 -4
  97. package/src/core/trial-lifecycle.ts +14 -0
  98. package/src/core/tui-mode.ts +29 -0
  99. package/src/core/update-check.ts +97 -0
  100. package/src/core/version.ts +55 -0
  101. package/src/core/work-state.ts +49 -3
  102. package/src/core/working-set.ts +91 -0
  103. package/src/runtime/auth.ts +11 -4
  104. package/src/runtime/branch-jobs.ts +36 -0
  105. package/src/runtime/catalog.ts +13 -3
  106. package/src/runtime/clipboard.ts +15 -4
  107. package/src/runtime/command-artifacts.ts +64 -0
  108. package/src/runtime/control-connection.ts +104 -0
  109. package/src/runtime/environment-tools.ts +60 -0
  110. package/src/runtime/evidence-notes.ts +4 -2
  111. package/src/runtime/exec-sessions.ts +206 -61
  112. package/src/runtime/execution-control.ts +1 -0
  113. package/src/runtime/extension-ui.ts +22 -30
  114. package/src/runtime/extensions.ts +95 -16
  115. package/src/runtime/harness-comparison.ts +27 -7
  116. package/src/runtime/harness-eval.ts +462 -0
  117. package/src/runtime/history-cache.ts +57 -0
  118. package/src/runtime/history-tools.ts +62 -12
  119. package/src/runtime/integrations.ts +210 -0
  120. package/src/runtime/loop.ts +1088 -282
  121. package/src/runtime/model-discovery.ts +208 -0
  122. package/src/runtime/model.ts +9 -0
  123. package/src/runtime/native-eval-fixtures.ts +33 -0
  124. package/src/runtime/native-eval.ts +146 -0
  125. package/src/runtime/oauth.ts +80 -3
  126. package/src/runtime/pi-provider.ts +169 -12
  127. package/src/runtime/program-status.ts +38 -0
  128. package/src/runtime/provider-connect.ts +109 -0
  129. package/src/runtime/provider-presets.ts +87 -0
  130. package/src/runtime/provider-request.ts +13 -2
  131. package/src/runtime/provider.ts +23 -9
  132. package/src/runtime/query-eval-verifier.ts +28 -0
  133. package/src/runtime/recovery-coverage.ts +71 -0
  134. package/src/runtime/recovery-evidence.ts +76 -0
  135. package/src/runtime/recovery-fixtures.ts +116 -0
  136. package/src/runtime/runtime-target.ts +149 -0
  137. package/src/runtime/session-control.ts +198 -0
  138. package/src/runtime/tool-broker.ts +137 -0
  139. package/src/runtime/tool-discovery.ts +57 -0
  140. package/src/runtime/tool-mode-eval.ts +146 -0
  141. package/src/runtime/tool-queue.ts +77 -0
  142. package/src/runtime/tools.ts +628 -183
  143. package/src/runtime/trial-control.ts +45 -0
  144. package/src/runtime/update-check.ts +47 -0
  145. package/src/runtime/window-trial.ts +2 -1
  146. package/src/runtime/working-notes.ts +33 -3
  147. package/src/runtime/workspace-lifecycle.ts +97 -0
  148. package/src/store/acceptance.ts +15 -0
  149. package/src/store/checkpoint-history.ts +43 -0
  150. package/src/store/command-artifacts.ts +96 -0
  151. package/src/store/command.ts +65 -37
  152. package/src/store/file-checkpoints.ts +104 -0
  153. package/src/store/file-index.ts +13 -1
  154. package/src/store/git-checkpoints.ts +67 -0
  155. package/src/store/observations.ts +61 -20
  156. package/src/store/process-receipts.ts +34 -0
  157. package/src/store/query-source.ts +13 -8
  158. package/src/store/recovery.ts +2 -3
  159. package/src/store/replay.ts +16 -4
  160. package/src/store/runtime-target.ts +64 -0
  161. package/src/store/search.ts +171 -132
  162. package/src/store/session-archive.ts +4 -0
  163. package/src/store/source-cancellation.ts +6 -0
  164. package/src/store/update-cache.ts +36 -0
  165. package/src/store/workspace-observation.ts +113 -0
  166. package/src/tui/ansi-spans.ts +126 -0
  167. package/src/tui/style.ts +19 -16
  168. package/src/tui/ui/atomic-draft.ts +121 -0
  169. package/src/tui/ui/canvas.ts +96 -0
  170. package/src/tui/ui/colors.ts +70 -0
  171. package/src/tui/ui/composer.ts +200 -0
  172. package/src/tui/ui/control-panel.ts +23 -0
  173. package/src/tui/ui/dialog.ts +379 -0
  174. package/src/tui/ui/draft.ts +23 -0
  175. package/src/tui/ui/edge.ts +186 -0
  176. package/src/tui/ui/image-size.ts +40 -0
  177. package/src/tui/ui/ink.ts +210 -0
  178. package/src/tui/ui/items.ts +40 -0
  179. package/src/tui/ui/keys.ts +40 -0
  180. package/src/tui/ui/layout.ts +74 -0
  181. package/src/tui/ui/list-model.ts +86 -0
  182. package/src/tui/ui/list-view.ts +172 -0
  183. package/src/tui/ui/mascot.ts +67 -0
  184. package/src/tui/ui/messages.ts +518 -0
  185. package/src/tui/ui/motion.ts +28 -0
  186. package/src/tui/ui/paths.ts +10 -0
  187. package/src/tui/ui/pictures.ts +78 -0
  188. package/src/tui/ui/screen.ts +421 -0
  189. package/src/tui/ui/status.ts +279 -0
  190. package/src/tui/ui/styled.ts +85 -0
  191. package/src/tui/ui/testing.ts +5 -0
  192. package/src/tui/ui/tool-card.ts +150 -0
  193. package/src/apps/extension-ui.ts +0 -330
  194. package/src/apps/resource-browser.ts +0 -75
  195. package/src/apps/session-browser.ts +0 -113
  196. package/src/apps/session-picker.ts +0 -55
  197. package/src/tui/ansi.ts +0 -67
  198. package/src/tui/autocomplete.ts +0 -29
  199. package/src/tui/chat-viewport.ts +0 -47
  200. package/src/tui/complete.ts +0 -87
  201. package/src/tui/editor.ts +0 -940
  202. package/src/tui/host.ts +0 -264
  203. package/src/tui/input.ts +0 -75
  204. package/src/tui/keys.ts +0 -378
  205. package/src/tui/markdown.ts +0 -17
  206. package/src/tui/render.ts +0 -153
  207. package/src/tui/select.ts +0 -258
  208. package/src/tui/settings.ts +0 -44
  209. package/src/tui/statusline.ts +0 -162
  210. package/src/tui/system-theme.ts +0 -700
  211. package/src/tui/terminal.ts +0 -30
  212. package/src/tui/text.ts +0 -52
  213. package/src/tui/toolview.ts +0 -386
  214. package/src/tui/transcript.ts +0 -123
  215. package/src/tui/tree.ts +0 -471
  216. package/src/vendor/pi-tui/LICENSE.upstream +0 -21
  217. package/src/vendor/pi-tui/UPSTREAM.json +0 -50
  218. package/src/vendor/pi-tui/VENDOR.md +0 -25
  219. package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
  220. package/src/vendor/pi-tui/autocomplete.ts +0 -860
  221. package/src/vendor/pi-tui/colors.ts +0 -367
  222. package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
  223. package/src/vendor/pi-tui/components/box.ts +0 -166
  224. package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
  225. package/src/vendor/pi-tui/components/editor.ts +0 -2472
  226. package/src/vendor/pi-tui/components/h-stack.ts +0 -44
  227. package/src/vendor/pi-tui/components/image.ts +0 -127
  228. package/src/vendor/pi-tui/components/input.ts +0 -494
  229. package/src/vendor/pi-tui/components/loader.ts +0 -101
  230. package/src/vendor/pi-tui/components/markdown.ts +0 -1021
  231. package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
  232. package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
  233. package/src/vendor/pi-tui/components/select-list.ts +0 -273
  234. package/src/vendor/pi-tui/components/settings-list.ts +0 -328
  235. package/src/vendor/pi-tui/components/spacer.ts +0 -28
  236. package/src/vendor/pi-tui/components/stack.ts +0 -154
  237. package/src/vendor/pi-tui/components/text.ts +0 -107
  238. package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
  239. package/src/vendor/pi-tui/components/v-stack.ts +0 -33
  240. package/src/vendor/pi-tui/editor-component.ts +0 -74
  241. package/src/vendor/pi-tui/fuzzy.ts +0 -138
  242. package/src/vendor/pi-tui/index.ts +0 -186
  243. package/src/vendor/pi-tui/keybindings.ts +0 -320
  244. package/src/vendor/pi-tui/keys.ts +0 -1401
  245. package/src/vendor/pi-tui/kill-ring.ts +0 -46
  246. package/src/vendor/pi-tui/latex.ts +0 -1506
  247. package/src/vendor/pi-tui/layout-node.ts +0 -51
  248. package/src/vendor/pi-tui/layout.ts +0 -449
  249. package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
  250. package/src/vendor/pi-tui/native/clipboard.h +0 -208
  251. package/src/vendor/pi-tui/native/darwin/README.md +0 -22
  252. package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
  253. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  254. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  255. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
  256. package/src/vendor/pi-tui/native/linux/README.md +0 -23
  257. package/src/vendor/pi-tui/native/linux/build.sh +0 -65
  258. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  259. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  260. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
  261. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
  262. package/src/vendor/pi-tui/native/napi.h +0 -91
  263. package/src/vendor/pi-tui/native/win32/README.md +0 -31
  264. package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
  265. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  266. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  267. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
  268. package/src/vendor/pi-tui/native-modifiers.ts +0 -13
  269. package/src/vendor/pi-tui/native-module-path.ts +0 -31
  270. package/src/vendor/pi-tui/native-platform.ts +0 -65
  271. package/src/vendor/pi-tui/oklab.ts +0 -233
  272. package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
  273. package/src/vendor/pi-tui/terminal-colors.ts +0 -91
  274. package/src/vendor/pi-tui/terminal-image.ts +0 -731
  275. package/src/vendor/pi-tui/terminal.ts +0 -554
  276. package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
  277. package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
  278. package/src/vendor/pi-tui/tui.ts +0 -1493
  279. package/src/vendor/pi-tui/undo-stack.ts +0 -28
  280. package/src/vendor/pi-tui/utils.ts +0 -1385
  281. package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
  282. package/src/vendor/pi-tui/word-navigation.ts +0 -117
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Labels set into a one-row rule (docs/tasks/refactor-tui-dock-layout.md). Pure.
3
+ *
4
+ * ╭─ left · left ───────────────── right ─╮ the input box edges
5
+ * ── node 3 · model switch ─────────────── transcript dividers
6
+ * xeno title right right the top bar (splitLine)
7
+ *
8
+ * A row never wraps and never squeezes what is next to it, so when labels do
9
+ * not fit, the least important go first: each label has a priority (0 is
10
+ * never dropped), and a label with `min` shrinks to that many cells with an
11
+ * ellipsis before it is dropped. The output is always exactly `width` cells,
12
+ * measured with the same display width the renderer uses (CJK counts 2).
13
+ */
14
+ import { displayWidth } from "../width.ts";
15
+
16
+ /** Colour roles; the view maps them to the current palette. */
17
+ export type Role = "text" | "muted" | "edge" | "line" | "accent" | "success" | "error" | "warning" | "reasoning" | "code";
18
+
19
+ export interface Seg {
20
+ readonly text: string;
21
+ readonly role: Role;
22
+ readonly bold?: boolean;
23
+ readonly italic?: boolean;
24
+ /** Drawn with the activity clock's current glyph instead of `text` (one cell). */
25
+ readonly spin?: boolean;
26
+ }
27
+
28
+ export interface Label {
29
+ readonly segs: readonly Seg[];
30
+ /** 0 is never dropped; among the rest, higher numbers drop first. */
31
+ readonly priority: number;
32
+ /** Shrink the `shrink` seg (default 0) to this many cells before dropping. */
33
+ readonly min?: number;
34
+ readonly shrink?: number;
35
+ /**
36
+ * When to shrink, on the same scale as `priority` (default: the label's own
37
+ * priority). A never-dropped title can still give up width early.
38
+ */
39
+ readonly shrinkPriority?: number;
40
+ }
41
+
42
+ export const SEPARATOR: readonly Seg[] = [{ text: " · ", role: "muted" }];
43
+
44
+ function segWidth(seg: Seg): number { return seg.spin ? 1 : displayWidth(seg.text); }
45
+
46
+ export function segsWidth(segs: readonly Seg[]): number {
47
+ let width = 0;
48
+ for (const seg of segs) width += segWidth(seg);
49
+ return width;
50
+ }
51
+
52
+ /** Cut `text` to at most `width` cells, ending in "…" when anything was cut. */
53
+ export function clipText(text: string, width: number): string {
54
+ if (width <= 0) return "";
55
+ if (displayWidth(text) <= width) return text;
56
+ let out = "", used = 0;
57
+ for (const ch of text) {
58
+ const w = displayWidth(ch);
59
+ if (used + w > width - 1) break;
60
+ out += ch; used += w;
61
+ }
62
+ return `${out}…`;
63
+ }
64
+
65
+ /** Cut a run of segs to at most `width` cells; the cut seg ends in "…". */
66
+ export function clipSegs(segs: readonly Seg[], width: number): Seg[] {
67
+ if (segsWidth(segs) <= width) return [...segs];
68
+ const out: Seg[] = [];
69
+ let left = width;
70
+ for (let i = 0; i < segs.length; i++) {
71
+ const seg = segs[i]!, w = segWidth(seg);
72
+ if (w < left) { out.push(seg); left -= w; continue; }
73
+ if (left <= 0) break;
74
+ if (!seg.spin) {
75
+ // Something after this seg is being cut away, so this seg carries the ellipsis.
76
+ const body = clipText(seg.text, left - 1).replace(/…$/, "");
77
+ out.push({ ...seg, text: `${body}…` });
78
+ }
79
+ break;
80
+ }
81
+ return out;
82
+ }
83
+
84
+ interface Fitted { label: Label; segs: Seg[]; index: number; side: "left" | "right" }
85
+
86
+ /**
87
+ * Which labels survive in `avail` cells. Returns the joined segs per side
88
+ * and the indices kept, so callers and tests can see exactly what dropped.
89
+ * `gap` is the minimum space between the two sides when both survive.
90
+ */
91
+ export function fitLabels(avail: number, left: readonly Label[], right: readonly Label[], separator: readonly Seg[] = SEPARATOR, gap = 0):
92
+ { left: Seg[]; right: Seg[]; keptLeft: number[]; keptRight: number[] } {
93
+ let items: Fitted[] = [
94
+ ...left.map((label, index) => ({ label, segs: [...label.segs], index, side: "left" as const })),
95
+ ...right.map((label, index) => ({ label, segs: [...label.segs], index, side: "right" as const })),
96
+ ];
97
+ const sepWidth = segsWidth(separator);
98
+ const width = () => {
99
+ let l = 0, r = 0, nl = 0, nr = 0;
100
+ for (const item of items) { const w = segsWidth(item.segs); if (item.side === "left") { l += w; nl++; } else { r += w; nr++; } }
101
+ return l + r + Math.max(0, nl - 1) * sepWidth + Math.max(0, nr - 1) * sepWidth + (nl && nr ? gap : 0);
102
+ };
103
+ const shrinkable = (item: Fitted) => item.label.min !== undefined && segsWidth(item.segs) > item.label.min;
104
+ // The step a label takes next and how unimportant that step is: shrinking happens at
105
+ // `shrinkPriority`, dropping at `priority`. Least important step first; on a tie a
106
+ // shrink comes before a drop, and the right side (the less central one) before the left.
107
+ const level = (item: Fitted) => shrinkable(item) ? item.label.shrinkPriority ?? item.label.priority : item.label.priority;
108
+ const before = (a: Fitted, b: Fitted) => level(a) !== level(b) ? level(a) > level(b)
109
+ : shrinkable(a) !== shrinkable(b) ? shrinkable(a) : a.side === "right" && b.side === "left";
110
+ for (let guard = 0; width() > avail && guard < 500; guard++) {
111
+ const candidates = items.filter(item => item.label.priority > 0 || shrinkable(item));
112
+ if (!candidates.length) break;
113
+ const victim = candidates.reduce((a, b) => before(b, a) ? b : a);
114
+ if (shrinkable(victim)) shrink(victim, Math.max(victim.label.min!, segsWidth(victim.segs) - (width() - avail)));
115
+ else items = items.filter(item => item !== victim);
116
+ }
117
+ // Still too wide: only never-dropped labels at their minimum remain. Cut them, right side first.
118
+ for (const item of [...items].reverse()) {
119
+ const over = width() - avail;
120
+ if (over <= 0) break;
121
+ shrink(item, Math.max(0, segsWidth(item.segs) - over));
122
+ }
123
+ items = items.filter(item => segsWidth(item.segs) > 0);
124
+ const join = (side: "left" | "right") => {
125
+ const list = items.filter(item => item.side === side);
126
+ return { segs: list.flatMap((item, i) => i ? [...separator, ...item.segs] : item.segs), kept: list.map(item => item.index) };
127
+ };
128
+ const l = join("left"), r = join("right");
129
+ return { left: l.segs, right: r.segs, keptLeft: l.kept, keptRight: r.kept };
130
+ }
131
+
132
+ function shrink(item: Fitted, target: number): void {
133
+ if (!item.segs.length) return;
134
+ const at = Math.min(item.label.shrink ?? 0, item.segs.length - 1);
135
+ const room = target - (segsWidth(item.segs) - segWidth(item.segs[at]!));
136
+ if (room <= 0 || item.segs[at]!.spin) { item.segs = clipSegs(item.segs, target); return; }
137
+ item.segs[at] = { ...item.segs[at]!, text: clipText(item.segs[at]!.text, room) };
138
+ item.segs = item.segs.filter(seg => seg.spin || seg.text.length > 0);
139
+ }
140
+
141
+ export interface EdgeOptions {
142
+ readonly width: number;
143
+ readonly left?: readonly Label[];
144
+ readonly right?: readonly Label[];
145
+ /** Corner glyphs; omit for a rule with open ends (transcript dividers). */
146
+ readonly corners?: readonly [string, string];
147
+ readonly fill?: string;
148
+ readonly role?: Role;
149
+ /** Fill cells before the left labels and after the right ones. Default 1. */
150
+ readonly lead?: number;
151
+ readonly trail?: number;
152
+ }
153
+
154
+ /** One complete row, exactly `width` cells (a wide character cut at the end is filled). */
155
+ export function edgeLine(options: EdgeOptions): Seg[] {
156
+ const width = Math.max(0, Math.floor(options.width));
157
+ const role = options.role ?? "edge", fill = options.fill ?? "─", corners = options.corners;
158
+ if (width === 0) return [];
159
+ if (corners && width < 2) return [{ text: corners[0], role }];
160
+ const left = options.left ?? [], right = options.right ?? [];
161
+ const lead = options.lead ?? 1, trail = options.trail ?? 1;
162
+ const inner = width - (corners ? 2 : 0);
163
+ const pads = (left.length ? 2 : 0) + (right.length ? 2 : 0);
164
+ const fitted = fitLabels(Math.max(0, inner - lead - trail - pads), left, right, SEPARATOR, 1);
165
+ const out: Seg[] = [];
166
+ let used = 0;
167
+ const push = (seg: Seg) => { if (seg.spin || seg.text) { out.push(seg); used += segWidth(seg); } };
168
+ if (corners) out.push({ text: corners[0], role });
169
+ push({ text: fill.repeat(Math.min(lead, inner)), role });
170
+ if (fitted.left.length) { push({ text: " ", role }); fitted.left.forEach(push); push({ text: " ", role }); }
171
+ const rightBlock = fitted.right.length ? segsWidth(fitted.right) + 2 : 0;
172
+ const gap = inner - used - rightBlock - trail;
173
+ if (gap > 0) push({ text: fill.repeat(gap), role });
174
+ if (fitted.right.length) { push({ text: " ", role }); fitted.right.forEach(push); push({ text: " ", role }); }
175
+ if (inner - used > 0) push({ text: fill.repeat(inner - used), role });
176
+ if (corners) out.push({ text: corners[1], role });
177
+ return out;
178
+ }
179
+
180
+ /** A row with labels at both ends and blank space between (the top bar). Exactly `width` cells. */
181
+ export function splitLine(width: number, left: readonly Label[], right: readonly Label[], gap = 3, separator: readonly Seg[] = [{ text: " ", role: "muted" }]): Seg[] {
182
+ const w = Math.max(0, Math.floor(width));
183
+ const fitted = fitLabels(w, left, right, separator, gap);
184
+ const space = w - segsWidth(fitted.left) - segsWidth(fitted.right);
185
+ return [...fitted.left, ...(space > 0 ? [{ text: " ".repeat(space), role: "text" as const }] : []), ...fitted.right];
186
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * How many terminal cells a picture takes (docs/tasks/feat-tui-inline-images.md). Pure.
3
+ *
4
+ * A cell is taller than it is wide, so keeping a picture's shape means
5
+ * columns / rows = (width / height) × (cell height / cell width). The result
6
+ * fits the given box, is at least one cell, and never asks for more cells
7
+ * than the picture has pixels for: a 16-pixel icon stays small instead of
8
+ * being blown up into a blurry tile.
9
+ */
10
+
11
+ export interface CellSize { readonly cols: number; readonly rows: number }
12
+
13
+ /** Pixels per cell when the terminal does not report them: 9×18, the common default. Assumed. */
14
+ export const DEFAULT_CELL = { width: 9, height: 18 } as const;
15
+
16
+ export function imageCells(pixels: { readonly width: number; readonly height: number }, maxCols: number, maxRows: number,
17
+ cell: { readonly width: number; readonly height: number } = DEFAULT_CELL): CellSize {
18
+ const w = Math.max(1, pixels.width), h = Math.max(1, pixels.height);
19
+ const cw = cell.width > 0 ? cell.width : DEFAULT_CELL.width, ch = cell.height > 0 ? cell.height : DEFAULT_CELL.height;
20
+ // The picture's own size in cells caps the box: no upscaling.
21
+ const capCols = Math.max(1, Math.min(Math.floor(maxCols), Math.ceil(w / cw)));
22
+ const capRows = Math.max(1, Math.min(Math.floor(maxRows), Math.ceil(h / ch)));
23
+ const shape = (w / h) * (ch / cw); // columns per row that keep the picture's proportions
24
+ let rows = capRows, cols = Math.round(rows * shape);
25
+ if (cols > capCols) { cols = capCols; rows = Math.round(cols / shape); }
26
+ return { cols: Math.max(1, Math.min(capCols, cols)), rows: Math.max(1, Math.min(capRows, rows)) };
27
+ }
28
+
29
+ /** Rows for pictures in the transcript: a quarter of the terminal, 4–12. Assumed, between OpenCode (≤8) and Grok (≤20). */
30
+ export function transcriptImageRows(terminalRows: number): number {
31
+ return Math.max(4, Math.min(12, Math.floor(terminalRows / 4)));
32
+ }
33
+
34
+ /** Rows for the attachment strip above the input box: a sixth of the terminal, 3–6. Assumed. */
35
+ export function stripImageRows(terminalRows: number): number {
36
+ return Math.max(3, Math.min(6, Math.floor(terminalRows / 6)));
37
+ }
38
+
39
+ /** Pictures shown per message before "+N more" (OpenCode shows 3). */
40
+ export const IMAGES_PER_ITEM = 3;
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Terminal-owned colour (docs/tasks/refactor-tui-terminal-colors.md). Pure.
3
+ *
4
+ * The terminal already has a palette the user chose, often with transparency
5
+ * and blur behind it. Painting our own background defeats both, and fixed RGB
6
+ * accents clash with whatever palette is underneath. So the view asks the
7
+ * terminal (OSC 4/10/11 via OpenTUI) and derives every role from the answer:
8
+ *
9
+ * text the terminal's own foreground (SGR 39), never a guess
10
+ * background the terminal's own background (SGR 49): nothing is painted
11
+ * accents one of the 16 slots, picked for contrast and hue
12
+ * greys the foreground blended toward the background, as faint as a
13
+ * contrast target allows (Codex styles.md does the same)
14
+ *
15
+ * When the terminal does not answer, a dark/light hint picks the slots and
16
+ * fixed greys. Semantic colour still only marks glyphs and rules; it never
17
+ * colours body text (D11 keeps green/red for logged outcomes).
18
+ */
19
+
20
+ /** How a colour is emitted. Indexed and default colours follow the terminal if its palette changes. */
21
+ export type InkSpec =
22
+ | { readonly kind: "default" }
23
+ | { readonly kind: "index"; readonly index: number }
24
+ | { readonly kind: "rgb"; readonly hex: string };
25
+
26
+ export interface TerminalPalette {
27
+ readonly foreground: string;
28
+ readonly background: string;
29
+ /** The 16 ANSI slots as #rrggbb. */
30
+ readonly slots: readonly string[];
31
+ /** The terminal's own selection colours (OSC 17 / 19), when it reports them. */
32
+ readonly highlight?: { readonly background: string; readonly foreground: string };
33
+ }
34
+
35
+ export const INK_ROLES = ["text", "muted", "edge", "line", "accent", "success", "error", "warning", "reasoning", "code"] as const;
36
+ export type InkRole = (typeof INK_ROLES)[number];
37
+ export type InkSet = { readonly [K in InkRole]: InkSpec } & {
38
+ readonly background: InkSpec;
39
+ /** Selected text: the terminal's selection colours, else an inverse of its own ground (feat-tui-copy-selection DoD 5). */
40
+ readonly selectionBg: InkSpec;
41
+ readonly selectionFg: InkSpec;
42
+ }
43
+
44
+ /**
45
+ * Contrast targets. 4.5:1 is WCAG AA for text and 3:1 for UI components;
46
+ * 1.8:1 for decorative rules is an assumption (visible, never louder than text).
47
+ * A terminal whose own foreground is weaker than these gets proportionally
48
+ * lower targets, so muted text never becomes louder than body text.
49
+ */
50
+ const MUTED = { target: 4.5, share: 0.62 };
51
+ const EDGE = { target: 3, share: 0.42 };
52
+ const LINE = { target: 1.8, share: 0.3 };
53
+ /** Minimum contrast for an accent glyph against the background (WCAG non-text). */
54
+ const ACCENT_CONTRAST = 3;
55
+ /** Below this saturation a slot is a grey, not a hue (Solarized's bright slots). Assumed. */
56
+ const CHROMATIC = 0.15;
57
+ /** A bright slot stands in for its normal slot only within this hue distance. Assumed. */
58
+ const SAME_HUE_DEGREES = 40;
59
+
60
+ /** [normal, bright] slot pairs per role, in preference order. */
61
+ const SLOTS: Readonly<Record<Exclude<InkRole, "text" | "muted" | "edge" | "line">, readonly (readonly [number, number])[]>> = {
62
+ accent: [[4, 12], [6, 14]],
63
+ success: [[2, 10]],
64
+ error: [[1, 9]],
65
+ warning: [[3, 11]],
66
+ reasoning: [[5, 13]],
67
+ code: [[6, 14]],
68
+ };
69
+
70
+ /** Greys used when the terminal does not report its palette. Chosen for ≥ 4.5 / 3 / 1.6 on typical dark (#1e1e1e) and light (#ffffff) grounds. */
71
+ const FALLBACK_GREYS = {
72
+ dark: { muted: "#9a9a9a", edge: "#6e6e6e", line: "#4a4a4a" },
73
+ light: { muted: "#6a6a6a", edge: "#949494", line: "#c8c8c8" },
74
+ } as const;
75
+
76
+ export function terminalInks(palette: TerminalPalette | null, hint: "dark" | "light"): InkSet {
77
+ const valid = palette !== null && isHex(palette.foreground) && isHex(palette.background) && palette.slots.length >= 16 && palette.slots.slice(0, 16).every(isHex);
78
+ if (!valid) return fallbackInks(hint);
79
+ const { foreground: fg, background: bg } = palette;
80
+ const dark = luminance(bg) < luminance(fg);
81
+ const c = contrast(fg, bg);
82
+ const pick = (pairs: readonly (readonly [number, number])[]): InkSpec => pickSlot(palette.slots, pairs, dark, bg);
83
+ const highlight = palette.highlight && isHex(palette.highlight.background) && isHex(palette.highlight.foreground) ? palette.highlight : undefined;
84
+ return {
85
+ background: { kind: "default" },
86
+ text: { kind: "default" },
87
+ selectionBg: { kind: "rgb", hex: highlight?.background ?? fg },
88
+ selectionFg: { kind: "rgb", hex: highlight?.foreground ?? bg },
89
+ muted: { kind: "rgb", hex: faintest(fg, bg, Math.min(MUTED.target, MUTED.share * c)) },
90
+ edge: { kind: "rgb", hex: faintest(fg, bg, Math.min(EDGE.target, EDGE.share * c)) },
91
+ line: { kind: "rgb", hex: faintest(fg, bg, Math.min(LINE.target, LINE.share * c)) },
92
+ accent: pick(SLOTS.accent), success: pick(SLOTS.success), error: pick(SLOTS.error),
93
+ warning: pick(SLOTS.warning), reasoning: pick(SLOTS.reasoning), code: pick(SLOTS.code),
94
+ };
95
+ }
96
+
97
+ function fallbackInks(hint: "dark" | "light"): InkSet {
98
+ const greys = FALLBACK_GREYS[hint];
99
+ // Without a palette, a bright slot is the safer guess on dark grounds and the normal slot on light ones.
100
+ const slot = (pair: readonly [number, number]): InkSpec => ({ kind: "index", index: hint === "dark" ? pair[1] : pair[0] });
101
+ return {
102
+ background: { kind: "default" }, text: { kind: "default" },
103
+ // Inverse through the terminal's own black and white slots: its ground colours are unknown here.
104
+ selectionBg: { kind: "index", index: hint === "dark" ? 15 : 0 }, selectionFg: { kind: "index", index: hint === "dark" ? 0 : 15 },
105
+ muted: { kind: "rgb", hex: greys.muted }, edge: { kind: "rgb", hex: greys.edge }, line: { kind: "rgb", hex: greys.line },
106
+ accent: slot(SLOTS.accent[0]!), success: slot(SLOTS.success[0]!), error: slot(SLOTS.error[0]!),
107
+ warning: slot(SLOTS.warning[0]!), reasoning: slot(SLOTS.reasoning[0]!), code: slot(SLOTS.code[0]!),
108
+ };
109
+ }
110
+
111
+ function pickSlot(slots: readonly string[], pairs: readonly (readonly [number, number])[], dark: boolean, bg: string): InkSpec {
112
+ const candidates: number[] = [];
113
+ for (const [normal, bright] of pairs) {
114
+ const normalOk = saturation(slots[normal]!) > CHROMATIC;
115
+ const brightOk = saturation(slots[bright]!) > CHROMATIC && sameHue(slots[bright]!, slots[normal]!);
116
+ for (const index of dark ? [bright, normal] : [normal, bright]) if (index === bright ? brightOk : normalOk) candidates.push(index);
117
+ }
118
+ const readable = candidates.find(index => contrast(slots[index]!, bg) >= ACCENT_CONTRAST);
119
+ if (readable !== undefined) return { kind: "index", index: readable };
120
+ // Every candidate is too faint on this ground: keep the first hue, pushed away from the background.
121
+ if (candidates.length) return { kind: "rgb", hex: deepen(slots[candidates[0]!]!, bg, ACCENT_CONTRAST) };
122
+ // No chromatic slot at all (a monochrome palette): the terminal's own slot is still the honest choice.
123
+ return { kind: "index", index: pairs[0]![0] };
124
+ }
125
+
126
+ // ----------------------------------------------------------------- colour math
127
+
128
+ export function isHex(value: string): boolean { return /^#[0-9a-fA-F]{6}$/.test(value); }
129
+
130
+ function channels(hex: string): [number, number, number] {
131
+ const n = Number.parseInt(hex.slice(1), 16);
132
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
133
+ }
134
+
135
+ function toHex(values: readonly number[]): string {
136
+ return `#${values.map(v => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0")).join("")}`;
137
+ }
138
+
139
+ /** WCAG relative luminance. */
140
+ export function luminance(hex: string): number {
141
+ const linear = (v: number) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; };
142
+ const [r, g, b] = channels(hex);
143
+ return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
144
+ }
145
+
146
+ /** WCAG contrast ratio, 1–21. */
147
+ export function contrast(a: string, b: string): number {
148
+ const x = luminance(a), y = luminance(b);
149
+ return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
150
+ }
151
+
152
+ /** `share` of `from` over `to` (1 = from, 0 = to). */
153
+ export function mix(from: string, to: string, share: number): string {
154
+ const a = channels(from), b = channels(to);
155
+ return toHex(a.map((v, i) => b[i]! + (v - b[i]!) * share));
156
+ }
157
+
158
+ export function saturation(hex: string): number {
159
+ const values = channels(hex).map(v => v / 255);
160
+ const max = Math.max(...values), min = Math.min(...values);
161
+ return max === 0 ? 0 : (max - min) / max;
162
+ }
163
+
164
+ export function hue(hex: string): number {
165
+ const [r, g, b] = channels(hex).map(v => v / 255) as [number, number, number];
166
+ const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
167
+ if (d === 0) return 0;
168
+ const sector = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
169
+ return (sector * 60 + 360) % 360;
170
+ }
171
+
172
+ export function sameHue(a: string, b: string): boolean {
173
+ const d = Math.abs(hue(a) - hue(b));
174
+ return Math.min(d, 360 - d) <= SAME_HUE_DEGREES;
175
+ }
176
+
177
+ /**
178
+ * The blend of fg into bg closest to bg that still reaches `target` against
179
+ * bg, in 1% steps. Never louder than fg itself: a channel-wise blend is not
180
+ * monotone in luminance when fg and bg differ in hue, so an intermediate mix
181
+ * can out-contrast fg, and a grey must not compete with body text.
182
+ */
183
+ export function faintest(fg: string, bg: string, target: number): string {
184
+ const ceiling = contrast(fg, bg);
185
+ if (ceiling <= target) return fg;
186
+ for (let step = 1; step <= 100; step++) {
187
+ const color = mix(fg, bg, step / 100);
188
+ const c = contrast(color, bg);
189
+ if (c >= target) return c <= ceiling ? color : fg;
190
+ }
191
+ return fg;
192
+ }
193
+
194
+ /**
195
+ * Push a colour toward white (dark ground) or black (light ground) until it
196
+ * reaches `target` against bg, in 5% steps. Mixing with white or black
197
+ * preserves hue exactly, and luminance moves monotonically away from bg, so
198
+ * a too-faint yellow becomes a darker yellow rather than some other colour.
199
+ * Either extreme reaches at least 4.58:1 against any ground, so any target up
200
+ * to that is always met.
201
+ */
202
+ export function deepen(color: string, bg: string, target: number): string {
203
+ if (contrast(color, bg) >= target) return color;
204
+ const extreme = contrast("#ffffff", bg) >= contrast("#000000", bg) ? "#ffffff" : "#000000";
205
+ for (let step = 1; step <= 20; step++) {
206
+ const next = mix(extreme, color, step / 20);
207
+ if (contrast(next, bg) >= target) return next;
208
+ }
209
+ return extreme;
210
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * What the message stream shows, as plain data. The app projects its log
3
+ * into these (apps/ui/project.ts); the view never sees log events.
4
+ *
5
+ * `rev` changes whenever an item's content changes; equal id + rev means the
6
+ * renderable can be reused untouched, which keeps long sessions cheap.
7
+ */
8
+ export type ToolStatus = "pending" | "ok" | "error";
9
+
10
+ /** A picture the view may draw: its blob address, name and pixel size (from the log's ImageRef). */
11
+ export interface ImageView { readonly sha256: string; readonly name: string; readonly width: number; readonly height: number }
12
+
13
+ export type ViewItem =
14
+ | { readonly kind: "user"; readonly id: string; readonly rev: string; readonly text: string; readonly images?: readonly ImageView[]; readonly interrupted?: boolean }
15
+ | { readonly kind: "assistant"; readonly id: string; readonly rev: string; readonly text: string; readonly streaming?: boolean }
16
+ | { readonly kind: "thinking"; readonly id: string; readonly rev: string; readonly text: string; readonly streaming?: boolean; readonly label?: string }
17
+ | { readonly kind: "tool"; readonly id: string; readonly rev: string; readonly name: string; readonly summary: string; readonly status: ToolStatus;
18
+ /** Evidence shown right-aligned: "exit 0", "+3 −1", "12 lines". Never the model's own claim (D11). */
19
+ readonly evidence?: string; readonly preview?: readonly string[]; readonly images?: readonly ImageView[]; readonly detail: (width: number) => string[] }
20
+ | { readonly kind: "notice"; readonly id: string; readonly rev: string; readonly text: string; readonly tone?: "info" | "success" | "warning" | "error" }
21
+ | { readonly kind: "segment"; readonly id: string; readonly rev: string; readonly label: string; readonly detail?: string }
22
+ | { readonly kind: "lines"; readonly id: string; readonly rev: string; readonly render: (width: number) => string[] };
23
+
24
+ /**
25
+ * Pure: what the reader missed between two projections. `added` counts
26
+ * items that did not exist before (a streaming reply growing is one
27
+ * message, not one per delta); `changed` says whether anything already
28
+ * shown changed. The session-start rule is chrome and never counts.
29
+ */
30
+ export function unseen(previous: readonly ViewItem[], next: readonly ViewItem[]): { added: number; changed: boolean } {
31
+ const before = new Map(previous.map(item => [item.id, item.rev]));
32
+ let added = 0, changed = false;
33
+ for (const item of next) {
34
+ if (item.id === "session-start") continue;
35
+ const rev = before.get(item.id);
36
+ if (rev === undefined) added++;
37
+ else if (rev !== item.rev) changed = true;
38
+ }
39
+ return { added, changed };
40
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * One key shape for the whole view. OpenTUI's KeyEvent is normalised into a
3
+ * chord string in core/keymap's canonical order (ctrl+alt+shift+super+key)
4
+ * so bindings parsed from settings compare by string equality.
5
+ */
6
+ import type { KeyEvent } from "@opentui/core";
7
+
8
+ export interface KeyPress {
9
+ readonly chord: string;
10
+ readonly name: string;
11
+ readonly ctrl: boolean;
12
+ readonly alt: boolean;
13
+ readonly shift: boolean;
14
+ /** Printable text this key inserts, or "" for a control key. */
15
+ readonly text: string;
16
+ }
17
+
18
+ const RENAME: Readonly<Record<string, string>> = { return: "enter", esc: "escape", " ": "space" };
19
+
20
+ export function keyPress(event: Pick<KeyEvent, "name" | "ctrl" | "meta" | "shift" | "sequence"> & { option?: boolean; super?: boolean }): KeyPress {
21
+ const raw = (event.name || "").toLowerCase();
22
+ const name = RENAME[raw] ?? raw;
23
+ const alt = Boolean(event.meta || event.option);
24
+ const sup = Boolean(event.super);
25
+ const printable = !event.ctrl && !alt && !sup && typeof event.sequence === "string" && event.sequence.length > 0 && !/[\x00-\x1f\x7f]/.test(event.sequence) && !event.sequence.startsWith("\x1b");
26
+ const parts: string[] = [];
27
+ if (event.ctrl) parts.push("ctrl");
28
+ if (alt) parts.push("alt");
29
+ // Shift on a printable character is already in the character itself.
30
+ if (event.shift && !(printable && name.length === 1)) parts.push("shift");
31
+ if (sup) parts.push("super");
32
+ parts.push(name);
33
+ return { chord: parts.join("+"), name, ctrl: Boolean(event.ctrl), alt, shift: Boolean(event.shift), text: printable ? event.sequence : "" };
34
+ }
35
+
36
+ export type Bindings = Readonly<Record<string, readonly string[]>>;
37
+
38
+ export function bound(bindings: Bindings, action: string, key: KeyPress): boolean {
39
+ return (bindings[action] ?? []).includes(key.chord);
40
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Where status lives at a given terminal size (docs/tasks/refactor-tui-dock-layout.md). Pure.
3
+ *
4
+ * sidebar ≥ 110 cols and ≥ 20 rows right column; the top bar is not shown
5
+ * topbar 80–109 cols, or a wide but short terminal (≥ 18 rows) one row on top
6
+ * bare everything else the input box's bottom edge only;
7
+ * session and branch go to the terminal title
8
+ *
9
+ * The input box's bottom edge carries model and thinking in every tier, and
10
+ * the context share wherever there is no sidebar to hold it. Every status
11
+ * field has exactly one place per tier, so nothing is shown twice.
12
+ *
13
+ * The 110-column threshold is the author's choice (2026-10-06), set so the
14
+ * main column keeps the 75 columns OpenCode keeps beside its 42-column sidebar
15
+ * at its 121-column threshold (packages/tui routes/session/index.tsx).
16
+ */
17
+ import { assertNever } from "../exhaustive.ts";
18
+
19
+ export type Tier = "sidebar" | "topbar" | "bare";
20
+
21
+ export interface ScreenLayout {
22
+ readonly tier: Tier;
23
+ /** Columns of the sidebar including its separator; 0 when hidden. */
24
+ readonly sidebarWidth: number;
25
+ readonly mainWidth: number;
26
+ readonly topbar: boolean;
27
+ }
28
+
29
+ export const SIDEBAR_MIN_COLUMNS = 110;
30
+ export const SIDEBAR_MIN_ROWS = 20;
31
+ export const TOPBAR_MIN_COLUMNS = 80;
32
+ export const TOPBAR_MIN_ROWS = 18;
33
+ /** The main column never gets narrower than this beside the sidebar. */
34
+ export const MAIN_MIN_COLUMNS = 75;
35
+
36
+ /** Sidebar content width (without the separator column). Wider terminals give it more room; assumed steps. */
37
+ export function sidebarContentWidth(cols: number): number {
38
+ return cols >= 150 ? 42 : cols >= 130 ? 38 : 34;
39
+ }
40
+
41
+ export function screenLayout(cols: number, rows: number, sidebarWanted = true): ScreenLayout {
42
+ const c = Math.max(0, Math.floor(cols)), r = Math.max(0, Math.floor(rows));
43
+ if (sidebarWanted && c >= SIDEBAR_MIN_COLUMNS && r >= SIDEBAR_MIN_ROWS) {
44
+ const sidebarWidth = sidebarContentWidth(c) + 1;
45
+ return { tier: "sidebar", sidebarWidth, mainWidth: c - sidebarWidth, topbar: false };
46
+ }
47
+ if (c >= TOPBAR_MIN_COLUMNS && r >= TOPBAR_MIN_ROWS) return { tier: "topbar", sidebarWidth: 0, mainWidth: c, topbar: true };
48
+ return { tier: "bare", sidebarWidth: 0, mainWidth: c, topbar: false };
49
+ }
50
+
51
+ export const STATUS_FIELDS = ["model", "thinking", "context", "title", "workspace", "branch", "usage", "cost", "handoff", "tasks", "files", "background", "resources", "extension"] as const;
52
+ export type StatusField = (typeof STATUS_FIELDS)[number];
53
+ /** Screen surfaces only; the terminal title is outside the frame and listed separately. */
54
+ export type Place = "sidebar" | "topbar" | "bottomEdge" | "hidden";
55
+
56
+ /**
57
+ * The one place each field takes in a tier. Hidden fields stay one command
58
+ * away (/usage, /state, /tasks, /session); the terminal title also carries
59
+ * session and branch in every tier, outside the frame.
60
+ */
61
+ export function placement(tier: Tier): Readonly<Record<StatusField, Place>> {
62
+ switch (tier) {
63
+ case "sidebar":
64
+ return { model: "bottomEdge", thinking: "bottomEdge", context: "sidebar", title: "sidebar", workspace: "sidebar", branch: "sidebar",
65
+ usage: "sidebar", cost: "sidebar", handoff: "sidebar", tasks: "sidebar", files: "sidebar", background: "sidebar", resources: "sidebar", extension: "sidebar" };
66
+ case "topbar":
67
+ return { model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", title: "topbar", workspace: "topbar", branch: "topbar",
68
+ usage: "hidden", cost: "topbar", handoff: "topbar", tasks: "hidden", files: "hidden", background: "topbar", resources: "hidden", extension: "topbar" };
69
+ case "bare":
70
+ return { model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", title: "hidden", workspace: "hidden", branch: "hidden",
71
+ usage: "hidden", cost: "hidden", handoff: "hidden", tasks: "hidden", files: "hidden", background: "hidden", resources: "hidden", extension: "hidden" };
72
+ default: return assertNever(tier, "placement");
73
+ }
74
+ }