@cspeach/cli 1.1.51 → 1.1.52

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 (85) hide show
  1. package/dist/agent/chat-prompt.js +4 -1
  2. package/dist/agent/conversation-prompt.js +30 -13
  3. package/dist/agent/inline-skill-block.js +341 -0
  4. package/dist/agent/loop.js +208 -41
  5. package/dist/agent/provider-shape.js +8 -4
  6. package/dist/agent/providers/ai-hub-provider.js +2 -2
  7. package/dist/agent/providers/azure-provider.js +2 -2
  8. package/dist/agent/providers/byok-provider.js +2 -2
  9. package/dist/agent/providers/local-provider.js +2 -2
  10. package/dist/agent/repair-partial.js +12 -0
  11. package/dist/agent/skill-block-key.js +134 -0
  12. package/dist/agent/steering-queue.js +50 -14
  13. package/dist/agent/system-prompt.js +22 -0
  14. package/dist/agent/tool-dispatch.js +19 -7
  15. package/dist/agent/turn-watchdog.js +1 -1
  16. package/dist/approvals/advisory-render.js +6 -3
  17. package/dist/approvals/approval-prompt.js +9 -4
  18. package/dist/approvals/op-labels.js +14 -0
  19. package/dist/approvals/plan-grant.js +8 -0
  20. package/dist/approvals/render.js +19 -8
  21. package/dist/approvals/risk-floor.js +32 -4
  22. package/dist/approvals/visible-changes.js +50 -0
  23. package/dist/cli.js +10 -2
  24. package/dist/commands/compact.js +6 -1
  25. package/dist/commands/config-set.js +13 -0
  26. package/dist/commands/config-show.js +16 -4
  27. package/dist/commands/export-conversation.js +7 -4
  28. package/dist/commands/finish-check-guard.js +124 -0
  29. package/dist/commands/plan-reground.js +27 -4
  30. package/dist/commands/plan-resume.js +111 -1
  31. package/dist/config/loader.js +5 -1
  32. package/dist/cost/cost-log.js +6 -0
  33. package/dist/cost/system-block.js +6 -0
  34. package/dist/models/server-config.js +1 -1
  35. package/dist/projects/save-command.js +5 -1
  36. package/dist/projects/yes-no.js +5 -1
  37. package/dist/renderer/change-card.js +3 -1
  38. package/dist/renderer/steering-echo.js +53 -8
  39. package/dist/renderer/tool-labels.js +3 -1
  40. package/dist/repl/chat-lane.js +5 -2
  41. package/dist/repl/inline-mode.js +51 -0
  42. package/dist/repl/leftover.js +90 -0
  43. package/dist/repl/reroute-turn.js +7 -1
  44. package/dist/repl/turn-stop.js +64 -0
  45. package/dist/repl.js +144 -22
  46. package/dist/router/classifier.js +1 -1
  47. package/dist/sap/reachability-state.js +7 -0
  48. package/dist/sap/unreachable.js +2 -2
  49. package/dist/session/queued-step.js +3 -1
  50. package/dist/session/user-prompt.js +10 -1
  51. package/dist/skills/manifest-client.js +3 -2
  52. package/dist/skills/preamble.js +6 -4
  53. package/dist/skills/source-authed.js +3 -2
  54. package/dist/tools/approval.js +37 -7
  55. package/dist/tools/dispatch-skill.js +1 -1
  56. package/dist/tools/hand-off.js +49 -7
  57. package/dist/tools/lane/app-lane-resolve.js +9 -0
  58. package/dist/tools/method-only-change.js +241 -0
  59. package/dist/tools/rap/skeleton-resolve.js +4 -0
  60. package/dist/tools/sap-write.js +78 -3
  61. package/dist/tools/snapshot.js +7 -2
  62. package/dist/tools/subagent/agent_run.js +14 -0
  63. package/dist/tools/tool-loading.js +16 -8
  64. package/dist/tools/transport-fit-check.js +70 -0
  65. package/dist/tools/transport.js +17 -8
  66. package/dist/tools/write-pipeline.js +46 -5
  67. package/dist/ui/app.js +112 -13
  68. package/dist/ui/approval-modal.js +11 -6
  69. package/dist/ui/ascii-chars.js +21 -3
  70. package/dist/ui/footer.js +128 -7
  71. package/dist/ui/interrupt.js +35 -1
  72. package/dist/ui/live-area.js +21 -4
  73. package/dist/ui/queue-box-emitter.js +14 -0
  74. package/dist/ui/rewind-panel.js +2 -1
  75. package/dist/ui/session-timeline.js +2 -1
  76. package/dist/ui/text-input.js +31 -2
  77. package/dist/ui/widgets/bar-chart.js +2 -1
  78. package/dist/ui/widgets/confirm-card.js +2 -1
  79. package/dist/ui/widgets/dep-graph.js +2 -1
  80. package/dist/ui/widgets/diff-viewer.js +2 -1
  81. package/dist/ui/widgets/question-card.js +2 -1
  82. package/dist/ui/widgets/shortcuts-sheet.js +2 -2
  83. package/dist/ui/widgets/stack-frames.js +2 -1
  84. package/dist/util/connect-retry.js +65 -0
  85. package/package.json +1 -1
