@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.
- package/README.md +3 -4
- package/dist/agent/loop.js +143 -25
- package/dist/agent/providers/local-provider.js +4 -1
- package/dist/agent/repair-partial.js +76 -19
- package/dist/agent/tool-dispatch.js +58 -21
- package/dist/agent/turn-error-ux.js +77 -26
- package/dist/agent/turn-stream.js +25 -3
- package/dist/approvals/adt-type.js +27 -0
- package/dist/approvals/advisory-prompt.js +48 -0
- package/dist/approvals/advisory-render.js +25 -11
- package/dist/approvals/approval-prompt.js +74 -24
- package/dist/approvals/jwt.js +2 -1
- package/dist/approvals/render.js +64 -36
- package/dist/approvals/risk-floor.js +53 -2
- package/dist/auth/api-key.js +30 -16
- package/dist/auth/keychain.js +0 -0
- package/dist/auth/me.js +25 -7
- package/dist/cli.js +25 -6
- package/dist/commands/compact.js +12 -9
- package/dist/commands/config-set.js +6 -2
- package/dist/commands/config-show.js +7 -5
- package/dist/commands/cost.js +41 -34
- package/dist/commands/help.js +88 -50
- package/dist/commands/login.js +28 -19
- package/dist/commands/logout.js +7 -3
- package/dist/commands/plan-chain.js +71 -77
- package/dist/commands/plan-continue.js +1 -0
- package/dist/commands/plan-resume.js +20 -12
- package/dist/commands/whoami.js +23 -9
- package/dist/config/loader.js +15 -3
- package/dist/cost/pricing.js +6 -5
- package/dist/doctor/checks/forge-rules.js +13 -3
- package/dist/doctor/checks/keychain.js +6 -4
- package/dist/doctor/run.js +101 -20
- package/dist/index.js +5 -2
- package/dist/lib/graceful-exit.js +56 -0
- package/dist/lib/piped-prompt.js +65 -0
- package/dist/lock-contention.js +2 -2
- package/dist/one-shot.js +135 -40
- package/dist/projects/answer-blockers.js +6 -1
- package/dist/projects/handover-md.js +15 -14
- package/dist/projects/plan-run.js +97 -54
- package/dist/projects/promote-command.js +11 -2
- package/dist/projects/save-command.js +3 -2
- package/dist/projects/status.js +2 -1
- package/dist/projects/yes-no.js +12 -0
- package/dist/renderer/abap-inline.js +18 -14
- package/dist/renderer/answer-trim.js +69 -0
- package/dist/renderer/banners.js +8 -8
- package/dist/renderer/brand-settled.js +19 -0
- package/dist/renderer/change-card.js +156 -0
- package/dist/renderer/checklist-format.js +130 -0
- package/dist/renderer/color-mode.js +45 -0
- package/dist/renderer/error-detail.js +104 -0
- package/dist/renderer/fence-state.js +46 -0
- package/dist/renderer/footer-line.js +96 -0
- package/dist/renderer/glyphs.js +25 -0
- package/dist/renderer/goodbye.js +38 -0
- package/dist/renderer/legacy-palette.js +41 -0
- package/dist/renderer/look.js +30 -0
- package/dist/renderer/markdown.js +299 -92
- package/dist/renderer/notice-log.js +72 -0
- package/dist/renderer/notice-shape.js +48 -0
- package/dist/renderer/notices.js +40 -8
- package/dist/renderer/panel-rows.js +16 -0
- package/dist/renderer/pipeline.js +66 -0
- package/dist/renderer/progress-chatter.js +23 -17
- package/dist/renderer/rendering-mode.js +34 -0
- package/dist/renderer/routing-line.js +14 -0
- package/dist/renderer/sanitize.js +12 -0
- package/dist/renderer/severity.js +2 -7
- package/dist/renderer/startup-lines.js +152 -0
- package/dist/renderer/status-footer.js +52 -34
- package/dist/renderer/steering-echo.js +41 -0
- package/dist/renderer/syntax.js +31 -12
- package/dist/renderer/tables.js +5 -1
- package/dist/renderer/theme.js +44 -0
- package/dist/renderer/thinking-heartbeat.js +33 -2
- package/dist/renderer/todo-block.js +10 -49
- package/dist/renderer/tool-labels.js +238 -0
- package/dist/renderer/tool-widget.js +245 -51
- package/dist/renderer/trace.js +42 -0
- package/dist/renderer/transcript-flow.js +132 -0
- package/dist/renderer/tty.js +34 -1
- package/dist/renderer/ui-width.js +42 -0
- package/dist/renderer/verify-chain.js +4 -2
- package/dist/renderer/widget-fallback.js +58 -65
- package/dist/repl/bracketed-paste.js +7 -1
- package/dist/repl/builtin-commands.js +19 -8
- package/dist/repl/credential-handover-gate.js +28 -0
- package/dist/repl/early-line-buffer.js +5 -2
- package/dist/repl/file-picker.js +54 -12
- package/dist/repl/ink-stdin-guard.js +66 -0
- package/dist/repl/inquirer-guard.js +59 -16
- package/dist/repl/inquirer-theme.js +27 -33
- package/dist/repl/paste-marker.js +19 -0
- package/dist/repl/plan-turn-end.js +20 -0
- package/dist/repl/post-turn-status.js +36 -11
- package/dist/repl/reroute-turn.js +21 -0
- package/dist/repl/reset-tty-stdin.js +44 -0
- package/dist/repl/restore-guard.js +22 -0
- package/dist/repl/resume-standalone.js +63 -0
- package/dist/repl/rule8-detector.js +11 -3
- package/dist/repl/safety-confirm.js +161 -93
- package/dist/repl/session-spend-line.js +8 -4
- package/dist/repl/themed-prompts.js +19 -0
- package/dist/repl/ui-look-command.js +75 -0
- package/dist/repl/update-method-preview-hook.js +48 -5
- package/dist/repl.js +795 -332
- package/dist/rewind/cli.js +3 -2
- package/dist/rewind/format.js +14 -9
- package/dist/rewind/restore.js +11 -0
- package/dist/router/classifier.js +47 -4
- package/dist/router/intent-extractor.js +24 -8
- package/dist/router/piped-routing.js +23 -0
- package/dist/router/resume-routing.js +16 -0
- package/dist/router/routing-failure.js +57 -0
- package/dist/sap/first-run-choice.js +1 -1
- package/dist/sap/onboarding.js +3 -2
- package/dist/sap/standalone-onboarding.js +1 -1
- package/dist/sap/system-info.js +4 -2
- package/dist/sap/unreachable.js +28 -0
- package/dist/sap-errors/clean-error-text.js +182 -0
- package/dist/session/interrupt-reason.js +39 -0
- package/dist/session/recap.js +36 -24
- package/dist/session/resume.js +104 -36
- package/dist/session/store.js +69 -27
- package/dist/session/user-prompt.js +21 -0
- package/dist/skill-catalog.js +7 -0
- package/dist/skills/bundled-skills.js +76 -69
- package/dist/standards/standards-init.js +1 -1
- package/dist/test-helpers/answer-any.js +46 -0
- package/dist/tools/approval.js +27 -11
- package/dist/tools/ask-question.js +47 -5
- package/dist/tools/filesystem/file-write.js +19 -2
- package/dist/tools/fiori/fe-extend.js +16 -1
- package/dist/tools/fiori/fe-scaffold.js +105 -3
- package/dist/tools/fiori/html-escapes.js +28 -0
- package/dist/tools/fiori/metadata/audit.js +605 -0
- package/dist/tools/fiori/metadata/references.js +104 -0
- package/dist/tools/fiori/metadata/types.js +8 -0
- package/dist/tools/fiori/preview/app-guard.js +215 -0
- package/dist/tools/fiori/preview/env.js +193 -0
- package/dist/tools/fiori/preview/readiness.js +127 -0
- package/dist/tools/fiori/preview/registry.js +412 -0
- package/dist/tools/fiori/preview/start.js +469 -0
- package/dist/tools/fiori/samples/loader.js +20 -5
- package/dist/tools/fiori/scaffold.js +18 -6
- package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
- package/dist/tools/fiori/smoke/app-driver.js +100 -0
- package/dist/tools/fiori/smoke/assertions.js +161 -24
- package/dist/tools/fiori/smoke/audit-columns.js +21 -0
- package/dist/tools/fiori/smoke/batch.js +273 -0
- package/dist/tools/fiori/smoke/draft-safety.js +241 -0
- package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
- package/dist/tools/fiori/smoke/driver.js +171 -4
- package/dist/tools/fiori/smoke/evidence.js +35 -0
- package/dist/tools/fiori/smoke/labels-codes.js +207 -0
- package/dist/tools/fiori/smoke/nav-error.js +26 -0
- package/dist/tools/fiori/smoke/run-smoke.js +155 -12
- package/dist/tools/fiori/tools.js +651 -7
- package/dist/tools/fiori/ui5-version.js +16 -0
- package/dist/tools/index.js +8 -0
- package/dist/tools/local-build.js +6 -0
- package/dist/tools/sap-read.js +16 -1
- package/dist/tools/sap-write.js +122 -10
- package/dist/tools/shell/shell_exec.js +30 -12
- package/dist/tools/syntax-state.js +67 -0
- package/dist/tools/todo.js +23 -18
- package/dist/tools/transport-fit.js +462 -0
- package/dist/tools/transport-resolution.js +3 -1
- package/dist/tools/transport.js +33 -7
- package/dist/tools/verify.js +184 -8
- package/dist/ui/app.js +284 -146
- package/dist/ui/approval-modal.js +41 -15
- package/dist/ui/ask-answer-text.js +17 -0
- package/dist/ui/body.js +25 -6
- package/dist/ui/card-slot.js +98 -0
- package/dist/ui/checklist.js +9 -0
- package/dist/ui/coaching-picker-classic.js +8 -5
- package/dist/ui/command-palette.js +6 -4
- package/dist/ui/confirm-request.js +18 -0
- package/dist/ui/context-grid.js +2 -1
- package/dist/ui/file-palette.js +6 -4
- package/dist/ui/files-card-emitter.js +19 -0
- package/dist/ui/footer-line.js +16 -0
- package/dist/ui/footer.js +105 -104
- package/dist/ui/input-wrap.js +122 -0
- package/dist/ui/interrupt.js +103 -0
- package/dist/ui/key-burst.js +117 -0
- package/dist/ui/line-resolution.js +4 -3
- package/dist/ui/live-area.js +71 -0
- package/dist/ui/login-banner.js +54 -43
- package/dist/ui/rewind-panel.js +76 -24
- package/dist/ui/role-props.js +47 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +49 -3
- package/dist/ui/skill-picker.js +5 -47
- package/dist/ui/text-input.js +128 -24
- package/dist/ui/todo-emitter.js +19 -0
- package/dist/ui/turn-status-emitter.js +28 -2
- package/dist/ui/turn-status.js +16 -19
- package/dist/ui/use-card-request.js +44 -0
- package/dist/ui/widgets/ask-card.js +521 -0
- package/dist/ui/widgets/bar-chart.js +11 -5
- package/dist/ui/widgets/coaching-picker.js +50 -55
- package/dist/ui/widgets/confirm-card.js +193 -0
- package/dist/ui/widgets/dep-graph.js +3 -2
- package/dist/ui/widgets/diff-viewer.js +6 -5
- package/dist/ui/widgets/files-card.js +97 -0
- package/dist/ui/widgets/question-card.js +2 -1
- package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
- package/dist/ui/widgets/shortcuts-sheet.js +25 -0
- package/dist/ui/widgets/stack-frames.js +2 -1
- package/dist/upgrade-check.js +39 -8
- package/package.json +11 -2
- package/test-harness/fe-draft/app.ts +188 -0
- package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
- package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
- package/test-harness/fe-draft/routes.ts +224 -0
- package/test-harness/fe-draft/routes.unit.test.ts +45 -0
- package/test-harness/fe-draft/server.ts +225 -0
- package/test-harness/fe-draft/variants.ts +196 -0
- 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
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
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:
|
|
115
|
-
firstHeading:
|
|
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:
|
|
130
|
+
strong: LEGACY_MD_STYLES.strong,
|
|
123
131
|
em: chalk.italic,
|
|
124
|
-
codespan:
|
|
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:
|
|
137
|
+
listitem: LEGACY_MD_STYLES.listitem,
|
|
130
138
|
hr: chalk.gray,
|
|
131
|
-
paragraph:
|
|
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
|
-
|
|
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
|
|
239
|
-
*
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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,
|
|
249
|
-
.replace(/\x1B\[0m/g, '\x1B[0m' +
|
|
250
|
-
return
|
|
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
|
+
// (" & …); 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
|
+
}
|