@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.
Files changed (225) 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 +80 -22
  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/skills/signing-public-key.js +1 -1
  132. package/dist/standards/standards-init.js +1 -1
  133. package/dist/test-helpers/answer-any.js +46 -0
  134. package/dist/tools/approval.js +27 -11
  135. package/dist/tools/ask-question.js +47 -5
  136. package/dist/tools/filesystem/file-write.js +19 -2
  137. package/dist/tools/fiori/fe-extend.js +16 -1
  138. package/dist/tools/fiori/fe-scaffold.js +105 -3
  139. package/dist/tools/fiori/html-escapes.js +28 -0
  140. package/dist/tools/fiori/metadata/audit.js +605 -0
  141. package/dist/tools/fiori/metadata/references.js +104 -0
  142. package/dist/tools/fiori/metadata/types.js +8 -0
  143. package/dist/tools/fiori/preview/app-guard.js +215 -0
  144. package/dist/tools/fiori/preview/env.js +193 -0
  145. package/dist/tools/fiori/preview/readiness.js +127 -0
  146. package/dist/tools/fiori/preview/registry.js +412 -0
  147. package/dist/tools/fiori/preview/start.js +469 -0
  148. package/dist/tools/fiori/samples/loader.js +20 -5
  149. package/dist/tools/fiori/scaffold.js +18 -6
  150. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  151. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  152. package/dist/tools/fiori/smoke/assertions.js +161 -24
  153. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  154. package/dist/tools/fiori/smoke/batch.js +273 -0
  155. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  156. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  157. package/dist/tools/fiori/smoke/driver.js +171 -4
  158. package/dist/tools/fiori/smoke/evidence.js +35 -0
  159. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  160. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  161. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  162. package/dist/tools/fiori/tools.js +651 -7
  163. package/dist/tools/fiori/ui5-version.js +16 -0
  164. package/dist/tools/index.js +8 -0
  165. package/dist/tools/local-build.js +6 -0
  166. package/dist/tools/sap-read.js +16 -1
  167. package/dist/tools/sap-write.js +122 -10
  168. package/dist/tools/shell/shell_exec.js +30 -12
  169. package/dist/tools/syntax-state.js +67 -0
  170. package/dist/tools/todo.js +23 -18
  171. package/dist/tools/transport-fit.js +462 -0
  172. package/dist/tools/transport-resolution.js +3 -1
  173. package/dist/tools/transport.js +33 -7
  174. package/dist/tools/verify.js +184 -8
  175. package/dist/ui/app.js +284 -146
  176. package/dist/ui/approval-modal.js +41 -15
  177. package/dist/ui/ask-answer-text.js +17 -0
  178. package/dist/ui/body.js +25 -6
  179. package/dist/ui/card-slot.js +98 -0
  180. package/dist/ui/checklist.js +9 -0
  181. package/dist/ui/coaching-picker-classic.js +8 -5
  182. package/dist/ui/command-palette.js +6 -4
  183. package/dist/ui/confirm-request.js +18 -0
  184. package/dist/ui/context-grid.js +2 -1
  185. package/dist/ui/file-palette.js +6 -4
  186. package/dist/ui/files-card-emitter.js +19 -0
  187. package/dist/ui/footer-line.js +16 -0
  188. package/dist/ui/footer.js +105 -104
  189. package/dist/ui/input-wrap.js +122 -0
  190. package/dist/ui/interrupt.js +103 -0
  191. package/dist/ui/key-burst.js +117 -0
  192. package/dist/ui/line-resolution.js +4 -3
  193. package/dist/ui/live-area.js +71 -0
  194. package/dist/ui/login-banner.js +54 -43
  195. package/dist/ui/rewind-panel.js +76 -24
  196. package/dist/ui/role-props.js +47 -0
  197. package/dist/ui/sap-state-store.js +1 -0
  198. package/dist/ui/session-timeline.js +49 -3
  199. package/dist/ui/skill-picker.js +5 -47
  200. package/dist/ui/text-input.js +128 -24
  201. package/dist/ui/todo-emitter.js +19 -0
  202. package/dist/ui/turn-status-emitter.js +28 -2
  203. package/dist/ui/turn-status.js +16 -19
  204. package/dist/ui/use-card-request.js +44 -0
  205. package/dist/ui/widgets/ask-card.js +521 -0
  206. package/dist/ui/widgets/bar-chart.js +11 -5
  207. package/dist/ui/widgets/coaching-picker.js +50 -55
  208. package/dist/ui/widgets/confirm-card.js +193 -0
  209. package/dist/ui/widgets/dep-graph.js +3 -2
  210. package/dist/ui/widgets/diff-viewer.js +6 -5
  211. package/dist/ui/widgets/files-card.js +97 -0
  212. package/dist/ui/widgets/question-card.js +2 -1
  213. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  214. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  215. package/dist/ui/widgets/stack-frames.js +2 -1
  216. package/dist/upgrade-check.js +39 -8
  217. package/package.json +11 -2
  218. package/test-harness/fe-draft/app.ts +188 -0
  219. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  220. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  221. package/test-harness/fe-draft/routes.ts +224 -0
  222. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  223. package/test-harness/fe-draft/server.ts +225 -0
  224. package/test-harness/fe-draft/variants.ts +196 -0
  225. package/vitest.smoke-e2e.config.ts +20 -0
