@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
@@ -28,21 +28,50 @@
28
28
  * their ctx.chunkEmitter stays undefined so child-turn notices remain
29
29
  * muted — exactly the pre-existing contract pinned in agent_run.test.ts.
30
30
  */
31
- import chalk from 'chalk';
31
+ import { writeStdout } from './transcript-flow.js';
32
+ import { writeAboveSpinner } from './tool-widget.js';
33
+ import { formatAttention, formatInfo } from './notice-shape.js';
34
+ import { cleanErrorText, looksLikeMarkup } from '../sap-errors/clean-error-text.js';
35
+ import stringWidth from 'string-width';
36
+ import { getLook } from './look.js';
37
+ import { getGlyphs } from './glyphs.js';
38
+ import { truncateToWidth, uiWidth } from './ui-width.js';
39
+ import { getNoticeLogPath, recordFullNotice } from './notice-log.js';
40
+ import { getEffectiveBodyWidth, isDeclaredOneShot } from './tty.js';
32
41
  import { EventEmitter } from 'node:events';
33
42
  /**
34
43
  * Format one notice as a single terminal line (trailing newline included).
35
44
  * Multi-line messages are collapsed to one line — notices are glanceable
36
45
  * margin notes, not documents; anything long belongs in the tool result.
37
46
  *
38
- * warn → yellow " ⚠ message"
39
- * info → dim " · message"
47
+ * Piece 2 §9: warn → the attention shape ("! message"), info → the info
48
+ * shape ("· message", dim). Same glyphs as every other notice.
40
49
  */
41
50
  export function formatNoticeLine(level, message) {
42
- const oneLine = message.replace(/\s*\r?\n\s*/g, ' ').trim();
43
- if (level === 'warn')
44
- return chalk.yellow(` ⚠ ${oneLine}`) + '\n';
45
- return chalk.dim(` · ${oneLine}`) + '\n';
51
+ // Live fix 2026-09-26: a SAP HTML error page must never leak tags/CSS here.
52
+ const readable = looksLikeMarkup(message) ? cleanErrorText(message, 400) : message;
53
+ let oneLine = readable.replace(/\s*\r?\n\s*/g, ' ').trim();
54
+ // Live fix 2026-09-26 (panels): never wrap mid-word — cut to one screen
55
+ // line ("<glyph> " + text ≤ uiWidth) and keep the full text in the
56
+ // session's notice log. Legacy stays byte-identical.
57
+ // Review I3: only when a screen is there to wrap (stdout is a TTY) or the
58
+ // full text has somewhere to go (a notice log). One-shot / piped output
59
+ // keeps the whole single line — nothing else would record it.
60
+ // Re-review R3: a one-shot on a terminal has no notice log either, so it
61
+ // keeps the whole line too.
62
+ const wrapsOnScreen = process.stdout.isTTY === true && !isDeclaredOneShot();
63
+ if (getLook() === 'panels' && (wrapsOnScreen || getNoticeLogPath() !== undefined)) {
64
+ const g = getGlyphs();
65
+ const room = Math.min(uiWidth(), getEffectiveBodyWidth()) - stringWidth(level === 'warn' ? g.attention : g.info) - 1;
66
+ const cut = truncateToWidth(oneLine, room);
67
+ // Review M10: truncateToWidth turns tabs into two spaces — compare against
68
+ // the same normalisation so only a real cut is logged.
69
+ if (cut !== oneLine.replace(/\t/g, ' ')) {
70
+ recordFullNotice(level, message);
71
+ oneLine = cut;
72
+ }
73
+ }
74
+ return level === 'warn' ? formatAttention({ text: oneLine }) : formatInfo(oneLine);
46
75
  }
47
76
  /**
48
77
  * Build the output emitter for the NON-Ink paths (classic REPL, one-shot).
@@ -55,7 +84,10 @@ export function formatNoticeLine(level, message) {
55
84
  *
56
85
  * The `write` sink defaults to process.stdout — injectable for tests.
57
86
  */
