@vincemakes/kiso-tui 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ask-panel.js +9 -1
- package/dist/at-picker.d.ts +4 -0
- package/dist/at-picker.js +41 -8
- package/dist/compositor.d.ts +20 -1
- package/dist/compositor.js +224 -29
- package/dist/context-ledger.d.ts +66 -0
- package/dist/context-ledger.js +87 -0
- package/dist/editor.d.ts +3 -0
- package/dist/editor.js +35 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +8 -3
- package/dist/status.d.ts +32 -2
- package/dist/status.js +24 -3
- package/package.json +2 -2
package/dist/ask-panel.js
CHANGED
|
@@ -197,7 +197,15 @@ export function askBlockRows(view, state, W, maxRows) {
|
|
|
197
197
|
rows.push(...body);
|
|
198
198
|
}
|
|
199
199
|
rows.push(`${gutter}${p.dim}${askAffordance(state)}${p.reset}`);
|
|
200
|
-
|
|
200
|
+
// TUI2-R1.5 11 (VD-13), shared with the approval panel: a real bottom RULE, in the block's own edge
|
|
201
|
+
// vocabulary — the same box-drawing run its divider already uses —
|
|
202
|
+
// anchored at the gutter column. It used to be `\u2514 `: a two-cell stub
|
|
203
|
+
// floating at column 1, with no rule running from it and no corner
|
|
204
|
+
// above it to answer. Worse, `\u2514 ` is the cut-notice prefix everywhere
|
|
205
|
+
// else in the product, so a CAPPED panel emitted two elbow rows in a
|
|
206
|
+
// row meaning entirely different things. The rule reads as an edge,
|
|
207
|
+
// and the cut notice above it reads as a notice.
|
|
208
|
+
rows.push(`${p.dim}\u2514${"\u2500".repeat(Math.max(0, W - 1))}${p.reset}`);
|
|
201
209
|
return rows;
|
|
202
210
|
}
|
|
203
211
|
/** The status row's right-hand hint — the phase's keys. */
|
package/dist/at-picker.d.ts
CHANGED
|
@@ -157,3 +157,7 @@ export declare function atPanelRows(state: {
|
|
|
157
157
|
selected: number;
|
|
158
158
|
capped: boolean;
|
|
159
159
|
}, W: number): string[];
|
|
160
|
+
/** TUI2-R1.5 ⑦(b) — the one-row dim header that turns a band into a
|
|
161
|
+
* surface. Shared by the @ picker and the / menu so the two read the
|
|
162
|
+
* same way. */
|
|
163
|
+
export declare function bandHeader(label: string, W: number): string;
|
package/dist/at-picker.js
CHANGED
|
@@ -182,20 +182,41 @@ function splitPath(path) {
|
|
|
182
182
|
export function atRow(match, selected, W) {
|
|
183
183
|
const p = palette();
|
|
184
184
|
const { dir, name } = splitPath(escapeTerminal(match.path));
|
|
185
|
-
const lead = selected ? `${p.rv}→ ${p.rvEnd}` : " ";
|
|
186
185
|
// the name's own matched positions, mapped out of the full path
|
|
187
186
|
const marks = new Set(match.hit.filter((i) => i >= dir.length).map((i) => i - dir.length));
|
|
188
|
-
//
|
|
189
|
-
|
|
187
|
+
// TUI2-R1.5 ⑧ (VD-9): the directory rides NEXT TO the name it
|
|
188
|
+
// qualifies. It used to be right-aligned to the band's far edge, which
|
|
189
|
+
// on a 100-column terminal put `src/` some eighty columns from the
|
|
190
|
+
// `parser.ts` it belongs to: the eye had to cross the whole row to
|
|
191
|
+
// learn which parser.ts this was, and the column read as a second list.
|
|
192
|
+
// Adjacent and dim, it is what it always meant to be — a qualifier.
|
|
193
|
+
// the name is the flexible column and the qualifier gives way first: a
|
|
194
|
+
// narrow window keeps the thing being aimed at.
|
|
195
|
+
const room = Math.max(1, W - 2);
|
|
196
|
+
const suffix = dir === "" ? "" : widthCut(` — ${dir}`, Math.max(0, room - 4));
|
|
197
|
+
const nameRoom = Math.max(1, room - visibleWidth(suffix));
|
|
190
198
|
const shownName = widthCut(name, nameRoom);
|
|
191
199
|
let painted = "";
|
|
192
200
|
for (let i = 0; i < shownName.length; i += 1) {
|
|
193
201
|
painted += marks.has(i) ? `${p.bold}${shownName[i]}${p.reset}` : shownName[i];
|
|
194
202
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
203
|
+
const text = `${painted}${suffix === "" ? "" : `${p.dim}${suffix}${p.reset}`}`;
|
|
204
|
+
const width = visibleWidth(shownName) + visibleWidth(suffix);
|
|
205
|
+
if (!selected)
|
|
206
|
+
return ` ${text}`;
|
|
207
|
+
// TUI2-R1.5 ⑧ (VD-9): the selection is a FULL-ROW bar — the W16 chip
|
|
208
|
+
// mechanism, which the user chip and the turn fold already use. A
|
|
209
|
+
// two-cell `→ ` marker on the inverse band is one character of
|
|
210
|
+
// highlight in an eighty-column row, and the walkthrough could barely
|
|
211
|
+
// find it. The bar spans the row's whole width so the selection is
|
|
212
|
+
// visible from anywhere on the line. Mono discipline: reverse video,
|
|
213
|
+
// no new colours.
|
|
214
|
+
//
|
|
215
|
+
// The inner spans close with rvEnd (SGR 27), never SGR 0 — a reset
|
|
216
|
+
// inside the bar would punch a hole in it. `painted`'s bold marks and
|
|
217
|
+
// the dim suffix both end in SGR 0, so the bar is re-opened after each.
|
|
218
|
+
const inner = `${painted.replaceAll(p.reset, `${p.reset}${p.rv}`)}${suffix === "" ? "" : `${p.dim}${suffix}${p.reset}${p.rv}`}`;
|
|
219
|
+
return `${p.rv} ${inner}${" ".repeat(Math.max(0, W - width - 2))} ${p.rvEnd}`;
|
|
199
220
|
}
|
|
200
221
|
/**
|
|
201
222
|
* KC3 §4 — the counter row: `(n/total)`, where n is the 1-based
|
|
@@ -220,9 +241,21 @@ export function atCounterRow(selected, total, capped, W) {
|
|
|
220
241
|
*/
|
|
221
242
|
export function atPanelRows(state, W) {
|
|
222
243
|
const { first, count } = atWindow(state.matches.length, state.selected);
|
|
223
|
-
|
|
244
|
+
// TUI2-R1.5 ⑦(b) (VD-8): the band NAMES itself. It renders frameless
|
|
245
|
+
// directly above the composer, so with scrollback behind it there was
|
|
246
|
+
// nothing to say where the surface began — the rows read as more
|
|
247
|
+
// history. One dim row is enough to make it UI. Mono discipline: dim
|
|
248
|
+
// text, no rule, no colour.
|
|
249
|
+
const rows = [bandHeader("files", W)];
|
|
224
250
|
for (let i = first; i < first + count; i += 1)
|
|
225
251
|
rows.push(atRow(state.matches[i], i === state.selected, W));
|
|
226
252
|
rows.push(atCounterRow(state.selected, state.matches.length, state.capped, W));
|
|
227
253
|
return rows;
|
|
228
254
|
}
|
|
255
|
+
/** TUI2-R1.5 ⑦(b) — the one-row dim header that turns a band into a
|
|
256
|
+
* surface. Shared by the @ picker and the / menu so the two read the
|
|
257
|
+
* same way. */
|
|
258
|
+
export function bandHeader(label, W) {
|
|
259
|
+
const p = palette();
|
|
260
|
+
return `${p.dim}${widthCut(label, Math.max(1, W))}${p.reset}`;
|
|
261
|
+
}
|
package/dist/compositor.d.ts
CHANGED
|
@@ -109,6 +109,20 @@ export declare class Body {
|
|
|
109
109
|
* outcome. */
|
|
110
110
|
toolVerdict(callId: string, decision: "approved" | "denied", decidedBy?: string, reason?: string): void;
|
|
111
111
|
toolRunning(callId: string): void;
|
|
112
|
+
/**
|
|
113
|
+
* TUI2-R1 (C) — the RUNNING call's observed output.
|
|
114
|
+
*
|
|
115
|
+
* The CLI tails the shell tool's progress sidecar and hands what it
|
|
116
|
+
* read to the cell. Deliberately narrow: only a cell that is still
|
|
117
|
+
* RUNNING accepts it, so an observation can never overwrite a real
|
|
118
|
+
* result, and an unchanged read costs no frame at all (a poller
|
|
119
|
+
* fires far more often than the output changes).
|
|
120
|
+
*
|
|
121
|
+
* This adds no event and no durable state. The text lands in the
|
|
122
|
+
* cell's live rendering and is replaced wholesale by the tool's own
|
|
123
|
+
* result at settle — which is the only text anything else ever reads.
|
|
124
|
+
*/
|
|
125
|
+
toolProgress(callId: string, text: string): void;
|
|
112
126
|
toolSucceeded(callId: string): void;
|
|
113
127
|
toolFailed(callId: string, error: string): void;
|
|
114
128
|
toolResult(callId: string, result: {
|
|
@@ -152,7 +166,7 @@ export declare class Body {
|
|
|
152
166
|
* frame. The inactive path keeps the historical bytes (no resume —
|
|
153
167
|
* the pipe contract). */
|
|
154
168
|
banner(version: string, extensionsText: string, resume?: ResumeMeta[]): void;
|
|
155
|
-
raw(lines: string[]): void;
|
|
169
|
+
raw(lines: string[], wrap?: "words"): void;
|
|
156
170
|
/** The last COMPLETE thinking block, for /think. */
|
|
157
171
|
lastThinking(): string | null;
|
|
158
172
|
/** The last completed tool call, for /last. */
|
|
@@ -208,6 +222,8 @@ export declare class Body {
|
|
|
208
222
|
bindInput(state: () => InputState, prompt: string): void;
|
|
209
223
|
/** Bind the editor's slash-command menu state — the MenuSelect slot
|
|
210
224
|
* occupant (the menu replaces the editor's view while open). */
|
|
225
|
+
/** TUI2-R1 (D): bind the editor's keys-sheet flag. */
|
|
226
|
+
bindSheet(state: () => boolean): void;
|
|
211
227
|
bindMenu(state: () => {
|
|
212
228
|
items: readonly MenuItem[];
|
|
213
229
|
selected: number;
|
|
@@ -252,6 +268,9 @@ export declare class Dock {
|
|
|
252
268
|
* occupant (the panel replaces the live region + the input lead
|
|
253
269
|
* while up; the old ApprovalPrompt's question slot retires). */
|
|
254
270
|
bindApproval(state: () => PanelState | null): void;
|
|
271
|
+
/** TUI2-R1 (D): bind the editor's keys-sheet flag — the slot read for
|
|
272
|
+
* the ? overlay (the menu/picker binding pattern). */
|
|
273
|
+
bindSheet(state: () => boolean): void;
|
|
255
274
|
bindInput(state: () => InputState, prompt: string): void;
|
|
256
275
|
bindMenu(state: () => {
|
|
257
276
|
items: readonly MenuItem[];
|
package/dist/compositor.js
CHANGED
|
@@ -48,9 +48,10 @@ import { leadWidth } from "./width.js"; // W23: the ONE width authority (the edi
|
|
|
48
48
|
// KC3.5: the panel-slot reads come from the DISPATCHERS — one source
|
|
49
49
|
// for four reads, so an ask can never render half as an approval.
|
|
50
50
|
import { panelAffordanceOf, panelLeadOf, panelRowsOf, panelStatusOf } from "./ask-panel.js";
|
|
51
|
-
import { atPanelRows } from "./at-picker.js";
|
|
52
|
-
import { Container, ROLLUP_NOUN, SPINNER, bodySpacing, boxBottom, boxTop, cellComponent, foldLine, pendingQueueRows, statusLine, turnFold, visibleWidth, } from "./components.js";
|
|
51
|
+
import { atPanelRows, bandHeader } from "./at-picker.js";
|
|
52
|
+
import { Container, ROLLUP_NOUN, SPINNER, bodySpacing, boxBottom, boxTop, cellComponent, exploreCounts, exploreRows, foldLine, isExploreTool, pendingQueueRows, statusLine, turnFold, visibleWidth, } from "./components.js";
|
|
53
53
|
import { bannerLines, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, toolTarget } from "./render.js";
|
|
54
|
+
import { keysSheetRows } from "./strings.js";
|
|
54
55
|
/** The cursor marker — an APC private sequence the focus component
|
|
55
56
|
* embeds at the edit position; the compositor strips it and moves
|
|
56
57
|
* relatively (it never reaches the terminal). */
|
|
@@ -118,6 +119,15 @@ export class Body {
|
|
|
118
119
|
// panel is up it replaces the live region, owns the input lead, and
|
|
119
120
|
// derives the status row (the CLI's painting status yields).
|
|
120
121
|
#panelState = null;
|
|
122
|
+
/** TUI2-R1 (D): the keys sheet's slot read — the editor's boolean.
|
|
123
|
+
* Unbound, the sheet cannot render and every frame is byte-identical
|
|
124
|
+
* to before the round. */
|
|
125
|
+
#sheetState = null;
|
|
126
|
+
/** TUI2-R1.5 7(a): the sheet's previous up/down state — a transition
|
|
127
|
+
* in either direction takes the full-redraw path. */
|
|
128
|
+
#sheetWasUp = false;
|
|
129
|
+
/** This frame is an overlay open or close — it must not scroll. */
|
|
130
|
+
#overlayFrame = false;
|
|
121
131
|
#inputState = () => ({ line: "", cursor: 0 });
|
|
122
132
|
#inputPrompt = "";
|
|
123
133
|
#menuState = null;
|
|
@@ -153,6 +163,7 @@ export class Body {
|
|
|
153
163
|
if (dockBindings.at !== null)
|
|
154
164
|
this.#atState = dockBindings.at;
|
|
155
165
|
this.#panelState = dockBindings.panel;
|
|
166
|
+
this.#sheetState = dockBindings.sheet;
|
|
156
167
|
if (dockBindings.queue !== null)
|
|
157
168
|
this.#queueState = dockBindings.queue;
|
|
158
169
|
}
|
|
@@ -212,6 +223,19 @@ export class Body {
|
|
|
212
223
|
this.#write(`→ ${escapeTerminal(name)}(${escapeTerminal(JSON.stringify(input).slice(0, 200))})\n`);
|
|
213
224
|
return;
|
|
214
225
|
}
|
|
226
|
+
// TUI2-R1.5 ① (VD-1): the tool's start CLOSES an open text block —
|
|
227
|
+
// the inactive path above has always done this; the active path
|
|
228
|
+
// forgot, and the consequence was structural. textAppend only ever
|
|
229
|
+
// grows the LAST cell, so a text block with a tool cell after it can
|
|
230
|
+
// never receive another byte: it is finished in fact while its
|
|
231
|
+
// `done` flag says otherwise. The commit loop takes leading DONE
|
|
232
|
+
// cells, so that one stale flag parked the whole rest of the turn
|
|
233
|
+
// behind it — every tool cell then reached the screen through the
|
|
234
|
+
// FORCE-commit path, which by design bypasses the fold-hold. That is
|
|
235
|
+
// why the walkthrough saw nine individual rows: not a fold that
|
|
236
|
+
// declined to form, a fold that was never consulted.
|
|
237
|
+
this.#closeOpenThinking();
|
|
238
|
+
this.#closeOpenText();
|
|
215
239
|
// W12: the cell carries the delegate's child roles from the FULL
|
|
216
240
|
// input — the display summary is sliced at 60 chars (unparseable);
|
|
217
241
|
// the roles are the only running-state data the parent holds (there
|
|
@@ -282,6 +306,30 @@ export class Body {
|
|
|
282
306
|
}
|
|
283
307
|
this.#mark();
|
|
284
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* TUI2-R1 (C) — the RUNNING call's observed output.
|
|
311
|
+
*
|
|
312
|
+
* The CLI tails the shell tool's progress sidecar and hands what it
|
|
313
|
+
* read to the cell. Deliberately narrow: only a cell that is still
|
|
314
|
+
* RUNNING accepts it, so an observation can never overwrite a real
|
|
315
|
+
* result, and an unchanged read costs no frame at all (a poller
|
|
316
|
+
* fires far more often than the output changes).
|
|
317
|
+
*
|
|
318
|
+
* This adds no event and no durable state. The text lands in the
|
|
319
|
+
* cell's live rendering and is replaced wholesale by the tool's own
|
|
320
|
+
* result at settle — which is the only text anything else ever reads.
|
|
321
|
+
*/
|
|
322
|
+
toolProgress(callId, text) {
|
|
323
|
+
if (!this.#isActive())
|
|
324
|
+
return; // the pipe path has no live region
|
|
325
|
+
const cell = this.#toolCell(callId);
|
|
326
|
+
if (cell === null || cell.kind !== "tool" || cell.state !== "running" || cell.done)
|
|
327
|
+
return;
|
|
328
|
+
if (cell.resultText === text)
|
|
329
|
+
return;
|
|
330
|
+
cell.resultText = text;
|
|
331
|
+
this.#mark();
|
|
332
|
+
}
|
|
285
333
|
toolSucceeded(callId) {
|
|
286
334
|
if (!this.#isActive())
|
|
287
335
|
this.#write(" ok\n");
|
|
@@ -492,7 +540,7 @@ export class Body {
|
|
|
492
540
|
this.#cells.push({ kind: "banner", version, extensionsText, resume, done: true });
|
|
493
541
|
this.#mark();
|
|
494
542
|
}
|
|
495
|
-
raw(lines) {
|
|
543
|
+
raw(lines, wrap) {
|
|
496
544
|
if (!this.#isActive()) {
|
|
497
545
|
this.#closeOpenThinking();
|
|
498
546
|
this.#closeOpenText();
|
|
@@ -502,7 +550,7 @@ export class Body {
|
|
|
502
550
|
}
|
|
503
551
|
this.#closeOpenThinking();
|
|
504
552
|
this.#closeOpenText();
|
|
505
|
-
this.#cells.push({ kind: "raw", lines, done: true });
|
|
553
|
+
this.#cells.push({ kind: "raw", lines, done: true, ...(wrap === undefined ? {} : { wrap }) });
|
|
506
554
|
this.#mark();
|
|
507
555
|
}
|
|
508
556
|
/** The last COMPLETE thinking block, for /think. */
|
|
@@ -555,8 +603,16 @@ export class Body {
|
|
|
555
603
|
// land as NEW content, history is never rewritten, ADR-0046).
|
|
556
604
|
const turnsBack = this.#cells.slice(idx + 1).filter((c) => c.kind === "user").length;
|
|
557
605
|
const p = palette();
|
|
606
|
+
const back = `${turnsBack} ${turnsBack === 1 ? "turn" : "turns"} back`;
|
|
607
|
+
// TUI2-R1 (B): an EXPLORATION head lists per TOOL — the counts
|
|
608
|
+
// the row showed, then one row per tool with its subjects. The
|
|
609
|
+
// header keeps W15's shape; only the subject changes.
|
|
610
|
+
if (cell.rolled.parts !== undefined) {
|
|
611
|
+
const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`explored ${exploreCounts(cell.rolled.parts)}`)} · ${back}`;
|
|
612
|
+
return { kind: "appended", lines: [header, ...exploreRows(cell.rolled.parts, this.#opts.width())] };
|
|
613
|
+
}
|
|
558
614
|
const noun = ROLLUP_NOUN[cell.name] ?? "calls";
|
|
559
|
-
const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`${cell.name.replace("_file", "")} ${cell.rolled.count} ${noun}`)} · ${
|
|
615
|
+
const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`${cell.name.replace("_file", "")} ${cell.rolled.count} ${noun}`)} · ${back}`;
|
|
560
616
|
return {
|
|
561
617
|
kind: "appended",
|
|
562
618
|
lines: [header, ...cell.rolled.targets.map((t) => ` ${p.dim}└ ${escapeTerminal(t)}${p.reset}`)],
|
|
@@ -680,6 +736,11 @@ export class Body {
|
|
|
680
736
|
}
|
|
681
737
|
/** Bind the editor's slash-command menu state — the MenuSelect slot
|
|
682
738
|
* occupant (the menu replaces the editor's view while open). */
|
|
739
|
+
/** TUI2-R1 (D): bind the editor's keys-sheet flag. */
|
|
740
|
+
bindSheet(state) {
|
|
741
|
+
this.#sheetState = state;
|
|
742
|
+
this.#mark();
|
|
743
|
+
}
|
|
683
744
|
bindMenu(state) {
|
|
684
745
|
this.#menuState = state;
|
|
685
746
|
}
|
|
@@ -772,10 +833,20 @@ export class Body {
|
|
|
772
833
|
* screen rows), threaded against the previous sibling's OWN rows. */
|
|
773
834
|
liveCount() {
|
|
774
835
|
const panel = this.#panelState?.() ?? null;
|
|
836
|
+
const sheet = this.#sheetState?.() === true;
|
|
775
837
|
const queueRows = this.#queueRows(this.#opts.width(), this.#opts.height());
|
|
776
838
|
// KC1 §6: the composer's extra rows are chrome too — the scalar
|
|
777
839
|
// counts them exactly like the menu/queue bands (N = 1 ⇒ +0)
|
|
778
840
|
const inputExtra = this.#inputRows(this.#opts.width(), this.#opts.height(), this.#menuRows(this.#opts.width()).length, queueRows.length).rows.length - 1;
|
|
841
|
+
// TUI2-R1 (D): the sheet occupies the live region, exactly like the
|
|
842
|
+
// panel — the scalar must say so, or the cap arithmetic disagrees
|
|
843
|
+
// with the screen.
|
|
844
|
+
if (sheet) {
|
|
845
|
+
return (keysSheetRows(this.#opts.width()).slice(0, Math.max(1, this.#opts.height() - 4 - inputExtra - queueRows.length)).length +
|
|
846
|
+
CHROME_ROWS +
|
|
847
|
+
inputExtra +
|
|
848
|
+
queueRows.length);
|
|
849
|
+
}
|
|
779
850
|
if (panel !== null) {
|
|
780
851
|
// W21: the panel's own rows (the cap is exact — the scalar
|
|
781
852
|
// reflects the screen). W22: the queue chips occupy their
|
|
@@ -861,7 +932,26 @@ export class Body {
|
|
|
861
932
|
const chromeRows = CHROME_ROWS + inputExtra + menuRows.length + queueRows.length;
|
|
862
933
|
let liveLines = [];
|
|
863
934
|
const panel = this.#panelState?.() ?? null;
|
|
864
|
-
|
|
935
|
+
// TUI2-R1.5 ⑦(a) (VD-8): the sheet is an OVERLAY, and the frame it
|
|
936
|
+
// opens on — and the one it closes on — take the full-redraw path.
|
|
937
|
+
// The sheet REPLACES the live region, so on an idle composer (where
|
|
938
|
+
// the live region is empty) opening it GROWS the model by its own
|
|
939
|
+
// height; the frame's skip grows with it and the difference is paid
|
|
940
|
+
// in real LFs — rows scrolled permanently into the terminal's
|
|
941
|
+
// scrollback, which closing cannot undo, because the scrollback is
|
|
942
|
+
// not ours to rewrite. Measured: three rows per open on a full
|
|
943
|
+
// screen. The overlay below displaces content on screen instead.
|
|
944
|
+
const sheetUp = this.#sheetState?.() === true;
|
|
945
|
+
this.#overlayFrame = sheetUp || this.#sheetWasUp;
|
|
946
|
+
this.#sheetWasUp = sheetUp;
|
|
947
|
+
if (sheetUp) {
|
|
948
|
+
// TUI2-R1 (D): the sheet REPLACES the live region — the same
|
|
949
|
+
// slot the panel uses, for the same reason (it is what the
|
|
950
|
+
// human is reading right now). It cannot coexist with a panel:
|
|
951
|
+
// the editor only opens it from an idle composer.
|
|
952
|
+
liveLines = keysSheetRows(W).slice(0, Math.max(1, H - 4 - inputExtra - queueRows.length));
|
|
953
|
+
}
|
|
954
|
+
else if (panel !== null) {
|
|
865
955
|
// W21: the panel REPLACES the running tool's live window — the
|
|
866
956
|
// bounded block, capped at H−4 (the panel IS the live region;
|
|
867
957
|
// the W11 blank would separate it from the frozen content).
|
|
@@ -940,14 +1030,17 @@ export class Body {
|
|
|
940
1030
|
#commitCell(i, W, ctx) {
|
|
941
1031
|
const cell = this.#cells[i];
|
|
942
1032
|
const lines = this.#foldOrRollup(cell, i, W, ctx);
|
|
943
|
-
// W15: a tool cell whose
|
|
944
|
-
// affordance
|
|
945
|
-
//
|
|
946
|
-
//
|
|
1033
|
+
// W15: a tool cell whose committed rows carried the "ctrl+r"
|
|
1034
|
+
// affordance joins the expand history — the detection is the
|
|
1035
|
+
// renderer's OWN output, so the read's "/last"-only cut note never
|
|
1036
|
+
// lands here. TUI2-R1 (A/B): the affordance is no longer only the
|
|
1037
|
+
// renderer cut's "└ … ctrl+r" — the self-naming head suffix and
|
|
1038
|
+
// the exploration row carry it on the HEAD row, and a promise the
|
|
1039
|
+
// key does not answer would be the one thing worse than silence.
|
|
947
1040
|
// unshift: the cells commit oldest-first, so the NEWEST cut lands
|
|
948
1041
|
// at the front — the expand pointer's "newest back" walk starts
|
|
949
1042
|
// where the user's last key press would aim.
|
|
950
|
-
if (cell.kind === "tool" &&
|
|
1043
|
+
if (cell.kind === "tool" && lines.some((l) => l.includes("ctrl+r")))
|
|
951
1044
|
this.#collapsed.unshift(i);
|
|
952
1045
|
this.#lineCache[i] = lines;
|
|
953
1046
|
const placed = bodySpacing(i > 0 ? this.#lineCache[i - 1] : null, lines);
|
|
@@ -967,7 +1060,51 @@ export class Body {
|
|
|
967
1060
|
const turn = cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
|
|
968
1061
|
if (turn === undefined || turn !== this.#turns[this.#turns.length - 1])
|
|
969
1062
|
return false;
|
|
970
|
-
|
|
1063
|
+
if (!turn.ended && !turn.hasText)
|
|
1064
|
+
return true;
|
|
1065
|
+
// the turn's END releases every hold — the settle is where the run
|
|
1066
|
+
// is decided, and a held cell at settle would never commit at all.
|
|
1067
|
+
if (turn.ended)
|
|
1068
|
+
return false;
|
|
1069
|
+
return this.#growingRun(i);
|
|
1070
|
+
}
|
|
1071
|
+
/** TUI2-R1.5 ① (VD-1) — the explore-run hold. W14's hold covers the
|
|
1072
|
+
* QUIET turn only, and the model's own narration ("let me look at the
|
|
1073
|
+
* parser area") sets hasText before the first read even starts: from
|
|
1074
|
+
* there each completion committed in its OWN frame, the head committed
|
|
1075
|
+
* alone, and `members.every(done)` — the fold's gate — could never be
|
|
1076
|
+
* true again. Every real session therefore degraded to one row per
|
|
1077
|
+
* call while the unit suite, which feeds the burst synchronously,
|
|
1078
|
+
* stayed green (the walkthrough's frame s1-06).
|
|
1079
|
+
*
|
|
1080
|
+
* The hold is the smallest honest fix: a DONE explore cell whose run
|
|
1081
|
+
* can still GROW does not commit yet — its committed form is not
|
|
1082
|
+
* decided until the run is closed. The run closes at the first
|
|
1083
|
+
* non-explore cell (the model's next word, an edit, a shell) or at the
|
|
1084
|
+
* turn's end, and the whole run then commits in ONE frame, which is
|
|
1085
|
+
* exactly the shape the fold was written for.
|
|
1086
|
+
*
|
|
1087
|
+
* The force-commit path never consults this (see #held's callers): the
|
|
1088
|
+
* screen's hard cap still wins, so the screen never sticks — a run
|
|
1089
|
+
* under real screen pressure degrades mid-turn, and the rows it
|
|
1090
|
+
* already froze stay frozen (history is never rewritten, ADR-0046). */
|
|
1091
|
+
#growingRun(i) {
|
|
1092
|
+
const cell = this.#cells[i];
|
|
1093
|
+
if (cell.kind !== "tool" || !isExploreTool(cell.name))
|
|
1094
|
+
return false;
|
|
1095
|
+
// the run is still growing while NOTHING but explore cells follow —
|
|
1096
|
+
// the turn-less noise cells (permission raws, ⚠ notices) are
|
|
1097
|
+
// transparent here for the same reason the run scan sees through
|
|
1098
|
+
// them: the streaming execution interleaves them between the calls.
|
|
1099
|
+
for (let j = i + 1; j < this.#cells.length; j += 1) {
|
|
1100
|
+
const next = this.#cells[j];
|
|
1101
|
+
if (next.kind === "raw" || next.kind === "notice")
|
|
1102
|
+
continue;
|
|
1103
|
+
if (next.kind === "tool" && isExploreTool(next.name))
|
|
1104
|
+
continue;
|
|
1105
|
+
return false; // a non-explore cell closed the run — commit now
|
|
1106
|
+
}
|
|
1107
|
+
return true;
|
|
971
1108
|
}
|
|
972
1109
|
/** W14/W13 — the release-time decision at a commit, BEFORE the cell's
|
|
973
1110
|
* own render: the folded-turn fold first (a QUIET turn — ended, no
|
|
@@ -996,26 +1133,38 @@ export class Body {
|
|
|
996
1133
|
return [];
|
|
997
1134
|
}
|
|
998
1135
|
}
|
|
999
|
-
if (cell.kind !== "tool" ||
|
|
1136
|
+
if (cell.kind !== "tool" || !isExploreTool(cell.name))
|
|
1000
1137
|
return cellComponent(cell).render(W, ctx);
|
|
1001
|
-
//
|
|
1138
|
+
// TUI2-R1 (B): the run is over the READ-ONLY SET, not one name —
|
|
1139
|
+
// a model exploring mixes read/list/search, and the same-name scan
|
|
1140
|
+
// split every real burst into fragments. Writes, edits, shells and
|
|
1141
|
+
// extension tools still break the run at the first one.
|
|
1142
|
+
// the maximal read-only run around i — forward/backward scans over
|
|
1002
1143
|
// the cells. The turn-less noise cells (the permission raws, the ⚠
|
|
1003
1144
|
// notices) are TRANSPARENT: the streaming execution (loop.ts launch)
|
|
1004
1145
|
// interleaves them BETWEEN the calls of one burst, so the run must
|
|
1005
1146
|
// see through them. It never crosses a user/text/thinking cell —
|
|
1006
1147
|
// those separate turns and contexts.
|
|
1148
|
+
// TUI2-R1.5 ① (VD-1): the backward scan stops at the cells this
|
|
1149
|
+
// FRAME is committing. A cell committed in an earlier frame is
|
|
1150
|
+
// frozen — its rows are on the screen and in the scrollback — so it
|
|
1151
|
+
// can never become the head of a rollup now, and a run that
|
|
1152
|
+
// force-committed its first rows mid-turn must not have the rest
|
|
1153
|
+
// silently absorbed into a summary that was computed without them.
|
|
1154
|
+
// The degraded head keeps its individual row; the rest of the run
|
|
1155
|
+
// rolls on its own.
|
|
1007
1156
|
let s = i;
|
|
1008
1157
|
let head = i;
|
|
1009
|
-
while (s >
|
|
1158
|
+
while (s > this.#committedAtFrameStart) {
|
|
1010
1159
|
const prev = this.#cells[s - 1];
|
|
1011
1160
|
if (prev.kind === "raw" || prev.kind === "notice") {
|
|
1012
1161
|
s -= 1;
|
|
1013
1162
|
continue;
|
|
1014
1163
|
}
|
|
1015
|
-
if (prev.kind !== "tool" || prev.name
|
|
1164
|
+
if (prev.kind !== "tool" || !isExploreTool(prev.name))
|
|
1016
1165
|
break;
|
|
1017
1166
|
s -= 1;
|
|
1018
|
-
head = s; // a
|
|
1167
|
+
head = s; // a read-only tool precedes — it is the group's head
|
|
1019
1168
|
}
|
|
1020
1169
|
let e = i;
|
|
1021
1170
|
while (e + 1 < this.#cells.length) {
|
|
@@ -1024,7 +1173,7 @@ export class Body {
|
|
|
1024
1173
|
e += 1;
|
|
1025
1174
|
continue;
|
|
1026
1175
|
}
|
|
1027
|
-
if (next.kind !== "tool" || next.name
|
|
1176
|
+
if (next.kind !== "tool" || !isExploreTool(next.name))
|
|
1028
1177
|
break;
|
|
1029
1178
|
e += 1;
|
|
1030
1179
|
}
|
|
@@ -1042,13 +1191,18 @@ export class Body {
|
|
|
1042
1191
|
this.#rolledHeads.add(head);
|
|
1043
1192
|
let total = 0;
|
|
1044
1193
|
const targets = [];
|
|
1194
|
+
// TUI2-R1 (B): the per-tool parts, in first-call order — the
|
|
1195
|
+
// exploration row's counts and its expanded list both read them.
|
|
1196
|
+
// A search's subject is the PATTERN it looked for (quoted); a
|
|
1197
|
+
// read's or a list's is the path it named.
|
|
1198
|
+
const parts = [];
|
|
1045
1199
|
for (const m of members) {
|
|
1046
1200
|
// the lines count, excluding the tool's OWN truncation note
|
|
1047
1201
|
// (read_file's "… N more lines") — the per-cell meta's rule
|
|
1048
1202
|
const noteAt = m.resultText.lastIndexOf("\n… ");
|
|
1049
1203
|
const shown = noteAt >= 0 ? m.resultText.slice(0, noteAt) : m.resultText;
|
|
1050
|
-
const
|
|
1051
|
-
total +=
|
|
1204
|
+
const rows = shown.split("\n");
|
|
1205
|
+
total += rows[rows.length - 1] === "" ? rows.length - 1 : rows.length;
|
|
1052
1206
|
let input = {};
|
|
1053
1207
|
try {
|
|
1054
1208
|
input = JSON.parse(m.inputFull);
|
|
@@ -1059,11 +1213,19 @@ export class Body {
|
|
|
1059
1213
|
}
|
|
1060
1214
|
const target = toolTarget(m.name, input);
|
|
1061
1215
|
targets.push(target.split("/").pop() ?? target);
|
|
1216
|
+
const subject = m.name === "search_text" ? `"${String(input.pattern ?? "")}"` : target;
|
|
1217
|
+
const part = parts.find((x) => x.name === m.name);
|
|
1218
|
+
if (part === undefined)
|
|
1219
|
+
parts.push({ name: m.name, subjects: [subject] });
|
|
1220
|
+
else
|
|
1221
|
+
part.subjects.push(subject);
|
|
1062
1222
|
}
|
|
1063
1223
|
const first = members[0];
|
|
1064
1224
|
const last = members[members.length - 1];
|
|
1065
1225
|
const elapsed = first.startedAt !== null && last.doneAt !== null ? ((last.doneAt - first.startedAt) / 1000).toFixed(1) : "?";
|
|
1066
|
-
|
|
1226
|
+
// TUI2-R1 (B): `parts` rides ONLY a mixed run — a single-name
|
|
1227
|
+
// run keeps W13's row, byte for byte (the generalization adds).
|
|
1228
|
+
cell.rolled = { count: members.length, lines: total, elapsed, targets, ...(parts.length > 1 ? { parts } : {}) };
|
|
1067
1229
|
return cellComponent(cell).render(W, ctx);
|
|
1068
1230
|
}
|
|
1069
1231
|
// a MEMBER of an already-rolled run → [] (its rows live in the
|
|
@@ -1134,7 +1296,11 @@ export class Body {
|
|
|
1134
1296
|
if (menu === null || menu === undefined || menu.items.length === 0)
|
|
1135
1297
|
return [];
|
|
1136
1298
|
const p = palette();
|
|
1137
|
-
|
|
1299
|
+
// TUI2-R1.5 ⑦(b) (VD-8): the band NAMES itself, the same way the @
|
|
1300
|
+
// picker's does. Both render frameless directly above the composer,
|
|
1301
|
+
// so with scrollback behind them there was nothing to say where the
|
|
1302
|
+
// surface began — the rows read as more history.
|
|
1303
|
+
const rows = [bandHeader("commands", W)];
|
|
1138
1304
|
for (let i = 0; i < menu.items.length; i += 1) {
|
|
1139
1305
|
const item = menu.items[i];
|
|
1140
1306
|
const text = i === menu.selected
|
|
@@ -1268,6 +1434,7 @@ export class Body {
|
|
|
1268
1434
|
* EVERY row is idempotent: N consecutive resizes end with the same
|
|
1269
1435
|
* screen as a single jump to the same size. */
|
|
1270
1436
|
#drawFull(out, W, H, liveTop, liveLines, queueRows, menuRows, editor) {
|
|
1437
|
+
const overlay = this.#overlayFrame;
|
|
1271
1438
|
const inputExtra = editor.rows.length - 1; // KC1: the composer's rows above the retired single input row
|
|
1272
1439
|
const committed = this.#committedLinesThisFrame;
|
|
1273
1440
|
// 0. the FROZEN rows — the re-folded committed content (re-flowed
|
|
@@ -1295,7 +1462,15 @@ export class Body {
|
|
|
1295
1462
|
// window (the committed share + the live + the chrome), r
|
|
1296
1463
|
// monotone, every row 1..H re-painted (the V6-1 every-row rule).
|
|
1297
1464
|
const all = [...frozen, ...committed, ...liveLines];
|
|
1298
|
-
|
|
1465
|
+
// TUI2-R1.5 7(a) (VD-8): while the sheet is up the window does NOT
|
|
1466
|
+
// move. skip is frozen at its pre-open value and #lastSkip is left
|
|
1467
|
+
// alone, so no LF is emitted and nothing enters the scrollback; the
|
|
1468
|
+
// march below is clamped to the window instead, which makes the
|
|
1469
|
+
// sheet displace content ON SCREEN. Closing takes the full-redraw
|
|
1470
|
+
// path with the same #lastSkip and every displaced row comes back.
|
|
1471
|
+
const skip = overlay
|
|
1472
|
+
? this.#lastSkip
|
|
1473
|
+
: Math.max(0, all.length + CHROME_ROWS + inputExtra + queueRows.length + menuRows.length - H);
|
|
1299
1474
|
// A8b (the shrink-trigger's completion): the rows that LEAVE the
|
|
1300
1475
|
// window scroll into the terminal's scrollback — the LF mechanism
|
|
1301
1476
|
// (the steady path's own). Only the rows the paint re-covers (the
|
|
@@ -1307,7 +1482,7 @@ export class Body {
|
|
|
1307
1482
|
// shrink EVERY frame) loses the scrolled-away turns from the
|
|
1308
1483
|
// terminal's scrollback entirely (finding #A8b — the queued-flood
|
|
1309
1484
|
// content loss).
|
|
1310
|
-
if (skip > 0) {
|
|
1485
|
+
if (skip > 0 && !overlay) {
|
|
1311
1486
|
const leaving = Math.max(0, skip - this.#lastSkip);
|
|
1312
1487
|
// A8b (the fresh leaving share): a leaving row whose old-screen
|
|
1313
1488
|
// copy is stale — the committed-this-frame lines (their old rows
|
|
@@ -1331,9 +1506,16 @@ export class Body {
|
|
|
1331
1506
|
for (let i = 0; i < skip; i += 1)
|
|
1332
1507
|
out.push("\n");
|
|
1333
1508
|
}
|
|
1334
|
-
|
|
1509
|
+
if (!overlay)
|
|
1510
|
+
this.#lastSkip = skip;
|
|
1335
1511
|
let r = 1;
|
|
1336
|
-
|
|
1512
|
+
// the window's content rows: everything above the chrome. With the
|
|
1513
|
+
// overlay up `all` can exceed it, and the rows that give way are the
|
|
1514
|
+
// OLDEST on screen — they are still in the model and come back on
|
|
1515
|
+
// the close.
|
|
1516
|
+
const contentRows = Math.max(0, H - CHROME_ROWS - inputExtra - queueRows.length - menuRows.length);
|
|
1517
|
+
const march = all.slice(skip);
|
|
1518
|
+
for (const line of march.length > contentRows ? march.slice(march.length - contentRows) : march) {
|
|
1337
1519
|
out.push(`\x1b[${r};1H\x1b[0K${this.#checked(line, W)}`);
|
|
1338
1520
|
r += 1;
|
|
1339
1521
|
}
|
|
@@ -1384,8 +1566,12 @@ export class Body {
|
|
|
1384
1566
|
// frozenCount, so the lines at [frozenCount..skip−1] are the fresh
|
|
1385
1567
|
// leaving share (their old-screen copies are stale).
|
|
1386
1568
|
const frozenCount = this.#committedLines - committed.length;
|
|
1387
|
-
|
|
1388
|
-
|
|
1569
|
+
// TUI2-R1.5 7(a): an overlay frame never moves the window (see
|
|
1570
|
+
// #drawFull) — the sheet's rows displace content on screen instead
|
|
1571
|
+
// of pushing it into the scrollback.
|
|
1572
|
+
const overlay = this.#overlayFrame;
|
|
1573
|
+
const skip = overlay ? this.#lastSkip : Math.max(0, this.#committedLines + liveLines.length + CHROME_ROWS + inputExtra + queueRows.length + menuRows.length - H);
|
|
1574
|
+
const leaving = overlay ? 0 : Math.max(0, skip - this.#lastSkip);
|
|
1389
1575
|
// the jump to the bottom row H, then N real LFs scroll the screen
|
|
1390
1576
|
// exactly N rows — ONE per committed line (the bookkeeping; the
|
|
1391
1577
|
// stale 1B anchor jumped to H−1 and the N LFs scrolled only N−1 —
|
|
@@ -1605,6 +1791,15 @@ export class Dock {
|
|
|
1605
1791
|
}
|
|
1606
1792
|
compositorRef.bindApproval(state);
|
|
1607
1793
|
}
|
|
1794
|
+
/** TUI2-R1 (D): bind the editor's keys-sheet flag — the slot read for
|
|
1795
|
+
* the ? overlay (the menu/picker binding pattern). */
|
|
1796
|
+
bindSheet(state) {
|
|
1797
|
+
if (compositorRef === null) {
|
|
1798
|
+
dockBindings.sheet = state;
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
compositorRef.bindSheet(state);
|
|
1802
|
+
}
|
|
1608
1803
|
bindInput(state, prompt) {
|
|
1609
1804
|
if (compositorRef === null) {
|
|
1610
1805
|
dockBindings.state = state; // the live buffer — order-agnostic
|
|
@@ -1655,4 +1850,4 @@ let compositorRef = null;
|
|
|
1655
1850
|
* — the old snapshot froze `menu` at bindInput time and the slash-
|
|
1656
1851
|
* command menu silently never bound in the real CLI (the e2e gates
|
|
1657
1852
|
* bind the Body directly and could not see it). */
|
|
1658
|
-
const dockBindings = { state: null, prompt: "", menu: null, at: null, panel: null, queue: null };
|
|
1853
|
+
const dockBindings = { state: null, prompt: "", menu: null, at: null, panel: null, sheet: null, queue: null };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI2-R1 (E) — /context's attribution rows.
|
|
3
|
+
*
|
|
4
|
+
* The question "where did my context go?" has had an answer since E3:
|
|
5
|
+
* the trace sidecar's rent ledger records, per request, exactly what
|
|
6
|
+
* each static surface costs, and the context manifest records what the
|
|
7
|
+
* conversation costs. Until now that answer was only readable by
|
|
8
|
+
* someone willing to parse JSONL.
|
|
9
|
+
*
|
|
10
|
+
* This module is the presentation half and nothing else — a pure
|
|
11
|
+
* function from counts to rows, with no idea where the counts came
|
|
12
|
+
* from. That matters for the purity gate: the trace surface is an
|
|
13
|
+
* OBSERVATION surface (ADR-0051 §6), correctness never reads it, and
|
|
14
|
+
* keeping the reader in the CLI and the renderer here means this module
|
|
15
|
+
* cannot accidentally become a second correctness path.
|
|
16
|
+
*
|
|
17
|
+
* Every number is a count the ledger already carries. Nothing here
|
|
18
|
+
* estimates, projects, or predicts.
|
|
19
|
+
*/
|
|
20
|
+
/** The counts one request's ledger yields, already grouped by surface.
|
|
21
|
+
* Estimated tokens throughout (the rent ledger's own chars/4 convention
|
|
22
|
+
* — R6), because that is the unit the ledger records. */
|
|
23
|
+
export interface ContextLedger {
|
|
24
|
+
/** The model's context window, as the session is configured. */
|
|
25
|
+
readonly window: number;
|
|
26
|
+
/** system:base + every system:ext:* append EXCEPT skills. */
|
|
27
|
+
readonly systemPrompt: number;
|
|
28
|
+
/** system:base alone — the detail behind the row. */
|
|
29
|
+
readonly systemBase: number;
|
|
30
|
+
/** how many extensions appended (the detail's count). */
|
|
31
|
+
readonly appends: number;
|
|
32
|
+
/** the sum of the tool:* lines. */
|
|
33
|
+
readonly toolTable: number;
|
|
34
|
+
readonly tools: number;
|
|
35
|
+
/** system:ext:skills — broken out because it is an INDEX of content
|
|
36
|
+
* rather than an instruction, and it grows with the workspace rather
|
|
37
|
+
* than with the build. 0 when the extension is not loaded. */
|
|
38
|
+
readonly skillsIndex: number;
|
|
39
|
+
/** how many skills the index lists — 0 when the caller cannot know
|
|
40
|
+
* (the rent ledger records surfaces, never their contents). */
|
|
41
|
+
readonly skills: number;
|
|
42
|
+
/** the per-request skeleton (the `envelope` rent line). */
|
|
43
|
+
readonly envelope: number;
|
|
44
|
+
/** the context manifest's turn segments — the conversation itself. */
|
|
45
|
+
readonly messages: number;
|
|
46
|
+
readonly turns: number;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The rows: the header, the bar, one row per surface that EXISTS, and
|
|
50
|
+
* the free remainder.
|
|
51
|
+
*
|
|
52
|
+
* An absent surface is an absent row — the rent ledger's own R9 rule
|
|
53
|
+
* ("not paid = no rent"), carried into the display: a session with no
|
|
54
|
+
* skills extension should not read a "skills index 0" row, because the
|
|
55
|
+
* zero would look like a measurement rather than an absence.
|
|
56
|
+
*
|
|
57
|
+
* The columns are fixed so the numbers line up as a column of numbers;
|
|
58
|
+
* the detail text rides after them, dim, and is cut by the caller's
|
|
59
|
+
* width if it must be.
|
|
60
|
+
*/
|
|
61
|
+
export declare function contextRows(ledger: ContextLedger): string[];
|
|
62
|
+
/** TUI2-R1 (E) — the honest fallback. The ledger is written PER REQUEST:
|
|
63
|
+
* a session that has not called the model yet has no sidecar, and the
|
|
64
|
+
* right thing to show is that fact and the one step that produces one.
|
|
65
|
+
* Never an empty bar — an empty bar reads as "measured zero". */
|
|
66
|
+
export declare function contextUnavailableRows(reason: string): string[];
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI2-R1 (E) — /context's attribution rows.
|
|
3
|
+
*
|
|
4
|
+
* The question "where did my context go?" has had an answer since E3:
|
|
5
|
+
* the trace sidecar's rent ledger records, per request, exactly what
|
|
6
|
+
* each static surface costs, and the context manifest records what the
|
|
7
|
+
* conversation costs. Until now that answer was only readable by
|
|
8
|
+
* someone willing to parse JSONL.
|
|
9
|
+
*
|
|
10
|
+
* This module is the presentation half and nothing else — a pure
|
|
11
|
+
* function from counts to rows, with no idea where the counts came
|
|
12
|
+
* from. That matters for the purity gate: the trace surface is an
|
|
13
|
+
* OBSERVATION surface (ADR-0051 §6), correctness never reads it, and
|
|
14
|
+
* keeping the reader in the CLI and the renderer here means this module
|
|
15
|
+
* cannot accidentally become a second correctness path.
|
|
16
|
+
*
|
|
17
|
+
* Every number is a count the ledger already carries. Nothing here
|
|
18
|
+
* estimates, projects, or predicts.
|
|
19
|
+
*/
|
|
20
|
+
import { palette } from "./render.js";
|
|
21
|
+
const BAR_CELLS = 12;
|
|
22
|
+
/** k-units for the ledger's columns: 25700 → 25.7k, 300 → 300, 11 → 11.
|
|
23
|
+
*
|
|
24
|
+
* TUI2-R1.5 ⑤ (VD-15): the floor was 100, which put `11`, `0.3k` and
|
|
25
|
+
* `25.7k` in one right-aligned column — two unit systems stacked, and
|
|
26
|
+
* the reader has to switch between them row by row to compare. The
|
|
27
|
+
* repo already had a k-formatter with a 1000 floor (render.ts's kUnit,
|
|
28
|
+
* which the status row and every settled card use); this now agrees
|
|
29
|
+
* with it, so /context speaks the same number language as the rest of
|
|
30
|
+
* the product. It still differs from kUnit in never having a null to
|
|
31
|
+
* report — every ledger figure is a measured count. */
|
|
32
|
+
function k(n) {
|
|
33
|
+
return n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k` : String(Math.round(n));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The rows: the header, the bar, one row per surface that EXISTS, and
|
|
37
|
+
* the free remainder.
|
|
38
|
+
*
|
|
39
|
+
* An absent surface is an absent row — the rent ledger's own R9 rule
|
|
40
|
+
* ("not paid = no rent"), carried into the display: a session with no
|
|
41
|
+
* skills extension should not read a "skills index 0" row, because the
|
|
42
|
+
* zero would look like a measurement rather than an absence.
|
|
43
|
+
*
|
|
44
|
+
* The columns are fixed so the numbers line up as a column of numbers;
|
|
45
|
+
* the detail text rides after them, dim, and is cut by the caller's
|
|
46
|
+
* width if it must be.
|
|
47
|
+
*/
|
|
48
|
+
export function contextRows(ledger) {
|
|
49
|
+
const p = palette();
|
|
50
|
+
const used = ledger.systemPrompt + ledger.toolTable + ledger.skillsIndex + ledger.envelope + ledger.messages;
|
|
51
|
+
const free = Math.max(0, ledger.window - used);
|
|
52
|
+
const ratio = ledger.window > 0 ? Math.min(1, used / ledger.window) : 1;
|
|
53
|
+
const filled = Math.max(0, Math.min(BAR_CELLS, Math.round(ratio * BAR_CELLS)));
|
|
54
|
+
const rows = [
|
|
55
|
+
`${p.bold}context — ${k(used)} / ${k(ledger.window)} tokens (${Math.round(ratio * 100)}%)${p.reset}`,
|
|
56
|
+
`${p.bold}${"▰".repeat(filled)}${p.reset}${p.dim}${"▱".repeat(BAR_CELLS - filled)}${p.reset}`,
|
|
57
|
+
];
|
|
58
|
+
/** One surface row: the label at 14 columns, the count right-aligned
|
|
59
|
+
* at 5, then the dim detail. */
|
|
60
|
+
const row = (label, value, detail) => ` ${p.bold}▰${p.reset} ${label.padEnd(14)}${p.bold}${k(value).padStart(5)}${p.reset}${detail === "" ? "" : ` ${p.dim}${detail}${p.reset}`}`;
|
|
61
|
+
if (ledger.systemPrompt > 0) {
|
|
62
|
+
rows.push(row("system prompt", ledger.systemPrompt, `(base ${k(ledger.systemBase)}${ledger.appends > 0 ? ` + ${ledger.appends} extension append${ledger.appends === 1 ? "" : "s"}` : ""})`));
|
|
63
|
+
}
|
|
64
|
+
if (ledger.toolTable > 0)
|
|
65
|
+
rows.push(row("tool table", ledger.toolTable, `${ledger.tools} tool${ledger.tools === 1 ? "" : "s"}`));
|
|
66
|
+
if (ledger.skillsIndex > 0) {
|
|
67
|
+
// the skill COUNT is not in the ledger (rent records surfaces, not
|
|
68
|
+
// their contents) — a caller that knows it passes it, and a caller
|
|
69
|
+
// that does not gets the honest half of the sentence rather than a
|
|
70
|
+
// fabricated number.
|
|
71
|
+
rows.push(row("skills index", ledger.skillsIndex, `${ledger.skills > 0 ? `${ledger.skills} skill${ledger.skills === 1 ? "" : "s"}, ` : ""}tier-1 lines only`));
|
|
72
|
+
}
|
|
73
|
+
if (ledger.envelope > 0)
|
|
74
|
+
rows.push(row("envelope", ledger.envelope, ""));
|
|
75
|
+
if (ledger.messages > 0)
|
|
76
|
+
rows.push(row("messages", ledger.messages, `${ledger.turns} turn${ledger.turns === 1 ? "" : "s"}`));
|
|
77
|
+
rows.push(` ${p.dim}▱ ${"free".padEnd(14)}${k(free).padStart(5)}${p.reset}`);
|
|
78
|
+
return rows;
|
|
79
|
+
}
|
|
80
|
+
/** TUI2-R1 (E) — the honest fallback. The ledger is written PER REQUEST:
|
|
81
|
+
* a session that has not called the model yet has no sidecar, and the
|
|
82
|
+
* right thing to show is that fact and the one step that produces one.
|
|
83
|
+
* Never an empty bar — an empty bar reads as "measured zero". */
|
|
84
|
+
export function contextUnavailableRows(reason) {
|
|
85
|
+
const p = palette();
|
|
86
|
+
return [`${p.bold}context — no ledger yet${p.reset}`, ` ${p.dim}${reason}${p.reset}`];
|
|
87
|
+
}
|
package/dist/editor.d.ts
CHANGED
|
@@ -60,6 +60,9 @@ export declare class Editor {
|
|
|
60
60
|
bindQueue(state: () => readonly string[], pop: () => string | null): void;
|
|
61
61
|
/** The whole buffer as text (the CLI's line()/clearLine()). */
|
|
62
62
|
line(): string;
|
|
63
|
+
/** TUI2-R1 (D): whether the keys sheet is up — the compositor's slot
|
|
64
|
+
* read (bound like the menu and the picker). */
|
|
65
|
+
sheetOpen(): boolean;
|
|
63
66
|
clearLine(): void;
|
|
64
67
|
/** The dock's input-row state — ADDITIVE (§5): `line` + `cursor` keep
|
|
65
68
|
* their legacy meaning (the CURSOR LINE's visible slice and the
|
package/dist/editor.js
CHANGED
|
@@ -42,6 +42,8 @@ export const MENU_ITEMS = [
|
|
|
42
42
|
{ name: "/think", desc: "show the last full thinking block" },
|
|
43
43
|
{ name: "/last", desc: "show the most recent tool call's input and output" },
|
|
44
44
|
{ name: "/status", desc: "show session id, event count, and context estimate" },
|
|
45
|
+
// TUI2-R1 (E): the rent-ledger attribution — where the context went
|
|
46
|
+
{ name: "/context", desc: "show where the context went — the last request's rent ledger" },
|
|
45
47
|
{ name: "/help", desc: "print this list of commands" },
|
|
46
48
|
];
|
|
47
49
|
/** KC1 §3 — the newline code point. Every source (paste, Ctrl+J, the
|
|
@@ -109,6 +111,12 @@ export class Editor {
|
|
|
109
111
|
// coexist; the editor never interprets the key itself.
|
|
110
112
|
#expandCbs = [];
|
|
111
113
|
#onRender;
|
|
114
|
+
/** TUI2-R1 (D): the keys sheet — a static one-screen overlay opened by
|
|
115
|
+
* `?` on an empty composer and closed by the next key, whatever it
|
|
116
|
+
* is. Deliberately a BOOLEAN and not a panel: the panel machinery
|
|
117
|
+
* exists for interactions (a lead, a status, a reducer, a stashed
|
|
118
|
+
* buffer), and the sheet has no interaction to speak of. */
|
|
119
|
+
#sheetOpen = false;
|
|
112
120
|
#menuOpen = false; // v3 §04: the slash-command menu
|
|
113
121
|
#menuSel = 0;
|
|
114
122
|
// KC3 §3 — the @ file picker. THREE fields and no more: the armed
|
|
@@ -194,6 +202,11 @@ export class Editor {
|
|
|
194
202
|
line() {
|
|
195
203
|
return String.fromCodePoint(...this.#chars);
|
|
196
204
|
}
|
|
205
|
+
/** TUI2-R1 (D): whether the keys sheet is up — the compositor's slot
|
|
206
|
+
* read (bound like the menu and the picker). */
|
|
207
|
+
sheetOpen() {
|
|
208
|
+
return this.#sheetOpen;
|
|
209
|
+
}
|
|
197
210
|
clearLine() {
|
|
198
211
|
this.#chars = [];
|
|
199
212
|
this.#cursor = 0;
|
|
@@ -510,6 +523,17 @@ export class Editor {
|
|
|
510
523
|
feed(raw) {
|
|
511
524
|
const text = this.#pending + this.#decoder.decode(raw, { stream: true });
|
|
512
525
|
this.#pending = "";
|
|
526
|
+
// TUI2-R1 (D): the sheet is up — ANY key closes it, and the key
|
|
527
|
+
// that closed it is CONSUMED. The whole chunk goes, deliberately:
|
|
528
|
+
// an arrow key is three bytes, and closing on the first while
|
|
529
|
+
// letting `[A` fall through as literal text would be a sheet that
|
|
530
|
+
// types into your composer on the way out. A dismissal costs one
|
|
531
|
+
// keystroke; that is the entire contract.
|
|
532
|
+
if (this.#sheetOpen) {
|
|
533
|
+
this.#sheetOpen = false;
|
|
534
|
+
this.#onRender();
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
513
537
|
let i = 0;
|
|
514
538
|
while (i < text.length) {
|
|
515
539
|
const c = text[i];
|
|
@@ -752,6 +776,17 @@ export class Editor {
|
|
|
752
776
|
cb();
|
|
753
777
|
i += 1;
|
|
754
778
|
}
|
|
779
|
+
else if (c === "?" && this.#composerIdle() && this.#chars.length === 0) {
|
|
780
|
+
// TUI2-R1 (D): `?` opens the keys sheet — but ONLY on an
|
|
781
|
+
// empty composer with nobody else holding the keys. Mid-text
|
|
782
|
+
// it is the question mark a human is typing, and #composerIdle
|
|
783
|
+
// already encodes "no panel, no menu, no picker, no browse".
|
|
784
|
+
// The precedence can only ever ADD: every state that used to
|
|
785
|
+
// insert a `?` still inserts one.
|
|
786
|
+
this.#sheetOpen = true;
|
|
787
|
+
this.#onRender();
|
|
788
|
+
i += 1;
|
|
789
|
+
}
|
|
755
790
|
else if (c !== undefined && c < " ") {
|
|
756
791
|
i += 1; // other control — ignored
|
|
757
792
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -7,12 +7,13 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export { Body, Dock, CURSOR_MARKER, type BodyOptions } from "./compositor.js";
|
|
9
9
|
export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWidth, panelStatus, type PanelArgs, type PanelFlavor, type PanelPhase, type PanelSel, type PanelState, type PanelVerdict, type PanelView, } from "./approval-panel.js";
|
|
10
|
-
export { Container, foldLine, visibleWidth, SPINNER, type Component, type FrameCtx } from "./components.js";
|
|
10
|
+
export { Container, foldLine, foldWords, visibleWidth, SPINNER, type Component, type FrameCtx } from "./components.js";
|
|
11
11
|
export { Editor, MENU_ITEMS, PROMPT, PROMPT_WIDTH, displayWidth, charWidth, widthOf, type MenuItem, } from "./editor.js";
|
|
12
12
|
export { bannerLines, COLOR_OFF, COLOR_ON, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderResumeList, renderSessionLine, renderStatusLine, relativeTime, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, type Palette, type PathResolver, type RecapStats, type ResumeMeta, type RenderInput, type RenderResult, type RunUsage, } from "./render.js";
|
|
13
13
|
export { editFileDiff, truncateDiff, writeFileDiff, type DiffLine, type DiffResult } from "./diff.js";
|
|
14
|
-
export { STATUS_GLYPHS, idleStatus, runningStatus } from "./status.js";
|
|
14
|
+
export { STATUS_GLYPHS, cacheHitPct, idleStatus, runningStatus, type StatusMeter } from "./status.js";
|
|
15
|
+
export { contextRows, contextUnavailableRows, type ContextLedger } from "./context-ledger.js";
|
|
15
16
|
export { interactivePrompt, projectTrustRows, projectTrustView, projectUntrustedNote, uncertainView, type TrustArtifact } from "./strings.js";
|
|
16
17
|
export { AT_CAP, AT_SKIP, AT_VISIBLE, atEmbed, atFilter, atPanelRows, atWindow, longestRun, type AtItem, type AtMatch } from "./at-picker.js";
|
|
17
18
|
export { ASK_HEADER_CAP, ASK_MAX_OPTIONS, ASK_MAX_QUESTIONS, ASK_MIN_OPTIONS, askAffordance, askAnswers, askBlockRows, askCommitCustom, askDeclineAll, askDeclineList, askKey, askLeadPlain, askStart, askStatus, askView, type AskAnswer, type AskOption, type AskQuestion, type AskResult, type AskRuntime, type AskSpec, type AskStep, } from "./ask-panel.js";
|
|
18
|
-
export { extensionsBannerText, helpRows, unansweredAskView, type BannerExtension } from "./strings.js";
|
|
19
|
+
export { KEY_BINDINGS, PANEL_KEYS_ROW, extensionsBannerText, helpRows, keysHelpRow, keysSheetRows, unansweredAskView, type BannerExtension, type KeyBinding } from "./strings.js";
|
package/dist/index.js
CHANGED
|
@@ -10,13 +10,16 @@ export { Body, Dock, CURSOR_MARKER } from "./compositor.js";
|
|
|
10
10
|
// that replaces the running tool's live window while a human-chain
|
|
11
11
|
// approval is pending (the shape authority is the committed preview).
|
|
12
12
|
export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWidth, panelStatus, } from "./approval-panel.js";
|
|
13
|
-
export { Container, foldLine, visibleWidth, SPINNER } from "./components.js";
|
|
13
|
+
export { Container, foldLine, foldWords, visibleWidth, SPINNER } from "./components.js";
|
|
14
14
|
export { Editor, MENU_ITEMS, PROMPT, PROMPT_WIDTH, displayWidth, charWidth, widthOf, } from "./editor.js";
|
|
15
15
|
export { bannerLines, COLOR_OFF, COLOR_ON, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderResumeList, renderSessionLine, renderStatusLine, relativeTime, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, } from "./render.js";
|
|
16
16
|
export { editFileDiff, truncateDiff, writeFileDiff } from "./diff.js";
|
|
17
17
|
// KC2 §5: the status rows' formatters — the CLI keeps the state and the
|
|
18
18
|
// repaint, the terminal layer owns what the row says.
|
|
19
|
-
export { STATUS_GLYPHS, idleStatus, runningStatus } from "./status.js";
|
|
19
|
+
export { STATUS_GLYPHS, cacheHitPct, idleStatus, runningStatus } from "./status.js";
|
|
20
|
+
// TUI2-R1 (E): /context's attribution rows — a pure function of the
|
|
21
|
+
// counts the trace sidecar already records (the CLI reads, this renders).
|
|
22
|
+
export { contextRows, contextUnavailableRows } from "./context-ledger.js";
|
|
20
23
|
// KC3 §1 (the extraction): the human-facing strings — the prompt, the
|
|
21
24
|
// project-trust listing/view/note, the uncertain execution's view. The
|
|
22
25
|
// FLOW (who is asked, what a verdict means) stays in the cli.
|
|
@@ -30,4 +33,6 @@ export { AT_CAP, AT_SKIP, AT_VISIBLE, atEmbed, atFilter, atPanelRows, atWindow,
|
|
|
30
33
|
export { ASK_HEADER_CAP, ASK_MAX_OPTIONS, ASK_MAX_QUESTIONS, ASK_MIN_OPTIONS, askAffordance, askAnswers, askBlockRows, askCommitCustom, askDeclineAll, askDeclineList, askKey, askLeadPlain, askStart, askStatus, askView, } from "./ask-panel.js";
|
|
31
34
|
// KC3.5 §4: the interrupted-ask copy — the SAME uncertainty gate, said
|
|
32
35
|
// honestly for a question nobody answered (the ① probe's surface).
|
|
33
|
-
|
|
36
|
+
// TUI2-R1 (D): the keys sheet + THE key table — one source for the ?
|
|
37
|
+
// overlay and /help's keys row.
|
|
38
|
+
export { KEY_BINDINGS, PANEL_KEYS_ROW, extensionsBannerText, helpRows, keysHelpRow, keysSheetRows, unansweredAskView } from "./strings.js";
|
package/dist/status.d.ts
CHANGED
|
@@ -33,6 +33,36 @@ export declare const STATUS_GLYPHS: readonly ["▖", "▘", "▝", "▗"];
|
|
|
33
33
|
* because it is on screen exactly when the gesture is useful.
|
|
34
34
|
*/
|
|
35
35
|
export declare function runningStatus(glyph: string, since: number, outTokens: number | null, ctxRatio: number): string;
|
|
36
|
+
/**
|
|
37
|
+
* TUI2-R1 (E) — the idle row's meter: what the session has SPENT, next
|
|
38
|
+
* to what it has left.
|
|
39
|
+
*
|
|
40
|
+
* Both fields are optional and both are omitted when unknown, because
|
|
41
|
+
* the row's job is to be true rather than complete:
|
|
42
|
+
*
|
|
43
|
+
* - `cacheHitPct` is cacheRead / (fresh + cacheRead) — the E2
|
|
44
|
+
* denominator (the pinned sentence: it cannot exceed 100%). A
|
|
45
|
+
* session with no usage yet has no cache hit rate, and an
|
|
46
|
+
* unmeasured cache is NOT a 0% cache, so it renders nothing.
|
|
47
|
+
* - `costUsd` is the CANONICAL cost, which is null whenever the
|
|
48
|
+
* pricing table has no rate for the route. Null renders nothing.
|
|
49
|
+
* No rate table, no number — kiso does not invent a price.
|
|
50
|
+
*/
|
|
51
|
+
export interface StatusMeter {
|
|
52
|
+
readonly cacheHitPct: number | null;
|
|
53
|
+
readonly costUsd: number | null;
|
|
54
|
+
}
|
|
36
55
|
/** The IDLE row: the approval tier as the CALLER names it, the /mode
|
|
37
|
-
* hint, the model driving the session,
|
|
38
|
-
|
|
56
|
+
* hint, the model driving the session, the TUI2-R1 meter when there is
|
|
57
|
+
* one, and the ctx estimate. Called without a meter — or with one that
|
|
58
|
+
* knows nothing — the row is byte-identical to the pre-round row. */
|
|
59
|
+
export declare function idleStatus(tier: string, model: string, ctxRatio: number, meter?: StatusMeter): string;
|
|
60
|
+
/** TUI2-R1 (E) — the cache hit rate the status row shows, from the usage
|
|
61
|
+
* the CLI already tracks. The denominator is the TOTAL the model was
|
|
62
|
+
* given (fresh + cacheRead), which is the E2 ruling's own: cacheRead
|
|
63
|
+
* over fresh alone once rendered 923%. No input at all → null, never a
|
|
64
|
+
* zero. */
|
|
65
|
+
export declare function cacheHitPct(usage: {
|
|
66
|
+
in: number | null;
|
|
67
|
+
cache: number | null;
|
|
68
|
+
}): number | null;
|
package/dist/status.js
CHANGED
|
@@ -46,7 +46,28 @@ export function runningStatus(glyph, since, outTokens, ctxRatio) {
|
|
|
46
46
|
return `${glyph} working ${seconds}s${out} · esc stop · alt+⏎ redirect · ctx left ~${ctxLeft(ctxRatio)}%`;
|
|
47
47
|
}
|
|
48
48
|
/** The IDLE row: the approval tier as the CALLER names it, the /mode
|
|
49
|
-
* hint, the model driving the session,
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
* hint, the model driving the session, the TUI2-R1 meter when there is
|
|
50
|
+
* one, and the ctx estimate. Called without a meter — or with one that
|
|
51
|
+
* knows nothing — the row is byte-identical to the pre-round row. */
|
|
52
|
+
export function idleStatus(tier, model, ctxRatio, meter) {
|
|
53
|
+
const parts = [`▸ ${tier}`, "/mode to switch", model];
|
|
54
|
+
if (meter?.cacheHitPct != null)
|
|
55
|
+
parts.push(`CH ${Math.round(meter.cacheHitPct)}%`);
|
|
56
|
+
if (meter?.costUsd != null)
|
|
57
|
+
parts.push(`$${meter.costUsd.toFixed(4)}`);
|
|
58
|
+
parts.push(`ctx left ~${ctxLeft(ctxRatio)}%`);
|
|
59
|
+
return parts.join(" · ");
|
|
60
|
+
}
|
|
61
|
+
/** TUI2-R1 (E) — the cache hit rate the status row shows, from the usage
|
|
62
|
+
* the CLI already tracks. The denominator is the TOTAL the model was
|
|
63
|
+
* given (fresh + cacheRead), which is the E2 ruling's own: cacheRead
|
|
64
|
+
* over fresh alone once rendered 923%. No input at all → null, never a
|
|
65
|
+
* zero. */
|
|
66
|
+
export function cacheHitPct(usage) {
|
|
67
|
+
const fresh = usage.in;
|
|
68
|
+
const cached = usage.cache;
|
|
69
|
+
if (fresh === null || cached === null)
|
|
70
|
+
return null;
|
|
71
|
+
const total = fresh + cached;
|
|
72
|
+
return total > 0 ? (cached / total) * 100 : null;
|
|
52
73
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-tui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "kiso tui — 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.
|
|
38
|
+
"@vincemakes/kiso-tui-cells": "0.9.0"
|
|
39
39
|
}
|
|
40
40
|
}
|