@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.
- package/CHANGELOG.md +18 -0
- package/README.md +126 -34
- package/docs/app-server.md +84 -0
- package/docs/install.md +51 -13
- package/docs/providers.md +24 -3
- package/package.json +7 -5
- package/src/apps/app-server.ts +28 -0
- package/src/apps/approve.ts +3 -3
- package/src/apps/cli.ts +25 -10
- package/src/apps/commands.ts +45 -8
- package/src/apps/completions.ts +78 -22
- package/src/apps/control-transport.ts +89 -0
- package/src/apps/image-draft.ts +46 -0
- package/src/apps/model-catalog.ts +2 -1
- package/src/apps/project-trust.ts +2 -42
- package/src/apps/provider-connect.ts +346 -0
- package/src/apps/render.ts +22 -47
- package/src/apps/resources.ts +5 -2
- package/src/apps/shell.ts +333 -1542
- package/src/apps/startup-input.ts +45 -9
- package/src/apps/startup.ts +149 -94
- package/src/apps/ui/app.ts +1553 -0
- package/src/apps/ui/control-model.ts +26 -0
- package/src/apps/ui/extension-host.ts +194 -0
- package/src/apps/ui/image-cache.ts +78 -0
- package/src/apps/ui/palette.ts +88 -0
- package/src/apps/ui/project.ts +127 -0
- package/src/apps/ui/standalone.ts +146 -0
- package/src/apps/ui/status-model.ts +68 -0
- package/src/apps/ui/surface.ts +86 -0
- package/src/apps/ui/theme.ts +86 -0
- package/src/core/acceptance.ts +107 -0
- package/src/core/action-defaults.ts +47 -295
- package/src/core/benchmark.ts +2 -2
- package/src/core/branch-receipt.ts +24 -0
- package/src/core/checkpoints.ts +64 -0
- package/src/core/churn.ts +2 -0
- package/src/core/command-artifacts.ts +94 -0
- package/src/core/command-contract.ts +35 -0
- package/src/core/completion-action.ts +20 -0
- package/src/core/completion-review.ts +37 -21
- package/src/core/context-detail.ts +37 -0
- package/src/core/context-policy.ts +74 -1
- package/src/core/continuation.ts +17 -2
- package/src/core/control-protocol.ts +62 -0
- package/src/core/control-view.ts +42 -0
- package/src/core/derive.ts +29 -3
- package/src/core/draft-images.ts +56 -0
- package/src/core/environment.ts +145 -0
- package/src/core/evaluation-coverage.ts +74 -0
- package/src/core/event-validation.ts +81 -6
- package/src/core/events.ts +99 -3
- package/src/core/evidence-links.ts +7 -0
- package/src/core/evidence.ts +13 -3
- package/src/core/execution.ts +8 -2
- package/src/core/experiment.ts +133 -0
- package/src/core/export.ts +9 -0
- package/src/core/failure-recovery.ts +173 -0
- package/src/core/file-precondition.ts +72 -0
- package/src/core/handoff.ts +65 -4
- package/src/core/harness-diagnostics.ts +206 -0
- package/src/core/history-query.ts +38 -2
- package/src/core/history.ts +38 -1
- package/src/core/integration-health.ts +34 -0
- package/src/core/keymap.ts +8 -20
- package/src/core/model-picker.ts +64 -0
- package/src/core/native-eval.ts +25 -0
- package/src/core/note-guidance.ts +34 -0
- package/src/core/oauth-fields.ts +44 -0
- package/src/core/observation-view.ts +49 -8
- package/src/core/permissions.ts +24 -2
- package/src/core/process-receipt.ts +105 -0
- package/src/core/process-state.ts +37 -0
- package/src/core/program-status.ts +31 -0
- package/src/core/project.ts +70 -19
- package/src/core/prompt-policy.ts +37 -0
- package/src/core/query-eval-contract.ts +14 -0
- package/src/core/read-observation.ts +82 -0
- package/src/core/recovery-corpus.ts +17 -0
- package/src/core/recovery-evaluation.ts +60 -0
- package/src/core/recovery-sources.ts +74 -0
- package/src/core/request-progress.ts +87 -0
- package/src/core/request-state.ts +114 -0
- package/src/core/runtime-target.ts +94 -0
- package/src/core/search-observation.ts +97 -0
- package/src/core/session-tree.ts +147 -0
- package/src/core/settings.ts +33 -10
- package/src/core/skills.ts +44 -0
- package/src/core/sql-query.ts +211 -0
- package/src/core/theme.ts +36 -58
- package/src/core/tool-contract.ts +51 -2
- package/src/core/tool-discovery.ts +87 -0
- package/src/core/tool-failure.ts +60 -0
- package/src/core/tool-input.ts +6 -0
- package/src/core/tool-mode-protocol.ts +25 -0
- package/src/core/transcript.ts +19 -4
- package/src/core/trial-lifecycle.ts +14 -0
- package/src/core/tui-mode.ts +29 -0
- package/src/core/update-check.ts +97 -0
- package/src/core/version.ts +55 -0
- package/src/core/work-state.ts +49 -3
- package/src/core/working-set.ts +91 -0
- package/src/runtime/auth.ts +11 -4
- package/src/runtime/branch-jobs.ts +36 -0
- package/src/runtime/catalog.ts +13 -3
- package/src/runtime/clipboard.ts +15 -4
- package/src/runtime/command-artifacts.ts +64 -0
- package/src/runtime/control-connection.ts +104 -0
- package/src/runtime/environment-tools.ts +60 -0
- package/src/runtime/evidence-notes.ts +4 -2
- package/src/runtime/exec-sessions.ts +206 -61
- package/src/runtime/execution-control.ts +1 -0
- package/src/runtime/extension-ui.ts +22 -30
- package/src/runtime/extensions.ts +95 -16
- package/src/runtime/harness-comparison.ts +27 -7
- package/src/runtime/harness-eval.ts +462 -0
- package/src/runtime/history-cache.ts +57 -0
- package/src/runtime/history-tools.ts +62 -12
- package/src/runtime/integrations.ts +210 -0
- package/src/runtime/loop.ts +1088 -282
- package/src/runtime/model-discovery.ts +208 -0
- package/src/runtime/model.ts +9 -0
- package/src/runtime/native-eval-fixtures.ts +33 -0
- package/src/runtime/native-eval.ts +146 -0
- package/src/runtime/oauth.ts +80 -3
- package/src/runtime/pi-provider.ts +169 -12
- package/src/runtime/program-status.ts +38 -0
- package/src/runtime/provider-connect.ts +109 -0
- package/src/runtime/provider-presets.ts +87 -0
- package/src/runtime/provider-request.ts +13 -2
- package/src/runtime/provider.ts +23 -9
- package/src/runtime/query-eval-verifier.ts +28 -0
- package/src/runtime/recovery-coverage.ts +71 -0
- package/src/runtime/recovery-evidence.ts +76 -0
- package/src/runtime/recovery-fixtures.ts +116 -0
- package/src/runtime/runtime-target.ts +149 -0
- package/src/runtime/session-control.ts +198 -0
- package/src/runtime/tool-broker.ts +137 -0
- package/src/runtime/tool-discovery.ts +57 -0
- package/src/runtime/tool-mode-eval.ts +146 -0
- package/src/runtime/tool-queue.ts +77 -0
- package/src/runtime/tools.ts +628 -183
- package/src/runtime/trial-control.ts +45 -0
- package/src/runtime/update-check.ts +47 -0
- package/src/runtime/window-trial.ts +2 -1
- package/src/runtime/working-notes.ts +33 -3
- package/src/runtime/workspace-lifecycle.ts +97 -0
- package/src/store/acceptance.ts +15 -0
- package/src/store/checkpoint-history.ts +43 -0
- package/src/store/command-artifacts.ts +96 -0
- package/src/store/command.ts +65 -37
- package/src/store/file-checkpoints.ts +104 -0
- package/src/store/file-index.ts +13 -1
- package/src/store/git-checkpoints.ts +67 -0
- package/src/store/observations.ts +61 -20
- package/src/store/process-receipts.ts +34 -0
- package/src/store/query-source.ts +13 -8
- package/src/store/recovery.ts +2 -3
- package/src/store/replay.ts +16 -4
- package/src/store/runtime-target.ts +64 -0
- package/src/store/search.ts +171 -132
- package/src/store/session-archive.ts +4 -0
- package/src/store/source-cancellation.ts +6 -0
- package/src/store/update-cache.ts +36 -0
- package/src/store/workspace-observation.ts +113 -0
- package/src/tui/ansi-spans.ts +126 -0
- package/src/tui/style.ts +19 -16
- package/src/tui/ui/atomic-draft.ts +121 -0
- package/src/tui/ui/canvas.ts +96 -0
- package/src/tui/ui/colors.ts +70 -0
- package/src/tui/ui/composer.ts +200 -0
- package/src/tui/ui/control-panel.ts +23 -0
- package/src/tui/ui/dialog.ts +379 -0
- package/src/tui/ui/draft.ts +23 -0
- package/src/tui/ui/edge.ts +186 -0
- package/src/tui/ui/image-size.ts +40 -0
- package/src/tui/ui/ink.ts +210 -0
- package/src/tui/ui/items.ts +40 -0
- package/src/tui/ui/keys.ts +40 -0
- package/src/tui/ui/layout.ts +74 -0
- package/src/tui/ui/list-model.ts +86 -0
- package/src/tui/ui/list-view.ts +172 -0
- package/src/tui/ui/mascot.ts +67 -0
- package/src/tui/ui/messages.ts +518 -0
- package/src/tui/ui/motion.ts +28 -0
- package/src/tui/ui/paths.ts +10 -0
- package/src/tui/ui/pictures.ts +78 -0
- package/src/tui/ui/screen.ts +421 -0
- package/src/tui/ui/status.ts +279 -0
- package/src/tui/ui/styled.ts +85 -0
- package/src/tui/ui/testing.ts +5 -0
- package/src/tui/ui/tool-card.ts +150 -0
- package/src/apps/extension-ui.ts +0 -330
- package/src/apps/resource-browser.ts +0 -75
- package/src/apps/session-browser.ts +0 -113
- package/src/apps/session-picker.ts +0 -55
- package/src/tui/ansi.ts +0 -67
- package/src/tui/autocomplete.ts +0 -29
- package/src/tui/chat-viewport.ts +0 -47
- package/src/tui/complete.ts +0 -87
- package/src/tui/editor.ts +0 -940
- package/src/tui/host.ts +0 -264
- package/src/tui/input.ts +0 -75
- package/src/tui/keys.ts +0 -378
- package/src/tui/markdown.ts +0 -17
- package/src/tui/render.ts +0 -153
- package/src/tui/select.ts +0 -258
- package/src/tui/settings.ts +0 -44
- package/src/tui/statusline.ts +0 -162
- package/src/tui/system-theme.ts +0 -700
- package/src/tui/terminal.ts +0 -30
- package/src/tui/text.ts +0 -52
- package/src/tui/toolview.ts +0 -386
- package/src/tui/transcript.ts +0 -123
- package/src/tui/tree.ts +0 -471
- package/src/vendor/pi-tui/LICENSE.upstream +0 -21
- package/src/vendor/pi-tui/UPSTREAM.json +0 -50
- package/src/vendor/pi-tui/VENDOR.md +0 -25
- package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
- package/src/vendor/pi-tui/autocomplete.ts +0 -860
- package/src/vendor/pi-tui/colors.ts +0 -367
- package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
- package/src/vendor/pi-tui/components/box.ts +0 -166
- package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
- package/src/vendor/pi-tui/components/editor.ts +0 -2472
- package/src/vendor/pi-tui/components/h-stack.ts +0 -44
- package/src/vendor/pi-tui/components/image.ts +0 -127
- package/src/vendor/pi-tui/components/input.ts +0 -494
- package/src/vendor/pi-tui/components/loader.ts +0 -101
- package/src/vendor/pi-tui/components/markdown.ts +0 -1021
- package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
- package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
- package/src/vendor/pi-tui/components/select-list.ts +0 -273
- package/src/vendor/pi-tui/components/settings-list.ts +0 -328
- package/src/vendor/pi-tui/components/spacer.ts +0 -28
- package/src/vendor/pi-tui/components/stack.ts +0 -154
- package/src/vendor/pi-tui/components/text.ts +0 -107
- package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
- package/src/vendor/pi-tui/components/v-stack.ts +0 -33
- package/src/vendor/pi-tui/editor-component.ts +0 -74
- package/src/vendor/pi-tui/fuzzy.ts +0 -138
- package/src/vendor/pi-tui/index.ts +0 -186
- package/src/vendor/pi-tui/keybindings.ts +0 -320
- package/src/vendor/pi-tui/keys.ts +0 -1401
- package/src/vendor/pi-tui/kill-ring.ts +0 -46
- package/src/vendor/pi-tui/latex.ts +0 -1506
- package/src/vendor/pi-tui/layout-node.ts +0 -51
- package/src/vendor/pi-tui/layout.ts +0 -449
- package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
- package/src/vendor/pi-tui/native/clipboard.h +0 -208
- package/src/vendor/pi-tui/native/darwin/README.md +0 -22
- package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
- package/src/vendor/pi-tui/native/linux/README.md +0 -23
- package/src/vendor/pi-tui/native/linux/build.sh +0 -65
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
- package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
- package/src/vendor/pi-tui/native/napi.h +0 -91
- package/src/vendor/pi-tui/native/win32/README.md +0 -31
- package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
- package/src/vendor/pi-tui/native-modifiers.ts +0 -13
- package/src/vendor/pi-tui/native-module-path.ts +0 -31
- package/src/vendor/pi-tui/native-platform.ts +0 -65
- package/src/vendor/pi-tui/oklab.ts +0 -233
- package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
- package/src/vendor/pi-tui/terminal-colors.ts +0 -91
- package/src/vendor/pi-tui/terminal-image.ts +0 -731
- package/src/vendor/pi-tui/terminal.ts +0 -554
- package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
- package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
- package/src/vendor/pi-tui/tui.ts +0 -1493
- package/src/vendor/pi-tui/undo-stack.ts +0 -28
- package/src/vendor/pi-tui/utils.ts +0 -1385
- package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
- 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
|
+
}
|