@cspeach/cli 1.1.51 → 1.1.53

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.
Files changed (112) hide show
  1. package/dist/agent/chat-prompt.js +4 -1
  2. package/dist/agent/conversation-prompt.js +31 -13
  3. package/dist/agent/inline-skill-block.js +341 -0
  4. package/dist/agent/loop.js +304 -49
  5. package/dist/agent/maybe-build-project-context.js +11 -2
  6. package/dist/agent/provider-shape.js +8 -4
  7. package/dist/agent/providers/ai-hub-provider.js +2 -2
  8. package/dist/agent/providers/azure-provider.js +2 -2
  9. package/dist/agent/providers/byok-provider.js +2 -2
  10. package/dist/agent/providers/local-provider.js +2 -2
  11. package/dist/agent/repair-partial.js +21 -0
  12. package/dist/agent/skill-block-key.js +134 -0
  13. package/dist/agent/steering-queue.js +50 -14
  14. package/dist/agent/system-prompt.js +22 -0
  15. package/dist/agent/tool-dispatch.js +25 -9
  16. package/dist/agent/turn-watchdog.js +1 -1
  17. package/dist/approvals/advisory-render.js +6 -3
  18. package/dist/approvals/approval-prompt.js +9 -4
  19. package/dist/approvals/op-labels.js +20 -3
  20. package/dist/approvals/plan-grant.js +8 -0
  21. package/dist/approvals/render.js +22 -10
  22. package/dist/approvals/risk-floor.js +32 -4
  23. package/dist/approvals/visible-changes.js +50 -0
  24. package/dist/cli.js +10 -2
  25. package/dist/commands/compact.js +6 -1
  26. package/dist/commands/config-set.js +13 -0
  27. package/dist/commands/config-show.js +16 -4
  28. package/dist/commands/export-conversation.js +7 -4
  29. package/dist/commands/finish-check-guard.js +124 -0
  30. package/dist/commands/plan-chain.js +2 -2
  31. package/dist/commands/plan-reground.js +27 -4
  32. package/dist/commands/plan-resume.js +113 -3
  33. package/dist/commands/plan-startup.js +3 -3
  34. package/dist/config/loader.js +5 -1
  35. package/dist/cost/cost-log.js +6 -0
  36. package/dist/cost/system-block.js +6 -0
  37. package/dist/docx/doc-meta.js +1 -0
  38. package/dist/docx/markdown-to-docx.js +2 -0
  39. package/dist/lane/app-chain.js +13 -6
  40. package/dist/models/server-config.js +1 -1
  41. package/dist/one-shot.js +1 -0
  42. package/dist/project-context/conventions.js +8 -1
  43. package/dist/project-context/index-files.js +97 -34
  44. package/dist/project-context/index.js +1 -1
  45. package/dist/project-context/render.js +38 -4
  46. package/dist/projects/answer-blockers.js +107 -16
  47. package/dist/projects/blocker-answers-note.js +89 -0
  48. package/dist/projects/change-options.js +70 -0
  49. package/dist/projects/document-save.js +86 -0
  50. package/dist/projects/extract-design.js +4 -0
  51. package/dist/projects/extract-estimate.js +4 -0
  52. package/dist/projects/extract-spec-gap.js +8 -0
  53. package/dist/projects/index.js +1 -1
  54. package/dist/projects/plan-objects.js +24 -0
  55. package/dist/projects/plan-run.js +12 -2
  56. package/dist/projects/save-command.js +50 -18
  57. package/dist/projects/yes-no.js +5 -1
  58. package/dist/renderer/change-card.js +17 -1
  59. package/dist/renderer/html-entities.js +25 -0
  60. package/dist/renderer/machine-blocks.js +54 -7
  61. package/dist/renderer/steering-echo.js +53 -8
  62. package/dist/renderer/tool-labels.js +5 -1
  63. package/dist/renderer/tool-widget.js +20 -5
  64. package/dist/repl/chat-lane.js +15 -3
  65. package/dist/repl/inline-mode.js +51 -0
  66. package/dist/repl/leftover.js +90 -0
  67. package/dist/repl/reroute-turn.js +7 -1
  68. package/dist/repl/turn-stop.js +64 -0
  69. package/dist/repl.js +155 -24
  70. package/dist/router/classifier.js +1 -1
  71. package/dist/sap/reachability-state.js +7 -0
  72. package/dist/sap/unreachable.js +2 -2
  73. package/dist/session/fresh-start.js +4 -0
  74. package/dist/session/queued-step.js +3 -1
  75. package/dist/session/user-prompt.js +12 -1
  76. package/dist/skills/manifest-client.js +3 -2
  77. package/dist/skills/preamble.js +6 -4
  78. package/dist/skills/source-authed.js +3 -2
  79. package/dist/tools/approval.js +49 -8
  80. package/dist/tools/dispatch-skill.js +1 -1
  81. package/dist/tools/hand-off.js +49 -7
  82. package/dist/tools/lane/app-lane-resolve.js +9 -0
  83. package/dist/tools/method-only-change.js +241 -0
  84. package/dist/tools/project/spec_answers_record.js +190 -0
  85. package/dist/tools/rap/skeleton-resolve.js +4 -0
  86. package/dist/tools/sap-write.js +78 -3
  87. package/dist/tools/snapshot.js +7 -2
  88. package/dist/tools/subagent/agent_run.js +14 -0
  89. package/dist/tools/tool-loading.js +18 -8
  90. package/dist/tools/transport-fit-check.js +70 -0
  91. package/dist/tools/transport.js +17 -8
  92. package/dist/tools/write-pipeline.js +46 -5
  93. package/dist/ui/app.js +112 -13
  94. package/dist/ui/approval-modal.js +13 -7
  95. package/dist/ui/ascii-chars.js +21 -3
  96. package/dist/ui/footer.js +128 -7
  97. package/dist/ui/interrupt.js +35 -1
  98. package/dist/ui/live-area.js +21 -4
  99. package/dist/ui/queue-box-emitter.js +14 -0
  100. package/dist/ui/rewind-panel.js +2 -1
  101. package/dist/ui/session-timeline.js +2 -1
  102. package/dist/ui/text-input.js +31 -2
  103. package/dist/ui/widgets/ask-card.js +171 -29
  104. package/dist/ui/widgets/bar-chart.js +2 -1
  105. package/dist/ui/widgets/confirm-card.js +2 -1
  106. package/dist/ui/widgets/dep-graph.js +2 -1
  107. package/dist/ui/widgets/diff-viewer.js +2 -1
  108. package/dist/ui/widgets/question-card.js +2 -1
  109. package/dist/ui/widgets/shortcuts-sheet.js +2 -2
  110. package/dist/ui/widgets/stack-frames.js +2 -1
  111. package/dist/util/connect-retry.js +65 -0
  112. package/package.json +2 -2
