@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,156 @@
|
|
|
1
|
+
// src/renderer/change-card.ts
|
|
2
|
+
//
|
|
3
|
+
// Panels look (2026-09-26): one card per completed SAP write. The footer is
|
|
4
|
+
// the SAME honest chain as verify-chain.ts; "activated" only when the write
|
|
5
|
+
// result says the write itself activated (round 8 item 5). Diff lines only when old AND new source are known.
|
|
6
|
+
import { diffLines } from 'diff';
|
|
7
|
+
import { paint } from './theme.js';
|
|
8
|
+
import { getBoxChars } from '../ui/ascii-chars.js';
|
|
9
|
+
import { ANSI_RE } from './tool-labels.js';
|
|
10
|
+
export const MIN_CARD_WIDTH = 40;
|
|
11
|
+
const DIFF_LINES = 6;
|
|
12
|
+
const DETAIL_MAX = 60;
|
|
13
|
+
/** Myers diff is O(N·D) and synchronous: past this budget we show no diff
|
|
14
|
+
* at all (object + footer only), never a partial one. */
|
|
15
|
+
const DIFF_TIMEOUT_MS = 200;
|
|
16
|
+
const defaultDiffer = (a, b) => diffLines(a, b, { timeout: DIFF_TIMEOUT_MS });
|
|
17
|
+
// eslint-disable-next-line no-control-regex
|
|
18
|
+
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
19
|
+
const visibleLen = (s) => s.replace(ANSI, '').length;
|
|
20
|
+
const cut = (s, max) => (s.length > max ? s.slice(0, Math.max(0, max - 1)) + '…' : s);
|
|
21
|
+
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
|
22
|
+
// Source lines go inside a fixed-width box: tabs and control characters
|
|
23
|
+
// (including stray escape sequences in the source) would break the border
|
|
24
|
+
// arithmetic or leak into the terminal, so they are neutralised here. Whole
|
|
25
|
+
// ANSI sequences go first (stripping only ESC would leave "[31m" visible);
|
|
26
|
+
// leading spaces survive so code indentation stays readable.
|
|
27
|
+
// eslint-disable-next-line no-control-regex
|
|
28
|
+
const clean = (s) => s.replace(ANSI_RE, '').replace(/\t/g, ' ').replace(/[\x00-\x1f\x7f]/g, '');
|
|
29
|
+
function footerParts(input) {
|
|
30
|
+
const parts = [];
|
|
31
|
+
if (input.snapshot === 'taken')
|
|
32
|
+
parts.push(`${paint('ok', '✓')} snapshot`);
|
|
33
|
+
else if (input.snapshot === 'new_object')
|
|
34
|
+
parts.push(paint('dim', '– snapshot (new object)'));
|
|
35
|
+
else if (input.snapshot === 'source_read_failed')
|
|
36
|
+
parts.push(paint('dim', `– snapshot (no source read${input.snapshotDetail ? ': ' + cut(flat(clean(input.snapshotDetail)), 40) : ''})`));
|
|
37
|
+
else
|
|
38
|
+
parts.push(paint('dim', '– snapshot (skipped)'));
|
|
39
|
+
parts.push(input.written ? `${paint('ok', '✓')} written` : `${paint('bad', '✗')} written`);
|
|
40
|
+
if (input.syntax === 'not_checked') {
|
|
41
|
+
parts.push(paint('dim', '– syntax (checked at activation)'));
|
|
42
|
+
parts.push(paint('dim', '· activate pending'));
|
|
43
|
+
}
|
|
44
|
+
else if (input.syntax.ok) {
|
|
45
|
+
parts.push(`${paint('ok', '✓')} syntax`);
|
|
46
|
+
// Round 8 item 5 — the real activation state from the write result.
|
|
47
|
+
const a = input.activation;
|
|
48
|
+
if (!a)
|
|
49
|
+
parts.push(paint('dim', '· activate pending'));
|
|
50
|
+
else if (a.state === 'active')
|
|
51
|
+
parts.push(`${paint('ok', '✓')} activated`);
|
|
52
|
+
else if (a.state === 'still_inactive')
|
|
53
|
+
parts.push(`${paint('bad', '✗')} still inactive`);
|
|
54
|
+
else if (a.state === 'unverified')
|
|
55
|
+
parts.push(paint('warn', '· activation unverified'));
|
|
56
|
+
else
|
|
57
|
+
parts.push(`${paint('bad', '✗')} activation${a.detail ? ' ' + paint('bad', `(${cut(flat(clean(a.detail)), DETAIL_MAX)})`) : ''}`);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
// Activation is blocked — say so; a pending step would lie.
|
|
61
|
+
parts.push(`${paint('bad', '✗')} syntax ${paint('bad', `(${cut(flat(clean(input.syntax.errors[0] ?? 'syntax errors')), DETAIL_MAX)})`)}`);
|
|
62
|
+
parts.push(paint('dim', '· activation blocked'));
|
|
63
|
+
}
|
|
64
|
+
return parts;
|
|
65
|
+
}
|
|
66
|
+
/** Changed lines for the card. Exported for tests only. */
|
|
67
|
+
export function diffBody(card, differ = defaultDiffer) {
|
|
68
|
+
if (card.oldSource === undefined || card.newSource === undefined)
|
|
69
|
+
return { lines: [], hidden: 0 };
|
|
70
|
+
const changed = [];
|
|
71
|
+
// SAP serves CRLF source while written source is usually LF: compare on
|
|
72
|
+
// normalised text so an EOL-only difference is never shown as a change.
|
|
73
|
+
const norm = (s) => s.replace(/\r\n?/g, '\n').replace(/\n$/, '');
|
|
74
|
+
const parts = differ(norm(card.oldSource), norm(card.newSource));
|
|
75
|
+
if (parts === undefined)
|
|
76
|
+
return { lines: [], hidden: 0 }; // gave up: no diff, never a partial one
|
|
77
|
+
for (const part of parts) {
|
|
78
|
+
if (!part.added && !part.removed)
|
|
79
|
+
continue;
|
|
80
|
+
for (const l of part.value.replace(/\r?\n$/, '').split(/\r?\n/))
|
|
81
|
+
changed.push(`${part.added ? '+' : '-'} ${clean(l)}`);
|
|
82
|
+
}
|
|
83
|
+
return { lines: changed.slice(0, DIFF_LINES), hidden: Math.max(0, changed.length - DIFF_LINES) };
|
|
84
|
+
}
|
|
85
|
+
export function buildChangeCard(input, width, box = getBoxChars()) {
|
|
86
|
+
const { card } = input;
|
|
87
|
+
const title = clean(`Changed ${card.object} · ${card.what}`);
|
|
88
|
+
const parts = footerParts(input);
|
|
89
|
+
const chain = parts.join(' ');
|
|
90
|
+
const transportText = card.transport ? `· ${clean(card.transport)}` : '';
|
|
91
|
+
const body = diffBody(card);
|
|
92
|
+
if (width < MIN_CARD_WIDTH) {
|
|
93
|
+
const out = [cut(title, width)];
|
|
94
|
+
for (const l of body.lines)
|
|
95
|
+
out.push(paint(l.startsWith('+') ? 'ok' : 'bad', cut(l, width)));
|
|
96
|
+
if (body.hidden > 0)
|
|
97
|
+
out.push(paint('dim', cut(`… +${body.hidden} lines`, width)));
|
|
98
|
+
// Narrow terminal: the same honest parts, same order, one per line, each
|
|
99
|
+
// cut to the width so nothing wraps (colour dropped only on a cut part).
|
|
100
|
+
for (const p of parts)
|
|
101
|
+
out.push(visibleLen(p) > width ? cut(p.replace(ANSI, ''), width) : p);
|
|
102
|
+
if (card.transport)
|
|
103
|
+
out.push(paint('dim', cut(`· ${clean(card.transport)}`, width)));
|
|
104
|
+
return out.join('\n');
|
|
105
|
+
}
|
|
106
|
+
const b = box;
|
|
107
|
+
const inner = width - 4; // "│ " + content + " │"
|
|
108
|
+
const row = (content) => {
|
|
109
|
+
const pad = Math.max(0, inner - visibleLen(content));
|
|
110
|
+
return `${paint('frame', b.v)} ${content}${' '.repeat(pad)} ${paint('frame', b.v)}`;
|
|
111
|
+
};
|
|
112
|
+
// Top: "╭─ " + title + " " + fill + "╮" = width → fill = width - 5 - titleLen (≥ 1).
|
|
113
|
+
const titleText = cut(title, width - 6);
|
|
114
|
+
const top = paint('frame', b.tl + b.h) + ' ' + titleText + ' ' + paint('frame', b.h.repeat(width - 5 - titleText.length) + b.tr);
|
|
115
|
+
const lines = [top];
|
|
116
|
+
for (const l of body.lines)
|
|
117
|
+
lines.push(row(paint(l.startsWith('+') ? 'ok' : 'bad', cut(l, inner))));
|
|
118
|
+
if (body.hidden > 0)
|
|
119
|
+
lines.push(row(paint('dim', ` … +${body.hidden} lines`)));
|
|
120
|
+
lines.push(...footerLines(parts, chain, transportText, width, b, row));
|
|
121
|
+
return lines.join('\n');
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Footer placement. Preferred: the whole footer on the bottom border. Too
|
|
125
|
+
* wide: the chain stays on the border and the transport becomes a row inside
|
|
126
|
+
* the box (a cut transport number would be wrong, a dropped one hides where
|
|
127
|
+
* the change went). Chain still too wide: the chain parts become rows INSIDE
|
|
128
|
+
* the box (same parts, same order, each cut to the inner width, like the
|
|
129
|
+
* narrow path) and a plain bottom border closes it — "activate pending" and
|
|
130
|
+
* the transport are never dropped.
|
|
131
|
+
*/
|
|
132
|
+
function footerLines(parts, chain, transportText, width, b, row) {
|
|
133
|
+
const room = width - 7;
|
|
134
|
+
const chainLen = visibleLen(chain);
|
|
135
|
+
const inner = width - 4;
|
|
136
|
+
if (transportText) {
|
|
137
|
+
const full = chain + ' ' + paint('dim', transportText);
|
|
138
|
+
if (visibleLen(full) <= room)
|
|
139
|
+
return [bottom(full, width, b)];
|
|
140
|
+
// Chain fits the border, the transport does not: transport as a boxed row.
|
|
141
|
+
if (chainLen <= room)
|
|
142
|
+
return [row(paint('dim', cut(transportText, inner))), bottom(chain, width, b)];
|
|
143
|
+
}
|
|
144
|
+
if (chainLen <= room)
|
|
145
|
+
return [bottom(chain, width, b)];
|
|
146
|
+
const out = parts.map((p) => row(visibleLen(p) > inner ? cut(p.replace(ANSI, ''), inner) : p));
|
|
147
|
+
if (transportText)
|
|
148
|
+
out.push(row(paint('dim', cut(transportText, inner))));
|
|
149
|
+
out.push(paint('frame', b.bl + b.h.repeat(width - 2) + b.br));
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
/** Bottom: "╰──" + " " + footer + " " + fill + "╯" = width → fill = width - 6 - footerLen (≥ 1). Callers guarantee the footer fits. */
|
|
153
|
+
function bottom(footer, width, b) {
|
|
154
|
+
const fill = width - 6 - visibleLen(footer);
|
|
155
|
+
return `${paint('frame', b.bl + b.h + b.h)} ${footer} ${paint('frame', b.h.repeat(fill) + b.br)}`;
|
|
156
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// src/renderer/checklist-format.ts
|
|
2
|
+
//
|
|
3
|
+
// Piece 2 (spec §4) — the plan checklist, as pure strings. The live Ink
|
|
4
|
+
// checklist (ui/checklist.tsx), the classic/headless scrollback block
|
|
5
|
+
// (renderer/todo-block.ts) and the turn-end close line all come from here, so
|
|
6
|
+
// the three can never disagree about counts, glyphs or which step is current.
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
import { getGlyphs } from './glyphs.js';
|
|
9
|
+
import { paint } from './theme.js';
|
|
10
|
+
import { sanitizeControlChars } from './sanitize.js';
|
|
11
|
+
import { truncateToWidth } from './ui-width.js';
|
|
12
|
+
import { shouldUseInk, isHeadless } from './tty.js';
|
|
13
|
+
/** Item rows shown before folding (spec §4: "At most 7 rows"). The header is extra. */
|
|
14
|
+
export const CHECKLIST_MAX_ROWS = 7;
|
|
15
|
+
const INDENT = ' ';
|
|
16
|
+
/** The one count format used everywhere (spec §4): "N of M". */
|
|
17
|
+
export function countLabel(done, total) {
|
|
18
|
+
return `${done} of ${total}`;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The live step: the LAST in_progress item, or -1. Same rule as todo_set's
|
|
22
|
+
* `in_progress` echo (tools/todo.ts, pinned by todo-current-pin.test.ts) —
|
|
23
|
+
* models batch transitions, so an earlier in_progress item is stale.
|
|
24
|
+
*/
|
|
25
|
+
export function currentTodoIndex(todos) {
|
|
26
|
+
for (let i = todos.length - 1; i >= 0; i--) {
|
|
27
|
+
if (todos[i].status === 'in_progress')
|
|
28
|
+
return i;
|
|
29
|
+
}
|
|
30
|
+
return -1;
|
|
31
|
+
}
|
|
32
|
+
/** True when nothing is left to show: an empty list, or every item completed. */
|
|
33
|
+
export function isPlanFinished(todos) {
|
|
34
|
+
return todos.length === 0 || todos.every((t) => t.status === 'completed');
|
|
35
|
+
}
|
|
36
|
+
function doneCount(todos) {
|
|
37
|
+
return todos.filter((t) => t.status === 'completed').length;
|
|
38
|
+
}
|
|
39
|
+
function allRows(todos, cur) {
|
|
40
|
+
return todos.map((t, i) => {
|
|
41
|
+
if (t.status === 'completed')
|
|
42
|
+
return { kind: 'done', text: t.text };
|
|
43
|
+
return { kind: i === cur ? 'current' : 'pending', text: t.text };
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Folded view (spec §4): "✓ K done", then the current row and the rows after
|
|
48
|
+
* it, then "… N more". Open items BEFORE the current one (rare — a stale
|
|
49
|
+
* pending step) come after the ones that follow it. `cap` is the item-row cap.
|
|
50
|
+
*/
|
|
51
|
+
function foldedRows(todos, cur, cap) {
|
|
52
|
+
const done = doneCount(todos);
|
|
53
|
+
const open = todos.map((t, i) => ({ t, i })).filter((x) => x.t.status !== 'completed');
|
|
54
|
+
const pivot = cur >= 0 ? cur : (open[0]?.i ?? 0);
|
|
55
|
+
const ordered = [...open.filter((x) => x.i >= pivot), ...open.filter((x) => x.i < pivot)];
|
|
56
|
+
const toRow = (x) => ({ kind: x.i === cur ? 'current' : 'pending', text: x.t.text });
|
|
57
|
+
if (cap <= 1) {
|
|
58
|
+
return ordered.length > 0 ? [toRow(ordered[0])] : [{ kind: 'summary', n: done }];
|
|
59
|
+
}
|
|
60
|
+
const head = done > 0 && cap >= 3 ? [{ kind: 'summary', n: done }] : [];
|
|
61
|
+
const slots = Math.max(1, cap - head.length - 1); // one row kept for "… N more"
|
|
62
|
+
const shown = ordered.slice(0, slots).map(toRow);
|
|
63
|
+
const more = ordered.length - shown.length;
|
|
64
|
+
const rows = [...head, ...shown];
|
|
65
|
+
if (more > 0)
|
|
66
|
+
rows.push({ kind: 'more', n: more });
|
|
67
|
+
return rows;
|
|
68
|
+
}
|
|
69
|
+
function renderRow(r, width) {
|
|
70
|
+
const g = getGlyphs();
|
|
71
|
+
const fit = (s) => truncateToWidth(INDENT + s, width);
|
|
72
|
+
switch (r.kind) {
|
|
73
|
+
case 'done': return paint('dim', fit(`${g.done} ${r.text}`));
|
|
74
|
+
case 'current': return chalk.bold(paint('sap', fit(`${g.current} ${r.text}`)));
|
|
75
|
+
case 'pending': return paint('dim', fit(`${g.pending} ${r.text}`));
|
|
76
|
+
case 'summary': return paint('dim', fit(`${g.done} ${r.n} done`));
|
|
77
|
+
case 'more': return paint('dim', fit(`${g.more} ${r.n} more`));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The checklist as display lines: `Plan · N of M` header, then rows.
|
|
82
|
+
* - maxRows: cap on ITEM rows (header excluded). Not expanded → also capped
|
|
83
|
+
* at CHECKLIST_MAX_ROWS; expanded → only maxRows (the §2 height cap) applies.
|
|
84
|
+
* - foldedToOne: exactly one line (used while a card is open).
|
|
85
|
+
* Todo text comes from the model: control characters are neutralised
|
|
86
|
+
* (sanitizeControlChars) and every line is truncated to `width` before it is
|
|
87
|
+
* painted, as the question cards do.
|
|
88
|
+
*/
|
|
89
|
+
export function formatChecklist(list, o) {
|
|
90
|
+
if (list.length === 0)
|
|
91
|
+
return [];
|
|
92
|
+
const todos = list.map((t) => ({ ...t, text: sanitizeControlChars(t.text) }));
|
|
93
|
+
const width = Math.max(1, Math.floor(o.width));
|
|
94
|
+
const cur = currentTodoIndex(todos);
|
|
95
|
+
const count = `Plan · ${countLabel(doneCount(todos), todos.length)}`;
|
|
96
|
+
if (o.foldedToOne) {
|
|
97
|
+
const tail = cur >= 0 ? ` · ${getGlyphs().current} ${todos[cur].text}` : '';
|
|
98
|
+
return [chalk.bold(truncateToWidth(count + tail, width))];
|
|
99
|
+
}
|
|
100
|
+
const cap = Math.max(1, o.expanded ? o.maxRows : Math.min(o.maxRows, CHECKLIST_MAX_ROWS));
|
|
101
|
+
const rows = todos.length <= cap ? allRows(todos, cur) : foldedRows(todos, cur, cap);
|
|
102
|
+
return [chalk.bold(truncateToWidth(count, width)), ...rows.map((r) => renderRow(r, width))];
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The one line a turn leaves in scrollback when its plan changed (spec §4):
|
|
106
|
+
* `✓ Plan · M of M done` (ok) when finished, else `Plan · N of M done` (dim).
|
|
107
|
+
*/
|
|
108
|
+
export function formatPlanClose(todos) {
|
|
109
|
+
const done = doneCount(todos);
|
|
110
|
+
const total = todos.length;
|
|
111
|
+
if (total > 0 && done === total) {
|
|
112
|
+
return paint('ok', `${getGlyphs().done} Plan · ${countLabel(done, total)} done`);
|
|
113
|
+
}
|
|
114
|
+
return paint('dim', `Plan · ${countLabel(done, total)} done`);
|
|
115
|
+
}
|
|
116
|
+
let modeOverride;
|
|
117
|
+
/** Test seam — force a mode; `undefined` restores detection. */
|
|
118
|
+
export function setChecklistModeForTest(m) {
|
|
119
|
+
modeOverride = m;
|
|
120
|
+
}
|
|
121
|
+
export function checklistRenderMode() {
|
|
122
|
+
if (modeOverride)
|
|
123
|
+
return modeOverride;
|
|
124
|
+
// Headless first: a one-shot or piped run under a `ui.rendering = "ink"`
|
|
125
|
+
// override still has a stdout TTY, so shouldUseInk() is true, but no live
|
|
126
|
+
// frame is mounted — the plan must print per update.
|
|
127
|
+
if (isHeadless())
|
|
128
|
+
return 'per-update';
|
|
129
|
+
return shouldUseInk() ? 'live' : 'turn-end';
|
|
130
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// src/renderer/color-mode.ts
|
|
2
|
+
//
|
|
3
|
+
// NO_COLOR (CLI look piece 2, spec §1.2). One switch: applyColorMode() at
|
|
4
|
+
// startup maps NO_COLOR (set and non-empty) to chalk.level = 0. Colour
|
|
5
|
+
// sources that are NOT the app's chalk (cli-table3's @colors/colors,
|
|
6
|
+
// cli-highlight's chalk 4, the raw legacy zebra escape) key on colorOff(),
|
|
7
|
+
// i.e. chalk.level === 0 — so NO_COLOR, a piped stream and a test that sets
|
|
8
|
+
// chalk.level all behave the same, and the legacy pins (chalk.level = 3)
|
|
9
|
+
// cannot be flipped by a CI NO_COLOR. Ink components use ui/role-props.ts.
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
let override;
|
|
12
|
+
export function isNoColor() {
|
|
13
|
+
if (override !== undefined)
|
|
14
|
+
return override;
|
|
15
|
+
const v = process.env['NO_COLOR'];
|
|
16
|
+
return typeof v === 'string' && v !== '';
|
|
17
|
+
}
|
|
18
|
+
/** Tests only. `undefined` clears the override. */
|
|
19
|
+
export function setNoColorForTest(v) {
|
|
20
|
+
override = v;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Call once at startup, before the first line of output.
|
|
24
|
+
*
|
|
25
|
+
* Under NO_COLOR it also sets FORCE_COLOR=0: chalk 5's bundled supports-color
|
|
26
|
+
* ignores NO_COLOR but honours FORCE_COLOR, so a chalk instance loaded AFTER
|
|
27
|
+
* this call (Ink and ink-text-input, if a package manager gives them their own
|
|
28
|
+
* copy) starts at level 0 too. That only works because cli.ts main() calls
|
|
29
|
+
* this before its lazy `await import('./repl.js')` / `('./one-shot.js')`, and
|
|
30
|
+
* cli.ts's static import graph does not reach Ink.
|
|
31
|
+
*/
|
|
32
|
+
export function applyColorMode() {
|
|
33
|
+
if (isNoColor()) {
|
|
34
|
+
chalk.level = 0;
|
|
35
|
+
process.env['FORCE_COLOR'] = '0';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** True when colour output is off (NO_COLOR applied, non-colour stream, or level 0). */
|
|
39
|
+
export function colorOff() {
|
|
40
|
+
return chalk.level === 0;
|
|
41
|
+
}
|
|
42
|
+
/** cli-table3 style: the coloured one, or empty head/border styles when colour is off. */
|
|
43
|
+
export function tableStyle(colored) {
|
|
44
|
+
return colorOff() ? { head: [], border: [] } : colored;
|
|
45
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* humanErrorDetail — readable detail lines for a failed tool row.
|
|
3
|
+
*
|
|
4
|
+
* Live fix 2026-09-26: a failed `sap_activate` row printed its raw JSON
|
|
5
|
+
* result (`{"success":false,"activation_results":[{"name":"ZC_X",…`). The
|
|
6
|
+
* row detail must say what went wrong, in words:
|
|
7
|
+
* - activation results → one line per failed object: `ZC_X: <error text>`
|
|
8
|
+
* (error-severity messages first; still-inactive objects when SAP gave
|
|
9
|
+
* no message);
|
|
10
|
+
* - other JSON → its `message` / `reason` field, or the existing
|
|
11
|
+
* error/detail + ADT-exception summary (formatToolErrorSummary);
|
|
12
|
+
* - anything still JSON-shaped → collapsed to ONE line (the caller
|
|
13
|
+
* truncates it to the width).
|
|
14
|
+
* Plain text passes through line by line. The model's tool result is not
|
|
15
|
+
* touched — this only shapes the on-screen row.
|
|
16
|
+
*/
|
|
17
|
+
import { formatToolErrorSummary } from '../sap-errors/parse-adt-exception.js';
|
|
18
|
+
import { cleanErrorText, looksLikeMarkup } from '../sap-errors/clean-error-text.js';
|
|
19
|
+
function messageText(m) {
|
|
20
|
+
if (typeof m === 'string')
|
|
21
|
+
return m;
|
|
22
|
+
if (m && typeof m === 'object') {
|
|
23
|
+
const o = m;
|
|
24
|
+
for (const k of ['shortText', 'text', 'message', 'longText']) {
|
|
25
|
+
const v = o[k];
|
|
26
|
+
if (typeof v === 'string' && v.trim())
|
|
27
|
+
return v;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
32
|
+
function isErrorMessage(m) {
|
|
33
|
+
if (typeof m === 'string')
|
|
34
|
+
return true;
|
|
35
|
+
const sev = m && typeof m === 'object' ? String(m['severity'] ?? m['type'] ?? '') : '';
|
|
36
|
+
return /^(e|error|a|abort|fatal)$/i.test(sev);
|
|
37
|
+
}
|
|
38
|
+
function activationLines(obj) {
|
|
39
|
+
const results = obj['activation_results'];
|
|
40
|
+
if (!Array.isArray(results))
|
|
41
|
+
return [];
|
|
42
|
+
const lines = [];
|
|
43
|
+
for (const r of results) {
|
|
44
|
+
if (!r || typeof r !== 'object' || r['success'] === true)
|
|
45
|
+
continue;
|
|
46
|
+
const msgs = Array.isArray(r['messages']) ? r['messages'] : [];
|
|
47
|
+
const ordered = [...msgs.filter(isErrorMessage), ...msgs.filter((m) => !isErrorMessage(m))];
|
|
48
|
+
const text = ordered.map(messageText).find((t) => t.trim()) ?? 'activation failed';
|
|
49
|
+
lines.push(`${String(r['name'] ?? '?')}: ${text}`);
|
|
50
|
+
}
|
|
51
|
+
if (lines.length === 0) {
|
|
52
|
+
const still = obj['still_inactive'];
|
|
53
|
+
if (Array.isArray(still))
|
|
54
|
+
for (const s of still)
|
|
55
|
+
lines.push(`${String(s)} still inactive`);
|
|
56
|
+
const unv = obj['unverifiable'];
|
|
57
|
+
if (Array.isArray(unv)) {
|
|
58
|
+
for (const u of unv)
|
|
59
|
+
lines.push(`${String(u?.['object'] ?? '?')} not verified: ${String(u?.['reason'] ?? '')}`.trim());
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (lines.length === 0 && typeof obj['note'] === 'string')
|
|
63
|
+
lines.push(obj['note']);
|
|
64
|
+
return lines;
|
|
65
|
+
}
|
|
66
|
+
const oneLine = (s) => s.replace(/\s+/g, ' ').trim();
|
|
67
|
+
function detailLines(raw) {
|
|
68
|
+
const t = raw.trim();
|
|
69
|
+
if (t.startsWith('{')) {
|
|
70
|
+
let obj;
|
|
71
|
+
try {
|
|
72
|
+
obj = JSON.parse(t);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
obj = undefined;
|
|
76
|
+
}
|
|
77
|
+
if (obj && typeof obj === 'object') {
|
|
78
|
+
const act = activationLines(obj);
|
|
79
|
+
if (act.length > 0)
|
|
80
|
+
return act;
|
|
81
|
+
for (const k of ['message', 'reason']) {
|
|
82
|
+
const v = obj[k];
|
|
83
|
+
if (typeof v === 'string' && v.trim() && typeof obj['error'] !== 'string' && typeof obj['detail'] !== 'string') {
|
|
84
|
+
return v.split('\n');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const summary = formatToolErrorSummary(raw);
|
|
89
|
+
if (summary.trim().startsWith('{'))
|
|
90
|
+
return [oneLine(summary)];
|
|
91
|
+
return summary.split('\n');
|
|
92
|
+
}
|
|
93
|
+
return raw.split('\n');
|
|
94
|
+
}
|
|
95
|
+
// Fix round 3 (item 10): a thrown sap-client error reaches the tool result as
|
|
96
|
+
// String(err) — "SapNotFoundError: Object not found: …". The class name is
|
|
97
|
+
// developer noise on a failure row; the message after it is the reason.
|
|
98
|
+
const SAP_ERROR_CLASS = /^Sap[A-Za-z]*Error:\s+/;
|
|
99
|
+
export function humanErrorDetail(raw) {
|
|
100
|
+
// An ICM HTML error page inside a message: no tags or CSS on screen.
|
|
101
|
+
return detailLines(raw)
|
|
102
|
+
.map((l) => l.replace(SAP_ERROR_CLASS, ''))
|
|
103
|
+
.map((l) => (looksLikeMarkup(l) ? cleanErrorText(l, 400) : l));
|
|
104
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// src/renderer/fence-state.ts
|
|
2
|
+
//
|
|
3
|
+
// Piece 2 §11.2 — while the markdown renderer holds back an unfinished fenced
|
|
4
|
+
// code block (it never flushes inside a fence), the answer looks frozen. The
|
|
5
|
+
// status strip says "writing code…" for exactly that stretch.
|
|
6
|
+
//
|
|
7
|
+
// One CommonMark fence rule, shared with pipeline.ts panelsFlushPoint so the
|
|
8
|
+
// label and the hold-back can never disagree: ``` or ~~~, the closer uses the
|
|
9
|
+
// same character, is at least as long, and has nothing after it; a ``` line
|
|
10
|
+
// with a backtick in its info string is not a fence. Lines are taken as-is
|
|
11
|
+
// (a trailing '\r' is not stripped, exactly as panelsFlushPoint has always
|
|
12
|
+
// behaved). Only complete lines are scanned.
|
|
13
|
+
/** CommonMark fence line: up to 3 spaces, then 3+ backticks or tildes. */
|
|
14
|
+
export const FENCE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
15
|
+
/**
|
|
16
|
+
* Advance the fence state by one complete line. `consumed` is true when the
|
|
17
|
+
* line is inside a fence (including its closer) or opens one, so the caller
|
|
18
|
+
* must not treat it as ordinary text (e.g. a blank-line flush point).
|
|
19
|
+
*/
|
|
20
|
+
export function stepFence(fence, line) {
|
|
21
|
+
const f = FENCE_RE.exec(line);
|
|
22
|
+
if (fence) {
|
|
23
|
+
if (f && f[1][0] === fence.ch && f[1].length >= fence.len && f[2].trim() === '')
|
|
24
|
+
return { fence: undefined, consumed: true };
|
|
25
|
+
return { fence, consumed: true };
|
|
26
|
+
}
|
|
27
|
+
if (f && !(f[1][0] === '`' && f[2].includes('`')))
|
|
28
|
+
return { fence: { ch: f[1][0], len: f[1].length }, consumed: true };
|
|
29
|
+
return { fence: undefined, consumed: false };
|
|
30
|
+
}
|
|
31
|
+
export const WRITING_CODE_LABEL = 'writing code…';
|
|
32
|
+
export const WRITING_RESPONSE_LABEL = 'writing response';
|
|
33
|
+
export function hasOpenFence(text) {
|
|
34
|
+
let fence;
|
|
35
|
+
const lines = text.split('\n');
|
|
36
|
+
lines.pop(); // the last element is an incomplete line (or '' after a final \n)
|
|
37
|
+
for (const line of lines)
|
|
38
|
+
fence = stepFence(fence, line).fence;
|
|
39
|
+
return fence !== undefined;
|
|
40
|
+
}
|
|
41
|
+
export function nextWritingActivity(prevWritingCode, pending) {
|
|
42
|
+
const writingCode = hasOpenFence(pending);
|
|
43
|
+
if (writingCode === prevWritingCode)
|
|
44
|
+
return { writingCode, label: null };
|
|
45
|
+
return { writingCode, label: writingCode ? WRITING_CODE_LABEL : WRITING_RESPONSE_LABEL };
|
|
46
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// src/renderer/footer-line.ts
|
|
2
|
+
//
|
|
3
|
+
// Piece 2 (spec §8) — the one footer line under the input box, and the
|
|
4
|
+
// one-line per-turn receipt. Pure strings (roles via theme.ts), so the Ink
|
|
5
|
+
// component, the classic receipt and one-shot share one formatter.
|
|
6
|
+
import stringWidth from 'string-width';
|
|
7
|
+
import { paint } from './theme.js';
|
|
8
|
+
import { truncateToWidth } from './ui-width.js';
|
|
9
|
+
import { sanitizeControlChars } from './sanitize.js';
|
|
10
|
+
import { formatCost } from '../cost/pricing.js';
|
|
11
|
+
import { formatCredits, CREDITS_PENDING, creditsActive } from '../cost/credits.js';
|
|
12
|
+
export const SHORTCUTS_HINT = '? shortcuts';
|
|
13
|
+
const SEP = ' · ';
|
|
14
|
+
/**
|
|
15
|
+
* The live spend segment (moved verbatim from ui/status-line.tsx). Single home
|
|
16
|
+
* for the credits/dollar switch: a managed customer sees the session credit
|
|
17
|
+
* total, BYOK/local see the session dollars — never the other.
|
|
18
|
+
*/
|
|
19
|
+
export function formatSpendSegment(s) {
|
|
20
|
+
if (!s.creditsMode)
|
|
21
|
+
return `$${s.dollarSpendToday.toFixed(2)}`;
|
|
22
|
+
if (s.creditsPending && s.creditsSessionTotal === 0)
|
|
23
|
+
return CREDITS_PENDING;
|
|
24
|
+
return formatCredits(s.creditsSessionTotal, s.creditsUnitLabel);
|
|
25
|
+
}
|
|
26
|
+
/** Store → footer facts. F-L2-1: the transport falls back to the one a write actually stamped. */
|
|
27
|
+
export function footerStateFrom(s) {
|
|
28
|
+
return {
|
|
29
|
+
system: s.system || undefined,
|
|
30
|
+
user: s.user || undefined,
|
|
31
|
+
transport: (s.transport ?? s.lastWriteTransport) || undefined,
|
|
32
|
+
standalone: s.standalone === true,
|
|
33
|
+
mode: s.writeMode ?? '',
|
|
34
|
+
spend: formatSpendSegment(s),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const MODE_ROLE = { 'advisory-only': 'warn', 'approval-gated': 'ok', auto: 'sap' };
|
|
38
|
+
/**
|
|
39
|
+
* `<SYS> · <USER> · transport <TR> · <mode> · <spend>` on the left and a dim
|
|
40
|
+
* `? shortcuts` on the right, padded to exactly `width`. When it does not fit,
|
|
41
|
+
* the hint drops first, then the left side truncates.
|
|
42
|
+
*/
|
|
43
|
+
export function formatFooterLine(s, width) {
|
|
44
|
+
const parts = [];
|
|
45
|
+
if (s.standalone) {
|
|
46
|
+
parts.push({ text: 'standalone', role: 'sap' }, { text: 'local files', role: 'dim' });
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
// System, user and transport come from config / SAP responses (the
|
|
50
|
+
// write-stamped transport has no format check) — never let them drive
|
|
51
|
+
// the terminal.
|
|
52
|
+
parts.push(s.system ? { text: sanitizeControlChars(s.system), role: 'sap' } : { text: 'no system', role: 'dim' });
|
|
53
|
+
if (s.user)
|
|
54
|
+
parts.push({ text: sanitizeControlChars(s.user), role: 'dim' });
|
|
55
|
+
parts.push({ text: s.transport ? `transport ${sanitizeControlChars(s.transport)}` : 'no transport', role: 'dim' });
|
|
56
|
+
}
|
|
57
|
+
if (s.mode)
|
|
58
|
+
parts.push({ text: s.mode, role: MODE_ROLE[s.mode] ?? 'dim' });
|
|
59
|
+
if (s.spend)
|
|
60
|
+
parts.push({ text: s.spend, role: 'dim' });
|
|
61
|
+
const w = Math.max(1, Math.floor(width));
|
|
62
|
+
const plain = parts.map((p) => p.text).join(SEP);
|
|
63
|
+
const plainW = stringWidth(plain);
|
|
64
|
+
const hintW = stringWidth(SHORTCUTS_HINT);
|
|
65
|
+
if (plainW + 2 + hintW <= w) {
|
|
66
|
+
const left = parts.map((p) => paint(p.role, p.text)).join(paint('dim', SEP));
|
|
67
|
+
return left + ' '.repeat(w - plainW - hintW) + paint('dim', SHORTCUTS_HINT);
|
|
68
|
+
}
|
|
69
|
+
return paint('dim', truncateToWidth(plain, w));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* This turn's spend for the receipt: credits charged this turn in credits mode
|
|
73
|
+
* (updating marker when unknown — never a dollar fallback), the turn's USD
|
|
74
|
+
* cost otherwise, '' when there is none.
|
|
75
|
+
*/
|
|
76
|
+
export function turnSpendText(p) {
|
|
77
|
+
if (creditsActive()) {
|
|
78
|
+
const c = p.credits;
|
|
79
|
+
if (!c || c.chargedThisTurn === null)
|
|
80
|
+
return CREDITS_PENDING;
|
|
81
|
+
return formatCredits(c.chargedThisTurn, c.unitLabel);
|
|
82
|
+
}
|
|
83
|
+
return p.costThisTurn != null ? formatCost(p.costThisTurn) : '';
|
|
84
|
+
}
|
|
85
|
+
/** `/<skill> · <tokens> tokens · <this-turn spend> · <time>` — one dim line, no newline. */
|
|
86
|
+
export function formatTurnReceipt(p) {
|
|
87
|
+
const parts = [];
|
|
88
|
+
if (p.skill && p.skill !== '(none)')
|
|
89
|
+
parts.push(p.skill.startsWith('/') ? p.skill : `/${p.skill}`);
|
|
90
|
+
parts.push(`${p.tokens.toLocaleString('en-US')} tokens`);
|
|
91
|
+
if (p.spend)
|
|
92
|
+
parts.push(p.spend);
|
|
93
|
+
if (p.ms > 0)
|
|
94
|
+
parts.push(`${(p.ms / 1000).toFixed(1)}s`);
|
|
95
|
+
return paint('dim', parts.join(SEP));
|
|
96
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// src/renderer/glyphs.ts
|
|
2
|
+
//
|
|
3
|
+
// One glyph set for every panels / new surface (CLI look piece 2, spec §1.3).
|
|
4
|
+
// The glyphs this set replaces are retired outside legacy-pinned code; the
|
|
5
|
+
// list lives in spec §1.3 and in test/unit/ui-guards.test.ts (not spelled out
|
|
6
|
+
// here, or the guard would flag this comment). ASCII fallback reuses the same
|
|
7
|
+
// legacy-Windows-console detection as ui/ascii-chars.ts.
|
|
8
|
+
import { shouldUseUnicodeBoxChars } from './tty.js';
|
|
9
|
+
export const UNICODE_GLYPHS = {
|
|
10
|
+
done: '✓', failed: '✗', attention: '!', info: '·', current: '◼', pending: '☐',
|
|
11
|
+
answered: '☒', cursor: '❯', trace: '?', tickOn: '[x]', tickOff: '[ ]', more: '…', arrow: '→',
|
|
12
|
+
};
|
|
13
|
+
export const ASCII_GLYPHS = {
|
|
14
|
+
done: 'v', failed: 'x', attention: '!', info: '-', current: '>', pending: '[ ]',
|
|
15
|
+
answered: '[x]', cursor: '>', trace: '?', tickOn: '[x]', tickOff: '[ ]', more: '...', arrow: '->',
|
|
16
|
+
};
|
|
17
|
+
let modeOverride;
|
|
18
|
+
/** Tests only: pin the set regardless of platform / terminal. `undefined` clears. */
|
|
19
|
+
export function setGlyphModeForTest(mode) {
|
|
20
|
+
modeOverride = mode;
|
|
21
|
+
}
|
|
22
|
+
export function getGlyphs() {
|
|
23
|
+
const unicode = modeOverride !== undefined ? modeOverride === 'unicode' : shouldUseUnicodeBoxChars();
|
|
24
|
+
return unicode ? UNICODE_GLYPHS : ASCII_GLYPHS;
|
|
25
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// src/renderer/goodbye.ts
|
|
2
|
+
//
|
|
3
|
+
// Piece 2 §10: the /exit block — aligned label/value rows, spend in the
|
|
4
|
+
// current display mode (credits for managed users; the caller passes the
|
|
5
|
+
// value from formatSessionSpendValue), and ONE Next: line to resume.
|
|
6
|
+
// Session id and skill come from the session file, so they are sanitized
|
|
7
|
+
// before they reach the terminal.
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
import { panelRow } from './panel-rows.js';
|
|
10
|
+
import { paint } from './theme.js';
|
|
11
|
+
import { formatInfo } from './notice-shape.js';
|
|
12
|
+
import { sanitizeControlChars } from './sanitize.js';
|
|
13
|
+
const LABEL = 10; // 'Last skill'
|
|
14
|
+
/**
|
|
15
|
+
* `logDir` — the display path of this session's turn-stream folder, passed
|
|
16
|
+
* only when it EXISTS (fix round 3, item 11b: the block named
|
|
17
|
+
* `streams/<id>.md`, a file that never exists — the logs are
|
|
18
|
+
* `streams/<id>/<timestamp>.md`). Absent → no log line.
|
|
19
|
+
*/
|
|
20
|
+
export function formatGoodbye(p) {
|
|
21
|
+
const id = sanitizeControlChars(p.sessionId);
|
|
22
|
+
const skill = p.lastSkill ? `/${sanitizeControlChars(p.lastSkill)}` : '(none)';
|
|
23
|
+
return [
|
|
24
|
+
'',
|
|
25
|
+
chalk.bold(`Session ${id}`),
|
|
26
|
+
panelRow('Turns', String(p.turns), LABEL),
|
|
27
|
+
panelRow('Last skill', skill, LABEL),
|
|
28
|
+
panelRow('Tokens', `${p.tokens.toLocaleString('en-US')} (input + output + cache)`, LABEL),
|
|
29
|
+
panelRow('Spend', p.spend, LABEL),
|
|
30
|
+
'',
|
|
31
|
+
// Round 6 review I1 — a session never written (no turns) has nothing to resume.
|
|
32
|
+
p.saved === false
|
|
33
|
+
? ` ${paint('dim', 'No session saved (no turns)')}`
|
|
34
|
+
: ` ${paint('dim', 'Next:')} cspeach --resume ${id}`,
|
|
35
|
+
...(p.logDir ? [formatInfo(`Full log: ${sanitizeControlChars(p.logDir)}`).trimEnd()] : []),
|
|
36
|
+
'',
|
|
37
|
+
];
|
|
38
|
+
}
|