@cspeach/cli 1.1.18 → 1.1.20

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 (252) hide show
  1. package/README.md +3 -4
  2. package/dist/agent/anthropic-provider.js +30 -10
  3. package/dist/agent/cache-keepalive.js +162 -0
  4. package/dist/agent/cold-prune.js +116 -0
  5. package/dist/agent/loop.js +804 -159
  6. package/dist/agent/provider-shape.js +263 -0
  7. package/dist/agent/providers/ai-hub-provider.js +17 -2
  8. package/dist/agent/providers/byok-provider.js +33 -3
  9. package/dist/agent/providers/local-provider.js +12 -2
  10. package/dist/agent/repair-partial.js +125 -7
  11. package/dist/agent/summarise-via-provider.js +6 -1
  12. package/dist/agent/system-prompt.js +38 -0
  13. package/dist/agent/tool-dispatch.js +66 -21
  14. package/dist/agent/tool-loading-pin.js +100 -0
  15. package/dist/agent/turn-error-ux.js +77 -26
  16. package/dist/agent/turn-stream.js +25 -3
  17. package/dist/approvals/adt-type.js +27 -0
  18. package/dist/approvals/advisory-prompt.js +48 -0
  19. package/dist/approvals/advisory-render.js +25 -11
  20. package/dist/approvals/approval-prompt.js +74 -24
  21. package/dist/approvals/jwt.js +2 -1
  22. package/dist/approvals/render.js +64 -36
  23. package/dist/approvals/risk-floor.js +53 -2
  24. package/dist/auth/api-key.js +30 -16
  25. package/dist/auth/keychain.js +0 -0
  26. package/dist/auth/me.js +25 -7
  27. package/dist/cli.js +36 -6
  28. package/dist/commands/auto-compact.js +33 -16
  29. package/dist/commands/compact.js +49 -11
  30. package/dist/commands/config-set.js +16 -3
  31. package/dist/commands/config-show.js +18 -5
  32. package/dist/commands/cost.js +54 -35
  33. package/dist/commands/help.js +88 -50
  34. package/dist/commands/login.js +28 -19
  35. package/dist/commands/logout.js +7 -3
  36. package/dist/commands/plan-audit.js +1 -0
  37. package/dist/commands/plan-chain.js +71 -77
  38. package/dist/commands/plan-continue.js +1 -0
  39. package/dist/commands/plan-resume.js +20 -12
  40. package/dist/commands/whoami.js +23 -9
  41. package/dist/config/loader.js +70 -5
  42. package/dist/cost/cost-log.js +62 -2
  43. package/dist/cost/pricing.js +11 -6
  44. package/dist/doctor/checks/forge-rules.js +13 -3
  45. package/dist/doctor/checks/keychain.js +6 -4
  46. package/dist/doctor/run.js +101 -20
  47. package/dist/index.js +5 -2
  48. package/dist/lib/graceful-exit.js +56 -0
  49. package/dist/lib/piped-prompt.js +65 -0
  50. package/dist/lib/spill-labels.js +13 -0
  51. package/dist/lock-contention.js +2 -2
  52. package/dist/models/resolve.js +93 -2
  53. package/dist/models/server-config.js +158 -3
  54. package/dist/one-shot.js +150 -45
  55. package/dist/projects/answer-blockers.js +6 -1
  56. package/dist/projects/handover-md.js +15 -14
  57. package/dist/projects/image-attachments.js +15 -2
  58. package/dist/projects/plan-run.js +97 -54
  59. package/dist/projects/promote-command.js +11 -2
  60. package/dist/projects/save-command.js +3 -2
  61. package/dist/projects/status.js +2 -1
  62. package/dist/projects/yes-no.js +12 -0
  63. package/dist/renderer/abap-inline.js +18 -14
  64. package/dist/renderer/answer-trim.js +69 -0
  65. package/dist/renderer/banners.js +8 -8
  66. package/dist/renderer/brand-settled.js +19 -0
  67. package/dist/renderer/change-card.js +156 -0
  68. package/dist/renderer/checklist-format.js +130 -0
  69. package/dist/renderer/color-mode.js +45 -0
  70. package/dist/renderer/error-detail.js +104 -0
  71. package/dist/renderer/fence-state.js +46 -0
  72. package/dist/renderer/footer-line.js +100 -0
  73. package/dist/renderer/glyphs.js +25 -0
  74. package/dist/renderer/goodbye.js +38 -0
  75. package/dist/renderer/legacy-palette.js +41 -0
  76. package/dist/renderer/look.js +30 -0
  77. package/dist/renderer/markdown.js +299 -92
  78. package/dist/renderer/notice-log.js +72 -0
  79. package/dist/renderer/notice-shape.js +48 -0
  80. package/dist/renderer/notices.js +40 -8
  81. package/dist/renderer/panel-rows.js +16 -0
  82. package/dist/renderer/pipeline.js +66 -0
  83. package/dist/renderer/progress-chatter.js +23 -17
  84. package/dist/renderer/rendering-mode.js +34 -0
  85. package/dist/renderer/routing-line.js +14 -0
  86. package/dist/renderer/sanitize.js +12 -0
  87. package/dist/renderer/severity.js +2 -7
  88. package/dist/renderer/startup-lines.js +154 -0
  89. package/dist/renderer/status-footer.js +52 -34
  90. package/dist/renderer/steering-echo.js +41 -0
  91. package/dist/renderer/syntax.js +31 -12
  92. package/dist/renderer/tables.js +5 -1
  93. package/dist/renderer/theme.js +44 -0
  94. package/dist/renderer/thinking-heartbeat.js +33 -2
  95. package/dist/renderer/todo-block.js +10 -49
  96. package/dist/renderer/tool-labels.js +269 -0
  97. package/dist/renderer/tool-widget.js +245 -51
  98. package/dist/renderer/trace.js +42 -0
  99. package/dist/renderer/transcript-flow.js +132 -0
  100. package/dist/renderer/tty.js +34 -1
  101. package/dist/renderer/ui-width.js +55 -0
  102. package/dist/renderer/verify-chain.js +4 -2
  103. package/dist/renderer/widget-fallback.js +58 -65
  104. package/dist/repl/bracketed-paste.js +7 -1
  105. package/dist/repl/builtin-commands.js +19 -8
  106. package/dist/repl/credential-handover-gate.js +28 -0
  107. package/dist/repl/current-transport.js +13 -0
  108. package/dist/repl/early-line-buffer.js +5 -2
  109. package/dist/repl/file-picker.js +54 -12
  110. package/dist/repl/ink-stdin-guard.js +66 -0
  111. package/dist/repl/inquirer-guard.js +59 -16
  112. package/dist/repl/inquirer-theme.js +27 -33
  113. package/dist/repl/paste-marker.js +19 -0
  114. package/dist/repl/plan-turn-end.js +20 -0
  115. package/dist/repl/post-turn-status.js +43 -11
  116. package/dist/repl/reroute-turn.js +21 -0
  117. package/dist/repl/reset-tty-stdin.js +44 -0
  118. package/dist/repl/restore-guard.js +22 -0
  119. package/dist/repl/resume-standalone.js +63 -0
  120. package/dist/repl/rule8-detector.js +11 -3
  121. package/dist/repl/safety-confirm.js +161 -93
  122. package/dist/repl/session-spend-line.js +8 -4
  123. package/dist/repl/themed-prompts.js +19 -0
  124. package/dist/repl/ui-look-command.js +75 -0
  125. package/dist/repl/update-method-preview-hook.js +48 -5
  126. package/dist/repl.js +863 -339
  127. package/dist/rewind/cli.js +3 -2
  128. package/dist/rewind/format.js +14 -9
  129. package/dist/rewind/restore.js +11 -0
  130. package/dist/router/classifier.js +47 -4
  131. package/dist/router/intent-extractor.js +24 -8
  132. package/dist/router/piped-routing.js +23 -0
  133. package/dist/router/resume-routing.js +16 -0
  134. package/dist/router/routing-failure.js +57 -0
  135. package/dist/sap/first-run-choice.js +1 -1
  136. package/dist/sap/onboarding.js +3 -2
  137. package/dist/sap/standalone-onboarding.js +1 -1
  138. package/dist/sap/system-info.js +4 -2
  139. package/dist/sap/unreachable.js +28 -0
  140. package/dist/sap-errors/clean-error-text.js +182 -0
  141. package/dist/session/interrupt-reason.js +39 -0
  142. package/dist/session/recap.js +36 -24
  143. package/dist/session/repin-model.js +18 -0
  144. package/dist/session/resume.js +104 -36
  145. package/dist/session/store.js +84 -28
  146. package/dist/session/user-prompt.js +21 -0
  147. package/dist/skill-catalog.js +7 -0
  148. package/dist/skills/bundled-skills.js +76 -69
  149. package/dist/skills/preamble.js +75 -0
  150. package/dist/skills/source-manifest.js +11 -1
  151. package/dist/standards/standards-init.js +1 -1
  152. package/dist/test-helpers/answer-any.js +46 -0
  153. package/dist/tools/approval.js +27 -11
  154. package/dist/tools/ask-question.js +47 -5
  155. package/dist/tools/filesystem/file-read.js +11 -1
  156. package/dist/tools/filesystem/file-write.js +19 -2
  157. package/dist/tools/fiori/fe-extend.js +16 -1
  158. package/dist/tools/fiori/fe-scaffold.js +105 -3
  159. package/dist/tools/fiori/html-escapes.js +28 -0
  160. package/dist/tools/fiori/metadata/audit.js +605 -0
  161. package/dist/tools/fiori/metadata/references.js +104 -0
  162. package/dist/tools/fiori/metadata/types.js +8 -0
  163. package/dist/tools/fiori/preview/app-guard.js +215 -0
  164. package/dist/tools/fiori/preview/env.js +193 -0
  165. package/dist/tools/fiori/preview/readiness.js +127 -0
  166. package/dist/tools/fiori/preview/registry.js +412 -0
  167. package/dist/tools/fiori/preview/start.js +469 -0
  168. package/dist/tools/fiori/samples/loader.js +20 -5
  169. package/dist/tools/fiori/scaffold.js +18 -6
  170. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  171. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  172. package/dist/tools/fiori/smoke/assertions.js +161 -24
  173. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  174. package/dist/tools/fiori/smoke/batch.js +273 -0
  175. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  176. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  177. package/dist/tools/fiori/smoke/driver.js +171 -4
  178. package/dist/tools/fiori/smoke/evidence.js +35 -0
  179. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  180. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  181. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  182. package/dist/tools/fiori/tools.js +651 -7
  183. package/dist/tools/fiori/ui5-version.js +16 -0
  184. package/dist/tools/index.js +8 -0
  185. package/dist/tools/local-build.js +6 -0
  186. package/dist/tools/result-spill.js +238 -0
  187. package/dist/tools/sap-read.js +74 -15
  188. package/dist/tools/sap-write.js +122 -10
  189. package/dist/tools/shell/shell_exec.js +39 -12
  190. package/dist/tools/subagent/adt-serial.js +33 -0
  191. package/dist/tools/subagent/agent_run.js +2 -0
  192. package/dist/tools/subagent/read_agent.js +178 -0
  193. package/dist/tools/subagent/reader-prompt.js +48 -0
  194. package/dist/tools/syntax-state.js +67 -0
  195. package/dist/tools/todo.js +26 -19
  196. package/dist/tools/tool-loading.js +255 -0
  197. package/dist/tools/tool-output-read.js +117 -0
  198. package/dist/tools/transport-fit.js +462 -0
  199. package/dist/tools/transport-resolution.js +3 -1
  200. package/dist/tools/transport.js +39 -8
  201. package/dist/tools/verify.js +184 -8
  202. package/dist/ui/app.js +284 -146
  203. package/dist/ui/approval-modal.js +41 -15
  204. package/dist/ui/ask-answer-text.js +17 -0
  205. package/dist/ui/body.js +25 -6
  206. package/dist/ui/card-slot.js +98 -0
  207. package/dist/ui/checklist.js +9 -0
  208. package/dist/ui/coaching-picker-classic.js +8 -5
  209. package/dist/ui/command-palette.js +6 -4
  210. package/dist/ui/confirm-request.js +18 -0
  211. package/dist/ui/context-grid.js +2 -1
  212. package/dist/ui/file-palette.js +6 -4
  213. package/dist/ui/files-card-emitter.js +19 -0
  214. package/dist/ui/footer-line.js +16 -0
  215. package/dist/ui/footer.js +105 -104
  216. package/dist/ui/input-wrap.js +122 -0
  217. package/dist/ui/interrupt.js +103 -0
  218. package/dist/ui/key-burst.js +117 -0
  219. package/dist/ui/line-resolution.js +4 -3
  220. package/dist/ui/live-area.js +71 -0
  221. package/dist/ui/login-banner.js +54 -43
  222. package/dist/ui/rewind-panel.js +76 -24
  223. package/dist/ui/role-props.js +47 -0
  224. package/dist/ui/sap-state-store.js +2 -1
  225. package/dist/ui/session-timeline.js +49 -3
  226. package/dist/ui/skill-picker.js +5 -47
  227. package/dist/ui/text-input.js +128 -24
  228. package/dist/ui/todo-emitter.js +19 -0
  229. package/dist/ui/turn-status-emitter.js +65 -2
  230. package/dist/ui/turn-status.js +16 -19
  231. package/dist/ui/use-card-request.js +44 -0
  232. package/dist/ui/widgets/ask-card.js +521 -0
  233. package/dist/ui/widgets/bar-chart.js +11 -5
  234. package/dist/ui/widgets/coaching-picker.js +50 -55
  235. package/dist/ui/widgets/confirm-card.js +193 -0
  236. package/dist/ui/widgets/dep-graph.js +3 -2
  237. package/dist/ui/widgets/diff-viewer.js +6 -5
  238. package/dist/ui/widgets/files-card.js +97 -0
  239. package/dist/ui/widgets/question-card.js +2 -1
  240. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  241. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  242. package/dist/ui/widgets/stack-frames.js +2 -1
  243. package/dist/upgrade-check.js +39 -8
  244. package/package.json +12 -2
  245. package/test-harness/fe-draft/app.ts +188 -0
  246. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  247. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  248. package/test-harness/fe-draft/routes.ts +224 -0
  249. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  250. package/test-harness/fe-draft/server.ts +225 -0
  251. package/test-harness/fe-draft/variants.ts +196 -0
  252. package/vitest.smoke-e2e.config.ts +20 -0
