@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
@@ -12,6 +12,10 @@ import chalk from 'chalk';
12
12
  import { readCostLog, summarise } from '../cost/cost-log.js';
13
13
  import { formatCost } from '../cost/pricing.js';
14
14
  import { creditsActive, formatCredits, globalCredits, CREDITS_PENDING, } from '../cost/credits.js';
15
+ import { panelHeading, panelRow } from '../renderer/panel-rows.js';
16
+ import { formatInfo } from '../renderer/notice-shape.js';
17
+ import { countLabel } from '../renderer/checklist-format.js';
18
+ import { sanitizeControlChars } from '../renderer/sanitize.js';
15
19
  /**
16
20
  * Sum the credits the proxy charged across the logged turns. Entries written
17
21
  * before credits existed (or on turns whose balance fetch failed) carry no
@@ -28,12 +32,18 @@ function summariseCredits(entries) {
28
32
  }
29
33
  return { total, unknown };
30
34
  }
31
- export async function runCostCommand(sessionId, credits = globalCredits.snapshot()) {
35
+ /** Model ids can come from server-side model governance: one line, no control chars. */
36
+ const modelText = (m) => sanitizeControlChars(m).replace(/\s*\r?\n\s*/g, ' ').trim();
37
+ /**
38
+ * `emit` (piece 2) — Ink passes the chunkEmitter so the panel lands in the
39
+ * transcript, not above the React frame. Default: console.log (classic).
40
+ */
41
+ export async function runCostCommand(sessionId, credits = globalCredits.snapshot(), emit = (l) => console.log(l)) {
32
42
  const entries = await readCostLog(sessionId);
33
43
  if (entries.length === 0) {
34
- console.log('');
35
- console.log(chalk.dim('No cost data yet for this session. Run a turn first.'));
36
- console.log('');
44
+ emit('');
45
+ emit(formatInfo('No cost data yet for this session. Run a turn first.').trimEnd());
46
+ emit('');
37
47
  return;
38
48
  }
39
49
  const s = summarise(entries);
@@ -43,47 +53,44 @@ export async function runCostCommand(sessionId, credits = globalCredits.snapshot
43
53
  // stay: they are a usage fact, not a COGS leak.
44
54
  const inCredits = creditsActive();
45
55
  const perTurn = summariseCredits(entries);
46
- // Header
47
- console.log('');
56
+ const turnsNote = `${s.turns} turn${s.turns === 1 ? '' : 's'}`;
57
+ emit('');
48
58
  if (inCredits) {
49
59
  const unit = credits?.unitLabel ?? 'credits';
50
60
  // Prefer the live session tracker (balance deltas observed this run);
51
61
  // fall back to the logged per-turn charges when it has nothing yet.
52
62
  const total = credits && credits.sessionTotal > 0 ? credits.sessionTotal : perTurn.total;
53
- console.log(chalk.bold(`Session usage so far: ${formatCredits(total, unit)}`) + chalk.dim(` (${s.turns} turn${s.turns === 1 ? '' : 's'})`));
54
- if (credits?.balance != null) {
55
- console.log(chalk.dim(`Remaining balance: ${formatCredits(credits.balance, unit)}`));
56
- }
63
+ emit(panelHeading(`Session usage so far: ${formatCredits(total, unit)}`, turnsNote));
64
+ if (credits?.balance != null)
65
+ emit(panelRow('Balance', formatCredits(credits.balance, unit)));
57
66
  }
58
67
  else {
59
- console.log(chalk.bold(`Session cost so far: ${formatCost(s.totalCost)}`) + chalk.dim(` (${s.turns} turn${s.turns === 1 ? '' : 's'})`));
68
+ emit(panelHeading(`Session cost so far: ${formatCost(s.totalCost)}`, turnsNote));
60
69
  }
61
- // Token breakdown
62
- console.log('');
63
- console.log(chalk.dim('Tokens:'));
64
- console.log(` input ${s.totalTokens.input.toLocaleString().padStart(12)}`);
65
- console.log(` output ${s.totalTokens.output.toLocaleString().padStart(12)}`);
66
- console.log(` cache read ${s.totalTokens.cacheRead.toLocaleString().padStart(12)}`);
67
- console.log(` cache create ${s.totalTokens.cacheCreate.toLocaleString().padStart(12)}`);
68
- // Per-model split (Phase 2 will make this interesting)
70
+ emit('');
71
+ emit(chalk.bold('Tokens'));
72
+ emit(panelRow('input', s.totalTokens.input.toLocaleString().padStart(12)));
73
+ emit(panelRow('output', s.totalTokens.output.toLocaleString().padStart(12)));
74
+ emit(panelRow('cache read', s.totalTokens.cacheRead.toLocaleString().padStart(12)));
75
+ emit(panelRow('cache create', s.totalTokens.cacheCreate.toLocaleString().padStart(12)));
69
76
  if (s.byModel.length > 1) {
70
- console.log('');
71
- console.log(chalk.dim('By model:'));
77
+ emit('');
78
+ emit(chalk.bold('By model:'));
72
79
  for (const m of s.byModel) {
73
80
  // Credits mode: turn counts only. A per-model dollar split is a direct
74
81
  // read on our per-model COGS.
75
82
  const tail = inCredits ? '' : ` ${formatCost(m.cost)}`;
76
- console.log(` ${m.model.padEnd(24)} ${m.turns} turn${m.turns === 1 ? '' : 's'}${tail}`);
83
+ emit(panelRow(modelText(m.model), `${m.turns} turn${m.turns === 1 ? '' : 's'}${tail}`, 24));
77
84
  }
78
85
  }
79
86
  else if (s.byModel[0]) {
80
- console.log('');
81
- console.log(chalk.dim(`Model: ${s.byModel[0].model}`));
87
+ emit('');
88
+ emit(panelRow('Model', modelText(s.byModel[0].model)));
82
89
  }
83
- // Recent turns — last 5 lines, most recent first
90
+ // Recent turns — last 5, most recent first. Piece 2: one count format, "N of M" (P1's countLabel).
84
91
  const recent = entries.slice(-5).reverse();
85
- console.log('');
86
- console.log(chalk.dim(`Recent turns (last ${recent.length}):`));
92
+ emit('');
93
+ emit(chalk.bold(`Recent turns · ${countLabel(recent.length, entries.length)}`));
87
94
  for (const e of recent) {
88
95
  const tok = (e.tokens.input + e.tokens.output).toLocaleString();
89
96
  // Credits mode: the per-turn figure is what the LEDGER charged (a balance
@@ -95,14 +102,14 @@ export async function runCostCommand(sessionId, credits = globalCredits.snapshot
95
102
  ? formatCredits(e.credits_charged, credits?.unitLabel ?? 'credits')
96
103
  : CREDITS_PENDING)
97
104
  : formatCost(e.cost);
98
- console.log(` turn ${String(e.turn).padStart(3)} ${tok.padStart(10)} tok ${tail}`);
105
+ emit(panelRow(`turn ${String(e.turn).padStart(3)}`, `${tok.padStart(10)} tok ${tail}`, 8));
99
106
  }
100
107
  if (inCredits && perTurn.unknown > 0) {
101
- console.log('');
102
- console.log(chalk.dim(`${perTurn.unknown} turn${perTurn.unknown === 1 ? '' : 's'} had no balance reading — `
103
- + `their usage is included in a later turn's figure.`));
108
+ emit('');
109
+ emit(formatInfo(`${perTurn.unknown} turn${perTurn.unknown === 1 ? '' : 's'} had no balance reading — `
110
+ + `their usage is included in a later turn's figure.`).trimEnd());
104
111
  }
105
- console.log('');
106
- console.log(chalk.dim(`Detailed JSONL: ~/.cspeach/sessions/${sessionId}-cost.jsonl`));
107
- console.log('');
112
+ emit('');
113
+ emit(formatInfo(`Detailed JSONL: ~/.cspeach/sessions/${sessionId}-cost.jsonl`).trimEnd());
114
+ emit('');
108
115
  }
@@ -1,5 +1,11 @@
1
1
  import chalk from 'chalk';
2
2
  import { SKILL_CATALOG } from '../skill-catalog.js';
3
+ import { costCommandDescription } from '../repl/builtin-commands.js';
4
+ import { panelHeading } from '../renderer/panel-rows.js';
5
+ import { paint } from '../renderer/theme.js';
6
+ import { uiWidth, truncateToWidth } from '../renderer/ui-width.js';
7
+ // I2's shortcuts table and note — one source for the `?` sheet and /help.
8
+ import { SHORTCUT_ROWS, SHORTCUTS_NOTE } from '../ui/widgets/shortcuts-sheet.js';
3
9
  // Workflow order: plan → understand → modify → build → upgrade → ship.
4
10
  const CATEGORY_ORDER = [
5
11
  'Pre-Coding',
@@ -9,13 +15,45 @@ const CATEGORY_ORDER = [
9
15
  'Upgrade / Migrate',
10
16
  'Ship / Release',
11
17
  ];
12
- const NAME_COL_PAD = 28; // padded width of "/skill-name"
13
- const NAME_GUTTER = 2; // leading 2-space indent
14
- const DESC_GUTTER = 1; // 1 space between name col and description
15
- const NAME_COL_WIDTH = NAME_GUTTER + NAME_COL_PAD + DESC_GUTTER; // 31
18
+ const NAME_COL_PAD = 28; // padded width of the command/skill column
19
+ const NAME_COL_WIDTH = 2 + NAME_COL_PAD + 1; // indent + column + gap = 31
16
20
  const MAX_DESC_LINES = 2;
17
- const MIN_DESC_WIDTH = 40;
18
- const DEFAULT_TERM_WIDTH = 100;
21
+ const MIN_DESC_WIDTH = 20;
22
+ const fixed = (s) => () => s;
23
+ /**
24
+ * Piece 2 §10 — /help grouped by what you want to do. Every palette command
25
+ * is listed (a test enforces it), plus /files and /ui look. The /cost text
26
+ * follows the cost display mode (credits for managed customers).
27
+ */
28
+ export const HELP_GROUPS = [
29
+ { title: 'Work on SAP', rows: [
30
+ { name: '/transport [TR]', description: fixed('View or set the transport for this session') },
31
+ { name: '/mode [advisory|gated|auto]', description: fixed('Set the write mode (Shift+Tab also cycles)') },
32
+ { name: '/safety-mode on|off|status', description: fixed('Turn the Forge Rule confirmation cards on or off') },
33
+ { name: '/rewind', description: fixed('Undo a write from this session (Esc Esc)') },
34
+ { name: '/reroute <skill>', description: fixed('Re-run your last prompt with a different skill') },
35
+ ] },
36
+ { title: 'Local files', rows: [
37
+ { name: '/files', description: fixed('Pick a saved project file and what to do with it') },
38
+ { name: '@<file>', description: fixed('Attach a file from this folder to your prompt') },
39
+ { name: '/export audit [file]', description: fixed('Write the signable session audit record to a file') },
40
+ ] },
41
+ { title: 'Session', rows: [
42
+ { name: '/new', description: fixed('Next prompt picks a skill afresh (also /reset)') },
43
+ { name: '/recap', description: fixed('Show the recap (last session, open tasks, plan)') },
44
+ { name: '/cost', description: costCommandDescription },
45
+ { name: '/context', description: fixed("Show what fills the model's context window") },
46
+ { name: '/compact', description: fixed('Shrink a long session and keep the facts') },
47
+ { name: '/help', description: fixed('This screen (also /skills)') },
48
+ { name: '/exit', description: fixed('Quit CSPeach (also /quit)') },
49
+ ] },
50
+ { title: 'Look and settings', rows: [
51
+ { name: '/ui [auto|ink]', description: fixed('Change the UI mode (applies after a relaunch)') },
52
+ { name: '/ui look [panels|legacy]', description: fixed('Switch between the panels and legacy looks') },
53
+ ] },
54
+ ];
55
+ /** /help lists the `?` key itself first, then I2's sheet rows in their order. */
56
+ const SHORTCUTS = [['?', 'shortcuts'], ...SHORTCUT_ROWS];
19
57
  /**
20
58
  * Word-wrap a description into at most `maxLines` lines of `width` chars each.
21
59
  * If content overflows, the last line is suffixed with `…`.
@@ -28,7 +66,6 @@ function wrapDescription(desc, width, maxLines) {
28
66
  while (i < words.length && lines.length < maxLines) {
29
67
  const word = words[i];
30
68
  if (current.length === 0) {
31
- // Word longer than width — hard-truncate
32
69
  current = word.length <= width ? word : word.slice(0, width);
33
70
  i++;
34
71
  }
@@ -41,65 +78,66 @@ function wrapDescription(desc, width, maxLines) {
41
78
  current = '';
42
79
  }
43
80
  }
44
- if (current.length > 0 && lines.length < maxLines) {
81
+ if (current.length > 0 && lines.length < maxLines)
45
82
  lines.push(current);
46
- }
47
- // Overflow → suffix last line with " …"
48
83
  if (i < words.length && lines.length > 0) {
49
84
  const last = lines[lines.length - 1];
50
- if (last.length + 2 <= width) {
51
- lines[lines.length - 1] = last + ' …';
52
- }
53
- else {
54
- lines[lines.length - 1] = last.slice(0, Math.max(0, width - 2)) + ' …';
55
- }
85
+ lines[lines.length - 1] = last.length + 2 <= width ? last + ' …' : last.slice(0, Math.max(0, width - 2)) + ' …';
56
86
  }
57
87
  return lines;
58
88
  }
59
- /**
60
- * Prints the flat-list /help screen grouped by category.
61
- * Cyan category headings, bold skill names, default-color descriptions.
62
- * Descriptions wrap to MAX_DESC_LINES lines max, aligned to the name column.
63
- */
89
+ const groupTitle = (t) => paint('sap', chalk.bold(t));
64
90
  /**
65
91
  * Phase D5 (2026-05-17) — accepts an optional emit callback so Ink mode
66
92
  * can route output through chunkEmitter instead of console.log (which
67
93
  * corrupts the React frame). Default keeps the classic-mode behaviour.
68
94
  */
69
95
  export function printHelp(emit = (s) => console.log(s)) {
70
- const termWidth = process.stdout.columns ?? DEFAULT_TERM_WIDTH;
71
- const descWidth = Math.max(MIN_DESC_WIDTH, termWidth - NAME_COL_WIDTH);
96
+ const width = uiWidth();
97
+ const descWidth = Math.max(MIN_DESC_WIDTH, width - NAME_COL_WIDTH);
72
98
  const continuationIndent = ' '.repeat(NAME_COL_WIDTH);
99
+ const row = (name, desc) => ` ${chalk.bold(truncateToWidth(name, NAME_COL_PAD).padEnd(NAME_COL_PAD))} ${paint('dim', truncateToWidth(desc, descWidth))}`;
73
100
  emit('');
74
- for (const category of CATEGORY_ORDER) {
75
- const skills = SKILL_CATALOG.filter((s) => s.category === category);
76
- if (skills.length === 0)
77
- continue;
78
- emit(chalk.cyan.bold(category));
79
- for (const skill of skills) {
80
- const nameCol = ('/' + skill.name).padEnd(NAME_COL_PAD);
81
- const descLines = wrapDescription(skill.description, descWidth, MAX_DESC_LINES);
82
- const firstLine = descLines[0] ?? '';
83
- emit(` ${chalk.bold(nameCol)} ${firstLine}`);
84
- for (let j = 1; j < descLines.length; j++) {
85
- emit(`${continuationIndent}${descLines[j]}`);
101
+ emit(panelHeading('Commands', 'type / to search'));
102
+ for (const group of HELP_GROUPS) {
103
+ emit('');
104
+ emit(groupTitle(group.title));
105
+ for (const r of group.rows)
106
+ emit(row(r.name, r.description()));
107
+ if (group.title === 'Work on SAP') {
108
+ // Skills live under "Work on SAP", one dim sub-heading per category.
109
+ for (const category of CATEGORY_ORDER) {
110
+ const skills = SKILL_CATALOG.filter((s) => s.category === category);
111
+ if (skills.length === 0)
112
+ continue;
113
+ emit(` ${paint('dim', category)}`);
114
+ for (const skill of skills) {
115
+ const descLines = wrapDescription(skill.description, descWidth, MAX_DESC_LINES);
116
+ emit(` ${chalk.bold(('/' + skill.name).padEnd(NAME_COL_PAD))} ${descLines[0] ?? ''}`);
117
+ for (let j = 1; j < descLines.length; j++)
118
+ emit(`${continuationIndent}${descLines[j]}`);
119
+ }
86
120
  }
87
121
  }
88
- emit('');
89
122
  }
90
- emit(chalk.cyan.bold('Shortcuts'));
91
- emit(` ${chalk.bold('/help'.padEnd(NAME_COL_PAD))} This screen`);
92
- emit(` ${chalk.bold('/skills'.padEnd(NAME_COL_PAD))} Same as /help`);
93
- emit(` ${chalk.bold('/ui [auto|ink|classic]'.padEnd(NAME_COL_PAD))} View or change UI rendering mode`);
94
- emit(` ${chalk.bold('/reroute <skill>'.padEnd(NAME_COL_PAD))} Re-run your last prompt with a different skill`);
95
- emit(` ${chalk.bold('/new'.padEnd(NAME_COL_PAD))} End the current Q&A chain — next prompt is classified fresh`);
96
- emit(` ${chalk.bold('/mode [advisory|gated|auto]'.padEnd(NAME_COL_PAD))} Cycle or set the session write mode (Shift+Tab / Alt+M also cycle)`);
97
- emit(` ${chalk.bold('/recap'.padEnd(NAME_COL_PAD))} Show the session recap (last session · open tasks · plan progress)`);
98
- emit(` ${chalk.bold('/rewind'.padEnd(NAME_COL_PAD))} Undo a write from this session — restore an object to a pre-write snapshot (Esc Esc)`);
99
- emit(` ${chalk.bold('/cost'.padEnd(NAME_COL_PAD))} Show this session's API spend so far ($ + token breakdown)`);
100
- emit(` ${chalk.bold('/context'.padEnd(NAME_COL_PAD))} Show what fills the model context window — a composition grid of bars`);
101
- emit(` ${chalk.bold('/export audit [file]'.padEnd(NAME_COL_PAD))} Write the signable session audit record to a markdown file`);
102
- emit(` ${chalk.bold('/compact'.padEnd(NAME_COL_PAD))} Summarise older turns into a compact context block — cuts subsequent turn cost by 80-90%`);
103
- emit(` ${chalk.bold('/exit'.padEnd(NAME_COL_PAD))} Quit CSPeach`);
123
+ emit('');
124
+ emit(groupTitle('Shortcuts'));
125
+ // Pack "key action" pairs into lines no wider than the UI. The plain
126
+ // length is tracked alongside the painted line (no ANSI stripping).
127
+ let line = ' ';
128
+ let lineLen = 1;
129
+ for (const [key, action] of SHORTCUTS) {
130
+ const cellLen = 2 + key.length + 2 + action.length;
131
+ if (lineLen + cellLen > width && lineLen > 1) {
132
+ emit(line);
133
+ line = ' ';
134
+ lineLen = 1;
135
+ }
136
+ line += ` ${chalk.bold(key)} ${paint('dim', action)}`;
137
+ lineLen += cellLen;
138
+ }
139
+ if (lineLen > 1)
140
+ emit(line);
141
+ emit(` ${paint('dim', truncateToWidth(SHORTCUTS_NOTE, width - 2))}`);
104
142
  emit('');
105
143
  }
@@ -7,7 +7,7 @@
7
7
  * 3) Print user_code prominently so customer can read it
8
8
  * 4) Poll /v1/auth/device/poll every interval until verified or maxAttempts
9
9
  * 5) Save api_key + customer_id to ~/.cspeach/auth.json
10
- * 6) Call GET /v1/me — if trial_mode === 'byok', prompt for Anthropic key
10
+ * 6) Call GET /v1/me — if trial_mode === 'byok', prompt for the user's own API key
11
11
  * 7) Re-save auth file with byokAnthropicKey if provided