@@ -8,7 +8,7 @@
8
8
  * (beforeApproval) → approval id → plan-grant claims → Rule 9 conflict warning → transport
9
9
  * (owning-transport resolve, session fallback then resolve, or the
10
10
  * three-tier fallback) → grant owning
11
- * mismatch → Rule 7 snapshot → safety card → preGate → WAL pending →
11
+ * mismatch → Rule 7 snapshot → (LT-15 D6 stop check) → safety card → preGate → WAL pending →
12
12
  * walGate → call → onThrow / landed → (noWrite: WAL closed, envelope) → noteWriteTransport → Rule 10 syntax check →
13
13
  * audit row (D7: after the check, status follows the verified outcome) →
14
14
  * WAL finalize → verified activation → chain / change card → after → envelope
@@ -45,6 +45,8 @@ import { noteWriteTransport } from '../repl/current-transport.js';
45
45
  import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
46
46
  import { isSafetyConfirmEnabled } from '../repl/safety-mode-state.js';
47
47
  import { argsHaveCutNote, CUT_NOTE_REFUSAL } from './cut-markers.js';
48
+ import { methodOnlyAuditDetail, methodOnlyRefusal, runMethodOnlyGuard } from './method-only-change.js';
49
+ import { STOPPED_BEFORE_START_RESULT, STOPPED_BEFORE_WRITE_RESULT } from '../agent/repair-partial.js';
48
50
  // ── Transcript visibility helpers (D16-display + tour slide-7, 2026-06-12) ──
49
51
  //
50
52
  // Rule 7 auto-snapshots always fired but the transcript never said so
@@ -211,6 +213,16 @@ export function snapshotSource(snap) {
211
213
  }
212
214
  }
213
215
  const refuse = (body) => ({ content: JSON.stringify(body), is_error: true });
216
+ /**
217
+ * LT-15 D6 (F6) — after a stop (Esc or Ctrl+C set the same turn abort) no new
218
+ * card opens. The shape dispatchTool returns for a tool that never started,
219
+ * with a text that says nothing ran. Checked only BEFORE a card, the WAL entry
220
+ * and any SAP change: a write whose card was answered always runs to the end.
221
+ */
222
+ const interrupted = () => ({ content: STOPPED_BEFORE_START_RESULT, is_error: true });
223
+ /** Review M5 — past the approval spend and the snapshot: the change itself was not made. */
224
+ const interruptedBeforeWrite = () => ({ content: STOPPED_BEFORE_WRITE_RESULT, is_error: true });
225
+ const stopped = (ctx) => ctx.signal?.aborted === true;
214
226
  /** Rule 7 recovery text for a hard source snapshot that could not read the object. */
215
227
  const SOURCE_READ_RECOVERY = 'Pre-write source read failed (non-404) and no committed active version could be confirmed — the object may exist with source we could not read, so no snapshot was possible. If this is a live connection/auth problem, fix it and retry. If this is a freshly-created shell that got edit-locked by an earlier aborted write, recover it directly: unlock it (or activate the empty shell, then delete it) and re-run the create+write. Do not bypass Rule 7 by writing over source you never read.';
216
228
  /** Refuses a spec that would skip a gate without saying why. */
@@ -225,17 +237,21 @@ export function writeToolHandler(spec) {
225
237
  return (args, ctx) => runWritePipeline(args, ctx, spec);
226
238
  }
227
239
  /** Rule 7 — runs the spec's snapshot gate. A ToolResult means: refused, stop. */
