@vincemakes/kiso-tui 0.39.2 → 0.40.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.
@@ -132,10 +132,13 @@ export declare class Body {
132
132
  toolProgress(callId: string, text: string): void;
133
133
  toolSucceeded(callId: string): void;
134
134
  toolFailed(callId: string, error: string): void;
135
+ /** `untimed`: 4c's replay settles a card from the durable log, whose
136
+ * events carry no clock — the card then says nothing about time. */
135
137
  toolResult(callId: string, result: {
136
138
  content: string;
137
139
  isError: boolean;
138
140
  reason?: string | null;
141
+ untimed?: boolean;
139
142
  }): void;
140
143
  textAppend(text: string): void;
141
144
  textEnd(): void;
@@ -144,6 +147,18 @@ export declare class Body {
144
147
  * CLI's wall-clocked thinking window. */
145
148
  endTurn(thoughtSeconds: number): void;
146
149
  terminal(label: string, statusLineText: string): void;
150
+ /**
151
+ * 4c — fold what `replay` puts on the body into ONE row. The replayed
152
+ * cells are the same cells a live run makes (the caller drives the
153
+ * ordinary mutations), moved out of the transcript into the fold cell:
154
+ * the row commits as one line and never expands on screen — so a resize
155
+ * reprint redraws one row however long the history — and the ctrl+r
156
+ * viewer reads them. Mutations only schedule frames, so nothing the
157
+ * replay adds can have committed before it is moved. Active bodies
158
+ * only: a pipe has no viewer to read a fold with (the caller prints the
159
+ * plain tail there).
160
+ */
161
+ fold(label: string, replay: () => void, summary?: string | null): void;
147
162
  notice(text: string): void;
148
163
  /** W20 — the task checklist as STATE, not events: the FIRST call of a
149
164
  * turn creates the ONE live block (done:false — the commit loop only
@@ -339,8 +354,19 @@ export declare class Body {
339
354
  * reads it — the marker math never desyncs by construction). */
340
355
  editCol(): number;
341
356
  /** The old dock's redraw — the editor's onRender target: mark + the
342
- * scheduler (16ms coalescing — the old sync draw coalesces the same). */
343
- redraw(): void;
357
+ * scheduler (16ms coalescing — the old sync draw coalesces the same).
358
+ *
359
+ * Item 6: a KEY-originated redraw paints on the next tick and takes
360
+ * the pending trailing frame with it. Keyboard input is the one
361
+ * latency-sensitive source: the trailing window made every key wait
362
+ * 16 ms, and 40 ms on Terminal.app — where an IME commit erases the
363
+ * terminal's marked text at once and the committed characters came
364
+ * back a frame window later, a visible blink. Stream, tool and
365
+ * spinner marks keep the trailing window exactly (the conservative
366
+ * mode's throughput protection is for them). setImmediate, not a
367
+ * synchronous paint: every mutation one chunk of keys makes still
368
+ * lands in one frame. */
369
+ redraw(fromKey?: boolean): void;
344
370
  /** Teardown — flush a pending frame, stop the timers. */
345
371
  close(): void;
346
372
  /** The live region's scalar — the unit tests assert the cap directly
@@ -403,5 +429,5 @@ export declare class Dock {
403
429
  * the LineInput's own bindQueue). */
404
430
  bindQueue(state: () => readonly string[]): void;
405
431
  editCol(): number;
406
- redraw(): void;
432
+ redraw(fromKey?: boolean): void;
407
433
  }
@@ -197,6 +197,10 @@ export class Body {
197
197
  #lastInputRows = 1;
198
198
  #lastAnchorRow = 0;
199
199
  #frameTimer = null;
200
+ #inputFrame = null;
201
+ /** Body mutations and non-key redraws so far — the input frame's
202
+ * "did anything but typing change the screen?" */
203
+ #runMarks = 0;
200
204
  #spinnerTimer = null;
201
205
  #spinnerI = 0;
202
206
  #lastThinking = null;
@@ -520,6 +524,8 @@ export class Body {
520
524
  this.#write(`${p.red} failed: ${escapeTerminal(error.slice(0, 160))}${p.reset}\n`);
521
525
  }
522
526
  }
527
+ /** `untimed`: 4c's replay settles a card from the durable log, whose
528
+ * events carry no clock — the card then says nothing about time. */
523
529
  toolResult(callId, result) {
524
530
  const call = this.#pendingCalls.get(callId);
525
531
  if (call !== undefined) {
@@ -543,7 +549,7 @@ export class Body {
543
549
  cell.isError = result.isError;
544
550
  cell.resultText = result.content;
545
551
  cell.reason = result.reason ?? null;
546
- cell.doneAt = Date.now();
552
+ cell.doneAt = result.untimed === true ? null : Date.now();
547
553
  cell.done = true;
548
554
  }
549
555
  this.#mark();
@@ -692,6 +698,31 @@ export class Body {
692
698
  this.#cells.push({ kind: "terminal", label: label.trim(), line: statusLineText, done: true });
693
699
  this.#mark();
694
700
  }
701
+ /**
702
+ * 4c — fold what `replay` puts on the body into ONE row. The replayed
703
+ * cells are the same cells a live run makes (the caller drives the
704
+ * ordinary mutations), moved out of the transcript into the fold cell:
705
+ * the row commits as one line and never expands on screen — so a resize
706
+ * reprint redraws one row however long the history — and the ctrl+r
707
+ * viewer reads them. Mutations only schedule frames, so nothing the
708
+ * replay adds can have committed before it is moved. Active bodies
709
+ * only: a pipe has no viewer to read a fold with (the caller prints the
710
+ * plain tail there).
711
+ */
712
+ fold(label, replay, summary = null) {
713
+ if (!this.#isActive())
714
+ return;
715
+ const start = this.#cells.length;
716
+ replay();
717
+ this.#closeOpenThinking();
718
+ this.#closeOpenText();
719
+ const children = this.#cells.splice(start);
720
+ for (const cell of children)
721
+ if (!cell.done)
722
+ cell.done = true; // a replay settles; nothing in a fold is live
723
+ this.#cells.push({ kind: "fold", label, children, summary, done: true });
724
+ this.#mark();
725
+ }
695
726
  notice(text) {
696
727
  if (!this.#isActive()) {
697
728
  this.#closeOpenThinking();
@@ -935,8 +966,15 @@ export class Body {
935
966
  const cell = this.#cells[idx];
936
967
  if (cell === undefined)
937
968
  continue;
938
- // R13 — the viewer's FOLD entry retired with the fold: every
939
- // entry is now a card, and a card's entry is its own full body.
969
+ // 4c — the resumed history's fold: the head is its one row, the
970
+ // body is the replayed turns rendered at the viewer's width —
971
+ // the checkpoint's summary first when it is one.
972
+ if (cell.kind === "fold") {
973
+ out.push({ head: cellComponent(cell).render(inner, ctx)[0] ?? "", body: this.#foldBody(cell, inner, ctx) });
974
+ continue;
975
+ }
976
+ // R13 — every other entry is a card, and a card's entry is its
977
+ // own full body.
940
978
  if (cell.kind !== "tool")
941
979
  continue;
942
980
  // the tool card's FULL body — the same rows its own ctrl+o
@@ -959,6 +997,24 @@ export class Body {
959
997
  }
960
998
  return out;
961
999
  }
1000
+ /** 4c — a fold's children as the transcript would have shown them: each
1001
+ * cell's own render, spaced by the body's own formula (no blank between
1002
+ * two blocks of one message). */
1003
+ #foldBody(cell, W, ctx) {
1004
+ const out = [];
1005
+ let prev = null;
1006
+ let prevKind = null;
1007
+ const cells = cell.summary === null ? cell.children : [{ kind: "raw", lines: cell.summary.split("\n"), done: true, wrap: "words" }, ...cell.children];
1008
+ for (const child of cells) {
1009
+ const rows = cellComponent(child).render(W, ctx);
1010
+ if (rows.length === 0)
1011
+ continue;
1012
+ out.push(...(prevKind === "md" && child.kind === "md" ? rows : bodySpacing(prev, rows)));
1013
+ prev = rows;
1014
+ prevKind = child.kind;
1015
+ }
1016
+ return out;
1017
+ }
962
1018
  /** The viewer's band: its title, its list, its keys. */
963
1019
  #viewerBand(W) {
964
1020
  if (this.#viewer === null)
@@ -1513,20 +1569,64 @@ export class Body {
1513
1569
  return 1 + leadWidth(lead) + st.cursor;
1514
1570
  }
1515
1571
  /** The old dock's redraw — the editor's onRender target: mark + the
1516
- * scheduler (16ms coalescing — the old sync draw coalesces the same). */
1517
- redraw() {
1572
+ * scheduler (16ms coalescing — the old sync draw coalesces the same).
1573
+ *
1574
+ * Item 6: a KEY-originated redraw paints on the next tick and takes
1575
+ * the pending trailing frame with it. Keyboard input is the one
1576
+ * latency-sensitive source: the trailing window made every key wait
1577
+ * 16 ms, and 40 ms on Terminal.app — where an IME commit erases the
1578
+ * terminal's marked text at once and the committed characters came
1579
+ * back a frame window later, a visible blink. Stream, tool and
1580
+ * spinner marks keep the trailing window exactly (the conservative
1581
+ * mode's throughput protection is for them). setImmediate, not a
1582
+ * synchronous paint: every mutation one chunk of keys makes still
1583
+ * lands in one frame. */
1584
+ redraw(fromKey = false) {
1518
1585
  if (!this.#isActive())
1519
1586
  return;
1520
1587
  this.#dirty = true;
1588
+ if (fromKey) {
1589
+ this.#scheduleInputFrame();
1590
+ return;
1591
+ }
1592
+ this.#runMarks += 1; // a non-key redraw is not typing: it keeps the window
1521
1593
  this.#scheduleFrame();
1522
1594
  }
1523
1595
  // ---- the scheduler (event-driven; zero heartbeat timers) ----
1524
1596
  #mark() {
1525
1597
  if (!this.#isActive())
1526
1598
  return;
1599
+ this.#runMarks += 1;
1527
1600
  this.#dirty = true;
1528
1601
  this.#scheduleFrame();
1529
1602
  }
1603
+ #scheduleInputFrame() {
1604
+ if (this.#inputFrame !== null)
1605
+ return;
1606
+ const marksAtKey = this.#runMarks;
1607
+ this.#inputFrame = setImmediate(() => {
1608
+ this.#inputFrame = null;
1609
+ // A key whose consequence is run state (an approval verdict, a
1610
+ // submit) mutates the body before this tick. That frame carries
1611
+ // the run, so it keeps the run's trailing window, exactly as
1612
+ // before: painting it now showed an approved write's transient
1613
+ // live card for one frame before its result settled.
1614
+ if (this.#runMarks !== marksAtKey) {
1615
+ if (this.#dirty)
1616
+ this.#scheduleFrame();
1617
+ return;
1618
+ }
1619
+ if (this.#frameTimer !== null) {
1620
+ clearTimeout(this.#frameTimer);
1621
+ this.#frameTimer = null;
1622
+ }
1623
+ if (this.#dirty) {
1624
+ this.#dirty = false;
1625
+ this.render();
1626
+ }
1627
+ });
1628
+ this.#inputFrame.unref();
1629
+ }
1530
1630
  #scheduleFrame() {
1531
1631
  if (this.#frameTimer !== null)
1532
1632
  return;
@@ -1574,6 +1674,10 @@ export class Body {
1574
1674
  }
1575
1675
  /** Teardown — flush a pending frame, stop the timers. */
1576
1676
  close() {
1677
+ if (this.#inputFrame !== null) {
1678
+ clearImmediate(this.#inputFrame);
1679
+ this.#inputFrame = null;
1680
+ }
1577
1681
  if (this.#frameTimer !== null) {
1578
1682
  clearTimeout(this.#frameTimer);
1579
1683
  this.#frameTimer = null;
@@ -2024,6 +2128,9 @@ export class Body {
2024
2128
  // oldest-first, so the newest cut is at the front.
2025
2129
  if (cell.kind === "tool" && lines.some((l) => l.includes("ctrl+o")))
2026
2130
  this.#collapsed.unshift(i);
2131
+ // 4c: a fold is read in the viewer and nowhere else.
2132
+ if (cell.kind === "fold")
2133
+ this.#collapsed.unshift(i);
2027
2134
  this.#lineCache[i] = lines;
2028
2135
  const placed = this.#space(i, i > 0 ? this.#lineCache[i - 1] : null, lines);
2029
2136
  this.#committed += 1;
@@ -2848,8 +2955,8 @@ export class Dock {
2848
2955
  editCol() {
2849
2956
  return compositorRef?.editCol() ?? 1;
2850
2957
  }
2851
- redraw() {
2852
- compositorRef?.redraw();
2958
+ redraw(fromKey = false) {
2959
+ compositorRef?.redraw(fromKey);
2853
2960
  }
2854
2961
  }
2855
2962
  /** The one-compositor registry — the Dock façade routes to it. */
@@ -58,6 +58,11 @@ export interface ContextLedger {
58
58
  * the detail text rides after them, dim, and is cut by the caller's
59
59
  * width if it must be.
60
60
  */
61
+ /** 0.40.0 — the ONE fill rule for a ▰▱ meter: `ratio` of `cells`, rounded,
62
+ * clamped to the bar. The `/context` bar and the compacting row's bar both
63
+ * draw through it, so the two cannot fill differently. Plain glyphs; the
64
+ * caller styles them. */
65
+ export declare function meterGlyphs(ratio: number, cells: number): string;
61
66
  export declare function contextRows(ledger: ContextLedger): string[];
62
67
  /** TUI2-R1 (E) — the honest fallback. The ledger is written PER REQUEST:
63
68
  * a session that has not called the model yet has no sidecar, and the
@@ -45,15 +45,24 @@ function k(n) {
45
45
  * the detail text rides after them, dim, and is cut by the caller's
46
46
  * width if it must be.
47
47
  */
48
+ /** 0.40.0 — the ONE fill rule for a ▰▱ meter: `ratio` of `cells`, rounded,
49
+ * clamped to the bar. The `/context` bar and the compacting row's bar both
50
+ * draw through it, so the two cannot fill differently. Plain glyphs; the
51
+ * caller styles them. */
52
+ export function meterGlyphs(ratio, cells) {
53
+ const filled = Math.max(0, Math.min(cells, Math.round((Number.isFinite(ratio) ? ratio : 0) * cells)));
54
+ return `${"\u25b0".repeat(filled)}${"\u25b1".repeat(cells - filled)}`;
55
+ }
48
56
  export function contextRows(ledger) {
49
57
  const p = palette();
50
58
  const used = ledger.systemPrompt + ledger.toolTable + ledger.skillsIndex + ledger.envelope + ledger.messages;
51
59
  const free = Math.max(0, ledger.window - used);
52
60
  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)));
61
+ const bar = meterGlyphs(ratio, BAR_CELLS);
62
+ const filled = bar.indexOf("\u25b1") < 0 ? BAR_CELLS : bar.indexOf("\u25b1");
54
63
  const rows = [
55
64
  `${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}`,
65
+ `${p.bold}${bar.slice(0, filled)}${p.reset}${p.dim}${bar.slice(filled)}${p.reset}`,
57
66
  ];
58
67
  /** One surface row: the label at 14 columns, the count right-aligned
59
68
  * at 5, then the dim detail. */
package/dist/editor.d.ts CHANGED
@@ -65,7 +65,7 @@ export declare const MENU_ITEMS: readonly MenuItem[];
65
65
  export declare class Editor {
66
66
  #private;
67
67
  readonly closed: Promise<void>;
68
- constructor(onRender: () => void);
68
+ constructor(onRender: (fromKey: boolean) => void);
69
69
  /**
70
70
  * DC-7 — the terminal answering a question kiso asked it.
71
71
  *
@@ -153,6 +153,9 @@ export declare class Editor {
153
153
  items: readonly MenuItem[];
154
154
  selected: number;
155
155
  } | null;
156
+ /** 0.40.1 — bind the menu's extra entries (the CLI binds the installed
157
+ * skills). A function, read per keystroke, never a snapshot. */
158
+ bindMenuExtras(extras: () => readonly MenuItem[]): void;
156
159
  /** KC3 §3 — bind the file source. The tui owns no file list and
157
160
  * never touches a disk (input is data, output is bytes): the CLI
158
161
  * feeds the paths, and until it does, the picker cannot open at
@@ -168,7 +171,7 @@ export declare class Editor {
168
171
  capped: boolean;
169
172
  } | null;
170
173
  /** Open the picker on a bound card source (PickInput, S5). */
171
- beginPick(cards: () => readonly SessionCardView[], onPick: (id: string | null) => void): void;
174
+ beginPick(cards: () => readonly SessionCardView[], onPick: (id: string | null) => void, here?: string): void;
172
175
  pickState(): SessionPickState | null;
173
176
  /** One-shot question mode: the NEXT submit answers, not a turn. */
174
177
  question(_query: string, cb: (answer: string) => void): void;
package/dist/editor.js CHANGED
@@ -110,7 +110,7 @@ export const MOUSE_OFF = "\x1b[?1000l\x1b[?1006l";
110
110
  export const PROMPT = "▌ ";
111
111
  export const PROMPT_WIDTH = displayWidth(PROMPT);
112
112
  export const MENU_ITEMS = [
113
- { name: "/mode", desc: "switch the approval tier (manual/default/accept-edits/plan/bypass)" },
113
+ { name: "/mode", desc: "switch the approval tier (default/accept-edits/plan/dontAsk/bypass)" },
114
114
  { name: "/model", desc: "list model profiles; switch with /model <name|provider/model>" },
115
115
  { name: "/compact", desc: "summarize the older conversation to free context" },
116
116
  // the /resume+/clear mini-spec: the session-navigation pair
@@ -130,6 +130,10 @@ export const MENU_ITEMS = [
130
130
  { name: "/status", desc: "show session id, event count, and context estimate" },
131
131
  // TUI2-R1 (E): the rent-ledger attribution — where the context went
132
132
  { name: "/context", desc: "show where the context went — the last request's rent ledger" },
133
+ // 0.40.0: the person's door to the installed skills — `/<name>` works
134
+ // too when no command here has that name.
135
+ { name: "/skills", desc: "list the installed skills — /<name> [args] runs one" },
136
+ { name: "/skill", desc: "run a skill as your turn: /skill <name> [args]" },
133
137
  { name: "/help", desc: "print this list of commands" },
134
138
  ];
135
139
  /** KC1 §3 — the newline code point. Every source (paste, Ctrl+J, the
@@ -341,6 +345,14 @@ export class Editor {
341
345
  * KEY, the CLI owns what it means. */
342
346
  #copyCbs = [];
343
347
  #onRender;
348
+ /** Item 6: true while a chunk of keys is being fed — the render hook
349
+ * is told a frame is key-originated, and the compositor paints it on
350
+ * the next tick instead of waiting out the trailing frame window. */
351
+ #feeding = false;
352
+ /** Item 6: set when a key in this chunk handed control to the run —
353
+ * a submitted line or a panel verdict. What follows is run state, not
354
+ * typing, so its frames keep the run's trailing window. */
355
+ #handedOff = false;
344
356
  /** TUI2-R1 (D): the keys sheet — a static one-screen overlay opened by
345
357
  * `?` on an empty composer and closed by the next key, whatever it
346
358
  * is. Deliberately a BOOLEAN and not a panel: the panel machinery
@@ -348,6 +360,9 @@ export class Editor {
348
360
  * buffer), and the sheet has no interaction to speak of. */
349
361
  #sheetOpen = false;
350
362
  #menuOpen = false; // v3 §04: the slash-command menu
363
+ /** 0.40.1: the menu's extra entries (the installed skills), read LIVE on
364
+ * every keystroke — a skill added by /reload appears without rebinding. */
365
+ #menuExtras = () => [];
351
366
  #menuSel = 0;
352
367
  // KC3 §3 — the @ file picker. THREE fields and no more: the armed
353
368
  // bit, the selection, and the per-open SNAPSHOT of the file list.
@@ -420,7 +435,7 @@ export class Editor {
420
435
  #closedResolve;
421
436
  closed;
422
437
  constructor(onRender) {
423
- this.#onRender = onRender;
438
+ this.#onRender = () => onRender(this.#feeding && !this.#handedOff);
424
439
  this.#onData = (raw) => this.feed(raw);
425
440
  this.closed = new Promise((resolve) => {
426
441
  this.#closedResolve = resolve;
@@ -794,11 +809,28 @@ export class Editor {
794
809
  const line = this.line();
795
810
  if (!line.startsWith("/"))
796
811
  return [];
797
- return MENU_ITEMS.filter((m) => m.name.startsWith(line));
812
+ // 0.40.1: the built-ins first, then the extras (skills) — and a
813
+ // built-in WINS a shared name, so a skill named like a command is
814
+ // never listed twice and never shadows it (the dispatcher's rule)
815
+ const builtins = new Set(MENU_ITEMS.map((m) => m.name));
816
+ const all = [...MENU_ITEMS, ...this.#menuExtras().filter((m) => !builtins.has(m.name))];
817
+ return all.filter((m) => m.name.startsWith(line));
818
+ }
819
+ /** 0.40.1 — bind the menu's extra entries (the CLI binds the installed
820
+ * skills). A function, read per keystroke, never a snapshot. */
821
+ bindMenuExtras(extras) {
822
+ this.#menuExtras = extras;
798
823
  }
799
824
  #refreshMenu() {
800
- if (this.#panelInput.up())
801
- return; // W21: the menu never opens while the panel owns the keys
825
+ if (this.#panelInput.up()) {
826
+ // W21: the menu never opens while the panel owns the keys — but
827
+ // the key that got here still changed the line, and insert and
828
+ // delete render only through this method (item 6: returning
829
+ // before the render left a panel's typed text to ride the next
830
+ // spinner tick, 0–150 ms late, or no tick at all).
831
+ this.#onRender();
832
+ return;
833
+ }
802
834
  const f = this.#menuFiltered();
803
835
  this.#menuOpen = f.length > 0;
804
836
  if (this.#menuSel >= f.length)
@@ -938,8 +970,8 @@ export class Editor {
938
970
  }
939
971
  // ── TUI2-R2 ② — the session picker ───────────────────────────────
940
972
  /** Open the picker on a bound card source (PickInput, S5). */
941
- beginPick(cards, onPick) {
942
- this.#pickInput.begin(cards, onPick);
973
+ beginPick(cards, onPick, here) {
974
+ this.#pickInput.begin(cards, onPick, here);
943
975
  }
944
976
  pickState() {
945
977
  return this.#pickInput.state();
@@ -957,7 +989,15 @@ export class Editor {
957
989
  * stashed and restored at close, the panel takes the keys and the
958
990
  * input row's lead, the composer's own bands close. */
959
991
  beginPanel(view, onCommit, opts) {
960
- this.#panelInput.begin(view, onCommit, opts);
992
+ const handOff = (v) => {
993
+ // item 6: a verdict hands the frame to the run. The panel already
994
+ // asked for a key frame on this key; the non-key render after the
995
+ // verdict demotes it to the run's window.
996
+ this.#handedOff = true;
997
+ onCommit(v);
998
+ this.#onRender();
999
+ };
1000
+ this.#panelInput.begin(view, handOff, opts);
961
1001
  }
962
1002
  /** W21: cancel the panel — the SIGINT path's pair to beginPanel. */
963
1003
  cancelPanel() {
@@ -1074,6 +1114,21 @@ export class Editor {
1074
1114
  // ---- input ----
1075
1115
  /** Feed raw stdin bytes — the parser. Public for unit tests. */
1076
1116
  feed(raw) {
1117
+ const outer = this.#feeding;
1118
+ const outerHandedOff = this.#handedOff;
1119
+ this.#feeding = true;
1120
+ if (!outer)
1121
+ this.#handedOff = false;
1122
+ try {
1123
+ this.#feedKeys(raw);
1124
+ }
1125
+ finally {
1126
+ this.#feeding = outer;
1127
+ if (!outer)
1128
+ this.#handedOff = outerHandedOff;
1129
+ }
1130
+ }
1131
+ #feedKeys(raw) {
1077
1132
  const text = this.#pending + this.#decoder.decode(raw, { stream: true });
1078
1133
  this.#pending = "";
1079
1134
  // TUI2-R1 (D): the sheet is up — ANY key closes it, and the key
@@ -1512,6 +1567,10 @@ export class Editor {
1512
1567
  }
1513
1568
  i += 1;
1514
1569
  }
1570
+ else if (c === "\t" && this.#pickInput.toggleScope()) {
1571
+ // 0.40.0: the session picker's scope — this workspace ↔ all
1572
+ i += 1;
1573
+ }
1515
1574
  else if (c === "\t" && this.#atUp()) {
1516
1575
  // KC3 §3: Tab accepts the selected path — the token becomes
1517
1576
  // `@<path> `. Never the file's content.
@@ -2436,6 +2495,7 @@ export class Editor {
2436
2495
  // recalls a readable line that still expands when it is sent
2437
2496
  // again (the map outlives the buffer, by design).
2438
2497
  const sent = this.#expandPastes(line);
2498
+ this.#handedOff = true;
2439
2499
  const cb = this.#questionCb;
2440
2500
  this.#questionCb = null;
2441
2501
  if (cb !== null) {
@@ -2449,7 +2509,7 @@ export class Editor {
2449
2509
  }
2450
2510
  if (cb === null && line !== "")
2451
2511
  this.#remember(line);
2452
- this.#onRender();
2512
+ this.#onRender(); // item 6: #handedOff is set, so this render is non-key — the submit's frame keeps the run's window
2453
2513
  }
2454
2514
  /** A2: step the history browse; a delta past the newest exits back to
2455
2515
  * the pre-browse input. */
package/dist/index.d.ts CHANGED
@@ -12,12 +12,12 @@ export { Container, foldLine, foldWords, visibleWidth, SPINNER, type Component,
12
12
  export { Editor, MENU_ITEMS, PROMPT, PROMPT_WIDTH, displayWidth, charWidth, widthOf, type MenuItem, } from "./editor.js";
13
13
  export { bannerLines, COLOR_OFF, COLOR_ON, currentGround, setGround, 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 "./lines.js";
14
14
  export { editFileDiff, truncateDiff, writeFileDiff, type DiffLine, type DiffResult } from "./diff.js";
15
- export { STATUS_GLYPHS, cacheHitPct, decodeRate, idleStatus, runningStatus, type StatusMeter } from "./status.js";
15
+ export { STATUS_GLYPHS, cacheHitPct, compactingStatus, composeRow, decodeRate, idleStatus, retrySegment, runningStatus, type CompactingProgress, type RetryOnRow, type RowSegment, type StatusMeter } from "./status.js";
16
16
  export { contextRows, contextUnavailableRows, type ContextLedger } from "./context-ledger.js";
17
17
  export { interactivePrompt, projectTrustRows, projectTrustView, projectUntrustedNote, uncertainView, verifyOfferView, type TrustArtifact } from "./strings.js";
18
18
  export { AT_CAP, AT_SKIP, AT_VISIBLE, atEmbed, atFilter, atPanelRows, atWindow, bandHeader, longestRun, type AtItem, type AtMatch } from "./at-picker.js";
19
- export { BADGE_GLYPH, idColumn, sessionAge, sessionBadge, sessionCounterRow, sessionFilter, sessionListFooter, sessionListRow, sessionNote, sessionPickerRows, sessionRow, type SessionCardView, type SessionPickState, } from "./session-picker.js";
19
+ export { idColumn, sessionAge, sessionCounterRow, sessionFilter, sessionListFooter, sessionListHeader, sessionListUnknownLine, sessionListRow, sessionNote, sessionPickerRows, sessionRow, scopeSessions, scopeTitle, type PickScopeState, type SessionCardView, type SessionPickState, } from "./session-picker.js";
20
20
  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";
21
21
  export { resolveGround, type Ground } from "@vincemakes/kiso-tui-cells";
22
22
  export { settledLabel } from "@vincemakes/kiso-tui-cells";
23
- export { KEY_BINDINGS, PANEL_KEYS_ROW, displayVerb, extensionsBannerText, helpRows, keysHelpRow, keysSheetRows, slashCommandNames, unansweredAskView, type BannerExtension, type KeyBinding } from "./strings.js";
23
+ export { KEY_BINDINGS, PANEL_KEYS_ROW, coldResumeLine, coldResumeView, displayVerb, extensionsBannerText, helpRows, keysHelpRow, keysSheetRows, slashCommandNames, unansweredAskView, type BannerExtension, type KeyBinding } from "./strings.js";
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@ export { bannerLines, COLOR_OFF, COLOR_ON, currentGround, setGround, escapeTermi
19
19
  export { editFileDiff, truncateDiff, writeFileDiff } from "./diff.js";
20
20
  // KC2 §5: the status rows' formatters — the CLI keeps the state and the
21
21
  // repaint, the terminal layer owns what the row says.
22
- export { STATUS_GLYPHS, cacheHitPct, decodeRate, idleStatus, runningStatus } from "./status.js";
22
+ export { STATUS_GLYPHS, cacheHitPct, compactingStatus, composeRow, decodeRate, idleStatus, retrySegment, runningStatus } from "./status.js";
23
23
  // TUI2-R1 (E): /context's attribution rows — a pure function of the
24
24
  // counts the trace sidecar already records (the CLI reads, this renders).
25
25
  export { contextRows, contextUnavailableRows } from "./context-ledger.js";
@@ -33,7 +33,10 @@ export { AT_CAP, AT_SKIP, AT_VISIBLE, atEmbed, atFilter, atPanelRows, atWindow,
33
33
  // TUI2-R2 ①–③: the session picker's pure half — the durability badge,
34
34
  // the row (picked or printed), the band, and the filter. The CARDS are
35
35
  // the cli's projection (session-cards.ts); this turns them into bytes.
36
- export { BADGE_GLYPH, idColumn, sessionAge, sessionBadge, sessionCounterRow, sessionFilter, sessionListFooter, sessionListRow, sessionNote, sessionPickerRows, sessionRow, } from "./session-picker.js";
36
+ export { idColumn, sessionAge, sessionCounterRow, sessionFilter, sessionListFooter, sessionListHeader, sessionListUnknownLine, sessionListRow, sessionNote, sessionPickerRows, sessionRow,
37
+ // 0.40.0: the workspace scope — pure, so `kiso sessions` and the picker
38
+ // scope by one rule
39
+ scopeSessions, scopeTitle, } from "./session-picker.js";
37
40
  // KC3.5 (the ask round): the ask view — the panel machinery generalized.
38
41
  // The cli composes the view and hands the answers to the tool; the keys,
39
42
  // the rows and the walk are the terminal layer's.
@@ -46,4 +49,4 @@ export { resolveGround } from "@vincemakes/kiso-tui-cells";
46
49
  // one duration form: the CLI's own surfaces label a settled duration the
47
50
  // way a settled card does, rather than writing a second one.
48
51
  export { settledLabel } from "@vincemakes/kiso-tui-cells";
49
- export { KEY_BINDINGS, PANEL_KEYS_ROW, displayVerb, extensionsBannerText, helpRows, keysHelpRow, keysSheetRows, slashCommandNames, unansweredAskView } from "./strings.js";
52
+ export { KEY_BINDINGS, PANEL_KEYS_ROW, coldResumeLine, coldResumeView, displayVerb, extensionsBannerText, helpRows, keysHelpRow, keysSheetRows, slashCommandNames, unansweredAskView } from "./strings.js";
package/dist/lines.d.ts CHANGED
@@ -169,6 +169,11 @@ export interface RecapStats {
169
169
  * passed only when above the noise floor — the re-sent-uncached
170
170
  * prefix. Absent → the recap bytes stay the historical form. */
171
171
  readonly missed?: number;
172
+ /** 0.40.0 (the owner): the turn's first request came this many minutes
173
+ * after the session's last bill, and the prompt cache had gone cold —
174
+ * the recap says so instead of a bare `fresh 730k · cache 0%`, which
175
+ * is true and reads like a fault. Absent → the historical form. */
176
+ readonly coldAfterMinutes?: number;
172
177
  readonly ctxLeftPct: number | null;
173
178
  /** R3g — the terminal's width. The recap is the ONE row on the screen
174
179
  * that was never measured: it is written raw, so a line longer than
package/dist/lines.js CHANGED
@@ -96,8 +96,8 @@ export function renderEvent(ev, prevThinking = false, resolvePath = (p) => p) {
96
96
  case "compacted":
97
97
  return { text: `${p.dim} [compacted ${ev.cleared.length} results]${p.reset}\n`, newline: true, prompt: false };
98
98
  case "summarized":
99
- // ADR-0044: the /compact event is OFF-LOOP — it never appears in
100
- // a run stream; rendered for the switch's completeness only.
99
+ // ADR-0044: /compact's event is off-loop; since ADR-0055 Amendment 1
100
+ // the in-run tiers also append one mid-run, and this is its line.
101
101
  return { text: `${p.dim} [summarized up to seq ${ev.coversToSeq}]${p.reset}\n`, newline: true, prompt: false };
102
102
  case "uncertain_pending":
103
103
  return {
@@ -211,7 +211,16 @@ export function renderRecap(s) {
211
211
  ? [`${s.tools} tool${s.tools === 1 ? "" : "s"}${edits > 0 ? ` (${edits} edit${edits === 1 ? "" : "s"})` : ""}`]
212
212
  : [];
213
213
  const parts = [`took ${elapsedLabel(s.seconds)}`, ...work];
214
- if (s.usage.known) {
214
+ // The cold turn: the gap and the re-read are the FACTS and come first,
215
+ // so a cut from the end never reaches them; "cache cold after" is the
216
+ // label, and it is the first thing shortened when the row is narrow.
217
+ const cold = s.coldAfterMinutes !== undefined && s.usage.known && s.usage.in !== null;
218
+ if (cold) {
219
+ parts.push(`cache cold after ${s.coldAfterMinutes} min`, `re-read ${kUnit(s.missed ?? s.usage.in ?? 0)}`);
220
+ if (s.usage.out !== null)
221
+ parts.push(`out ${kUnit(s.usage.out)}`);
222
+ }
223
+ else if (s.usage.known) {
215
224
  const seg = `${s.usage.in !== null ? `fresh ${kUnit(s.usage.in)}` : ""}${s.usage.in !== null && s.usage.out !== null ? " " : ""}${s.usage.out !== null ? `out ${kUnit(s.usage.out)}` : ""}`;
216
225
  if (seg !== "")
217
226
  parts.push(seg);
@@ -230,7 +239,13 @@ export function renderRecap(s) {
230
239
  // R3g: ONE physical row, at any width — the same rule every other row
231
240
  // in the product obeys. The cut is the honest "…": the recap said
232
241
  // more than fits, and says so.
233
- const line = parts.join(" · ");
242
+ let line = parts.join(" · ");
243
+ // A cold turn that does not fit sheds its LABEL first, then the cut
244
+ // below takes the tail — the gap and the re-read stand in front of it.
245
+ if (cold && s.width !== undefined && s.width >= 20 && visibleWidth(`✦ ${line}`) > s.width) {
246
+ parts[parts.indexOf(`cache cold after ${s.coldAfterMinutes} min`)] = `cold ${s.coldAfterMinutes} min`;
247
+ line = parts.join(" · ");
248
+ }
234
249
  // R3g: the floor is the renderer's own guard against a caller that
235
250
  // hands it a degenerate width (a PTY with no winsize reports 0). A
236
251
  // recap cut to one character is worse than one that wraps.
@@ -9,13 +9,17 @@ export declare class PickInput {
9
9
  * (the buffer becomes the filter query) and `onPick` receives the
10
10
  * chosen id — or null when the human leaves without picking, which
11
11
  * is a first-class outcome and not an error. */
12
- begin(cards: () => readonly SessionCardView[], onPick: (id: string | null) => void): void;
12
+ begin(cards: () => readonly SessionCardView[], onPick: (id: string | null) => void, here?: string): void;
13
13
  /** The picker's state, derived: the full card list (the id column
14
14
  * measures over ALL of them, so the columns never jump), the
15
15
  * filtered matches, and the selection CLAMPED at read time — the
16
16
  * same correction discipline the @ picker uses, for the same
17
17
  * reason: narrowing can only ever shrink the list. */
18
18
  state(): SessionPickState | null;
19
+ /** 0.40.0 — tab flips CURRENT ↔ ALL. The filter owns every printable
20
+ * key (the buffer IS the query), so the toggle cannot be one. True when
21
+ * the picker owned the key. */
22
+ toggleScope(): boolean;
19
23
  /** The band's height estimate: the header + the windowed rows (or
20
24
  * the one "no match" row) + the counter. */
21
25
  rows(): number;