@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,200 @@
1
+ /**
2
+ * The one editor (D20). It lives inside the input box at the bottom of the
3
+ * screen and is never recreated: pickers and forms open in the same box
4
+ * above it and give focus back, so the draft, undo history and cursor stay
5
+ * (docs/tasks/refactor-tui-dock-layout.md DoD 1). Typing, undo and paste are
6
+ * OpenTUI's; submission and history are the app's.
7
+ *
8
+ * Completion is state here and drawn by the screen above the box, where it
9
+ * takes layout space instead of covering the transcript.
10
+ */
11
+ import { BoxRenderable, type CliRenderer } from "@opentui/core";
12
+ import { AtomicDraftArea, type DraftAtom } from "./atomic-draft.ts";
13
+ import { Canvas } from "./canvas.ts";
14
+ import type { UiColors } from "./colors.ts";
15
+ import { draftRows, type DraftRows } from "./draft.ts";
16
+ import type { ListItem } from "./list-model.ts";
17
+
18
+ export interface Completion {
19
+ readonly kind: "command" | "argument" | "path";
20
+ /** Replace text[start, end) with an item's value. */
21
+ readonly start: number;
22
+ readonly end: number;
23
+ readonly items: readonly ListItem[];
24
+ }
25
+
26
+ /**
27
+ * The tallest the draft grows before it scrolls inside the box: a third of
28
+ * the terminal, at least 5 rows. Peers cap by screen fraction rather than a
29
+ * constant (pi 30% min 5, OpenCode ⅓ min 6, Grok ½, Codex ⅔); a third keeps
30
+ * most of the transcript in view while a paragraph still fits.
31
+ */
32
+ export function draftMaxLines(terminalRows: number): number {
33
+ return Math.max(5, Math.floor(terminalRows / 3));
34
+ }
35
+ const MAX_LINES = 5;
36
+ export const PLACEHOLDER = "What would you like to work on?";
37
+
38
+ /**
39
+ * The textarea, plus a hook after each of its frames. The editor scrolls to
40
+ * its cursor while it renders, and its own render() skips renderAfter, so
41
+ * this is the one place the visible part of a tall draft is known.
42
+ */
43
+ export class Composer {
44
+ /** `›` and the editor. Its height is the editor's own measurement of the wrapped draft. */
45
+ readonly row: BoxRenderable;
46
+ readonly area: AtomicDraftArea;
47
+ private draftRevision = 0;
48
+ private readonly prompt: Canvas;
49
+ private completion: Completion | undefined;
50
+ private completionIndex = 0;
51
+ private history: string[] = [];
52
+ private historyAt = -1;
53
+ private historyDraft = "";
54
+ private maxLines = MAX_LINES;
55
+ private shape: DraftRows = { rows: 1, above: 0, below: 0 };
56
+ onChange: ((text: string) => void) | undefined;
57
+ onCursor: (() => void) | undefined;
58
+ /** The row count changed (the box above must re-layout). */
59
+ onResize: (() => void) | undefined;
60
+ /** Rows hidden above or below the visible draft changed (the box edge shows them). */
61
+ onOverflow: (() => void) | undefined;
62
+
63
+ constructor(renderer: CliRenderer, colors: UiColors) {
64
+ this.row = new BoxRenderable(renderer, { id: "composer", flexDirection: "row", flexShrink: 0 });
65
+ this.prompt = new Canvas(renderer, { id: "composer-prompt", width: 2, height: 1, flexShrink: 0, colors });
66
+ this.prompt.setRows([[{ text: "›", role: "accent", bold: true }]]);
67
+ const area = new AtomicDraftArea(renderer, {
68
+ id: "composer-area", flexGrow: 1, minHeight: 1, maxHeight: MAX_LINES, wrapMode: "word",
69
+ textColor: colors.text, focusedTextColor: colors.text, backgroundColor: "transparent", focusedBackgroundColor: "transparent",
70
+ selectionBg: colors.selectionBg, selectionFg: colors.selectionFg,
71
+ placeholder: PLACEHOLDER, placeholderColor: colors.muted,
72
+ // Enter, Shift+Enter, Up/Down are the app's (submit, history); remove them from the textarea.
73
+ keyBindings: [{ name: "return", action: "move-left" }, { name: "_", ctrl: true, action: "undo" }, { name: "/", ctrl: true, action: "undo" }],
74
+ });
75
+ this.area = area;
76
+ // Height comes from the editor's native measurement of its wrapped text, capped by
77
+ // maxHeight (the way OpenCode's prompt grows). Nothing here sets a height, so width
78
+ // changes re-wrap and re-measure on their own (fix-tui-composer-growth).
79
+ this.area.onContentChange = () => { this.draftRevision++; this.onChange?.(this.text); };
80
+ this.area.onCursorChange = () => { this.draftRevision++; this.onCursor?.(); };
81
+ this.area.onSizeChange = () => this.measured();
82
+ // Which rows scrolled out of a tall draft is known only after the editor drew itself.
83
+ area.onRendered = () => this.measured();
84
+ this.row.add(this.prompt); this.row.add(this.area);
85
+ }
86
+
87
+ get text(): string { return this.area.plainText; }
88
+ get cursor(): number { return this.area.textCursor; }
89
+ get revision(): number { return this.draftRevision; }
90
+ setAtoms(spans: (text: string) => readonly DraftAtom[]): void { this.area.atoms = spans; }
91
+ get completing(): boolean { return this.completion !== undefined && this.completion.items.length > 0; }
92
+ /** What the completion list shows (screen draws it). */
93
+ get completionView(): { readonly kind: Completion["kind"]; readonly items: readonly ListItem[]; readonly selected: number } | undefined {
94
+ return this.completion ? { kind: this.completion.kind, items: this.completion.items, selected: this.completionIndex } : undefined;
95
+ }
96
+ get historyEntries(): readonly string[] { return this.history; }
97
+ get rows(): number { return Math.max(1, this.area.height); }
98
+ /** Wrapped rows of the draft hidden above / below the box (0 when it all fits). */
99
+ get overflow(): { readonly above: number; readonly below: number } { return { above: this.shape.above, below: this.shape.below }; }
100
+
101
+ setColors(colors: UiColors): void {
102
+ this.area.textColor = colors.text;
103
+ this.area.focusedTextColor = colors.text;
104
+ this.area.placeholderColor = colors.muted;
105
+ this.area.selectionBg = colors.selectionBg;
106
+ this.area.selectionFg = colors.selectionFg;
107
+ this.prompt.setColors(colors);
108
+ }
109
+
110
+ setText(text: string): void {
111
+ this.draftRevision++;
112
+ this.area.setText(text);
113
+ this.area.gotoBufferEnd();
114
+ }
115
+
116
+ insert(text: string): void { this.draftRevision++; this.area.insertText(text); }
117
+ removeAtom(label: string): void { this.draftRevision++; this.area.replaceText(this.text.split(label).join("")); }
118
+ resetDraft(project: (text: string) => string): void {
119
+ this.history = this.history.map(project).filter(Boolean);
120
+ this.historyAt = -1; this.historyDraft = ""; this.area.clearKilledText(); this.setText("");
121
+ }
122
+ focus(): void { this.area.focus(); }
123
+
124
+ remember(entry: string): void {
125
+ if (!entry.trim()) return;
126
+ if (this.history[0] !== entry) this.history.unshift(entry);
127
+ this.history = this.history.slice(0, 200);
128
+ this.historyAt = -1;
129
+ }
130
+
131
+ /** Up/Down walk history only when the cursor is on the first/last line, so multi-line drafts stay editable. */
132
+ historyStep(direction: -1 | 1): boolean {
133
+ const text = this.area.plainText;
134
+ const offset = this.area.cursorOffset;
135
+ const firstLine = !text.slice(0, offset).includes("\n");
136
+ const lastLine = !text.slice(offset).includes("\n");
137
+ if (direction < 0 && !firstLine) return false;
138
+ if (direction > 0 && !lastLine) return false;
139
+ if (direction < 0) {
140
+ if (this.historyAt + 1 >= this.history.length) return true;
141
+ if (this.historyAt === -1) this.historyDraft = text;
142
+ this.historyAt++;
143
+ this.setText(this.history[this.historyAt]!);
144
+ return true;
145
+ }
146
+ if (this.historyAt < 0) return false;
147
+ this.historyAt--;
148
+ this.setText(this.historyAt < 0 ? this.historyDraft : this.history[this.historyAt]!);
149
+ return true;
150
+ }
151
+
152
+ setCompletion(completion: Completion | undefined): void {
153
+ const next = completion && completion.items.length ? completion : undefined;
154
+ if (!next && !this.completion) return;
155
+ this.completion = next;
156
+ this.completionIndex = 0;
157
+ this.onResize?.();
158
+ }
159
+ moveCompletion(delta: number): void {
160
+ if (!this.completion) return;
161
+ const n = this.completion.items.length;
162
+ this.completionIndex = (this.completionIndex + delta + n) % n;
163
+ this.onResize?.();
164
+ }
165
+ /** Apply the selected completion; returns the applied item. */
166
+ acceptCompletion(): ListItem | undefined {
167
+ const completion = this.completion;
168
+ const item = completion?.items[this.completionIndex];
169
+ if (!completion || !item) return undefined;
170
+ const text = this.area.plainText;
171
+ this.area.setText(text.slice(0, completion.start) + item.value + text.slice(completion.end));
172
+ this.area.cursorOffset = completion.start + item.value.length;
173
+ this.setCompletion(undefined);
174
+ return item;
175
+ }
176
+
177
+ /** The tallest the draft may grow, so the box never pushes the transcript off a short screen. */
178
+ /** The screen's limit for this terminal size (draftMaxLines, then whatever space is left). */
179
+ setMaxLines(lines: number): void {
180
+ const next = Math.max(1, Math.floor(lines));
181
+ if (next === this.maxLines) return;
182
+ this.maxLines = next;
183
+ this.area.maxHeight = next;
184
+ }
185
+
186
+ /**
187
+ * After layout or a frame: report a new height and what scrolled out of
188
+ * view. `virtualLineCount` is only the viewport's visible rows (always 1 in
189
+ * a one-row box, which is how the box used to stay one row); the editor
190
+ * view's total is the whole wrapped draft. Writes only on change, so a
191
+ * quiet frame costs two native reads.
192
+ */
193
+ private measured(): void {
194
+ const next = draftRows(this.area.editorView.getTotalVirtualLineCount(), this.area.scrollY, Math.max(1, Math.min(this.maxLines, this.area.height || 1)));
195
+ const previous = this.shape;
196
+ this.shape = next;
197
+ if (next.rows !== previous.rows) this.onResize?.();
198
+ if (next.above !== previous.above || next.below !== previous.below) this.onOverflow?.();
199
+ }
200
+ }
@@ -0,0 +1,23 @@
1
+ /** View-only data: apps owns the projection and actions, as required by D20. */
2
+ import { clipText } from "./edge.ts";
3
+
4
+ export type ControlPanelModel = {
5
+ thread: string; turn: string | null; goal: string; status: string; execution: string; gaps: readonly string[];
6
+ progress: readonly { label: string; count: number; sources: readonly string[] }[]; checks: readonly string[];
7
+ context: readonly string[]; omitted: readonly string[]; history: readonly string[];
8
+ };
9
+ export type ControlTab = "goal" | "progress" | "context";
10
+ export type ControlFocus = "messages" | "omitted" | "history";
11
+
12
+ export function controlPanelLines(model: ControlPanelModel, tab: ControlTab, width: number, focus: ControlFocus = "messages"): string[] {
13
+ const lines = [`${tab === "goal" ? "●" : "○"} Goal ${tab === "progress" ? "●" : "○"} Progress ${tab === "context" ? "●" : "○"} Context Lens`, "",
14
+ ...(tab === "goal" ? [model.goal, `Goal: ${model.status}`, `Execution: ${model.execution}`, "", ...model.gaps,
15
+ ...model.checks, "", `Thread ${model.thread}`, `Turn ${model.turn ?? "none"}`]
16
+ : tab === "progress" ? ["Evidence for this request", ...model.progress.map(item => `${item.label}: ${item.count}`), "",
17
+ "Counts describe observations, not a completion percentage.", ...model.checks,
18
+ ...model.progress.flatMap(item => item.sources.map(source => `${item.label}: ${source}`))]
19
+ : focus === "messages" ? ["Actual model context · ←/→ change lens", ...model.context]
20
+ : focus === "omitted" ? ["Not replayed as messages · ←/→ change lens", ...(model.omitted.length ? model.omitted : ["No omitted events in this segment."])]
21
+ : ["Handoff retention and recovery · ←/→ change lens", ...(model.history.length ? model.history : ["No carried history in this segment."])])];
22
+ return lines.map(line => clipText(line.replace(/[\x00-\x1f\x7f-\x9f]/g, " "), Math.max(1, width)));
23
+ }
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Dialogs open inside the input box, stacked (docs/tasks/refactor-tui-dock-layout.md).
3
+ * Escape pops one level (back); popping the last returns focus to the editor.
4
+ *
5
+ * The box keeps its place at the bottom and grows upward; the dialog's
6
+ * title and any non-obvious keys go into the box's top edge (`title`,
7
+ * `edgeHint`), so there is no separate frame or hint row. Every picker and
8
+ * form in the app is one of these kinds:
9
+ * list grouped rows (+ checkboxes), search on the input row, action keys
10
+ * form one text field; `secret` keeps the value out of every renderable
11
+ * confirm yes / no
12
+ * message read-only lines (ANSI allowed)
13
+ * busy the activity glyph while the app awaits IO
14
+ * lines an app- or extension-rendered block with its own key handler
15
+ */
16
+ import { BoxRenderable, InputRenderable, TextRenderable, type CliRenderer, type Renderable } from "@opentui/core";
17
+ import { Canvas } from "./canvas.ts";
18
+ import type { UiColors } from "./colors.ts";
19
+ import { bound, type Bindings, type KeyPress } from "./keys.ts";
20
+ import type { ListItem } from "./list-model.ts";
21
+ import { ListView } from "./list-view.ts";
22
+ import { ansiText, parts } from "./styled.ts";
23
+
24
+ export interface ListAction { readonly key: string; readonly label: string; run(item: ListItem | undefined, dialog: ListHandle): void | Promise<void> }
25
+ export interface ListHandle { setItems(items: readonly ListItem[], selected?: string): void; readonly current: ListItem | undefined; readonly checked: ReadonlySet<string>; readonly query: string }
26
+
27
+ export type DialogSpec =
28
+ | { kind: "list"; title: string; items: readonly ListItem[]; selected?: string; multi?: boolean; checked?: readonly string[]; search?: boolean; placeholder?: string; empty?: string; rows?: number;
29
+ /** Lines above the list (approval command, context). ANSI allowed. */
30
+ intro?: readonly string[];
31
+ /** Stay on the stack after a pick, so a drill-down's Esc returns here. */
32
+ keep?: boolean;
33
+ /** The box border colour: "warning" for decisions that run something (approval). */
34
+ tone?: "warning";
35
+ hint?: string; actions?: readonly ListAction[]; onPick?(item: ListItem, dialog: ListHandle): void | Promise<void>;
36
+ onSubmitChecked?(values: string[]): void | Promise<void>; onMove?(item: ListItem | undefined): void; onCancel?(): void }
37
+ | { kind: "form"; title: string; label: string; value?: string; secret?: boolean; hint?: string; intro?: readonly string[]; keep?: boolean; validate?(value: string): string | undefined; onSubmit(value: string): void | Promise<void>; onCancel?(): void }
38
+ | { kind: "confirm"; title: string; message: string; yes?: string; no?: string; onAnswer(yes: boolean): void | Promise<void> }
39
+ | { kind: "message"; title: string; lines: readonly string[]; onDone?(): void | Promise<void> }
40
+ | { kind: "busy"; title: string; message: string; onCancel?(): void }
41
+ | { kind: "lines"; title: string; render(width: number): string[]; handleKey?(key: KeyPress): boolean | Promise<boolean>; hint?: string; onCancel?(): void };
42
+
43
+ interface Open {
44
+ spec: DialogSpec;
45
+ list?: ListView;
46
+ input?: InputRenderable;
47
+ secret?: string;
48
+ error?: string | undefined;
49
+ yes?: boolean;
50
+ }
51
+
52
+ /**
53
+ * Hint clauses everyone already knows (Enter confirms, Esc goes back, arrows
54
+ * move). Only the rest earns a place on the edge (refactor-tui-chrome-cleanup).
55
+ */
56
+ const OBVIOUS = /^(enter|esc|↑↓|←→|↑|↓)\b/;
57
+ export function edgeHint(hint: string | undefined): string {
58
+ return (hint ?? "").split(" · ").map(clause => clause.trim()).filter(clause => clause && !OBVIOUS.test(clause)).join(" · ");
59
+ }
60
+
61
+ let serial = 0;
62
+
63
+ export class DialogHost {
64
+ private readonly renderer: CliRenderer;
65
+ private colors: UiColors;
66
+ private readonly bindings: () => Bindings;
67
+ private readonly slot: BoxRenderable;
68
+ private readonly availableRows: () => number;
69
+ private readonly glyph: () => string;
70
+ private stack: Open[] = [];
71
+ private readonly work = new Set<Promise<unknown>>();
72
+ /** Nodes built for the current view; persistent ones (lists, inputs) live on their Open. */
73
+ private ephemeral: Renderable[] = [];
74
+ private shownTitle = "";
75
+ private shownHint = "";
76
+ onOpened: (() => void) | undefined;
77
+ /** Called when the stack becomes empty (focus returns to the editor). */
78
+ onClosed: (() => void) | undefined;
79
+ /** Title, hint or tone changed: the box edge repaints. */
80
+ onChrome: (() => void) | undefined;
81
+ /** Errors thrown by dialog callbacks, surfaced by the app as notices. */
82
+ onError: ((error: unknown) => void) | undefined;
83
+
84
+ constructor(renderer: CliRenderer, colors: UiColors, bindings: () => Bindings, slot: BoxRenderable, availableRows: () => number, glyph: () => string = () => "●") {
85
+ this.renderer = renderer;
86
+ this.colors = colors;
87
+ this.bindings = bindings;
88
+ this.slot = slot;
89
+ this.availableRows = availableRows;
90
+ this.glyph = glyph;
91
+ }
92
+
93
+ get open(): boolean { return this.stack.length > 0; }
94
+ get busy(): boolean { return this.work.size > 0; }
95
+ /** Resolves when every callback started by a key has finished (tests, shutdown). */
96
+ async idle(): Promise<void> { while (this.work.size) await Promise.all([...this.work]); }
97
+ get depth(): number { return this.stack.length; }
98
+ get top(): DialogSpec | undefined { return this.stack.at(-1)?.spec; }
99
+ /** The list of the top dialog, for callers that update it after async loads. */
100
+ get list(): ListHandle | undefined { return this.stack.at(-1)?.list; }
101
+ /** The top list's view (tests read its drawn rows). */
102
+ get listView(): ListView | undefined { return this.stack.at(-1)?.list; }
103
+ /** What the box's top edge shows while a dialog is open. */
104
+ get title(): string { return this.shownTitle; }
105
+ get edgeHint(): string { return this.shownHint; }
106
+ get tone(): "warning" | undefined { const spec = this.top; return spec?.kind === "list" ? spec.tone : undefined; }
107
+ /** A busy dialog shows the activity glyph, which needs the clock running. */
108
+ get animated(): boolean { return this.top?.kind === "busy"; }
109
+
110
+ setColors(colors: UiColors): void {
111
+ this.colors = colors;
112
+ for (const open of this.stack) {
113
+ open.list?.setColors(colors);
114
+ if (open.input) { open.input.textColor = colors.text; open.input.focusedTextColor = colors.text; open.input.placeholderColor = colors.muted; }
115
+ }
116
+ if (this.open) this.show();
117
+ }
118
+
119
+ dispose(): void { this.clearView(); }
120
+
121
+ push(spec: DialogSpec, replace = false): void {
122
+ if (replace) this.stack.pop();
123
+ this.stack.push(this.build(spec));
124
+ this.onOpened?.();
125
+ this.show();
126
+ }
127
+
128
+ /** Replace the whole stack (e.g. after a save there is nothing to go back to). */
129
+ reset(spec: DialogSpec): void { this.stack = []; this.push(spec); }
130
+
131
+ pop(): void {
132
+ const top = this.stack.pop();
133
+ if (top) {
134
+ const spec = top.spec;
135
+ if ((spec.kind === "list" || spec.kind === "form" || spec.kind === "busy" || spec.kind === "lines") && spec.onCancel) this.guard(() => spec.onCancel!());
136
+ if (spec.kind === "confirm") this.guard(() => spec.onAnswer(false));
137
+ }
138
+ this.show();
139
+ }
140
+
141
+ /** Remove one dialog by its spec without running its callbacks (an extension request that timed out). */
142
+ remove(spec: DialogSpec): void {
143
+ const at = this.stack.findIndex(open => open.spec === spec);
144
+ if (at < 0) return;
145
+ this.stack.splice(at, 1);
146
+ this.show();
147
+ }
148
+
149
+ /**
150
+ * Close everything (session switch, shutdown). Cancel callbacks run, top
151
+ * first, so anything awaiting an answer resolves as "cancelled" instead of
152
+ * hanging forever.
153
+ */
154
+ clear(): void {
155
+ const open = this.stack.reverse();
156
+ this.stack = [];
157
+ for (const entry of open) {
158
+ const spec = entry.spec;
159
+ if ((spec.kind === "list" || spec.kind === "form" || spec.kind === "busy" || spec.kind === "lines") && spec.onCancel) void this.guard(() => spec.onCancel!());
160
+ if (spec.kind === "confirm") void this.guard(() => spec.onAnswer(false));
161
+ }
162
+ this.show();
163
+ }
164
+
165
+ /** Rebuild the top dialog's view (after its spec's data changed). */
166
+ refresh(): void { if (this.open) this.show(); }
167
+
168
+ private build(spec: DialogSpec): Open {
169
+ const open: Open = { spec };
170
+ if (spec.kind === "list") {
171
+ const list = new ListView(this.renderer, { colors: this.colors, ...(spec.rows ? { rows: spec.rows } : {}), search: spec.search !== false, multi: spec.multi ?? false,
172
+ ...(spec.placeholder ? { placeholder: spec.placeholder } : {}), ...(spec.empty ? { empty: spec.empty } : {}) });
173
+ for (const value of spec.checked ?? []) list.checked.add(value);
174
+ list.setItems(spec.items, spec.selected);
175
+ list.onChange = () => { spec.onMove?.(list.current); this.paintChrome(); };
176
+ open.list = list;
177
+ }
178
+ if (spec.kind === "form" && !spec.secret) {
179
+ open.input = new InputRenderable(this.renderer, { id: `dialog-input-${serial++}`, value: spec.value ?? "", textColor: this.colors.text,
180
+ focusedTextColor: this.colors.text, backgroundColor: "transparent", focusedBackgroundColor: "transparent", flexGrow: 1 });
181
+ // Prefilled values are edited from their end (a URL gets a path appended).
182
+ open.input.gotoBufferEnd();
183
+ }
184
+ if (spec.kind === "form" && spec.secret) open.secret = "";
185
+ if (spec.kind === "confirm") open.yes = true;
186
+ return open;
187
+ }
188
+
189
+ /** Re-fit the top list to the rows the screen can spare. */
190
+ layout(): void {
191
+ const top = this.stack.at(-1);
192
+ if (top?.spec.kind !== "list") return;
193
+ const overhead = (top.spec.search === false ? 0 : 2) + (top.spec.intro?.length ? introRows(top.spec.intro) + 1 : 0);
194
+ top.list?.setRowLimit(Math.min(top.spec.rows ?? 10, Math.max(1, this.availableRows() - overhead)));
195
+ }
196
+
197
+ private clearView(): void {
198
+ for (const child of this.slot.getChildren()) this.slot.remove(child);
199
+ for (const node of this.ephemeral) node.destroyRecursively();
200
+ this.ephemeral = [];
201
+ }
202
+
203
+ private add(node: Renderable): void { this.ephemeral.push(node); this.slot.add(node); }
204
+
205
+ private show(): void {
206
+ this.clearView();
207
+ const top = this.stack.at(-1);
208
+ if (!top) {
209
+ this.slot.visible = false;
210
+ this.shownTitle = ""; this.shownHint = "";
211
+ this.onClosed?.();
212
+ this.onChrome?.();
213
+ this.renderer.requestRender();
214
+ return;
215
+ }
216
+ this.slot.visible = true;
217
+ const c = this.colors, spec = top.spec;
218
+ const text = (content: ReturnType<typeof parts>, extra: Record<string, unknown> = {}) => new TextRenderable(this.renderer, { id: `dialog-text-${serial++}`, content, wrapMode: "word", ...extra });
219
+ const canvas = (rows: Parameters<Canvas["setRows"]>[0]) => { const node = new Canvas(this.renderer, { id: `dialog-canvas-${serial++}`, height: rows.length, colors: c, glyph: this.glyph }); node.setRows(rows); return node; };
220
+ switch (spec.kind) {
221
+ case "list": {
222
+ if (spec.intro?.length) this.add(text(ansiText(spec.intro), { marginBottom: 1 }));
223
+ this.slot.add(top.list!);
224
+ this.layout();
225
+ top.list!.searchInput?.focus();
226
+ if (!top.list!.searchInput) this.renderer.currentFocusedRenderable?.blur();
227
+ break;
228
+ }
229
+ case "form": {
230
+ if (spec.intro?.length) this.add(text(ansiText(spec.intro), { marginBottom: 1 }));
231
+ const row = new BoxRenderable(this.renderer, { id: `form-row-${serial++}`, flexDirection: "row", height: 1 });
232
+ row.add(new TextRenderable(this.renderer, { id: `form-label-${serial++}`, content: parts({ text: `${spec.label} › `, fg: c.muted }), flexShrink: 0 }));
233
+ if (top.input) { row.add(top.input); top.input.focus(); }
234
+ else {
235
+ // The secret never reaches a renderable: only its length does.
236
+ const mask = canvas([[{ text: "•".repeat(Math.min(top.secret!.length, 48)), role: "text" }, { text: "▏", role: "accent" }]]);
237
+ mask.flexGrow = 1;
238
+ row.add(mask);
239
+ this.renderer.currentFocusedRenderable?.blur();
240
+ }
241
+ this.add(row);
242
+ if (top.error) this.add(text(parts({ text: top.error, fg: c.error })));
243
+ break;
244
+ }
245
+ case "confirm":
246
+ this.add(text(parts({ text: spec.message, fg: c.text })));
247
+ // The choice is marked by shape and weight; nothing is painted behind it.
248
+ this.add(canvas([[
249
+ { text: top.yes ? "▸ " : " ", role: "accent", bold: true }, { text: spec.yes ?? "Yes", role: top.yes ? "accent" : "text", bold: top.yes === true },
250
+ { text: " ", role: "text" },
251
+ { text: !top.yes ? "▸ " : " ", role: "accent", bold: true }, { text: spec.no ?? "No", role: !top.yes ? "accent" : "text", bold: top.yes !== true },
252
+ ]]));
253
+ this.renderer.currentFocusedRenderable?.blur();
254
+ break;
255
+ case "message":
256
+ this.add(text(ansiText(spec.lines)));
257
+ this.renderer.currentFocusedRenderable?.blur();
258
+ break;
259
+ case "busy":
260
+ this.add(canvas([[{ text: "●", role: "accent", spin: true }, { text: ` ${spec.message.split("\n")[0] ?? ""}`, role: "text" }]]));
261
+ if (spec.message.includes("\n")) this.add(text(parts({ text: spec.message.split("\n").slice(1).join("\n"), fg: c.muted })));
262
+ this.renderer.currentFocusedRenderable?.blur();
263
+ break;
264
+ case "lines": {
265
+ const width = Math.max(10, this.renderer.width - 8);
266
+ this.add(text(ansiText(this.guardRender(() => spec.render(width))), { wrapMode: "none" }));
267
+ this.renderer.currentFocusedRenderable?.blur();
268
+ break;
269
+ }
270
+ }
271
+ this.paintChrome();
272
+ this.renderer.requestRender();
273
+ }
274
+
275
+ private paintChrome(): void {
276
+ const top = this.stack.at(-1);
277
+ const spec = top?.spec;
278
+ let hint = "";
279
+ if (spec?.kind === "list") {
280
+ const keys = (spec.actions ?? []).map(action => `${action.key} ${action.label}`);
281
+ hint = [edgeHint(spec.hint), ...(spec.hint ? [] : [...(spec.multi ? ["space toggle"] : []), ...keys]), top!.list!.scrollPosition].filter(Boolean).join(" · ");
282
+ } else if (spec?.kind === "form" || spec?.kind === "lines") hint = edgeHint(spec.hint);
283
+ const title = spec?.title ?? "";
284
+ if (title === this.shownTitle && hint === this.shownHint) return;
285
+ this.shownTitle = title; this.shownHint = hint;
286
+ this.onChrome?.();
287
+ }
288
+
289
+ /** Paste goes to the focused Input natively; a secret form takes it here. */
290
+ paste(text: string): boolean {
291
+ const top = this.stack.at(-1);
292
+ if (!top) return false;
293
+ if (top.secret !== undefined) { top.secret += text.replace(/[\r\n]+/g, ""); top.error = undefined; this.show(); return true; }
294
+ return top.input !== undefined || top.list?.searchInput !== undefined;
295
+ }
296
+
297
+ /** Returns true when the key was consumed (the caller then stops propagation). Synchronous so
298
+ * the caller can preventDefault inside the key event; callbacks run as tracked work. */
299
+ key(key: KeyPress): boolean {
300
+ const top = this.stack.at(-1);
301
+ if (!top) return false;
302
+ const b = this.bindings();
303
+ const spec = top.spec;
304
+ const run = (action: () => unknown) => { const job = this.guard(action).finally(() => { this.work.delete(job); this.refreshIfTop(top); }); this.work.add(job); };
305
+ const leave = () => { const at = this.stack.indexOf(top); if (at >= 0) this.stack.splice(at, 1); this.show(); };
306
+ if (bound(b, "list.cancel", key) || key.chord === "escape") { this.pop(); return true; }
307
+ switch (spec.kind) {
308
+ case "list": {
309
+ const list = top.list!;
310
+ if (bound(b, "list.up", key)) { list.move(-1); return true; }
311
+ if (bound(b, "list.down", key)) { list.move(1); return true; }
312
+ if (bound(b, "list.pageUp", key)) { list.page(-1); return true; }
313
+ if (bound(b, "list.pageDown", key)) { list.page(1); return true; }
314
+ if (spec.multi && bound(b, "list.toggle", key)) { list.toggle(); return true; }
315
+ if (spec.multi && key.chord === "ctrl+a") { list.toggleAllVisible(); return true; }
316
+ const action = spec.actions?.find(entry => entry.key === key.chord);
317
+ if (action) { run(() => action.run(list.current, list)); return true; }
318
+ if (bound(b, "list.confirm", key)) {
319
+ if (spec.multi) { const values = [...list.checked]; if (!spec.keep) leave(); run(() => spec.onSubmitChecked?.(values)); return true; }
320
+ const item = list.current;
321
+ // The dialog leaves the stack before its callback runs, so a callback
322
+ // that opens another dialog (or closes everything) wins.
323
+ if (item && !item.disabled) { if (!spec.keep) leave(); run(() => spec.onPick?.(item, list)); }
324
+ return true;
325
+ }
326
+ return false; // printable keys reach the focused search Input
327
+ }
328
+ case "form": {
329
+ if (key.chord === "enter") {
330
+ const value = (top.input ? top.input.value : top.secret!).trim();
331
+ const error = spec.validate?.(value);
332
+ if (error) { top.error = error; this.show(); return true; }
333
+ if (!spec.keep) leave();
334
+ run(() => spec.onSubmit(value));
335
+ return true;
336
+ }
337
+ if (top.secret !== undefined) {
338
+ if (key.chord === "backspace") top.secret = top.secret.slice(0, -1);
339
+ else if (key.chord === "ctrl+u") top.secret = "";
340
+ else if (key.text) top.secret += key.text;
341
+ else return true;
342
+ top.error = undefined;
343
+ this.show();
344
+ return true;
345
+ }
346
+ if (top.error) { top.error = undefined; this.show(); }
347
+ return false;
348
+ }
349
+ case "confirm":
350
+ if (["left", "right", "tab", "h", "l"].includes(key.chord)) { top.yes = !top.yes; this.show(); return true; }
351
+ if (key.chord === "y" || key.chord === "n" || key.chord === "enter") {
352
+ const yes = key.chord === "enter" ? top.yes! : key.chord === "y";
353
+ leave();
354
+ run(() => spec.onAnswer(yes));
355
+ }
356
+ return true;
357
+ case "message":
358
+ if (key.chord === "enter") { leave(); run(() => spec.onDone?.()); }
359
+ return true;
360
+ case "busy":
361
+ return true;
362
+ case "lines":
363
+ if (spec.handleKey) run(() => spec.handleKey!(key));
364
+ return true;
365
+ }
366
+ }
367
+
368
+ private refreshIfTop(open: Open): void { if (this.stack.at(-1) === open) this.show(); }
369
+
370
+ private async guard<T>(run: () => T | Promise<T>): Promise<T | undefined> {
371
+ try { return await run(); } catch (error) { this.onError?.(error); return undefined; }
372
+ }
373
+ private guardRender(run: () => string[]): string[] {
374
+ try { return run(); } catch (error) { this.onError?.(error); return ["(this block failed to render)"]; }
375
+ }
376
+ }
377
+
378
+ /** Rows an intro block takes before wrapping (wrapping only adds rows; the list shrinks to fit). */
379
+ function introRows(intro: readonly string[]): number { return intro.length; }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * How tall the draft is and what scrolled out of it (docs/tasks/fix-tui-composer-growth.md). Pure.
3
+ *
4
+ * `total` is the draft's wrapped row count, `offset` the editor's first
5
+ * visible row, `max` the tallest the box may grow. The box shows
6
+ * min(total, max) rows; whatever does not fit is reported on the box's top
7
+ * edge as "↑ N more" / "↓ N more", the way pi marks a scrolled editor.
8
+ */
9
+ export interface DraftRows {
10
+ /** Rows the editor occupies. */
11
+ readonly rows: number;
12
+ /** Wrapped rows hidden above the visible part. */
13
+ readonly above: number;
14
+ /** Wrapped rows hidden below it. */
15
+ readonly below: number;
16
+ }
17
+
18
+ export function draftRows(total: number, offset: number, max: number): DraftRows {
19
+ const t = Math.max(1, Math.floor(total) || 1), m = Math.max(1, Math.floor(max) || 1);
20
+ const rows = Math.min(t, m);
21
+ const above = Math.max(0, Math.min(Math.floor(offset) || 0, t - rows));
22
+ return { rows, above, below: t - rows - above };
23
+ }