228
- async function snapshotGate(a, ctx, spec, pre) {
240
+ async function snapshotGate(a, ctx, spec, pre, preRead) {
229
241
  const s = spec.snapshot;
230
242
  if (s.kind === 'none')
231
243
  return undefined;
232
244
  if (s.kind === 'source') {
233
245
  const type = s.type(a);
234
246
  const name = s.name(a);
247
+ // Rule 7a guard already read this object before the approval: reuse it.
248
+ const read = () => (preRead && preRead.type === type && preRead.name === name
249
+ ? autoSnapshot(name, type, ctx.adt, preRead.outcome)
250
+ : autoSnapshot(name, type, ctx.adt));
235
251
  if (!s.hard) {
236
252
  // Best-effort: the object may be a shell with no readable source.
237
253
  try {
238
- pre.snapshot = await autoSnapshot(name, type, ctx.adt);
254
+ pre.snapshot = await read();
239
255
  }
240
256
  catch { /* swallowed, as before */ }
241
257
  emitSnapshotNotice(ctx, pre.snapshot, type, name);
@@ -244,7 +260,7 @@ async function snapshotGate(a, ctx, spec, pre) {
244
260
  // HARD gate: only a genuine 404 (new object) may proceed snapshot-less. A
245
261
  // read failure on a possibly-EXISTING object means no recovery copy → block.
246
262
  try {
247
- pre.snapshot = await autoSnapshot(name, type, ctx.adt);
263
+ pre.snapshot = await read();
248
264
  }
249
265
  catch (err) {
250
266
  return refuse({ error: ERR.SNAPSHOT_FAILED, detail: String(err) });
@@ -345,6 +361,26 @@ export async function runWritePipeline(args, ctx, spec) {
345
361
  if (refusal)
346
362
  return refuse(refusal);
347
363
  }
364
+ // ── Rule 7a in code (2026-10-22): a class rewrite that only changed method bodies ──
365
+ // Before the approval is spent. One read; the snapshot below reuses it.
366
+ // A refusal writes one audit line (status skipped) so Rule 7a firings can be counted.
367
+ let preRead;
368
+ if (spec.methodOnlyGuard) {
369
+ const g = spec.methodOnlyGuard(args);
370
+ if (typeCode(g.type) === 'CLAS') {
371
+ const startedAt = Date.now();
372
+ const r = await runMethodOnlyGuard(ctx.adt, g);
373
+ if ('refuse' in r) {
374
+ await auditLog(spec.audit.method, g.type, g.name, 'skipped', Date.now() - startedAt, methodOnlyAuditDetail(r.refuse));
375
+ return refuse(methodOnlyRefusal(r.refuse));
376
+ }
377
+ if (r.go)
378
+ preRead = { type: g.type, name: g.name, outcome: r.go };
379
+ }
380
+ }
381
+ // ── LT-15 D6: stopped before the approval is spent → nothing more ─────────
382
+ if (stopped(ctx))
383
+ return interrupted();
348
384
  // ── Gate 1: Approval ──────────────────────────────────────────────────────
349
385
  const verify = await verifyAndSpendApprovalId(raw.approval_id, spec.approval.object(args), spec.approval.op);
350
386
  if (!verify.ok)
@@ -406,9 +442,12 @@ export async function runWritePipeline(args, ctx, spec) {
406
442
  }).request;
407
443
  }
408
444
  // ── Gate 2: Snapshot (Rule 7) ─────────────────────────────────────────────
409
- const snapRefusal = await snapshotGate(args, ctx, spec, pre);
445
+ const snapRefusal = await snapshotGate(args, ctx, spec, pre, preRead);
410
446
  if (snapRefusal)
411
447
  return snapRefusal;
448
+ // ── LT-15 D6: stopped during the reads above → no safety card, no preview ─
449
+ if (stopped(ctx))
450
+ return interruptedBeforeWrite();
412
451
  // ── Gate 3: Safety confirmation (Rule 7) ──────────────────────────────────
413
452
  if (spec.safetyConfirm && isSafetyConfirmEnabled()) {
414
453
  const r = await presentSafetyConfirmation(spec.safetyConfirm(args, pre.transport ?? ctx.currentTransport?.get() ?? ''));
@@ -418,6 +457,8 @@ export async function runWritePipeline(args, ctx, spec) {
418
457
  // ── preGate (preview, transport fit, package prepare) ─────────────────────
419
458
  if (spec.preGate) {
420
459
  const g = await spec.preGate(args, ctx, pre);
460
+ if ('interrupted' in g)
461
+ return interruptedBeforeWrite();
421
462
  if ('error' in g)
422
463
  return refuse(g.error);
423
464
  if (g.extra)
package/dist/ui/app.js CHANGED
@@ -15,13 +15,14 @@ import { FooterLine } from './footer-line.js';
15
15
  import { ShortcutsSheet } from './widgets/shortcuts-sheet.js';
16
16
  import { wasPasted } from '../repl/paste-marker.js';
17
17
  import { turnStatusEmitter } from './turn-status-emitter.js';
18
- import { pickSlotOccupant, liveBudget, cardRowBudget, SLOT_ROWS_ESTIMATE, PALETTE_ROWS } from './live-area.js';
18
+ import { pickSlotOccupant, liveBudget, cardRowBudget, queueRowBudget, SLOT_ROWS_ESTIMATE, PALETTE_ROWS } from './live-area.js';
19
19
  import { Checklist } from './checklist.js';
20
20
  import { formatChecklist, isPlanFinished, CHECKLIST_MAX_ROWS } from '../renderer/checklist-format.js';
21
21
  import { listWorkspaceFileChoices } from '../repl/at-picker.js';
22
22
  import { SessionTimeline, toTimelineEntry } from './session-timeline.js';
23
23
  import { RewindPanel } from './rewind-panel.js';
24
24
  import { rewindEmitter } from './rewind-emitter.js';
25
+ import { queueBoxEmitter } from './queue-box-emitter.js';
25
26
  import { todoEmitter } from './todo-emitter.js';
26
27
  import { approvalEmitter } from './approval-emitter.js';
27
28
  import { useHoldRawMode } from './widgets/hold-raw-mode.js';
@@ -37,8 +38,8 @@ import { FilesCard } from './widgets/files-card.js';
37
38
  import { filesCardEmitter } from './files-card-emitter.js';
38
39
  import { uiWidth } from '../renderer/ui-width.js';
39
40
  import { runModeCommand } from '../repl/mode-cycle.js';
40
- import { enqueueSteering } from '../agent/steering-queue.js';
41
- import { steeringEchoLine, classifyMidTurnLine, midTurnSlashHint } from '../renderer/steering-echo.js';
41
+ import { enqueueSteering, subscribeSteering, steeringSnapshot, withdrawLastSteering, drainSteeringText } from '../agent/steering-queue.js';
42
+ import { classifyMidTurnLine, midTurnSlashHint, formatQueueRow } from '../renderer/steering-echo.js';
42
43
  // Moved to renderer/steering-echo.ts (piece 2 I1); re-exported for existing importers.
43
44
  export { STEERING_ECHO_MAX, formatSteeringEcho } from '../renderer/steering-echo.js';
44
45
  /**
@@ -48,6 +49,8 @@ export { STEERING_ECHO_MAX, formatSteeringEcho } from '../renderer/steering-echo
48
49
  * words.
49
50
  */
50
51
  const ASKING_OCCUPANTS = new Set(['ask', 'confirm', 'approval', 'coaching', 'files']);
52
+ /** LT-15 Revision 2 — the plan gate's confirm card id (approvals/render.ts renderPlanGate). */
53
+ export const PLAN_GATE_CARD_ID = 'plan-gate';
51
54
  export function isAskingOccupant(occupant) {
52
55
  return ASKING_OCCUPANTS.has(occupant);
53
56
  }
@@ -66,7 +69,7 @@ export function isAskingOccupant(occupant) {
66
69
  *
67
70
  * The terminal's native scrollback handles everything above the live area.
68
71
  */
69
- export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger, getToolCalls, getTodos, getRewindCandidates, onRewindRestore, sapRole, sapAlias, onInterrupt, onExitRequest }) {
72
+ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger, getToolCalls, getTodos, getRewindCandidates, onRewindRestore, sapRole, sapAlias, onInterrupt, onExitRequest, onBrake, onCardStop }) {
70
73
  // 1.1.29 — keep raw mode held across card hand-overs (Windows froze the
71
74
  // approval card when Ink released it between cards). See hold-raw-mode.ts.
72
75
  useHoldRawMode();
@@ -117,6 +120,14 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
117
120
  const [todos, setTodos] = useState(() => getTodos?.() ?? []);
118
121
  const [planClosed, setPlanClosed] = useState(() => isPlanFinished(getTodos?.() ?? []));
119
122
  const [checklistExpanded, setChecklistExpanded] = useState(false);
123
+ // LT-15 Feature 1 — the messages typed while the model works, waiting for
124
+ // its next round. Mirrors the steering queue (subscribed below).
125
+ const [waiting, setWaiting] = useState(() => steeringSnapshot());
126
+ // LT-15 Feature 2 — "a turn is running or about to" as state, so the row's
127
+ // "· ↑ to edit" hint follows it. The ↑ itself re-checks isBusy() live.
128
+ const [busy, setBusy] = useState(() => turnStatusEmitter.isBusy());
129
+ // LT-15 (review I3) — the Footer's put-text-in-the-box handle.
130
+ const footerBoxRef = useRef(null);
120
131
  // Task 3 (ux-wave3) — the Esc-Esc / /rewind panel + its lazily-loaded
121
132
  // candidate list. Candidates come from an ASYNC accessor (snapshots.list
122
133
  // reads fs), so the open handler loads them BEFORE flipping the panel on —
@@ -151,6 +162,9 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
151
162
  // palette pick that prefills would orphan the buffered lines. Pinned:
152
163
  // all live triggers stay dormant while the buffer is non-empty.
153
164
  const [footerBufferCount, setFooterBufferCount] = useState(0);
165
+ // LT-15 Task 5b (F5) — the Footer's Ctrl+R search is open: Esc closes the
166
+ // search only, never the brake (mirrored up from the Footer).
167
+ const [fuzzyOpen, setFuzzyOpen] = useState(false);
154
168
  // Wave-2 review I-1: model-driven overlays (approval / ask-question /
155
169
  // coaching) must own input EXCLUSIVELY. A passive panel (the ? timeline,
156
170
  // the rewind panel) can be up when a request arrives —
@@ -259,6 +273,48 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
259
273
  // closure over the session object) — mount-once is intentional.
260
274
  // eslint-disable-next-line react-hooks/exhaustive-deps
261
275
  }, []);
276
+ // LT-15 Feature 1 — follow the steering queue (enqueue, the loop's drain,
277
+ // a withdraw) so the queue row is right in the same render (I1.3). Re-seed
278
+ // AFTER subscribing: a change between the first render and this effect
279
+ // would otherwise be missed.
280
+ useEffect(() => {
281
+ const off = subscribeSteering((q) => setWaiting(q));
282
+ setWaiting(steeringSnapshot());
283
+ return off;
284
+ }, []);
285
+ useEffect(() => {
286
+ const onChange = () => setBusy(turnStatusEmitter.isBusy());
287
+ turnStatusEmitter.on('change', onChange);
288
+ setBusy(turnStatusEmitter.isBusy());
289
+ return () => { turnStatusEmitter.off('change', onChange); };
290
+ }, []);
291
+ // LT-15 (review I3) — take waiting text out of the queue and put it back in
292
+ // the input box: a multiline message lands as held lines plus its last
293
+ // line, so it stays readable (the input row shows a newline as a space).
294
+ // `take` says which text (Feature 2: the last message; Task 4 adds the
295
+ // whole queue). Without a box to put it in, nothing is taken, so a message
296
+ // can never be lost between the queue and the box.
297
+ const returnQueueToBox = useCallback((take) => {
298
+ const box = footerBoxRef.current;
299
+ if (!box)
300
+ return false;
301
+ const text = take();
302
+ if (text === null)
303
+ return false;
304
+ box.putInBox(text);
305
+ return true;
306
+ }, []);
307
+ // LT-15 Feature 2 — ↑ on an empty box while a turn runs. The Footer is
308
+ // disabled whenever a card, panel or palette is open, so this never fires
309
+ // under an overlay. At idle it misses and ↑ is plain history.
310
+ const withdrawToBox = useCallback(() => turnStatusEmitter.isBusy() && returnQueueToBox(withdrawLastSteering), [returnQueueToBox]);
311
+ // LT-15 Task 4 (D1, review I5) — repl.tsx asks for the WHOLE queue back in
312
+ // the box: a turn end after Ctrl+C or an error, or a session switch.
313
+ useEffect(() => {
314
+ const onReturn = () => { returnQueueToBox(drainSteeringText); };
315
+ queueBoxEmitter.on('return', onReturn);
316
+ return () => { queueBoxEmitter.off('return', onReturn); };
317
+ }, [returnQueueToBox]);
262
318
  // Task 3 (ux-wave3) — load candidates (async fs read) THEN show the panel, so
263
319
  // it never renders against a stale/empty list. Shared by the Esc-Esc chord and
264
320
  // the /rewind builtin (which signals through rewindEmitter — <App> is a
@@ -334,12 +390,11 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
334
390
  setPrefill((p) => ({ text: line, nonce: p.nonce + 1 }));
335
391
  return;
336
392
  }
337
- if (t.length > 0) {
393
+ // LT-15 Feature 1 (D5) — no scrollback receipt: the live queue row
394
+ // shows the message while it waits (a withdrawn message must not leave
395
+ // a false receipt). The model still gets `t` verbatim.
396
+ if (t.length > 0)
338
397
  enqueueSteering(t);
339
- // Fast-follow: echo a truncated receipt, not the full (possibly
340
- // multiline-pasted) text — the model still gets `t` verbatim.
341
- chunkEmitter.emit('chunk', steeringEchoLine(t));
342
- }
343
398
  setPrefill((p) => ({ text: '', nonce: p.nonce + 1 }));
344
399
  return;
345
400
  }
@@ -516,6 +571,11 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
516
571
  if (!(key.ctrl && input === 'c'))
517
572
  return;
518
573
  const r = onInterrupt?.();
574
+ // LT-15 Task 4 (D1) — Ctrl+C stopped something: the waiting messages go
575
+ // back into the box now (a message typed after the press comes back at
576
+ // the turn's end, through queueBoxEmitter).
577
+ if (r === 'stopped')
578
+ returnQueueToBox(drainSteeringText);
519
579
  if (exitHintTimer.current)
520
580
  clearTimeout(exitHintTimer.current);
521
581
  if (r === 'armed') {
@@ -526,6 +586,26 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
526
586
  setExitHint(false);
527
587
  }
528
588
  }, { isActive: !overlayOpen });
