@cspeach/cli 1.1.17 → 1.1.19
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/README.md +3 -4
- package/dist/agent/loop.js +143 -25
- package/dist/agent/providers/local-provider.js +4 -1
- package/dist/agent/repair-partial.js +76 -19
- package/dist/agent/tool-dispatch.js +80 -22
- package/dist/agent/turn-error-ux.js +77 -26
- package/dist/agent/turn-stream.js +25 -3
- package/dist/approvals/adt-type.js +27 -0
- package/dist/approvals/advisory-prompt.js +48 -0
- package/dist/approvals/advisory-render.js +25 -11
- package/dist/approvals/approval-prompt.js +74 -24
- package/dist/approvals/jwt.js +2 -1
- package/dist/approvals/render.js +64 -36
- package/dist/approvals/risk-floor.js +53 -2
- package/dist/auth/api-key.js +30 -16
- package/dist/auth/keychain.js +0 -0
- package/dist/auth/me.js +25 -7
- package/dist/cli.js +25 -6
- package/dist/commands/compact.js +12 -9
- package/dist/commands/config-set.js +6 -2
- package/dist/commands/config-show.js +7 -5
- package/dist/commands/cost.js +41 -34
- package/dist/commands/help.js +88 -50
- package/dist/commands/login.js +28 -19
- package/dist/commands/logout.js +7 -3
- package/dist/commands/plan-chain.js +71 -77
- package/dist/commands/plan-continue.js +1 -0
- package/dist/commands/plan-resume.js +20 -12
- package/dist/commands/whoami.js +23 -9
- package/dist/config/loader.js +15 -3
- package/dist/cost/pricing.js +6 -5
- package/dist/doctor/checks/forge-rules.js +13 -3
- package/dist/doctor/checks/keychain.js +6 -4
- package/dist/doctor/run.js +101 -20
- package/dist/index.js +5 -2
- package/dist/lib/graceful-exit.js +56 -0
- package/dist/lib/piped-prompt.js +65 -0
- package/dist/lock-contention.js +2 -2
- package/dist/one-shot.js +135 -40
- package/dist/projects/answer-blockers.js +6 -1
- package/dist/projects/handover-md.js +15 -14
- package/dist/projects/plan-run.js +97 -54
- package/dist/projects/promote-command.js +11 -2
- package/dist/projects/save-command.js +3 -2
- package/dist/projects/status.js +2 -1
- package/dist/projects/yes-no.js +12 -0
- package/dist/renderer/abap-inline.js +18 -14
- package/dist/renderer/answer-trim.js +69 -0
- package/dist/renderer/banners.js +8 -8
- package/dist/renderer/brand-settled.js +19 -0
- package/dist/renderer/change-card.js +156 -0
- package/dist/renderer/checklist-format.js +130 -0
- package/dist/renderer/color-mode.js +45 -0
- package/dist/renderer/error-detail.js +104 -0
- package/dist/renderer/fence-state.js +46 -0
- package/dist/renderer/footer-line.js +96 -0
- package/dist/renderer/glyphs.js +25 -0
- package/dist/renderer/goodbye.js +38 -0
- package/dist/renderer/legacy-palette.js +41 -0
- package/dist/renderer/look.js +30 -0
- package/dist/renderer/markdown.js +299 -92
- package/dist/renderer/notice-log.js +72 -0
- package/dist/renderer/notice-shape.js +48 -0
- package/dist/renderer/notices.js +40 -8
- package/dist/renderer/panel-rows.js +16 -0
- package/dist/renderer/pipeline.js +66 -0
- package/dist/renderer/progress-chatter.js +23 -17
- package/dist/renderer/rendering-mode.js +34 -0
- package/dist/renderer/routing-line.js +14 -0
- package/dist/renderer/sanitize.js +12 -0
- package/dist/renderer/severity.js +2 -7
- package/dist/renderer/startup-lines.js +152 -0
- package/dist/renderer/status-footer.js +52 -34
- package/dist/renderer/steering-echo.js +41 -0
- package/dist/renderer/syntax.js +31 -12
- package/dist/renderer/tables.js +5 -1
- package/dist/renderer/theme.js +44 -0
- package/dist/renderer/thinking-heartbeat.js +33 -2
- package/dist/renderer/todo-block.js +10 -49
- package/dist/renderer/tool-labels.js +238 -0
- package/dist/renderer/tool-widget.js +245 -51
- package/dist/renderer/trace.js +42 -0
- package/dist/renderer/transcript-flow.js +132 -0
- package/dist/renderer/tty.js +34 -1
- package/dist/renderer/ui-width.js +42 -0
- package/dist/renderer/verify-chain.js +4 -2
- package/dist/renderer/widget-fallback.js +58 -65
- package/dist/repl/bracketed-paste.js +7 -1
- package/dist/repl/builtin-commands.js +19 -8
- package/dist/repl/credential-handover-gate.js +28 -0
- package/dist/repl/early-line-buffer.js +5 -2
- package/dist/repl/file-picker.js +54 -12
- package/dist/repl/ink-stdin-guard.js +66 -0
- package/dist/repl/inquirer-guard.js +59 -16
- package/dist/repl/inquirer-theme.js +27 -33
- package/dist/repl/paste-marker.js +19 -0
- package/dist/repl/plan-turn-end.js +20 -0
- package/dist/repl/post-turn-status.js +36 -11
- package/dist/repl/reroute-turn.js +21 -0
- package/dist/repl/reset-tty-stdin.js +44 -0
- package/dist/repl/restore-guard.js +22 -0
- package/dist/repl/resume-standalone.js +63 -0
- package/dist/repl/rule8-detector.js +11 -3
- package/dist/repl/safety-confirm.js +161 -93
- package/dist/repl/session-spend-line.js +8 -4
- package/dist/repl/themed-prompts.js +19 -0
- package/dist/repl/ui-look-command.js +75 -0
- package/dist/repl/update-method-preview-hook.js +48 -5
- package/dist/repl.js +795 -332
- package/dist/rewind/cli.js +3 -2
- package/dist/rewind/format.js +14 -9
- package/dist/rewind/restore.js +11 -0
- package/dist/router/classifier.js +47 -4
- package/dist/router/intent-extractor.js +24 -8
- package/dist/router/piped-routing.js +23 -0
- package/dist/router/resume-routing.js +16 -0
- package/dist/router/routing-failure.js +57 -0
- package/dist/sap/first-run-choice.js +1 -1
- package/dist/sap/onboarding.js +3 -2
- package/dist/sap/standalone-onboarding.js +1 -1
- package/dist/sap/system-info.js +4 -2
- package/dist/sap/unreachable.js +28 -0
- package/dist/sap-errors/clean-error-text.js +182 -0
- package/dist/session/interrupt-reason.js +39 -0
- package/dist/session/recap.js +36 -24
- package/dist/session/resume.js +104 -36
- package/dist/session/store.js +69 -27
- package/dist/session/user-prompt.js +21 -0
- package/dist/skill-catalog.js +7 -0
- package/dist/skills/bundled-skills.js +76 -69
- package/dist/skills/signing-public-key.js +1 -1
- package/dist/standards/standards-init.js +1 -1
- package/dist/test-helpers/answer-any.js +46 -0
- package/dist/tools/approval.js +27 -11
- package/dist/tools/ask-question.js +47 -5
- package/dist/tools/filesystem/file-write.js +19 -2
- package/dist/tools/fiori/fe-extend.js +16 -1
- package/dist/tools/fiori/fe-scaffold.js +105 -3
- package/dist/tools/fiori/html-escapes.js +28 -0
- package/dist/tools/fiori/metadata/audit.js +605 -0
- package/dist/tools/fiori/metadata/references.js +104 -0
- package/dist/tools/fiori/metadata/types.js +8 -0
- package/dist/tools/fiori/preview/app-guard.js +215 -0
- package/dist/tools/fiori/preview/env.js +193 -0
- package/dist/tools/fiori/preview/readiness.js +127 -0
- package/dist/tools/fiori/preview/registry.js +412 -0
- package/dist/tools/fiori/preview/start.js +469 -0
- package/dist/tools/fiori/samples/loader.js +20 -5
- package/dist/tools/fiori/scaffold.js +18 -6
- package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
- package/dist/tools/fiori/smoke/app-driver.js +100 -0
- package/dist/tools/fiori/smoke/assertions.js +161 -24
- package/dist/tools/fiori/smoke/audit-columns.js +21 -0
- package/dist/tools/fiori/smoke/batch.js +273 -0
- package/dist/tools/fiori/smoke/draft-safety.js +241 -0
- package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
- package/dist/tools/fiori/smoke/driver.js +171 -4
- package/dist/tools/fiori/smoke/evidence.js +35 -0
- package/dist/tools/fiori/smoke/labels-codes.js +207 -0
- package/dist/tools/fiori/smoke/nav-error.js +26 -0
- package/dist/tools/fiori/smoke/run-smoke.js +155 -12
- package/dist/tools/fiori/tools.js +651 -7
- package/dist/tools/fiori/ui5-version.js +16 -0
- package/dist/tools/index.js +8 -0
- package/dist/tools/local-build.js +6 -0
- package/dist/tools/sap-read.js +16 -1
- package/dist/tools/sap-write.js +122 -10
- package/dist/tools/shell/shell_exec.js +30 -12
- package/dist/tools/syntax-state.js +67 -0
- package/dist/tools/todo.js +23 -18
- package/dist/tools/transport-fit.js +462 -0
- package/dist/tools/transport-resolution.js +3 -1
- package/dist/tools/transport.js +33 -7
- package/dist/tools/verify.js +184 -8
- package/dist/ui/app.js +284 -146
- package/dist/ui/approval-modal.js +41 -15
- package/dist/ui/ask-answer-text.js +17 -0
- package/dist/ui/body.js +25 -6
- package/dist/ui/card-slot.js +98 -0
- package/dist/ui/checklist.js +9 -0
- package/dist/ui/coaching-picker-classic.js +8 -5
- package/dist/ui/command-palette.js +6 -4
- package/dist/ui/confirm-request.js +18 -0
- package/dist/ui/context-grid.js +2 -1
- package/dist/ui/file-palette.js +6 -4
- package/dist/ui/files-card-emitter.js +19 -0
- package/dist/ui/footer-line.js +16 -0
- package/dist/ui/footer.js +105 -104
- package/dist/ui/input-wrap.js +122 -0
- package/dist/ui/interrupt.js +103 -0
- package/dist/ui/key-burst.js +117 -0
- package/dist/ui/line-resolution.js +4 -3
- package/dist/ui/live-area.js +71 -0
- package/dist/ui/login-banner.js +54 -43
- package/dist/ui/rewind-panel.js +76 -24
- package/dist/ui/role-props.js +47 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +49 -3
- package/dist/ui/skill-picker.js +5 -47
- package/dist/ui/text-input.js +128 -24
- package/dist/ui/todo-emitter.js +19 -0
- package/dist/ui/turn-status-emitter.js +28 -2
- package/dist/ui/turn-status.js +16 -19
- package/dist/ui/use-card-request.js +44 -0
- package/dist/ui/widgets/ask-card.js +521 -0
- package/dist/ui/widgets/bar-chart.js +11 -5
- package/dist/ui/widgets/coaching-picker.js +50 -55
- package/dist/ui/widgets/confirm-card.js +193 -0
- package/dist/ui/widgets/dep-graph.js +3 -2
- package/dist/ui/widgets/diff-viewer.js +6 -5
- package/dist/ui/widgets/files-card.js +97 -0
- package/dist/ui/widgets/question-card.js +2 -1
- package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
- package/dist/ui/widgets/shortcuts-sheet.js +25 -0
- package/dist/ui/widgets/stack-frames.js +2 -1
- package/dist/upgrade-check.js +39 -8
- package/package.json +11 -2
- package/test-harness/fe-draft/app.ts +188 -0
- package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
- package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
- package/test-harness/fe-draft/routes.ts +224 -0
- package/test-harness/fe-draft/routes.unit.test.ts +45 -0
- package/test-harness/fe-draft/server.ts +225 -0
- package/test-harness/fe-draft/variants.ts +196 -0
- package/vitest.smoke-e2e.config.ts +20 -0
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { PEACH } from './
|
|
2
|
+
import { LEGACY_PEACH as PEACH } from './legacy-palette.js';
|
|
3
|
+
import { getLook } from './look.js';
|
|
4
|
+
import { paint } from './theme.js';
|
|
5
|
+
import { toolLabel, toolActivityLabel, foldOutput, clean } from './tool-labels.js';
|
|
6
|
+
import { getEffectiveBodyWidth } from './tty.js';
|
|
7
|
+
import { flowFor, stdoutFlow, writeStdout, canAnimateInPlace } from './transcript-flow.js';
|
|
8
|
+
import { humanErrorDetail } from './error-detail.js';
|
|
9
|
+
export { canAnimateInPlace };
|
|
3
10
|
/**
|
|
4
11
|
* Tool-dispatch renderer — CC-style two-line format with peach spinner.
|
|
5
12
|
*
|
|
@@ -46,6 +53,14 @@ const WIDGET_SUPPRESSED_TOOLS = new Set(['ask_question', 'todo_set']);
|
|
|
46
53
|
export function isWidgetSuppressedTool(name) {
|
|
47
54
|
return WIDGET_SUPPRESSED_TOOLS.has(name);
|
|
48
55
|
}
|
|
56
|
+
/** Live activity text (TurnStatus). Legacy = the raw tool name, as today. */
|
|
57
|
+
export function toolActivityText(name, args) {
|
|
58
|
+
return getLook() === 'panels' ? toolActivityLabel(name, args) : name;
|
|
59
|
+
}
|
|
60
|
+
/** Heartbeat label. Legacy = `${name} running…`, as today. */
|
|
61
|
+
export function toolHeartbeatLabel(name, args) {
|
|
62
|
+
return getLook() === 'panels' ? `${toolActivityLabel(name, args)}…` : `${name} running…`;
|
|
63
|
+
}
|
|
49
64
|
const ARG_SUMMARY_MAX = 64;
|
|
50
65
|
// 2026-05-15 (bug 1): widened from 96 → 240 so a parsed ADT exception
|
|
51
66
|
// message ("CTS_WBO_API/047: Request S4HK903388 is not a local request"
|
|
@@ -65,17 +80,72 @@ const SPINNER_VERBS = ['slicing peach', 'mashing pulp', 'pitting', 'pressing', '
|
|
|
65
80
|
* tool. Tool dispatch = peach-cooking metaphor; thinking = peach-growing
|
|
66
81
|
* metaphor (a beat slower in pacing). */
|
|
67
82
|
const THINKING_VERBS = ['thinking', 'ripening', 'steeping', 'marinating', 'pondering', 'considering'];
|
|
83
|
+
let activeSpinner = null;
|
|
84
|
+
export function clearActiveSpinner() {
|
|
85
|
+
if (activeSpinner) {
|
|
86
|
+
activeSpinner.stop();
|
|
87
|
+
activeSpinner = null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
68
90
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
91
|
+
* Piece 2 §11.3 — pause the in-place spinner while an inquirer prompt owns the
|
|
92
|
+
* terminal and hand back a resume function (inquirer-guard calls it when it
|
|
93
|
+
* releases the terminal). Fixes the classic spinner vanishing for the rest of
|
|
94
|
+
* a tool after an approval mid-tool.
|
|
73
95
|
*/
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
|
|
96
|
+
export function suspendActiveSpinner() {
|
|
97
|
+
const s = activeSpinner;
|
|
98
|
+
if (!s)
|
|
99
|
+
return () => undefined;
|
|
100
|
+
return s.suspend();
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Review M1 (2026-09-27): run a print-then-prompt sequence with the active
|
|
104
|
+
* tool spinner suspended for the WHOLE sequence. The classic plan gate and
|
|
105
|
+
* the method diff preview print their preamble before withInquirer opens;
|
|
106
|
+
* without this the spinner kept repainting around that text and a stale
|
|
107
|
+
* frame was left in scrollback. Ref-counted like any suspend, so the nested
|
|
108
|
+
* withInquirer suspend is harmless. No spinner → just runs fn.
|
|
109
|
+
*/
|
|
110
|
+
export async function withSpinnerSuspended(fn) {
|
|
111
|
+
const resume = suspendActiveSpinner();
|
|
112
|
+
try {
|
|
113
|
+
return await fn();
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
resume();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Live fix 2026-09-26 — the ONE way classic output is written while a tool
|
|
121
|
+
* runs. The in-place spinner owns the current line (erase + `\r<frame>`), so
|
|
122
|
+
* a notice or change card written straight to stdout was glued onto the
|
|
123
|
+
* spinner text. When the spinner is painting: suspend it (erases its line,
|
|
124
|
+
* cursor to column 0), write the text on the clean line (with a trailing
|
|
125
|
+
* newline so the resumed spinner cannot erase it), then resume the spinner
|
|
126
|
+
* below. No spinner (Ink, pipes, between tools) → a plain write.
|
|
127
|
+
*/
|
|
128
|
+
/**
|
|
129
|
+
* Round 6 review M3 — true when writeAboveSpinner(text) would add a newline
|
|
130
|
+
* of its own (a spinner is painting and `text` does not end a line), so a
|
|
131
|
+
* caller that counts newlines (one-shot's AnswerTrim) can account for it.
|
|
132
|
+
*/
|
|
133
|
+
export function aboveSpinnerAddsNewline(text) {
|
|
134
|
+
const s = activeSpinner;
|
|
135
|
+
return !!s && s.painting() && !text.endsWith('\n');
|
|
136
|
+
}
|
|
137
|
+
export function writeAboveSpinner(text, write = writeStdout) {
|
|
138
|
+
const s = activeSpinner;
|
|
139
|
+
if (!s || !s.painting()) {
|
|
140
|
+
write(text);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const resume = s.suspend();
|
|
144
|
+
try {
|
|
145
|
+
write(text.endsWith('\n') ? text : text + '\n');
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
resume();
|
|
79
149
|
}
|
|
80
150
|
}
|
|
81
151
|
export function renderToolCallTop(params) {
|
|
@@ -88,8 +158,23 @@ export function renderToolCallTop(params) {
|
|
|
88
158
|
if (chunkEmitter)
|
|
89
159
|
chunkEmitter.emit('chunk', s);
|
|
90
160
|
else
|
|
91
|
-
|
|
161
|
+
writeStdout(s);
|
|
92
162
|
};
|
|
163
|
+
// Panels look: no dispatch line — the live TurnStatus row carries
|
|
164
|
+
// "Reading x…" while the step runs; the transcript gets ONE row at the end.
|
|
165
|
+
// Start a fresh line so the classic spinner and the panels row never erase
|
|
166
|
+
// or glue onto assistant text that didn't end in a newline. Polish P1: the
|
|
167
|
+
// newline is only added when the output is really mid-line (the transcript
|
|
168
|
+
// flow knows), so consecutive tool rows sit together with no blank line.
|
|
169
|
+
// A raw emitter with no Body attached (tests, subagent/audit sinks) has no
|
|
170
|
+
// flow: it gets the unconditional "\n" as before.
|
|
171
|
+
if (getLook() === 'panels') {
|
|
172
|
+
const flow = chunkEmitter ? flowFor(chunkEmitter) : stdoutFlow;
|
|
173
|
+
const brk = flow ? flow.lineBreakIfNeeded() : '\n';
|
|
174
|
+
if (brk)
|
|
175
|
+
write(brk);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
93
178
|
const summary = summariseToolArgs(name, args);
|
|
94
179
|
const wrapped = summary ? chalk.gray('(') + chalk.gray(summary) + chalk.gray(')') : '';
|
|
95
180
|
const line = `${peach('⏺')} ${chalk.bold(name)}${wrapped}`;
|
|
@@ -106,66 +191,109 @@ export function renderToolCallTop(params) {
|
|
|
106
191
|
* prompt opens, same as tool spinner).
|
|
107
192
|
*/
|
|
108
193
|
export function startThinkingSpinner(params) {
|
|
109
|
-
return startSpinnerWithVerbs(params, THINKING_VERBS, 'thinking');
|
|
194
|
+
return startSpinnerWithVerbs(params, THINKING_VERBS, 'thinking', getLook() === 'panels' ? 'thinking' : undefined);
|
|
110
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* `label` (panels look only): the step's activity text, e.g.
|
|
198
|
+
* `Searching "*AGING*" · PROG` — classic has no live status row, so the
|
|
199
|
+
* spinner carries it (spec §4). Legacy ignores it and keeps its verbs.
|
|
200
|
+
*/
|
|
111
201
|
export function startToolSpinner(params) {
|
|
112
|
-
return startSpinnerWithVerbs(params, SPINNER_VERBS, 'slicing peach');
|
|
202
|
+
return startSpinnerWithVerbs(params, SPINNER_VERBS, 'slicing peach', getLook() === 'panels' ? params.label : undefined);
|
|
113
203
|
}
|
|
114
|
-
|
|
204
|
+
/** Panels spinner: elapsed time appears after this long (matches the heartbeat's first "(10s)"). */
|
|
205
|
+
const SPINNER_ELAPSED_AFTER_MS = 10_000;
|
|
206
|
+
function startSpinnerWithVerbs(params, verbs, defaultVerb, fixedLabel) {
|
|
115
207
|
const { chunkEmitter } = params;
|
|
116
|
-
const disabled = chunkEmitter
|
|
117
|
-
!process.stdout.isTTY ||
|
|
118
|
-
process.env['CI'] === '1' ||
|
|
119
|
-
process.env['TERM'] === 'dumb';
|
|
208
|
+
const disabled = !canAnimateInPlace(chunkEmitter);
|
|
120
209
|
if (disabled)
|
|
121
210
|
return { stop: () => undefined };
|
|
122
211
|
// Pick one verb per dispatch so the indicator doesn't churn.
|
|
123
|
-
const verb = verbs[Math.floor(Math.random() * verbs.length)] ?? defaultVerb;
|
|
212
|
+
const verb = fixedLabel !== undefined ? clean(fixedLabel) : (verbs[Math.floor(Math.random() * verbs.length)] ?? defaultVerb);
|
|
124
213
|
let frameIdx = 0;
|
|
125
|
-
let stopped = false;
|
|
126
|
-
|
|
127
|
-
|
|
214
|
+
let stopped = false; // for good
|
|
215
|
+
let running = false; // painting right now
|
|
216
|
+
let suspendCount = 0; // open suspenders (prompts / focus claims)
|
|
217
|
+
let interval;
|
|
218
|
+
const startedAt = Date.now();
|
|
219
|
+
const paintFrame = () => {
|
|
220
|
+
if (!running)
|
|
128
221
|
return;
|
|
129
222
|
const frame = SPINNER_FRAMES[frameIdx];
|
|
130
223
|
frameIdx = (frameIdx + 1) % SPINNER_FRAMES.length;
|
|
131
|
-
// Erase current line + reprint
|
|
132
|
-
|
|
133
|
-
|
|
224
|
+
// Erase current line + reprint in place.
|
|
225
|
+
let line = ` ${peach(frame)} ${chalk.dim(verb + '…')}`;
|
|
226
|
+
if (fixedLabel !== undefined) {
|
|
227
|
+
// Panels: elapsed time once the wait is long, and never wider than the
|
|
228
|
+
// terminal — a wrapped spinner row would leave its first half behind
|
|
229
|
+
// when the erase clears only the current row.
|
|
230
|
+
const elapsed = Date.now() - startedAt;
|
|
231
|
+
// Piece 2 §8 — one cancel wording on every live surface (I2's strip says "stop").
|
|
232
|
+
const tail = elapsed >= SPINNER_ELAPSED_AFTER_MS ? ` (${Math.floor(elapsed / 1000)}s · ctrl+c to stop)` : '';
|
|
233
|
+
const room = Math.max(10, (process.stdout.columns || 80) - 1) - 4;
|
|
234
|
+
line = ` ${peach(frame)} ${chalk.dim(truncate(verb + '…' + tail, room))}`;
|
|
235
|
+
}
|
|
134
236
|
process.stdout.write(`\x1B[2K\r${line}`);
|
|
135
237
|
};
|
|
136
|
-
// Hide the
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
238
|
+
// Hide the cursor (so it doesn't blink mid-frame), start at column 0,
|
|
239
|
+
// paint, tick. Used at start and on resume.
|
|
240
|
+
const begin = () => {
|
|
241
|
+
if (stopped || running)
|
|
242
|
+
return;
|
|
243
|
+
running = true;
|
|
244
|
+
process.stdout.write('\x1B[?25l');
|
|
245
|
+
process.stdout.write('\r');
|
|
246
|
+
paintFrame();
|
|
247
|
+
interval = setInterval(paintFrame, SPINNER_TICK_MS);
|
|
248
|
+
interval.unref?.();
|
|
249
|
+
};
|
|
250
|
+
// Erase the spinner line and re-show the cursor (paired with the hide above).
|
|
251
|
+
// No newline, so the bottom renderer's \n keeps the spacing correct.
|
|
252
|
+
const halt = () => {
|
|
253
|
+
if (!running)
|
|
146
254
|
return;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
// \r → start of line, \x1B[2K → erase entire line. No newline so the
|
|
151
|
-
// bottom renderer's \n keeps the spacing correct.
|
|
152
|
-
// Also re-show the cursor (\x1B[?25h) — paired with the hide above.
|
|
255
|
+
running = false;
|
|
256
|
+
if (interval)
|
|
257
|
+
clearInterval(interval);
|
|
153
258
|
process.stdout.write('\r\x1B[2K\x1B[?25h');
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
259
|
+
};
|
|
260
|
+
const controller = {
|
|
261
|
+
painting: () => running,
|
|
262
|
+
stop: () => {
|
|
263
|
+
if (stopped)
|
|
264
|
+
return;
|
|
265
|
+
stopped = true;
|
|
266
|
+
halt();
|
|
267
|
+
// Clear the global handle if it's still pointing at us. (If a newer
|
|
268
|
+
// spinner already replaced us, leave its registration alone.)
|
|
269
|
+
if (activeSpinner === controller)
|
|
270
|
+
activeSpinner = null;
|
|
271
|
+
},
|
|
272
|
+
suspend: () => {
|
|
273
|
+
suspendCount += 1;
|
|
274
|
+
halt();
|
|
275
|
+
let used = false;
|
|
276
|
+
return () => {
|
|
277
|
+
if (used)
|
|
278
|
+
return;
|
|
279
|
+
used = true;
|
|
280
|
+
suspendCount -= 1;
|
|
281
|
+
if (suspendCount === 0 && activeSpinner === controller)
|
|
282
|
+
begin();
|
|
283
|
+
};
|
|
284
|
+
},
|
|
158
285
|
};
|
|
159
286
|
// Defensive: if a previous spinner is somehow still active when we start
|
|
160
287
|
// (shouldn't happen — agent loop stops the previous one before next
|
|
161
288
|
// dispatch — but easy to guarantee here), stop it before claiming the slot.
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
289
|
+
if (activeSpinner)
|
|
290
|
+
activeSpinner.stop();
|
|
291
|
+
activeSpinner = controller;
|
|
292
|
+
begin();
|
|
293
|
+
return { stop: controller.stop };
|
|
166
294
|
}
|
|
167
295
|
export function renderToolCallBottom(params) {
|
|
168
|
-
const { durationMs, isError, resultSummary, chunkEmitter, name, args } = params;
|
|
296
|
+
const { durationMs, isError, resultSummary, chunkEmitter, name, args, resultContent } = params;
|
|
169
297
|
// Ask-form de-clutter: paired with the top-line suppression — on SUCCESS
|
|
170
298
|
// the answer already came back through the form; a ⎿ row would be noise.
|
|
171
299
|
// ERRORS still render (review fix): an ask_form_invalid /
|
|
@@ -178,8 +306,12 @@ export function renderToolCallBottom(params) {
|
|
|
178
306
|
if (chunkEmitter)
|
|
179
307
|
chunkEmitter.emit('chunk', s);
|
|
180
308
|
else
|
|
181
|
-
|
|
309
|
+
writeStdout(s);
|
|
182
310
|
};
|
|
311
|
+
if (getLook() === 'panels') {
|
|
312
|
+
write(panelsRow({ name: name ?? '', args: args ?? {}, isError, resultSummary, resultContent, durationMs }));
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
183
315
|
const icon = isError ? chalk.red('✗') : chalk.green('✓');
|
|
184
316
|
// Label: plain (not bold — the top line keeps visual primacy) name +
|
|
185
317
|
// dim arg summary, mirroring the top-line shape so orphaned rows still
|
|
@@ -253,3 +385,65 @@ function formatDuration(ms) {
|
|
|
253
385
|
return `${ms}ms`;
|
|
254
386
|
return `${(ms / 1000).toFixed(1)}s`;
|
|
255
387
|
}
|
|
388
|
+
const VERB_COL = 12;
|
|
389
|
+
const ERROR_DETAIL_LINES = 3;
|
|
390
|
+
const OUTPUT_LINES = 3;
|
|
391
|
+
/** 'cancelled' / 'not approved' when a request_approval result declined; undefined otherwise. */
|
|
392
|
+
function approvalDeclined(content) {
|
|
393
|
+
if (!content || !content.trim().startsWith('{'))
|
|
394
|
+
return undefined;
|
|
395
|
+
try {
|
|
396
|
+
const r = JSON.parse(content);
|
|
397
|
+
if (r.approved !== false)
|
|
398
|
+
return undefined;
|
|
399
|
+
return r.reason === 'plan_rejected' || r.reason === 'user_cancelled_turn' ? 'cancelled' : 'not approved';
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
function panelsRow(p) {
|
|
406
|
+
const width = Math.max(20, getEffectiveBodyWidth());
|
|
407
|
+
const label = toolLabel(p.name, p.args, p.isError ? undefined : p.resultContent);
|
|
408
|
+
// Round 8 item 2 — a cancelled / rejected approval is not a success: no
|
|
409
|
+
// green tick, and the row says what happened.
|
|
410
|
+
const declined = !p.isError && p.name === 'request_approval' ? approvalDeclined(p.resultContent) : undefined;
|
|
411
|
+
const icon = p.isError ? paint('bad', '✗') : declined ? paint('warn', '·') : paint('ok', '✓');
|
|
412
|
+
const timing = p.durationMs >= 1000 ? formatDuration(p.durationMs) : '';
|
|
413
|
+
const tail = [declined ?? label.detail, timing].filter(Boolean).join(' · ');
|
|
414
|
+
const verb = label.verb.padEnd(VERB_COL);
|
|
415
|
+
// Budget: "✓ " + verb + " " + object + " " + tail must fit the width.
|
|
416
|
+
const budget = width - 2 - verb.length - 1 - (tail ? tail.length + 2 : 0);
|
|
417
|
+
const object = budget <= 1 ? '' : truncate(label.object, budget);
|
|
418
|
+
let line = `${icon} ${verb} ${object}`;
|
|
419
|
+
if (tail)
|
|
420
|
+
line += ` ${paint('dim', tail)}`;
|
|
421
|
+
const out = [truncatePlain(line, width)];
|
|
422
|
+
// Live fix 2026-09-26: a JSON tool result (e.g. sap_activate's
|
|
423
|
+
// activation_results) is turned into readable lines, never shown raw.
|
|
424
|
+
const errorSource = p.resultContent?.trim().startsWith('{') ? p.resultContent : p.resultSummary;
|
|
425
|
+
if (p.isError && errorSource) {
|
|
426
|
+
// Raw error text can carry ANSI codes, \r redraws and tabs: clean() each
|
|
427
|
+
// line (the one sanitiser, shared with the labels) before truncate + paint.
|
|
428
|
+
for (const d of humanErrorDetail(errorSource).map(clean).filter(Boolean).slice(0, ERROR_DETAIL_LINES)) {
|
|
429
|
+
out.push(' ' + paint('bad', truncate(d, width - 4)));
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
else if (!p.isError && p.name === 'shell_exec' && p.resultContent) {
|
|
433
|
+
const stdout = /stdout:\n([\s\S]*?)\n\nstderr:/.exec(p.resultContent)?.[1] ?? '';
|
|
434
|
+
// Real command output carries ANSI colour, \r progress redraws and tabs:
|
|
435
|
+
// clean() every line before folding so counts, truncation and colour are safe.
|
|
436
|
+
const folded = foldOutput(stdout.split('\n').map(clean).join('\n'), OUTPUT_LINES);
|
|
437
|
+
for (const l of folded.lines)
|
|
438
|
+
out.push(' ' + paint('dim', truncate(l, width - 4)));
|
|
439
|
+
if (folded.hidden > 0)
|
|
440
|
+
out.push(' ' + paint('dim', `… +${folded.hidden} lines`));
|
|
441
|
+
}
|
|
442
|
+
return out.join('\n') + '\n';
|
|
443
|
+
}
|
|
444
|
+
/** Truncate a string that may hold ANSI codes, measuring visible characters only. */
|
|
445
|
+
function truncatePlain(s, max) {
|
|
446
|
+
// eslint-disable-next-line no-control-regex
|
|
447
|
+
const visible = s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
448
|
+
return visible.length <= max ? s : truncate(visible, max);
|
|
449
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import stringWidth from 'string-width';
|
|
2
|
+
import { getGlyphs } from './glyphs.js';
|
|
3
|
+
import { paint } from './theme.js';
|
|
4
|
+
import { sanitizeControlChars } from './sanitize.js';
|
|
5
|
+
import { truncateToWidth, uiWidth } from './ui-width.js';
|
|
6
|
+
let emitter;
|
|
7
|
+
export function setTraceEmitter(e) {
|
|
8
|
+
emitter = e;
|
|
9
|
+
}
|
|
10
|
+
/** One line, no terminal control: tabs/newlines flattened, other controls → space. */
|
|
11
|
+
function flat(s) {
|
|
12
|
+
return sanitizeControlChars(s.replace(/\t/g, ' ').replace(/\r?\n/g, ' '));
|
|
13
|
+
}
|
|
14
|
+
/** `<glyph> <label> → <value>`: glyph/label/arrow dim, value plain, the whole line ≤ width. */
|
|
15
|
+
function traceLine(glyph, label, value, width) {
|
|
16
|
+
const head = `${glyph} `;
|
|
17
|
+
const sep = ` ${getGlyphs().arrow} `;
|
|
18
|
+
const l = flat(label);
|
|
19
|
+
const v = flat(value);
|
|
20
|
+
const avail = Math.max(0, width - stringWidth(head) - stringWidth(sep));
|
|
21
|
+
const lw = stringWidth(l);
|
|
22
|
+
const vw = stringWidth(v);
|
|
23
|
+
let lt = l;
|
|
24
|
+
let vt = v;
|
|
25
|
+
if (lw + vw > avail) {
|
|
26
|
+
// The answer keeps at least half the room (it is what the reader scans
|
|
27
|
+
// for); a short label hands the rest to the answer.
|
|
28
|
+
const vBudget = Math.min(vw, Math.max(Math.ceil(avail / 2), avail - lw));
|
|
29
|
+
vt = truncateToWidth(v, vBudget);
|
|
30
|
+
lt = truncateToWidth(l, Math.max(0, avail - vBudget));
|
|
31
|
+
}
|
|
32
|
+
return paint('dim', head + lt + sep) + vt;
|
|
33
|
+
}
|
|
34
|
+
export function formatAnswerTrace(label, answer, width = uiWidth()) {
|
|
35
|
+
return traceLine(getGlyphs().trace, label, answer, width);
|
|
36
|
+
}
|
|
37
|
+
export function formatDecisionTrace(label, decision, width = uiWidth()) {
|
|
38
|
+
return traceLine(getGlyphs().info, label, decision, width);
|
|
39
|
+
}
|
|
40
|
+
export function emitTrace(line) {
|
|
41
|
+
emitter?.emit('chunk', `${line}\n`);
|
|
42
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { getLook } from './look.js';
|
|
2
|
+
// eslint-disable-next-line no-control-regex
|
|
3
|
+
const ESC_SEQ = /^\x1b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\x07]*\x07)/;
|
|
4
|
+
export class TranscriptFlow {
|
|
5
|
+
/** Newlines at the end of the output: 0 = mid-line, 1 = line start, 2 = after a blank line. */
|
|
6
|
+
nl = 1;
|
|
7
|
+
/** Spaces at a line start, held until the line proves not to be blank. */
|
|
8
|
+
held = '';
|
|
9
|
+
/** Normalise one chunk: runs of blank lines collapse to one; blank lines of spaces count as blank. */
|
|
10
|
+
feed(s) {
|
|
11
|
+
let out = '';
|
|
12
|
+
let i = 0;
|
|
13
|
+
while (i < s.length) {
|
|
14
|
+
const ch = s[i];
|
|
15
|
+
if (ch === '\x1b') {
|
|
16
|
+
const m = ESC_SEQ.exec(s.slice(i));
|
|
17
|
+
if (m) {
|
|
18
|
+
out += m[0];
|
|
19
|
+
i += m[0].length;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (ch === '\n') {
|
|
24
|
+
this.held = '';
|
|
25
|
+
if (this.nl < 2) {
|
|
26
|
+
out += '\n';
|
|
27
|
+
this.nl++;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
else if ((ch === ' ' || ch === '\t') && this.nl > 0) {
|
|
31
|
+
this.held += ch;
|
|
32
|
+
}
|
|
33
|
+
else if (ch === '\r') {
|
|
34
|
+
out += ch;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
out += this.held + ch;
|
|
38
|
+
this.held = '';
|
|
39
|
+
this.nl = 0;
|
|
40
|
+
}
|
|
41
|
+
i++;
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* "\n" when the output is mid-line, else "" — start the next row on a fresh
|
|
47
|
+
* line without adding a blank one. A new block row starts at column 0, so
|
|
48
|
+
* spaces held at the line start (never written) are dropped here; they
|
|
49
|
+
* would otherwise indent the row (fix round 1).
|
|
50
|
+
*/
|
|
51
|
+
lineBreakIfNeeded() {
|
|
52
|
+
this.held = '';
|
|
53
|
+
return this.nl === 0 ? '\n' : '';
|
|
54
|
+
}
|
|
55
|
+
/** Forget the history (the next output starts on a fresh line). */
|
|
56
|
+
reset() {
|
|
57
|
+
this.nl = 1;
|
|
58
|
+
this.held = '';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const flows = new WeakMap();
|
|
62
|
+
/** Ink Body: the flow for this emitter (created on first use). */
|
|
63
|
+
export function attachFlow(emitter) {
|
|
64
|
+
let f = flows.get(emitter);
|
|
65
|
+
if (!f) {
|
|
66
|
+
f = new TranscriptFlow();
|
|
67
|
+
flows.set(emitter, f);
|
|
68
|
+
}
|
|
69
|
+
return f;
|
|
70
|
+
}
|
|
71
|
+
/** The flow a Body attached to this emitter, if any (raw emitters have none). */
|
|
72
|
+
export function flowFor(emitter) {
|
|
73
|
+
return flows.get(emitter);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* True when a spinner can redraw in place: classic stdout on a real terminal.
|
|
77
|
+
* Ink (an emitter) owns its frame; pipes, CI and dumb terminals cannot redraw.
|
|
78
|
+
* Polish P2: in the panels look the heartbeat stays silent when a live
|
|
79
|
+
* indicator exists — the classic spinner, or Ink's TurnStatus row.
|
|
80
|
+
*/
|
|
81
|
+
export function canAnimateInPlace(chunkEmitter) {
|
|
82
|
+
return !(chunkEmitter !== undefined ||
|
|
83
|
+
!process.stdout.isTTY ||
|
|
84
|
+
process.env['CI'] === '1' ||
|
|
85
|
+
process.env['TERM'] === 'dumb');
|
|
86
|
+
}
|
|
87
|
+
/** Classic / one-shot stdout flow. */
|
|
88
|
+
export const stdoutFlow = new TranscriptFlow();
|
|
89
|
+
/** Transcript write to stdout: panels normalises spacing; legacy writes verbatim. */
|
|
90
|
+
export function writeStdout(s) {
|
|
91
|
+
process.stdout.write(getLook() === 'panels' ? stdoutFlow.feed(s) : s);
|
|
92
|
+
}
|
|
93
|
+
// eslint-disable-next-line no-control-regex
|
|
94
|
+
const LEADING_NL = /^((?:\x1b\[[0-9;]*m)*)\n/;
|
|
95
|
+
// eslint-disable-next-line no-control-regex
|
|
96
|
+
const TRAILING_NL = /\n((?:\x1b\[[0-9;]*m)*)$/;
|
|
97
|
+
// eslint-disable-next-line no-control-regex
|
|
98
|
+
const ONLY_BREAKS = /^(?:\x1b\[[0-9;]*m|\n)*$/;
|
|
99
|
+
/**
|
|
100
|
+
* Ink commits every batch as a <Static> node, and each node starts on its own
|
|
101
|
+
* line. So a node ending in "\n" rendered one extra blank line, and a node of
|
|
102
|
+
* only "\n" rendered two. The committer turns a byte stream into nodes that
|
|
103
|
+
* render exactly like the same bytes on a terminal:
|
|
104
|
+
* - the one newline that ends a batch is dropped (the node boundary is it);
|
|
105
|
+
* - a batch that is only line breaks is carried into the next batch;
|
|
106
|
+
* - after a batch that ended mid-line, the next batch's first newline is
|
|
107
|
+
* dropped (Ink already started a new line).
|
|
108
|
+
*/
|
|
109
|
+
export class InkNodeCommitter {
|
|
110
|
+
carry = '';
|
|
111
|
+
midLine = false;
|
|
112
|
+
/** The node text for this batch, or null when it is carried forward. */
|
|
113
|
+
commit(batch) {
|
|
114
|
+
let s = this.carry + batch;
|
|
115
|
+
this.carry = '';
|
|
116
|
+
if (this.midLine) {
|
|
117
|
+
s = s.replace(LEADING_NL, '$1');
|
|
118
|
+
this.midLine = false;
|
|
119
|
+
}
|
|
120
|
+
if (ONLY_BREAKS.test(s)) {
|
|
121
|
+
this.carry = s;
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
if (TRAILING_NL.test(s)) {
|
|
125
|
+
s = s.replace(TRAILING_NL, '$1');
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
this.midLine = true;
|
|
129
|
+
}
|
|
130
|
+
return s;
|
|
131
|
+
}
|
|
132
|
+
}
|
package/dist/renderer/tty.js
CHANGED
|
@@ -13,9 +13,42 @@ export function isTTY() {
|
|
|
13
13
|
* e.g. `cspeach "/radar ZTTT_GAME"` vs interactive REPL.
|
|
14
14
|
*/
|
|
15
15
|
export function isOneShot() {
|
|
16
|
+
if (oneShotOverride !== null)
|
|
17
|
+
return oneShotOverride;
|
|
16
18
|
const args = process.argv.slice(2);
|
|
17
|
-
|
|
19
|
+
if (args.some((a) => a === '--one-shot'))
|
|
20
|
+
return true;
|
|
21
|
+
// The token that follows a value-taking flag (`--resume <id>`,
|
|
22
|
+
// `--sap-alias <name>`) is that flag's value, not a prompt. Counting it made
|
|
23
|
+
// `cspeach --resume <id>` fall back to the classic look (live fix 2026-09-26).
|
|
24
|
+
for (let i = 0; i < args.length; i++) {
|
|
25
|
+
const a = args[i];
|
|
26
|
+
if (VALUE_FLAGS.has(a)) {
|
|
27
|
+
i++;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (!a.startsWith('-'))
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
18
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* The entry point knows whether this run is one-shot — runRepl sets false,
|
|
37
|
+
* runOneShot sets true — so an argv token the heuristic below misreads (an
|
|
38
|
+
* unknown flag's value, say) can never drop an interactive REPL to the
|
|
39
|
+
* classic renderer. null (tests, pre-entry calls) falls back to the argv scan.
|
|
40
|
+
*/
|
|
41
|
+
let oneShotOverride = null;
|
|
42
|
+
/** Set (or clear with null) the explicit one-shot decision. */
|
|
43
|
+
export function setOneShotOverride(v) {
|
|
44
|
+
oneShotOverride = v;
|
|
45
|
+
}
|
|
46
|
+
/** True only when the one-shot entry point declared this run one-shot. */
|
|
47
|
+
export function isDeclaredOneShot() {
|
|
48
|
+
return oneShotOverride === true;
|
|
49
|
+
}
|
|
50
|
+
/** CLI flags (see cli.ts) whose next token is the flag's value. */
|
|
51
|
+
const VALUE_FLAGS = new Set(['--resume', '--sap-alias']);
|
|
19
52
|
let renderingOverride = 'classic';
|
|
20
53
|
/** Set the rendering mode override. Call once at startup after loadConfig(). */
|
|
21
54
|
export function setRenderingOverride(mode) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// src/renderer/ui-width.ts
|
|
2
|
+
//
|
|
3
|
+
// One width rule for live and new surfaces (CLI look piece 2, spec §1.4):
|
|
4
|
+
// min(terminal columns, 100), never below 20. Non-UI caps (SLUG_MAX,
|
|
5
|
+
// DETAIL_MAX …) are not width rules and stay where they are.
|
|
6
|
+
import stringWidth from 'string-width';
|
|
7
|
+
import { getGlyphs } from './glyphs.js';
|
|
8
|
+
export const UI_MAX_WIDTH = 100;
|
|
9
|
+
export const UI_MIN_WIDTH = 20;
|
|
10
|
+
export function uiWidth(columns) {
|
|
11
|
+
const cols = columns ?? process.stdout.columns ?? 80;
|
|
12
|
+
return Math.min(UI_MAX_WIDTH, Math.max(UI_MIN_WIDTH, cols));
|
|
13
|
+
}
|
|
14
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
15
|
+
/**
|
|
16
|
+
* Cut PLAIN text (no ANSI; paint after truncating) to `width` terminal cells.
|
|
17
|
+
* Tabs become two spaces and newlines one space, so a row never wraps or
|
|
18
|
+
* collapses inside the live frame. Wide characters count two cells and are
|
|
19
|
+
* never split. When cut, the result ends with the `more` glyph (… / ...).
|
|
20
|
+
*/
|
|
21
|
+
export function truncateToWidth(text, width) {
|
|
22
|
+
if (width <= 0)
|
|
23
|
+
return '';
|
|
24
|
+
const flat = text.replace(/\t/g, ' ').replace(/\r?\n/g, ' ');
|
|
25
|
+
if (stringWidth(flat) <= width)
|
|
26
|
+
return flat;
|
|
27
|
+
const more = getGlyphs().more;
|
|
28
|
+
const moreWidth = stringWidth(more);
|
|
29
|
+
if (width <= moreWidth)
|
|
30
|
+
return more.slice(0, width);
|
|
31
|
+
const budget = width - moreWidth;
|
|
32
|
+
let out = '';
|
|
33
|
+
let used = 0;
|
|
34
|
+
for (const { segment } of segmenter.segment(flat)) {
|
|
35
|
+
const w = stringWidth(segment);
|
|
36
|
+
if (used + w > budget)
|
|
37
|
+
break;
|
|
38
|
+
out += segment;
|
|
39
|
+
used += w;
|
|
40
|
+
}
|
|
41
|
+
return out + more;
|
|
42
|
+
}
|
|
@@ -18,8 +18,10 @@
|
|
|
18
18
|
* activation is blocked, so we don't show a fake pending step
|
|
19
19
|
* - syntax 'not_checked' → "③ syntax – (checked at activation)" —
|
|
20
20
|
* sap_set_class_include has no standalone syntax gate
|
|
21
|
-
* - ④ is always 'pending'
|
|
22
|
-
*
|
|
21
|
+
* - legacy line: ④ is always 'pending'. The panels change card (round 8
|
|
22
|
+
* item 5 + final review A-I1) shows the write's own activation instead:
|
|
23
|
+
* '✓ activated' only after the inactive-objects list confirms it,
|
|
24
|
+
* otherwise '✗ still inactive' / '· activation unverified' / '✗ activation'.
|
|
23
25
|
*
|
|
24
26
|
* Consumed by the write tools in tools/sap-write.ts, emitted on the
|
|
25
27
|
* 'chunk' channel so it lands between the ⏺ dispatch line and the ⎿
|