@@ -26,6 +26,7 @@ import { createRequire } from 'node:module';
26
26
  import abapGrammar from './highlighters/abap.js';
27
27
  import cdsGrammar from './highlighters/cds.js';
28
28
  import bdefGrammar from './highlighters/bdef.js';
29
+ import { colorOff } from './color-mode.js';
29
30
  // A5 fix (2026-06-06) — registration must hit the EXACT module instance
30
31
  // cli-highlight loads, and on this pnpm/Windows layout that is NOT what a
31
32
  // plain `import hljs from 'highlight.js'` (or even a local require)
@@ -41,18 +42,31 @@ import bdefGrammar from './highlighters/bdef.js';
41
42
  const require = createRequire(import.meta.url);
42
43
  // eslint-disable-next-line @typescript-eslint/no-var-requires
43
44
  const hljs = createRequire(require.resolve('cli-highlight'))('highlight.js');
44
- // Module-load-time registration. The grammar modules export plain objects
45
- // (shape matches highlight.js `Language` definition). Cast is safe — the
46
- // grammars match the runtime contract even when strict typings drift.
47
- hljs.registerLanguage('abap', () => abapGrammar);
48
- hljs.registerLanguage('cds', () => cdsGrammar);
49
- hljs.registerLanguage('bdef', () => bdefGrammar);
50
- // `ddl` is an alias of the CDS grammar: ```ddl fences carry CDS DDL source
51
- // (the abap-rap templates emit them). Registering it on the same hljs
52
- // instance also silences marked-terminal's "Could not find the language
53
- // 'ddl'" warning, since marked-terminal calls cli-highlight directly with
54
- // the raw fence lang and never goes through highlightCodeBlock().
55
- hljs.registerLanguage('ddl', () => cdsGrammar);
45
+ // Registration is idempotent and triggered explicitly (2026-09-26, look
46
+ // piece 1): pipeline.ts calls ensureGrammars() at ITS module load — the same
47
+ // moment this module used to self-register, since pipeline.ts is the only
48
+ // production caller of renderMarkdown — and highlightCodeBlock() calls it
49
+ // lazily. This lets markdown.ts import the fenced-body highlighter without
50
+ // changing the legacy marked-terminal output of a markdown-only module graph
51
+ // (pinned by test/unit/legacy-look-pin.test.ts).
52
+ // The grammar modules export plain objects (shape matches highlight.js
53
+ // `Language` definition). Cast is safe — the grammars match the runtime
54
+ // contract even when strict typings drift.
55
+ let grammarsRegistered = false;
56
+ export function ensureGrammars() {
57
+ if (grammarsRegistered)
58
+ return;
59
+ grammarsRegistered = true;
60
+ hljs.registerLanguage('abap', () => abapGrammar);
61
+ hljs.registerLanguage('cds', () => cdsGrammar);
62
+ hljs.registerLanguage('bdef', () => bdefGrammar);
63
+ // `ddl` is an alias of the CDS grammar: ```ddl fences carry CDS DDL source
64
+ // (the abap-rap templates emit them). Registering it on the same hljs
65
+ // instance also silences marked-terminal's "Could not find the language
66
+ // 'ddl'" warning, since marked-terminal calls cli-highlight directly with
67
+ // the raw fence lang and never goes through highlightCodeBlock().
68
+ hljs.registerLanguage('ddl', () => cdsGrammar);
69
+ }
56
70
  const SUPPORTED = new Set(['abap', 'cds', 'bdef', 'ddl']);
57
71
  /**
58
72
  * Highlight a code block body (without fence markers).
@@ -66,6 +80,11 @@ export function highlightCodeBlock(code, lang) {
66
80
  const normalised = lang.trim().toLowerCase();
67
81
  if (!SUPPORTED.has(normalised))
68
82
  return code;
83
+ // NO_COLOR (piece 2 §1.2): cli-highlight's own chalk 4 ignores NO_COLOR and an
84
+ // empty theme falls back to DEFAULT_THEME, so skip highlighting entirely.
85
+ if (colorOff())
86
+ return code;
87
+ ensureGrammars();
69
88
  try {
70
89
  return highlight(code, {
71
90
  language: normalised,
@@ -2,6 +2,7 @@
2
2
  import Table from 'cli-table3';
3
3
  import chalk from 'chalk';
4
4
  import { applySeverity } from './severity.js';
5
+ import { colorOff } from './color-mode.js';
5
6
  /** Regex that matches a markdown pipe-table row (at least two | delimiters). */