589
+ // LT-15 Task 5b — Esc while working is the emergency brake. Only while a
590
+ // dispatch is busy (idle Esc keeps its meaning: the Esc-Esc rewind chord
591
+ // above, I3.6) and only when nothing else owns that Esc: a card, sheet or
592
+ // palette (overlayOpen), a held multiline draft (the Footer discards it,
593
+ // also a taken-back multiline message) or the Ctrl+R search (the Footer
594
+ // closes it, F5). Ink gives every key to every active handler, so the gate
595
+ // is the render-time state: the Esc that closes the search or discards the
596
+ // draft finds this handler still inactive. The REPL decides and acts.
597
+ useInput((_input, key) => {
598
+ if (!key.escape)
599
+ return;
600
+ if (!turnStatusEmitter.isBusy())
601
+ return;
602
+ // LT-15 final review I1 — the render-time gate lags inside one input
603
+ // burst (↑ then Esc): ask the Footer now whether this Esc is its own
604
+ // (held lines in the box, or it just handled them).
605
+ if (footerBoxRef.current?.ownsEscape())
606
+ return;
607
+ onBrake?.({ overlayOpen, bufferCount: footerBufferCount, fuzzyOpen });
608
+ }, { isActive: !!onBrake && !overlayOpen && footerBufferCount === 0 && !fuzzyOpen });
529
609
  // I1(b) — clear ONLY when this request still owns the slot: a replacing