12
12
  *
13
13
  * Dependencies (open, print, prompt) are injected so the command is
@@ -16,8 +16,16 @@
16
16
  import keychain from '../auth/keychain.js';
17
17
  import { writeAuthFile } from '../auth/auth-file.js';
18
18
  import { fetchOrgAnthropicKey } from '../auth/org-anthropic-key.js';
19
+ import { formatFailure, formatAttention, formatInfo } from '../renderer/notice-shape.js';
20
+ import { panelRow } from '../renderer/panel-rows.js';
21
+ import { paint } from '../renderer/theme.js';
22
+ import { getGlyphs } from '../renderer/glyphs.js';
23
+ import { sanitizeControlChars } from '../renderer/sanitize.js';
19
24
  const ANTHROPIC_KEYCHAIN_SERVICE = 'cspeach.anthropic';
20
25
  const ANTHROPIC_KEYCHAIN_ACCOUNT = 'api-key';
26
+ /** Server-supplied text (user code, URL, email, plan) is one line with no control characters. */
27
+ const serverText = (v) => sanitizeControlChars(String(v ?? '')).replace(/\s*\r?\n\s*/g, ' ').trim();
28
+ const done = (text) => `${paint('ok', getGlyphs().done)} ${text}`;
21
29
  export async function runLoginCommand(deps) {
22
30
  const proxy = deps.proxyUrl.replace(/\/$/, '');
23
31
  const pollInterval = deps.pollIntervalMs ?? 5000;
@@ -27,26 +35,26 @@ export async function runLoginCommand(deps) {
27
35
  try {
28
36
  const r = await fetch(`${proxy}/v1/auth/device/start`, { method: 'POST' });
29
37
  if (!r.ok) {
30
- deps.print(`error: device-start HTTP ${r.status}`);
38
+ deps.print(formatFailure({ what: 'Could not start sign-in', detail: [`HTTP ${r.status}`], next: 'check your network, then run cspeach login again' }).trimEnd());
31
39
  return 2;
32
40
  }
33
41
  start = await r.json();
34
42
  }
35
43
  catch (err) {
36
- deps.print(`error: device-start network failure — ${err instanceof Error ? err.message : String(err)}`);
44
+ deps.print(formatFailure({ what: 'Could not start sign-in', detail: [err instanceof Error ? err.message : String(err)], next: 'check your network, then run cspeach login again' }).trimEnd());
37
45
  return 2;
38
46
  }
39
47
  // 2 + 3) Browser + user_code. The next line auto-opens the browser, so
40
48
  // there's nothing for the user to do — just print honest "opening" wording.
41
49
  deps.print('');
42
- deps.print(`Your one-time code: ${start.user_code}`);
43
- deps.print(`Opening browser to: ${start.verification_url_complete}`);
50
+ deps.print(panelRow('One-time code', serverText(start.user_code), 14));
51
+ deps.print(panelRow('Opening', serverText(start.verification_url_complete), 14));
44
52
  deps.print('');
45
53
  try {
46
54
  await deps.open(start.verification_url_complete);
47
55
  }
48
56
  catch {
49
- deps.print(`(browser didn't auto-open — paste this URL manually: ${start.verification_url_complete})`);
57
+ deps.print(formatAttention({ text: "The browser didn't open", next: `open ${start.verification_url_complete} yourself` }).trimEnd());
50
58
  }
51
59
  // 4) Poll.
52
60
  let polled = null;
@@ -67,19 +75,19 @@ export async function runLoginCommand(deps) {
67
75
  if (errBody.error === 'authorization_pending')
68
76
  continue;
69
77
  if (errBody.error === 'expired_token') {
70
- deps.print('error: device code expired — run `cspeach login` again');
78
+ deps.print(formatFailure({ what: 'The sign-in code expired', next: 'cspeach login' }).trimEnd());
71
79
  return 3;
72
80
  }
73
- deps.print(`error: poll HTTP ${r.status} — ${errBody.error ?? 'unknown'}`);
81
+ deps.print(formatFailure({ what: 'Sign-in failed', detail: [`HTTP ${r.status} · ${errBody.error ?? 'unknown'}`], next: 'cspeach login' }).trimEnd());
74
82
  return 4;
75
83
  }
76
84
  catch (err) {
77
- deps.print(`(network blip — retrying — ${err instanceof Error ? err.message : String(err)})`);
85
+ deps.print(formatInfo(`Network blip, retrying (${err instanceof Error ? err.message : String(err)})`).trimEnd());
78
86
  continue;
79
87
  }
80
88
  }
81
89
  if (!polled) {
82
- deps.print('error: timed out waiting for browser authorization');
90
+ deps.print(formatFailure({ what: 'Timed out waiting for the browser sign-in', next: 'cspeach login' }).trimEnd());
83
91
  return 5;
84
92
  }
85
93
  // 5) Save (non-BYOK part).
@@ -88,13 +96,13 @@ export async function runLoginCommand(deps) {
88
96
  customerId: polled.customer_id,
89
97
  byokAnthropicKey: null,
90
98
  });