6
7
  const PIPE_ROW_RE = /^\|(.+\|)+$/;
7
8
  /** Separator row: | --- | or | :---: | etc. */
@@ -46,7 +47,10 @@ function buildTable(lines) {
46
47
  return lines.join('\n');
47
48
  const [headerRow, ...dataRows] = rows;
48
49
  const headers = parseRow(headerRow).map((h) => chalk.cyan.bold(h));
49
- const table = new Table({ head: headers });
50
+ // NO_COLOR (piece 2 §1.2): cli-table3's @colors/colors ignores NO_COLOR, so
51
+ // empty its default red head / grey border when colour is off. Coloured
52
+ // output keeps the default style object (byte-identical).
53
+ const table = new Table(colorOff() ? { head: headers, style: { head: [], border: [] } } : { head: headers });
50
54
  for (const row of dataRows) {
51
55
  const cells = parseRow(row).map((c) => applySeverity(c));
52
56
  table.push(cells);
@@ -0,0 +1,44 @@
1
+ // src/renderer/theme.ts
2
+ //
3
+ // Panels look colours (2026-09-26). Every panels string builder asks for a ROLE,
4
+ // never a raw colour, so dark/light terminals and colour depth are handled here
5
+ // once. The legacy look does not use this module.
6
+ import chalk from 'chalk';
7
+ import { getBackground } from './look.js';
8
+ export const PALETTE = {
9
+ dark: {
10
+ sap: '#5AA9FF', ok: '#6CCF7F', warn: '#F5B04D', bad: '#FF8A8A',
11
+ frame: '#33506F', dim: '#8A9BB0', brand: '#FFB38A',
12
+ },
13
+ light: {
14
+ sap: '#0064D9', ok: '#256F3A', warn: '#B44F00', bad: '#AA0808',
15
+ frame: '#A9B4BE', dim: '#556B82', brand: '#C65A24',
16
+ },
17
+ };
18
+ export const BRAND_HEX = PALETTE.dark.brand;
19
+ /** Filled tag chip colours (SAP blue, white text) — readable on dark and light terminals. */
20
+ export const CHIP_BG = '#0070F2';
21
+ export const CHIP_FG = '#FFFFFF';
22
+ const BASIC = {
23
+ sap: (s) => chalk.blue(s),
24
+ ok: (s) => chalk.green(s),
25
+ warn: (s) => chalk.yellow(s),
26
+ bad: (s) => chalk.red(s),
27
+ frame: (s) => chalk.gray(s),
28
+ dim: (s) => chalk.gray(s),
29
+ brand: (s) => chalk.yellow(s),
30
+ };
31
+ export function paint(role, text) {
32
+ if (chalk.level === 0)
33
+ return text;
34
+ if (chalk.level === 1)
35
+ return BASIC[role](text);
36
+ return chalk.hex(PALETTE[getBackground()][role])(text);
37
+ }
38
+ export function chip(text) {
39
+ if (chalk.level === 0)
40
+ return `[${text}]`;
41
+ if (chalk.level === 1)
42
+ return chalk.bgBlue.white.bold(` ${text} `);
43
+ return chalk.bgHex(CHIP_BG).hex(CHIP_FG).bold(` ${text} `);
44
+ }
@@ -29,6 +29,9 @@
29
29
  * fallback.
30
30
  */
31
31
  import chalk from 'chalk';
32
+ import { getLook } from './look.js';
33
+ import { writeStdout, canAnimateInPlace } from './transcript-flow.js';
34
+ import { isDeclaredOneShot } from './tty.js';
32
35
  const LLM_THRESHOLDS = [
33
36
  { at: 3_000, label: 'thinking…' },
34
37
  { at: 10_000, label: 'thinking… (10s)' },
@@ -71,7 +74,29 @@ export const AUDIT_HEARTBEAT_THRESHOLDS = [
71
74
  { at: 240_000, label: '[audit] …running (4m — a prior attempt timed out; retrying · Ctrl+C aborts the turn)' },
72
75
  { at: 300_000, label: '[audit] …running (5m — nearly at the retry ceiling; will stop and re-audit on resume if it can\'t finish)' },
73
76
  ];
77
+ /**
78
+ * Fix round 3 (item 8) — one-shot's routing wait (the classifier round-trip,
79
+ * up to ~16 s with its retry) showed nothing. These lines go to stderr in
80
+ * one-shot (the default sink there), so stdout stays the answer only.
81
+ */
82
+ export const ROUTING_HEARTBEAT_THRESHOLDS = [
83
+ { at: 1_000, label: 'routing…' },
84
+ { at: 5_000, label: 'routing… (5s)' },
85
+ { at: 10_000, label: 'routing… (10s)' },
86
+ ];
74
87
  export function startThinkingHeartbeat(opts) {
88
+ // Polish P2 (panels look): liveness lines are transient. Where a live
89
+ // indicator exists — Ink's TurnStatus row (chunkEmitter present) or the
90
+ // classic in-place spinner (real terminal) — the heartbeat prints nothing,
91
+ // so no "thinking…" / "Searching …" line is left in the transcript. Pipes
92
+ // and dumb terminals keep the permanent lines (nothing can be erased
93
+ // there). Custom sinks/schedules (the plan-chain audit) are unchanged.
94
+ if (getLook() === 'panels' &&
95
+ !opts?.emit &&
96
+ !opts?.thresholds &&
97
+ (opts?.chunkEmitter !== undefined || canAnimateInPlace())) {
98
+ return { stop: () => undefined };
99
+ }
75
100
  const startedAt = Date.now();
76
101
  let nextIdx = 0;
77
102
  let stopped = false;
@@ -106,9 +131,15 @@ export function startThinkingHeartbeat(opts) {
106
131
  // render tree and survives subsequent renders.
107
132
  opts.chunkEmitter.emit('chunk', line + '\n');
108
133
  }
134
+ else if (isDeclaredOneShot()) {
135
+ // Fix round 3 (item 7) — one-shot stdout is the answer only; liveness
136
+ // chatter goes to stderr (piped `cspeach "…" | cat` showed `thinking…`).
137
+ process.stderr.write(line + '\n');
138
+ }
109
139
  else {
110
- // Classic mode — direct stdout, plain newline-terminated line.
111
- process.stdout.write(line + '\n');
140
+ // Classic mode — direct stdout, plain newline-terminated line
141
+ // (panels: through the transcript flow; legacy: verbatim).
142
+ writeStdout(line + '\n');
112
143
  }
113
144
  };
114
145
  const tick = () => {
@@ -1,51 +1,12 @@
1
- /**
2
- * todo_set transcript block — UX Wave 2 follow-up (CC-style checklist).
3
- *
4
- * Owner feedback (2026-07): "it's not like we have in Claude Code — only
5
- * shows the phase it's working on with an arrow." The turn-status strip
6
- * (single ▶ row) and the Ctrl+T panel are both by-design; what was missing
7
- * was Claude Code's behaviour of dropping the FULL checklist into the
8
- * scrollback transcript on every todo_set, so the user sees the whole plan
9
- * advance in place rather than a raw `⏺ todo_set({...})` JSON line.
10
- *
11
- * This module is the pure formatter for that block. todo.ts emits the
12
- * returned string as a `chunk` on ctx.chunkEmitter after a successful set
13
- * (routes to Ink Static scrollback AND the classic 'chunk'→stdout path);
14
- * the raw ⏺/⎿ todo_set rows are widget-suppressed (tool-widget.ts) so the
15
- * block is the sole visible representation — exactly mirroring ask_question.
16
- *
17
- * House style follows todo-panel.tsx glyphs precisely so the transcript
18
- * block and the Ctrl+T panel read as the same list:
19
- * ▣ completed (dim)
20
- * ▶ in_progress (peach, bold)
21
- * □ pending (default)
22
- * Every row is truncate-end (Wave-1 rule — no soft-wrap), preceded by a
23
- * slim `Tasks (done/total done)` header.
24
- */
25
- import chalk from 'chalk';
26
- import { PEACH } from './banners.js';
27
- /** Max visible width of a row's TEXT (after the 2-char glyph+space prefix).
28
- * 72-col total matches the Ctrl+T panel width; 70 leaves room for `▶ `. */
29
- export const TODO_BLOCK_TEXT_MAX = 70;
30
- function truncateEnd(s, max) {
31
- return s.length > max ? s.substring(0, max - 1) + '…' : s;
32
- }
33
- /**
34
- * Render the checklist as a scrollback block (leading + trailing newline for
35
- * separation from surrounding transcript content). Returns ANSI-coloured
36
- * text ready to emit as a `chunk`.
37
- */
38
- export function formatTodoChecklistBlock(todos) {
39
- const done = todos.filter((t) => t.status === 'completed').length;
40
- const total = todos.length;
41
- const header = chalk.dim(`Tasks (${done}/${total} done)`);
42
- const rows = todos.map((t) => {
43
- const text = truncateEnd(t.text, TODO_BLOCK_TEXT_MAX);
44
- if (t.status === 'completed')
45
- return chalk.dim(`▣ ${text}`);
46
- if (t.status === 'in_progress')
47
- return chalk.hex(PEACH).bold(`▶ ${text}`);
48
- return `□ ${text}`;
1
+ import { formatChecklist } from './checklist-format.js';
2
+ import { uiWidth } from './ui-width.js';
3
+ /** Leading + trailing newline separate the block from surrounding scrollback. */
4
+ export function formatTodoChecklistBlock(todos, width = uiWidth()) {
5
+ const lines = formatChecklist(todos, {
6
+ width,
7
+ maxRows: Math.max(1, todos.length),
8
+ expanded: true,
9
+ foldedToOne: false,
49
10
  });
50
- return '\n' + [header, ...rows].join('\n') + '\n';
11
+ return '\n' + lines.join('\n') + '\n';
51
12
  }
@@ -0,0 +1,238 @@
1
+ const OBJECT_MAX = 60;
2
+ /**
3
+ * Every value shown in a label goes through here: ANSI escape sequences and
4
+ * other C0 control characters (incl. ESC) and DEL are stripped, whitespace
5
+ * (incl. newlines) collapses to one space, ends trimmed — one step, one line.
6
+ */
7
+ // eslint-disable-next-line no-control-regex
8
+ export const ANSI_RE = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
9
+ // eslint-disable-next-line no-control-regex
10
+ const CTRL_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
11
+ export const clean = (s) => s.replace(ANSI_RE, '').replace(CTRL_RE, '').replace(/\s+/g, ' ').trim();
12
+ /** Raw string arg (for line counting only — never displayed). */
13
+ const raw = (v) => (typeof v === 'string' && v.length > 0 ? v : undefined);
14
+ /** Display-safe string arg. */
15
+ const str = (v) => {
16
+ const r = raw(v);
17
+ if (r === undefined)
18
+ return undefined;
19
+ const c = clean(r);
20
+ return c.length > 0 ? c : undefined;
21
+ };
22
+ const lineCount = (s) => (s === '' ? 0 : s.split(/\r?\n/).length);
23
+ /** Shorten from the LEFT for paths (the file name is what matters). */
24
+ const shortLeft = (s, max = OBJECT_MAX) => (s.length > max ? '…' + s.slice(s.length - (max - 1)) : s);
25
+ const shortRight = (s, max = OBJECT_MAX) => (s.length > max ? s.slice(0, max - 1) + '…' : s);
26
+ const humanise = (name) => clean(name.replace(/_/g, ' '));
27
+ const VERB_MAX = 40;
28
+ const sapObject = (verb) => (a) => ({ verb, object: shortRight(str(a['name']) ?? '') });
29
+ /** User-side names for class local includes; unknown values pass through. */
30
+ const INCLUDE_NAMES = {
31
+ locals_imp: 'local types',
32
+ locals_def: 'local definitions',
33
+ testclasses: 'test classes',
34
+ macros: 'macros',
35
+ };
36
+ /**
37
+ * sap_activate's real schema is a batch: `objects: [{ name, type }]`.
38
+ * Up to 3 names are shown; with 4 or more, the first two then "+N more". A bare `name` is still honoured.
39
+ */
40
+ const activateObject = (a) => {
41
+ const list = Array.isArray(a['objects'])
42
+ ? a['objects']
43
+ .map((o) => (o && typeof o === 'object' ? str(o['name']) : undefined))
44
+ .filter((n) => n !== undefined)
45
+ : [];
46
+ if (list.length === 0)
47
+ return str(a['name']) ?? '';
48
+ const shown = list.slice(0, list.length > 3 ? 2 : 3).join(', ');
49
+ return list.length > 3 ? `${shown} +${list.length - 2} more` : shown;
50
+ };
51
+ /** Length of a JSON array result (e.g. sap_search_object); undefined when it is not one — never a guess. */
52
+ const arrayCount = (r) => {
53
+ try {
54
+ const v = JSON.parse(r);
55
+ return Array.isArray(v) ? v.length : undefined;
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ };
61
+ /** sap_search_object's default maxResults (src/tools/sap-read.ts: `args.maxResults ?? 100`). */
62
+ const SEARCH_DEFAULT_MAX = 100;
63
+ const fileCount = (v) => (Array.isArray(v) ? `${v.length} ${v.length === 1 ? 'file' : 'files'}` : '');
64
+ const commandLine = (a) => [str(a['command']) ?? '', ...(Array.isArray(a['args']) ? a['args'].map(str).filter((x) => x !== undefined) : [])].join(' ').trim();
65
+ const RULES = {
66
+ file_read: (a, r) => ({ verb: 'Read', object: shortLeft(str(a['path']) ?? ''), ...(r !== undefined ? { detail: `${lineCount(r)} lines` } : {}) }),
67
+ file_write: (a) => ({ verb: 'Wrote', object: shortLeft(str(a['path']) ?? ''), detail: `${lineCount(raw(a['content']) ?? '')} lines` }),
68
+ file_edit: (a) => ({
69
+ verb: 'Edited',
70
+ object: shortLeft(str(a['path']) ?? ''),
71
+ detail: `+${lineCount(raw(a['new_string']) ?? '')} −${lineCount(raw(a['old_string']) ?? '')}`,
72
+ }),
73
+ grep: (a) => ({ verb: 'Searched', object: shortRight(`"${str(a['pattern']) ?? ''}"`) }),
74
+ glob: (a) => ({ verb: 'Listed', object: shortRight(str(a['pattern']) ?? '') }),
75
+ shell_exec: (a, r) => {
76
+ const exit = r ? /exit code:\s*(-?\d+|null)/.exec(r)?.[1] : undefined;
77
+ return { verb: 'Ran', object: shortRight(commandLine(a)), ...(exit !== undefined ? { detail: `exit ${exit}` } : {}) };
78
+ },
79
+ background_run: (a) => ({ verb: 'Started', object: shortRight(commandLine(a)) }),
80
+ sap_get_source: sapObject('Read'),
81
+ sap_set_source: sapObject('Wrote'),
82
+ sap_activate: (a) => ({ verb: 'Activated', object: shortRight(activateObject(a)) }),
83
+ sap_syntax_check: sapObject('Checked syntax'),
84
+ sap_atc_run: sapObject('Ran ATC on'),
85
+ sap_delete_object: sapObject('Deleted'),
86
+ sap_snapshot_take: sapObject('Snapshot'),
87
+ sap_usage_references: sapObject('Where-used for'),
88
+ sap_create_object: (a) => ({ verb: 'Created', object: shortRight([str(a['type']), str(a['name'])].filter(Boolean).join(' ')) }),
89
+ sap_update_method: (a) => ({ verb: 'Updated', object: shortRight(`${str(a['class_name']) ?? ''} → ${str(a['method_name']) ?? ''}`) }),
90
+ sap_set_class_include: (a) => {
91
+ const inc = str(a['includeType']);
92
+ const incName = inc !== undefined ? (Object.hasOwn(INCLUDE_NAMES, inc) ? INCLUDE_NAMES[inc] : inc) : '';
93
+ return { verb: 'Wrote', object: shortRight(`${str(a['className']) ?? ''} ${incName}`.trim()) };
94
+ },
95
+ sap_search_object: (a, r) => {
96
+ // "*AGING*" · PROG — the type filter tells otherwise identical searches apart.
97
+ const type = str(a['type']);
98
+ const count = r !== undefined ? arrayCount(r) : undefined;
99
+ // At the cap (maxResults, default 100 as in the tool) there may be more: "100+ hits".
100
+ const max = typeof a['maxResults'] === 'number' && a['maxResults'] > 0 ? a['maxResults'] : SEARCH_DEFAULT_MAX;
101
+ const hits = count === undefined ? undefined : count >= max ? `${max}+ hits` : `${count} ${count === 1 ? 'hit' : 'hits'}`;
102
+ return {
103
+ verb: 'Searched',
104
+ object: shortRight(`"${str(a['query']) ?? ''}"${type ? ` · ${type}` : ''}`),
105
+ ...(hits !== undefined ? { detail: hits } : {}),
106
+ };
107
+ },
108
+ sap_sql_query: (a) => ({ verb: 'Queried', object: shortRight(str(a['query']) ?? '') }),
109
+ sap_inactive_objects: () => ({ verb: 'Checked inactive objects', object: '' }),
110
+ fiori_scaffold_fe: (a) => ({ verb: 'Scaffolded app', object: shortLeft(str(a['basePath']) ?? '') }),
111
+ fiori_render_smoke: (a) => ({ verb: 'Render check', object: shortRight(str(a['previewId']) ?? str(a['appUrl']) ?? '') }),
112
+ fiori_draft_smoke: (a) => ({ verb: 'Draft check', object: shortRight(str(a['servicePath']) ?? '') }),
113
+ request_approval: (a) => ({ verb: 'Approval', object: shortRight(str(a['summary']) ?? '') }),
114
+ // Polish wave (P4): every other registered tool, so none falls back to
115
+ // "tool name key=value". Coverage test: tool-labels-coverage.test.ts.
116
+ sap_object_structure: sapObject('Structure'),
117
+ sap_change_log: sapObject('Change log'),
118
+ sap_short_dump: (a) => ({ verb: 'Read dumps', object: shortRight(str(a['dumpId']) ?? [str(a['user']), str(a['fromDate'])].filter(Boolean).join(' ')) }),
119
+ sap_api_state: (a) => ({ verb: 'API state', object: shortRight(activateObject(a)) }),
120
+ sap_abap_docu: (a) => ({ verb: 'ABAP docs', object: shortRight(str(a['keyword']) ?? '') }),
121
+ sap_class_includes: (a) => ({ verb: 'Read includes', object: shortRight([str(a['className']), str(a['includeType'])].filter(Boolean).join(' ')) }),
122
+ sap_diff: sapObject('Compared'),
123
+ sap_preview_change: sapObject('Previewed change'),
124
+ sap_run_unit_test: sapObject('Ran unit tests'),
125
+ sap_odata_test: (a) => ({ verb: 'OData test', object: shortRight([str(a['servicePath']), str(a['entity'])].filter(Boolean).join(' ')) }),
126
+ sap_badi_list: (a) => ({ verb: 'Searched BAdIs', object: shortRight(`"${str(a['query']) ?? ''}"`) }),
127
+ sap_snapshot_list: (a) => ({ verb: 'Listed snapshots', object: shortRight(str(a['name']) ?? '') }),
128
+ sap_snapshot_read: sapObject('Read snapshot'),
129
+ sap_snapshot_cleanup: () => ({ verb: 'Cleaned snapshots', object: '' }),
130
+ sap_transport_create: (a) => ({ verb: 'Created transport', object: shortRight(str(a['description']) ?? '') }),
131
+ sap_transport_for_object: (a) => ({ verb: 'Transport for', object: shortRight(str(a['object_name']) ?? '') }),
132
+ sap_transport_list: (a) => ({ verb: 'Listed transports', object: shortRight(str(a['owner']) ?? '') }),
133
+ sap_transport_release: (a) => ({ verb: 'Released', object: shortRight(str(a['transport']) ?? '') }),
134
+ sap_create_domain: sapObject('Created domain'),
135
+ sap_create_data_element: sapObject('Created data element'),
136
+ sap_lock: sapObject('Locked'),
137
+ sap_unlock: sapObject('Unlocked'),
138
+ sap_service_binding_publish: sapObject('Published'),
139
+ sap_service_binding_unpublish: sapObject('Unpublished'),
140
+ sap_message_maintain: (a) => ({ verb: 'Message', object: shortRight([str(a['messageClass']), str(a['messageNumber'])].filter(Boolean).join(' ')) }),
141
+ sap_number_range_intervals: (a) => ({ verb: 'Number range', object: shortRight(str(a['nrObject']) ?? '') }),
142
+ ask_question: (a) => ({ verb: 'Question', object: shortRight(str(a['question']) ?? '') }),
143
+ todo_set: () => ({ verb: 'Updated todos', object: '' }),
144
+ dispatch_skill: (a) => ({ verb: 'Ran skill', object: shortRight(str(a['command']) ?? '') }),
145
+ read_document: (a, r) => ({ verb: 'Read document', object: shortLeft(str(a['path']) ?? ''), ...(r !== undefined ? { detail: `${lineCount(r)} lines` } : {}) }),
146
+ web_fetch: (a) => ({ verb: 'Fetched', object: shortRight(str(a['url']) ?? '') }),
147
+ web_search: (a) => ({ verb: 'Searched web', object: shortRight(`"${str(a['query']) ?? ''}"`) }),
148
+ project_context_get: () => ({ verb: 'Read project context', object: '' }),
149
+ playbook_get: (a) => ({ verb: 'Read playbook', object: shortRight(str(a['name']) ?? '') }),
150
+ convention_get: (a) => ({ verb: 'Read convention', object: shortRight(str(a['field']) ?? '') }),
151
+ cca_merge: (a) => ({ verb: 'Merged CCA', object: fileCount(a['inputs']) }),
152
+ upgrade_merge_progress: (a) => ({ verb: 'Merged progress', object: fileCount(a['inputs']) }),
153
+ monitor_emit: (a) => ({ verb: 'Checked background', object: shortRight(str(a['bg_id']) ?? '') }),
154
+ agent_run: (a) => ({ verb: 'Subagent', object: shortRight(str(a['skill']) ?? '') }),
155
+ schedule_draft_create: (a) => ({ verb: 'Drafted schedule', object: shortRight(str(a['when']) ?? '') }),
156
+ fiori_scaffold: (a) => ({ verb: 'Scaffolded app', object: shortLeft(str(a['basePath']) ?? '') }),
157
+ fiori_apply: (a) => ({ verb: 'Applied', object: shortRight([str(a['entry']), str(a['appDir'])].filter(Boolean).join(' · ')) }),
158
+ fiori_list: () => ({ verb: 'Listed patterns', object: '' }),
159
+ fiori_deploy_config: (a) => ({ verb: 'Deploy config', object: shortRight(str(a['name']) ?? str(a['appDir']) ?? '') }),
160
+ fiori_fe_extend: (a) => ({ verb: 'Extended app', object: shortRight([str(a['op']), str(a['basePath'])].filter(Boolean).join(' · ')) }),
161
+ fiori_preview: (a) => ({ verb: 'Preview', object: shortRight([str(a['action']) ?? 'start', str(a['appDir'])].filter(Boolean).join(' ')) }),
162
+ fiori_sample_search: (a) => ({ verb: 'Searched samples', object: shortRight(`"${str(a['text']) ?? ''}"`) }),
163
+ fiori_sample_get: (a) => ({ verb: 'Read sample', object: shortRight(str(a['name']) ?? '') }),
164
+ fiori_metadata_audit: (a) => ({ verb: 'Audited metadata', object: shortRight(str(a['servicePath']) ?? '') }),
165
+ extend_model_insert: (a) => ({ verb: 'Extended model', object: shortRight([str(a['kind']), str(a['field'])].filter(Boolean).join(' · ')) }),
166
+ system_capability: (a) => ({ verb: 'Checked capability', object: shortRight(str(a['capability']) ?? '') }),
167
+ };
168
+ /** True when the tool has its own label (not the generic fallback). */
169
+ export function hasToolLabel(name) {
170
+ return Object.hasOwn(RULES, name);
171
+ }
172
+ function fallback(name, a) {
173
+ // Only simple values: strings (display-cleaned; JSON-looking strings skipped),
174
+ // numbers and booleans. null, arrays and objects are never shown.
175
+ const simple = [];
176
+ for (const [k, v] of Object.entries(a)) {
177
+ if (simple.length >= 2)
178
+ break;
179
+ let shown;
180
+ if (typeof v === 'string') {
181
+ const c = str(v);
182
+ shown = c !== undefined && !/^[{[]/.test(c) ? c : undefined;
183
+ }
184
+ else if (typeof v === 'number' || typeof v === 'boolean') {
185
+ shown = String(v);
186
+ }
187
+ const key = clean(k);
188
+ if (shown !== undefined && key.length > 0)
189
+ simple.push(`${key}=${shown}`);
190
+ }
191
+ return { verb: shortRight(humanise(name), VERB_MAX), object: shortRight(simple.join(' · ')) };
192
+ }
193
+ export function toolLabel(name, args, resultContent) {
194
+ const rule = RULES[name];
195
+ try {
196
+ return rule ? rule(args ?? {}, resultContent) : fallback(name, args ?? {});
197
+ }
198
+ catch {
199
+ return fallback(name, {});
200
+ }
201
+ }
202
+ const PRESENT = {
203
+ Read: 'Reading', Wrote: 'Writing', Edited: 'Editing', Searched: 'Searching', Listed: 'Listing',
204
+ Ran: 'Running', Started: 'Starting', Activated: 'Activating', 'Checked syntax': 'Checking syntax',
205
+ 'Ran ATC on': 'Running ATC on', Deleted: 'Deleting', Snapshot: 'Taking snapshot of',
206
+ 'Where-used for': 'Finding where-used for', Created: 'Creating', Updated: 'Updating',
207
+ Queried: 'Querying', 'Checked inactive objects': 'Checking inactive objects',
208
+ 'Scaffolded app': 'Scaffolding app', 'Render check': 'Running render check',
209
+ 'Draft check': 'Running draft check', Approval: 'Waiting for approval',
210
+ Structure: 'Reading structure of', 'Change log': 'Reading change log of', 'Read dumps': 'Reading dumps',
211
+ 'API state': 'Checking API state of', 'ABAP docs': 'Reading ABAP docs for', 'Read includes': 'Reading includes of',
212
+ Compared: 'Comparing', 'Previewed change': 'Previewing change to', 'Ran unit tests': 'Running unit tests on',
213
+ 'OData test': 'Testing OData', 'Searched BAdIs': 'Searching BAdIs', 'Listed snapshots': 'Listing snapshots',
214
+ 'Read snapshot': 'Reading snapshot of', 'Cleaned snapshots': 'Cleaning snapshots',
215
+ 'Created transport': 'Creating transport', 'Transport for': 'Finding transport for',
216
+ 'Listed transports': 'Listing transports', Released: 'Releasing', 'Created domain': 'Creating domain',
217
+ 'Created data element': 'Creating data element', Locked: 'Locking', Unlocked: 'Unlocking',
218
+ Published: 'Publishing', Unpublished: 'Unpublishing', Message: 'Maintaining message',
219
+ 'Number range': 'Maintaining number range', Question: 'Asking', 'Updated todos': 'Updating todos',
220
+ 'Ran skill': 'Running skill', 'Read document': 'Reading document', Fetched: 'Fetching',
221
+ 'Searched web': 'Searching web', 'Read project context': 'Reading project context',
222
+ 'Read playbook': 'Reading playbook', 'Read convention': 'Reading convention', 'Merged CCA': 'Merging CCA',
223
+ 'Merged progress': 'Merging progress', 'Checked background': 'Checking background',
224
+ Subagent: 'Running subagent', 'Drafted schedule': 'Drafting schedule', Applied: 'Applying',
225
+ 'Listed patterns': 'Listing patterns', 'Deploy config': 'Writing deploy config', 'Extended app': 'Extending app',
226
+ Preview: 'Preview', 'Searched samples': 'Searching samples', 'Read sample': 'Reading sample',
227
+ 'Audited metadata': 'Auditing metadata', 'Extended model': 'Extending model',
228
+ 'Checked capability': 'Checking capability',
229
+ };
230
+ export function toolActivityLabel(name, args) {
231
+ const l = toolLabel(name, args);
232
+ const verb = PRESENT[l.verb] ?? `Running ${l.verb}`;
233
+ return l.object ? `${verb} ${l.object}` : verb;
234
+ }
235
+ export function foldOutput(text, keep) {
236
+ const all = text.split(/\r?\n/).map((l) => l.trimEnd()).filter((l) => l.trim().length > 0);
237
+ return { lines: all.slice(0, keep), hidden: Math.max(0, all.length - keep) };
238
+ }