530
610
  // request (resolved-as-superseded in onReq) may already be pending, and the
531
611
  // old modal's resolve callback must not evict it.
@@ -604,7 +684,19 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
604
684
  foldedToOne: false,
605
685
  }).length
606
686
  : 0;
607
- const budget = liveBudget({ rows, checklistRows: checklistDesiredRows, cardRows, inputLines, paletteRows });
687
+ // LT-15 Feature 1 — "N waiting" row, hidden while a card, panel or palette
688
+ // holds the input (the same set that disables the Footer). Its rows count
689
+ // against the height cap like the status strip, and it never takes more
690
+ // rows than are left (fix round 1, review I-2): it folds to one line.
691
+ // Feature 2 (review I6): "· ↑ to edit" only while ↑ would act — a turn
692
+ // runs and the box is empty (no text, no held lines).
693
+ const queueCanEdit = busy && footerValue === '' && footerBufferCount === 0;
694
+ const queueRowLines = overlayOpen
695
+ ? []
696
+ : formatQueueRow(waiting, queueRowBudget({ rows, checklistRows: checklistDesiredRows, paletteRows }), { canEdit: queueCanEdit });
697
+ const budget = liveBudget({
698
+ rows, checklistRows: checklistDesiredRows, cardRows, inputLines, paletteRows, queueRows: queueRowLines.length,
699
+ });
608
700
  let slot = null;