@@ -0,0 +1,156 @@
1
+ // src/renderer/change-card.ts
2
+ //
3
+ // Panels look (2026-09-26): one card per completed SAP write. The footer is
4
+ // the SAME honest chain as verify-chain.ts; "activated" only when the write
5
+ // result says the write itself activated (round 8 item 5). Diff lines only when old AND new source are known.
6
+ import { diffLines } from 'diff';
7
+ import { paint } from './theme.js';
8
+ import { getBoxChars } from '../ui/ascii-chars.js';
9
+ import { ANSI_RE } from './tool-labels.js';
10
+ export const MIN_CARD_WIDTH = 40;
11
+ const DIFF_LINES = 6;
12
+ const DETAIL_MAX = 60;
13
+ /** Myers diff is O(N·D) and synchronous: past this budget we show no diff
14
+ * at all (object + footer only), never a partial one. */
15
+ const DIFF_TIMEOUT_MS = 200;
16
+ const defaultDiffer = (a, b) => diffLines(a, b, { timeout: DIFF_TIMEOUT_MS });
17
+ // eslint-disable-next-line no-control-regex
18
+ const ANSI = /\x1b\[[0-9;]*m/g;
19
+ const visibleLen = (s) => s.replace(ANSI, '').length;
20
+ const cut = (s, max) => (s.length > max ? s.slice(0, Math.max(0, max - 1)) + '…' : s);
21
+ const flat = (s) => s.replace(/\s+/g, ' ').trim();
22
+ // Source lines go inside a fixed-width box: tabs and control characters
23
+ // (including stray escape sequences in the source) would break the border
24
+ // arithmetic or leak into the terminal, so they are neutralised here. Whole
25
+ // ANSI sequences go first (stripping only ESC would leave "[31m" visible);
26
+ // leading spaces survive so code indentation stays readable.
27
+ // eslint-disable-next-line no-control-regex
28
+ const clean = (s) => s.replace(ANSI_RE, '').replace(/\t/g, ' ').replace(/[\x00-\x1f\x7f]/g, '');
29
+ function footerParts(input) {
30
+ const parts = [];
31
+ if (input.snapshot === 'taken')
32
+ parts.push(`${paint('ok', '✓')} snapshot`);
33
+ else if (input.snapshot === 'new_object')
34
+ parts.push(paint('dim', '– snapshot (new object)'));
35
+ else if (input.snapshot === 'source_read_failed')
36
+ parts.push(paint('dim', `– snapshot (no source read${input.snapshotDetail ? ': ' + cut(flat(clean(input.snapshotDetail)), 40) : ''})`));
37
+ else
38
+ parts.push(paint('dim', '– snapshot (skipped)'));
39
+ parts.push(input.written ? `${paint('ok', '✓')} written` : `${paint('bad', '✗')} written`);
40
+ if (input.syntax === 'not_checked') {
41
+ parts.push(paint('dim', '– syntax (checked at activation)'));
42
+ parts.push(paint('dim', '· activate pending'));
43
+ }
44
+ else if (input.syntax.ok) {
45
+ parts.push(`${paint('ok', '✓')} syntax`);
46
+ // Round 8 item 5 — the real activation state from the write result.
47
+ const a = input.activation;
48
+ if (!a)
49
+ parts.push(paint('dim', '· activate pending'));
50
+ else if (a.state === 'active')
51
+ parts.push(`${paint('ok', '✓')} activated`);
52
+ else if (a.state === 'still_inactive')
53
+ parts.push(`${paint('bad', '✗')} still inactive`);
54
+ else if (a.state === 'unverified')
55
+ parts.push(paint('warn', '· activation unverified'));
56
+ else
57
+ parts.push(`${paint('bad', '✗')} activation${a.detail ? ' ' + paint('bad', `(${cut(flat(clean(a.detail)), DETAIL_MAX)})`) : ''}`);
58
+ }
59
+ else {
60
+ // Activation is blocked — say so; a pending step would lie.
61
+ parts.push(`${paint('bad', '✗')} syntax ${paint('bad', `(${cut(flat(clean(input.syntax.errors[0] ?? 'syntax errors')), DETAIL_MAX)})`)}`);
62
+ parts.push(paint('dim', '· activation blocked'));
63
+ }
64
+ return parts;
65
+ }
66
+ /** Changed lines for the card. Exported for tests only. */
67
+ export function diffBody(card, differ = defaultDiffer) {
68
+ if (card.oldSource === undefined || card.newSource === undefined)
69
+ return { lines: [], hidden: 0 };
70
+ const changed = [];
71
+ // SAP serves CRLF source while written source is usually LF: compare on
72
+ // normalised text so an EOL-only difference is never shown as a change.
73
+ const norm = (s) => s.replace(/\r\n?/g, '\n').replace(/\n$/, '');
74
+ const parts = differ(norm(card.oldSource), norm(card.newSource));
75
+ if (parts === undefined)
76
+ return { lines: [], hidden: 0 }; // gave up: no diff, never a partial one
77
+ for (const part of parts) {
78
+ if (!part.added && !part.removed)
79
+ continue;
80
+ for (const l of part.value.replace(/\r?\n$/, '').split(/\r?\n/))
81
+ changed.push(`${part.added ? '+' : '-'} ${clean(l)}`);
82
+ }
83
+ return { lines: changed.slice(0, DIFF_LINES), hidden: Math.max(0, changed.length - DIFF_LINES) };
84
+ }
85
+ export function buildChangeCard(input, width, box = getBoxChars()) {
86
+ const { card } = input;
87
+ const title = clean(`Changed ${card.object} · ${card.what}`);
88
+ const parts = footerParts(input);
89
+ const chain = parts.join(' ');
90
+ const transportText = card.transport ? `· ${clean(card.transport)}` : '';
91
+ const body = diffBody(card);
92
+ if (width < MIN_CARD_WIDTH) {
93
+ const out = [cut(title, width)];
94
+ for (const l of body.lines)
95
+ out.push(paint(l.startsWith('+') ? 'ok' : 'bad', cut(l, width)));
96
+ if (body.hidden > 0)
97
+ out.push(paint('dim', cut(`… +${body.hidden} lines`, width)));
98
+ // Narrow terminal: the same honest parts, same order, one per line, each
99
+ // cut to the width so nothing wraps (colour dropped only on a cut part).
100
+ for (const p of parts)
101
+ out.push(visibleLen(p) > width ? cut(p.replace(ANSI, ''), width) : p);
102
+ if (card.transport)
103
+ out.push(paint('dim', cut(`· ${clean(card.transport)}`, width)));
104
+ return out.join('\n');
105
+ }
106
+ const b = box;
107
+ const inner = width - 4; // "│ " + content + " │"
108
+ const row = (content) => {
109
+ const pad = Math.max(0, inner - visibleLen(content));
110
+ return `${paint('frame', b.v)} ${content}${' '.repeat(pad)} ${paint('frame', b.v)}`;
111
+ };
112
+ // Top: "╭─ " + title + " " + fill + "╮" = width → fill = width - 5 - titleLen (≥ 1).
113
+ const titleText = cut(title, width - 6);
114
+ const top = paint('frame', b.tl + b.h) + ' ' + titleText + ' ' + paint('frame', b.h.repeat(width - 5 - titleText.length) + b.tr);
115
+ const lines = [top];
116
+ for (const l of body.lines)
117
+ lines.push(row(paint(l.startsWith('+') ? 'ok' : 'bad', cut(l, inner))));
118
+ if (body.hidden > 0)
119
+ lines.push(row(paint('dim', ` … +${body.hidden} lines`)));
120
+ lines.push(...footerLines(parts, chain, transportText, width, b, row));
121
+ return lines.join('\n');
122
+ }
123
+ /**
124
+ * Footer placement. Preferred: the whole footer on the bottom border. Too
125
+ * wide: the chain stays on the border and the transport becomes a row inside
126
+ * the box (a cut transport number would be wrong, a dropped one hides where
127
+ * the change went). Chain still too wide: the chain parts become rows INSIDE
128
+ * the box (same parts, same order, each cut to the inner width, like the
129
+ * narrow path) and a plain bottom border closes it — "activate pending" and
130
+ * the transport are never dropped.
131
+ */
132
+ function footerLines(parts, chain, transportText, width, b, row) {
133
+ const room = width - 7;
134
+ const chainLen = visibleLen(chain);
135
+ const inner = width - 4;
136
+ if (transportText) {
137
+ const full = chain + ' ' + paint('dim', transportText);
138
+ if (visibleLen(full) <= room)
139
+ return [bottom(full, width, b)];
140
+ // Chain fits the border, the transport does not: transport as a boxed row.
141
+ if (chainLen <= room)
142
+ return [row(paint('dim', cut(transportText, inner))), bottom(chain, width, b)];
143
+ }
144
+ if (chainLen <= room)
145
+ return [bottom(chain, width, b)];
146
+ const out = parts.map((p) => row(visibleLen(p) > inner ? cut(p.replace(ANSI, ''), inner) : p));
147
+ if (transportText)
148
+ out.push(row(paint('dim', cut(transportText, inner))));
149
+ out.push(paint('frame', b.bl + b.h.repeat(width - 2) + b.br));
150
+ return out;
151
+ }
152
+ /** Bottom: "╰──" + " " + footer + " " + fill + "╯" = width → fill = width - 6 - footerLen (≥ 1). Callers guarantee the footer fits. */
153
+ function bottom(footer, width, b) {
154
+ const fill = width - 6 - visibleLen(footer);
155
+ return `${paint('frame', b.bl + b.h + b.h)} ${footer} ${paint('frame', b.h.repeat(fill) + b.br)}`;
156
+ }
@@ -0,0 +1,130 @@
1
+ // src/renderer/checklist-format.ts
2
+ //
3
+ // Piece 2 (spec §4) — the plan checklist, as pure strings. The live Ink
4
+ // checklist (ui/checklist.tsx), the classic/headless scrollback block
5
+ // (renderer/todo-block.ts) and the turn-end close line all come from here, so
6
+ // the three can never disagree about counts, glyphs or which step is current.
7
+ import chalk from 'chalk';
8
+ import { getGlyphs } from './glyphs.js';
9
+ import { paint } from './theme.js';
10
+ import { sanitizeControlChars } from './sanitize.js';
11
+ import { truncateToWidth } from './ui-width.js';
12
+ import { shouldUseInk, isHeadless } from './tty.js';
13
+ /** Item rows shown before folding (spec §4: "At most 7 rows"). The header is extra. */
14
+ export const CHECKLIST_MAX_ROWS = 7;
15
+ const INDENT = ' ';
16
+ /** The one count format used everywhere (spec §4): "N of M". */
17
+ export function countLabel(done, total) {
18
+ return `${done} of ${total}`;
19
+ }
20
+ /**
21
+ * The live step: the LAST in_progress item, or -1. Same rule as todo_set's
22
+ * `in_progress` echo (tools/todo.ts, pinned by todo-current-pin.test.ts) —
23
+ * models batch transitions, so an earlier in_progress item is stale.
24
+ */
25
+ export function currentTodoIndex(todos) {
26
+ for (let i = todos.length - 1; i >= 0; i--) {
27
+ if (todos[i].status === 'in_progress')
28
+ return i;
29
+ }
30
+ return -1;
31
+ }
32
+ /** True when nothing is left to show: an empty list, or every item completed. */
33
+ export function isPlanFinished(todos) {
34
+ return todos.length === 0 || todos.every((t) => t.status === 'completed');
35
+ }
36
+ function doneCount(todos) {
37
+ return todos.filter((t) => t.status === 'completed').length;
38
+ }
39
+ function allRows(todos, cur) {
40
+ return todos.map((t, i) => {
41
+ if (t.status === 'completed')
42
+ return { kind: 'done', text: t.text };
43
+ return { kind: i === cur ? 'current' : 'pending', text: t.text };
44
+ });
45
+ }
46
+ /**
47
+ * Folded view (spec §4): "✓ K done", then the current row and the rows after
48
+ * it, then "… N more". Open items BEFORE the current one (rare — a stale
49
+ * pending step) come after the ones that follow it. `cap` is the item-row cap.
50
+ */
51
+ function foldedRows(todos, cur, cap) {
52
+ const done = doneCount(todos);
53
+ const open = todos.map((t, i) => ({ t, i })).filter((x) => x.t.status !== 'completed');
54
+ const pivot = cur >= 0 ? cur : (open[0]?.i ?? 0);
55
+ const ordered = [...open.filter((x) => x.i >= pivot), ...open.filter((x) => x.i < pivot)];
56
+ const toRow = (x) => ({ kind: x.i === cur ? 'current' : 'pending', text: x.t.text });
57
+ if (cap <= 1) {
58
+ return ordered.length > 0 ? [toRow(ordered[0])] : [{ kind: 'summary', n: done }];
59
+ }
60
+ const head = done > 0 && cap >= 3 ? [{ kind: 'summary', n: done }] : [];
61
+ const slots = Math.max(1, cap - head.length - 1); // one row kept for "… N more"
62
+ const shown = ordered.slice(0, slots).map(toRow);
63
+ const more = ordered.length - shown.length;
64
+ const rows = [...head, ...shown];
65
+ if (more > 0)
66
+ rows.push({ kind: 'more', n: more });
67
+ return rows;
68
+ }
69
+ function renderRow(r, width) {
70
+ const g = getGlyphs();
71
+ const fit = (s) => truncateToWidth(INDENT + s, width);
72
+ switch (r.kind) {
73
+ case 'done': return paint('dim', fit(`${g.done} ${r.text}`));
74
+ case 'current': return chalk.bold(paint('sap', fit(`${g.current} ${r.text}`)));
75
+ case 'pending': return paint('dim', fit(`${g.pending} ${r.text}`));
76
+ case 'summary': return paint('dim', fit(`${g.done} ${r.n} done`));
77
+ case 'more': return paint('dim', fit(`${g.more} ${r.n} more`));
78
+ }
79
+ }
80
+ /**
81
+ * The checklist as display lines: `Plan · N of M` header, then rows.
82
+ * - maxRows: cap on ITEM rows (header excluded). Not expanded → also capped
83
+ * at CHECKLIST_MAX_ROWS; expanded → only maxRows (the §2 height cap) applies.
84
+ * - foldedToOne: exactly one line (used while a card is open).
85
+ * Todo text comes from the model: control characters are neutralised
86
+ * (sanitizeControlChars) and every line is truncated to `width` before it is
87
+ * painted, as the question cards do.
88
+ */
89
+ export function formatChecklist(list, o) {
90
+ if (list.length === 0)
91
+ return [];
92
+ const todos = list.map((t) => ({ ...t, text: sanitizeControlChars(t.text) }));
93
+ const width = Math.max(1, Math.floor(o.width));
94
+ const cur = currentTodoIndex(todos);
95
+ const count = `Plan · ${countLabel(doneCount(todos), todos.length)}`;
96
+ if (o.foldedToOne) {
97
+ const tail = cur >= 0 ? ` · ${getGlyphs().current} ${todos[cur].text}` : '';
98
+ return [chalk.bold(truncateToWidth(count + tail, width))];
99
+ }
100
+ const cap = Math.max(1, o.expanded ? o.maxRows : Math.min(o.maxRows, CHECKLIST_MAX_ROWS));
101
+ const rows = todos.length <= cap ? allRows(todos, cur) : foldedRows(todos, cur, cap);
102
+ return [chalk.bold(truncateToWidth(count, width)), ...rows.map((r) => renderRow(r, width))];
103
+ }
104
+ /**
105
+ * The one line a turn leaves in scrollback when its plan changed (spec §4):
106
+ * `✓ Plan · M of M done` (ok) when finished, else `Plan · N of M done` (dim).
107
+ */
108
+ export function formatPlanClose(todos) {
109
+ const done = doneCount(todos);
110
+ const total = todos.length;
111
+ if (total > 0 && done === total) {
112
+ return paint('ok', `${getGlyphs().done} Plan · ${countLabel(done, total)} done`);
113
+ }
114
+ return paint('dim', `Plan · ${countLabel(done, total)} done`);
115
+ }
116
+ let modeOverride;
117
+ /** Test seam — force a mode; `undefined` restores detection. */
118
+ export function setChecklistModeForTest(m) {
119
+ modeOverride = m;
120
+ }
121
+ export function checklistRenderMode() {
122
+ if (modeOverride)
123
+ return modeOverride;
124
+ // Headless first: a one-shot or piped run under a `ui.rendering = "ink"`
125
+ // override still has a stdout TTY, so shouldUseInk() is true, but no live
126
+ // frame is mounted — the plan must print per update.
127
+ if (isHeadless())
128
+ return 'per-update';
129
+ return shouldUseInk() ? 'live' : 'turn-end';
130
+ }
@@ -0,0 +1,45 @@
1
+ // src/renderer/color-mode.ts
2
+ //
3
+ // NO_COLOR (CLI look piece 2, spec §1.2). One switch: applyColorMode() at
4
+ // startup maps NO_COLOR (set and non-empty) to chalk.level = 0. Colour
5
+ // sources that are NOT the app's chalk (cli-table3's @colors/colors,
6
+ // cli-highlight's chalk 4, the raw legacy zebra escape) key on colorOff(),
7
+ // i.e. chalk.level === 0 — so NO_COLOR, a piped stream and a test that sets
8
+ // chalk.level all behave the same, and the legacy pins (chalk.level = 3)
9
+ // cannot be flipped by a CI NO_COLOR. Ink components use ui/role-props.ts.
10
+ import chalk from 'chalk';
11
+ let override;
12
+ export function isNoColor() {
13
+ if (override !== undefined)
14
+ return override;
15
+ const v = process.env['NO_COLOR'];
16
+ return typeof v === 'string' && v !== '';
17
+ }
18
+ /** Tests only. `undefined` clears the override. */
19
+ export function setNoColorForTest(v) {
20
+ override = v;
21
+ }
22
+ /**
23
+ * Call once at startup, before the first line of output.
24
+ *
25
+ * Under NO_COLOR it also sets FORCE_COLOR=0: chalk 5's bundled supports-color
26
+ * ignores NO_COLOR but honours FORCE_COLOR, so a chalk instance loaded AFTER
27
+ * this call (Ink and ink-text-input, if a package manager gives them their own
28
+ * copy) starts at level 0 too. That only works because cli.ts main() calls
29
+ * this before its lazy `await import('./repl.js')` / `('./one-shot.js')`, and
30
+ * cli.ts's static import graph does not reach Ink.
31
+ */
32
+ export function applyColorMode() {
33
+ if (isNoColor()) {
34
+ chalk.level = 0;
35
+ process.env['FORCE_COLOR'] = '0';
36
+ }
37
+ }
38
+ /** True when colour output is off (NO_COLOR applied, non-colour stream, or level 0). */
39
+ export function colorOff() {
40
+ return chalk.level === 0;
41
+ }
42
+ /** cli-table3 style: the coloured one, or empty head/border styles when colour is off. */
43
+ export function tableStyle(colored) {
44
+ return colorOff() ? { head: [], border: [] } : colored;
45
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * humanErrorDetail — readable detail lines for a failed tool row.
3
+ *
4
+ * Live fix 2026-09-26: a failed `sap_activate` row printed its raw JSON
5
+ * result (`{"success":false,"activation_results":[{"name":"ZC_X",…`). The
6
+ * row detail must say what went wrong, in words:
7
+ * - activation results → one line per failed object: `ZC_X: <error text>`
8
+ * (error-severity messages first; still-inactive objects when SAP gave
9
+ * no message);
10
+ * - other JSON → its `message` / `reason` field, or the existing
11
+ * error/detail + ADT-exception summary (formatToolErrorSummary);
12
+ * - anything still JSON-shaped → collapsed to ONE line (the caller
13
+ * truncates it to the width).
14
+ * Plain text passes through line by line. The model's tool result is not
15
+ * touched — this only shapes the on-screen row.
16
+ */
17
+ import { formatToolErrorSummary } from '../sap-errors/parse-adt-exception.js';
18
+ import { cleanErrorText, looksLikeMarkup } from '../sap-errors/clean-error-text.js';
19
+ function messageText(m) {
20
+ if (typeof m === 'string')
21
+ return m;
22
+ if (m && typeof m === 'object') {
23
+ const o = m;
24
+ for (const k of ['shortText', 'text', 'message', 'longText']) {
25
+ const v = o[k];
26
+ if (typeof v === 'string' && v.trim())
27
+ return v;
28
+ }
29
+ }
30
+ return '';
31
+ }
32
+ function isErrorMessage(m) {
33
+ if (typeof m === 'string')
34
+ return true;
35
+ const sev = m && typeof m === 'object' ? String(m['severity'] ?? m['type'] ?? '') : '';
36
+ return /^(e|error|a|abort|fatal)$/i.test(sev);
37
+ }
38
+ function activationLines(obj) {
39
+ const results = obj['activation_results'];
40
+ if (!Array.isArray(results))
41
+ return [];
42
+ const lines = [];
43
+ for (const r of results) {
44
+ if (!r || typeof r !== 'object' || r['success'] === true)
45
+ continue;
46
+ const msgs = Array.isArray(r['messages']) ? r['messages'] : [];
47
+ const ordered = [...msgs.filter(isErrorMessage), ...msgs.filter((m) => !isErrorMessage(m))];
48
+ const text = ordered.map(messageText).find((t) => t.trim()) ?? 'activation failed';
49
+ lines.push(`${String(r['name'] ?? '?')}: ${text}`);
50
+ }
51
+ if (lines.length === 0) {
52
+ const still = obj['still_inactive'];
53
+ if (Array.isArray(still))
54
+ for (const s of still)
55
+ lines.push(`${String(s)} still inactive`);
56
+ const unv = obj['unverifiable'];
57
+ if (Array.isArray(unv)) {
58
+ for (const u of unv)
59
+ lines.push(`${String(u?.['object'] ?? '?')} not verified: ${String(u?.['reason'] ?? '')}`.trim());
60
+ }
61
+ }
62
+ if (lines.length === 0 && typeof obj['note'] === 'string')
63
+ lines.push(obj['note']);
64
+ return lines;
65
+ }
66
+ const oneLine = (s) => s.replace(/\s+/g, ' ').trim();
67
+ function detailLines(raw) {
68
+ const t = raw.trim();
69
+ if (t.startsWith('{')) {
70
+ let obj;
71
+ try {
72
+ obj = JSON.parse(t);
73
+ }
74
+ catch {
75
+ obj = undefined;
76
+ }
77
+ if (obj && typeof obj === 'object') {
78
+ const act = activationLines(obj);
79
+ if (act.length > 0)
80
+ return act;
81
+ for (const k of ['message', 'reason']) {
82
+ const v = obj[k];
83
+ if (typeof v === 'string' && v.trim() && typeof obj['error'] !== 'string' && typeof obj['detail'] !== 'string') {
84
+ return v.split('\n');
85
+ }
86
+ }
87
+ }
88
+ const summary = formatToolErrorSummary(raw);
89
+ if (summary.trim().startsWith('{'))
90
+ return [oneLine(summary)];
91
+ return summary.split('\n');
92
+ }
93
+ return raw.split('\n');
94
+ }
95
+ // Fix round 3 (item 10): a thrown sap-client error reaches the tool result as
96
+ // String(err) — "SapNotFoundError: Object not found: …". The class name is
97
+ // developer noise on a failure row; the message after it is the reason.
98
+ const SAP_ERROR_CLASS = /^Sap[A-Za-z]*Error:\s+/;
99
+ export function humanErrorDetail(raw) {
100
+ // An ICM HTML error page inside a message: no tags or CSS on screen.
101
+ return detailLines(raw)
102
+ .map((l) => l.replace(SAP_ERROR_CLASS, ''))
103
+ .map((l) => (looksLikeMarkup(l) ? cleanErrorText(l, 400) : l));
104
+ }
@@ -0,0 +1,46 @@
1
+ // src/renderer/fence-state.ts
2
+ //
3
+ // Piece 2 §11.2 — while the markdown renderer holds back an unfinished fenced
4
+ // code block (it never flushes inside a fence), the answer looks frozen. The
5
+ // status strip says "writing code…" for exactly that stretch.
6
+ //
7
+ // One CommonMark fence rule, shared with pipeline.ts panelsFlushPoint so the
8
+ // label and the hold-back can never disagree: ``` or ~~~, the closer uses the
9
+ // same character, is at least as long, and has nothing after it; a ``` line
10
+ // with a backtick in its info string is not a fence. Lines are taken as-is
11
+ // (a trailing '\r' is not stripped, exactly as panelsFlushPoint has always
12
+ // behaved). Only complete lines are scanned.
13
+ /** CommonMark fence line: up to 3 spaces, then 3+ backticks or tildes. */
14
+ export const FENCE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
15
+ /**
16
+ * Advance the fence state by one complete line. `consumed` is true when the
17
+ * line is inside a fence (including its closer) or opens one, so the caller
18
+ * must not treat it as ordinary text (e.g. a blank-line flush point).
19
+ */
20
+ export function stepFence(fence, line) {
21
+ const f = FENCE_RE.exec(line);
22
+ if (fence) {
23
+ if (f && f[1][0] === fence.ch && f[1].length >= fence.len && f[2].trim() === '')
24
+ return { fence: undefined, consumed: true };
25
+ return { fence, consumed: true };
26
+ }
27
+ if (f && !(f[1][0] === '`' && f[2].includes('`')))
28
+ return { fence: { ch: f[1][0], len: f[1].length }, consumed: true };
29
+ return { fence: undefined, consumed: false };
30
+ }
31
+ export const WRITING_CODE_LABEL = 'writing code…';
32
+ export const WRITING_RESPONSE_LABEL = 'writing response';
33
+ export function hasOpenFence(text) {
34
+ let fence;
35
+ const lines = text.split('\n');
36
+ lines.pop(); // the last element is an incomplete line (or '' after a final \n)
37
+ for (const line of lines)
38
+ fence = stepFence(fence, line).fence;
39
+ return fence !== undefined;
40
+ }
41
+ export function nextWritingActivity(prevWritingCode, pending) {
42
+ const writingCode = hasOpenFence(pending);
43
+ if (writingCode === prevWritingCode)
44
+ return { writingCode, label: null };
45
+ return { writingCode, label: writingCode ? WRITING_CODE_LABEL : WRITING_RESPONSE_LABEL };
46
+ }
@@ -0,0 +1,100 @@
1
+ // src/renderer/footer-line.ts
2
+ //
3
+ // Piece 2 (spec §8) — the one footer line under the input box, and the
4
+ // one-line per-turn receipt. Pure strings (roles via theme.ts), so the Ink
5
+ // component, the classic receipt and one-shot share one formatter.
6
+ import stringWidth from 'string-width';
7
+ import { paint } from './theme.js';
8
+ import { truncateToWidth } from './ui-width.js';
9
+ import { sanitizeControlChars } from './sanitize.js';
10
+ import { formatCost } from '../cost/pricing.js';
11
+ import { formatCredits, CREDITS_PENDING, creditsActive } from '../cost/credits.js';
12
+ export const SHORTCUTS_HINT = '? shortcuts';
13
+ const SEP = ' · ';
14
+ /**
15
+ * The live spend segment (moved verbatim from ui/status-line.tsx). Single home
16
+ * for the credits/dollar switch: a managed customer sees the session credit
17
+ * total, BYOK/local see the session dollars — never the other.
18
+ */
19
+ export function formatSpendSegment(s) {
20
+ if (!s.creditsMode)
21
+ return `$${s.dollarSpendToday.toFixed(2)}`;
22
+ if (s.creditsPending && s.creditsSessionTotal === 0)
23
+ return CREDITS_PENDING;
24
+ return formatCredits(s.creditsSessionTotal, s.creditsUnitLabel);
25
+ }
26
+ /**
27
+ * Store → footer facts. F-L2-1: the transport falls back to the one a write
28
+ * actually stamped; UI fix round (1.1.20): then to one a transport lookup
29
+ * confirmed. A transport the user only typed is never shown.
30
+ */
31
+ export function footerStateFrom(s) {
32
+ return {
33
+ system: s.system || undefined,
34
+ user: s.user || undefined,
35
+ transport: (s.transport ?? s.lastWriteTransport ?? s.confirmedTransport) || undefined,
36
+ standalone: s.standalone === true,
37
+ mode: s.writeMode ?? '',
38
+ spend: formatSpendSegment(s),
39
+ };
40
+ }
41
+ const MODE_ROLE = { 'advisory-only': 'warn', 'approval-gated': 'ok', auto: 'sap' };
42
+ /**
43
+ * `<SYS> · <USER> · transport <TR> · <mode> · <spend>` on the left and a dim
44
+ * `? shortcuts` on the right, padded to exactly `width`. When it does not fit,
45
+ * the hint drops first, then the left side truncates.
46
+ */
47
+ export function formatFooterLine(s, width) {
48
+ const parts = [];
49
+ if (s.standalone) {
50
+ parts.push({ text: 'standalone', role: 'sap' }, { text: 'local files', role: 'dim' });
51
+ }
52
+ else {
53
+ // System, user and transport come from config / SAP responses (the
54
+ // write-stamped transport has no format check) — never let them drive
55
+ // the terminal.
56
+ parts.push(s.system ? { text: sanitizeControlChars(s.system), role: 'sap' } : { text: 'no system', role: 'dim' });
57
+ if (s.user)
58
+ parts.push({ text: sanitizeControlChars(s.user), role: 'dim' });
59
+ parts.push({ text: s.transport ? `transport ${sanitizeControlChars(s.transport)}` : 'no transport', role: 'dim' });
60
+ }
61
+ if (s.mode)
62
+ parts.push({ text: s.mode, role: MODE_ROLE[s.mode] ?? 'dim' });
63
+ if (s.spend)
64
+ parts.push({ text: s.spend, role: 'dim' });
65
+ const w = Math.max(1, Math.floor(width));
66
+ const plain = parts.map((p) => p.text).join(SEP);
67
+ const plainW = stringWidth(plain);
68
+ const hintW = stringWidth(SHORTCUTS_HINT);
69
+ if (plainW + 2 + hintW <= w) {
70
+ const left = parts.map((p) => paint(p.role, p.text)).join(paint('dim', SEP));
71
+ return left + ' '.repeat(w - plainW - hintW) + paint('dim', SHORTCUTS_HINT);
72
+ }
73
+ return paint('dim', truncateToWidth(plain, w));
74
+ }
75
+ /**
76
+ * This turn's spend for the receipt: credits charged this turn in credits mode
77
+ * (updating marker when unknown — never a dollar fallback), the turn's USD
78
+ * cost otherwise, '' when there is none.
79
+ */
80
+ export function turnSpendText(p) {
81
+ if (creditsActive()) {
82
+ const c = p.credits;
83
+ if (!c || c.chargedThisTurn === null)
84
+ return CREDITS_PENDING;
85
+ return formatCredits(c.chargedThisTurn, c.unitLabel);
86
+ }
87
+ return p.costThisTurn != null ? formatCost(p.costThisTurn) : '';
88
+ }
89
+ /** `/<skill> · <tokens> tokens · <this-turn spend> · <time>` — one dim line, no newline. */
90
+ export function formatTurnReceipt(p) {
91
+ const parts = [];
92
+ if (p.skill && p.skill !== '(none)')
93
+ parts.push(p.skill.startsWith('/') ? p.skill : `/${p.skill}`);
94
+ parts.push(`${p.tokens.toLocaleString('en-US')} tokens`);
95
+ if (p.spend)
96
+ parts.push(p.spend);
97
+ if (p.ms > 0)
98
+ parts.push(`${(p.ms / 1000).toFixed(1)}s`);
99
+ return paint('dim', parts.join(SEP));
100
+ }
@@ -0,0 +1,25 @@
1
+ // src/renderer/glyphs.ts
2
+ //
3
+ // One glyph set for every panels / new surface (CLI look piece 2, spec §1.3).
4
+ // The glyphs this set replaces are retired outside legacy-pinned code; the
5
+ // list lives in spec §1.3 and in test/unit/ui-guards.test.ts (not spelled out
6
+ // here, or the guard would flag this comment). ASCII fallback reuses the same
7
+ // legacy-Windows-console detection as ui/ascii-chars.ts.
8
+ import { shouldUseUnicodeBoxChars } from './tty.js';
9
+ export const UNICODE_GLYPHS = {
10
+ done: '✓', failed: '✗', attention: '!', info: '·', current: '◼', pending: '☐',
11
+ answered: '☒', cursor: '❯', trace: '?', tickOn: '[x]', tickOff: '[ ]', more: '…', arrow: '→',
12
+ };
13
+ export const ASCII_GLYPHS = {
14
+ done: 'v', failed: 'x', attention: '!', info: '-', current: '>', pending: '[ ]',
15
+ answered: '[x]', cursor: '>', trace: '?', tickOn: '[x]', tickOff: '[ ]', more: '...', arrow: '->',
16
+ };
17
+ let modeOverride;
18
+ /** Tests only: pin the set regardless of platform / terminal. `undefined` clears. */
19
+ export function setGlyphModeForTest(mode) {
20
+ modeOverride = mode;
21
+ }
22
+ export function getGlyphs() {
23
+ const unicode = modeOverride !== undefined ? modeOverride === 'unicode' : shouldUseUnicodeBoxChars();
24
+ return unicode ? UNICODE_GLYPHS : ASCII_GLYPHS;
25
+ }
@@ -0,0 +1,38 @@
1
+ // src/renderer/goodbye.ts
2
+ //
3
+ // Piece 2 §10: the /exit block — aligned label/value rows, spend in the
4
+ // current display mode (credits for managed users; the caller passes the
5
+ // value from formatSessionSpendValue), and ONE Next: line to resume.
6
+ // Session id and skill come from the session file, so they are sanitized
7
+ // before they reach the terminal.
8
+ import chalk from 'chalk';
9
+ import { panelRow } from './panel-rows.js';
10
+ import { paint } from './theme.js';
11
+ import { formatInfo } from './notice-shape.js';
12
+ import { sanitizeControlChars } from './sanitize.js';
13
+ const LABEL = 10; // 'Last skill'
14
+ /**
15
+ * `logDir` — the display path of this session's turn-stream folder, passed
16
+ * only when it EXISTS (fix round 3, item 11b: the block named
17
+ * `streams/<id>.md`, a file that never exists — the logs are
18
+ * `streams/<id>/<timestamp>.md`). Absent → no log line.
19
+ */
20
+ export function formatGoodbye(p) {
21
+ const id = sanitizeControlChars(p.sessionId);
22
+ const skill = p.lastSkill ? `/${sanitizeControlChars(p.lastSkill)}` : '(none)';
23
+ return [
24
+ '',
25
+ chalk.bold(`Session ${id}`),
26
+ panelRow('Turns', String(p.turns), LABEL),
27
+ panelRow('Last skill', skill, LABEL),
28
+ panelRow('Tokens', `${p.tokens.toLocaleString('en-US')} (input + output + cache)`, LABEL),
29
+ panelRow('Spend', p.spend, LABEL),
30
+ '',
31
+ // Round 6 review I1 — a session never written (no turns) has nothing to resume.
32
+ p.saved === false
33
+ ? ` ${paint('dim', 'No session saved (no turns)')}`
34
+ : ` ${paint('dim', 'Next:')} cspeach --resume ${id}`,
35
+ ...(p.logDir ? [formatInfo(`Full log: ${sanitizeControlChars(p.logDir)}`).trimEnd()] : []),
36
+ '',
37
+ ];
38
+ }