@@ -111,6 +111,76 @@ export async function checkTransportFit(adt, o) {
111
111
  },
112
112
  };
113
113
  }
114
+ /** The person's LOCAL calendar date as YYYY-MM-DD (fix round 1, M-1: never UTC). */
115
+ export function localIsoDate(date = new Date()) {
116
+ const p = (n) => String(n).padStart(2, '0');
117
+ return `${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())}`;
118
+ }
119
+ /** The suggested session-transport description (owner ruling 2026-10-24), editable by the person. */
120
+ export function cspeachTransportDefault(date = new Date()) {
121
+ return `CSPeach ${localIsoDate(date)}: <work summary>`;
122
+ }
123
+ /**
124
+ * Fix round 1, M-3 ruling: only the person's pick after a transport_needed
125
+ * question becomes the session transport. A refusal in the REPL session arms
126
+ * this; the next landed create uses it up. Not persisted (a new CLI process
127
+ * starts unarmed), like the created-this-session ledger in transport.ts.
128
+ */
129
+ let transportNeededAsked = false;
130
+ export function noteTransportNeededAsked() { transportNeededAsked = true; }
131
+ /** True once, then false: the next landed create consumes the question. */
132
+ export function takeTransportNeededAsked() {
133
+ const was = transportNeededAsked;
134
+ transportNeededAsked = false;
135
+ return was;
136
+ }
137
+ /** Test hook — module-scope state would otherwise leak across Vitest tests. */
138
+ export function clearTransportNeededAsked() { transportNeededAsked = false; }
139
+ export async function checkTransportNeeded(adt, o) {
140
+ const pkg = String(o.pkg ?? '').trim().toUpperCase();
141
+ if (!pkg || pkg.startsWith('$'))
142
+ return { ok: true };
143
+ if (typeof adt.transportCheck !== 'function')
144
+ return { ok: true };
145
+ const object = String(o.type ?? '').toUpperCase().split('/')[0].trim();
146
+ const objectName = String(o.name ?? '').trim().toUpperCase();
147
+ if (NO_FIT_CHECK_TYPES.has(object))
148
+ return { ok: true };
149
+ let uri;
150
+ try {
151
+ uri = toObjectUrl(object, objectName).toLowerCase();
152
+ }
153
+ catch {
154
+ debug(`no object URI for ${object} ${objectName}, no transport-needed check`);
155
+ return { ok: true };
156
+ }
157
+ let check;
158
+ try {
159
+ check = await adt.transportCheck({ pgmid: 'R3TR', object, objectName, devclass: pkg, operation: 'I', uri });
160
+ }
161
+ catch (err) {
162
+ debug(`transport-needed check failed, create goes ahead: ${(err instanceof Error ? err.message : String(err)).slice(0, 200)}`);
163
+ return { ok: true };
164
+ }
165
+ if (!check.ok) {
166
+ debug(`transport-needed check gave no answer, create goes ahead: ${(check.error ?? '').slice(0, 200)}`);
167
+ return { ok: true };
168
+ }
169
+ if (!check.requestRequired || !check.transportLayer)
170
+ return { ok: true };
171
+ const shown = (check.requests ?? []).slice(0, MAX_FITTING_SHOWN).map((r) => ({ number: r.number, description: r.text ?? '' }));
172
+ const list = shown.map((r) => (r.description ? `${r.number} ('${r.description}')` : r.number)).join(', ');
173
+ const message = `Package ${pkg} is transportable, so creating ${objectName} needs a transport request, and no session transport is set yet. `
174
+ + 'Nothing was created. Ask the person once with ask_question: '
175
+ + `create a new session transport (recommended; sap_transport_create, proposing the description "${cspeachTransportDefault()}", which they can edit)`
176
+ + (shown.length ? `, or use one of the open requests SAP lists for the package: ${list}` : '')
177
+ + '. Their answer becomes the session transport for the rest of this work. '
178
+ + 'Then call this tool again with the same approval_id (and the chosen request as transport, if it is an existing one).';
179
+ return {
180
+ ok: false,
181
+ refusal: { error: 'transport_needed', message, ...(shown.length ? { fitting_requests: shown } : {}) },
182
+ };
183
+ }
114
184
  /**
115
185
  * 1.1.31 — SAP answered a create with an existing request (the user's newest
116
186
  * open one, which may belong to other work). It is never used silently, and
@@ -24,7 +24,8 @@ import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
24
24
  import { isSafetyConfirmEnabled } from '../repl/safety-mode-state.js';
25
25
  import { setCurrentTransport, noteConfirmedTransport } from '../repl/current-transport.js';
26
26
  import { enrichTransportList } from './transport-fit.js';
27
- import { reusedRequestRefusal } from './transport-fit-check.js';
27
+ import { reusedRequestRefusal, localIsoDate } from './transport-fit-check.js';
28
+ import { STOPPED_BEFORE_START_RESULT } from '../agent/repair-partial.js';
28
29
  // ── Session ledger: transports CREATED by sap_transport_create this session ──
29
30
  //
30
31
  // Orphan-hint support (third junk transport this month, 2026-06-12 — D15/D20
@@ -63,10 +64,12 @@ registerTool({
63
64
  + 'Flow: resolve → object locked in an open transport? this tool then does NOT '
64
65
  + 'create anything and returns the owning transport — use it → only create when '
65
66
  + 'the object is free. '
66
- + 'Ask the user for the transport description using `ask_question` — DO NOT '
67
- + 'generate a marketing-style description yourself. When asking, SUGGEST the '
68
- + 'Rule 9 session format "<YYYY-MM-DD>: <work summary>" (no product name) as the default; '
69
- + 'the user may override. Use the user\'s exact answer as the `description` '
67
+ + 'Ask the user for the transport description once, using `ask_question` — DO NOT '
68
+ + 'make up a description yourself. When asking, PROPOSE the session format '
69
+ + '"CSPeach <YYYY-MM-DD>: <work summary>" as the recommended default (today\'s date, '
70
+ + 'the work in a few words); the user may edit it. '
71
+ + 'Never tell the person they will be asked unless you ask in this turn. '
72
+ + 'Use the user\'s exact answer as the `description` '
70
73
  + 'argument AND in your `request_approval` reason (identical strings prevent the '
71
74
  + 'approval mismatch failure mode observed live 2026-05-07). '
72
75
  + 'Requires approval_id. Returns the transport number to use in subsequent writes.',
@@ -300,12 +303,14 @@ registerTool({
300
303
  // Rule 9 description format (D20: "Magic fix" accepted verbatim) —
301
304
  // non-blocking note when the description doesn't follow the session
302
305
  // format. The transport IS created; the user's wording always wins.
303
- // Any leading label (a customer's own prefix) is fine; no product name is suggested.
306
+ // Any leading label (a customer's own prefix) is fine. LT-9 owner ruling
307
+ // (2026-10-24): the suggested default is "CSPeach <date>: <work summary>".
304
308
  const rule9Format = /^(?:\S.* )?\d{4}-\d{2}-\d{2}: .+/;
305
- const today = new Date().toISOString().slice(0, 10);
309
+ // Fix round 1, M-1: the person's local date, as in the transport_needed ask.
310
+ const today = localIsoDate();
306
311
  const rule9Suggestion = rule9Format.test(args.description)
307
312
  ? undefined
308
- : `Description does not follow the Rule 9 session format. Suggested: "${today}: <work summary>". `
313
+ : `Description does not follow the Rule 9 session format. Suggested: "CSPeach ${today}: <work summary>". `
309
314
  + 'Non-blocking — keep the user\'s wording if they chose it deliberately.';
310
315
  return {
311
316
  content: JSON.stringify({
@@ -637,6 +642,10 @@ registerTool({
637
642
  };
638
643
  }
639
644
  }
645
+ // ── LT-15 D6: stopped during the reads above → no card, no release ──────
646
+ // Before the approval is spent, the safety card and any release call.
647
+ if (ctx.signal?.aborted)
648
+ return { content: STOPPED_BEFORE_START_RESULT, is_error: true };
640
649
  // ── Gate 1: Approval (op release, type CTS) ─────────────────────────────
641
650
  const verify = await verifyAndSpendApprovalId(args.approval_id, args.transport, 'release');
642
651
  if (!verify.ok) {
@@ -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
@@ -18,6 +18,7 @@ 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
20
  import { uiWidth } from '../renderer/ui-width.js';
21
+ import { cardBorderStyle } from './ascii-chars.js';
21
22
  /**
22
23
  * Fix batch 2026-10-12 (Task 2): why the risk went up is the decision input,
23
24
  * so the "Risk: … (raised: …)" text wraps onto up to this many rows (both
@@ -27,7 +28,7 @@ export const RISK_MAX_ROWS = 4;
27
28
  /** Border 2 + paddingX 2 on each side. */
