@cspeach/cli 1.1.18 → 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.
Files changed (224) hide show
  1. package/README.md +3 -4
  2. package/dist/agent/loop.js +143 -25
  3. package/dist/agent/providers/local-provider.js +4 -1
  4. package/dist/agent/repair-partial.js +76 -19
  5. package/dist/agent/tool-dispatch.js +58 -21
  6. package/dist/agent/turn-error-ux.js +77 -26
  7. package/dist/agent/turn-stream.js +25 -3
  8. package/dist/approvals/adt-type.js +27 -0
  9. package/dist/approvals/advisory-prompt.js +48 -0
  10. package/dist/approvals/advisory-render.js +25 -11
  11. package/dist/approvals/approval-prompt.js +74 -24
  12. package/dist/approvals/jwt.js +2 -1
  13. package/dist/approvals/render.js +64 -36
  14. package/dist/approvals/risk-floor.js +53 -2
  15. package/dist/auth/api-key.js +30 -16
  16. package/dist/auth/keychain.js +0 -0
  17. package/dist/auth/me.js +25 -7
  18. package/dist/cli.js +25 -6
  19. package/dist/commands/compact.js +12 -9
  20. package/dist/commands/config-set.js +6 -2
  21. package/dist/commands/config-show.js +7 -5
  22. package/dist/commands/cost.js +41 -34
  23. package/dist/commands/help.js +88 -50
  24. package/dist/commands/login.js +28 -19
  25. package/dist/commands/logout.js +7 -3
  26. package/dist/commands/plan-chain.js +71 -77
  27. package/dist/commands/plan-continue.js +1 -0
  28. package/dist/commands/plan-resume.js +20 -12
  29. package/dist/commands/whoami.js +23 -9
  30. package/dist/config/loader.js +15 -3
  31. package/dist/cost/pricing.js +6 -5
  32. package/dist/doctor/checks/forge-rules.js +13 -3
  33. package/dist/doctor/checks/keychain.js +6 -4
  34. package/dist/doctor/run.js +101 -20
  35. package/dist/index.js +5 -2
  36. package/dist/lib/graceful-exit.js +56 -0
  37. package/dist/lib/piped-prompt.js +65 -0
  38. package/dist/lock-contention.js +2 -2
  39. package/dist/one-shot.js +135 -40
  40. package/dist/projects/answer-blockers.js +6 -1
  41. package/dist/projects/handover-md.js +15 -14
  42. package/dist/projects/plan-run.js +97 -54
  43. package/dist/projects/promote-command.js +11 -2
  44. package/dist/projects/save-command.js +3 -2
  45. package/dist/projects/status.js +2 -1
  46. package/dist/projects/yes-no.js +12 -0
  47. package/dist/renderer/abap-inline.js +18 -14
  48. package/dist/renderer/answer-trim.js +69 -0
  49. package/dist/renderer/banners.js +8 -8
  50. package/dist/renderer/brand-settled.js +19 -0
  51. package/dist/renderer/change-card.js +156 -0
  52. package/dist/renderer/checklist-format.js +130 -0
  53. package/dist/renderer/color-mode.js +45 -0
  54. package/dist/renderer/error-detail.js +104 -0
  55. package/dist/renderer/fence-state.js +46 -0
  56. package/dist/renderer/footer-line.js +96 -0
  57. package/dist/renderer/glyphs.js +25 -0
  58. package/dist/renderer/goodbye.js +38 -0
  59. package/dist/renderer/legacy-palette.js +41 -0
  60. package/dist/renderer/look.js +30 -0
  61. package/dist/renderer/markdown.js +299 -92
  62. package/dist/renderer/notice-log.js +72 -0
  63. package/dist/renderer/notice-shape.js +48 -0
  64. package/dist/renderer/notices.js +40 -8
  65. package/dist/renderer/panel-rows.js +16 -0
  66. package/dist/renderer/pipeline.js +66 -0
  67. package/dist/renderer/progress-chatter.js +23 -17
  68. package/dist/renderer/rendering-mode.js +34 -0
  69. package/dist/renderer/routing-line.js +14 -0
  70. package/dist/renderer/sanitize.js +12 -0
  71. package/dist/renderer/severity.js +2 -7
  72. package/dist/renderer/startup-lines.js +152 -0
  73. package/dist/renderer/status-footer.js +52 -34
  74. package/dist/renderer/steering-echo.js +41 -0
  75. package/dist/renderer/syntax.js +31 -12
  76. package/dist/renderer/tables.js +5 -1
  77. package/dist/renderer/theme.js +44 -0
  78. package/dist/renderer/thinking-heartbeat.js +33 -2
  79. package/dist/renderer/todo-block.js +10 -49
  80. package/dist/renderer/tool-labels.js +238 -0
  81. package/dist/renderer/tool-widget.js +245 -51
  82. package/dist/renderer/trace.js +42 -0
  83. package/dist/renderer/transcript-flow.js +132 -0
  84. package/dist/renderer/tty.js +34 -1
  85. package/dist/renderer/ui-width.js +42 -0
  86. package/dist/renderer/verify-chain.js +4 -2
  87. package/dist/renderer/widget-fallback.js +58 -65
  88. package/dist/repl/bracketed-paste.js +7 -1
  89. package/dist/repl/builtin-commands.js +19 -8
  90. package/dist/repl/credential-handover-gate.js +28 -0
  91. package/dist/repl/early-line-buffer.js +5 -2
  92. package/dist/repl/file-picker.js +54 -12
  93. package/dist/repl/ink-stdin-guard.js +66 -0
  94. package/dist/repl/inquirer-guard.js +59 -16
  95. package/dist/repl/inquirer-theme.js +27 -33
  96. package/dist/repl/paste-marker.js +19 -0
  97. package/dist/repl/plan-turn-end.js +20 -0
  98. package/dist/repl/post-turn-status.js +36 -11
  99. package/dist/repl/reroute-turn.js +21 -0
  100. package/dist/repl/reset-tty-stdin.js +44 -0
  101. package/dist/repl/restore-guard.js +22 -0
  102. package/dist/repl/resume-standalone.js +63 -0
  103. package/dist/repl/rule8-detector.js +11 -3
  104. package/dist/repl/safety-confirm.js +161 -93
  105. package/dist/repl/session-spend-line.js +8 -4
  106. package/dist/repl/themed-prompts.js +19 -0
  107. package/dist/repl/ui-look-command.js +75 -0
  108. package/dist/repl/update-method-preview-hook.js +48 -5
  109. package/dist/repl.js +795 -332
  110. package/dist/rewind/cli.js +3 -2
  111. package/dist/rewind/format.js +14 -9
  112. package/dist/rewind/restore.js +11 -0
  113. package/dist/router/classifier.js +47 -4
  114. package/dist/router/intent-extractor.js +24 -8
  115. package/dist/router/piped-routing.js +23 -0
  116. package/dist/router/resume-routing.js +16 -0
  117. package/dist/router/routing-failure.js +57 -0
  118. package/dist/sap/first-run-choice.js +1 -1
  119. package/dist/sap/onboarding.js +3 -2
  120. package/dist/sap/standalone-onboarding.js +1 -1
  121. package/dist/sap/system-info.js +4 -2
  122. package/dist/sap/unreachable.js +28 -0
  123. package/dist/sap-errors/clean-error-text.js +182 -0
  124. package/dist/session/interrupt-reason.js +39 -0
  125. package/dist/session/recap.js +36 -24
  126. package/dist/session/resume.js +104 -36
  127. package/dist/session/store.js +69 -27
  128. package/dist/session/user-prompt.js +21 -0
  129. package/dist/skill-catalog.js +7 -0
  130. package/dist/skills/bundled-skills.js +76 -69
  131. package/dist/standards/standards-init.js +1 -1
  132. package/dist/test-helpers/answer-any.js +46 -0
  133. package/dist/tools/approval.js +27 -11
  134. package/dist/tools/ask-question.js +47 -5
  135. package/dist/tools/filesystem/file-write.js +19 -2
  136. package/dist/tools/fiori/fe-extend.js +16 -1
  137. package/dist/tools/fiori/fe-scaffold.js +105 -3
  138. package/dist/tools/fiori/html-escapes.js +28 -0
  139. package/dist/tools/fiori/metadata/audit.js +605 -0
  140. package/dist/tools/fiori/metadata/references.js +104 -0
  141. package/dist/tools/fiori/metadata/types.js +8 -0
  142. package/dist/tools/fiori/preview/app-guard.js +215 -0
  143. package/dist/tools/fiori/preview/env.js +193 -0
  144. package/dist/tools/fiori/preview/readiness.js +127 -0
  145. package/dist/tools/fiori/preview/registry.js +412 -0
  146. package/dist/tools/fiori/preview/start.js +469 -0
  147. package/dist/tools/fiori/samples/loader.js +20 -5
  148. package/dist/tools/fiori/scaffold.js +18 -6
  149. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  150. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  151. package/dist/tools/fiori/smoke/assertions.js +161 -24
  152. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  153. package/dist/tools/fiori/smoke/batch.js +273 -0
  154. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  155. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  156. package/dist/tools/fiori/smoke/driver.js +171 -4
  157. package/dist/tools/fiori/smoke/evidence.js +35 -0
  158. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  159. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  160. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  161. package/dist/tools/fiori/tools.js +651 -7
  162. package/dist/tools/fiori/ui5-version.js +16 -0
  163. package/dist/tools/index.js +8 -0
  164. package/dist/tools/local-build.js +6 -0
  165. package/dist/tools/sap-read.js +16 -1
  166. package/dist/tools/sap-write.js +122 -10
  167. package/dist/tools/shell/shell_exec.js +30 -12
  168. package/dist/tools/syntax-state.js +67 -0
  169. package/dist/tools/todo.js +23 -18
  170. package/dist/tools/transport-fit.js +462 -0
  171. package/dist/tools/transport-resolution.js +3 -1
  172. package/dist/tools/transport.js +33 -7
  173. package/dist/tools/verify.js +184 -8
  174. package/dist/ui/app.js +284 -146
  175. package/dist/ui/approval-modal.js +41 -15
  176. package/dist/ui/ask-answer-text.js +17 -0
  177. package/dist/ui/body.js +25 -6
  178. package/dist/ui/card-slot.js +98 -0
  179. package/dist/ui/checklist.js +9 -0
  180. package/dist/ui/coaching-picker-classic.js +8 -5
  181. package/dist/ui/command-palette.js +6 -4
  182. package/dist/ui/confirm-request.js +18 -0
  183. package/dist/ui/context-grid.js +2 -1
  184. package/dist/ui/file-palette.js +6 -4
  185. package/dist/ui/files-card-emitter.js +19 -0
  186. package/dist/ui/footer-line.js +16 -0
  187. package/dist/ui/footer.js +105 -104
  188. package/dist/ui/input-wrap.js +122 -0
  189. package/dist/ui/interrupt.js +103 -0
  190. package/dist/ui/key-burst.js +117 -0
  191. package/dist/ui/line-resolution.js +4 -3
  192. package/dist/ui/live-area.js +71 -0
  193. package/dist/ui/login-banner.js +54 -43
  194. package/dist/ui/rewind-panel.js +76 -24
  195. package/dist/ui/role-props.js +47 -0
  196. package/dist/ui/sap-state-store.js +1 -0
  197. package/dist/ui/session-timeline.js +49 -3
  198. package/dist/ui/skill-picker.js +5 -47
  199. package/dist/ui/text-input.js +128 -24
  200. package/dist/ui/todo-emitter.js +19 -0
  201. package/dist/ui/turn-status-emitter.js +28 -2
  202. package/dist/ui/turn-status.js +16 -19
  203. package/dist/ui/use-card-request.js +44 -0
  204. package/dist/ui/widgets/ask-card.js +521 -0
  205. package/dist/ui/widgets/bar-chart.js +11 -5
  206. package/dist/ui/widgets/coaching-picker.js +50 -55
  207. package/dist/ui/widgets/confirm-card.js +193 -0
  208. package/dist/ui/widgets/dep-graph.js +3 -2
  209. package/dist/ui/widgets/diff-viewer.js +6 -5
  210. package/dist/ui/widgets/files-card.js +97 -0
  211. package/dist/ui/widgets/question-card.js +2 -1
  212. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  213. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  214. package/dist/ui/widgets/stack-frames.js +2 -1
  215. package/dist/upgrade-check.js +39 -8
  216. package/package.json +11 -2
  217. package/test-harness/fe-draft/app.ts +188 -0
  218. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  219. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  220. package/test-harness/fe-draft/routes.ts +224 -0
  221. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  222. package/test-harness/fe-draft/server.ts +225 -0
  223. package/test-harness/fe-draft/variants.ts +196 -0
  224. package/vitest.smoke-e2e.config.ts +20 -0