58
- export function createClassicOutputEmitter(write = (s) => process.stdout.write(s)) {
87
+ export function createClassicOutputEmitter(
88
+ // Panels: through the stdout transcript flow (spacing); legacy: verbatim.
89
+ // Above the classic tool spinner when one is painting (live fix 2026-09-26).
90
+ write = (s) => writeAboveSpinner(s, writeStdout)) {
59
91
  const emitter = new EventEmitter();
60
92
  emitter.on('chunk', (s) => write(s));
61
93
  emitter.on('warn', (msg) => write(formatNoticeLine('warn', msg)));
@@ -0,0 +1,16 @@
1
+ // src/renderer/panel-rows.ts
2
+ //
3
+ // Piece 2 §10: every panel-like surface (whoami, /cost, /compact, /ui, /recap,
4
+ // goodbye, help) uses ONE heading style (bold) and dim label / plain value rows.
5
+ // Callers sanitize server- or model-supplied values before passing them in.
6
+ import chalk from 'chalk';
7
+ import { paint } from './theme.js';
8
+ export function panelHeading(title, sub) {
9
+ return chalk.bold(title) + (sub ? ' ' + paint('dim', sub) : '');
10
+ }
11
+ export function panelRow(label, value, labelWidth = 12) {
12
+ return ' ' + paint('dim', label.padEnd(labelWidth)) + ' ' + value;
13
+ }
14
+ export function formatPanel(title, rows, sub) {
15
+ return [panelHeading(title, sub), ...rows];
16
+ }
@@ -15,6 +15,13 @@ import { WIDGET_CONTROL } from '../ui/widget-control.js'; // NEW IN v0.3
15
15
  // kept commented-out as a breadcrumb; the file itself is retained in case
16
16
  // any other skill still emits a widget-real block we want to catch.
17
17
  import { renderQuestionCard } from './widget-fallback.js'; // NEW IN v0.3.1
18
+ import { ensureGrammars } from './syntax.js'; // 2026-09-26
19
+ import { getLook } from './look.js'; // 2026-09-26 polish
20
+ import { stepFence } from './fence-state.js'; // piece 2 Z1a: one fence rule
21
+ // Register the ABAP/CDS/BDEF/DDL hljs grammars now, exactly when syntax.ts
22
+ // used to self-register (it was loaded through abap-inline.js above), so
23
+ // marked-terminal's fenced blocks in renderMarkdown stay as they were.
24
+ ensureGrammars();
18
25
  export function initialBufferState() {
19
26
  return { pending: '', fenceCount: 0, widgetBuffer: '', insideWidget: false, orderedListCounter: 0 };
20
27
  }
@@ -101,6 +108,17 @@ function renderPassthrough(chunk, state) {
101
108
  // markdown.ts / abap-inline.ts (both /^```/m). UNCHANGED FROM v0.1.1.
102
109
  const newFencesInChunk = (chunk.match(/^```/gm) || []).length;
103
110
  const projectedFenceCount = state.fenceCount + newFencesInChunk;
111
+ if (getLook() === 'panels') {
112
+ // Panels: one fence-aware scan decides the flush point (fix round 1).
113
+ // Pending text only ever starts outside a fence (we never cut inside
114
+ // one), so scanning it from the start tracks fence state over all the
115
+ // text emitted so far. The legacy chunk-local ``` count is not used: it
116
+ // misses ~~~ fences and fences split across chunks.
117
+ const cut = panelsFlushPoint(combined);
118
+ state.pending = combined.slice(cut);
119
+ state.fenceCount = projectedFenceCount;
120
+ return cut === 0 ? '' : render(combined.slice(0, cut));
121
+ }
104
122
  if (projectedFenceCount % 2 !== 0) {
105
123
  // Inside an open fence — hold everything until fence closes.
106
124
  state.pending = combined;
@@ -122,6 +140,54 @@ function renderPassthrough(chunk, state) {
122
140
  state.fenceCount = projectedFenceCount;
123
141
  return '';
124
142
  }
143
+ // Panels (P5): a block that starts a list item, and a block indented under one.
144
+ const LIST_ITEM_START_RE = /^ {0,3}(?:\d{1,9}[.)]|[-*+])[ \t]/;
145
+ const LIST_CONTINUATION_RE = /^(?: {2,}|\t)\S/;
146
+ /**
147
+ * Panels flush point in `text` (index; 0 = flush nothing).
148
+ *
149
+ * Candidate cuts are the starts of lines that follow an empty line OUTSIDE a
150
+ * fenced code block (```…``` or ~~~…~~~, closed by the same character, at
151
+ * least as long, nothing after it). Only complete lines are scanned. Of the
152
+ * candidates, the last one is used, then moved back to the start of a
153
+ * still-open list (P5): the trailing run of blocks that are list items or
154
+ * indented item bodies is held when it contains a list item. A block is the
155
+ * text between two candidates, so a "* comment" or "- " line inside a fence
156
+ * is never seen as a list.
157
+ */
158
+ function panelsFlushPoint(text) {
159
+ const cuts = [];
160
+ let fence;
161
+ let pos = 0;
162
+ for (;;) {
163
+ const nl = text.indexOf('\n', pos);
164
+ if (nl < 0)
165
+ break; // last line incomplete: not scanned
166
+ const line = text.slice(pos, nl);
167
+ const step = stepFence(fence, line); // shared with the "writing code…" label (fence-state.ts)
168
+ fence = step.fence;
169
+ if (!step.consumed && line === '' && pos > 0)
170
+ cuts.push(nl + 1);
171
+ pos = nl + 1;
172
+ }
173
+ if (cuts.length === 0)
174
+ return 0;
175
+ const last = cuts[cuts.length - 1];
176
+ // Blocks between consecutive cuts (the first starts at 0), up to `last`.
177
+ const starts = [0, ...cuts.slice(0, -1)];
178
+ let listStart;
179
+ for (let i = starts.length - 1; i >= 0; i--) {
180
+ const block = text.slice(starts[i], i + 1 < starts.length ? starts[i + 1] : last);
181
+ const firstLine = block.split('\n').find((l) => l.trim() !== '');
182
+ if (firstLine === undefined)
183
+ continue; // only blank lines
184
+ if (LIST_ITEM_START_RE.test(firstLine))
185
+ listStart = starts[i];
186
+ else if (!LIST_CONTINUATION_RE.test(firstLine))
187
+ break;
188
+ }
189
+ return listStart ?? last;
190
+ }
125
191
  // ────────────────────────────────────────────────────────────────────
126
192
  // ORDERED-LIST RENUMBER — NEW
127
193
  // Walks rendered output line-by-line and renumbers consecutive ordered
@@ -1,4 +1,7 @@
1
- import chalk from 'chalk';
1
+ import stringWidth from 'string-width';
2
+ import { paint } from './theme.js';
3
+ import { uiWidth, truncateToWidth } from './ui-width.js';
4
+ import { sanitizeControlChars } from './sanitize.js';
2
5
  /**
3
6
  * Progress chatter — keeps the tool-widget box alive while a tool runs.
4
7
  *
@@ -15,17 +18,28 @@ import chalk from 'chalk';
15
18
  const SPINNER_CHARS = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'];
16
19
  const SPINNER_TICK_MS = 80;
17
20
  const MESSAGE_ROTATE_MS = 3_000;
18
- // BOX_WIDTH must match tool-widget.ts — we could import it, but duplicating
19
- // the const here keeps progress-chatter.ts decoupled from that module's
20
- // internals and the value is set once across v0.2.
21
- const BOX_WIDTH = 64;
21
+ // Piece 2 §1.4: the chatter line is as wide as the UI (uiWidth()); the
22
+ // content area is that minus the "│ " and " │" edges.
23
+ const contentWidth = () => Math.max(10, uiWidth() - 4);
24
+ /**
25
+ * One chatter row, exactly `contentWidth() + 4` cells. The message can carry
26
+ * a tool arg ({name}), so it is neutralised and pre-cut with string-width
27
+ * BEFORE colouring: a wide or control-laden name can never widen the row or
28
+ * drive the terminal.
29
+ */
30
+ function chatterLine(spinner, message) {
31
+ const w = contentWidth();
32
+ const text = truncateToWidth(`${spinner} ${sanitizeControlChars(message)}`, w);
33
+ const padded = text + ' '.repeat(Math.max(0, w - stringWidth(text)));
34
+ return paint('frame', '│ ') + paint('sap', padded) + paint('frame', ' │');
35
+ }
22
36
  /**
23
37
  * Per-tool message pools. Messages may include `{name}` / `{type}` placeholders
24
38
  * — substituted from the tool's args at render time. If the arg is absent,
25
39
  * the placeholder is dropped together with any surrounding whitespace.
26
40
  *
27
- * Keep messages short (≤ ~50 chars) so they fit inside the widget's
28
- * BOX_WIDTH content area without truncation.
41
+ * Keep messages short (≤ ~50 chars) so they fit inside the chatter
42
+ * content area (uiWidth() − 4) without truncation.
29
43
  */
30
44
  const MESSAGES = {
31
45
  sap_search_object: [
@@ -203,20 +217,12 @@ export function startProgress(params) {
203
217
  }
204
218
  const spinner = SPINNER_CHARS[spinnerIdx];
205
219
  spinnerIdx = (spinnerIdx + 1) % SPINNER_CHARS.length;
206
- const raw = `${spinner} ${substituted[messageIdx]}`;
207
- const truncated = raw.length > BOX_WIDTH ? raw.slice(0, BOX_WIDTH - 1) + '…' : raw;
208
- const padded = truncated.padEnd(BOX_WIDTH);
209
- const line = chalk.dim('│ ') + chalk.cyan(padded) + chalk.dim(' │');
220
+ const line = chatterLine(spinner, substituted[messageIdx]);
210
221
  // Cursor-up 1, erase current line, rewrite. Trailing \n puts us back below.
211
222
  process.stdout.write(`\x1B[1A\x1B[2K${line}\n`);
212
223
  };
213
224
  // Paint the initial chatter line (no cursor-up — it occupies a fresh line).
214
- const initialSpinner = SPINNER_CHARS[0];
215
- const initialRaw = `${initialSpinner} ${substituted[0]}`;
216
- const initialTruncated = initialRaw.length > BOX_WIDTH ? initialRaw.slice(0, BOX_WIDTH - 1) + '…' : initialRaw;
217
- const initialPadded = initialTruncated.padEnd(BOX_WIDTH);
218
- const initialLine = chalk.dim('│ ') + chalk.cyan(initialPadded) + chalk.dim(' │');
219
- process.stdout.write(initialLine + '\n');
225
+ process.stdout.write(chatterLine(SPINNER_CHARS[0], substituted[0]) + '\n');
220
226
  const interval = setInterval(render, SPINNER_TICK_MS);
221
227
  // Don't keep the event loop alive waiting for this timer.
222
228
  interval.unref?.();
@@ -0,0 +1,34 @@
1
+ import { setRenderingOverride, shouldUseInk } from './tty.js';
2
+ import { formatInfo } from './notice-shape.js';
3
+ /** The environment variable that forces the classic renderer for one launch. */
4
+ export const CLASSIC_ENV = 'CSPEACH_UI';
5
+ export const CLASSIC_RETIRING_NOTICE = 'Classic display is retiring · using the standard display';
6
+ export function resolveRenderingMode(stored, env = process.env) {
7
+ if (env[CLASSIC_ENV]?.trim().toLowerCase() === 'classic') {
8
+ return { mode: 'classic', storedClassicRetired: false };
9
+ }
10
+ if (stored === 'classic')
11
+ return { mode: 'auto', storedClassicRetired: true };
12
+ return { mode: stored, storedClassicRetired: false };
13
+ }
14
+ /**
15
+ * Interactive REPL start: set the rendering override from the config (with the
16
+ * retiring rule above). When a stored `classic` was overridden AND this launch
17
+ * really gets Ink, rewrite the stored value to `auto` (best-effort — a failed
18
+ * save just retries next launch) and return the dim notice to show. Returns
19
+ * null otherwise.
20
+ */
21
+ export async function applyStartupRendering(cfg, save) {
22
+ const r = resolveRenderingMode(cfg.ui.rendering);
23
+ setRenderingOverride(r.mode);
24
+ if (!r.storedClassicRetired || !shouldUseInk())
25
+ return null;
26
+ cfg.ui.rendering = 'auto';
27
+ try {
28
+ await save(cfg);
29
+ }
30
+ catch {
31
+ /* best-effort — the value is still read as auto; the next launch retries */
32
+ }
33
+ return formatInfo(CLASSIC_RETIRING_NOTICE);
34
+ }
@@ -0,0 +1,14 @@
1
+ // src/renderer/routing-line.ts
2
+ //
3
+ // Piece 2 §9: routing chatter is ONE dim line — "· Using /<skill>" when the
4
+ // router picked silently, "· Continuing /<skill>" for a follow-up, and
5
+ // "· Cancelled" when the picker was dismissed. No confidence percentage.
6
+ // formatInfo sanitizes the text, so a skill name cannot drive the terminal.
7
+ import { formatInfo } from './notice-shape.js';
8
+ export function formatRoutingLine(e) {
9
+ switch (e.kind) {
10
+ case 'using': return formatInfo(`Using /${e.skill}`);
11
+ case 'continuing': return formatInfo(`Continuing /${e.skill}`);
12
+ case 'cancelled': return formatInfo('Cancelled');
13
+ }
14
+ }
@@ -0,0 +1,12 @@
1
+ // src/renderer/sanitize.ts
2
+ //
3
+ // Model- or user-supplied text (questions, labels, pasted answers) must not
4
+ // drive the terminal. Every C0/C1 control character except tab and CRLF /
5
+ // LF — a lone \r, ESC, BEL, NUL, DEL, CSI (\x9b) … — becomes a space, so it
6
+ // cannot move the cursor or start an escape sequence. Tabs and newlines are
7
+ // left to the caller (truncateToWidth / trace flattening handle them).
8
+ // eslint-disable-next-line no-control-regex
9
+ const CONTROL_CHARS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]|\r(?!\n)/g;
10
+ export function sanitizeControlChars(text) {
11
+ return text.replace(CONTROL_CHARS, ' ');
12
+ }
@@ -1,11 +1,6 @@
1
1
  // C:\Users\Admin\ClaudeProjects\ABAPForge\cspeach-cli\src\renderer\severity.ts
2
- import chalk from 'chalk';
3
- const PILLS = {
4
- CRITICAL: (s) => chalk.bgRedBright.white.bold(s),
5
- HIGH: (s) => chalk.bgRed.white(s),
6
- MEDIUM: (s) => chalk.bgYellow.black(s),
7
- LOW: (s) => chalk.bgGreen.black(s),
8
- };
2
+ import { LEGACY_SEVERITY_PILLS } from './legacy-palette.js';
3
+ const PILLS = LEGACY_SEVERITY_PILLS;
9
4
  const FENCE_RE = /^```/;
10
5
  const ANSI_STRIP_RE = /\x1B\[[0-9;]*[mGKHFABCDsuJSTfrl]|\x1B\][^\x07]*\x07/g;
11
6
  // A line that is part of a rendered cli-table3 block always starts with one of
@@ -0,0 +1,152 @@
1
+ // src/renderer/startup-lines.ts
2
+ //
3
+ // Panels look (2026-09-26, piece 2 §10): exactly three lines (brand + sign-in,
4
+ // status, hint or resume), then one '!' line per thing that needs action.
5
+ // Legacy prints exactly the old lines (kept in repl.tsx). formatPromptEcho's
6
+ // legacy branch is today's string.
7
+ import chalk from 'chalk';
8
+ import stringWidth from 'string-width';
9
+ import { getLook } from './look.js';
10
+ import { paint, BRAND_HEX } from './theme.js';
11
+ import { formatAttention } from './notice-shape.js';
12
+ import { truncateToWidth, uiWidth } from './ui-width.js';
13
+ import { sanitizeControlChars } from './sanitize.js';
14
+ import { holdBrand } from './brand-settled.js';
15
+ export { brandSettled } from './brand-settled.js';
16
+ /** Folder budget on the panels status line (controller ruling R2). */
17
+ const STATUS_PATH_MAX = 20;
18
+ /**
19
+ * Longest tail of whole path segments, prefixed `…<sep>`, with total length
20
+ * ≤ max; the path unchanged if it already fits. If even the last segment is
21
+ * too long, its trailing characters are kept after `…`.
22
+ */
23
+ export function shortenPath(p, max) {
24
+ if (p.length <= max)
25
+ return p;
26
+ const sep = p.includes('\\') ? '\\' : '/';
27
+ const parts = p.split(sep).filter((s) => s.length > 0);
28
+ const last = parts.pop() ?? p;
29
+ let tail = last;
30
+ if (1 + sep.length + tail.length > max) {
31
+ return '…' + last.slice(-(Math.max(1, max - 1)));
32
+ }
33
+ while (parts.length > 0) {
34
+ const next = parts[parts.length - 1] + sep + tail;
35
+ if (1 + sep.length + next.length > max)
36
+ break;
37
+ tail = next;
38
+ parts.pop();
39
+ }
40
+ return `…${sep}${tail}`;
41
+ }
42
+ /** Line 1 of the panels start screen. `signIn` (piece 2 §10) folds the account
43
+ * into the brand line, e.g. "signed in as a@b.com · team". */
44
+ export function panelsBrandLine(version, signIn) {
45
+ return panelsBrandBase(version) + panelsBrandSuffix(version, signIn);
46
+ }
47
+ function panelsBrandBase(version) {
48
+ return `🍑 ${chalk.hex(BRAND_HEX).bold('CSPeach')} ${paint('dim', 'v' + version)}`;
49
+ }
50
+ /** The part of line 1 after "🍑 CSPeach vX" — empty when there is no sign-in. */
51
+ function panelsBrandSuffix(version, signIn) {
52
+ if (!signIn)
53
+ return '';
54
+ // Line 1 never wraps: the fragment gets what is left of the UI width.
55
+ const used = stringWidth(`🍑 CSPeach v${version} · `);
56
+ return paint('dim', ` · ${truncateToWidth(signIn, Math.max(1, uiWidth() - used))}`);
57
+ }
58
+ const processSignals = {
59
+ once: (ev, h) => { process.once(ev, h); },
60
+ removeListener: (ev, h) => { process.removeListener(ev, h); },
61
+ exit: (code) => { process.exit(code); },
62
+ };
63
+ export function startEarlyBrand(p) {
64
+ const signals = p.signals ?? processSignals;
65
+ let done = false;
66
+ let settle;
67
+ const onSigint = () => {
68
+ if (done)
69
+ return;
70
+ done = true;
71
+ p.write('\n');
72
+ settle?.();
73
+ signals.exit(130);
74
+ };
75
+ if (p.tty) {
76
+ settle = holdBrand();
77
+ p.write(panelsBrandBase(p.version));
78
+ signals.once('SIGINT', onSigint);
79
+ }
80
+ const end = (text) => {
81
+ if (done)
82
+ return;
83
+ done = true;
84
+ if (p.tty)
85
+ signals.removeListener('SIGINT', onSigint);
86
+ if (text)
87
+ p.write(text);
88
+ settle?.();
89
+ };
90
+ return {
91
+ finish(signIn) {
92
+ end((p.tty ? panelsBrandSuffix(p.version, signIn) : panelsBrandLine(p.version, signIn)) + '\n');
93
+ },
94
+ close() {
95
+ end(p.tty ? '\n' : '');
96
+ },
97
+ };
98
+ }
99
+ /**
100
+ * The dim note saying where the SAP system came from (--sap-alias flag, or
101
+ * the resumed session). Legacy only: the panels status line already shows
102
+ * the system, and a 4th line would break §10's three-line screen.
103
+ */
104
+ export function aliasSourceNote(source, alias) {
105
+ if (getLook() === 'panels')
106
+ return null;
107
+ return source === 'flag' ? chalk.dim(`Using --sap-alias ${alias}`) : chalk.dim(`Resumed on SAP ${alias}`);
108
+ }
109
+ export function panelsStatusLine(p) {
110
+ const segs = [p.standalone ? paint('dim', 'standalone (no SAP system)') : paint('sap', p.system)];
111
+ if (p.tools === 'files+commands')
112
+ segs.push(paint('warn', 'local files + commands on'));
113
+ else if (p.tools === 'files')
114
+ segs.push(paint('dim', 'files → cspeach-out'));
115
+ if (p.writeMode !== undefined && p.writeMode !== 'approval-gated' && p.writeMode !== 'advisory-only') {
116
+ segs.push(paint('warn', `writes: ${p.writeMode}`));
117
+ }
118
+ segs.push(paint('dim', shortenPath(p.cwd, STATUS_PATH_MAX)));
119
+ return ' ' + segs.join(paint('dim', ' · '));
120
+ }
121
+ /** Line 3 of the panels start screen (non-resume). */
122
+ export function panelsHintLine(tools) {
123
+ const text = tools === 'files+commands'
124
+ ? 'turn off commands: cspeach config set local_build off · ? for shortcuts'
125
+ : '? for shortcuts · /help for commands';
126
+ return ' ' + paint('dim', text);
127
+ }
128
+ /** Line 3 of the panels start screen on --resume: the recap's "last session" fact.
129
+ * The fact quotes the user's last prompt, so control characters are neutralised. */
130
+ export function panelsResumeLine(last, priorTurns) {
131
+ const fact = last ?? `${priorTurns} prior turn${priorTurns === 1 ? '' : 's'}`;
132
+ return ' ' + paint('dim', truncateToWidth(sanitizeControlChars(`resumed · ${fact}`), Math.max(20, uiWidth() - 3)));
133
+ }
134
+ /** A "!" line for the start screen (no Next: line → exactly one line). */
135
+ export function panelsInterruptedNote(n) {
136
+ return formatAttention({ text: `${n} unfinished session${n === 1 ? '' : 's'}`, fact: 'cspeach --resume' }).trimEnd();
137
+ }
138
+ /**
139
+ * Lines 2-3 of the panels start screen, then — only when something needs
140
+ * action — a blank line and one "!" notice per item (each already ends '\n').
141
+ * Always ends with one blank line before the input.
142
+ */
143
+ export function panelsStartTail(p) {
144
+ const head = `${panelsStatusLine(p.status)}\n${p.third}\n`;
145
+ return p.notices.length === 0 ? head + '\n' : head + '\n' + p.notices.join('') + '\n';
146
+ }
147
+ export function formatPromptEcho(text, width) {
148
+ if (getLook() !== 'panels') {
149
+ return chalk.dim('\n──────────────────────────────────\n') + chalk.cyan(`› ${text}\n`);
150
+ }
151
+ return '\n' + paint('frame', '─'.repeat(Math.min(60, Math.max(20, width - 4)))) + '\n' + paint('dim', 'you ›') + ` ${text}\n`;
152
+ }
@@ -1,8 +1,10 @@
1
- import chalk from 'chalk';
2
1
  import { formatCost } from '../cost/pricing.js';
3
2
  import { creditsActive, formatCreditsLine, CREDITS_PENDING } from '../cost/credits.js';
4
- const RULE_WIDTH = 72;
5
- const RULE = chalk.dim('─'.repeat(RULE_WIDTH));
3
+ import { formatTurnReceipt, turnSpendText } from './footer-line.js';
4
+ import { formatAttention } from './notice-shape.js';
5
+ import { paint } from './theme.js';
6
+ import { uiWidth } from './ui-width.js';
7
+ import { getBoxChars } from '../ui/ascii-chars.js';
6
8
  /**
7
9
  * Phase 2 (2026-05-16) — when a session crosses this much non-cached input,
8
10
  * we nudge the user to /compact. Threshold is "feel right" — tuned to fire
@@ -13,9 +15,9 @@ export const LARGE_SESSION_THRESHOLD = 100_000;
13
15
  /**
14
16
  * Prints a status footer after each turn ends.
15
17
  *
16
- * Format (spec §3.7, v0.2 — cost-free variant):
18
+ * Format (piece 2 restyle — rules span uiWidth()):
17
19
  * ────────────────────────────────────────────────────────────────────
18
- * session 870ed8cc │ S4H-DEV │ /abap-radar │ 3 turns │ 6,533 tok this turn │ 2,100 cached │ 4.2s
20
+ * session 870ed8cc · S4H-DEV · /abap-radar · 3 turns · 6,533 tok this turn · $0.12 · 2,100 cached · session: $0.54 · 4.2s
19
21
  * ────────────────────────────────────────────────────────────────────
20
22
  *
21
23
  * H1 — `tokens` is the per-turn delta (input + output consumed THIS turn),
@@ -34,6 +36,20 @@ export const LARGE_SESSION_THRESHOLD = 100_000;
34
36
  * v0.3 transition: this chalk status-footer-after-turn is replaced (not
35
37
  * augmented) by the Ink persistent footer region in Plan 4c.
36
38
  */
39
+ /**
40
+ * The "session getting large" notice (piece 2, spec §8/§9) — a `!` attention
41
+ * line with the /compact next step. '' below LARGE_SESSION_THRESHOLD or when
42
+ * the caller passed no count.
43
+ */
44
+ export function largeSessionNotice(sessionInputTokens) {
45
+ if (sessionInputTokens == null || sessionInputTokens < LARGE_SESSION_THRESHOLD)
46
+ return '';
47
+ return formatAttention({
48
+ text: 'session getting large',
49
+ fact: `${sessionInputTokens.toLocaleString('en-US')} input tokens`,
50
+ next: 'type /compact to summarise older turns, or /exit and start fresh for a new topic',
51
+ });
52
+ }
37
53
  /**
38
54
  * Format the status footer as a single string (with embedded newlines).
39
55
  * Pulled out from `printStatusFooter` so the Ink path can route the
@@ -42,6 +58,16 @@ export const LARGE_SESSION_THRESHOLD = 100_000;
42
58
  */
43
59
  export function formatStatusFooter(params) {
44
60
  const { sessionId, sapAlias, skill, turnCount, tokens, cachedTokens, elapsedMs, costThisTurn, costSession, sessionInputTokens, variant, credits } = params;
61
+ // Piece 2 (spec §8) — Ink per-turn receipt: ONE dim line, this turn only.
62
+ // The session total and balance live in the footer line now.
63
+ if (variant === 'turn') {
64
+ return formatTurnReceipt({
65
+ skill,
66
+ tokens,
67
+ spend: turnSpendText({ costThisTurn, credits }),
68
+ ms: elapsedMs,
69
+ }) + '\n' + largeSessionNotice(sessionInputTokens);
70
+ }
45
71
  const shortSession = sessionId.slice(0, 8);
46
72
  const skillDisplay = skill.startsWith('/') ? skill : `/${skill}`;
47
73
  const turns = `${turnCount} turn${turnCount === 1 ? '' : 's'}`;
@@ -50,8 +76,7 @@ export function formatStatusFooter(params) {
50
76
  const elapsed = elapsedMs > 0 ? `${(elapsedMs / 1000).toFixed(1)}s` : '';
51
77
  // Credits mode: the whole spend story collapses into ONE customer-facing
52
78
  // segment (turn · session · balance) in place of the two dollar segments.
53
- // Both variants read `costTurnStr`, so putting the combined line there
54
- // covers the slim Ink receipt and the full classic receipt alike.
79
+ // (Classic receipt only — the Ink 'turn' variant returned above.)
55
80
  const inCredits = creditsActive();
56
81
  const costTurnStr = inCredits
57
82
  ? (credits ? formatCreditsLine(credits) : CREDITS_PENDING)
@@ -59,33 +84,26 @@ export function formatStatusFooter(params) {
59
84
  const costSessStr = inCredits
60
85
  ? ''
61
86
  : (costSession != null ? `session: ${formatCost(costSession)}` : '');
62
- // Ink turn-receipt (see the variant doc on StatusFooterParams):
63
- // turn-only facts — everything session-scoped lives in <StatusLine />.
64
- const parts = variant === 'turn'
65
- ? [
66
- chalk.dim(skillDisplay),
67
- chalk.dim(turns),
68
- chalk.dim(tokStr),
69
- ...(costTurnStr ? [chalk.dim(costTurnStr)] : []),
70
- ...(elapsed ? [chalk.dim(elapsed)] : []),
71
- ]
72
- : [
73
- chalk.dim(`session ${shortSession}`),
74
- chalk.dim(sapAlias),
75
- chalk.dim(skillDisplay),
76
- chalk.dim(turns),
77
- chalk.dim(tokStr),
78
- ...(costTurnStr ? [chalk.dim(costTurnStr)] : []),
79
- ...(cachedStr ? [chalk.dim(cachedStr)] : []),
80
- ...(costSessStr ? [chalk.dim(costSessStr)] : []),
81
- ...(elapsed ? [chalk.dim(elapsed)] : []),
82
- ];
83
- const lines = ['', RULE, ' ' + parts.join(chalk.dim(' │ '))];
84
- if (sessionInputTokens != null && sessionInputTokens >= LARGE_SESSION_THRESHOLD) {
85
- lines.push(' ' + chalk.yellow(`⚠ session getting large (${sessionInputTokens.toLocaleString()} input tokens)`
86
- + ` — type /compact to summarise older turns, or /exit + cspeach (fresh) for a new topic`));
87
- }
88
- lines.push(RULE, '');
87
+ // Classic full receipt (piece 2 restyle — same facts, reviewed pin):
88
+ // rules at uiWidth() in the frame role, ' · ' separators, the large-session
89
+ // advisory as a `!` notice (largeSessionNotice).
90
+ const parts = [
91
+ `session ${shortSession}`,
92
+ sapAlias,
93
+ skillDisplay,
94
+ turns,
95
+ tokStr,
96
+ ...(costTurnStr ? [costTurnStr] : []),
97
+ ...(cachedStr ? [cachedStr] : []),
98
+ ...(costSessStr ? [costSessStr] : []),
99
+ ...(elapsed ? [elapsed] : []),
100
+ ];
101
+ const rule = paint('frame', getBoxChars().h.repeat(uiWidth()));
102
+ const lines = ['', rule, ' ' + paint('dim', parts.join(' · '))];
103
+ const notice = largeSessionNotice(sessionInputTokens);
104
+ if (notice)
105
+ lines.push(notice.replace(/\n$/, ''));
106
+ lines.push(rule, '');
89
107
  return lines.join('\n');
90
108
  }
91
109
  export function printStatusFooter(params) {
@@ -0,0 +1,41 @@
1
+ // src/renderer/steering-echo.ts
2
+ //
3
+ // The receipt a mid-turn message leaves in scrollback (spec §7). ONE themed
4
+ // helper for both echo paths: App.beginSubmit (ui/app.tsx) and the fallback
5
+ // in repl.tsx handleSubmit for callers that bypass beginSubmit.
6
+ import { paint } from './theme.js';
7
+ import { sanitizeControlChars } from './sanitize.js';
8
+ /** Wave1 fast-follow — only the first line, clipped, with a line count. */
9
+ export const STEERING_ECHO_MAX = 60;
10
+ export function formatSteeringEcho(text) {
11
+ const lines = text.split('\n');
12
+ const first = lines[0] ?? '';
13
+ const extra = lines.length - 1;
14
+ const clipped = first.length > STEERING_ECHO_MAX;
15
+ let out = clipped ? first.slice(0, STEERING_ECHO_MAX) : first;
16
+ if (clipped || extra > 0)
17
+ out += '…';
18
+ if (extra > 0)
19
+ out += ` (+${extra} line${extra === 1 ? '' : 's'})`;
20
+ return out;
21
+ }
22
+ /**
23
+ * `↳ queued · <first line…>` in the dim role, newline-terminated. The text may
24
+ * come from a paste, so control characters are neutralised before it reaches
25
+ * scrollback.
26
+ */
27
+ export function steeringEchoLine(text) {
28
+ return paint('dim', `↳ queued · ${sanitizeControlChars(formatSteeringEcho(text))}`) + '\n';
29
+ }
30
+ export function classifyMidTurnLine(text) {
31
+ const first = text.trim().split(/\s+/)[0] ?? '';
32
+ if (first === '/exit' || first === '/quit')
33
+ return { kind: 'exit' };
34
+ if (/^\/[A-Za-z][\w-]*$/.test(first))
35
+ return { kind: 'slash', command: first };
36
+ return { kind: 'steer' };
37
+ }
38
+ /** The one-line hint for a slash command typed mid-turn (dim, newline-terminated). */
39
+ export function midTurnSlashHint(command) {
40
+ return paint('dim', `↳ ${sanitizeControlChars(command)} runs after this turn · press Enter when it ends`) + '\n';
41
+ }