@cspeach/cli 1.1.18 → 1.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/README.md +3 -4
  2. package/dist/agent/loop.js +143 -25
  3. package/dist/agent/providers/local-provider.js +4 -1
  4. package/dist/agent/repair-partial.js +76 -19
  5. package/dist/agent/tool-dispatch.js +58 -21
  6. package/dist/agent/turn-error-ux.js +77 -26
  7. package/dist/agent/turn-stream.js +25 -3
  8. package/dist/approvals/adt-type.js +27 -0
  9. package/dist/approvals/advisory-prompt.js +48 -0
  10. package/dist/approvals/advisory-render.js +25 -11
  11. package/dist/approvals/approval-prompt.js +74 -24
  12. package/dist/approvals/jwt.js +2 -1
  13. package/dist/approvals/render.js +64 -36
  14. package/dist/approvals/risk-floor.js +53 -2
  15. package/dist/auth/api-key.js +30 -16
  16. package/dist/auth/keychain.js +0 -0
  17. package/dist/auth/me.js +25 -7
  18. package/dist/cli.js +25 -6
  19. package/dist/commands/compact.js +12 -9
  20. package/dist/commands/config-set.js +6 -2
  21. package/dist/commands/config-show.js +7 -5
  22. package/dist/commands/cost.js +41 -34
  23. package/dist/commands/help.js +88 -50
  24. package/dist/commands/login.js +28 -19
  25. package/dist/commands/logout.js +7 -3
  26. package/dist/commands/plan-chain.js +71 -77
  27. package/dist/commands/plan-continue.js +1 -0
  28. package/dist/commands/plan-resume.js +20 -12
  29. package/dist/commands/whoami.js +23 -9
  30. package/dist/config/loader.js +15 -3
  31. package/dist/cost/pricing.js +6 -5
  32. package/dist/doctor/checks/forge-rules.js +13 -3
  33. package/dist/doctor/checks/keychain.js +6 -4
  34. package/dist/doctor/run.js +101 -20
  35. package/dist/index.js +5 -2
  36. package/dist/lib/graceful-exit.js +56 -0
  37. package/dist/lib/piped-prompt.js +65 -0
  38. package/dist/lock-contention.js +2 -2
  39. package/dist/one-shot.js +135 -40
  40. package/dist/projects/answer-blockers.js +6 -1
  41. package/dist/projects/handover-md.js +15 -14
  42. package/dist/projects/plan-run.js +97 -54
  43. package/dist/projects/promote-command.js +11 -2
  44. package/dist/projects/save-command.js +3 -2
  45. package/dist/projects/status.js +2 -1
  46. package/dist/projects/yes-no.js +12 -0
  47. package/dist/renderer/abap-inline.js +18 -14
  48. package/dist/renderer/answer-trim.js +69 -0
  49. package/dist/renderer/banners.js +8 -8
  50. package/dist/renderer/brand-settled.js +19 -0
  51. package/dist/renderer/change-card.js +156 -0
  52. package/dist/renderer/checklist-format.js +130 -0
  53. package/dist/renderer/color-mode.js +45 -0
  54. package/dist/renderer/error-detail.js +104 -0
  55. package/dist/renderer/fence-state.js +46 -0
  56. package/dist/renderer/footer-line.js +96 -0
  57. package/dist/renderer/glyphs.js +25 -0
  58. package/dist/renderer/goodbye.js +38 -0
  59. package/dist/renderer/legacy-palette.js +41 -0
  60. package/dist/renderer/look.js +30 -0
  61. package/dist/renderer/markdown.js +299 -92
  62. package/dist/renderer/notice-log.js +72 -0
  63. package/dist/renderer/notice-shape.js +48 -0
  64. package/dist/renderer/notices.js +40 -8
  65. package/dist/renderer/panel-rows.js +16 -0
  66. package/dist/renderer/pipeline.js +66 -0
  67. package/dist/renderer/progress-chatter.js +23 -17
  68. package/dist/renderer/rendering-mode.js +34 -0
  69. package/dist/renderer/routing-line.js +14 -0
  70. package/dist/renderer/sanitize.js +12 -0
  71. package/dist/renderer/severity.js +2 -7
  72. package/dist/renderer/startup-lines.js +152 -0
  73. package/dist/renderer/status-footer.js +52 -34
  74. package/dist/renderer/steering-echo.js +41 -0
  75. package/dist/renderer/syntax.js +31 -12
  76. package/dist/renderer/tables.js +5 -1
  77. package/dist/renderer/theme.js +44 -0
  78. package/dist/renderer/thinking-heartbeat.js +33 -2
  79. package/dist/renderer/todo-block.js +10 -49
  80. package/dist/renderer/tool-labels.js +238 -0
  81. package/dist/renderer/tool-widget.js +245 -51
  82. package/dist/renderer/trace.js +42 -0
  83. package/dist/renderer/transcript-flow.js +132 -0
  84. package/dist/renderer/tty.js +34 -1
  85. package/dist/renderer/ui-width.js +42 -0
  86. package/dist/renderer/verify-chain.js +4 -2
  87. package/dist/renderer/widget-fallback.js +58 -65
  88. package/dist/repl/bracketed-paste.js +7 -1
  89. package/dist/repl/builtin-commands.js +19 -8
  90. package/dist/repl/credential-handover-gate.js +28 -0
  91. package/dist/repl/early-line-buffer.js +5 -2
  92. package/dist/repl/file-picker.js +54 -12
  93. package/dist/repl/ink-stdin-guard.js +66 -0
  94. package/dist/repl/inquirer-guard.js +59 -16
  95. package/dist/repl/inquirer-theme.js +27 -33
  96. package/dist/repl/paste-marker.js +19 -0
  97. package/dist/repl/plan-turn-end.js +20 -0
  98. package/dist/repl/post-turn-status.js +36 -11
  99. package/dist/repl/reroute-turn.js +21 -0
  100. package/dist/repl/reset-tty-stdin.js +44 -0
  101. package/dist/repl/restore-guard.js +22 -0
  102. package/dist/repl/resume-standalone.js +63 -0
  103. package/dist/repl/rule8-detector.js +11 -3
  104. package/dist/repl/safety-confirm.js +161 -93
  105. package/dist/repl/session-spend-line.js +8 -4
  106. package/dist/repl/themed-prompts.js +19 -0
  107. package/dist/repl/ui-look-command.js +75 -0
  108. package/dist/repl/update-method-preview-hook.js +48 -5
  109. package/dist/repl.js +795 -332
  110. package/dist/rewind/cli.js +3 -2
  111. package/dist/rewind/format.js +14 -9
  112. package/dist/rewind/restore.js +11 -0
  113. package/dist/router/classifier.js +47 -4
  114. package/dist/router/intent-extractor.js +24 -8
  115. package/dist/router/piped-routing.js +23 -0
  116. package/dist/router/resume-routing.js +16 -0
  117. package/dist/router/routing-failure.js +57 -0
  118. package/dist/sap/first-run-choice.js +1 -1
  119. package/dist/sap/onboarding.js +3 -2
  120. package/dist/sap/standalone-onboarding.js +1 -1
  121. package/dist/sap/system-info.js +4 -2
  122. package/dist/sap/unreachable.js +28 -0
  123. package/dist/sap-errors/clean-error-text.js +182 -0
  124. package/dist/session/interrupt-reason.js +39 -0
  125. package/dist/session/recap.js +36 -24
  126. package/dist/session/resume.js +104 -36
  127. package/dist/session/store.js +69 -27
  128. package/dist/session/user-prompt.js +21 -0
  129. package/dist/skill-catalog.js +7 -0
  130. package/dist/skills/bundled-skills.js +76 -69
  131. package/dist/standards/standards-init.js +1 -1
  132. package/dist/test-helpers/answer-any.js +46 -0
  133. package/dist/tools/approval.js +27 -11
  134. package/dist/tools/ask-question.js +47 -5
  135. package/dist/tools/filesystem/file-write.js +19 -2
  136. package/dist/tools/fiori/fe-extend.js +16 -1
  137. package/dist/tools/fiori/fe-scaffold.js +105 -3
  138. package/dist/tools/fiori/html-escapes.js +28 -0
  139. package/dist/tools/fiori/metadata/audit.js +605 -0
  140. package/dist/tools/fiori/metadata/references.js +104 -0
  141. package/dist/tools/fiori/metadata/types.js +8 -0
  142. package/dist/tools/fiori/preview/app-guard.js +215 -0
  143. package/dist/tools/fiori/preview/env.js +193 -0
  144. package/dist/tools/fiori/preview/readiness.js +127 -0
  145. package/dist/tools/fiori/preview/registry.js +412 -0
  146. package/dist/tools/fiori/preview/start.js +469 -0
  147. package/dist/tools/fiori/samples/loader.js +20 -5
  148. package/dist/tools/fiori/scaffold.js +18 -6
  149. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  150. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  151. package/dist/tools/fiori/smoke/assertions.js +161 -24
  152. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  153. package/dist/tools/fiori/smoke/batch.js +273 -0
  154. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  155. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  156. package/dist/tools/fiori/smoke/driver.js +171 -4
  157. package/dist/tools/fiori/smoke/evidence.js +35 -0
  158. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  159. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  160. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  161. package/dist/tools/fiori/tools.js +651 -7
  162. package/dist/tools/fiori/ui5-version.js +16 -0
  163. package/dist/tools/index.js +8 -0
  164. package/dist/tools/local-build.js +6 -0
  165. package/dist/tools/sap-read.js +16 -1
  166. package/dist/tools/sap-write.js +122 -10
  167. package/dist/tools/shell/shell_exec.js +30 -12
  168. package/dist/tools/syntax-state.js +67 -0
  169. package/dist/tools/todo.js +23 -18
  170. package/dist/tools/transport-fit.js +462 -0
  171. package/dist/tools/transport-resolution.js +3 -1
  172. package/dist/tools/transport.js +33 -7
  173. package/dist/tools/verify.js +184 -8
  174. package/dist/ui/app.js +284 -146
  175. package/dist/ui/approval-modal.js +41 -15
  176. package/dist/ui/ask-answer-text.js +17 -0
  177. package/dist/ui/body.js +25 -6
  178. package/dist/ui/card-slot.js +98 -0
  179. package/dist/ui/checklist.js +9 -0
  180. package/dist/ui/coaching-picker-classic.js +8 -5
  181. package/dist/ui/command-palette.js +6 -4
  182. package/dist/ui/confirm-request.js +18 -0
  183. package/dist/ui/context-grid.js +2 -1
  184. package/dist/ui/file-palette.js +6 -4
  185. package/dist/ui/files-card-emitter.js +19 -0
  186. package/dist/ui/footer-line.js +16 -0
  187. package/dist/ui/footer.js +105 -104
  188. package/dist/ui/input-wrap.js +122 -0
  189. package/dist/ui/interrupt.js +103 -0
  190. package/dist/ui/key-burst.js +117 -0
  191. package/dist/ui/line-resolution.js +4 -3
  192. package/dist/ui/live-area.js +71 -0
  193. package/dist/ui/login-banner.js +54 -43
  194. package/dist/ui/rewind-panel.js +76 -24
  195. package/dist/ui/role-props.js +47 -0
  196. package/dist/ui/sap-state-store.js +1 -0
  197. package/dist/ui/session-timeline.js +49 -3
  198. package/dist/ui/skill-picker.js +5 -47
  199. package/dist/ui/text-input.js +128 -24
  200. package/dist/ui/todo-emitter.js +19 -0
  201. package/dist/ui/turn-status-emitter.js +28 -2
  202. package/dist/ui/turn-status.js +16 -19
  203. package/dist/ui/use-card-request.js +44 -0
  204. package/dist/ui/widgets/ask-card.js +521 -0
  205. package/dist/ui/widgets/bar-chart.js +11 -5
  206. package/dist/ui/widgets/coaching-picker.js +50 -55
  207. package/dist/ui/widgets/confirm-card.js +193 -0
  208. package/dist/ui/widgets/dep-graph.js +3 -2
  209. package/dist/ui/widgets/diff-viewer.js +6 -5
  210. package/dist/ui/widgets/files-card.js +97 -0
  211. package/dist/ui/widgets/question-card.js +2 -1
  212. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  213. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  214. package/dist/ui/widgets/stack-frames.js +2 -1
  215. package/dist/upgrade-check.js +39 -8
  216. package/package.json +11 -2
  217. package/test-harness/fe-draft/app.ts +188 -0
  218. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  219. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  220. package/test-harness/fe-draft/routes.ts +224 -0
  221. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  222. package/test-harness/fe-draft/server.ts +225 -0
  223. package/test-harness/fe-draft/variants.ts +196 -0
  224. package/vitest.smoke-e2e.config.ts +20 -0
