@visual-z/xeno 0.2.0 → 0.3.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 (259) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +106 -26
  3. package/docs/install.md +52 -9
  4. package/docs/providers.md +11 -0
  5. package/package.json +5 -4
  6. package/src/apps/approve.ts +3 -3
  7. package/src/apps/cli.ts +10 -11
  8. package/src/apps/commands.ts +38 -5
  9. package/src/apps/completions.ts +73 -19
  10. package/src/apps/image-draft.ts +46 -0
  11. package/src/apps/project-trust.ts +2 -42
  12. package/src/apps/provider-connect.ts +346 -0
  13. package/src/apps/render.ts +22 -47
  14. package/src/apps/resources.ts +5 -2
  15. package/src/apps/shell.ts +298 -1526
  16. package/src/apps/startup-input.ts +130 -0
  17. package/src/apps/startup.ts +143 -115
  18. package/src/apps/ui/app.ts +1383 -0
  19. package/src/apps/ui/extension-host.ts +194 -0
  20. package/src/apps/ui/image-cache.ts +78 -0
  21. package/src/apps/ui/palette.ts +88 -0
  22. package/src/apps/ui/project.ts +127 -0
  23. package/src/apps/ui/standalone.ts +146 -0
  24. package/src/apps/ui/status-model.ts +68 -0
  25. package/src/apps/ui/surface.ts +84 -0
  26. package/src/apps/ui/theme.ts +86 -0
  27. package/src/core/acceptance.ts +107 -0
  28. package/src/core/action-defaults.ts +47 -295
  29. package/src/core/benchmark.ts +2 -2
  30. package/src/core/branch-receipt.ts +24 -0
  31. package/src/core/checkpoints.ts +64 -0
  32. package/src/core/churn.ts +2 -0
  33. package/src/core/command-artifacts.ts +94 -0
  34. package/src/core/command-contract.ts +35 -0
  35. package/src/core/completion-review.ts +21 -5
  36. package/src/core/context-detail.ts +37 -0
  37. package/src/core/context-policy.ts +74 -1
  38. package/src/core/continuation.ts +17 -2
  39. package/src/core/derive.ts +29 -3
  40. package/src/core/draft-images.ts +56 -0
  41. package/src/core/environment.ts +145 -0
  42. package/src/core/evaluation-coverage.ts +74 -0
  43. package/src/core/event-validation.ts +80 -5
  44. package/src/core/events.ts +97 -3
  45. package/src/core/evidence-links.ts +7 -0
  46. package/src/core/evidence.ts +13 -3
  47. package/src/core/execution.ts +8 -2
  48. package/src/core/experiment.ts +133 -0
  49. package/src/core/export.ts +9 -0
  50. package/src/core/failure-recovery.ts +173 -0
  51. package/src/core/file-precondition.ts +72 -0
  52. package/src/core/handoff.ts +65 -4
  53. package/src/core/history-query.ts +38 -2
  54. package/src/core/history.ts +38 -1
  55. package/src/core/integration-health.ts +34 -0
  56. package/src/core/keymap.ts +8 -20
  57. package/src/core/model-picker.ts +64 -0
  58. package/src/core/native-eval.ts +25 -0
  59. package/src/core/note-guidance.ts +34 -0
  60. package/src/core/observation-view.ts +49 -8
  61. package/src/core/permissions.ts +24 -2
  62. package/src/core/process-receipt.ts +105 -0
  63. package/src/core/process-state.ts +37 -0
  64. package/src/core/project.ts +45 -18
  65. package/src/core/prompt-policy.ts +37 -0
  66. package/src/core/query-eval-contract.ts +14 -0
  67. package/src/core/read-observation.ts +82 -0
  68. package/src/core/recovery-corpus.ts +17 -0
  69. package/src/core/recovery-evaluation.ts +60 -0
  70. package/src/core/recovery-sources.ts +74 -0
  71. package/src/core/runtime-target.ts +94 -0
  72. package/src/core/search-observation.ts +97 -0
  73. package/src/core/session-tree.ts +147 -0
  74. package/src/core/settings.ts +26 -10
  75. package/src/core/skills.ts +44 -0
  76. package/src/core/sql-query.ts +211 -0
  77. package/src/core/theme.ts +36 -58
  78. package/src/core/tool-contract.ts +51 -2
  79. package/src/core/tool-discovery.ts +87 -0
  80. package/src/core/tool-failure.ts +60 -0
  81. package/src/core/tool-mode-protocol.ts +25 -0
  82. package/src/core/transcript.ts +19 -4
  83. package/src/core/trial-lifecycle.ts +14 -0
  84. package/src/core/tui-mode.ts +29 -0
  85. package/src/core/update-check.ts +97 -0
  86. package/src/core/version.ts +55 -0
  87. package/src/core/work-state.ts +49 -3
  88. package/src/core/working-set.ts +91 -0
  89. package/src/runtime/auth.ts +2 -2
  90. package/src/runtime/branch-jobs.ts +36 -0
  91. package/src/runtime/clipboard.ts +15 -4
  92. package/src/runtime/command-artifacts.ts +64 -0
  93. package/src/runtime/environment-tools.ts +60 -0
  94. package/src/runtime/evidence-notes.ts +4 -2
  95. package/src/runtime/exec-sessions.ts +206 -61
  96. package/src/runtime/execution-control.ts +1 -0
  97. package/src/runtime/extension-ui.ts +22 -30
  98. package/src/runtime/extensions.ts +95 -16
  99. package/src/runtime/harness-comparison.ts +27 -7
  100. package/src/runtime/harness-eval.ts +460 -0
  101. package/src/runtime/history-cache.ts +57 -0
  102. package/src/runtime/history-tools.ts +62 -12
  103. package/src/runtime/integrations.ts +210 -0
  104. package/src/runtime/loop.ts +1021 -268
  105. package/src/runtime/model-discovery.ts +208 -0
  106. package/src/runtime/native-eval-fixtures.ts +33 -0
  107. package/src/runtime/native-eval.ts +143 -0
  108. package/src/runtime/oauth.ts +1 -1
  109. package/src/runtime/provider-connect.ts +109 -0
  110. package/src/runtime/provider-presets.ts +87 -0
  111. package/src/runtime/query-eval-verifier.ts +28 -0
  112. package/src/runtime/recovery-coverage.ts +71 -0
  113. package/src/runtime/recovery-evidence.ts +76 -0
  114. package/src/runtime/recovery-fixtures.ts +116 -0
  115. package/src/runtime/runtime-target.ts +149 -0
  116. package/src/runtime/tool-broker.ts +137 -0
  117. package/src/runtime/tool-discovery.ts +57 -0
  118. package/src/runtime/tool-mode-eval.ts +146 -0
  119. package/src/runtime/tool-queue.ts +77 -0
  120. package/src/runtime/tools.ts +628 -183
  121. package/src/runtime/trial-control.ts +45 -0
  122. package/src/runtime/update-check.ts +47 -0
  123. package/src/runtime/window-trial.ts +2 -1
  124. package/src/runtime/working-notes.ts +33 -3
  125. package/src/runtime/workspace-lifecycle.ts +97 -0
  126. package/src/store/acceptance.ts +15 -0
  127. package/src/store/checkpoint-history.ts +43 -0
  128. package/src/store/command-artifacts.ts +96 -0
  129. package/src/store/command.ts +65 -37
  130. package/src/store/file-checkpoints.ts +104 -0
  131. package/src/store/file-index.ts +13 -1
  132. package/src/store/git-checkpoints.ts +67 -0
  133. package/src/store/observations.ts +61 -20
  134. package/src/store/process-receipts.ts +34 -0
  135. package/src/store/query-source.ts +13 -8
  136. package/src/store/recovery.ts +2 -3
  137. package/src/store/replay.ts +16 -4
  138. package/src/store/runtime-target.ts +64 -0
  139. package/src/store/search.ts +171 -132
  140. package/src/store/session-archive.ts +4 -0
  141. package/src/store/source-cancellation.ts +6 -0
  142. package/src/store/update-cache.ts +36 -0
  143. package/src/store/workspace-observation.ts +113 -0
  144. package/src/tui/ansi-spans.ts +126 -0
  145. package/src/tui/style.ts +19 -16
  146. package/src/tui/ui/atomic-draft.ts +121 -0
  147. package/src/tui/ui/canvas.ts +96 -0
  148. package/src/tui/ui/colors.ts +70 -0
  149. package/src/tui/ui/composer.ts +199 -0
  150. package/src/tui/ui/dialog.ts +379 -0
  151. package/src/tui/ui/draft.ts +23 -0
  152. package/src/tui/ui/edge.ts +186 -0
  153. package/src/tui/ui/image-size.ts +40 -0
  154. package/src/tui/ui/ink.ts +210 -0
  155. package/src/tui/ui/items.ts +40 -0
  156. package/src/tui/ui/keys.ts +40 -0
  157. package/src/tui/ui/layout.ts +74 -0
  158. package/src/tui/ui/list-model.ts +86 -0
  159. package/src/tui/ui/list-view.ts +172 -0
  160. package/src/tui/ui/mascot.ts +67 -0
  161. package/src/tui/ui/messages.ts +518 -0
  162. package/src/tui/ui/motion.ts +28 -0
  163. package/src/tui/ui/paths.ts +10 -0
  164. package/src/tui/ui/pictures.ts +78 -0
  165. package/src/tui/ui/screen.ts +421 -0
  166. package/src/tui/ui/status.ts +271 -0
  167. package/src/tui/ui/styled.ts +85 -0
  168. package/src/tui/ui/testing.ts +5 -0
  169. package/src/tui/ui/tool-card.ts +150 -0
  170. package/src/apps/extension-ui.ts +0 -330
  171. package/src/apps/resource-browser.ts +0 -75
  172. package/src/apps/session-browser.ts +0 -113
  173. package/src/apps/session-picker.ts +0 -55
  174. package/src/tui/ansi.ts +0 -67
  175. package/src/tui/autocomplete.ts +0 -29
  176. package/src/tui/chat-viewport.ts +0 -47
  177. package/src/tui/complete.ts +0 -87
  178. package/src/tui/editor.ts +0 -940
  179. package/src/tui/host.ts +0 -264
  180. package/src/tui/input.ts +0 -75
  181. package/src/tui/keys.ts +0 -378
  182. package/src/tui/markdown.ts +0 -17
  183. package/src/tui/render.ts +0 -153
  184. package/src/tui/select.ts +0 -258
  185. package/src/tui/settings.ts +0 -44
  186. package/src/tui/statusline.ts +0 -162
  187. package/src/tui/system-theme.ts +0 -700
  188. package/src/tui/terminal.ts +0 -30
  189. package/src/tui/text.ts +0 -52
  190. package/src/tui/toolview.ts +0 -386
  191. package/src/tui/transcript.ts +0 -123
  192. package/src/tui/tree.ts +0 -471
  193. package/src/vendor/pi-tui/LICENSE.upstream +0 -21
  194. package/src/vendor/pi-tui/UPSTREAM.json +0 -50
  195. package/src/vendor/pi-tui/VENDOR.md +0 -25
  196. package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
  197. package/src/vendor/pi-tui/autocomplete.ts +0 -860
  198. package/src/vendor/pi-tui/colors.ts +0 -367
  199. package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
  200. package/src/vendor/pi-tui/components/box.ts +0 -166
  201. package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
  202. package/src/vendor/pi-tui/components/editor.ts +0 -2472
  203. package/src/vendor/pi-tui/components/h-stack.ts +0 -44
  204. package/src/vendor/pi-tui/components/image.ts +0 -127
  205. package/src/vendor/pi-tui/components/input.ts +0 -494
  206. package/src/vendor/pi-tui/components/loader.ts +0 -101
  207. package/src/vendor/pi-tui/components/markdown.ts +0 -1021
  208. package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
  209. package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
  210. package/src/vendor/pi-tui/components/select-list.ts +0 -273
  211. package/src/vendor/pi-tui/components/settings-list.ts +0 -328
  212. package/src/vendor/pi-tui/components/spacer.ts +0 -28
  213. package/src/vendor/pi-tui/components/stack.ts +0 -154
  214. package/src/vendor/pi-tui/components/text.ts +0 -107
  215. package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
  216. package/src/vendor/pi-tui/components/v-stack.ts +0 -33
  217. package/src/vendor/pi-tui/editor-component.ts +0 -74
  218. package/src/vendor/pi-tui/fuzzy.ts +0 -138
  219. package/src/vendor/pi-tui/index.ts +0 -186
  220. package/src/vendor/pi-tui/keybindings.ts +0 -320
  221. package/src/vendor/pi-tui/keys.ts +0 -1401
  222. package/src/vendor/pi-tui/kill-ring.ts +0 -46
  223. package/src/vendor/pi-tui/latex.ts +0 -1506
  224. package/src/vendor/pi-tui/layout-node.ts +0 -51
  225. package/src/vendor/pi-tui/layout.ts +0 -449
  226. package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
  227. package/src/vendor/pi-tui/native/clipboard.h +0 -208
  228. package/src/vendor/pi-tui/native/darwin/README.md +0 -22
  229. package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
  230. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  231. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  232. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
  233. package/src/vendor/pi-tui/native/linux/README.md +0 -23
  234. package/src/vendor/pi-tui/native/linux/build.sh +0 -65
  235. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  236. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  237. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
  238. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
  239. package/src/vendor/pi-tui/native/napi.h +0 -91
  240. package/src/vendor/pi-tui/native/win32/README.md +0 -31
  241. package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
  242. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  243. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  244. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
  245. package/src/vendor/pi-tui/native-modifiers.ts +0 -13
  246. package/src/vendor/pi-tui/native-module-path.ts +0 -31
  247. package/src/vendor/pi-tui/native-platform.ts +0 -65
  248. package/src/vendor/pi-tui/oklab.ts +0 -233
  249. package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
  250. package/src/vendor/pi-tui/terminal-colors.ts +0 -91
  251. package/src/vendor/pi-tui/terminal-image.ts +0 -731
  252. package/src/vendor/pi-tui/terminal.ts +0 -554
  253. package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
  254. package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
  255. package/src/vendor/pi-tui/tui.ts +0 -1493
  256. package/src/vendor/pi-tui/undo-stack.ts +0 -28
  257. package/src/vendor/pi-tui/utils.ts +0 -1385
  258. package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
  259. package/src/vendor/pi-tui/word-navigation.ts +0 -117
