moqi-tui 0.2.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 (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +782 -0
  3. package/bin/moqi.mjs +40 -0
  4. package/cordis.patch.yml +41 -0
  5. package/lib/cross-find.js +217 -0
  6. package/lib/file-index.js +121 -0
  7. package/lib/fleet-sources.js +114 -0
  8. package/lib/index.js +3999 -0
  9. package/lib/persist.js +194 -0
  10. package/lib/plugins.js +371 -0
  11. package/lib/presence.js +144 -0
  12. package/lib/rename.js +35 -0
  13. package/lib/rewind.js +94 -0
  14. package/lib/sessions-store.js +134 -0
  15. package/lib/startup.js +92 -0
  16. package/lib/tui/atfile.js +154 -0
  17. package/lib/tui/export.js +48 -0
  18. package/lib/tui/fleet.js +346 -0
  19. package/lib/tui/i18n.js +201 -0
  20. package/lib/tui/jobs.js +65 -0
  21. package/lib/tui/keys.js +205 -0
  22. package/lib/tui/markdown.js +368 -0
  23. package/lib/tui/mcp.js +95 -0
  24. package/lib/tui/panels.js +231 -0
  25. package/lib/tui/screen.js +156 -0
  26. package/lib/tui/state.js +502 -0
  27. package/lib/tui/stream.js +109 -0
  28. package/lib/tui/text.js +173 -0
  29. package/lib/tui/theme.js +183 -0
  30. package/lib/tui/themes.js +153 -0
  31. package/lib/tui/tooldetail.js +140 -0
  32. package/lib/tui/view.js +830 -0
  33. package/lib/tui/vim.js +222 -0
  34. package/lib/tui-host-core.js +141 -0
  35. package/lib/tui-host.js +48 -0
  36. package/lib/types/cross-find.d.ts +66 -0
  37. package/lib/types/file-index.d.ts +34 -0
  38. package/lib/types/fleet-sources.d.ts +34 -0
  39. package/lib/types/index.d.ts +51 -0
  40. package/lib/types/persist.d.ts +116 -0
  41. package/lib/types/plugins.d.ts +218 -0
  42. package/lib/types/presence.d.ts +48 -0
  43. package/lib/types/rename.d.ts +32 -0
  44. package/lib/types/rewind.d.ts +75 -0
  45. package/lib/types/sessions-store.d.ts +46 -0
  46. package/lib/types/startup.d.ts +45 -0
  47. package/lib/types/tui/atfile.d.ts +90 -0
  48. package/lib/types/tui/export.d.ts +18 -0
  49. package/lib/types/tui/fleet.d.ts +209 -0
  50. package/lib/types/tui/i18n.d.ts +34 -0
  51. package/lib/types/tui/jobs.d.ts +28 -0
  52. package/lib/types/tui/keys.d.ts +52 -0
  53. package/lib/types/tui/markdown.d.ts +14 -0
  54. package/lib/types/tui/mcp.d.ts +34 -0
  55. package/lib/types/tui/panels.d.ts +125 -0
  56. package/lib/types/tui/screen.d.ts +79 -0
  57. package/lib/types/tui/state.d.ts +323 -0
  58. package/lib/types/tui/stream.d.ts +78 -0
  59. package/lib/types/tui/text.d.ts +28 -0
  60. package/lib/types/tui/theme.d.ts +87 -0
  61. package/lib/types/tui/themes.d.ts +70 -0
  62. package/lib/types/tui/tooldetail.d.ts +45 -0
  63. package/lib/types/tui/view.d.ts +163 -0
  64. package/lib/types/tui/vim.d.ts +64 -0
  65. package/lib/types/tui-host-core.d.ts +62 -0
  66. package/lib/types/tui-host.d.ts +42 -0
  67. package/lib/types/version.d.ts +8 -0
  68. package/lib/types/voice.d.ts +227 -0
  69. package/lib/version.js +32 -0
  70. package/lib/voice.js +405 -0
  71. package/package.json +119 -0
  72. package/scripts/harness-root.mjs +88 -0
  73. package/scripts/install-profile.mjs +133 -0
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Transcript export: turn the in-app message list into a markdown document.
3
+ *
4
+ * Pure and free of Harness imports, like everything under `./tui/`, so it can
5
+ * be tested without a profile.
6
+ * @module
7
+ */
8
+ import { type Message } from './state.ts';
9
+ /**
10
+ * Render a transcript as markdown.
11
+ *
12
+ * User turns become `## >`-quoted sections and assistant turns `##` sections.
13
+ * An assistant turn is written in the order it happened — each tool call as a
14
+ * checklist line between the prose it came between — because a document that
15
+ * collects the calls at the end tells you what the agent did but not when, and
16
+ * the reason it said the next thing is usually what the call returned.
17
+ */
18
+ export declare function transcriptMarkdown(messages: readonly Message[], title: string): string;
@@ -0,0 +1,209 @@
1
+ /**
2
+ * The fleet overview: sessions across every device, in one list.
3
+ *
4
+ * This module is pure. It knows nothing about filesystems, SSH, or the
5
+ * Harness — it takes presence records that someone else collected and turns
6
+ * them into ordered, rendered rows. That is what keeps the interesting part
7
+ * (staleness, ranking, what a row says) testable without a second machine.
8
+ *
9
+ * The transport is deliberately outside this file: see `src/fleet-sources.ts`.
10
+ * @module
11
+ */
12
+ /**
13
+ * What one device publishes about one of its open sessions.
14
+ *
15
+ * A record is written by the app that owns the session and refreshed on a
16
+ * heartbeat, so its age is the liveness signal. Nothing here is derived from
17
+ * the session log: the owning app already knows its own status exactly, and
18
+ * decompressing a log per session per device would not scale over SSH.
19
+ */
20
+ export interface PresenceRecord {
21
+ /** Record format version, so an older device's file can be rejected. */
22
+ v: number;
23
+ /** Device name, as the overview labels it. */
24
+ host: string;
25
+ /** Process that owns the session, for diagnosis only. */
26
+ pid: number;
27
+ sessionId: string;
28
+ title: string;
29
+ status: PresenceStatus;
30
+ /** Model the session is using, when known. */
31
+ model?: string;
32
+ /** Working directory the session was created in. */
33
+ cwd?: string;
34
+ /** Epoch millis of the last heartbeat. */
35
+ updatedAt: number;
36
+ }
37
+ /**
38
+ * What a session is doing, as a device reports it.
39
+ *
40
+ * Deliberately declared here rather than imported from the session state: the
41
+ * overview's wire format must not move when the local UI's types do, and it
42
+ * keeps this module dependency-free apart from rendering helpers.
43
+ */
44
+ export type PresenceStatus = 'idle' | 'running' | 'ready';
45
+ /** The record version this build writes and accepts. */
46
+ export declare const PRESENCE_VERSION = 1;
47
+ /**
48
+ * How long a record may go unrefreshed before its device is presumed gone.
49
+ *
50
+ * Generous relative to the heartbeat: a laptop that sleeps mid-turn should
51
+ * read as `stale` rather than flapping, and a slow SSH round trip must not
52
+ * make a healthy device look dead.
53
+ */
54
+ export declare const DEFAULT_STALE_AFTER_MS = 30000;
55
+ /** A session's state in the overview, including the one presence cannot claim. */
56
+ export type FleetStatus = PresenceStatus | 'stale';
57
+ /** One row of the overview. */
58
+ export interface FleetSession {
59
+ host: string;
60
+ sessionId: string;
61
+ title: string;
62
+ status: FleetStatus;
63
+ model?: string;
64
+ cwd?: string;
65
+ updatedAt: number;
66
+ /** True when this row is the device the overview is running on. */
67
+ local: boolean;
68
+ /** Seconds since the last heartbeat, for display. */
69
+ ageSeconds: number;
70
+ }
71
+ /** What a device's collector returned, including the failure case. */
72
+ export interface FleetSource {
73
+ host: string;
74
+ local: boolean;
75
+ /** Records read from the device, or an empty list when it could not be read. */
76
+ records: readonly PresenceRecord[];
77
+ /** Set when the device could not be reached or read. */
78
+ error?: string;
79
+ }
80
+ /** Whether a record is well-formed enough to display. */
81
+ export declare function isPresenceRecord(value: unknown): value is PresenceRecord;
82
+ /**
83
+ * Merge every device's records into one ordered list.
84
+ *
85
+ * A record older than `staleAfterMs` is reported `stale` whatever it claimed:
86
+ * a device that stopped heartbeating mid-turn would otherwise sit in the
87
+ * overview claiming to be running forever.
88
+ */
89
+ export declare function mergeFleet(sources: readonly FleetSource[], now: number, staleAfterMs?: number): FleetSession[];
90
+ /** A compact age: 8s, 4m, 2h, 3d. */
91
+ export declare function formatAge(seconds: number): string;
92
+ /**
93
+ * The command that opens a session on the device that owns it.
94
+ *
95
+ * A remote session is reached the same way the device itself is: over SSH,
96
+ * with a TTY, resuming by id. Nothing new is exposed to do it.
97
+ */
98
+ export declare function jumpCommand(session: FleetSession, profile?: string): string;
99
+ /**
100
+ * Quote one argument for a POSIX shell.
101
+ *
102
+ * A prompt is arbitrary text and is about to travel through `ssh`, which hands
103
+ * it to the remote shell — so it is single-quoted with the one escape a single
104
+ * quoted string has. Nothing here trusts the caller.
105
+ */
106
+ export declare function shellQuote(text: string): string;
107
+ /**
108
+ * The argv for dispatching a task to a peer's headless profile.
109
+ *
110
+ * The prompt is quoted for the remote shell; the profile name and host are
111
+ * passed as separate argv words so the local shell never interprets them.
112
+ */
113
+ export declare function dispatchArgv(host: string, profile: string, prompt: string): string[];
114
+ /** Options for {@link renderFleet}. */
115
+ export interface FleetRenderOptions {
116
+ width: number;
117
+ /** Index of the highlighted row, or -1 for none. */
118
+ selectedIndex?: number;
119
+ spinner?: string;
120
+ /** Devices that could not be read, reported under the list. */
121
+ sources?: readonly FleetSource[];
122
+ }
123
+ /**
124
+ * Render the overview to styled lines.
125
+ *
126
+ * Grouped by device, because "which machine is this on" is the question the
127
+ * overview exists to answer; an unreachable device is named rather than
128
+ * silently contributing nothing.
129
+ */
130
+ export declare function renderFleet(sessions: readonly FleetSession[], options: FleetRenderOptions): string[];
131
+ /** A one-line summary for the status bar: how much is running where. */
132
+ export declare function fleetSummary(sessions: readonly FleetSession[]): string;
133
+ /** Colors re-exported so a caller can match the overview's palette. */
134
+ export declare const FLEET_COLORS: {
135
+ readonly colGreen: import("./theme.ts").AdaptiveColor;
136
+ readonly colGold: import("./theme.ts").AdaptiveColor;
137
+ readonly colMuted: import("./theme.ts").AdaptiveColor;
138
+ readonly colText: import("./theme.ts").AdaptiveColor;
139
+ readonly ok: (text: string) => string;
140
+ };
141
+ /**
142
+ * Which rendered line carries row `index`.
143
+ *
144
+ * {@link renderFleet} inserts a heading per device and a blank line between
145
+ * groups, so the selected row's index is not its line. The pane needs the
146
+ * line to scroll, and duplicating the rule here rather than returning it from
147
+ * the renderer keeps the renderer a plain function of its inputs. The two must
148
+ * agree, which is what the smoke test pins.
149
+ */
150
+ export declare function fleetLineOf(sessions: readonly FleetSession[], index: number): number;
151
+ /**
152
+ * Whether a string is safe and sensible to use as a peer.
153
+ *
154
+ * The value ends up on an `ssh` command line, so this is a gate rather than a
155
+ * tidy-up: anything a shell would treat as more than one word, or that `ssh`
156
+ * would read as an option, is refused outright instead of being escaped and
157
+ * hoped for. What remains is the shape of a host, an alias, or `user@host`.
158
+ */
159
+ export declare function isValidPeer(host: string): boolean;
160
+ /**
161
+ * The overview's interaction state: what was collected, and where the cursor is.
162
+ *
163
+ * Kept beside the renderer because it is the same concern and equally pure —
164
+ * it never reads a file or a socket. The app owns collection and hands the
165
+ * result here.
166
+ */
167
+ export declare class FleetView {
168
+ open: boolean;
169
+ /** True while a collection round is in flight, so the pane can say so. */
170
+ loading: boolean;
171
+ sessions: FleetSession[];
172
+ sources: FleetSource[];
173
+ selected: number;
174
+ /**
175
+ * Set while the pane is asking for a device to add.
176
+ *
177
+ * Adding a peer belongs here rather than only on the command line, because
178
+ * the list is exactly where you notice a device is missing from it.
179
+ */
180
+ adding: boolean;
181
+ /** What has been typed into that prompt so far. */
182
+ draft: string;
183
+ show(): void;
184
+ hide(): void;
185
+ /**
186
+ * Install a freshly collected round.
187
+ *
188
+ * The cursor follows the session it was on rather than the position it was
189
+ * at: rows reorder as work starts and finishes, and a refresh that moved the
190
+ * selection onto a different machine would be a way to open the wrong thing.
191
+ */
192
+ setResult(sessions: readonly FleetSession[], sources: readonly FleetSource[]): void;
193
+ move(delta: number): void;
194
+ current(): FleetSession | undefined;
195
+ /** Start asking for a device to add. */
196
+ beginAdd(): void;
197
+ /** Abandon the prompt, leaving the list as it was. */
198
+ cancelAdd(): void;
199
+ typeAdd(text: string): void;
200
+ backspaceAdd(): void;
201
+ /**
202
+ * Finish the prompt, returning the host to add.
203
+ *
204
+ * A rejected name leaves the prompt open with the text intact, so a typo is
205
+ * corrected rather than retyped.
206
+ */
207
+ commitAdd(): string | undefined;
208
+ private clamp;
209
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Interface language: one catalog, looked up by key.
3
+ *
4
+ * The scope is deliberate: the chrome a reader reads — the welcome, the key
5
+ * reference, the trust panels, and the footer hints. Operational status lines
6
+ * (what a command just did) stay English because they are diagnostics, not
7
+ * interface, and translating a moving target is how a UI ends up half in each
8
+ * language.
9
+ *
10
+ * A missing key falls back to English and then to the key itself, so a new
11
+ * string can never render as blank.
12
+ * @module
13
+ */
14
+ /** The languages the interface ships with. */
15
+ export type Lang = 'en' | 'zh-CN';
16
+ /** Every language, in menu order, with the label to show in a picker. */
17
+ export declare const LANGS: readonly {
18
+ id: Lang;
19
+ label: string;
20
+ }[];
21
+ /** Whether an untrusted string names a language this build speaks. */
22
+ export declare function isLang(value: string): value is Lang;
23
+ /** Fill `{name}` placeholders; a missing value leaves the placeholder alone. */
24
+ export declare function fill(template: string, params: Record<string, string | number> | undefined): string;
25
+ /** Look a string up in one explicit language. */
26
+ export declare function translate(lang: Lang, key: string, params?: Record<string, string | number>): string;
27
+ /** The language the interface is drawing in. */
28
+ export declare function currentLanguage(): Lang;
29
+ /** Switch the interface language; the caller persists the choice. */
30
+ export declare function setLanguage(lang: Lang): void;
31
+ /** Look a string up in the active language. */
32
+ export declare function t(key: string, params?: Record<string, string | number>): string;
33
+ /** The full key reference in the active language. */
34
+ export declare function helpText(): string;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `/jobs` formatting: a background job list rendered as markdown.
3
+ *
4
+ * Pure, like the rest of `tui/`: the app layer hands over snapshots and owns
5
+ * the registry calls.
6
+ * @module
7
+ */
8
+ /** The subset of a job snapshot this module formats. */
9
+ export interface JobLike {
10
+ id: string;
11
+ kind: string;
12
+ label: string;
13
+ status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed' | string;
14
+ detail?: string;
15
+ startedAt: number;
16
+ finishedAt?: number;
17
+ }
18
+ /** A compact "3s" / "4m" / "2h" duration. */
19
+ export declare function formatDuration(seconds: number): string;
20
+ /** The mark a job's state earns in the list. */
21
+ export declare function jobMark(status: string): string;
22
+ /**
23
+ * The `/jobs` overlay body.
24
+ *
25
+ * Running jobs first, then the settled ones newest-first, because the list
26
+ * exists to answer "what is still going" before "what happened".
27
+ */
28
+ export declare function renderJobs(jobs: readonly JobLike[], now?: number): string;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Key decoding for raw-mode stdin.
3
+ *
4
+ * Node hands the app raw bytes, so escape sequences have to be turned back
5
+ * into key names. The decoder is chunk-tolerant: a sequence split across two
6
+ * reads is held until it completes rather than being reported as a stray
7
+ * escape.
8
+ * @module
9
+ */
10
+ /** One decoded keypress. */
11
+ export interface Key {
12
+ /** Canonical name, e.g. `enter`, `up`, `ctrl+c`, `a`. */
13
+ name: string;
14
+ /** Printable text this key contributes, if any. */
15
+ text: string;
16
+ }
17
+ /**
18
+ * Decode a buffer into keys, returning the keys and any trailing bytes that
19
+ * form an incomplete sequence.
20
+ */
21
+ export declare function decode(input: string): {
22
+ keys: Key[];
23
+ rest: string;
24
+ };
25
+ /**
26
+ * How long a lone escape waits for the rest of a sequence before it is read as
27
+ * the escape key.
28
+ *
29
+ * A terminal sends the same byte for "the user pressed Escape" and for the
30
+ * first byte of `ESC [ A`; only time tells them apart. Without this, a lone
31
+ * Escape produced no key at all and the *next* keystroke was misread as an
32
+ * `alt+` chord, so every documented `esc` — interrupt, close an overlay,
33
+ * dismiss a menu — was dead. Vim's own `ttimeoutlen` sits in this range.
34
+ */
35
+ export declare const ESCAPE_DELAY_MS = 50;
36
+ /** A stateful decoder, plus the handles a terminal loop needs to own it. */
37
+ export interface KeyDecoder {
38
+ /** Decode one chunk; complete keys come back, an unfinished tail is held. */
39
+ (chunk: string): Key[];
40
+ /** Read a held lone escape now, as its own key. */
41
+ flush(): void;
42
+ /** Cancel any pending timer, so a stopped screen emits nothing more. */
43
+ dispose(): void;
44
+ }
45
+ /**
46
+ * A stateful decoder that carries an incomplete sequence between chunks.
47
+ *
48
+ * @param emit - receives a key that arrives asynchronously (a flushed lone
49
+ * escape), because no further chunk will carry it.
50
+ * @param escapeDelayMs - how long a lone escape waits; see {@link ESCAPE_DELAY_MS}.
51
+ */
52
+ export declare function createDecoder(emit?: (key: Key) => void, escapeDelayMs?: number): KeyDecoder;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Markdown to ANSI rendering, plus a small syntax highlighter for fenced code.
3
+ *
4
+ * The original Go client leaned on glamour and chroma. This is the same job
5
+ * done with no dependency: the transcript is re-rendered on every frame and
6
+ * while a reply streams in, so the renderer must tolerate a half-written
7
+ * document (an unterminated fence, a dangling emphasis run) without throwing
8
+ * or swallowing text.
9
+ * @module
10
+ */
11
+ /** Render inline spans: code, bold, italic, strikethrough, and links. */
12
+ export declare function renderInline(text: string): string;
13
+ /** Render a markdown document to styled lines at `width` columns. */
14
+ export declare function renderMarkdown(source: string, width: number): string;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `/mcp` formatting: which MCP servers' tools are actually mounted.
3
+ *
4
+ * The MCP client is a composition-level plugin — servers are declared in the
5
+ * profile, not registered through a queryable runtime API — so the honest view
6
+ * is the tool registry filtered to MCP-provided names. This module does the
7
+ * naming and grouping; the app layer does the probing.
8
+ * @module
9
+ */
10
+ /** One MCP server and the tools it contributed. */
11
+ export interface McpServer {
12
+ name: string;
13
+ tools: string[];
14
+ }
15
+ /**
16
+ * The separator conventions MCP bridges use in tool names.
17
+ *
18
+ * `mcp__server__tool` is the Claude-style double-underscore form; `server/tool`
19
+ * and `server:tool` appear in some bridges. Parsing all three means a server
20
+ * shows up whichever bridge mounted it.
21
+ */
22
+ export declare function parseMcpToolName(name: string): {
23
+ server: string;
24
+ tool: string;
25
+ } | undefined;
26
+ /** Group tool names into their MCP servers, alphabetically by server then tool. */
27
+ export declare function groupMcpTools(toolNames: readonly string[]): McpServer[];
28
+ /**
29
+ * The `/mcp` overlay body.
30
+ *
31
+ * @param servers - grouped MCP tools, from {@link groupMcpTools}.
32
+ * @param totalTools - every tool the registry offers, for the empty case.
33
+ */
34
+ export declare function renderMcp(servers: readonly McpServer[], totalTools: number): string;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Trust-surface panels: tool approval, `ask_user_question`, and plan review.
3
+ *
4
+ * These are the moments the agent stops and asks a human, so they own the
5
+ * keyboard while open and answer through the Harness waterfall seams. Like the
6
+ * rest of `tui/`, this module is pure state plus a view shape — the app layer
7
+ * does the Cordis wiring and the renderer does the drawing.
8
+ * @module
9
+ */
10
+ /** One selectable row in a panel. */
11
+ export interface PanelRow {
12
+ label: string;
13
+ description?: string;
14
+ /** Highlighted by the cursor. */
15
+ selected: boolean;
16
+ /** Multi-select state; undefined for a single-select row. */
17
+ checked?: boolean;
18
+ }
19
+ /**
20
+ * The panel the renderer draws in place of the transcript.
21
+ *
22
+ * `detail` is markdown: an approval's reason, a question's supporting text, or
23
+ * a plan under review.
24
+ */
25
+ export interface PanelView {
26
+ kind: 'approval' | 'questions';
27
+ title: string;
28
+ detail: string;
29
+ rows: PanelRow[];
30
+ hint: string;
31
+ /** Extra lines under the rows: the free-text line and its draft. */
32
+ inputLabel?: string;
33
+ inputText?: string;
34
+ inputFocused?: boolean;
35
+ }
36
+ /**
37
+ * Read a spoken (transcribed) answer as an approval decision.
38
+ *
39
+ * Deliberately narrow: a misheard sentence must never grant a tool call, so
40
+ * only unambiguous words decide and anything else returns `undefined`, leaving
41
+ * the panel waiting. Both shipped interface languages are accepted.
42
+ */
43
+ export declare function interpretApproval(text: string): ApprovalDecision | undefined;
44
+ /** What the user decided about one approval request. */
45
+ export type ApprovalDecision = 'allowed-once' | 'rejected';
46
+ /**
47
+ * One pending tool approval.
48
+ *
49
+ * There is no persistent grant in the protocol — "allow once" or deny — so the
50
+ * panel offers exactly those two rows and no "always" temptation.
51
+ */
52
+ export declare class ApprovalPanel {
53
+ selected: number;
54
+ readonly toolName: string;
55
+ readonly reason: string | undefined;
56
+ private readonly command;
57
+ constructor(toolName: string, reason: string | undefined, command: string | undefined);
58
+ move(delta: number): void;
59
+ decision(): ApprovalDecision;
60
+ view(): PanelView;
61
+ }
62
+ /** One question as the answerer receives it. */
63
+ export interface QuestionSpec {
64
+ id: string;
65
+ question: string;
66
+ detail?: string;
67
+ header?: string;
68
+ options?: readonly {
69
+ label: string;
70
+ description?: string;
71
+ }[];
72
+ multiSelect?: boolean;
73
+ intent?: {
74
+ kind: 'plan-review';
75
+ approve: string;
76
+ };
77
+ }
78
+ /** One answered question, in the shape the Harness expects back. */
79
+ export interface QuestionAnswer {
80
+ id: string;
81
+ selected: string[];
82
+ custom?: string;
83
+ }
84
+ /** Raised when the user backs out of a question set; the service maps it to `ASK_CANCELLED`. */
85
+ export declare const ASK_CANCELLED_CODE = "ASK_CANCELLED";
86
+ /**
87
+ * The `ask_user_question` flow, question by question.
88
+ *
89
+ * Single-select answers on `enter`; multi-select toggles with `space` and
90
+ * collects with `enter`; `tab` moves to the free-text line, and typing on an
91
+ * option row combines that option's label with the text, the way a form does.
92
+ * A plan review is the same shape with a markdown body and an approve label
93
+ * named by the protocol — approval never carries feedback, because the
94
+ * protocol reads feedback as "keep planning".
95
+ */
96
+ export declare class QuestionsPanel {
97
+ index: number;
98
+ focus: 'options' | 'custom';
99
+ private readonly drafts;
100
+ private readonly questions;
101
+ constructor(questions: readonly QuestionSpec[]);
102
+ private get question();
103
+ private get draft();
104
+ /** Whether this set is a plan under review rather than a question. */
105
+ get isPlanReview(): boolean;
106
+ move(delta: number): void;
107
+ /** Space toggles the highlighted option on a multi-select question. */
108
+ toggle(): void;
109
+ focusCustom(): void;
110
+ /** Type into the free-text line; on an option row the option joins the answer. */
111
+ typeText(chunk: string): void;
112
+ backspaceText(): void;
113
+ /**
114
+ * Commit the current question and move on.
115
+ *
116
+ * @returns `'next'` when another question follows, `'done'` when the set is
117
+ * answered, and `'empty'` when nothing has been chosen yet.
118
+ */
119
+ advance(): 'next' | 'done' | 'empty';
120
+ /** Step back one question, keeping what was already chosen. */
121
+ back(): boolean;
122
+ /** The finished answer set, in question order. */
123
+ answers(): QuestionAnswer[];
124
+ view(): PanelView;
125
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The terminal driver: raw mode, the alternate screen, and frame painting.
3
+ *
4
+ * Frames are painted line by line against the previous frame so a streaming
5
+ * reply only rewrites the lines that actually changed. That keeps a fast token
6
+ * stream from flickering the whole screen, which a naive full clear-and-redraw
7
+ * does at any real terminal size.
8
+ * @module
9
+ */
10
+ import { type Key } from './keys.ts';
11
+ /** What the screen reports about its size. */
12
+ export interface Size {
13
+ columns: number;
14
+ rows: number;
15
+ }
16
+ /**
17
+ * Clamp a reported terminal size to something drawable.
18
+ *
19
+ * A stream can report `0` as well as `undefined` — a pty opened without a
20
+ * window size does exactly that — and `?? 80` does not catch a zero. Left
21
+ * alone, the whole frame collapses to zero-width lines and the app paints
22
+ * nothing but cursor moves, which looks like a hang rather than a sizing
23
+ * problem.
24
+ */
25
+ export declare function normalizeSize(columns: number | undefined, rows: number | undefined): Size;
26
+ /** Callbacks the owner supplies. */
27
+ export interface ScreenHandlers {
28
+ onKey(key: Key): void;
29
+ onResize(size: Size): void;
30
+ }
31
+ /** Optional screen behavior. */
32
+ export interface ScreenOptions {
33
+ /**
34
+ * Report mouse events so the wheel can scroll. Off by default: this is a
35
+ * keyboard-first app, and terminals suppress their own selection while
36
+ * reporting is on, which costs copy-paste to buy a wheel most people do not
37
+ * reach for.
38
+ */
39
+ mouse?: boolean;
40
+ }
41
+ /**
42
+ * Owns stdin/stdout for the lifetime of the app. Construction does not touch
43
+ * the terminal; {@link Screen.start} does, and {@link Screen.stop} is safe to
44
+ * call more than once so teardown paths can be blunt.
45
+ */
46
+ export declare class Screen {
47
+ private previous;
48
+ private started;
49
+ private readonly decode;
50
+ private readonly onData;
51
+ private readonly onResize;
52
+ private cursor;
53
+ private readonly handlers;
54
+ private readonly mouse;
55
+ constructor(handlers: ScreenHandlers, options?: ScreenOptions);
56
+ /** Current terminal size, with defaults for a non-TTY stdout. */
57
+ size(): Size;
58
+ /** Whether this process is attached to a real terminal on both ends. */
59
+ static isInteractive(): boolean;
60
+ /** Enter the alternate screen and begin delivering keys. */
61
+ start(): void;
62
+ /** Restore the terminal. Safe to call repeatedly and after a failed start. */
63
+ stop(): void;
64
+ /**
65
+ * Place the hardware cursor on the next paint, in 0-indexed screen
66
+ * coordinates. Passing `undefined` hides it.
67
+ */
68
+ setCursor(position: {
69
+ row: number;
70
+ column: number;
71
+ } | undefined): void;
72
+ /**
73
+ * Paint a frame. `frame` is the whole screen as lines; missing lines are
74
+ * treated as blank so the caller need not pad to the window height.
75
+ */
76
+ paint(frame: string[]): void;
77
+ /** Drop the cached frame so the next paint rewrites every line. */
78
+ invalidate(): void;
79
+ }