28
29
  const MODAL_CHROME_COLS = 6;
29
30
  function riskText(effectiveRisk) {
30
- const raised = shownRiskReasons(effectiveRisk.raisedBy).map(riskReasonText).join('; ');
31
+ const raised = riskReasonTexts(effectiveRisk).join('; ');
31
32
  return `Risk: ${effectiveRisk.level}${raised ? ` (raised: ${raised})` : ''}`;
32
33
  }
33
34
  /** The risk text as the rows it is drawn on at card width `width`. */
@@ -85,7 +86,7 @@ function diffLineColor(line, panels) {
85
86
  export const BACK_GUARD_MS = 300;
86
87
  /** The review view's key line, always at its bottom: how to go back and how to answer from here. */
87
88
  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 }) {
89
+ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSkip, onDiff, onCancel, onInterruptTurn, onStopTurn, width }) {
89
90
  const cardWidth = width ?? uiWidth();
90
91
  // BUG-1 — re-assert raw mode on mount so keypresses reach useInput even
91
92
  // when an inquirer gate ran earlier in the turn (see import block).
@@ -149,6 +150,7 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
149
150
  return;
150
151
  }
151
152
  if (input === 'c') {
153
+ onStopTurn?.();
152
154
  onCancel();
153
155
  return;
154
156
  }
@@ -181,7 +183,10 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
181
183
  }
182
184
  if (key.escape && Date.now() - backAt.current < BACK_GUARD_MS)
183
185
  return;
186
+ // LT-15 Revision 2 — Esc / [c] stop the whole turn (the same safe stop as
187
+ // the Esc brake), then resolve the card with the cancel-turn result.
184
188
  if (input === 'c' || key.escape) {
189
+ onStopTurn?.();
185
190
  onCancel();
186
191
  return;
187
192
  }
@@ -197,7 +202,7 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
197
202
  const where = scrolls
198
203
  ? ` lines ${from + 1}–${from + shownLines.length} of ${viewLines.length} · ↑↓ scroll`
199
204
  : '';
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) }) })] }));
205
+ 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
206
  }
202
207
  // Pre-wrapped rows, never Ink soft-wrap (Wave-1 rule): every row is counted.
203
208
  const risks = riskRows(effectiveRisk, cardWidth);
@@ -229,7 +234,7 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
229
234
  if (getLook() === 'panels') {
230
235
  const risk = roleProps(riskRole(effectiveRisk.level));
231
236
  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(' ') })] }) })] }));
237
+ 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
238
  }
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(' ') }) })] }));
239
+ 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
240
  }
@@ -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
  }