91
- deps.print(`✓ Saved API key to ~/.cspeach/auth.json`);
99
+ deps.print(done('Saved your CSPeach key to ~/.cspeach/auth.json'));
92
100
  // 6) /v1/me — figure out if BYOK is required.
93
101
  try {
94
102
  const r = await fetch(`${proxy}/v1/me`, { headers: { Authorization: `Bearer ${polled.api_key}` } });
95
103
  if (r.ok) {
96
104
  const me = await r.json();
97
- deps.print(`✓ Signed in as ${me.email} (${me.plan})`);
105
+ deps.print(done(`Signed in as ${serverText(me.email)} · ${serverText(me.plan)}`));
98
106
  if (me.trial_mode === 'byok' || me.plan === 'byo-key') {
99
107
  // Try to fetch the org's Anthropic key first — non-fatal if unavailable.
100
108
  const orgKey = await fetchOrgAnthropicKey(proxy, polled.api_key, {
@@ -104,14 +112,14 @@ export async function runLoginCommand(deps) {
104
112
  ((svc, acct, k) => keychain.setPassword(svc, acct, k));
105
113
  if (orgKey !== null) {
106
114
  await setKey(ANTHROPIC_KEYCHAIN_SERVICE, ANTHROPIC_KEYCHAIN_ACCOUNT, orgKey);
107
- deps.print('Using your organization\'s Anthropic key.');
115
+ deps.print(done("Using your organization's API key"));
108
116
  }
109
117
  else {
110
118
  // Fall back to the manual paste prompt — behaviour unchanged.
111
119
  deps.print('');
112
- deps.print('This account is BYOK — you provide your own Anthropic API key.');
113
- deps.print('Get one at https://console.anthropic.com/settings/keys');
114
- const ak = (await deps.prompt('Paste your Anthropic API key (sk-ant-...): ')).trim();
120
+ deps.print(formatInfo('This account is BYOK: you provide your own API key').trimEnd());
121
+ // The key-entry row — the one place the vendor and its key page are named.
122
+ const ak = (await deps.prompt('Paste your Anthropic API key (sk-ant-..., from https://console.anthropic.com/settings/keys): ')).trim();
115
123
  if (ak.startsWith('sk-ant-') && ak.length > 20) {
116
124
  // The non-BYOK save above just persisted these exact values,
117
125
  // so reading them back from disk would be redundant.
@@ -120,18 +128,19 @@ export async function runLoginCommand(deps) {
120
128
  customerId: polled.customer_id,
121
129
  byokAnthropicKey: ak,
122
130
  });
123
- deps.print('✓ Anthropic key saved');
131
+ deps.print(done('API key saved'));
124
132
  }
125
133
  else {
126
- deps.print('error: Anthropic key looks malformed (must start with sk-ant-). Skipped — run `cspeach login` again later.');
134
+ deps.print(formatFailure({ what: 'That does not look like your own API key', detail: ['it must start with sk-ant-', 'skipped'], next: 'run cspeach login again later' }).trimEnd());
127
135
  }
128
136
  }
129
137
  }
130
138
  }
131
139
  }
132
140
  catch (err) {
133
- deps.print(`(could not fetch /v1/me — login still succeeded — ${err instanceof Error ? err.message : String(err)})`);
141
+ deps.print(formatInfo(`Could not read your account details (${err instanceof Error ? err.message : String(err)}) · sign-in still worked`).trimEnd());
134
142
  }
143
+ deps.print(` ${paint('dim', 'Next:')} run cspeach to start`);
135
144
  return 0;
136
145
  }
137
146
  function sleep(ms) {
@@ -3,15 +3,19 @@
3
3
  * will fall back to the keytar-stored API key if one is present.
4
4
  */
5
5
  import { deleteAuthFile, readAuthFile } from '../auth/auth-file.js';
6
+ import { formatInfo } from '../renderer/notice-shape.js';
7
+ import { paint } from '../renderer/theme.js';
8
+ import { getGlyphs } from '../renderer/glyphs.js';
6
9
  export async function runLogoutCommand(deps) {
7
10
  const before = await readAuthFile();
8
11
  await deleteAuthFile();
9
12
  if (before) {
10
- deps.print('✓ Signed out — removed ~/.cspeach/auth.json');
11
- deps.print(' (the REPL will use the keychain-stored key if you have one)');
13
+ deps.print(`${paint('ok', getGlyphs().done)} Signed out · removed ~/.cspeach/auth.json`);
14
+ deps.print(` ${paint('dim', 'The REPL uses the keychain-stored key if you have one.')}`);
15
+ deps.print(` ${paint('dim', 'Next:')} cspeach login (to sign in again)`);
12
16
  }
13
17
  else {
14
- deps.print('Already signed out — no auth.json present.');
18
+ deps.print(formatInfo('Already signed out · no auth.json present').trimEnd());
15
19
  }
16
20
  return 0;
17
21
  }