@vincemakes/kiso-tui 0.1.39 → 0.1.41

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/diff.d.ts CHANGED
@@ -1,32 +1,4 @@
1
- /**
2
- * v2e the diff renderer: edit/write changes as inline ± lines, zero
3
- * dependencies, no syntax highlighting (the spec's scope line). Shown at
4
- * the approval moment ONLY — the frozen summary stays one line (v2d's
5
- * anti-leak principle), /last has the full data.
6
- *
7
- * edit_file diffs IN PLACE (the search→replace windows are known — no
8
- * general engine needed); write_file does a row-level LCS over the old
9
- * file (small files are the target). Context: 2 rows each side. The
10
- * RENDERER truncates (18 head + 18 tail + "… N lines"); the stats come
11
- * from the full diff.
12
- */
13
- /** The diff block's per-row kind. */
14
- export type DiffLine = {
15
- kind: "-" | "+" | " ";
16
- text: string;
17
- };
18
- export interface DiffResult {
19
- /** The FULL diff (with context, not truncated) — the display truncates. */
20
- lines: DiffLine[];
21
- added: number;
22
- removed: number;
23
- }
24
- /** The RENDERER's truncation: head + "… N lines (/last for full)" + tail. */
25
- export declare function truncateDiff(diff: DiffLine[]): DiffLine[];
26
- /** edit_file: the search→replace windows replace in place — the changed
27
- * region is KNOWN, so the diff is the old window vs the new window,
28
- * context from the surrounding file. */
29
- export declare function editFileDiff(oldContent: string, search: string, replace: string): DiffResult;
30
- /** write_file: a new file is all +; an existing file diffs row-level
31
- * against its old content. */
32
- export declare function writeFileDiff(oldContent: string | null, newContent: string): DiffResult;
1
+ /** The diff renderer moved to tui-cells (ADR-0043 Amendment 4) —
2
+ * this module is the re-export shim: the compositor's and index.ts's
3
+ * imports (./diff.js) stay verbatim. */
4
+ export * from "@vincemakes/kiso-tui-cells/diff";
package/dist/diff.js CHANGED
@@ -1,122 +1,4 @@
1
- /**
2
- * v2e the diff renderer: edit/write changes as inline ± lines, zero
3
- * dependencies, no syntax highlighting (the spec's scope line). Shown at
4
- * the approval moment ONLY — the frozen summary stays one line (v2d's
5
- * anti-leak principle), /last has the full data.
6
- *
7
- * edit_file diffs IN PLACE (the search→replace windows are known — no
8
- * general engine needed); write_file does a row-level LCS over the old
9
- * file (small files are the target). Context: 2 rows each side. The
10
- * RENDERER truncates (18 head + 18 tail + "… N lines"); the stats come
11
- * from the full diff.
12
- */
13
- /** A line-level LCS diff — the classic two-row DP, ~small inputs. */
14
- function lcsDiff(oldLines, newLines) {
15
- const n = oldLines.length;
16
- const m = newLines.length;
17
- const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
18
- for (let i = n - 1; i >= 0; i -= 1) {
19
- for (let j = m - 1; j >= 0; j -= 1) {
20
- dp[i][j] = oldLines[i] === newLines[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
21
- }
22
- }
23
- const out = [];
24
- let i = 0;
25
- let j = 0;
26
- while (i < n && j < m) {
27
- if (oldLines[i] === newLines[j]) {
28
- out.push({ kind: " ", text: oldLines[i] });
29
- i += 1;
30
- j += 1;
31
- }
32
- else if (dp[i + 1][j] >= dp[i][j + 1]) {
33
- out.push({ kind: "-", text: oldLines[i] });
34
- i += 1;
35
- }
36
- else {
37
- out.push({ kind: "+", text: newLines[j] });
38
- j += 1;
39
- }
40
- }
41
- while (i < n) {
42
- out.push({ kind: "-", text: oldLines[i] });
43
- i += 1;
44
- }
45
- while (j < m) {
46
- out.push({ kind: "+", text: newLines[j] });
47
- j += 1;
48
- }
49
- return out;
50
- }
51
- /** Keep 2 context rows around each change — the unified-style window. */
52
- function withContext(diff) {
53
- const out = [];
54
- let lastAdded = -10;
55
- for (let k = 0; k < diff.length; k += 1) {
56
- if (diff[k].kind === " ")
57
- continue;
58
- const from = Math.max(0, k - 2);
59
- const to = Math.min(diff.length - 1, k + 2);
60
- for (let c = from; c <= to; c += 1) {
61
- if (c > lastAdded) {
62
- out.push(diff[c]);
63
- lastAdded = c;
64
- }
65
- }
66
- lastAdded = to;
67
- }
68
- return out;
69
- }
70
- const MAX_DIFF_LINES = 40; // the RENDERED cap
71
- const TRUNCATE_KEEP = 18;
72
- /** The RENDERER's truncation: head + "… N lines (/last for full)" + tail. */
73
- export function truncateDiff(diff) {
74
- if (diff.length <= MAX_DIFF_LINES)
75
- return diff;
76
- const omitted = diff.length - 2 * TRUNCATE_KEEP;
77
- return [
78
- ...diff.slice(0, TRUNCATE_KEEP),
79
- { kind: " ", text: `… ${omitted} lines (/last for full)` },
80
- ...diff.slice(diff.length - TRUNCATE_KEEP),
81
- ];
82
- }
83
- function stats(diff) {
84
- let added = 0;
85
- let removed = 0;
86
- for (const d of diff) {
87
- if (d.kind === "+")
88
- added += 1;
89
- else if (d.kind === "-")
90
- removed += 1;
91
- }
92
- return { added, removed };
93
- }
94
- /** edit_file: the search→replace windows replace in place — the changed
95
- * region is KNOWN, so the diff is the old window vs the new window,
96
- * context from the surrounding file. */
97
- export function editFileDiff(oldContent, search, replace) {
98
- const oldLines = oldContent.split("\n");
99
- const searchLines = search.split("\n");
100
- const replaceLines = replace.split("\n");
101
- // Locate the search window (the first occurrence — the edit tool's own
102
- // semantics); no occurrence → the whole file is the old side.
103
- let at = -1;
104
- for (let i = 0; i + searchLines.length <= oldLines.length; i += 1) {
105
- if (oldLines.slice(i, i + searchLines.length).join("\n") === search) {
106
- at = i;
107
- break;
108
- }
109
- }
110
- const lines = at < 0 ? withContext(lcsDiff(oldLines, replaceLines)) : withContext(lcsDiff(oldLines, [...oldLines.slice(0, at), ...replaceLines, ...oldLines.slice(at + searchLines.length)]));
111
- return { lines, ...stats(lines) };
112
- }
113
- /** write_file: a new file is all +; an existing file diffs row-level
114
- * against its old content. */
115
- export function writeFileDiff(oldContent, newContent) {
116
- if (oldContent === null) {
117
- const lines = newContent.split("\n").map((text) => ({ kind: "+", text }));
118
- return { lines, added: lines.length, removed: 0 };
119
- }
120
- const lines = withContext(lcsDiff(oldContent.split("\n"), newContent.split("\n")));
121
- return { lines, ...stats(lines) };
122
- }
1
+ /** The diff renderer moved to tui-cells (ADR-0043 Amendment 4) —
2
+ * this module is the re-export shim: the compositor's and index.ts's
3
+ * imports (./diff.js) stay verbatim. */
4
+ export * from "@vincemakes/kiso-tui-cells/diff";
package/dist/editor.d.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { charWidth, displayWidth, widthOf } from "./width.js";
20
20
  export { charWidth, displayWidth, widthOf };
21
+ import { type PanelState, type PanelVerdict, type PanelView } from "./approval-panel.js";
21
22
  export declare const PROMPT = "\u258C ";
22
23
  export declare const PROMPT_WIDTH: number;
23
24
  /** v3 §04 — the slash-command menu's command table (English one-liners). */
@@ -42,6 +43,10 @@ export declare class Editor {
42
43
  onEot(cb: () => void): void;
43
44
  onEscape(cb: () => void): void;
44
45
  onExpand(cb: () => void): void;
46
+ /** W22: bind the pending-turn queue — the CLI's live slots. The ↑
47
+ * pop walks them (each pop leaves the queue, cancelling the turn);
48
+ * esc ends the walk after one more pop. */
49
+ bindQueue(state: () => readonly string[], pop: () => string | null): void;
45
50
  /** The whole buffer as text (the CLI's line()/clearLine()). */
46
51
  line(): string;
47
52
  clearLine(): void;
@@ -61,6 +66,15 @@ export declare class Editor {
61
66
  /** Cancel a pending question — the buffer stays (its text becomes the
62
67
  * next turn on Enter, the readline re-emit equivalent). */
63
68
  cancelQuestion(): void;
69
+ /** W21: open the approval panel. The current buffer is stashed
70
+ * (restored at close — commit AND cancel), the panel takes the
71
+ * keys and the input row's lead, the menu closes. */
72
+ beginPanel(view: PanelView, onCommit: (v: PanelVerdict) => void): void;
73
+ /** W21: cancel the panel — the SIGINT path's pair to beginPanel. */
74
+ cancelPanel(): void;
75
+ /** W21: the compositor's bound view — the phase/selection while the
76
+ * panel is up, null otherwise. */
77
+ panelState(): PanelState | null;
64
78
  enter(): void;
65
79
  exit(): void;
66
80
  /** The row's own render when the dock is inactive (a TTY without a
package/dist/editor.js CHANGED
@@ -16,11 +16,12 @@
16
16
  * The editor is a SINGLE line: bracketed paste (?2004h) unwraps and
17
17
  * inserts, internal newlines become spaces.
18
18
  */
19
- import { charWidth, displayWidth, widthOf } from "./width.js";
19
+ import { charWidth, displayWidth, leadWidth, widthOf } from "./width.js";
20
20
  // the width primitives moved to width.ts (W1, the single width
21
21
  // authority) — re-exported so the editor's public surface is unchanged.
22
22
  export { charWidth, displayWidth, widthOf };
23
23
  import { palette } from "./render.js";
24
+ import { panelLead } from "./approval-panel.js";
24
25
  // TUI v4 #16d: the input row is the blue brick + the edit area — the
25
26
  // "you>" text is gone (the brick IS the prompt; the pipe path's readline
26
27
  // prompt keeps its own "you> " — v2a line mode, byte-for-byte).
@@ -47,6 +48,14 @@ export class Editor {
47
48
  #cursor = 0;
48
49
  #scroll = 0; // chars scrolled off the left (width-based reflow)
49
50
  #questionCb = null;
51
+ // W21: the panel state machine — the approval/trust panel owns the
52
+ // interaction while up: the digit/y/n/esc/tab routing, the rule
53
+ // input, the tab-amend feedback, the phase/selection the compositor
54
+ // renders. The menu never opens while a panel is up; the pre-panel
55
+ // buffer is stashed at open and restored at close (commit AND
56
+ // cancel) — the panel's rule/feedback text never leaks into the
57
+ // user's next turn.
58
+ #panel = null;
50
59
  #pasting = false;
51
60
  #lineCb = null;
52
61
  #pendingLines = []; // submits before onLine is wired (startup) — never dropped
@@ -70,6 +79,16 @@ export class Editor {
70
79
  #history = [];
71
80
  #historyIdx = null;
72
81
  #preBrowse = [];
82
+ // W22: the pending-turn queue's bound state — the CLI's live slots
83
+ // (chat.ts). ↑ pops the LAST queued message into the buffer and
84
+ // enters the pop-mode (the walk: repeated ↑ pop older ones, each
85
+ // replacing the line); esc in the pop-mode pops once more and ENDS
86
+ // the mode — the next esc at rest rides the escapeCbs (the
87
+ // interrupt survives). The chips themselves are the compositor's
88
+ // bindQueue; this is the keys only.
89
+ #queueState = () => [];
90
+ #queuePop = null;
91
+ #queuePopMode = false;
73
92
  #pending = ""; // an incomplete ESC/CSI prefix across chunks
74
93
  #decoder = new TextDecoder();
75
94
  #entered = false;
@@ -103,6 +122,13 @@ export class Editor {
103
122
  onExpand(cb) {
104
123
  this.#expandCbs.push(cb);
105
124
  }
125
+ /** W22: bind the pending-turn queue — the CLI's live slots. The ↑
126
+ * pop walks them (each pop leaves the queue, cancelling the turn);
127
+ * esc ends the walk after one more pop. */
128
+ bindQueue(state, pop) {
129
+ this.#queueState = state;
130
+ this.#queuePop = pop;
131
+ }
106
132
  /** The whole buffer as text (the CLI's line()/clearLine()). */
107
133
  line() {
108
134
  return String.fromCodePoint(...this.#chars);
@@ -136,6 +162,8 @@ export class Editor {
136
162
  return MENU_ITEMS.filter((m) => m.name.startsWith(line));
137
163
  }
138
164
  #refreshMenu() {
165
+ if (this.#panel !== null)
166
+ return; // W21: the menu never opens while the panel owns the keys
139
167
  const f = this.#menuFiltered();
140
168
  this.#menuOpen = f.length > 0;
141
169
  if (this.#menuSel >= f.length)
@@ -151,6 +179,38 @@ export class Editor {
151
179
  cancelQuestion() {
152
180
  this.#questionCb = null;
153
181
  }
182
+ /** W21: open the approval panel. The current buffer is stashed
183
+ * (restored at close — commit AND cancel), the panel takes the
184
+ * keys and the input row's lead, the menu closes. */
185
+ beginPanel(view, onCommit) {
186
+ this.#panel = {
187
+ view,
188
+ phase: "options",
189
+ sel: 0,
190
+ amend: "yes",
191
+ onCommit,
192
+ stash: { chars: this.#chars, cursor: this.#cursor, scroll: this.#scroll },
193
+ };
194
+ this.#chars = [];
195
+ this.#cursor = 0;
196
+ this.#scroll = 0;
197
+ this.#menuOpen = false;
198
+ this.#menuSel = 0;
199
+ this.#queuePopMode = false; // W22: the panel owns the keys while up
200
+ this.#onRender();
201
+ }
202
+ /** W21: cancel the panel — the SIGINT path's pair to beginPanel. */
203
+ cancelPanel() {
204
+ this.#panelClose({ action: "cancel" });
205
+ }
206
+ /** W21: the compositor's bound view — the phase/selection while the
207
+ * panel is up, null otherwise. */
208
+ panelState() {
209
+ const panel = this.#panel;
210
+ if (panel === null)
211
+ return null;
212
+ return { view: panel.view, phase: panel.phase, sel: panel.sel };
213
+ }
154
214
  enter() {
155
215
  if (this.#entered)
156
216
  return;
@@ -176,8 +236,17 @@ export class Editor {
176
236
  const p = palette();
177
237
  const st = this.dockState();
178
238
  const W = (process.stdout.columns ?? 0) || 80; // a degenerate 0 size (no TIOCSWINSZ) falls back
179
- const cursorCol = Math.min(1 + PROMPT_WIDTH + st.cursor, W);
180
- process.stdout.write(`\r\x1b[0K${p.bold}${PROMPT}${p.reset}${st.line}\x1b[${cursorCol}G`);
239
+ // W21: the panel's lead owns the row while up (the brick returns
240
+ // when the panel closes).
241
+ const panel = this.#panel;
242
+ const lead = panel !== null ? panelLead(panel.view, panel.phase, panel.sel) : `${p.bold}${PROMPT}${p.reset}`;
243
+ // W23: the ONE width authority — leadWidth(lead), the ANSI-stripped
244
+ // visible width (the styled panel lead / the styled brick measure
245
+ // the same as their plain text — a lead can never measure
246
+ // differently at the editor than at the compositor)
247
+ const leadW = leadWidth(lead);
248
+ const cursorCol = Math.min(1 + leadW + st.cursor, W);
249
+ process.stdout.write(`\r\x1b[0K${lead}${st.line}\x1b[${cursorCol}G`);
181
250
  }
182
251
  // ---- input ----
183
252
  /** Feed raw stdin bytes — the parser. Public for unit tests. */
@@ -187,6 +256,50 @@ export class Editor {
187
256
  let i = 0;
188
257
  while (i < text.length) {
189
258
  const c = text[i];
259
+ if (this.#panel !== null) {
260
+ // W21: the panel owns the keys — the digits/y/n select in
261
+ // the options phase (digit 2 jumps to the rule input), tab
262
+ // opens the amend (approval only), esc backs out (rule/
263
+ // amend → options, selection → rest, rest → cancel), enter
264
+ // commits by phase. CSI/SS3 and the editing keys still ride
265
+ // the normal chain below (the rule/amend lines are free
266
+ // text); ctrl-c still rides the SIGINT handler (which
267
+ // cancels the panel).
268
+ const panel = this.#panel;
269
+ if (c === "\x1b" && !text.slice(i + 1).startsWith("[") && !text.slice(i + 1).startsWith("O")) {
270
+ this.#panelEsc();
271
+ i += 1;
272
+ continue;
273
+ }
274
+ if (c === "\t") {
275
+ if (panel.phase === "options")
276
+ this.#panelTab();
277
+ i += 1;
278
+ continue;
279
+ }
280
+ if (c === "\x0d" || c === "\x0a") {
281
+ this.#panelEnter();
282
+ i += 1;
283
+ continue;
284
+ }
285
+ if (c === "1" || c === "y" || c === "Y") {
286
+ if (panel.phase === "options")
287
+ this.#panelSelect(1);
288
+ i += 1;
289
+ continue;
290
+ }
291
+ if (c === "2" && panel.phase === "options" && panel.view.flavor === "approval") {
292
+ this.#panelRule();
293
+ i += 1;
294
+ continue;
295
+ }
296
+ if (c === "3" || c === "n" || c === "N") {
297
+ if (panel.phase === "options")
298
+ this.#panelSelect(3);
299
+ i += 1;
300
+ continue;
301
+ }
302
+ }
190
303
  if (c === "\x1b") {
191
304
  const rest = text.slice(i + 1);
192
305
  if (rest.startsWith("[")) {
@@ -203,19 +316,32 @@ export class Editor {
203
316
  }
204
317
  else if (this.#menuOpen) {
205
318
  // v3 §04: Esc closes the menu and clears the buffer.
319
+ // CA-4: the closing esc consumes its burst (the `i += 1`
320
+ // convention) — a double-esc can never abort the turn.
206
321
  this.#chars = [];
207
322
  this.#cursor = 0;
208
323
  this.#scroll = 0;
209
324
  this.#refreshMenu();
325
+ i += 1;
326
+ }
327
+ else if (this.#queuePopMode) {
328
+ // W22: esc in the pop-mode — ONE more pop, then the
329
+ // mode ends: the next esc at rest rides the escapeCbs
330
+ // (the interrupt chain survives the walk).
331
+ this.#queuePopMode = false;
332
+ this.#queuePopIntoBuffer();
333
+ i += 1;
210
334
  }
211
335
  else if (this.#historyIdx !== null) {
212
336
  // A2: Esc exits the history browse — the pre-browse
213
- // (empty) input returns.
337
+ // (empty) input returns. CA-4: the exiting esc consumes
338
+ // its burst — a double-esc can never abort the turn.
214
339
  this.#historyIdx = null;
215
340
  this.#chars = [...this.#preBrowse];
216
341
  this.#cursor = this.#chars.length;
217
342
  this.#reflow();
218
343
  this.#onRender();
344
+ i += 1;
219
345
  }
220
346
  else {
221
347
  for (const cb of [...this.#escapeCbs])
@@ -312,13 +438,27 @@ export class Editor {
312
438
  // v3 §04: the menu owns ↑↓ while open (the selection, never the
313
439
  // cursor). A2 (the feel): otherwise ↑↓ navigate the session history
314
440
  // — ONLY from an empty input or while already browsing; mid-edit
315
- // the cursor semantics are unchanged (↑↓ do nothing).
316
- if (this.#menuOpen) {
441
+ // the cursor semantics are unchanged (↑↓ do nothing). W21: the
442
+ // panel owns the keys while up (↑↓ do nothing — the panel has no
443
+ // ↑↓ role).
444
+ if (this.#panel !== null) {
445
+ /* the panel owns the keys */
446
+ }
447
+ else if (this.#menuOpen) {
317
448
  if (final === "A")
318
449
  this.#menuSel = Math.max(0, this.#menuSel - 1);
319
450
  else
320
451
  this.#menuSel = Math.min(this.#menuFiltered().length - 1, this.#menuSel + 1);
321
452
  }
453
+ else if (final === "A" && this.#queuePop !== null && (this.#queuePopMode || this.line() === "") && this.#queueState().length > 0) {
454
+ // W22: ↑ pops the LAST queued message into the buffer — the
455
+ // walk: repeated presses pop older ones (each replaces the
456
+ // line, the cursor at the end); esc ends the mode after one
457
+ // more pop. Mid-edit the pop never fires (the A2
458
+ // non-destructive feel, mirroring the history browse).
459
+ this.#queuePopMode = true;
460
+ this.#queuePopIntoBuffer();
461
+ }
322
462
  else if (this.#historyIdx !== null || this.line() === "") {
323
463
  this.#historyMove(final === "A" ? -1 : 1);
324
464
  }
@@ -339,10 +479,104 @@ export class Editor {
339
479
  this.#reflow();
340
480
  }
341
481
  }
482
+ // ---- W21: the panel state machine ----
483
+ #panelSelect(sel) {
484
+ const panel = this.#panel;
485
+ if (panel === null)
486
+ return;
487
+ panel.sel = sel;
488
+ this.#onRender();
489
+ }
490
+ /** digit 2 — the rule input: the buffer prefilled with the tool name
491
+ * (the option-2 prefill; enter commits the rule). */
492
+ #panelRule() {
493
+ const panel = this.#panel;
494
+ if (panel === null)
495
+ return;
496
+ panel.phase = "rule";
497
+ panel.sel = 2;
498
+ this.#chars = [...panel.view.name].map((ch) => ch.codePointAt(0));
499
+ this.#cursor = this.#chars.length;
500
+ this.#scroll = 0;
501
+ this.#onRender();
502
+ }
503
+ /** tab — the amend phase on the selected option (yes/deny); the
504
+ * simple flavor never has it (options 1/3 only, no option 2). */
505
+ #panelTab() {
506
+ const panel = this.#panel;
507
+ if (panel === null || panel.view.flavor !== "approval")
508
+ return;
509
+ panel.amend = panel.sel === 3 ? "no" : "yes";
510
+ panel.phase = "amend";
511
+ this.#chars = [];
512
+ this.#cursor = 0;
513
+ this.#scroll = 0;
514
+ this.#onRender();
515
+ }
516
+ /** esc — back out of the rule/amend to the options (the buffer
517
+ * clears), deselect, or cancel the panel at rest. */
518
+ #panelEsc() {
519
+ const panel = this.#panel;
520
+ if (panel === null)
521
+ return;
522
+ if (panel.phase !== "options") {
523
+ panel.phase = "options";
524
+ panel.sel = 0;
525
+ this.#chars = [];
526
+ this.#cursor = 0;
527
+ this.#scroll = 0;
528
+ this.#onRender();
529
+ return;
530
+ }
531
+ if (panel.sel !== 0) {
532
+ panel.sel = 0;
533
+ this.#onRender();
534
+ return;
535
+ }
536
+ this.#panelClose({ action: "cancel" });
537
+ }
538
+ /** enter — commit by phase: the rule input (the tool name when
539
+ * empty), the amend feedback (the bare verdict when empty), or the
540
+ * selected option (nothing at rest — an accidental enter never
541
+ * approves). Enter on the selected option 2 is the digit-2 key. */
542
+ #panelEnter() {
543
+ const panel = this.#panel;
544
+ if (panel === null)
545
+ return;
546
+ const line = this.line();
547
+ if (panel.phase === "rule") {
548
+ this.#panelClose({ action: "allow-rule", rule: line === "" ? panel.view.name : line });
549
+ return;
550
+ }
551
+ if (panel.phase === "amend") {
552
+ this.#panelClose(panel.amend === "yes" ? { action: "allow", reason: line } : { action: "deny", reason: line });
553
+ return;
554
+ }
555
+ if (panel.sel === 1)
556
+ this.#panelClose({ action: "allow", reason: "" });
557
+ else if (panel.sel === 2)
558
+ this.#panelRule();
559
+ else if (panel.sel === 3)
560
+ this.#panelClose({ action: "deny", reason: "" });
561
+ }
562
+ #panelClose(verdict) {
563
+ const panel = this.#panel;
564
+ if (panel === null)
565
+ return;
566
+ this.#panel = null;
567
+ // the pre-panel buffer returns — the panel's rule/feedback text
568
+ // never leaks into the user's next turn (commit AND cancel).
569
+ this.#chars = [...panel.stash.chars];
570
+ this.#cursor = panel.stash.cursor;
571
+ this.#scroll = panel.stash.scroll;
572
+ this.#onRender();
573
+ panel.onCommit(verdict);
574
+ }
342
575
  // ---- editing ----
343
576
  #insert(cp) {
344
577
  if (this.#historyIdx !== null)
345
578
  this.#historyIdx = null; // editing leaves the browse
579
+ this.#queuePopMode = false; // W22: editing leaves the pop-walk too
346
580
  this.#chars.splice(this.#cursor, 0, cp);
347
581
  this.#cursor += 1;
348
582
  this.#reflow();
@@ -354,6 +588,7 @@ export class Editor {
354
588
  return;
355
589
  if (this.#historyIdx !== null)
356
590
  this.#historyIdx = null; // editing leaves the browse
591
+ this.#queuePopMode = false; // W22: editing leaves the pop-walk too
357
592
  this.#chars.splice(this.#cursor - 1, 1);
358
593
  this.#cursor -= 1;
359
594
  this.#reflow();
@@ -420,6 +655,7 @@ export class Editor {
420
655
  this.#scroll = 0;
421
656
  this.#menuOpen = false;
422
657
  this.#menuSel = 0;
658
+ this.#queuePopMode = false; // W22: a submit ends the pop-walk — the next esc at rest interrupts again
423
659
  const cb = this.#questionCb;
424
660
  this.#questionCb = null;
425
661
  if (cb !== null) {
@@ -467,10 +703,33 @@ export class Editor {
467
703
  this.#cursor = this.#chars.length;
468
704
  this.#reflow();
469
705
  }
706
+ /** W22: pop the LAST queued message into the buffer (the walk's
707
+ * step — ↑ enters/stays in the pop-mode, esc's pop ends it). The
708
+ * chip leaves the queue (cancelled in the CLI), the line becomes
709
+ * the popped text, the cursor sits at the end. */
710
+ #queuePopIntoBuffer() {
711
+ if (this.#queuePop === null)
712
+ return;
713
+ const line = this.#queuePop();
714
+ if (line === null)
715
+ return;
716
+ this.#chars = [...line].map((ch) => ch.codePointAt(0));
717
+ this.#cursor = this.#chars.length;
718
+ this.#scroll = 0;
719
+ this.#onRender();
720
+ }
470
721
  // ---- width-based horizontal scroll ----
471
722
  #reflow() {
472
723
  const W = (process.stdout.columns ?? 0) || 80; // degenerate 0 falls back to 80
473
- const maxW = Math.max(1, W - PROMPT_WIDTH - 4); // W6: the box's walls (2+2) the visible line fits the box's inner width; the "…" rides inside
724
+ // W21: the panel's phase lead owns the input row while up the
725
+ // line's max width follows the lead (the rule/amend leads are
726
+ // wider than the brick).
727
+ // W23: the ONE width authority — leadWidth(lead) — the cap follows
728
+ // the lead the editor itself renders (the panel lead when the panel
729
+ // owns the keys, the brick otherwise): maxW = W − walls − lead.
730
+ const lead = this.#panel !== null ? panelLead(this.#panel.view, this.#panel.phase, this.#panel.sel) : PROMPT;
731
+ const leadW = leadWidth(lead);
732
+ const maxW = Math.max(1, W - leadW - 4); // W6: the box's walls (2+2) — the visible line fits the box's inner width; the "…" rides inside
474
733
  const curCol = widthOf(this.#chars.slice(0, this.#cursor));
475
734
  const scrolledW = widthOf(this.#chars.slice(0, this.#scroll));
476
735
  if (curCol < scrolledW) {
package/dist/index.d.ts CHANGED
@@ -6,7 +6,8 @@
6
6
  * editor, the diff renderer, and the palette.
7
7
  */
8
8
  export { Body, Dock, CURSOR_MARKER, type BodyOptions } from "./compositor.js";
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";
9
10
  export { Container, foldLine, visibleWidth, SPINNER, type Component, type FrameCtx } from "./components.js";
10
11
  export { Editor, MENU_ITEMS, PROMPT, PROMPT_WIDTH, displayWidth, charWidth, widthOf, type MenuItem, } from "./editor.js";
11
- export { bannerLines, COLOR_OFF, COLOR_ON, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderResumeList, renderSessionLine, renderStatusLine, relativeTime, renderTerminalGap, renderToolSummary, TAGLINE, truncateRow, type Palette, type PathResolver, type RecapStats, type ResumeMeta, type RenderInput, type RenderResult, type RunUsage, } from "./render.js";
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";
12
13
  export { editFileDiff, truncateDiff, writeFileDiff, type DiffLine, type DiffResult } from "./diff.js";
package/dist/index.js CHANGED
@@ -6,7 +6,11 @@
6
6
  * editor, the diff renderer, and the palette.
7
7
  */
8
8
  export { Body, Dock, CURSOR_MARKER } from "./compositor.js";
9
+ // W21 (the v8 approval round): the approval panel — the bounded block
10
+ // that replaces the running tool's live window while a human-chain
11
+ // approval is pending (the shape authority is the committed preview).
12
+ export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWidth, panelStatus, } from "./approval-panel.js";
9
13
  export { Container, foldLine, visibleWidth, SPINNER } from "./components.js";
10
14
  export { Editor, MENU_ITEMS, PROMPT, PROMPT_WIDTH, displayWidth, charWidth, widthOf, } from "./editor.js";
11
- export { bannerLines, COLOR_OFF, COLOR_ON, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderResumeList, renderSessionLine, renderStatusLine, relativeTime, renderTerminalGap, renderToolSummary, TAGLINE, truncateRow, } from "./render.js";
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";
12
16
  export { editFileDiff, truncateDiff, writeFileDiff } from "./diff.js";