609
701
  if (occupant === 'approval' && pendingApproval) {
610
702
  slot = (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(ApprovalModal, { change: pendingApproval.change, effectiveRisk: pendingApproval.effectiveRisk, transport: pendingApproval.transport, width: uiWidth(columns) - 2, onApply: () => {
@@ -616,11 +708,18 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
616
708
  }, onDiff: () => { }, onCancel: () => {
617
709
  pendingApproval.resolve({ approved: false, reason: '__cancel_turn__' });
618
710
  clearApproval(pendingApproval);
619
- }, onInterruptTurn: () => { onInterrupt?.(); } }, approvalSeq) }));
711
+ }, onInterruptTurn: () => { onInterrupt?.(); }, onStopTurn: () => { onCardStop?.(); } }, approvalSeq) }));
620
712
  }
621
713
  else if (occupant === 'confirm' && confirmSlot.pending) {
622
714
  const req = confirmSlot.pending;
623
- slot = (_jsx(ConfirmCard, { request: req, width: uiWidth(columns), maxRows: rows - 6, onResolve: (r) => confirmSlot.finish(req, r), onInterruptTurn: () => { onInterrupt?.(); } }, confirmSlot.seq));
715
+ slot = (_jsx(ConfirmCard, { request: req, width: uiWidth(columns), maxRows: rows - 6, onResolve: (r) => {
716
+ // LT-15 Revision 2 — Cancel on the plan gate card stops the turn too.
717
+ if (req.id === PLAN_GATE_CARD_ID && r.value === 'cancel')
718
+ onCardStop?.();
719
+ confirmSlot.finish(req, r);
720
+ }, onInterruptTurn: () => { onInterrupt?.(); },
721
+ // LT-15 Revision 2 — Esc on the plan gate card stops the turn; other cards only cancel.
722
+ onEscape: req.id === PLAN_GATE_CARD_ID ? () => { onCardStop?.(); } : undefined }, confirmSlot.seq));
624
723
  }
625
724
  else if (occupant === 'ask' && askQuestionPending) {
626
725
  const req = askQuestionPending;
@@ -668,7 +767,7 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
668
767
  }
