@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.
- package/dist/agent/chat-prompt.js +4 -1
- package/dist/agent/conversation-prompt.js +30 -13
- package/dist/agent/inline-skill-block.js +341 -0
- package/dist/agent/loop.js +208 -41
- package/dist/agent/provider-shape.js +8 -4
- package/dist/agent/providers/ai-hub-provider.js +2 -2
- package/dist/agent/providers/azure-provider.js +2 -2
- package/dist/agent/providers/byok-provider.js +2 -2
- package/dist/agent/providers/local-provider.js +2 -2
- package/dist/agent/repair-partial.js +12 -0
- package/dist/agent/skill-block-key.js +134 -0
- package/dist/agent/steering-queue.js +50 -14
- package/dist/agent/system-prompt.js +22 -0
- package/dist/agent/tool-dispatch.js +19 -7
- package/dist/agent/turn-watchdog.js +1 -1
- package/dist/approvals/advisory-render.js +6 -3
- package/dist/approvals/approval-prompt.js +9 -4
- package/dist/approvals/op-labels.js +14 -0
- package/dist/approvals/plan-grant.js +8 -0
- package/dist/approvals/render.js +19 -8
- package/dist/approvals/risk-floor.js +32 -4
- package/dist/approvals/visible-changes.js +50 -0
- package/dist/cli.js +10 -2
- package/dist/commands/compact.js +6 -1
- package/dist/commands/config-set.js +13 -0
- package/dist/commands/config-show.js +16 -4
- package/dist/commands/export-conversation.js +7 -4
- package/dist/commands/finish-check-guard.js +124 -0
- package/dist/commands/plan-reground.js +27 -4
- package/dist/commands/plan-resume.js +111 -1
- package/dist/config/loader.js +5 -1
- package/dist/cost/cost-log.js +6 -0
- package/dist/cost/system-block.js +6 -0
- package/dist/models/server-config.js +1 -1
- package/dist/projects/save-command.js +5 -1
- package/dist/projects/yes-no.js +5 -1
- package/dist/renderer/change-card.js +3 -1
- package/dist/renderer/steering-echo.js +53 -8
- package/dist/renderer/tool-labels.js +3 -1
- package/dist/repl/chat-lane.js +5 -2
- package/dist/repl/inline-mode.js +51 -0
- package/dist/repl/leftover.js +90 -0
- package/dist/repl/reroute-turn.js +7 -1
- package/dist/repl/turn-stop.js +64 -0
- package/dist/repl.js +144 -22
- package/dist/router/classifier.js +1 -1
- package/dist/sap/reachability-state.js +7 -0
- package/dist/sap/unreachable.js +2 -2
- package/dist/session/queued-step.js +3 -1
- package/dist/session/user-prompt.js +10 -1
- package/dist/skills/manifest-client.js +3 -2
- package/dist/skills/preamble.js +6 -4
- package/dist/skills/source-authed.js +3 -2
- package/dist/tools/approval.js +37 -7
- package/dist/tools/dispatch-skill.js +1 -1
- package/dist/tools/hand-off.js +49 -7
- package/dist/tools/lane/app-lane-resolve.js +9 -0
- package/dist/tools/method-only-change.js +241 -0
- package/dist/tools/rap/skeleton-resolve.js +4 -0
- package/dist/tools/sap-write.js +78 -3
- package/dist/tools/snapshot.js +7 -2
- package/dist/tools/subagent/agent_run.js +14 -0
- package/dist/tools/tool-loading.js +16 -8
- package/dist/tools/transport-fit-check.js +70 -0
- package/dist/tools/transport.js +17 -8
- package/dist/tools/write-pipeline.js +46 -5
- package/dist/ui/app.js +112 -13
- package/dist/ui/approval-modal.js +11 -6
- package/dist/ui/ascii-chars.js +21 -3
- package/dist/ui/footer.js +128 -7
- package/dist/ui/interrupt.js +35 -1
- package/dist/ui/live-area.js +21 -4
- package/dist/ui/queue-box-emitter.js +14 -0
- package/dist/ui/rewind-panel.js +2 -1
- package/dist/ui/session-timeline.js +2 -1
- package/dist/ui/text-input.js +31 -2
- package/dist/ui/widgets/bar-chart.js +2 -1
- package/dist/ui/widgets/confirm-card.js +2 -1
- package/dist/ui/widgets/dep-graph.js +2 -1
- package/dist/ui/widgets/diff-viewer.js +2 -1
- package/dist/ui/widgets/question-card.js +2 -1
- package/dist/ui/widgets/shortcuts-sheet.js +2 -2
- package/dist/ui/widgets/stack-frames.js +2 -1
- package/dist/util/connect-retry.js +65 -0
- 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
|
package/dist/tools/transport.js
CHANGED
|
@@ -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
|
-
+ '
|
|
68
|
-
+ '
|
|
69
|
-
+ 'the
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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 {
|
|
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 =
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
}
|
package/dist/ui/ascii-chars.js
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import { shouldUseUnicodeBoxChars } from '../renderer/tty.js';
|
|
2
|
-
export const UNICODE_BOX = { tl: '
|
|
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
|
-
|
|
5
|
-
|
|
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
|
}
|