@@ -0,0 +1,41 @@
1
+ // src/renderer/legacy-palette.ts
2
+ //
3
+ // FROZEN legacy colours (CLI look piece 2, 2026-09-26, spec §1.1). These are
4
+ // the literal colours the legacy look uses today, byte for byte. They are
5
+ // pinned by test/unit/legacy-look-pin.test.ts, legacy-look-pin-production
6
+ // .test.ts and legacy-palette-move-pin.test.ts. Nothing new may use them:
7
+ // panels and new surfaces paint through theme roles (theme.ts / role-props.ts).
8
+ // This file is deleted when the legacy look retires (release N+1).
9
+ import chalk from 'chalk';
10
+ import { colorOff } from './color-mode.js';
11
+ /** Was banners.ts PEACH: brand name, ⏺/⎿ glyphs, spinner, todo block, approvals. */
12
+ export const LEGACY_PEACH = '#FFCBA4';
13
+ /** Was markdown.ts PEACH: legacy first heading + bold. */
14
+ export const LEGACY_MD_PEACH = '#FFB88C';
15
+ /** Was renderer/severity.ts PILLS. */
16
+ export const LEGACY_SEVERITY_PILLS = {
17
+ CRITICAL: (s) => chalk.bgRedBright.white.bold(s),
18
+ HIGH: (s) => chalk.bgRed.white(s),
19
+ MEDIUM: (s) => chalk.bgYellow.black(s),
20
+ LOW: (s) => chalk.bgGreen.black(s),
21
+ };
22
+ /** Was the cyan / white / peach literals in markdown.ts's legacy markedTerminal options. */
23
+ export const LEGACY_MD_STYLES = {
24
+ heading: chalk.cyan.bold,
25
+ firstHeading: chalk.hex(LEGACY_MD_PEACH).bold,
26
+ strong: chalk.bold.hex(LEGACY_MD_PEACH),
27
+ codespan: chalk.bgGray.white,
28
+ listitem: chalk.white,
29
+ paragraph: chalk.white,
30
+ };
31
+ /** Was markdown.ts buildTable's cli-table3 style. Fresh object per call (cli-table3 may mutate). */
32
+ export function legacyTableStyle() {
33
+ return { head: ['cyan', 'bold'], border: ['gray'] };
34
+ }
35
+ /** Was markdown.ts ZEBRA_BG / ZEBRA_RESET (256-colour dark gray stripe). */
36
+ export const LEGACY_ZEBRA_BG = '\x1b[48;5;235m';
37
+ export const LEGACY_ZEBRA_RESET = '\x1b[49m';
38
+ export function legacyZebra(s) {
39
+ // NO_COLOR / level 0: no background stripe (applyZebraBg then leaves the row untouched).
40
+ return colorOff() ? s : LEGACY_ZEBRA_BG + s + LEGACY_ZEBRA_RESET;
41
+ }
@@ -0,0 +1,30 @@
1
+ // src/renderer/look.ts
2
+ //
3
+ // CLI look (2026-09-26, spec docs/superpowers/specs/2026-09-26-cli-look-piece1-design.md).
4
+ // 'panels' = the new look (config default); 'legacy' = today's output, byte for
5
+ // byte. The MODULE fallback is 'legacy' so any caller or test that never sets a
6
+ // look is unchanged; the REPL and one-shot set it from config at startup.
7
+ let look = 'legacy';
8
+ let background;
9
+ export function getLook() {
10
+ return look;
11
+ }
12
+ export function setLook(next) {
13
+ look = next;
14
+ }
15
+ /** COLORFGBG is "fg;bg" (some terminals add a middle field). bg 7 / 15 = light. */
16
+ export function backgroundFromColorFgBg(v) {
17
+ if (!v)
18
+ return undefined;
19
+ const last = v.split(';').pop();
20
+ if (last === undefined || !/^\d+$/.test(last))
21
+ return undefined;
22
+ const bg = Number(last);
23
+ return bg === 7 || bg === 15 ? 'light' : 'dark';
24
+ }
25
+ export function getBackground() {
26
+ return background ?? backgroundFromColorFgBg(process.env['COLORFGBG']) ?? 'dark';
27
+ }
28
+ export function setBackground(bg) {
29
+ background = bg;
30
+ }
@@ -8,16 +8,24 @@
8
8
  * and `image(href, title, text)`; every other key is a wrapper applied to the