669
768
  return (_jsx(SapStateContext.Provider, { value: globalStore, children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Body, { chunkEmitter: chunkEmitter }), showChecklist && (_jsx(Checklist, { todos: todos, width: checklistWidth, maxRows: Math.max(1, budget.checklistRows - 1), expanded: checklistExpanded,
670
769
  // liveBudget folds the checklist to 1 row whenever a card is open (occupant !== null).
671
- foldedToOne: budget.checklistRows <= 1 })), slot, occupant === null && _jsx(TurnStatus, {}), _jsx(Footer, { onSubmit: beginSubmit, onSlashTrigger: handleSlashTrigger, onHistoryTrigger: handleHistoryTrigger, prefill: prefill, onValueChange: setFooterValue, onBufferChange: setFooterBufferCount, maxLines: budget.inputMaxLines, onInputLinesChange: setInputLines,
770
+ foldedToOne: budget.checklistRows <= 1 })), slot, occupant === null && _jsx(TurnStatus, {}), queueRowLines.length > 0 && (_jsx(Box, { flexDirection: "column", children: queueRowLines.map((l, i) => (_jsx(Text, { ...roleProps('dim'), wrap: "truncate-end", children: l }, i))) })), _jsx(Footer, { onSubmit: beginSubmit, onSlashTrigger: handleSlashTrigger, onHistoryTrigger: handleHistoryTrigger, prefill: prefill, onValueChange: setFooterValue, onBufferChange: setFooterBufferCount, onFuzzyChange: setFuzzyOpen, maxLines: budget.inputMaxLines, onInputLinesChange: setInputLines, onWithdraw: withdrawToBox, boxRef: footerBoxRef,
672
771
  // Spec §7: "(answer above to continue)" only while a card that asks
673
772
  // waits — not for the timeline / rewind / shortcuts panels, nor an
674
773
  // open `/` / `@` palette (those are not slot occupants).
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useRef, useState } from 'react';
3
3
  import { Box, Text, useInput, useStdout } from 'ink';
4
- import { riskReasonText, shownRiskReasons } from '../approvals/risk-floor.js';
4
+ import { riskReasonTexts } from '../approvals/risk-floor.js';
5
5
  import { verbLine, viewAffordance, toSourceLines, affectsLine, releaseLines, behaviourLines } from '../approvals/op-labels.js';
6
6
  // BUG-1 (2026-07-05 live smoke): the interactive approval modal mounted
7
7
  // DEAD — every keypress ignored — when it appeared after a guarded
@@ -17,7 +17,9 @@ import { useReclaimRawStdin } from './widgets/reclaim-raw-stdin.js';
17
17
  import { getLook } from '../renderer/look.js';
18
18
  import { roleProps, inkChip, legacyInkColor } from './role-props.js';
19
19
  import { wrapRows } from '../renderer/wrap-rows.js';
20
+ import { decodeDisplayBlock } from '../renderer/html-entities.js';
20
21
  import { uiWidth } from '../renderer/ui-width.js';
22
+ import { cardBorderStyle } from './ascii-chars.js';
21
23
  /**
22
24
  * Fix batch 2026-10-12 (Task 2): why the risk went up is the decision input,
23
25
  * so the "Risk: … (raised: …)" text wraps onto up to this many rows (both
@@ -27,7 +29,7 @@ export const RISK_MAX_ROWS = 4;
27
29
  /** Border 2 + paddingX 2 on each side. */
28
30
  const MODAL_CHROME_COLS = 6;
29
31
  function riskText(effectiveRisk) {
30
- const raised = shownRiskReasons(effectiveRisk.raisedBy).map(riskReasonText).join('; ');
32
+ const raised = riskReasonTexts(effectiveRisk).join('; ');
31
33
  return `Risk: ${effectiveRisk.level}${raised ? ` (raised: ${raised})` : ''}`;
32
34
  }
33
35
  /** The risk text as the rows it is drawn on at card width `width`. */
@@ -85,7 +87,7 @@ function diffLineColor(line, panels) {
85
87
  export const BACK_GUARD_MS = 300;
86
88
  /** The review view's key line, always at its bottom: how to go back and how to answer from here. */
87
89
  export const viewKeysLine = (reviewKey) => `Esc or ${reviewKey}: back · a: approve · s: skip · c: cancel turn`;
88
- export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSkip, onDiff, onCancel, onInterruptTurn, width }) {
90
+ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSkip, onDiff, onCancel, onInterruptTurn, onStopTurn, width }) {
89
91
  const cardWidth = width ?? uiWidth();
90
92
  // BUG-1 — re-assert raw mode on mount so keypresses reach useInput even
91
93
  // when an inquirer gate ran earlier in the turn (see import block).
@@ -108,7 +110,7 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
108
110
  const backAt = useRef(0);
109
111
  const back = () => { backAt.current = Date.now(); setViewing(false); };
110
112
  const viewLines = viewing && aff && change.diff
111
- ? (aff.key === 'd' ? change.diff.split('\n') : toSourceLines(change.diff))
113
+ ? (aff.key === 'd' ? decodeDisplayBlock(change.diff).split('\n') : toSourceLines(change.diff))
112
114
  : [];
113
115
  const lastTop = Math.max(0, viewLines.length - viewRows);
114
116
  useInput((input, key) => {
@@ -149,6 +151,7 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
149
151
  return;
150
152
  }
151
153
  if (input === 'c') {
154
+ onStopTurn?.();
152
155
  onCancel();
153
156
  return;
154
157
  }
@@ -181,7 +184,10 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
181
184
  }
182
185
  if (key.escape && Date.now() - backAt.current < BACK_GUARD_MS)
183
186
  return;
187
+ // LT-15 Revision 2 — Esc / [c] stop the whole turn (the same safe stop as
188
+ // the Esc brake), then resolve the card with the cancel-turn result.
184
189
  if (input === 'c' || key.escape) {
190
+ onStopTurn?.();
185
191
  onCancel();
186
192
  return;
187
193
  }
@@ -197,7 +203,7 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
197
203
  const where = scrolls
198
204
  ? ` lines ${from + 1}–${from + shownLines.length} of ${viewLines.length} · ↑↓ scroll`
199
205
  : '';
200
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: panels ? roleProps('frame').color : quiet, paddingX: 1, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [isDiff ? 'Diff' : aff.label, _jsx(Text, { color: quiet, children: where })] }), change.diff ? (isDiff ? (shownLines.map((line, i) => (_jsx(Text, { wrap: "truncate-end", color: diffLineColor(line, panels), children: line }, from + i)))) : (shownLines.map((line, i) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: quiet, children: [String(from + i + 1).padStart(4), " "] }), line] }, from + i))))) : (_jsx(Text, { dimColor: true, children: "(no source provided by the model)" })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { wrap: "truncate-end", children: viewKeysLine(aff.key) }) })] }));
206
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: cardBorderStyle(), borderColor: panels ? roleProps('frame').color : quiet, paddingX: 1, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [isDiff ? 'Diff' : aff.label, _jsx(Text, { color: quiet, children: where })] }), change.diff ? (isDiff ? (shownLines.map((line, i) => (_jsx(Text, { wrap: "truncate-end", color: diffLineColor(line, panels), children: line }, from + i)))) : (shownLines.map((line, i) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: quiet, children: [String(from + i + 1).padStart(4), " "] }), line] }, from + i))))) : (_jsx(Text, { dimColor: true, children: "(no source provided by the model)" })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { wrap: "truncate-end", children: viewKeysLine(aff.key) }) })] }));
201
207
  }
202
208
  // Pre-wrapped rows, never Ink soft-wrap (Wave-1 rule): every row is counted.
203
209
  const risks = riskRows(effectiveRisk, cardWidth);
