@vincemakes/kiso-tui 0.1.30 → 0.1.32
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/dist/components.d.ts +116 -0
- package/dist/components.js +333 -0
- package/dist/compositor.d.ts +165 -0
- package/dist/compositor.js +779 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +5 -4
- package/package.json +1 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI v6 (ADR-0046) — the components: EVERY screen line's renderer.
|
|
3
|
+
*
|
|
4
|
+
* Each component turns one piece of state into display lines (SGR
|
|
5
|
+
* included, raw — the compositor writes them verbatim). The folding
|
|
6
|
+
* lives HERE: every line a component returns must fit the terminal
|
|
7
|
+
* width — the compositor's crash-on-violation invariant backs it up
|
|
8
|
+
* (a component that forgets to fold CRASHES with a diagnostic, never
|
|
9
|
+
* silently truncates — pi tui-main-screen.ts:447-473).
|
|
10
|
+
*
|
|
11
|
+
* The fold is SGR-AWARE: a line whose bold/dim span would straddle a
|
|
12
|
+
* fold boundary closes the span at the break and reopens it on the
|
|
13
|
+
* next row — the #16b contract (no literal "[2m" fragments) survives
|
|
14
|
+
* folding. displayWidth/charWidth (editor.ts) are the width primitives
|
|
15
|
+
* (untouched); render.ts supplies the original text (palette, escape,
|
|
16
|
+
* tint, fold wording).
|
|
17
|
+
*/
|
|
18
|
+
import { foldThinking, foldResult, renderToolSummary } from "./render.js";
|
|
19
|
+
/** The spinner glyphs, cycled by the compositor's on-demand tick. */
|
|
20
|
+
export declare const SPINNER: string[];
|
|
21
|
+
/** The frame context the compositor passes down — the pieces of time
|
|
22
|
+
* that make a live render non-deterministic (the running tool's glyph
|
|
23
|
+
* and elapsed). Everything else is a pure function of the cell. */
|
|
24
|
+
export interface FrameCtx {
|
|
25
|
+
readonly spinnerI: number;
|
|
26
|
+
readonly now: number;
|
|
27
|
+
}
|
|
28
|
+
/** ONE screen line a component emits (raw, SGR included). */
|
|
29
|
+
export type RenderLine = string;
|
|
30
|
+
/**
|
|
31
|
+
* The fold — split a display-width line into ≤W rows, preserving SGR
|
|
32
|
+
* spans across the break: a span open at the break closes (reset) at
|
|
33
|
+
* the row's end and reopens on the next row. The rows are what the
|
|
34
|
+
* terminal's own soft-wrap would have produced — except the compositor
|
|
35
|
+
* folds FIRST, so the terminal never reflows a component's line (the
|
|
36
|
+
* #17 merge class cannot reach committed content).
|
|
37
|
+
*/
|
|
38
|
+
export declare function foldLine(line: string, W: number): string[];
|
|
39
|
+
/** The visible width of a rendered line (SGR stripped — the invariant
|
|
40
|
+
* the compositor enforces on every emitted line). */
|
|
41
|
+
export declare function visibleWidth(line: string): number;
|
|
42
|
+
/** A component: render the display lines for one piece of state. */
|
|
43
|
+
export interface Component {
|
|
44
|
+
render(width: number, ctx: FrameCtx): string[];
|
|
45
|
+
}
|
|
46
|
+
/** The container — vertical concatenation of its children. */
|
|
47
|
+
export declare class Container implements Component {
|
|
48
|
+
private readonly children;
|
|
49
|
+
constructor(children: Component[]);
|
|
50
|
+
render(width: number, ctx: FrameCtx): string[];
|
|
51
|
+
}
|
|
52
|
+
export type BodyCell = {
|
|
53
|
+
kind: "user";
|
|
54
|
+
text: string;
|
|
55
|
+
done: true;
|
|
56
|
+
} | {
|
|
57
|
+
kind: "thinking";
|
|
58
|
+
text: string;
|
|
59
|
+
done: boolean;
|
|
60
|
+
} | {
|
|
61
|
+
kind: "tool";
|
|
62
|
+
name: string;
|
|
63
|
+
input: string;
|
|
64
|
+
state: "pending" | "approval" | "running" | "done";
|
|
65
|
+
isError: boolean;
|
|
66
|
+
resultText: string;
|
|
67
|
+
diff: import("./diff.js").DiffLine[] | null;
|
|
68
|
+
added: number;
|
|
69
|
+
removed: number;
|
|
70
|
+
startedAt: number | null;
|
|
71
|
+
doneAt: number | null;
|
|
72
|
+
done: boolean;
|
|
73
|
+
} | {
|
|
74
|
+
kind: "text";
|
|
75
|
+
text: string;
|
|
76
|
+
done: boolean;
|
|
77
|
+
} | {
|
|
78
|
+
kind: "notice";
|
|
79
|
+
text: string;
|
|
80
|
+
done: true;
|
|
81
|
+
} | {
|
|
82
|
+
kind: "raw";
|
|
83
|
+
lines: string[];
|
|
84
|
+
done: true;
|
|
85
|
+
} | {
|
|
86
|
+
kind: "terminal";
|
|
87
|
+
label: string;
|
|
88
|
+
line: string;
|
|
89
|
+
done: true;
|
|
90
|
+
} | {
|
|
91
|
+
kind: "checklist";
|
|
92
|
+
header: string;
|
|
93
|
+
items: {
|
|
94
|
+
text: string;
|
|
95
|
+
status: "pending" | "active" | "done";
|
|
96
|
+
}[];
|
|
97
|
+
done: true;
|
|
98
|
+
};
|
|
99
|
+
declare const TOOL_SUMMARY_MAX = 60;
|
|
100
|
+
/** The component for one cell — the mapping table lives here so the
|
|
101
|
+
* compositor stays a pure writer. */
|
|
102
|
+
export declare function cellComponent(cell: BodyCell): Component;
|
|
103
|
+
/** The status container's row: the status text (+ the tail) with the
|
|
104
|
+
* right-aligned "/ commands · ↑ history" hint in the idle state —
|
|
105
|
+
* the hint CUT FIRST when the width is short (the #16g rule); when
|
|
106
|
+
* the STATUS ITSELF cannot fit, it cuts with a "…" — the last resort,
|
|
107
|
+
* enforced by invariant ① (the old code let the status soft-wrap). */
|
|
108
|
+
export declare function statusLine(status: string, tail: string, question: boolean, W: number): string;
|
|
109
|
+
/** The footer — the ONE dotted row (the old two-row chrome is gone;
|
|
110
|
+
* the wall cannot return by construction). */
|
|
111
|
+
export declare function footerLine(W: number): string;
|
|
112
|
+
/** The terminal label + rhythm gap (the pipe path's v2c bytes — the
|
|
113
|
+
* exact render the passthrough needs). */
|
|
114
|
+
export declare function terminalPipe(label: string, statusLineText: string): string;
|
|
115
|
+
/** The pipe-path pieces the passthrough reuses (byte-identical). */
|
|
116
|
+
export { foldThinking, foldResult, renderToolSummary, TOOL_SUMMARY_MAX };
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI v6 (ADR-0046) — the components: EVERY screen line's renderer.
|
|
3
|
+
*
|
|
4
|
+
* Each component turns one piece of state into display lines (SGR
|
|
5
|
+
* included, raw — the compositor writes them verbatim). The folding
|
|
6
|
+
* lives HERE: every line a component returns must fit the terminal
|
|
7
|
+
* width — the compositor's crash-on-violation invariant backs it up
|
|
8
|
+
* (a component that forgets to fold CRASHES with a diagnostic, never
|
|
9
|
+
* silently truncates — pi tui-main-screen.ts:447-473).
|
|
10
|
+
*
|
|
11
|
+
* The fold is SGR-AWARE: a line whose bold/dim span would straddle a
|
|
12
|
+
* fold boundary closes the span at the break and reopens it on the
|
|
13
|
+
* next row — the #16b contract (no literal "[2m" fragments) survives
|
|
14
|
+
* folding. displayWidth/charWidth (editor.ts) are the width primitives
|
|
15
|
+
* (untouched); render.ts supplies the original text (palette, escape,
|
|
16
|
+
* tint, fold wording).
|
|
17
|
+
*/
|
|
18
|
+
import { displayWidth } from "./editor.js";
|
|
19
|
+
import { escapeTerminal, foldThinking, foldResult, colorInlineCode, renderTerminalGap, renderToolSummary, palette, } from "./render.js";
|
|
20
|
+
/** The spinner glyphs, cycled by the compositor's on-demand tick. */
|
|
21
|
+
export const SPINNER = ["▖", "▘", "▝", "▗"];
|
|
22
|
+
/**
|
|
23
|
+
* The fold — split a display-width line into ≤W rows, preserving SGR
|
|
24
|
+
* spans across the break: a span open at the break closes (reset) at
|
|
25
|
+
* the row's end and reopens on the next row. The rows are what the
|
|
26
|
+
* terminal's own soft-wrap would have produced — except the compositor
|
|
27
|
+
* folds FIRST, so the terminal never reflows a component's line (the
|
|
28
|
+
* #17 merge class cannot reach committed content).
|
|
29
|
+
*/
|
|
30
|
+
export function foldLine(line, W) {
|
|
31
|
+
if (W < 1)
|
|
32
|
+
return [line];
|
|
33
|
+
// collect the plain text + the SGR segments so the walk can track
|
|
34
|
+
// the open span state
|
|
35
|
+
const out = [];
|
|
36
|
+
let current = "";
|
|
37
|
+
let width = 0;
|
|
38
|
+
let open = []; // the SGR sequences seen since the last reset
|
|
39
|
+
for (let i = 0; i < line.length;) {
|
|
40
|
+
if (line[i] === "\n") {
|
|
41
|
+
// a real line break — the row ends here (the same close/reopen
|
|
42
|
+
// as a fold boundary, so a span never leaks across the break)
|
|
43
|
+
const close = open.length > 0 ? "\x1b[0m" : "";
|
|
44
|
+
out.push(current + close);
|
|
45
|
+
current = open.join("");
|
|
46
|
+
width = 0;
|
|
47
|
+
i += 1;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (line[i] === "\x1b") {
|
|
51
|
+
const m = /^\x1b\[[0-9;]*m/.exec(line.slice(i));
|
|
52
|
+
if (m !== null) {
|
|
53
|
+
if (m[0] === "\x1b[0m")
|
|
54
|
+
open = [];
|
|
55
|
+
else
|
|
56
|
+
open.push(m[0]);
|
|
57
|
+
current += m[0];
|
|
58
|
+
i += m[0].length;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
// a non-SGR CSI (the raw cell's own content, escaped at
|
|
62
|
+
// composition) — copy verbatim, zero width
|
|
63
|
+
const csi = /^\x1b\[[0-9;?]*[A-Za-z]/.exec(line.slice(i));
|
|
64
|
+
if (csi !== null) {
|
|
65
|
+
current += csi[0];
|
|
66
|
+
i += csi[0].length;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
current += line[i];
|
|
70
|
+
i += 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const cw = displayWidth(line[i]);
|
|
74
|
+
if (width + cw > W && width > 0) {
|
|
75
|
+
// the fold — close the open spans, push, reopen on the next row
|
|
76
|
+
const close = open.length > 0 ? "\x1b[0m" : "";
|
|
77
|
+
out.push(current + close);
|
|
78
|
+
current = open.join("");
|
|
79
|
+
width = 0;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
current += line[i];
|
|
83
|
+
width += cw;
|
|
84
|
+
i += 1;
|
|
85
|
+
}
|
|
86
|
+
if (current !== "" || out.length === 0)
|
|
87
|
+
out.push(current);
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
/** The visible width of a rendered line (SGR stripped — the invariant
|
|
91
|
+
* the compositor enforces on every emitted line). */
|
|
92
|
+
export function visibleWidth(line) {
|
|
93
|
+
let w = 0;
|
|
94
|
+
for (let i = 0; i < line.length;) {
|
|
95
|
+
if (line[i] === "\x1b") {
|
|
96
|
+
const m = /^\x1b\[[0-9;?]*[A-Za-z]/.exec(line.slice(i));
|
|
97
|
+
if (m !== null) {
|
|
98
|
+
i += m[0].length;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
i += 1;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
w += displayWidth(line[i]);
|
|
105
|
+
i += 1;
|
|
106
|
+
}
|
|
107
|
+
return w;
|
|
108
|
+
}
|
|
109
|
+
/** The container — vertical concatenation of its children. */
|
|
110
|
+
export class Container {
|
|
111
|
+
children;
|
|
112
|
+
constructor(children) {
|
|
113
|
+
this.children = children;
|
|
114
|
+
}
|
|
115
|
+
render(width, ctx) {
|
|
116
|
+
return this.children.flatMap((c) => c.render(width, ctx));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const TOOL_SUMMARY_MAX = 60; // the tool line's parameter summary, chars
|
|
120
|
+
/** The component for one cell — the mapping table lives here so the
|
|
121
|
+
* compositor stays a pure writer. */
|
|
122
|
+
export function cellComponent(cell) {
|
|
123
|
+
switch (cell.kind) {
|
|
124
|
+
case "user":
|
|
125
|
+
return new UserMessage(cell);
|
|
126
|
+
case "thinking":
|
|
127
|
+
return new ThinkingFold(cell);
|
|
128
|
+
case "tool":
|
|
129
|
+
return new ToolExecution(cell);
|
|
130
|
+
case "text":
|
|
131
|
+
return new AssistantMessage(cell);
|
|
132
|
+
case "notice":
|
|
133
|
+
return new ErrorLine(cell);
|
|
134
|
+
case "raw":
|
|
135
|
+
return new RawBlock(cell);
|
|
136
|
+
case "terminal":
|
|
137
|
+
return new TerminalBlock(cell);
|
|
138
|
+
case "checklist":
|
|
139
|
+
return new Checklist(cell);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The user message — the left rail (bright-white BOLD ▍ per row, the
|
|
144
|
+
* v4.1 design). The text folds at W−2 (the rail + space) so every row
|
|
145
|
+
* carries the rail and NO row exceeds the width (the v5 code split on
|
|
146
|
+
* "\n" only — a long line soft-wrapped and its continuation row had no
|
|
147
|
+
* rail).
|
|
148
|
+
*/
|
|
149
|
+
class UserMessage {
|
|
150
|
+
cell;
|
|
151
|
+
constructor(cell) {
|
|
152
|
+
this.cell = cell;
|
|
153
|
+
}
|
|
154
|
+
render(W, _ctx) {
|
|
155
|
+
const p = palette();
|
|
156
|
+
const rail = `${p.bold}▍${p.reset} `;
|
|
157
|
+
const textW = Math.max(1, W - 2);
|
|
158
|
+
const rows = [];
|
|
159
|
+
for (const para of this.cell.text.split("\n")) {
|
|
160
|
+
const folded = foldLine(escapeTerminal(para), textW);
|
|
161
|
+
for (const row of folded)
|
|
162
|
+
rows.push(`${rail}${row}`);
|
|
163
|
+
}
|
|
164
|
+
return rows.length > 0 ? rows : [rail.trimEnd()];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** The thinking fold — one dim line, width-capped so the /think suffix
|
|
168
|
+
* rides the fold's own row (the #17 fix's slice, componentized). */
|
|
169
|
+
class ThinkingFold {
|
|
170
|
+
cell;
|
|
171
|
+
constructor(cell) {
|
|
172
|
+
this.cell = cell;
|
|
173
|
+
}
|
|
174
|
+
render(W, _ctx) {
|
|
175
|
+
const block = this.cell.text;
|
|
176
|
+
const trimmed = escapeTerminal(block.trim());
|
|
177
|
+
if (trimmed.length <= 100)
|
|
178
|
+
return [`${palette().dim}…${trimmed}${palette().reset}`];
|
|
179
|
+
const suffix = ` (${block.length} chars · /think)`;
|
|
180
|
+
const slice = Math.max(1, W - 1 - suffix.length);
|
|
181
|
+
return [`${palette().dim}…${trimmed.slice(0, slice)}${suffix}${palette().reset}`];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** The tool execution line + the approval mini-diff — every state is
|
|
185
|
+
* its own render; the lines fold (the summary gives way first). */
|
|
186
|
+
class ToolExecution {
|
|
187
|
+
cell;
|
|
188
|
+
constructor(cell) {
|
|
189
|
+
this.cell = cell;
|
|
190
|
+
}
|
|
191
|
+
render(W, ctx) {
|
|
192
|
+
const p = palette();
|
|
193
|
+
const c = this.cell;
|
|
194
|
+
const name = escapeTerminal(c.name);
|
|
195
|
+
const summary = escapeTerminal(c.input);
|
|
196
|
+
if (c.state === "done") {
|
|
197
|
+
const elapsed = c.startedAt !== null && c.doneAt !== null ? ((c.doneAt - c.startedAt) / 1000).toFixed(1) : "?";
|
|
198
|
+
const line = c.isError
|
|
199
|
+
? `${p.red}✗ ${name} (${escapeTerminal(c.resultText.split("\n")[0].slice(0, 60))}, ${elapsed}s)${p.reset}`
|
|
200
|
+
: `${p.bold}✓ ${name}${p.reset} (${summary}${c.added + c.removed > 0 ? `, +${c.added} -${c.removed}` : ""}, ${elapsed}s)`;
|
|
201
|
+
return foldLine(line, W);
|
|
202
|
+
}
|
|
203
|
+
if (c.state === "approval") {
|
|
204
|
+
const lines = foldLine(`→ ${name} ${summary} ${p.bold}⏸${p.reset}`, W);
|
|
205
|
+
if (c.diff !== null) {
|
|
206
|
+
for (const d of c.diff) {
|
|
207
|
+
const body = d.kind === "-"
|
|
208
|
+
? `${p.red}- ${escapeTerminal(d.text)}${p.reset}`
|
|
209
|
+
: d.kind === "+"
|
|
210
|
+
? `${p.green}+ ${escapeTerminal(d.text)}${p.reset}`
|
|
211
|
+
: `${p.dim} ${escapeTerminal(d.text)}${p.reset}`;
|
|
212
|
+
lines.push(...foldLine(`${p.bold}▎${p.reset}${body}`, W));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return lines;
|
|
216
|
+
}
|
|
217
|
+
if (c.state === "running") {
|
|
218
|
+
const elapsed = c.startedAt !== null ? Math.max(1, Math.round((ctx.now - c.startedAt) / 1000)) : 1;
|
|
219
|
+
return foldLine(`→ ${name} ${summary} ${p.bold}${SPINNER[ctx.spinnerI % SPINNER.length]}${p.reset} ${elapsed}s`, W);
|
|
220
|
+
}
|
|
221
|
+
return foldLine(`→ ${name} ${summary}`, W);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** The assistant body text — wrapped at W, the inline-code tint per
|
|
225
|
+
* row (the #16e rule: a span never matches across rows). */
|
|
226
|
+
class AssistantMessage {
|
|
227
|
+
cell;
|
|
228
|
+
constructor(cell) {
|
|
229
|
+
this.cell = cell;
|
|
230
|
+
}
|
|
231
|
+
render(W, _ctx) {
|
|
232
|
+
const text = escapeTerminal(this.cell.text);
|
|
233
|
+
const wrapped = foldLine(text, W);
|
|
234
|
+
return wrapped.length > 0 ? wrapped.map((l) => colorInlineCode(l)) : [""];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/** The ⚠ / notice lines — the error surface. */
|
|
238
|
+
class ErrorLine {
|
|
239
|
+
cell;
|
|
240
|
+
constructor(cell) {
|
|
241
|
+
this.cell = cell;
|
|
242
|
+
}
|
|
243
|
+
render(W, _ctx) {
|
|
244
|
+
return foldLine(escapeTerminal(this.cell.text), W);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/** The CLI's pre-rendered blocks (the banner, the recap, slash-command
|
|
248
|
+
* output) — the SGR applied at composition (render.ts), folded here
|
|
249
|
+
* verbatim: the #16b contract (no re-escaping) holds, and the fold is
|
|
250
|
+
* SGR-aware so the accent spans survive a break. */
|
|
251
|
+
class RawBlock {
|
|
252
|
+
cell;
|
|
253
|
+
constructor(cell) {
|
|
254
|
+
this.cell = cell;
|
|
255
|
+
}
|
|
256
|
+
render(W, _ctx) {
|
|
257
|
+
return this.cell.lines.flatMap((l) => foldLine(l, W));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/** The terminal label + the status line + the rhythm gap blank. */
|
|
261
|
+
class TerminalBlock {
|
|
262
|
+
cell;
|
|
263
|
+
constructor(cell) {
|
|
264
|
+
this.cell = cell;
|
|
265
|
+
}
|
|
266
|
+
render(W, _ctx) {
|
|
267
|
+
const lines = [...foldLine(this.cell.label, W), ...foldLine(this.cell.line, W)];
|
|
268
|
+
if (this.cell.label !== "")
|
|
269
|
+
lines.push("");
|
|
270
|
+
return lines;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/** The durable checklist — the ▞ header + one brick-glyph row per item. */
|
|
274
|
+
class Checklist {
|
|
275
|
+
cell;
|
|
276
|
+
constructor(cell) {
|
|
277
|
+
this.cell = cell;
|
|
278
|
+
}
|
|
279
|
+
render(W, _ctx) {
|
|
280
|
+
const p = palette();
|
|
281
|
+
const glyphOf = (status) => (status === "pending" ? "□" : status === "active" ? "▖" : "▣");
|
|
282
|
+
const rows = foldLine(`${p.bold}▞${p.reset} ${escapeTerminal(this.cell.header)}`, W);
|
|
283
|
+
for (const item of this.cell.items) {
|
|
284
|
+
rows.push(...foldLine(` ${glyphOf(item.status)} ${escapeTerminal(item.text)}`, W));
|
|
285
|
+
}
|
|
286
|
+
return rows;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// ---- the chrome components (the status container, the slot, the footer) ----
|
|
290
|
+
/** The status container's row: the status text (+ the tail) with the
|
|
291
|
+
* right-aligned "/ commands · ↑ history" hint in the idle state —
|
|
292
|
+
* the hint CUT FIRST when the width is short (the #16g rule); when
|
|
293
|
+
* the STATUS ITSELF cannot fit, it cuts with a "…" — the last resort,
|
|
294
|
+
* enforced by invariant ① (the old code let the status soft-wrap). */
|
|
295
|
+
export function statusLine(status, tail, question, W) {
|
|
296
|
+
const p = palette();
|
|
297
|
+
const text = `${status}${tail === "" ? "" : ` · ${tail}`}`;
|
|
298
|
+
if (question)
|
|
299
|
+
return `${p.dim}${widthCut(text, W)}${p.reset}`;
|
|
300
|
+
const hint = " / commands · ↑ history";
|
|
301
|
+
const statusW = visibleWidth(text);
|
|
302
|
+
if (statusW > W) {
|
|
303
|
+
return `${p.dim}${widthCut(text, W - 1)}…${p.reset}`;
|
|
304
|
+
}
|
|
305
|
+
const hintW = visibleWidth(hint);
|
|
306
|
+
if (statusW + hintW > W)
|
|
307
|
+
return `${p.dim}${text}${p.reset}`;
|
|
308
|
+
return `${p.dim}${text}${" ".repeat(Math.max(0, W - statusW - hintW))}${hint}${p.reset}`;
|
|
309
|
+
}
|
|
310
|
+
/** The display-width prefix of a plain (SGR-free) text. */
|
|
311
|
+
function widthCut(text, max) {
|
|
312
|
+
let w = 0;
|
|
313
|
+
let i = 0;
|
|
314
|
+
for (; i < text.length; i += 1) {
|
|
315
|
+
const cw = displayWidth(text[i]);
|
|
316
|
+
if (w + cw > max)
|
|
317
|
+
break;
|
|
318
|
+
w += cw;
|
|
319
|
+
}
|
|
320
|
+
return text.slice(0, i);
|
|
321
|
+
}
|
|
322
|
+
/** The footer — the ONE dotted row (the old two-row chrome is gone;
|
|
323
|
+
* the wall cannot return by construction). */
|
|
324
|
+
export function footerLine(W) {
|
|
325
|
+
return `\x1b[2m${"╌".repeat(W)}\x1b[0m`;
|
|
326
|
+
}
|
|
327
|
+
/** The terminal label + rhythm gap (the pipe path's v2c bytes — the
|
|
328
|
+
* exact render the passthrough needs). */
|
|
329
|
+
export function terminalPipe(label, statusLineText) {
|
|
330
|
+
return label + renderTerminalGap(statusLineText);
|
|
331
|
+
}
|
|
332
|
+
/** The pipe-path pieces the passthrough reuses (byte-identical). */
|
|
333
|
+
export { foldThinking, foldResult, renderToolSummary, TOOL_SUMMARY_MAX };
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI v6 (ADR-0046) — THE one-compositor: every byte of the screen's
|
|
3
|
+
* stdout comes from this file's doRender. `body.ts` + `dock.ts` are
|
|
4
|
+
* RETIRED — this class implements BOTH façades (the `Body` mutations
|
|
5
|
+
* the CLI's consumeRun calls, and the `Dock` chrome API), so the CLI
|
|
6
|
+
* itself is untouched (zero diff outside the tui package + tests).
|
|
7
|
+
*
|
|
8
|
+
* The model:
|
|
9
|
+
* - cells (the CLI's mutation surface) → components → lines;
|
|
10
|
+
* - `lines[] + commitIndex` (the scrollback fork — the departure from
|
|
11
|
+
* pi): a line COMMITS (leaves the live region) via the real-LF
|
|
12
|
+
* scroll at the last row (`\x1b[1B\n` — CUP-free) when its cell is
|
|
13
|
+
* DONE and the region needs the room. Committed bytes are never
|
|
14
|
+
* re-emitted — the native scrollback gets them, reflow-safe, and
|
|
15
|
+
* the user's shell history is never touched (zero \x1b[3J, zero
|
|
16
|
+
* replay);
|
|
17
|
+
* - the live region (content + status + editor + footer + menu) is
|
|
18
|
+
* hard-capped at H−1 lines; overflow FORCE-commits the oldest live
|
|
19
|
+
* line regardless of done-ness — the one sharp edge this round
|
|
20
|
+
* introduces (asserted by the VT-emulator gate);
|
|
21
|
+
* - two crash invariants: ① every emitted line's visible width ≤ W
|
|
22
|
+
* (components fold; a violation THROWS with diagnostics — pi
|
|
23
|
+
* tui-main-screen.ts:447-473, no silent truncate); ② within the
|
|
24
|
+
* live region only RELATIVE cursor moves (vertical A/B, horizontal
|
|
25
|
+
* G/D) — CUP exists only in the full-redraw path (the first frame,
|
|
26
|
+
* the resize repaint);
|
|
27
|
+
* - the cursor DERIVES from the frame: the focus component embeds the
|
|
28
|
+
* APC marker in its rendered line; the compositor locates, strips,
|
|
29
|
+
* and relatively positions from the frame — no side-channel cursor
|
|
30
|
+
* bookkeeping that could desync from the picture;
|
|
31
|
+
* - zero timers: the spinner animation is a dirty flag through the
|
|
32
|
+
* scheduler — a one-shot setTimeout re-armed only while a running
|
|
33
|
+
* tool exists (the #14/#15 zero-output contract is structural).
|
|
34
|
+
*
|
|
35
|
+
* Layout at H rows: content rows 1..H−3, status H−2, editor (the slot)
|
|
36
|
+
* H−1, footer ╌ at H. Pipes / NO_COLOR: the passthrough branches below
|
|
37
|
+
* keep the v2a/v2b line-mode bytes byte-for-byte (the e2e guards them).
|
|
38
|
+
*/
|
|
39
|
+
import { type MenuItem } from "./editor.js";
|
|
40
|
+
/** The cursor marker — an APC private sequence the focus component
|
|
41
|
+
* embeds at the edit position; the compositor strips it and moves
|
|
42
|
+
* relatively (it never reaches the terminal). */
|
|
43
|
+
export declare const CURSOR_MARKER = "\u001B_[kiso-cur]\u001B\\";
|
|
44
|
+
export interface BodyOptions {
|
|
45
|
+
/** Is the cell renderer live? A color TTY with a real size — checked
|
|
46
|
+
* per mutation (the TIOCSWINSZ can land after main constructs us). */
|
|
47
|
+
active: () => boolean;
|
|
48
|
+
/** The terminal height (rows) — live, for the region geometry. */
|
|
49
|
+
height: () => number;
|
|
50
|
+
/** The terminal width (cols) — live, for wrap estimates. */
|
|
51
|
+
width: () => number;
|
|
52
|
+
/** v6: RETIRED — the cursor derives from the frame's marker. Kept in
|
|
53
|
+
* the interface so the CLI's construction is untouched. */
|
|
54
|
+
editCol: () => number;
|
|
55
|
+
/** v6: RETIRED — the compositor draws the chrome itself. Same. */
|
|
56
|
+
onDock?: () => void;
|
|
57
|
+
/** The stdout writer — injectable for unit tests (default: stdout). */
|
|
58
|
+
write?: (s: string) => void;
|
|
59
|
+
}
|
|
60
|
+
/** The one compositor — implements the Body façade AND the Dock chrome
|
|
61
|
+
* API (see the class comments on each method group). */
|
|
62
|
+
export declare class Body {
|
|
63
|
+
#private;
|
|
64
|
+
constructor(opts: BodyOptions);
|
|
65
|
+
userLine(text: string): void;
|
|
66
|
+
thinkingAppend(text: string): void;
|
|
67
|
+
thinkingEnd(): void;
|
|
68
|
+
toolStart(name: string, callId: string, input: Record<string, unknown>): void;
|
|
69
|
+
toolApproval(callId: string, diff: import("./diff.js").DiffResult | null): void;
|
|
70
|
+
toolRunning(callId: string): void;
|
|
71
|
+
toolSucceeded(callId: string): void;
|
|
72
|
+
toolFailed(callId: string, error: string): void;
|
|
73
|
+
toolResult(callId: string, result: {
|
|
74
|
+
content: string;
|
|
75
|
+
isError: boolean;
|
|
76
|
+
}): void;
|
|
77
|
+
textAppend(text: string): void;
|
|
78
|
+
textEnd(): void;
|
|
79
|
+
terminal(label: string, statusLineText: string): void;
|
|
80
|
+
notice(text: string): void;
|
|
81
|
+
checklist(header: string, items: {
|
|
82
|
+
text: string;
|
|
83
|
+
status: "pending" | "active" | "done";
|
|
84
|
+
}[]): void;
|
|
85
|
+
raw(lines: string[]): void;
|
|
86
|
+
/** The last COMPLETE thinking block, for /think. */
|
|
87
|
+
lastThinking(): string | null;
|
|
88
|
+
/** The last completed tool call, for /last. */
|
|
89
|
+
lastTool(): {
|
|
90
|
+
name: string;
|
|
91
|
+
input: Record<string, unknown>;
|
|
92
|
+
result: {
|
|
93
|
+
content: string;
|
|
94
|
+
isError: boolean;
|
|
95
|
+
};
|
|
96
|
+
} | null;
|
|
97
|
+
/** Docked = the chrome is live (a color TTY with a real size). */
|
|
98
|
+
get active(): boolean;
|
|
99
|
+
enter(): void;
|
|
100
|
+
/** Teardown — CSI r (the "no broken terminal" contract byte), the
|
|
101
|
+
* chrome rows cleared, the cursor home at the input line. */
|
|
102
|
+
exit(): void;
|
|
103
|
+
/** SIGWINCH: clear the OLD live area (recorded geometry, ED only —
|
|
104
|
+
* zero LF, zero \x1b[3J — the shell history untouched), then the
|
|
105
|
+
* full-redraw path at the NEW geometry (O(height), zero replay).
|
|
106
|
+
* The clear starts at the ON-SCREEN live top (the bottom-anchored
|
|
107
|
+
* live region's first row) — NEVER at the formula's committed-count
|
|
108
|
+
* top: external writes (the CLI's console.error CRLF) can shift the
|
|
109
|
+
* committed content down, and a formula-top ED0 would clear it. */
|
|
110
|
+
onResize(): void;
|
|
111
|
+
setStatus(text: string): void;
|
|
112
|
+
setTail(tail: string): void;
|
|
113
|
+
showQuestion(question: string): void;
|
|
114
|
+
clearQuestion(): void;
|
|
115
|
+
/** Bind the CURRENT input line's state — the focus component reads it. */
|
|
116
|
+
bindInput(state: () => {
|
|
117
|
+
line: string;
|
|
118
|
+
cursor: number;
|
|
119
|
+
}, prompt: string): void;
|
|
120
|
+
/** Bind the editor's slash-command menu state — the MenuSelect slot
|
|
121
|
+
* occupant (the menu replaces the editor's view while open). */
|
|
122
|
+
bindMenu(state: () => {
|
|
123
|
+
items: readonly MenuItem[];
|
|
124
|
+
selected: number;
|
|
125
|
+
} | null): void;
|
|
126
|
+
/** The input line's edit column — the old dock's API. v6: the CURSOR
|
|
127
|
+
* derives from the frame's marker; this is the same value computed
|
|
128
|
+
* from the bound input state (the CLI's BodyOptions.editCol callback
|
|
129
|
+
* reads it — the marker math never desyncs by construction). */
|
|
130
|
+
editCol(): number;
|
|
131
|
+
/** The old dock's redraw — the editor's onRender target: mark + the
|
|
132
|
+
* scheduler (16ms coalescing — the old sync draw coalesces the same). */
|
|
133
|
+
redraw(): void;
|
|
134
|
+
/** Teardown — flush a pending frame, stop the timers. */
|
|
135
|
+
close(): void;
|
|
136
|
+
/** The live region's scalar — the unit tests assert the cap directly
|
|
137
|
+
* (the e2e gate pins the screen consequence). */
|
|
138
|
+
liveCount(): number;
|
|
139
|
+
render(): void;
|
|
140
|
+
}
|
|
141
|
+
/** The Dock — the CLI's module-scope singleton façade. Every method
|
|
142
|
+
* delegates to the one compositor (registered at construction); the
|
|
143
|
+
* input/menu bindings, which the CLI performs BEFORE the Body exists,
|
|
144
|
+
* are buffered and applied by the Body's constructor. */
|
|
145
|
+
export declare class Dock {
|
|
146
|
+
#private;
|
|
147
|
+
get active(): boolean;
|
|
148
|
+
enter(): void;
|
|
149
|
+
exit(): void;
|
|
150
|
+
onResize(): void;
|
|
151
|
+
setStatus(text: string): void;
|
|
152
|
+
setTail(tail: string): void;
|
|
153
|
+
showQuestion(question: string): void;
|
|
154
|
+
clearQuestion(): void;
|
|
155
|
+
bindInput(state: () => {
|
|
156
|
+
line: string;
|
|
157
|
+
cursor: number;
|
|
158
|
+
}, prompt: string): void;
|
|
159
|
+
bindMenu(state: () => {
|
|
160
|
+
items: readonly MenuItem[];
|
|
161
|
+
selected: number;
|
|
162
|
+
} | null): void;
|
|
163
|
+
editCol(): number;
|
|
164
|
+
redraw(): void;
|
|
165
|
+
}
|