@@ -1,5 +1,12 @@
1
1
  import chalk from 'chalk';
2
- import { PEACH } from './banners.js';
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
- * Module-level reference to the currently-running spinner's stop function.
70
- * Set by startToolSpinner; cleared on stop. Lets unrelated subsystems
71
- * (e.g. inquirer-guard, when an inquirer prompt is about to open) silence
72
- * the spinner so it doesn't fight inquirer for stdout/cursor.
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
- let activeSpinnerStop = null;
75
- export function clearActiveSpinner() {
76
- if (activeSpinnerStop) {
77
- activeSpinnerStop();
78
- activeSpinnerStop = null;
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
- process.stdout.write(s);
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
- function startSpinnerWithVerbs(params, verbs, defaultVerb) {
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 !== undefined ||
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
- const paint = () => {
127
- if (stopped)
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. First call appends a fresh line; later
132
- // calls cursor-up to the spinner row and overwrite in place.
133
- const line = ` ${peach(frame)} ${chalk.dim(verb + '…')}`;
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 terminal cursor while the spinner animates so it doesn't blink
137
- // mid-frame and confuse the user about whether their typing went through.
138
- process.stdout.write('\x1B[?25l');
139
- // Print the initial spinner line.
140
- process.stdout.write('\r');
141
- paint();
142
- const interval = setInterval(paint, SPINNER_TICK_MS);
143
- interval.unref?.();
144
- const stopFn = () => {
145
- if (stopped)
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
- stopped = true;
148
- clearInterval(interval);
149
- // Erase the spinner line so the result row paints in its place.
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
- // Clear the global handle if it's still pointing at us. (If a newer
155
- // spinner already replaced us, leave its registration alone.)
156
- if (activeSpinnerStop === stopFn)
157
- activeSpinnerStop = null;
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 (activeSpinnerStop)
163
- activeSpinnerStop();
164
- activeSpinnerStop = stopFn;
165
- return { stop: stopFn };
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
- process.stdout.write(s);
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
+ }
@@ -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
- return args.some((a) => a === '--one-shot') || args.some((a) => !a.startsWith('-'));
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', never '✓': activation is a SEPARATE tool call
22
- * (sap_activate); these write tools never activate.
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 ⎿