@@ -229,7 +235,7 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
229
235
  if (getLook() === 'panels') {
230
236
  const risk = roleProps(riskRole(effectiveRisk.level));
231
237
  const tag = inkChip('APPROVAL');
232
- return (_jsxs(Box, { flexDirection: "column", width: cardWidth, borderStyle: "round", borderColor: risk.color, paddingX: 2, paddingY: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...tag.props, children: tag.text }), _jsx(Text, { children: ' ' }), _jsx(Text, { ...(change.op === 'delete' ? roleProps('bad') : {}), bold: true, children: verb }), change.op === 'create' ? _jsx(Text, { ...roleProps('dim'), children: " (new object)" }) : null] }), affects.map((row, i) => _jsx(Text, { ...roleProps('dim'), wrap: "truncate-end", children: row }, `affects-${i}`)), metaSegs.length > 0 && _jsx(Text, { ...roleProps('dim'), wrap: "truncate-end", children: metaSegs.join(' · ') }), risks.map((row, i) => _jsx(Text, { ...risk, wrap: "truncate-end", children: row }, `risk-${i}`)), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...roleProps('sap', { bold: true }), children: keys[0] }), _jsx(Text, { children: ' ' + keys.slice(1).join(' ') })] }) })] }));
238
+ return (_jsxs(Box, { flexDirection: "column", width: cardWidth, borderStyle: cardBorderStyle(), borderColor: risk.color, paddingX: 2, paddingY: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...tag.props, children: tag.text }), _jsx(Text, { children: ' ' }), _jsx(Text, { ...(change.op === 'delete' ? roleProps('bad') : {}), bold: true, children: verb }), change.op === 'create' ? _jsx(Text, { ...roleProps('dim'), children: " (new object)" }) : null] }), affects.map((row, i) => _jsx(Text, { ...roleProps('dim'), wrap: "truncate-end", children: row }, `affects-${i}`)), metaSegs.length > 0 && _jsx(Text, { ...roleProps('dim'), wrap: "truncate-end", children: metaSegs.join(' · ') }), risks.map((row, i) => _jsx(Text, { ...risk, wrap: "truncate-end", children: row }, `risk-${i}`)), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...roleProps('sap', { bold: true }), children: keys[0] }), _jsx(Text, { children: ' ' + keys.slice(1).join(' ') })] }) })] }));
233
239
  }
234
- return (_jsxs(Box, { flexDirection: "column", width: cardWidth, borderStyle: "round", borderColor: legacyInkColor(riskColor(effectiveRisk.level)), paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Write approval" }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: verbColor, children: verb }), change.op === 'create' ? _jsx(Text, { dimColor: true, children: " (new object)" }) : null] }) }), affects.map((row, i) => (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: row }, `affects-${i}`))), metaSegs.length > 0 && (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: metaSegs.join(' · ') })), risks.map((row, i) => (_jsx(Text, { color: legacyInkColor(riskColor(effectiveRisk.level)), wrap: "truncate-end", children: row }, `risk-${i}`))), _jsx(Box, { marginTop: 1, children: _jsx(Text, { wrap: "truncate-end", children: keys.join(' ') }) })] }));
240
+ return (_jsxs(Box, { flexDirection: "column", width: cardWidth, borderStyle: cardBorderStyle(), borderColor: legacyInkColor(riskColor(effectiveRisk.level)), paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Write approval" }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: verbColor, children: verb }), change.op === 'create' ? _jsx(Text, { dimColor: true, children: " (new object)" }) : null] }) }), affects.map((row, i) => (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: row }, `affects-${i}`))), metaSegs.length > 0 && (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: metaSegs.join(' · ') })), risks.map((row, i) => (_jsx(Text, { color: legacyInkColor(riskColor(effectiveRisk.level)), wrap: "truncate-end", children: row }, `risk-${i}`))), _jsx(Box, { marginTop: 1, children: _jsx(Text, { wrap: "truncate-end", children: keys.join(' ') }) })] }));
235
241
  }
@@ -1,6 +1,24 @@
1
1
  import { shouldUseUnicodeBoxChars } from '../renderer/tty.js';
2
- export const UNICODE_BOX = { tl: '\u256D', tr: '\u256E', bl: '\u2570', br: '\u256F', h: '\u2500', v: '\u2502' };
2
+ export const UNICODE_BOX = { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' };
3
+ /** Square single-line frame (hand-drawn auto-approve nag box). */
4
+ export const SQUARE_BOX = { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│' };
5
+ /** Double-line frame (hand-drawn advisory proposal). */
6
+ export const DOUBLE_BOX = { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║' };
3
7
  export const ASCII_BOX = { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' };
4
- export function getBoxChars() {
5
- return shouldUseUnicodeBoxChars() ? UNICODE_BOX : ASCII_BOX;
8
+ /**
9
+ * Characters for a hand-drawn frame: the card's own set (rounded by default),
10
+ * or the classic ASCII set on an old Windows console (tty.ts
11
+ * isWindowsLegacyTerminal). Every set is one cell per character, so the
12
+ * frame never changes width.
13
+ */
14
+ export function getBoxChars(modern = UNICODE_BOX) {
15
+ return shouldUseUnicodeBoxChars() ? modern : ASCII_BOX;
16
+ }
17
+ /**
18
+ * Ink `borderStyle` for a bordered card: the card's own style (rounded by
19
+ * default) everywhere, Ink's 'classic' (+ - |, the same frame the change card
20
+ * draws by hand) on an old Windows console. One-cell border either way.
21
+ */
22
+ export function cardBorderStyle(modern) {
23
+ return shouldUseUnicodeBoxChars() ? (modern ?? 'round') : 'classic';
6
24
  }