9
9
  * already-rendered text.
10
10
  *
11
- * We cannot distinguish h1/h2/h3/h4 with a single chalk function, so we pick one
12
- * styling for all heading levels — terminal output rarely needs a level hierarchy.
11
+ * Legacy look: marked-terminal's `heading` is a single chalk function, so all
12
+ * heading levels share one style (plus `firstHeading`), with "#" prefixes.
13
+ * Panels look: a separate Marked instance (bottom of this file) overrides
14
+ * `heading` and distinguishes levels, without "#" prefixes.
13
15
  */
14
16
  import chalk from 'chalk';
15
- import { marked } from 'marked';
17
+ import { marked, Marked } from 'marked';
16
18
  import { markedTerminal } from 'marked-terminal';
17
19
  import Table from 'cli-table3';
20
+ import { highlight as cliHighlight, supportsLanguage } from 'cli-highlight';
18
21
  import { applySeverityToCell } from './severity.js';
19
22
  import { getEffectiveBodyWidth } from './tty.js';
20
- const PEACH = '#FFB88C';
23
+ import { getLook, getBackground } from './look.js';
24
+ import { paint } from './theme.js';
25
+ import { highlightFencedBody } from './abap-inline.js';
26
+ import { getBoxChars } from '../ui/ascii-chars.js';
27
+ import { LEGACY_MD_STYLES, legacyTableStyle, legacyZebra } from './legacy-palette.js';
28
+ import { colorOff, tableStyle } from './color-mode.js';
21
29
  // marked-terminal uses these sentinels internally. We parse them back out to