@@ -0,0 +1,199 @@
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
+ /** Replace text[start, end) with an item's value. */
20
+ readonly start: number;
21
+ readonly end: number;
22
+ readonly items: readonly ListItem[];
23
+ }
24
+
25
+ /**
26
+ * The tallest the draft grows before it scrolls inside the box: a third of
27
+ * the terminal, at least 5 rows. Peers cap by screen fraction rather than a
28
+ * constant (pi 30% min 5, OpenCode ⅓ min 6, Grok ½, Codex ⅔); a third keeps
29
+ * most of the transcript in view while a paragraph still fits.
30
+ */
31
+ export function draftMaxLines(terminalRows: number): number {
32
+ return Math.max(5, Math.floor(terminalRows / 3));
33
+ }
34
+ const MAX_LINES = 5;
35
+ export const PLACEHOLDER = "What would you like to work on?";
36
+
37
+ /**
38
+ * The textarea, plus a hook after each of its frames. The editor scrolls to
39
+ * its cursor while it renders, and its own render() skips renderAfter, so
40
+ * this is the one place the visible part of a tall draft is known.
41
+ */
42
+ export class Composer {
43
+ /** `›` and the editor. Its height is the editor's own measurement of the wrapped draft. */
44
+ readonly row: BoxRenderable;
45
+ readonly area: AtomicDraftArea;
46
+ private draftRevision = 0;
47
+ private readonly prompt: Canvas;
48
+ private completion: Completion | undefined;
49
+ private completionIndex = 0;
50
+ private history: string[] = [];
51
+ private historyAt = -1;
52
+ private historyDraft = "";
53
+ private maxLines = MAX_LINES;
54
+ private shape: DraftRows = { rows: 1, above: 0, below: 0 };
55
+ onChange: ((text: string) => void) | undefined;
56
+ onCursor: (() => void) | undefined;
57
+ /** The row count changed (the box above must re-layout). */
58
+ onResize: (() => void) | undefined;
59
+ /** Rows hidden above or below the visible draft changed (the box edge shows them). */
60
+ onOverflow: (() => void) | undefined;
61
+
62
+ constructor(renderer: CliRenderer, colors: UiColors) {
63
+ this.row = new BoxRenderable(renderer, { id: "composer", flexDirection: "row", flexShrink: 0 });
64
+ this.prompt = new Canvas(renderer, { id: "composer-prompt", width: 2, height: 1, flexShrink: 0, colors });
65
+ this.prompt.setRows([[{ text: "›", role: "accent", bold: true }]]);
66
+ const area = new AtomicDraftArea(renderer, {
67
+ id: "composer-area", flexGrow: 1, minHeight: 1, maxHeight: MAX_LINES, wrapMode: "word",
68
+ textColor: colors.text, focusedTextColor: colors.text, backgroundColor: "transparent", focusedBackgroundColor: "transparent",
69
+ selectionBg: colors.selectionBg, selectionFg: colors.selectionFg,
70
+ placeholder: PLACEHOLDER, placeholderColor: colors.muted,
71
+ // Enter, Shift+Enter, Up/Down are the app's (submit, history); remove them from the textarea.
72
+ keyBindings: [{ name: "return", action: "move-left" }, { name: "_", ctrl: true, action: "undo" }, { name: "/", ctrl: true, action: "undo" }],
73
+ });
74
+ this.area = area;
75
+ // Height comes from the editor's native measurement of its wrapped text, capped by
76
+ // maxHeight (the way OpenCode's prompt grows). Nothing here sets a height, so width
77
+ // changes re-wrap and re-measure on their own (fix-tui-composer-growth).
78
+ this.area.onContentChange = () => { this.draftRevision++; this.onChange?.(this.text); };
79
+ this.area.onCursorChange = () => { this.draftRevision++; this.onCursor?.(); };
80
+ this.area.onSizeChange = () => this.measured();
81
+ // Which rows scrolled out of a tall draft is known only after the editor drew itself.
82
+ area.onRendered = () => this.measured();
83
+ this.row.add(this.prompt); this.row.add(this.area);
84
+ }
85
+
86
+ get text(): string { return this.area.plainText; }
87
+ get cursor(): number { return this.area.textCursor; }
88
+ get revision(): number { return this.draftRevision; }
89
+ setAtoms(spans: (text: string) => readonly DraftAtom[]): void { this.area.atoms = spans; }
90
+ get completing(): boolean { return this.completion !== undefined && this.completion.items.length > 0; }
91
+ /** What the completion list shows (screen draws it). */
92
+ get completionView(): { readonly items: readonly ListItem[]; readonly selected: number } | undefined {
93
+ return this.completion ? { items: this.completion.items, selected: this.completionIndex } : undefined;
94
+ }
95
+ get historyEntries(): readonly string[] { return this.history; }
96
+ get rows(): number { return Math.max(1, this.area.height); }
97
+ /** Wrapped rows of the draft hidden above / below the box (0 when it all fits). */
98
+ get overflow(): { readonly above: number; readonly below: number } { return { above: this.shape.above, below: this.shape.below }; }
99
+
100
+ setColors(colors: UiColors): void {
101
+ this.area.textColor = colors.text;
102
+ this.area.focusedTextColor = colors.text;
103
+ this.area.placeholderColor = colors.muted;
104
+ this.area.selectionBg = colors.selectionBg;
105
+ this.area.selectionFg = colors.selectionFg;
106
+ this.prompt.setColors(colors);
107
+ }
108
+
109
+ setText(text: string): void {
110
+ this.draftRevision++;
111
+ this.area.setText(text);
112
+ this.area.gotoBufferEnd();
113
+ }
114
+
115
+ insert(text: string): void { this.draftRevision++; this.area.insertText(text); }
116
+ removeAtom(label: string): void { this.draftRevision++; this.area.replaceText(this.text.split(label).join("")); }
117
+ resetDraft(project: (text: string) => string): void {
118
+ this.history = this.history.map(project).filter(Boolean);
119
+ this.historyAt = -1; this.historyDraft = ""; this.area.clearKilledText(); this.setText("");
120
+ }
121
+ focus(): void { this.area.focus(); }
122
+
123
+ remember(entry: string): void {
124
+ if (!entry.trim()) return;
125
+ if (this.history[0] !== entry) this.history.unshift(entry);
126
+ this.history = this.history.slice(0, 200);
127
+ this.historyAt = -1;
128
+ }
129
+
130
+ /** Up/Down walk history only when the cursor is on the first/last line, so multi-line drafts stay editable. */
131
+ historyStep(direction: -1 | 1): boolean {
132
+ const text = this.area.plainText;
133
+ const offset = this.area.cursorOffset;
134
+ const firstLine = !text.slice(0, offset).includes("\n");
135
+ const lastLine = !text.slice(offset).includes("\n");
136
+ if (direction < 0 && !firstLine) return false;
137
+ if (direction > 0 && !lastLine) return false;
138
+ if (direction < 0) {
139
+ if (this.historyAt + 1 >= this.history.length) return true;
140
+ if (this.historyAt === -1) this.historyDraft = text;
141
+ this.historyAt++;
142
+ this.setText(this.history[this.historyAt]!);
143
+ return true;
144
+ }
145
+ if (this.historyAt < 0) return false;
146
+ this.historyAt--;
147
+ this.setText(this.historyAt < 0 ? this.historyDraft : this.history[this.historyAt]!);
148
+ return true;
149
+ }
150
+
151
+ setCompletion(completion: Completion | undefined): void {
152
+ const next = completion && completion.items.length ? completion : undefined;
153
+ if (!next && !this.completion) return;
154
+ this.completion = next;
155
+ this.completionIndex = 0;
156
+ this.onResize?.();
157
+ }
158
+ moveCompletion(delta: number): void {
159
+ if (!this.completion) return;
160
+ const n = this.completion.items.length;
161
+ this.completionIndex = (this.completionIndex + delta + n) % n;
162
+ this.onResize?.();
163
+ }
164
+ /** Apply the selected completion; returns the applied item. */
165
+ acceptCompletion(): ListItem | undefined {
166
+ const completion = this.completion;
167
+ const item = completion?.items[this.completionIndex];
168
+ if (!completion || !item) return undefined;
169
+ const text = this.area.plainText;
170
+ this.area.setText(text.slice(0, completion.start) + item.value + text.slice(completion.end));
171
+ this.area.cursorOffset = completion.start + item.value.length;
172
+ this.setCompletion(undefined);
173
+ return item;
174
+ }
175
+
176
+ /** The tallest the draft may grow, so the box never pushes the transcript off a short screen. */
177
+ /** The screen's limit for this terminal size (draftMaxLines, then whatever space is left). */
178
+ setMaxLines(lines: number): void {
179
+ const next = Math.max(1, Math.floor(lines));
180
+ if (next === this.maxLines) return;
181
+ this.maxLines = next;
182
+ this.area.maxHeight = next;
183
+ }
184
+
185
+ /**
186
+ * After layout or a frame: report a new height and what scrolled out of
187
+ * view. `virtualLineCount` is only the viewport's visible rows (always 1 in
188
+ * a one-row box, which is how the box used to stay one row); the editor
189
+ * view's total is the whole wrapped draft. Writes only on change, so a
190
+ * quiet frame costs two native reads.
191
+ */
192
+ private measured(): void {
193
+ const next = draftRows(this.area.editorView.getTotalVirtualLineCount(), this.area.scrollY, Math.max(1, Math.min(this.maxLines, this.area.height || 1)));
194
+ const previous = this.shape;
195
+ this.shape = next;
196
+ if (next.rows !== previous.rows) this.onResize?.();
197
+ if (next.above !== previous.above || next.below !== previous.below) this.onOverflow?.();
198
+ }
199
+ }
@@ -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
+ }