@vincemakes/kiso-tui 0.15.2 → 0.15.3
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/ask-panel.d.ts +0 -7
- package/dist/ask-panel.js +29 -6
- package/package.json +2 -2
- package/dist/body.d.ts +0 -151
- package/dist/body.js +0 -606
- package/dist/dock.d.ts +0 -76
- package/dist/dock.js +0 -199
package/dist/ask-panel.d.ts
CHANGED
|
@@ -53,13 +53,6 @@ export interface AskStep {
|
|
|
53
53
|
readonly state: AskRuntime;
|
|
54
54
|
readonly result?: AskResult;
|
|
55
55
|
}
|
|
56
|
-
/**
|
|
57
|
-
* The pure key reducer. `key` is a single logical key: a digit "1".."4",
|
|
58
|
-
* "space", "up"/"down", "left" (walk back), "enter", "t" (type your own),
|
|
59
|
-
* or "esc". The custom phase's TEXT is not routed here — the editor owns
|
|
60
|
-
* the buffer exactly as it does for the rule-input phase, and hands the
|
|
61
|
-
* committed line to `askCommitCustom`.
|
|
62
|
-
*/
|
|
63
56
|
export declare function askKey(spec: AskSpec, state: AskRuntime, key: string): AskStep;
|
|
64
57
|
/** The typed answer commits: it becomes THE answer for this question
|
|
65
58
|
* (clearing its picks) and the walk advances. An empty line is a
|
package/dist/ask-panel.js
CHANGED
|
@@ -103,6 +103,10 @@ function toggle(state, option, multi) {
|
|
|
103
103
|
* the buffer exactly as it does for the rule-input phase, and hands the
|
|
104
104
|
* committed line to `askCommitCustom`.
|
|
105
105
|
*/
|
|
106
|
+
/** REL-0152-D3 — the index of the type-your-own row: one past the last
|
|
107
|
+
* option, which is where `askBlockRows` draws it. Options and this row
|
|
108
|
+
* are one list to the eye, so they are one list to the cursor. */
|
|
109
|
+
const customRow = (q) => q.options.length;
|
|
106
110
|
export function askKey(spec, state, key) {
|
|
107
111
|
const q = spec.questions[state.qIndex];
|
|
108
112
|
const multi = q.multiSelect === true;
|
|
@@ -121,17 +125,29 @@ export function askKey(spec, state, key) {
|
|
|
121
125
|
return { state: state.qIndex === 0 ? state : { ...state, qIndex: state.qIndex - 1, cursor: 0 } };
|
|
122
126
|
if (key === "up")
|
|
123
127
|
return { state: { ...state, cursor: Math.max(0, state.cursor - 1) } };
|
|
128
|
+
// REL-0152-D3: the cursor range includes the type-your-own row, which
|
|
129
|
+
// askBlockRows renders as the list's last item. It used to stop one
|
|
130
|
+
// short, so a row the eye counts as fourth could not be reached by
|
|
131
|
+
// the key that walks the list — the affordance promised a list and
|
|
132
|
+
// delivered four of its five rows.
|
|
124
133
|
if (key === "down")
|
|
125
|
-
return { state: { ...state, cursor: Math.min(q
|
|
126
|
-
if (key === "enter")
|
|
134
|
+
return { state: { ...state, cursor: Math.min(customRow(q), state.cursor + 1) } };
|
|
135
|
+
if (key === "enter") {
|
|
136
|
+
// on the custom row, enter is the way in — the same gesture the
|
|
137
|
+
// row's neighbours answer with.
|
|
138
|
+
if (state.cursor === customRow(q))
|
|
139
|
+
return { state: { ...state, phase: "custom" } };
|
|
127
140
|
return answered(state, state.qIndex) ? advance(spec, state) : { state };
|
|
141
|
+
}
|
|
128
142
|
// SPACE selects at the cursor and NEVER commits — in either mode. It
|
|
129
143
|
// used to answer-and-advance a single-select question, which made a
|
|
130
144
|
// stray space (the most pressable key there is) an instant answer of
|
|
131
145
|
// whatever the cursor happened to be on. Enter and the digits are the
|
|
132
146
|
// only gestures that commit; space is how you point at something.
|
|
147
|
+
// space points at an option; on the custom row it opens the typing
|
|
148
|
+
// phase rather than toggling an option that is not there.
|
|
133
149
|
if (key === "space")
|
|
134
|
-
return { state: toggle(state, state.cursor, multi) };
|
|
150
|
+
return state.cursor === customRow(q) ? { state: { ...state, phase: "custom" } } : { state: toggle(state, state.cursor, multi) };
|
|
135
151
|
const digit = Number.parseInt(key, 10);
|
|
136
152
|
if (Number.isInteger(digit) && digit >= 1 && digit <= q.options.length) {
|
|
137
153
|
const next = toggle(state, digit - 1, multi);
|
|
@@ -181,10 +197,14 @@ export function askBlockRows(view, state, W, maxRows) {
|
|
|
181
197
|
rows.push(cutLine(`${p.dim}─ ${multi ? "pick any — space toggles" : "pick one"} ─${p.reset}`, Math.max(1, W - 2)));
|
|
182
198
|
const picks = state.picks[state.qIndex] ?? [];
|
|
183
199
|
const body = q.options.map((o, i) => `${gutter}${optionRow(o, i + 1, picks.includes(i), state.cursor === i, multi, W)}`);
|
|
200
|
+
// REL-0152-D3: the row is part of the list, so it carries the same
|
|
201
|
+
// cursor affordance the options do. Dim-always made a reachable row
|
|
202
|
+
// look like a footnote.
|
|
184
203
|
const typed = state.custom[state.qIndex];
|
|
204
|
+
const onCustom = state.cursor === customRow(q);
|
|
185
205
|
body.push(`${gutter}${cutLine(typed === null || typed === undefined
|
|
186
|
-
? `${p.dim} t type your own answer${p.reset}`
|
|
187
|
-
:
|
|
206
|
+
? `${onCustom ? p.bold : p.dim} t type your own answer${p.reset}`
|
|
207
|
+
: `${onCustom ? p.bold : ""} t ◉ ${escapeTerminal(typed)}${p.reset}`, Math.max(1, W - 2))}`);
|
|
188
208
|
// the bounded block: the options fold nothing and cut individually,
|
|
189
209
|
// so the cap drops whole rows with the W21 notice row.
|
|
190
210
|
const budget = Math.max(1, maxRows - 5);
|
|
@@ -222,7 +242,10 @@ export function askStatus(view, state) {
|
|
|
222
242
|
/** The input row's lead: the digit lead while picking, the typing lead
|
|
223
243
|
* in the custom phase (the rule-input phase's shape, reused). */
|
|
224
244
|
export function askLeadPlain(state) {
|
|
225
|
-
|
|
245
|
+
// REL-0152-D3: "1-4> " was hard-coded and wrong twice over — it named
|
|
246
|
+
// a range even when there were two options, and it excluded the
|
|
247
|
+
// type-your-own row the list shows.
|
|
248
|
+
return state.phase === "custom" ? "your answer: " : "pick> ";
|
|
226
249
|
}
|
|
227
250
|
// ── the dispatchers: the panel slot, with the ask branch folded in ────
|
|
228
251
|
export function panelBlockRows(view, phase, cursor, W, maxRows, ask, pick, note, safer) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-tui",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.3",
|
|
4
4
|
"description": "kiso tui \u2014 the pure terminal layer (cell renderer, dock, raw editor, diff, palette). Zero runtime dependencies: input is data, output is bytes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -35,6 +35,6 @@
|
|
|
35
35
|
},
|
|
36
36
|
"homepage": "https://github.com/vincemakes/kiso/tree/main/packages/tui#readme",
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@vincemakes/kiso-tui-cells": "0.15.
|
|
38
|
+
"@vincemakes/kiso-tui-cells": "0.15.3"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/dist/body.d.ts
DELETED
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* v2d — the body renderer: ONE writer for the stdout scroll region.
|
|
3
|
-
*
|
|
4
|
-
* The v2b/v2c body writes streamed directly (each event wrote its bytes),
|
|
5
|
-
* so the tool lines, thinking folds, and text deltas could interleave in
|
|
6
|
-
* the same frame — and a tool's life was scattered across several writes
|
|
7
|
-
* (the "leak"). v2d: every event handler ONLY mutates cell state; the
|
|
8
|
-
* Body's render loop is the only thing that writes the region.
|
|
9
|
-
*
|
|
10
|
-
* Frozen semantics: a completed cell prints its final form into the
|
|
11
|
-
* scroll region ONCE and is never touched again. The ACTIVE TAIL — the
|
|
12
|
-
* unfinished cells — renders at the region's bottom (between the frozen
|
|
13
|
-
* area and the dock) and redraws in place, CSI 2026 wrapped. State
|
|
14
|
-
* changes coalesce to ≥16ms frames; a 200ms heartbeat drives the running
|
|
15
|
-
* spinners and elapsed timers. An over-height tail (rare) overflows to
|
|
16
|
-
* freeze by completion order.
|
|
17
|
-
*
|
|
18
|
-
* Pipes / NO_COLOR: the Body runs in PASSTHROUGH — every mutation writes
|
|
19
|
-
* the v2b/v2c line-mode bytes immediately, byte-for-byte (the existing
|
|
20
|
-
* e2e guards it). The cell renderer never activates.
|
|
21
|
-
*
|
|
22
|
-
* The cell model is ours (UserCell / ThinkingCell / ToolCell / TextCell /
|
|
23
|
-
* NoticeCell / raw block) — deliberately NOT pi's Component interface
|
|
24
|
-
* shape (ADR-0040).
|
|
25
|
-
*/
|
|
26
|
-
/** One completed-or-active body line. The renderer's only state. */
|
|
27
|
-
export type BodyCell = {
|
|
28
|
-
kind: "user";
|
|
29
|
-
text: string;
|
|
30
|
-
done: true;
|
|
31
|
-
} | {
|
|
32
|
-
kind: "thinking";
|
|
33
|
-
text: string;
|
|
34
|
-
done: boolean;
|
|
35
|
-
} | {
|
|
36
|
-
kind: "tool";
|
|
37
|
-
name: string;
|
|
38
|
-
input: string;
|
|
39
|
-
state: "pending" | "approval" | "running" | "done";
|
|
40
|
-
isError: boolean;
|
|
41
|
-
resultText: string;
|
|
42
|
-
diff: import("./diff.js").DiffLine[] | null;
|
|
43
|
-
added: number;
|
|
44
|
-
removed: number;
|
|
45
|
-
startedAt: number | null;
|
|
46
|
-
doneAt: number | null;
|
|
47
|
-
done: boolean;
|
|
48
|
-
} | {
|
|
49
|
-
kind: "text";
|
|
50
|
-
text: string;
|
|
51
|
-
done: boolean;
|
|
52
|
-
} | {
|
|
53
|
-
kind: "notice";
|
|
54
|
-
text: string;
|
|
55
|
-
done: true;
|
|
56
|
-
} | {
|
|
57
|
-
kind: "raw";
|
|
58
|
-
lines: string[];
|
|
59
|
-
done: true;
|
|
60
|
-
} | {
|
|
61
|
-
kind: "terminal";
|
|
62
|
-
label: string;
|
|
63
|
-
line: string;
|
|
64
|
-
done: true;
|
|
65
|
-
} | {
|
|
66
|
-
kind: "checklist";
|
|
67
|
-
header: string;
|
|
68
|
-
items: {
|
|
69
|
-
text: string;
|
|
70
|
-
status: "pending" | "active" | "done";
|
|
71
|
-
}[];
|
|
72
|
-
done: true;
|
|
73
|
-
};
|
|
74
|
-
export interface BodyOptions {
|
|
75
|
-
/** Is the cell renderer live? A color TTY with a real size — checked
|
|
76
|
-
* per mutation (the TIOCSWINSZ can land after main constructs us). */
|
|
77
|
-
active: () => boolean;
|
|
78
|
-
/** The terminal height (rows) — live, for the region geometry. */
|
|
79
|
-
height: () => number;
|
|
80
|
-
/** The terminal width (cols) — live, for wrap estimates. */
|
|
81
|
-
width: () => number;
|
|
82
|
-
/** The input line's edit column — the render's cursor home. */
|
|
83
|
-
editCol: () => number;
|
|
84
|
-
/** The dock's redraw (the bottom three rows) — the body never writes
|
|
85
|
-
* below the region, but the frame may call it to re-pin the chrome. */
|
|
86
|
-
onDock?: () => void;
|
|
87
|
-
/** The stdout writer — injectable for unit tests (default: stdout). */
|
|
88
|
-
write?: (s: string) => void;
|
|
89
|
-
}
|
|
90
|
-
export declare class Body {
|
|
91
|
-
#private;
|
|
92
|
-
constructor(opts: BodyOptions);
|
|
93
|
-
/** Teardown — flush a pending frame, stop the timers. */
|
|
94
|
-
close(): void;
|
|
95
|
-
/**
|
|
96
|
-
* #17 (P1): a resize reflows the terminal's buffer — the old chrome
|
|
97
|
-
* rows SURVIVE the reflow at their shifted positions (the recorded
|
|
98
|
-
* separator wall + the tail ghost — the #16a assumption that the
|
|
99
|
-
* reflow erases them was wrong). The handler: (1) clear the old tail +
|
|
100
|
-
* dock area with the OLD geometry (one ED from the last-drawn tail
|
|
101
|
-
* top; EL/ED only, zero LF — the #16 storm gate's invariants hold);
|
|
102
|
-
* (2) redraw immediately at the NEW geometry (the tail, the cursor
|
|
103
|
-
* home, the dock — via the normal render). The frozen content is
|
|
104
|
-
* strictly ABOVE the old tail top — the clear never touches it (the
|
|
105
|
-
* frozen bytes stay emitted exactly once). Consecutive resizes are
|
|
106
|
-
* idempotent: the clear covers an already-clear area.
|
|
107
|
-
*/
|
|
108
|
-
onResize(): void;
|
|
109
|
-
/** The last COMPLETE thinking block, for /think. */
|
|
110
|
-
lastThinking(): string | null;
|
|
111
|
-
/** The last completed tool call, for /last. */
|
|
112
|
-
lastTool(): {
|
|
113
|
-
name: string;
|
|
114
|
-
input: Record<string, unknown>;
|
|
115
|
-
result: {
|
|
116
|
-
content: string;
|
|
117
|
-
isError: boolean;
|
|
118
|
-
};
|
|
119
|
-
} | null;
|
|
120
|
-
userLine(text: string): void;
|
|
121
|
-
thinkingAppend(text: string): void;
|
|
122
|
-
thinkingEnd(): void;
|
|
123
|
-
toolStart(name: string, callId: string, input: Record<string, unknown>): void;
|
|
124
|
-
toolApproval(callId: string, diff: import("./diff.js").DiffResult | null): void;
|
|
125
|
-
toolRunning(callId: string): void;
|
|
126
|
-
toolSucceeded(callId: string): void;
|
|
127
|
-
toolFailed(callId: string, error: string): void;
|
|
128
|
-
toolResult(callId: string, result: {
|
|
129
|
-
content: string;
|
|
130
|
-
isError: boolean;
|
|
131
|
-
}): void;
|
|
132
|
-
textAppend(text: string): void;
|
|
133
|
-
textEnd(): void;
|
|
134
|
-
/** The terminal's status line + the rhythm gap (one blank). */
|
|
135
|
-
terminal(label: string, statusLine: string): void;
|
|
136
|
-
notice(text: string): void;
|
|
137
|
-
/** ⑥ todo round: the durable checklist — header + one brick-glyph line
|
|
138
|
-
* per item, frozen immediately (it is static content). The CLI
|
|
139
|
-
* translates a tagged tool result into the structured items; the
|
|
140
|
-
* passthrough writes the same lines (byte-identical in pipes). */
|
|
141
|
-
checklist(header: string, items: {
|
|
142
|
-
text: string;
|
|
143
|
-
status: "pending" | "active" | "done";
|
|
144
|
-
}[]): void;
|
|
145
|
-
/** A pre-rendered block (the banner, the session line, slash-command
|
|
146
|
-
* outputs) — frozen immediately. */
|
|
147
|
-
raw(lines: string[]): void;
|
|
148
|
-
/** The one writer. Frozen cells print once; the tail redraws in place,
|
|
149
|
-
* CSI 2026 wrapped; the cursor lands at the input edit column. */
|
|
150
|
-
render(): void;
|
|
151
|
-
}
|
package/dist/body.js
DELETED
|
@@ -1,606 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* v2d — the body renderer: ONE writer for the stdout scroll region.
|
|
3
|
-
*
|
|
4
|
-
* The v2b/v2c body writes streamed directly (each event wrote its bytes),
|
|
5
|
-
* so the tool lines, thinking folds, and text deltas could interleave in
|
|
6
|
-
* the same frame — and a tool's life was scattered across several writes
|
|
7
|
-
* (the "leak"). v2d: every event handler ONLY mutates cell state; the
|
|
8
|
-
* Body's render loop is the only thing that writes the region.
|
|
9
|
-
*
|
|
10
|
-
* Frozen semantics: a completed cell prints its final form into the
|
|
11
|
-
* scroll region ONCE and is never touched again. The ACTIVE TAIL — the
|
|
12
|
-
* unfinished cells — renders at the region's bottom (between the frozen
|
|
13
|
-
* area and the dock) and redraws in place, CSI 2026 wrapped. State
|
|
14
|
-
* changes coalesce to ≥16ms frames; a 200ms heartbeat drives the running
|
|
15
|
-
* spinners and elapsed timers. An over-height tail (rare) overflows to
|
|
16
|
-
* freeze by completion order.
|
|
17
|
-
*
|
|
18
|
-
* Pipes / NO_COLOR: the Body runs in PASSTHROUGH — every mutation writes
|
|
19
|
-
* the v2b/v2c line-mode bytes immediately, byte-for-byte (the existing
|
|
20
|
-
* e2e guards it). The cell renderer never activates.
|
|
21
|
-
*
|
|
22
|
-
* The cell model is ours (UserCell / ThinkingCell / ToolCell / TextCell /
|
|
23
|
-
* NoticeCell / raw block) — deliberately NOT pi's Component interface
|
|
24
|
-
* shape (ADR-0040).
|
|
25
|
-
*/
|
|
26
|
-
import { truncateDiff } from "./diff.js";
|
|
27
|
-
import { displayWidth } from "./editor.js";
|
|
28
|
-
import { colorInlineCode, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, } from "./render.js";
|
|
29
|
-
/** The spinner glyphs, cycled by the heartbeat (v3 §05 — the working family). */
|
|
30
|
-
const SPINNER = ["▖", "▘", "▝", "▗"];
|
|
31
|
-
const TOOL_SUMMARY_MAX = 60; // the tool line's parameter summary, chars
|
|
32
|
-
const FRAME_MS = 16; // state changes coalesce to ≥16ms frames
|
|
33
|
-
const HEARTBEAT_MS = 200; // spinner / elapsed cadence
|
|
34
|
-
export class Body {
|
|
35
|
-
#active;
|
|
36
|
-
#opts;
|
|
37
|
-
#cells = [];
|
|
38
|
-
#nextFrozen = 0; // index of the first not-yet-printed cell
|
|
39
|
-
#height = 0; // the last DRAWN height — the resize handler clears with the OLD geometry
|
|
40
|
-
#oldTailTop = 0; // the last-drawn tail top — the clear pass + the resize handler's clear
|
|
41
|
-
#oldTailHeight = 0; // the last-drawn tail's height — the clear covers EXACTLY it (never the frozen area)
|
|
42
|
-
#frameTimer = null;
|
|
43
|
-
#heartbeat = null;
|
|
44
|
-
#dirty = false;
|
|
45
|
-
#spinnerI = 0;
|
|
46
|
-
#lastThinking = null;
|
|
47
|
-
#lastTool = null;
|
|
48
|
-
#pendingCalls = new Map();
|
|
49
|
-
#pipeBuf = ""; // the passthrough's thinking buffer — the cell model needs no buffer
|
|
50
|
-
#toolCells = new Map(); // callId → cell index (parallel tools)
|
|
51
|
-
#write;
|
|
52
|
-
#resizeHandler = null;
|
|
53
|
-
constructor(opts) {
|
|
54
|
-
this.#opts = opts;
|
|
55
|
-
this.#write = opts.write ?? ((s) => process.stdout.write(s));
|
|
56
|
-
this.#active = opts.active();
|
|
57
|
-
this.#height = opts.height();
|
|
58
|
-
if (this.#isActive()) {
|
|
59
|
-
// #17 (P1): the resize handler — clear the OLD tail + dock area
|
|
60
|
-
// (old geometry, EL/ED only — no LF), then redraw at the new
|
|
61
|
-
// geometry. The #16a assumption is retired: the terminal's
|
|
62
|
-
// reflow does NOT erase the old chrome rows (the recorded
|
|
63
|
-
// separator wall + the tail ghost prove it) — the clear does.
|
|
64
|
-
this.#resizeHandler = () => this.onResize();
|
|
65
|
-
process.stdout.on("resize", this.#resizeHandler);
|
|
66
|
-
this.#heartbeat = setInterval(() => {
|
|
67
|
-
// #14/#15: the idle heartbeat PAINTS NOTHING unless an
|
|
68
|
-
// ANIMATION advances — only a RUNNING tool's glyph/elapsed
|
|
69
|
-
// changes between beats. The #14 fix skipped an all-frozen
|
|
70
|
-
// body; #15 widened the skip to ANY no-change body: a cell
|
|
71
|
-
// that stays unfinished without animating (an unclosed text
|
|
72
|
-
// or thinking block) would otherwise re-paint the tail AND
|
|
73
|
-
// the dock every 200ms with zero change — the short-session
|
|
74
|
-
// leak (measured: 51KB / 46 beats after the recap, LF=0).
|
|
75
|
-
if (!this.#cells.some((c) => c.kind === "tool" && c.state === "running"))
|
|
76
|
-
return;
|
|
77
|
-
this.#spinnerI = (this.#spinnerI + 1) % SPINNER.length;
|
|
78
|
-
this.#dirty = true; // the running cells' glyph/elapsed advance
|
|
79
|
-
this.#scheduleFrame();
|
|
80
|
-
}, HEARTBEAT_MS);
|
|
81
|
-
this.#heartbeat.unref();
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
/** Live re-check — a TTY whose size lands after construction flips in. */
|
|
85
|
-
#isActive() {
|
|
86
|
-
this.#active = this.#opts.active();
|
|
87
|
-
return this.#active;
|
|
88
|
-
}
|
|
89
|
-
/** Teardown — flush a pending frame, stop the timers. */
|
|
90
|
-
close() {
|
|
91
|
-
if (this.#frameTimer !== null) {
|
|
92
|
-
clearTimeout(this.#frameTimer);
|
|
93
|
-
this.#frameTimer = null;
|
|
94
|
-
}
|
|
95
|
-
if (this.#heartbeat !== null) {
|
|
96
|
-
clearInterval(this.#heartbeat);
|
|
97
|
-
this.#heartbeat = null;
|
|
98
|
-
}
|
|
99
|
-
if (this.#resizeHandler !== null) {
|
|
100
|
-
process.stdout.off("resize", this.#resizeHandler);
|
|
101
|
-
this.#resizeHandler = null;
|
|
102
|
-
}
|
|
103
|
-
if (this.#dirty)
|
|
104
|
-
this.render();
|
|
105
|
-
}
|
|
106
|
-
/**
|
|
107
|
-
* #17 (P1): a resize reflows the terminal's buffer — the old chrome
|
|
108
|
-
* rows SURVIVE the reflow at their shifted positions (the recorded
|
|
109
|
-
* separator wall + the tail ghost — the #16a assumption that the
|
|
110
|
-
* reflow erases them was wrong). The handler: (1) clear the old tail +
|
|
111
|
-
* dock area with the OLD geometry (one ED from the last-drawn tail
|
|
112
|
-
* top; EL/ED only, zero LF — the #16 storm gate's invariants hold);
|
|
113
|
-
* (2) redraw immediately at the NEW geometry (the tail, the cursor
|
|
114
|
-
* home, the dock — via the normal render). The frozen content is
|
|
115
|
-
* strictly ABOVE the old tail top — the clear never touches it (the
|
|
116
|
-
* frozen bytes stay emitted exactly once). Consecutive resizes are
|
|
117
|
-
* idempotent: the clear covers an already-clear area.
|
|
118
|
-
*/
|
|
119
|
-
onResize() {
|
|
120
|
-
if (!this.#isActive())
|
|
121
|
-
return;
|
|
122
|
-
const from = this.#oldTailTop > 0 ? this.#oldTailTop : Math.max(1, this.#height - 3);
|
|
123
|
-
const H = this.#opts.height(); // the NEW height — the clamp for a shrunk screen
|
|
124
|
-
const out = [];
|
|
125
|
-
// The cursor home + the ED land with the NEW geometry — rows beyond
|
|
126
|
-
// the old screen are already gone; rows below the old tail top are
|
|
127
|
-
// exactly the tail + the dock areas (the old tail, the old chrome).
|
|
128
|
-
out.push(`\x1b[${Math.min(from, Math.max(1, H))};1H\x1b[0J`);
|
|
129
|
-
this.#write(out.join(""));
|
|
130
|
-
this.#dirty = true; // the immediate redraw at the NEW geometry
|
|
131
|
-
this.render();
|
|
132
|
-
}
|
|
133
|
-
/** The last COMPLETE thinking block, for /think. */
|
|
134
|
-
lastThinking() {
|
|
135
|
-
return this.#lastThinking;
|
|
136
|
-
}
|
|
137
|
-
/** The last completed tool call, for /last. */
|
|
138
|
-
lastTool() {
|
|
139
|
-
return this.#lastTool;
|
|
140
|
-
}
|
|
141
|
-
// ---- mutations (the ONLY way the CLI touches the body) ----
|
|
142
|
-
userLine(text) {
|
|
143
|
-
if (!this.#isActive()) {
|
|
144
|
-
this.#closeOpenThinking();
|
|
145
|
-
this.#closeOpenText();
|
|
146
|
-
const p = palette();
|
|
147
|
-
this.#write(`${p.bold}you> ${escapeTerminal(text)}${p.reset}\n`);
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
this.#closeOpenThinking();
|
|
151
|
-
this.#closeOpenText();
|
|
152
|
-
this.#cells.push({ kind: "user", text, done: true });
|
|
153
|
-
this.#mark();
|
|
154
|
-
}
|
|
155
|
-
thinkingAppend(text) {
|
|
156
|
-
if (!this.#isActive()) {
|
|
157
|
-
this.#pipeBuf += text; // buffered; the fold prints at the block's end
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
const last = this.#cells[this.#cells.length - 1];
|
|
161
|
-
if (last !== undefined && last.kind === "thinking" && !last.done) {
|
|
162
|
-
last.text += text;
|
|
163
|
-
}
|
|
164
|
-
else {
|
|
165
|
-
this.#cells.push({ kind: "thinking", text, done: false });
|
|
166
|
-
}
|
|
167
|
-
this.#mark();
|
|
168
|
-
}
|
|
169
|
-
thinkingEnd() {
|
|
170
|
-
const last = this.#cells[this.#cells.length - 1];
|
|
171
|
-
if (last !== undefined && last.kind === "thinking" && !last.done) {
|
|
172
|
-
last.done = true;
|
|
173
|
-
this.#lastThinking = last.text;
|
|
174
|
-
if (!this.#isActive())
|
|
175
|
-
process.stdout.write(foldThinking(last.text));
|
|
176
|
-
this.#mark();
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
toolStart(name, callId, input) {
|
|
180
|
-
const summary = JSON.stringify(input).slice(0, TOOL_SUMMARY_MAX);
|
|
181
|
-
// Registered BEFORE the passthrough branch — /last and the pipe
|
|
182
|
-
// summary need the call on BOTH paths.
|
|
183
|
-
this.#pendingCalls.set(callId, { name, input, result: { content: "", isError: false } });
|
|
184
|
-
if (!this.#isActive()) {
|
|
185
|
-
this.#closeOpenThinking();
|
|
186
|
-
this.#closeOpenText();
|
|
187
|
-
process.stdout.write(`→ ${escapeTerminal(name)}(${escapeTerminal(JSON.stringify(input).slice(0, 200))})\n`);
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
this.#toolCells.set(callId, this.#cells.length);
|
|
191
|
-
this.#cells.push({ kind: "tool", name, input: summary, state: "pending", isError: false, resultText: "", diff: null, added: 0, removed: 0, startedAt: null, doneAt: null, done: false });
|
|
192
|
-
this.#mark();
|
|
193
|
-
}
|
|
194
|
-
toolApproval(callId, diff) {
|
|
195
|
-
if (!this.#isActive())
|
|
196
|
-
return;
|
|
197
|
-
const cell = this.#toolCell(callId);
|
|
198
|
-
if (cell !== null && cell.kind === "tool" && !cell.done) {
|
|
199
|
-
cell.state = "approval";
|
|
200
|
-
// v2e: the mini-diff renders BELOW the tool line at the approval
|
|
201
|
-
// moment — the human sees the change before deciding. Auto-allowed
|
|
202
|
-
// tools pass null (nobody is looking — no diff, no cost).
|
|
203
|
-
cell.diff = diff === null ? null : truncateDiff(diff.lines);
|
|
204
|
-
cell.added = diff?.added ?? 0;
|
|
205
|
-
cell.removed = diff?.removed ?? 0;
|
|
206
|
-
}
|
|
207
|
-
this.#mark();
|
|
208
|
-
}
|
|
209
|
-
toolRunning(callId) {
|
|
210
|
-
if (!this.#isActive()) {
|
|
211
|
-
const p = palette();
|
|
212
|
-
process.stdout.write(`${p.dim} running…${p.reset}\n`);
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
const cell = this.#toolCell(callId);
|
|
216
|
-
if (cell !== null && cell.kind === "tool" && !cell.done) {
|
|
217
|
-
cell.state = "running";
|
|
218
|
-
cell.startedAt = Date.now();
|
|
219
|
-
}
|
|
220
|
-
this.#mark();
|
|
221
|
-
}
|
|
222
|
-
toolSucceeded(callId) {
|
|
223
|
-
if (!this.#isActive()) {
|
|
224
|
-
process.stdout.write(` ok\n`);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
toolFailed(callId, error) {
|
|
228
|
-
if (!this.#isActive()) {
|
|
229
|
-
const p = palette();
|
|
230
|
-
process.stdout.write(`${p.red} failed: ${escapeTerminal(error.slice(0, 160))}${p.reset}\n`);
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
toolResult(callId, result) {
|
|
234
|
-
const call = this.#pendingCalls.get(callId);
|
|
235
|
-
if (call !== undefined) {
|
|
236
|
-
call.result = result;
|
|
237
|
-
this.#lastTool = { name: call.name, input: call.input, result };
|
|
238
|
-
this.#pendingCalls.delete(callId);
|
|
239
|
-
}
|
|
240
|
-
if (!this.#isActive()) {
|
|
241
|
-
// The v2b/v2c pipe bytes: the summary line + the [result] line.
|
|
242
|
-
const p = palette();
|
|
243
|
-
process.stdout.write(`${renderToolSummary(call?.name ?? "?", call?.input ?? {}, result)}\n` +
|
|
244
|
-
`${p.dim}${result.isError ? p.red : p.dim} [result${result.isError ? " ✗" : ""}] ${foldResult(result.content)}${p.reset}\n`);
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
247
|
-
const cell = this.#toolCell(callId);
|
|
248
|
-
this.#toolCells.delete(callId);
|
|
249
|
-
if (cell !== null && cell.kind === "tool" && !cell.done) {
|
|
250
|
-
cell.state = "done";
|
|
251
|
-
cell.isError = result.isError;
|
|
252
|
-
cell.resultText = result.content;
|
|
253
|
-
cell.doneAt = Date.now();
|
|
254
|
-
cell.done = true;
|
|
255
|
-
}
|
|
256
|
-
this.#mark();
|
|
257
|
-
}
|
|
258
|
-
textAppend(text) {
|
|
259
|
-
if (!this.#isActive()) {
|
|
260
|
-
this.#closeOpenThinking();
|
|
261
|
-
this.#closeOpenText();
|
|
262
|
-
process.stdout.write(escapeTerminal(text));
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
const last = this.#cells[this.#cells.length - 1];
|
|
266
|
-
if (last !== undefined && last.kind === "text" && !last.done) {
|
|
267
|
-
last.text += text;
|
|
268
|
-
}
|
|
269
|
-
else {
|
|
270
|
-
this.#closeOpenThinking();
|
|
271
|
-
this.#closeOpenText();
|
|
272
|
-
this.#cells.push({ kind: "text", text, done: false });
|
|
273
|
-
}
|
|
274
|
-
this.#mark();
|
|
275
|
-
}
|
|
276
|
-
textEnd() {
|
|
277
|
-
if (!this.#isActive()) {
|
|
278
|
-
process.stdout.write("\n");
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
const last = this.#cells[this.#cells.length - 1];
|
|
282
|
-
if (last !== undefined && last.kind === "text" && !last.done)
|
|
283
|
-
last.done = true;
|
|
284
|
-
this.#mark();
|
|
285
|
-
}
|
|
286
|
-
/** The terminal's status line + the rhythm gap (one blank). */
|
|
287
|
-
terminal(label, statusLine) {
|
|
288
|
-
if (!this.#isActive()) {
|
|
289
|
-
this.#closeOpenThinking();
|
|
290
|
-
this.#closeOpenText();
|
|
291
|
-
// the v2c bytes: the terminal label (\ndone\n) + the status gap.
|
|
292
|
-
process.stdout.write(label + renderTerminalGap(statusLine));
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
this.#closeOpenThinking();
|
|
296
|
-
this.#closeOpenText();
|
|
297
|
-
this.#cells.push({ kind: "terminal", label: label.trim(), line: statusLine, done: true });
|
|
298
|
-
this.#mark();
|
|
299
|
-
}
|
|
300
|
-
notice(text) {
|
|
301
|
-
if (!this.#isActive()) {
|
|
302
|
-
this.#closeOpenThinking();
|
|
303
|
-
this.#closeOpenText();
|
|
304
|
-
process.stdout.write(`${text}\n`);
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
this.#closeOpenThinking();
|
|
308
|
-
this.#closeOpenText();
|
|
309
|
-
this.#cells.push({ kind: "notice", text, done: true });
|
|
310
|
-
this.#mark();
|
|
311
|
-
}
|
|
312
|
-
/** ⑥ todo round: the durable checklist — header + one brick-glyph line
|
|
313
|
-
* per item, frozen immediately (it is static content). The CLI
|
|
314
|
-
* translates a tagged tool result into the structured items; the
|
|
315
|
-
* passthrough writes the same lines (byte-identical in pipes). */
|
|
316
|
-
checklist(header, items) {
|
|
317
|
-
const glyphOf = (status) => (status === "pending" ? "□" : status === "active" ? "▖" : "▣");
|
|
318
|
-
if (!this.#isActive()) {
|
|
319
|
-
this.#closeOpenThinking();
|
|
320
|
-
this.#closeOpenText();
|
|
321
|
-
const p = palette();
|
|
322
|
-
process.stdout.write(`${p.bold}▞${p.reset} ${escapeTerminal(header)}\n`);
|
|
323
|
-
for (const item of items)
|
|
324
|
-
process.stdout.write(` ${glyphOf(item.status)} ${escapeTerminal(item.text)}\n`);
|
|
325
|
-
return;
|
|
326
|
-
}
|
|
327
|
-
this.#closeOpenThinking();
|
|
328
|
-
this.#closeOpenText();
|
|
329
|
-
this.#cells.push({ kind: "checklist", header, items, done: true });
|
|
330
|
-
this.#mark();
|
|
331
|
-
}
|
|
332
|
-
/** A pre-rendered block (the banner, the session line, slash-command
|
|
333
|
-
* outputs) — frozen immediately. */
|
|
334
|
-
raw(lines) {
|
|
335
|
-
if (!this.#isActive()) {
|
|
336
|
-
this.#closeOpenThinking();
|
|
337
|
-
this.#closeOpenText();
|
|
338
|
-
for (const line of lines)
|
|
339
|
-
process.stdout.write(`${line}\n`);
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
342
|
-
this.#closeOpenThinking();
|
|
343
|
-
this.#closeOpenText();
|
|
344
|
-
this.#cells.push({ kind: "raw", lines, done: true });
|
|
345
|
-
this.#mark();
|
|
346
|
-
}
|
|
347
|
-
// ---- rendering ----
|
|
348
|
-
#mark() {
|
|
349
|
-
if (!this.#isActive())
|
|
350
|
-
return;
|
|
351
|
-
this.#dirty = true;
|
|
352
|
-
this.#scheduleFrame();
|
|
353
|
-
}
|
|
354
|
-
#scheduleFrame() {
|
|
355
|
-
if (this.#frameTimer !== null)
|
|
356
|
-
return;
|
|
357
|
-
this.#frameTimer = setTimeout(() => {
|
|
358
|
-
this.#frameTimer = null;
|
|
359
|
-
if (this.#dirty) {
|
|
360
|
-
this.#dirty = false;
|
|
361
|
-
this.render();
|
|
362
|
-
}
|
|
363
|
-
}, FRAME_MS);
|
|
364
|
-
this.#frameTimer.unref();
|
|
365
|
-
}
|
|
366
|
-
/** The one writer. Frozen cells print once; the tail redraws in place,
|
|
367
|
-
* CSI 2026 wrapped; the cursor lands at the input edit column. */
|
|
368
|
-
render() {
|
|
369
|
-
if (!this.#isActive())
|
|
370
|
-
return;
|
|
371
|
-
const H = this.#opts.height();
|
|
372
|
-
const W = this.#opts.width();
|
|
373
|
-
if (H < 4)
|
|
374
|
-
return;
|
|
375
|
-
this.#height = H;
|
|
376
|
-
const out = [];
|
|
377
|
-
// #13 (P1), v2d-B: NO DECSTBM — a frozen line scrolls with a REAL
|
|
378
|
-
// LF at the screen's last row (\x1b[H;1H\n — the top line leaves
|
|
379
|
-
// into the NATIVE scrollback) and lands at the body's bottom row,
|
|
380
|
-
// just above the active tail. The dock is redrawn after.
|
|
381
|
-
// #17 (P1): the pre-fill phase is GONE — EVERY frozen line takes
|
|
382
|
-
// this REAL-LF path, short sessions included. The old pre-fill drew
|
|
383
|
-
// at absolute CUP rows with NO real LF: the terminal's resize
|
|
384
|
-
// reflow treated those rows as soft lines — the recorded fold/body
|
|
385
|
-
// MERGE (the /think suffix lost), the tail ghost, the separator
|
|
386
|
-
// wall. A real-LF line reflows as ONE logical line, never merged
|
|
387
|
-
// with a neighbor; the only soft lines left are the active tail +
|
|
388
|
-
// the dock (small, redrawn every frame / cleared by onResize).
|
|
389
|
-
// The tail (the remaining ACTIVE cells) and its geometry — computed
|
|
390
|
-
// FIRST from the final nextFrozen, so the frozen cells are NOT in it
|
|
391
|
-
// (a stale tail would re-draw them — the double-render).
|
|
392
|
-
let nextFrozen = this.#nextFrozen;
|
|
393
|
-
while (nextFrozen < this.#cells.length && this.#cells[nextFrozen].done)
|
|
394
|
-
nextFrozen += 1;
|
|
395
|
-
// #17: the tail holds ONLY the unfinished cells — slice(nextFrozen)
|
|
396
|
-
// also captures the DONE cells that follow (an approval verdict
|
|
397
|
-
// freezing behind a live text+tool), inflating tailHeight so the
|
|
398
|
-
// tail's top rises INTO the frozen area and its clear pass wipes
|
|
399
|
-
// the freshly-frozen lines (the fold's /think suffix — recorded).
|
|
400
|
-
const tail = this.#cells.slice(nextFrozen);
|
|
401
|
-
// #17: the tail HEIGHT counts only the unfinished cells — slice()
|
|
402
|
-
// also captures the DONE cells that follow (an approval verdict
|
|
403
|
-
// freezing behind a live text+tool); counting them inflates the
|
|
404
|
-
// height so the tail's top rises INTO the frozen area and its
|
|
405
|
-
// clear pass wipes the freshly-frozen lines (the fold's /think
|
|
406
|
-
// suffix — recorded). The tail array itself keeps the old shape.
|
|
407
|
-
const tailHeight = tail.reduce((n, c) => n + (c.done ? 0 : this.#cellHeight(c, W)), 0);
|
|
408
|
-
const tailTop = Math.max(1, H - 3 - tailHeight); // v3 §03: 4 dock rows below
|
|
409
|
-
const writeRow = Math.max(1, tailTop - 1); // the frozen area's bottom row
|
|
410
|
-
let scrolled = 0;
|
|
411
|
-
for (let i = this.#nextFrozen; i < nextFrozen; i += 1) {
|
|
412
|
-
for (const line of this.#cellLines(this.#cells[i], W)) {
|
|
413
|
-
out.push(`\x1b[${H};1H\n`); // the REAL LF — the whole screen scrolls
|
|
414
|
-
out.push(`\x1b[${writeRow};1H\x1b[0K${line}`);
|
|
415
|
-
scrolled += 1;
|
|
416
|
-
}
|
|
417
|
-
this.#nextFrozen += 1;
|
|
418
|
-
}
|
|
419
|
-
// 2. the active tail — clear EXACTLY its old area (shifted up by the
|
|
420
|
-
// freeze scrolls) and the current area, draw the cells at the body's
|
|
421
|
-
// bottom. #17: the old code cleared clearFrom..H-4 unconditionally —
|
|
422
|
-
// harmless when the frozen lines sat at the TOP (pre-fill), but with
|
|
423
|
-
// the real-LF commits the frozen lines land just ABOVE the tail, so
|
|
424
|
-
// an over-wide clear wipes the freshly-frozen cells (the recorded
|
|
425
|
-
// fold/response vanishing).
|
|
426
|
-
out.push("\x1b[?2026h");
|
|
427
|
-
const oldBottom = this.#oldTailHeight > 0 ? this.#oldTailTop + this.#oldTailHeight - 1 - scrolled : -1;
|
|
428
|
-
const newBottom = tailHeight > 0 ? tailTop + tailHeight - 1 : -1;
|
|
429
|
-
const clearFrom = Math.min(this.#oldTailTop === 0 ? tailTop : this.#oldTailTop - scrolled, tailTop);
|
|
430
|
-
const clearTo = Math.max(oldBottom, newBottom);
|
|
431
|
-
for (let row = clearFrom; row <= Math.min(clearTo, H - 4); row += 1) {
|
|
432
|
-
out.push(`\x1b[${row};1H\x1b[0K`);
|
|
433
|
-
}
|
|
434
|
-
let row = tailTop;
|
|
435
|
-
for (const cell of tail) {
|
|
436
|
-
for (const line of this.#cellLines(cell, W)) {
|
|
437
|
-
out.push(`\x1b[${row};1H${line}`);
|
|
438
|
-
row += 1;
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
this.#oldTailTop = tailTop; // the last-drawn tail top — the resize clear starts here
|
|
442
|
-
this.#oldTailHeight = tailHeight;
|
|
443
|
-
// 3. the cursor home — the input line's edit column.
|
|
444
|
-
out.push(`\x1b[${H};${this.#opts.editCol()}H`);
|
|
445
|
-
out.push("\x1b[?2026l");
|
|
446
|
-
this.#write(out.join(""));
|
|
447
|
-
// 4. the dock rows — the freeze scrolls shifted them; redraw (the
|
|
448
|
-
// dock's own redraw re-pins the cursor at the edit position).
|
|
449
|
-
this.#opts.onDock?.();
|
|
450
|
-
}
|
|
451
|
-
// ---- cell → lines ----
|
|
452
|
-
#cellLines(cell, W) {
|
|
453
|
-
const p = palette();
|
|
454
|
-
switch (cell.kind) {
|
|
455
|
-
case "user":
|
|
456
|
-
// v3 §02, TUI v5 #16f (v4.1 design): the user message is a left
|
|
457
|
-
// rail — a bright-white BOLD ▍ per line, then the text (the
|
|
458
|
-
// reverse-video block is RETIRED: it washed out on light
|
|
459
|
-
// themes). Multi-line whole: every line carries the rail
|
|
460
|
-
// (coherent across lines); resize-safe; NO_COLOR → the rail renders plain.
|
|
461
|
-
return cell.text.split("\n").map((l) => `${p.bold}▍${p.reset} ${escapeTerminal(l)}`);
|
|
462
|
-
case "thinking": {
|
|
463
|
-
const block = cell.text;
|
|
464
|
-
const trimmed = escapeTerminal(block.trim());
|
|
465
|
-
if (trimmed.length <= 100)
|
|
466
|
-
return [`${p.dim}…${trimmed}${p.reset}`];
|
|
467
|
-
const suffix = ` (${block.length} chars · /think)`;
|
|
468
|
-
// #17: the fold must FIT its row — every frozen line commits
|
|
469
|
-
// via the REAL-LF scroll path at the SAME write row; a
|
|
470
|
-
// soft-wrapped fold's continuation row would be clobbered by
|
|
471
|
-
// the next line's commit write (the /think suffix lost — the
|
|
472
|
-
// recorded symptom). The slice shrinks with the width; the
|
|
473
|
-
// suffix always rides the fold's own row.
|
|
474
|
-
const slice = Math.max(1, W - 1 - suffix.length);
|
|
475
|
-
return [`${p.dim}…${trimmed.slice(0, slice)}${suffix}${p.reset}`];
|
|
476
|
-
}
|
|
477
|
-
case "tool": {
|
|
478
|
-
const name = escapeTerminal(cell.name);
|
|
479
|
-
const summary = escapeTerminal(cell.input);
|
|
480
|
-
if (cell.state === "done") {
|
|
481
|
-
const elapsed = cell.startedAt !== null && cell.doneAt !== null ? ((cell.doneAt - cell.startedAt) / 1000).toFixed(1) : "?";
|
|
482
|
-
if (cell.isError) {
|
|
483
|
-
const err = escapeTerminal(cell.resultText.split("\n")[0].slice(0, 60));
|
|
484
|
-
return [`${p.red}✗ ${name} (${err}, ${elapsed}s)${p.reset}`];
|
|
485
|
-
}
|
|
486
|
-
const delta = cell.added + cell.removed > 0 ? `, +${cell.added} -${cell.removed}` : "";
|
|
487
|
-
return [`${p.bold}✓ ${name}${p.reset} (${summary}${delta}, ${elapsed}s)`];
|
|
488
|
-
}
|
|
489
|
-
if (cell.state === "approval") {
|
|
490
|
-
const lines = [`→ ${name} ${summary} ${p.bold}⏸${p.reset}`];
|
|
491
|
-
// v2e: the mini-diff — ▎ bold edge (the brick motif), - red /
|
|
492
|
-
// + green / context dim; NO_COLOR keeps the ± prefixes plain.
|
|
493
|
-
if (cell.diff !== null) {
|
|
494
|
-
for (const d of cell.diff) {
|
|
495
|
-
const body = d.kind === "-"
|
|
496
|
-
? `${p.red}- ${escapeTerminal(d.text)}${p.reset}`
|
|
497
|
-
: d.kind === "+"
|
|
498
|
-
? `${p.green}+ ${escapeTerminal(d.text)}${p.reset}`
|
|
499
|
-
: `${p.dim} ${escapeTerminal(d.text)}${p.reset}`;
|
|
500
|
-
lines.push(`${p.bold}▎${p.reset}${body}`);
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
return lines;
|
|
504
|
-
}
|
|
505
|
-
if (cell.state === "running") {
|
|
506
|
-
const elapsed = cell.startedAt !== null ? Math.max(1, Math.round((Date.now() - cell.startedAt) / 1000)) : 1;
|
|
507
|
-
return [`→ ${name} ${summary} ${p.bold}${SPINNER[this.#spinnerI % SPINNER.length]}${p.reset} ${elapsed}s`];
|
|
508
|
-
}
|
|
509
|
-
return [`→ ${name} ${summary}`];
|
|
510
|
-
}
|
|
511
|
-
case "text": {
|
|
512
|
-
const text = escapeTerminal(cell.text);
|
|
513
|
-
const wrapped = this.#wrap(text, W);
|
|
514
|
-
// TUI v5 #16e: the inline-code tint — backtick spans in
|
|
515
|
-
// assistant body text, matched PER LINE after the wrap (a
|
|
516
|
-
// span opened on one line and closed on another does NOT
|
|
517
|
-
// match — no cross-line matching). NO_COLOR → the codes are empty →
|
|
518
|
-
// byte-identical.
|
|
519
|
-
return wrapped.length > 0 ? wrapped.map((l) => colorInlineCode(l)) : [""];
|
|
520
|
-
}
|
|
521
|
-
case "notice":
|
|
522
|
-
return [escapeTerminal(cell.text)];
|
|
523
|
-
case "raw":
|
|
524
|
-
// TUI v4 #16b: the raw cell carries the CLI's OWN pre-rendered
|
|
525
|
-
// lines (the banner, the recap, slash-command output) — the
|
|
526
|
-
// SGR is applied at COMPOSITION time (renderRecap/startupBanner),
|
|
527
|
-
// and model/tool content was already escapeTerminal'd there.
|
|
528
|
-
// Re-escaping at render STRIPPED the ESC from the SGR — the
|
|
529
|
-
// literal "[38;5;75m▞[0m" garbage the user saw (the #16 mojibake,
|
|
530
|
-
// also the banner's dim). Verbatim: the injection guard lives
|
|
531
|
-
// at composition, not here.
|
|
532
|
-
return cell.lines;
|
|
533
|
-
case "terminal":
|
|
534
|
-
// the honest label (done / aborted / error) + the status + the
|
|
535
|
-
// rhythm gap blank
|
|
536
|
-
return [cell.label, cell.line, ""];
|
|
537
|
-
case "checklist": {
|
|
538
|
-
// ⑥: the durable checklist — the ▞ header accent + one brick
|
|
539
|
-
// glyph per item (□ pending / ▖ active / ▣ done). Text is
|
|
540
|
-
// escaped at composition; the glyphs are renderer-owned.
|
|
541
|
-
const lines = [`${p.bold}▞${p.reset} ${escapeTerminal(cell.header)}`];
|
|
542
|
-
for (const item of cell.items) {
|
|
543
|
-
const glyph = item.status === "pending" ? "□" : item.status === "active" ? "▖" : "▣";
|
|
544
|
-
lines.push(` ${glyph} ${escapeTerminal(item.text)}`);
|
|
545
|
-
}
|
|
546
|
-
return lines;
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
#cellHeight(cell, W) {
|
|
551
|
-
const lines = this.#cellLines(cell, W);
|
|
552
|
-
return Math.max(1, lines.length);
|
|
553
|
-
}
|
|
554
|
-
/** Wrap a body text by display width (the terminal's own wrapping,
|
|
555
|
-
* approximated — documented; the region clamp keeps the dock safe). */
|
|
556
|
-
#wrap(text, W) {
|
|
557
|
-
if (W < 4)
|
|
558
|
-
return [text];
|
|
559
|
-
const out = [];
|
|
560
|
-
let current = "";
|
|
561
|
-
let width = 0;
|
|
562
|
-
for (const ch of text) {
|
|
563
|
-
const cw = displayWidth(ch);
|
|
564
|
-
if (ch === "\n" || width + cw > W) {
|
|
565
|
-
out.push(current);
|
|
566
|
-
current = "";
|
|
567
|
-
width = 0;
|
|
568
|
-
if (ch === "\n")
|
|
569
|
-
continue;
|
|
570
|
-
}
|
|
571
|
-
current += ch;
|
|
572
|
-
width += cw;
|
|
573
|
-
}
|
|
574
|
-
out.push(current);
|
|
575
|
-
return out;
|
|
576
|
-
}
|
|
577
|
-
#toolCell(callId) {
|
|
578
|
-
const i = this.#toolCells.get(callId);
|
|
579
|
-
return i === undefined ? null : (this.#cells[i] ?? null);
|
|
580
|
-
}
|
|
581
|
-
/** Close an open TEXT cell when a new cell starts — the runtime emits
|
|
582
|
-
* no text_end (it is an adapter-level event), so the stream's next
|
|
583
|
-
* cell is the close signal; without it the freeze blocks behind the
|
|
584
|
-
* open text and everything after it re-renders in the tail forever
|
|
585
|
-
* (the #13 flood reproduced the overwrite). */
|
|
586
|
-
#closeOpenText() {
|
|
587
|
-
const last = this.#cells[this.#cells.length - 1];
|
|
588
|
-
if (last !== undefined && last.kind === "text" && !last.done)
|
|
589
|
-
last.done = true;
|
|
590
|
-
}
|
|
591
|
-
/** Close an open thinking cell when a new cell starts (the block's
|
|
592
|
-
* fold freezes at the transition). */
|
|
593
|
-
#closeOpenThinking() {
|
|
594
|
-
if (!this.#isActive() && this.#pipeBuf !== "") {
|
|
595
|
-
this.#lastThinking = this.#pipeBuf;
|
|
596
|
-
process.stdout.write(foldThinking(this.#pipeBuf));
|
|
597
|
-
this.#pipeBuf = "";
|
|
598
|
-
return;
|
|
599
|
-
}
|
|
600
|
-
const last = this.#cells[this.#cells.length - 1];
|
|
601
|
-
if (last !== undefined && last.kind === "thinking" && !last.done) {
|
|
602
|
-
last.done = true;
|
|
603
|
-
this.#lastThinking = last.text;
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
}
|
package/dist/dock.d.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* v2b/v2c — the bottom-anchored UI (TTY + color only). Borrows pi-tui's
|
|
3
|
-
* IDEA — a DECSTBM scroll region with a reserved bottom — without its
|
|
4
|
-
* implementation: zero dependencies, line-level ANSI, no differential
|
|
5
|
-
* renderer.
|
|
6
|
-
*
|
|
7
|
-
* Layout (H = terminal height): rows 1..H-4 = the scroll region (the body
|
|
8
|
-
* streams and scrolls here, never touching the bottom), row H-3 = the
|
|
9
|
-
* upper dim dotted separator (╌), row H-2 = the input line (the blue
|
|
10
|
-
* brick ▌you> + the v2c editor's row — readline is gone from the TTY
|
|
11
|
-
* path), row H-1 = the lower dotted separator, row H = the live status
|
|
12
|
-
* bar (v3 §03: idle "▸ <mode> · /mode to switch · …", running
|
|
13
|
-
* "▖ working Ns · …"; a takeover question replaces it). Bottom redraws
|
|
14
|
-
* are wrapped in CSI 2026 (synchronized output) to avoid flicker — the
|
|
15
|
-
* pi trick. The visual identity is the kiso brick motif — ▌ half-block,
|
|
16
|
-
* dotted separators — deliberately NOT the CC rounded frame nor the pi
|
|
17
|
-
* editor (ADR-0039 Amendment 2).
|
|
18
|
-
*
|
|
19
|
-
* Pipes / NO_COLOR: the dock never activates; the v2a line mode stays
|
|
20
|
-
* byte-for-byte (the existing e2e assertions guard it).
|
|
21
|
-
*/
|
|
22
|
-
export declare class Dock {
|
|
23
|
-
#private;
|
|
24
|
-
/** v2b: docked only on a color TTY — pipes and NO_COLOR stay v2a. */
|
|
25
|
-
get active(): boolean;
|
|
26
|
-
/** Bind the CURRENT input line's state — called when a readline takes
|
|
27
|
-
* over (the chat REPL, the trust question's short-lived rl, resume). */
|
|
28
|
-
bindInput(state: () => {
|
|
29
|
-
line: string;
|
|
30
|
-
cursor: number;
|
|
31
|
-
}, prompt: string): void;
|
|
32
|
-
/** Enter docked mode: draw the chrome. #13 (P1): the DECSTBM scroll
|
|
33
|
-
* region is GONE — v2d-B (ADR-0040): the body uses plain LF scrolling
|
|
34
|
-
* so frozen lines enter the native scrollback deterministically
|
|
35
|
-
* (region-scrolled lines are terminal-dependent — some terminals drop
|
|
36
|
-
* them). The dock rows are redrawn by the body after every scroll. A
|
|
37
|
-
* TTY without a real window size (rows < 4) stays in the v2a line
|
|
38
|
-
* mode — the bottom three rows need room to exist. */
|
|
39
|
-
enter(): void;
|
|
40
|
-
/** Teardown — CSI r resets the scroll region, the cursor lands at the
|
|
41
|
-
* input line, the bottom rows are cleared: no broken terminal. Called
|
|
42
|
-
* from main's finally on EVERY exit path (kill -9 excepted — README:
|
|
43
|
-
* `reset` saves it). */
|
|
44
|
-
exit(): void;
|
|
45
|
-
/** SIGWINCH: recompute the size, redraw the chrome. */
|
|
46
|
-
onResize(): void;
|
|
47
|
-
/** v3 §04: bind the editor's slash-command menu state — the menu rows
|
|
48
|
-
* render ABOVE the chrome (over the body's bottom rows; the menu
|
|
49
|
-
* opens while the buffer is a "/" prefix, when no tail is live). */
|
|
50
|
-
bindMenu(state: () => {
|
|
51
|
-
items: readonly import("./editor.js").MenuItem[];
|
|
52
|
-
selected: number;
|
|
53
|
-
} | null): void;
|
|
54
|
-
/** The input line's edit column — prompt width + cursor + 1. The
|
|
55
|
-
* dock's redraw and the body's cursor return both end here, so the
|
|
56
|
-
* ACTUAL cursor always equals what the editor tracks. The width is
|
|
57
|
-
* DISPLAY width (the editor's cursor column is already width-based —
|
|
58
|
-
* the CJK drift root cause, editor.ts). v2d: public — the Body's
|
|
59
|
-
* render loop ends at this column. */
|
|
60
|
-
editCol(): number;
|
|
61
|
-
/** The status bar's base text (usage, ctx, session, …). */
|
|
62
|
-
setStatus(text: string): void;
|
|
63
|
-
/** The live tail — the spinner glyph or "running <tool> Ns". */
|
|
64
|
-
setTail(tail: string): void;
|
|
65
|
-
/** Show a takeover question at the status position (answered at the
|
|
66
|
-
* input line by the caller's readline); clearQuestion() restores. */
|
|
67
|
-
showQuestion(question: string): void;
|
|
68
|
-
clearQuestion(): void;
|
|
69
|
-
/** The bottom four rows, wrapped in CSI 2026 (synchronized output —
|
|
70
|
-
* the pi trick against flicker). The cursor ends at the input line's
|
|
71
|
-
* edit position. v3 §03: the upper ╌ row, the input row, the lower
|
|
72
|
-
* ╌ row, the status row — the status is dim (bold accents inside
|
|
73
|
-
* come from the CLI's composition). TUI v5 #16g: the idle status
|
|
74
|
-
* row carries the right-aligned "/ commands · ↑ history" hint. */
|
|
75
|
-
redraw(): void;
|
|
76
|
-
}
|
package/dist/dock.js
DELETED
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* v2b/v2c — the bottom-anchored UI (TTY + color only). Borrows pi-tui's
|
|
3
|
-
* IDEA — a DECSTBM scroll region with a reserved bottom — without its
|
|
4
|
-
* implementation: zero dependencies, line-level ANSI, no differential
|
|
5
|
-
* renderer.
|
|
6
|
-
*
|
|
7
|
-
* Layout (H = terminal height): rows 1..H-4 = the scroll region (the body
|
|
8
|
-
* streams and scrolls here, never touching the bottom), row H-3 = the
|
|
9
|
-
* upper dim dotted separator (╌), row H-2 = the input line (the blue
|
|
10
|
-
* brick ▌you> + the v2c editor's row — readline is gone from the TTY
|
|
11
|
-
* path), row H-1 = the lower dotted separator, row H = the live status
|
|
12
|
-
* bar (v3 §03: idle "▸ <mode> · /mode to switch · …", running
|
|
13
|
-
* "▖ working Ns · …"; a takeover question replaces it). Bottom redraws
|
|
14
|
-
* are wrapped in CSI 2026 (synchronized output) to avoid flicker — the
|
|
15
|
-
* pi trick. The visual identity is the kiso brick motif — ▌ half-block,
|
|
16
|
-
* dotted separators — deliberately NOT the CC rounded frame nor the pi
|
|
17
|
-
* editor (ADR-0039 Amendment 2).
|
|
18
|
-
*
|
|
19
|
-
* Pipes / NO_COLOR: the dock never activates; the v2a line mode stays
|
|
20
|
-
* byte-for-byte (the existing e2e assertions guard it).
|
|
21
|
-
*/
|
|
22
|
-
import { displayWidth } from "./editor.js";
|
|
23
|
-
import { palette } from "./render.js";
|
|
24
|
-
export class Dock {
|
|
25
|
-
#active = false;
|
|
26
|
-
#height = 0;
|
|
27
|
-
#width = 0;
|
|
28
|
-
#status = "";
|
|
29
|
-
#tail = ""; // the live tail: the spinner glyph or "running <tool> Ns"
|
|
30
|
-
#question = null; // a takeover question shown at H-1
|
|
31
|
-
#inputState = () => ({ line: "", cursor: 0 });
|
|
32
|
-
#inputPrompt = "";
|
|
33
|
-
#bodyRow = 1; // the body's logical row inside the scroll region
|
|
34
|
-
#bodyCol = 1; // …and column (mid-line continuations survive cursor jumps)
|
|
35
|
-
#resizeHandler = null;
|
|
36
|
-
/** v2b: docked only on a color TTY — pipes and NO_COLOR stay v2a. */
|
|
37
|
-
get active() {
|
|
38
|
-
return this.#active;
|
|
39
|
-
}
|
|
40
|
-
/** Bind the CURRENT input line's state — called when a readline takes
|
|
41
|
-
* over (the chat REPL, the trust question's short-lived rl, resume). */
|
|
42
|
-
bindInput(state, prompt) {
|
|
43
|
-
this.#inputState = state;
|
|
44
|
-
this.#inputPrompt = prompt;
|
|
45
|
-
}
|
|
46
|
-
/** Enter docked mode: draw the chrome. #13 (P1): the DECSTBM scroll
|
|
47
|
-
* region is GONE — v2d-B (ADR-0040): the body uses plain LF scrolling
|
|
48
|
-
* so frozen lines enter the native scrollback deterministically
|
|
49
|
-
* (region-scrolled lines are terminal-dependent — some terminals drop
|
|
50
|
-
* them). The dock rows are redrawn by the body after every scroll. A
|
|
51
|
-
* TTY without a real window size (rows < 4) stays in the v2a line
|
|
52
|
-
* mode — the bottom three rows need room to exist. */
|
|
53
|
-
enter() {
|
|
54
|
-
const rows = process.stdout.rows ?? 0;
|
|
55
|
-
if (process.stdout.isTTY !== true || palette().bold === "" || rows < 4)
|
|
56
|
-
return;
|
|
57
|
-
this.#active = true;
|
|
58
|
-
this.#height = rows;
|
|
59
|
-
this.#width = process.stdout.columns ?? 80;
|
|
60
|
-
this.#bodyRow = 1;
|
|
61
|
-
this.#bodyCol = 1;
|
|
62
|
-
this.redraw();
|
|
63
|
-
this.#resizeHandler = () => this.onResize();
|
|
64
|
-
process.stdout.on("resize", this.#resizeHandler);
|
|
65
|
-
}
|
|
66
|
-
/** Teardown — CSI r resets the scroll region, the cursor lands at the
|
|
67
|
-
* input line, the bottom rows are cleared: no broken terminal. Called
|
|
68
|
-
* from main's finally on EVERY exit path (kill -9 excepted — README:
|
|
69
|
-
* `reset` saves it). */
|
|
70
|
-
exit() {
|
|
71
|
-
if (!this.#active)
|
|
72
|
-
return;
|
|
73
|
-
this.#active = false;
|
|
74
|
-
if (this.#resizeHandler !== null) {
|
|
75
|
-
process.stdout.off("resize", this.#resizeHandler);
|
|
76
|
-
this.#resizeHandler = null;
|
|
77
|
-
}
|
|
78
|
-
const H = this.#height;
|
|
79
|
-
process.stdout.write("\x1b[r"); // reset the scroll region
|
|
80
|
-
for (let row = H - 3; row <= H; row += 1) {
|
|
81
|
-
process.stdout.write(`\x1b[${row};1H\x1b[0K`); // clear the four rows
|
|
82
|
-
}
|
|
83
|
-
process.stdout.write(`\x1b[${H};1H`);
|
|
84
|
-
}
|
|
85
|
-
/** SIGWINCH: recompute the size, redraw the chrome. */
|
|
86
|
-
onResize() {
|
|
87
|
-
if (!this.#active)
|
|
88
|
-
return;
|
|
89
|
-
this.#height = process.stdout.rows ?? this.#height;
|
|
90
|
-
this.#width = process.stdout.columns ?? this.#width;
|
|
91
|
-
this.redraw();
|
|
92
|
-
}
|
|
93
|
-
#menuState = null;
|
|
94
|
-
/** v3 §04: bind the editor's slash-command menu state — the menu rows
|
|
95
|
-
* render ABOVE the chrome (over the body's bottom rows; the menu
|
|
96
|
-
* opens while the buffer is a "/" prefix, when no tail is live). */
|
|
97
|
-
bindMenu(state) {
|
|
98
|
-
this.#menuState = state;
|
|
99
|
-
}
|
|
100
|
-
/** The input line's edit column — prompt width + cursor + 1. The
|
|
101
|
-
* dock's redraw and the body's cursor return both end here, so the
|
|
102
|
-
* ACTUAL cursor always equals what the editor tracks. The width is
|
|
103
|
-
* DISPLAY width (the editor's cursor column is already width-based —
|
|
104
|
-
* the CJK drift root cause, editor.ts). v2d: public — the Body's
|
|
105
|
-
* render loop ends at this column. */
|
|
106
|
-
editCol() {
|
|
107
|
-
return this.#inputCol();
|
|
108
|
-
}
|
|
109
|
-
#inputCol() {
|
|
110
|
-
const inp = this.#inputState();
|
|
111
|
-
const promptWidth = displayWidth(this.#inputPrompt.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
112
|
-
return promptWidth + inp.cursor + 1;
|
|
113
|
-
}
|
|
114
|
-
/** The status bar's base text (usage, ctx, session, …). */
|
|
115
|
-
setStatus(text) {
|
|
116
|
-
this.#status = text;
|
|
117
|
-
this.redraw();
|
|
118
|
-
}
|
|
119
|
-
/** The live tail — the spinner glyph or "running <tool> Ns". */
|
|
120
|
-
setTail(tail) {
|
|
121
|
-
this.#tail = tail;
|
|
122
|
-
this.redraw();
|
|
123
|
-
}
|
|
124
|
-
/** Show a takeover question at the status position (answered at the
|
|
125
|
-
* input line by the caller's readline); clearQuestion() restores. */
|
|
126
|
-
showQuestion(question) {
|
|
127
|
-
this.#question = question;
|
|
128
|
-
this.redraw();
|
|
129
|
-
}
|
|
130
|
-
clearQuestion() {
|
|
131
|
-
this.#question = null;
|
|
132
|
-
this.redraw();
|
|
133
|
-
}
|
|
134
|
-
/** The bottom four rows, wrapped in CSI 2026 (synchronized output —
|
|
135
|
-
* the pi trick against flicker). The cursor ends at the input line's
|
|
136
|
-
* edit position. v3 §03: the upper ╌ row, the input row, the lower
|
|
137
|
-
* ╌ row, the status row — the status is dim (bold accents inside
|
|
138
|
-
* come from the CLI's composition). TUI v5 #16g: the idle status
|
|
139
|
-
* row carries the right-aligned "/ commands · ↑ history" hint. */
|
|
140
|
-
redraw() {
|
|
141
|
-
if (!this.#active)
|
|
142
|
-
return;
|
|
143
|
-
const p = palette();
|
|
144
|
-
// #17 (P1): read the LIVE size, not the cache — the body's resize
|
|
145
|
-
// render calls onDock (this redraw) BEFORE this dock's own resize
|
|
146
|
-
// handler runs, so the cached geometry would draw the chrome at
|
|
147
|
-
// stale rows (clamped into the body — the separator residue wall).
|
|
148
|
-
// The live read makes the handler order irrelevant; the cache keeps
|
|
149
|
-
// serving exit().
|
|
150
|
-
const H = process.stdout.rows ?? this.#height;
|
|
151
|
-
const W = process.stdout.columns ?? this.#width;
|
|
152
|
-
const sep = `${p.dim}${"╌".repeat(W)}${p.reset}`;
|
|
153
|
-
const status = `${this.#status}${this.#tail === "" ? "" : ` · ${this.#tail}`}`;
|
|
154
|
-
const statusLine = this.#question ?? this.#statusRow(status, p, W);
|
|
155
|
-
const inp = this.#inputState();
|
|
156
|
-
const out = [];
|
|
157
|
-
// P3 (review): the DEC private-mode SET/RESET needs the "?" prefix —
|
|
158
|
-
// \x1b[?2026h/l, the pi source's exact form. Without it terminals
|
|
159
|
-
// silently ignore the mode and the anti-flicker never engages.
|
|
160
|
-
out.push("\x1b[?2026h"); // synchronized output ON (DEC 2026)
|
|
161
|
-
// v3 §04: the slash-command menu — above the chrome, one row per
|
|
162
|
-
// filtered command, the selection highlighted. Drawn first so the
|
|
163
|
-
// chrome rows repaint on top of any overlap.
|
|
164
|
-
const menu = this.#menuState?.();
|
|
165
|
-
if (menu !== null && menu !== undefined) {
|
|
166
|
-
for (let i = 0; i < menu.items.length; i += 1) {
|
|
167
|
-
const item = menu.items[i];
|
|
168
|
-
const row = H - 4 - (menu.items.length - 1 - i);
|
|
169
|
-
const text = i === menu.selected
|
|
170
|
-
? `${p.bold}▸ ${item.name}${p.reset} ${item.desc}`
|
|
171
|
-
: `${p.dim} ${item.name} ${item.desc}${p.reset}`;
|
|
172
|
-
out.push(`\x1b[${row};1H\x1b[0K${text}`);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
out.push(`\x1b[${H - 3};1H\x1b[0K${sep}`);
|
|
176
|
-
out.push(`\x1b[${H - 2};1H\x1b[0K${this.#inputPrompt}${inp.line}`);
|
|
177
|
-
out.push(`\x1b[${H - 1};1H\x1b[0K${sep}`);
|
|
178
|
-
out.push(`\x1b[${H};1H\x1b[0K${statusLine}`);
|
|
179
|
-
out.push(`\x1b[${H - 2};${this.#inputCol()}H`); // back to the edit position
|
|
180
|
-
out.push("\x1b[?2026l"); // synchronized output OFF
|
|
181
|
-
process.stdout.write(out.join(""));
|
|
182
|
-
}
|
|
183
|
-
/** TUI v5 #16g: the status row — the base status left-aligned, the
|
|
184
|
-
* "/ commands · ↑ history" hint right-aligned in the idle state
|
|
185
|
-
* (tail empty, no takeover question). The hint is CUT FIRST when
|
|
186
|
-
* the width is short — the status itself is never truncated for it;
|
|
187
|
-
* the running state carries its own esc hint in the status text, so
|
|
188
|
-
* the non-empty tail suppresses this one. */
|
|
189
|
-
#statusRow(status, p, W) {
|
|
190
|
-
const hint = this.#tail === "" && this.#question === null ? " / commands · ↑ history" : "";
|
|
191
|
-
if (hint === "")
|
|
192
|
-
return `${p.dim}${status}${p.reset}`;
|
|
193
|
-
const statusW = displayWidth(status.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
194
|
-
const hintW = displayWidth(hint);
|
|
195
|
-
if (statusW + hintW > W)
|
|
196
|
-
return `${p.dim}${status}${p.reset}`;
|
|
197
|
-
return `${p.dim}${status}${" ".repeat(W - statusW - hintW)}${hint}${p.reset}`;
|
|
198
|
-
}
|
|
199
|
-
}
|