22
30
  // rebuild a width-capped cli-table3 in our table renderer override below.
23
31
  // Source: node_modules/marked-terminal/index.js lines 10-15.
@@ -111,24 +119,24 @@ function computeColWidths(naturalContentWidths, minContentWidths, termWidth) {
111
119
  // is resized mid-session the wrap will be slightly off but still word-safe.
112
120
  const PROSE_WIDTH = Math.max(60, (process.stdout.columns || 100) - 2);
113
121
  marked.use(markedTerminal({
114
- heading: chalk.cyan.bold,
115
- firstHeading: chalk.hex(PEACH).bold,
122
+ heading: LEGACY_MD_STYLES.heading,
123
+ firstHeading: LEGACY_MD_STYLES.firstHeading,
116
124
  // `strong` (markdown bold via `**...**`) was previously plain `chalk.bold`,
117
125
  // which emits only `[1m`. On many terminals — and inside Ink's default
118
126
  // <Text> wrap — bold weight on white text is visually indistinguishable
119
127
  // from normal-weight white. Pair bold with the peach brand colour so
120
128
  // labels like `**Headline:**` stand out even when bold weight isn't
121
129
  // rendered strongly. Cheap, safe, and consistent with firstHeading.
122
- strong: chalk.bold.hex(PEACH),
130
+ strong: LEGACY_MD_STYLES.strong,
123
131
  em: chalk.italic,
124
- codespan: chalk.bgGray.white,
132
+ codespan: LEGACY_MD_STYLES.codespan,
125
133
  code: chalk.gray,
126
134
  blockquote: chalk.gray.italic,
127
135
  link: chalk.blue.underline,
128
136
  href: chalk.blue.underline,
129
- listitem: chalk.white,
137
+ listitem: LEGACY_MD_STYLES.listitem,
130
138
  hr: chalk.gray,
131
- paragraph: chalk.white,
139
+ paragraph: LEGACY_MD_STYLES.paragraph,
132
140
  // Enable reflow so paragraphs and list items wrap at WORD boundaries at
133
141
  // the configured width, and continuation lines of list items align under
134
142
  // the bullet content (not at column 0). Without this, marked-terminal
@@ -147,81 +155,89 @@ marked.use(markedTerminal({
147
155
  marked.use({
148
156
  renderer: {
149
157
  table(header, body) {
150
- const headerRow = parseMtTableRows(header)[0] ?? [];
151
- const rawBodyRows = parseMtTableRows(body);
152
- if (headerRow.length === 0)
153
- return `${header}${body}\n\n`;
154
- // Apply severity pills to EACH CELL before cli-table3 sees them. If we
155
- // left severity for the outer pipeline (applySeverity after markdown),
156
- // it would wrap "HIGH" (4 chars) with " HIGH " (6 visible chars) AFTER
157
- // cli-table3 had already allocated a 4-char column — the extra 2 chars
158
- // then push the next `│` border right and break every following row's
159
- // alignment. Applying here keeps visible widths correct via string-width.
160
- const bodyRows = rawBodyRows.map((row) => row.map((c) => applySeverityToCell(c)));
161
- // Natural content width per column (stripped of ANSI escapes) AND
162
- // minimum word width per column (so wrap points never cut mid-word).
163
- const colCount = headerRow.length;
164
- const naturalWidths = new Array(colCount).fill(0);
165
- const minWidths = new Array(colCount).fill(1);
166
- for (const row of [headerRow, ...bodyRows]) {
167
- for (let i = 0; i < colCount; i++) {
168
- const content = row[i] ?? '';
169
- const visible = stripAnsi(content);
170
- if (visible.length > naturalWidths[i])
171
- naturalWidths[i] = visible.length;
172
- // Longest whitespace-separated token — the minimum column width that
173
- // wordWrap can respect without mid-word breakage.
174
- for (const word of visible.split(/\s+/)) {
175
- if (word.length > minWidths[i])
176
- minWidths[i] = word.length;
177
- }
178
- }
179
- }
180
- // Phase A #2 (2026-05-16) — was `process.stdout.columns || 120` which
181
- // over-allocated in Ink mode: the Body sits next to a 30-col Sidebar,
182
- // so tables sized for full terminal width wrapped inside Body and
183
- // bled into the Sidebar visually. getEffectiveBodyWidth() returns
184
- // `cols − sidebar − border` under Ink and the full width under classic.
185
- const termWidth = getEffectiveBodyWidth();
186
- const colWidths = computeColWidths(naturalWidths, minWidths, termWidth);
187
- // Decide whether row separators are needed. When every row fits on one
188
- // visual line we suppress them (avoids stairstep on small tables, see
189
- // earlier comment). When ANY row will wrap (some cell content exceeds
190
- // its allocated column width), the wrap line otherwise looks like a
191
- // separate empty row to the reader — so re-enable thin separators
192
- // between body rows. The header/body separator is suppressed in both
193
- // cases (the cyan-bold header colour already separates them visually).
194
- let anyRowWraps = false;
195
- for (const row of bodyRows) {
196
- for (let i = 0; i < colCount; i++) {
197
- const visible = stripAnsi(row[i] ?? '');
198
- // colWidths[i] = content width + 2 padding. Wrap happens when the
199
- // visible content exceeds the content width (colWidths[i] - 2).
200
- if (visible.length > (colWidths[i] - 2)) {
201
- anyRowWraps = true;
202
- break;
203
- }
204
- }
205
- if (anyRowWraps)
206
- break;
207
- }
208
- const tableChars = anyRowWraps
209
- ? {} // default chars — keeps `mid: ─` so wrapped rows have separators
210
- : { mid: '', 'left-mid': '', 'mid-mid': '', 'right-mid': '' };
211
- const table = new Table({
212
- head: headerRow,
213
- colWidths,
214
- wordWrap: true,
215
- wrapOnWordBoundary: true,
216
- style: { head: ['cyan', 'bold'], border: ['gray'] },
217
- chars: tableChars,
218
- });
219
- for (const row of bodyRows)
220
- table.push(row);
221
- return zebraStripe(table.toString()) + '\n\n';
158
+ return buildTable(header, body, legacyZebra);
222
159
  },
223
160
  },
224
161
  });
162
+ /**
163
+ * Shared table builder (legacy + panels). `zebra` wraps a striped body row in
164
+ * its background; the legacy look passes the fixed 256-colour dark gray, the
165
+ * panels look a theme colour that follows the terminal background.
166
+ */
167
+ function buildTable(header, body, zebra) {
168
+ const headerRow = parseMtTableRows(header)[0] ?? [];
169
+ const rawBodyRows = parseMtTableRows(body);
170
+ if (headerRow.length === 0)
171
+ return `${header}${body}\n\n`;
172
+ // Apply severity pills to EACH CELL before cli-table3 sees them. If we
173
+ // left severity for the outer pipeline (applySeverity after markdown),
174
+ // it would wrap "HIGH" (4 chars) with " HIGH " (6 visible chars) AFTER
175
+ // cli-table3 had already allocated a 4-char column — the extra 2 chars
176
+ // then push the next `│` border right and break every following row's
177
+ // alignment. Applying here keeps visible widths correct via string-width.
178
+ const bodyRows = rawBodyRows.map((row) => row.map((c) => applySeverityToCell(c)));
179
+ // Natural content width per column (stripped of ANSI escapes) AND
180
+ // minimum word width per column (so wrap points never cut mid-word).
181
+ const colCount = headerRow.length;
182
+ const naturalWidths = new Array(colCount).fill(0);
183
+ const minWidths = new Array(colCount).fill(1);
184
+ for (const row of [headerRow, ...bodyRows]) {
185
+ for (let i = 0; i < colCount; i++) {
186
+ const content = row[i] ?? '';
187
+ const visible = stripAnsi(content);
188
+ if (visible.length > naturalWidths[i])
189
+ naturalWidths[i] = visible.length;
190
+ // Longest whitespace-separated token — the minimum column width that
191
+ // wordWrap can respect without mid-word breakage.
192
+ for (const word of visible.split(/\s+/)) {
193
+ if (word.length > minWidths[i])
194
+ minWidths[i] = word.length;
195
+ }
196
+ }
197
+ }
198
+ // Phase A #2 (2026-05-16) — was `process.stdout.columns || 120` which
199
+ // over-allocated in Ink mode: the Body sits next to a 30-col Sidebar,
200
+ // so tables sized for full terminal width wrapped inside Body and
201
+ // bled into the Sidebar visually. getEffectiveBodyWidth() returns
202
+ // `cols − sidebar − border` under Ink and the full width under classic.
203
+ const termWidth = getEffectiveBodyWidth();
204
+ const colWidths = computeColWidths(naturalWidths, minWidths, termWidth);
205
+ // Decide whether row separators are needed. When every row fits on one
206
+ // visual line we suppress them (avoids stairstep on small tables, see
207
+ // earlier comment). When ANY row will wrap (some cell content exceeds
208
+ // its allocated column width), the wrap line otherwise looks like a
209
+ // separate empty row to the reader — so re-enable thin separators
210
+ // between body rows. The header/body separator is suppressed in both
211
+ // cases (the cyan-bold header colour already separates them visually).
212
+ let anyRowWraps = false;
213
+ for (const row of bodyRows) {
214
+ for (let i = 0; i < colCount; i++) {
215
+ const visible = stripAnsi(row[i] ?? '');
216
+ // colWidths[i] = content width + 2 padding. Wrap happens when the
217
+ // visible content exceeds the content width (colWidths[i] - 2).
218
+ if (visible.length > (colWidths[i] - 2)) {
219
+ anyRowWraps = true;
220
+ break;
221
+ }
222
+ }
223
+ if (anyRowWraps)
224
+ break;
225
+ }
226
+ const tableChars = anyRowWraps
227
+ ? {} // default chars — keeps `mid: ─` so wrapped rows have separators
228
+ : { mid: '', 'left-mid': '', 'mid-mid': '', 'right-mid': '' };
229
+ const table = new Table({
230
+ head: headerRow,
231
+ colWidths,
232
+ wordWrap: true,
233
+ wrapOnWordBoundary: true,
234
+ style: tableStyle(legacyTableStyle()),
235
+ chars: tableChars,
236
+ });
237
+ for (const row of bodyRows)
238
+ table.push(row);
239
+ return zebraStripe(table.toString(), zebra) + '\n\n';
240
+ }
225
241
  /**
226
242
  * Apply subtle dark-gray background to every other body row.
227
243
  *
@@ -235,21 +251,25 @@ marked.use({
235
251
  * - A body line is a NEW row start when the first column (between the first
236
252
  * two `│` chars) contains non-whitespace (cli-table3 pads continuation
237
253
  * lines of multi-line cells with whitespace in non-wrapping columns).
238
- * - Odd row indices get wrapped with `\x1b[48;5;235m … \x1b[49m`. Mid-line
239
- * bg-resets (e.g. when a severity pill's `\x1b[49m` ends) are rewritten
254
+ * - Odd row indices get wrapped in the zebra background (legacy look:
255
+ * LEGACY_ZEBRA_BG … LEGACY_ZEBRA_RESET). Mid-line background resets
256
+ * (e.g. when a severity pill's background ends) are rewritten
240
257
  * to re-apply the zebra bg so the reset doesn't punch a hole after the pill.
241
258
  */
242
- const ZEBRA_BG = '\x1b[48;5;235m'; // dark gray — subtle but visible
243
- const ZEBRA_RESET = '\x1b[49m';
244
- function applyZebraBg(line) {
259
+ function applyZebraBg(line, zebra) {
260
+ // Split the zebra wrapper into its open/close codes (a NUL probe never
261
+ // occurs in table text). With colour off both are empty → line unchanged.
262
+ const [open = '', close = ''] = zebra('\0').split('\0');
263
+ if (open === '')
264
+ return line;
245
265
  // Re-apply zebra bg after any in-line bg-reset or full-reset so the stripe
246
266
  // survives through severity pills and inline-code spans.
247
267
  const reZebraed = line
248
- .replace(/\x1B\[49m/g, ZEBRA_BG)
249
- .replace(/\x1B\[0m/g, '\x1B[0m' + ZEBRA_BG);
250
- return ZEBRA_BG + reZebraed + ZEBRA_RESET;
268
+ .replace(/\x1B\[49m/g, open)
269
+ .replace(/\x1B\[0m/g, '\x1B[0m' + open);
270
+ return open + reZebraed + close;
251
271
  }
252
- function zebraStripe(tableText) {
272
+ function zebraStripe(tableText, zebra) {
253
273
  const lines = tableText.split('\n');
254
274
  const result = [];
255
275
  let sawTop = false;
@@ -279,7 +299,7 @@ function zebraStripe(tableText) {
279
299
  if (firstCellTrimmed !== '')
280
300
  rowIndex++;
281
301
  if (rowIndex >= 0 && rowIndex % 2 === 1) {
282
- result.push(applyZebraBg(line));
302
+ result.push(applyZebraBg(line, zebra));
283
303
  }
284
304
  else {
285
305
  result.push(line);
@@ -291,7 +311,194 @@ function zebraStripe(tableText) {
291
311
  }
292
312
  return result.join('\n');
293
313
  }
314
+ /** Panels table: legacy layout, zebra stripe that follows the terminal background. */
315
+ function panelsTable(header, body) {
316
+ const zebra = getBackground() === 'light'
317
+ ? (s) => chalk.bgHex('#E8EEF5')(s)
318
+ : (s) => chalk.bgHex('#1B2530')(s);
319
+ return buildTable(header, body, zebra);
320
+ }
321
+ /**
322
+ * Panels code-block body: CSPeach's own ABAP/CDS/BDEF/DDL highlighting first;
323
+ * otherwise cli-highlight for languages it knows (json, sql, js … as in
324
+ * legacy); otherwise plain gray. supportsLanguage() is checked first so an
325
+ * unknown language never prints cli-highlight's "Could not find the
326
+ * language" stderr noise.
327
+ */
328
+ function panelsCodeBody(code, info) {
329
+ const lang = (info ?? '').trim().split(/\s+/)[0] ?? '';
330
+ const ours = highlightFencedBody(code, lang);
331
+ if (ours !== undefined)
332
+ return ours;
333
+ if (lang !== '' && !colorOff() && supportsLanguage(lang)) {
334
+ try {
335
+ return cliHighlight(code, { language: lang, ignoreIllegals: true });
336
+ }
337
+ catch {
338
+ // Grammar error must never surface — fall through to plain gray.
339
+ }
340
+ }
341
+ return chalk.gray(code);
342
+ }
343
+ // Panels look (2026-09-26): a SEPARATE Marked instance so the legacy global
344
+ // configuration above stays byte-identical. Differences: no "#" prefixes and a
345
+ // real heading hierarchy, plain bold, CSPeach's own ABAP/CDS/BDEF colouring
346
+ // inside code blocks, zebra stripe that follows the terminal background.
347
+ //
348
+ // Polish wave (P6): marked-terminal's reflow runs at a width no line reaches,
349
+ // so it only joins soft line breaks; the real wrapping is wrapPanels() below,
350
+ // at the CURRENT body width (not a module-load snapshot), and it never breaks
351
+ // between "(" and a coloured word, before a closing bracket, or strands a
352
+ // single punctuation character.
353
+ const PANELS_REFLOW_WIDTH = 100_000;
354
+ const panelsExt = markedTerminal({
355
+ showSectionPrefix: false,
356
+ heading: chalk.bold,
357
+ firstHeading: chalk.bold,
358
+ strong: chalk.bold,
359
+ em: chalk.italic,
360
+ codespan: (s) => paint('sap', s),
361
+ code: chalk.gray,
362
+ blockquote: chalk.gray.italic,
363
+ link: chalk.underline,
364
+ href: chalk.underline,
365
+ listitem: (s) => s,
366
+ hr: (s) => paint('frame', s),
367
+ paragraph: (s) => s,
368
+ reflowText: true,
369
+ width: PANELS_REFLOW_WIDTH,
370
+ });
371
+ const panelsMarked = new Marked();
372
+ panelsMarked.use(panelsExt);
373
+ panelsMarked.use({
374
+ renderer: {
375
+ heading(raw, level) {
376
+ // Round 7 item 6 — marked hands the heading text HTML-escaped
377
+ // (&quot; &amp; …); unescape it like body text (marked-terminal's
378
+ // own heading does this; this override skipped it).
379
+ const text = mtTransform(raw);
380
+ const t = level === 1 ? paint('sap', chalk.bold(text)) : level === 2 ? chalk.bold(text) : paint('dim', chalk.bold(text));
381
+ return `\n${t}\n\n`;
382
+ },
383
+ code(code, lang) {
384
+ const body = panelsCodeBody(code, lang);
385
+ const bar = paint('frame', '│');
386
+ return '\n' + body.split('\n').map((l) => ` ${bar} ${l}`).join('\n') + '\n\n';
387
+ },
388
+ table: panelsTable,
389
+ // P5: marked-terminal numbers every list from 1 and ignores marked's
390
+ // `start`, so a list that resumes after a paragraph restarted at "1.".
391
+ list(body, ordered, start) {
392
+ const out = panelsExt.renderer.list.call(this, body, ordered);
393
+ const first = typeof start === 'number' ? start : 1;
394
+ if (!ordered || first === 1)
395
+ return out;
396
+ return out.replace(/^( {4})(\d+)\. /gm, (_m, ind, n) => `${ind}${Number(n) + first - 1}. `);
397
+ },
398
+ hr() {
399
+ return paint('frame', getBoxChars().h.repeat(panelsProseWidth())) + '\n\n';
400
+ },
401
+ },
402
+ });
403
+ /** Panels prose width: the live body width (Ink: minus its border), 2 columns of margin like legacy. */
404
+ function panelsProseWidth() {
405
+ return Math.max(20, getEffectiveBodyWidth() - 2);
406
+ }
407
+ // eslint-disable-next-line no-control-regex
408
+ const SGR_RE = /\x1b\[[0-9;]*m/g;
409
+ const visibleWidth = (s) => s.replace(ANSI_RE, '').length;
410
+ /** Opening brackets/quotes that must stay with the NEXT word. */
411
+ const OPENERS_ONLY = /^[([{“‘«]+$/;
412
+ /** Closing brackets and punctuation that must stay with the PREVIOUS word. */
413
+ const CLOSERS_ONLY = /^[)\]}”’».,;:!?…]+$/;
414
+ /** Lines wrapWord must never touch: tables, code bars, rules, widget controls. */
415
+ const NO_WRAP = /^\s*[│┌└├┬┴┼╭╰─|+]/;
416
+ /**
417
+ * Word-wrap one rendered line to `width` visible columns. Breaks only at
418
+ * spaces; ANSI codes are zero-width and stay attached to their word; opening
419
+ * brackets glue forward and closing punctuation glues back; continuation
420
+ * lines hang under the text (list items: under the text after "1. ").
421
+ * Styles are closed at a break and re-opened after the indent, so an
422
+ * underline never runs into the indentation.
423
+ */
424
+ function wrapLine(line, width) {
425
+ const plain = line.replace(ANSI_RE, '');
426
+ if (plain.length <= width || NO_WRAP.test(plain) || line.includes('\u0000'))
427
+ return [line];
428
+ // Prefix = everything before the first visible non-space character.
429
+ let i = 0;
430
+ while (i < line.length) {
431
+ if (line[i] === '\x1b') {
432
+ const m = /^\x1b\[[0-9;?]*[ -/]*[@-~]/.exec(line.slice(i));
433
+ if (m) {
434
+ i += m[0].length;
435
+ continue;
436
+ }
437
+ }
438
+ if (line[i] === ' ') {
439
+ i++;
440
+ continue;
441
+ }
442
+ break;
443
+ }
444
+ const prefix = line.slice(0, i);
445
+ const indent = visibleWidth(prefix);
446
+ const marker = /^(\d+\.|[*•-]) /.exec(plain.slice(indent));
447
+ let hang = indent + (marker ? marker[0].length : 0);
448
+ if (hang > width / 2)
449
+ hang = 0;
450
+ // Units: words, with openers glued forward and closers glued back.
451
+ const words = line.slice(i).split(' ').filter((w) => w !== '');
452
+ const units = [];
453
+ let glueNext = false;
454
+ for (const w of words) {
455
+ const vis = w.replace(ANSI_RE, '');
456
+ if (units.length > 0 && (glueNext || vis === '' || CLOSERS_ONLY.test(vis))) {
457
+ units[units.length - 1] += ' ' + w;
458
+ }
459
+ else {
460
+ units.push(w);
461
+ }
462
+ glueNext = OPENERS_ONLY.test(vis);
463
+ }
464
+ const out = [];
465
+ let cur = prefix + (units[0] ?? '');
466
+ let curW = indent + visibleWidth(units[0] ?? '');
467
+ let active = [];
468
+ const track = (s) => {
469
+ for (const code of s.match(SGR_RE) ?? []) {
470
+ if (code === '\x1b[0m' || code === '\x1b[m')
471
+ active = [];
472
+ else
473
+ active.push(code);
474
+ }
475
+ };
476
+ track(cur);
477
+ for (const u of units.slice(1)) {
478
+ const w = visibleWidth(u);
479
+ if (curW + 1 + w <= width) {
480
+ cur += ' ' + u;
481
+ curW += 1 + w;
482
+ }
483
+ else {
484
+ out.push(active.length > 0 ? cur + '\x1b[0m' : cur);
485
+ cur = ' '.repeat(hang) + active.join('') + u;
486
+ curW = hang + w;
487
+ }
488
+ track(u);
489
+ }
490
+ out.push(cur);
491
+ return out;
492
+ }
493
+ /** P6: wrap every rendered prose line at the current panels width. */
494
+ function wrapPanels(rendered, width) {
495
+ return rendered.split('\n').flatMap((l) => wrapLine(l, width)).join('\n');
496
+ }
294
497
  export function renderMarkdown(input) {
498
+ if (getLook() === 'panels') {
499
+ const result = panelsMarked.parse(input);
500
+ return typeof result === 'string' ? wrapPanels(result, panelsProseWidth()) : input;
501
+ }
295
502
  const result = marked.parse(input);
296
503
  return typeof result === 'string' ? result : input;
297
504
  }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Notice log (live fix 2026-09-26).
3
+ *
4
+ * Panels cuts a long notice to one screen line so it never wraps mid-word.
5
+ * The full text must not be lost, so a cut notice is appended here — one
6
+ * line per notice — in the session's log file under ~/.cspeach/logs.
7
+ * runRepl points it at `<logsDir>/<sessionId>-notices.log`; until then (tests,
8
+ * one-shot) recording is a no-op. Best effort: a failed write never throws.
9
+ */
10
+ import { appendFileSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
+ let logPath;
13
+ export function setNoticeLogPath(p) {
14
+ logPath = p;
15
+ }
16
+ export function getNoticeLogPath() {
17
+ return logPath;
18
+ }
19
+ export function recordFullNotice(level, message) {
20
+ if (!logPath)
21
+ return;
22
+ try {
23
+ mkdirSync(dirname(logPath), { recursive: true });
24
+ appendFileSync(logPath, `${new Date().toISOString()} ${level} ${message.replace(/\s*\r?\n\s*/g, ' ')}\n`, 'utf8');
25
+ }
26
+ catch {
27
+ /* best effort — the screen line already showed the start */
28
+ }
29
+ }
30
+ /** Notice logs older than this are deleted at REPL start (review M6). */
31
+ export const NOTICE_LOG_MAX_AGE_DAYS = 14;
32
+ const NOTICE_LOG_SUFFIX = '-notices.log';
33
+ /**
34
+ * `<logsDir>/<id>-notices.log` with the session id reduced to
35
+ * `[A-Za-z0-9-]` (review M6): a resumed on-disk session id must never steer
36
+ * the file outside the logs directory.
37
+ */
38
+ export function noticeLogPathFor(logsDir, sessionId) {
39
+ const safe = sessionId.replace(/[^A-Za-z0-9-]/g, '') || 'session';
40
+ return join(logsDir, `${safe}${NOTICE_LOG_SUFFIX}`);
41
+ }
42
+ /**
43
+ * Delete `*-notices.log` files in `dir` last modified more than
44
+ * NOTICE_LOG_MAX_AGE_DAYS ago (review M6). Other files are left alone.
45
+ * Returns how many were deleted. Best effort: never throws.
46
+ */
47
+ export function pruneNoticeLogs(dir, now = Date.now()) {
48
+ const cutoff = now - NOTICE_LOG_MAX_AGE_DAYS * 24 * 60 * 60 * 1000;
49
+ let removed = 0;
50
+ let names;
51
+ try {
52
+ names = readdirSync(dir);
53
+ }
54
+ catch {
55
+ return 0;
56
+ }
57
+ for (const name of names) {
58
+ if (!name.endsWith(NOTICE_LOG_SUFFIX))
59
+ continue;
60
+ const p = join(dir, name);
61
+ try {
62
+ if (statSync(p).mtimeMs < cutoff) {
63
+ unlinkSync(p);
64
+ removed++;
65
+ }
66
+ }
67
+ catch {
68
+ /* best effort */
69
+ }
70
+ }
71
+ return removed;
72
+ }
@@ -0,0 +1,48 @@
1
+ // src/renderer/notice-shape.ts
2
+ //
3
+ // Piece 2 §9 — the one shape for every error and notice:
4
+ //
5
+ // ✗ <what failed> · <object> (bad)
6
+ // <detail, dim>
7
+ // Next: <what the user can do> (only when there is something)
8
+ // ! <needs attention> · <fact> (warn)
9
+ // · <for your information> (dim)
10
+ //
11
+ // Glyph at column 0, further lines indented two spaces, exactly one trailing
12
+ // newline. Colour comes only from theme roles, so NO_COLOR (chalk level 0)
13
+ // leaves glyphs and layout intact. All caller text (tool notices, error
14
+ // messages, model text) is passed through sanitizeControlChars first, so it
15
+ // cannot move the cursor or start an escape sequence.
16
+ import chalk from 'chalk';
17
+ import { paint } from './theme.js';
18
+ import { getGlyphs } from './glyphs.js';
19
+ import { sanitizeControlChars } from './sanitize.js';
20
+ const oneLine = (s) => sanitizeControlChars(s).replace(/\s*\r?\n\s*/g, ' ').trim();
21
+ function nextLine(next) {
22
+ return next && oneLine(next) !== '' ? [` ${paint('dim', 'Next:')} ${oneLine(next)}`] : [];
23
+ }
24
+ export function formatFailure(p) {
25
+ const g = getGlyphs();
26
+ let head = `${paint('bad', g.failed)} ${chalk.bold(oneLine(p.what))}`;
27
+ if (p.object && oneLine(p.object) !== '')
28
+ head += paint('dim', ' · ') + paint('sap', oneLine(p.object));
29
+ const lines = [head];
30
+ for (const d of p.detail ?? []) {
31
+ for (const l of sanitizeControlChars(d).split(/\r?\n/)) {
32
+ if (l.trim() !== '')
33
+ lines.push(` ${paint('dim', l.trimEnd())}`);
34
+ }
35
+ }
36
+ lines.push(...nextLine(p.next));
37
+ return lines.join('\n') + '\n';
38
+ }
39
+ export function formatAttention(p) {
40
+ const g = getGlyphs();
41
+ let head = `${paint('warn', g.attention)} ${chalk.bold(oneLine(p.text))}`;
42
+ if (p.fact && oneLine(p.fact) !== '')
43
+ head += paint('dim', ` · ${oneLine(p.fact)}`);
44
+ return [head, ...nextLine(p.next)].join('\n') + '\n';
45
+ }
46
+ export function formatInfo(text) {
47
+ return paint('dim', `${getGlyphs().info} ${oneLine(text)}`) + '\n';
48
+ }