@cspeach/cli 1.1.17 → 1.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +80 -22
- 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/skills/signing-public-key.js +1 -1
- 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
package/dist/renderer/notices.js
CHANGED
|
@@ -28,21 +28,50 @@
|
|
|
28
28
|
* their ctx.chunkEmitter stays undefined so child-turn notices remain
|
|
29
29
|
* muted — exactly the pre-existing contract pinned in agent_run.test.ts.
|
|
30
30
|
*/
|
|
31
|
-
import
|
|
31
|
+
import { writeStdout } from './transcript-flow.js';
|
|
32
|
+
import { writeAboveSpinner } from './tool-widget.js';
|
|
33
|
+
import { formatAttention, formatInfo } from './notice-shape.js';
|
|
34
|
+
import { cleanErrorText, looksLikeMarkup } from '../sap-errors/clean-error-text.js';
|
|
35
|
+
import stringWidth from 'string-width';
|
|
36
|
+
import { getLook } from './look.js';
|
|
37
|
+
import { getGlyphs } from './glyphs.js';
|
|
38
|
+
import { truncateToWidth, uiWidth } from './ui-width.js';
|
|
39
|
+
import { getNoticeLogPath, recordFullNotice } from './notice-log.js';
|
|
40
|
+
import { getEffectiveBodyWidth, isDeclaredOneShot } from './tty.js';
|
|
32
41
|
import { EventEmitter } from 'node:events';
|
|
33
42
|
/**
|
|
34
43
|
* Format one notice as a single terminal line (trailing newline included).
|
|
35
44
|
* Multi-line messages are collapsed to one line — notices are glanceable
|
|
36
45
|
* margin notes, not documents; anything long belongs in the tool result.
|
|
37
46
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
47
|
+
* Piece 2 §9: warn → the attention shape ("! message"), info → the info
|
|
48
|
+
* shape ("· message", dim). Same glyphs as every other notice.
|
|
40
49
|
*/
|
|
41
50
|
export function formatNoticeLine(level, message) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
51
|
+
// Live fix 2026-09-26: a SAP HTML error page must never leak tags/CSS here.
|
|
52
|
+
const readable = looksLikeMarkup(message) ? cleanErrorText(message, 400) : message;
|
|
53
|
+
let oneLine = readable.replace(/\s*\r?\n\s*/g, ' ').trim();
|
|
54
|
+
// Live fix 2026-09-26 (panels): never wrap mid-word — cut to one screen
|
|
55
|
+
// line ("<glyph> " + text ≤ uiWidth) and keep the full text in the
|
|
56
|
+
// session's notice log. Legacy stays byte-identical.
|
|
57
|
+
// Review I3: only when a screen is there to wrap (stdout is a TTY) or the
|
|
58
|
+
// full text has somewhere to go (a notice log). One-shot / piped output
|
|
59
|
+
// keeps the whole single line — nothing else would record it.
|
|
60
|
+
// Re-review R3: a one-shot on a terminal has no notice log either, so it
|
|
61
|
+
// keeps the whole line too.
|
|
62
|
+
const wrapsOnScreen = process.stdout.isTTY === true && !isDeclaredOneShot();
|
|
63
|
+
if (getLook() === 'panels' && (wrapsOnScreen || getNoticeLogPath() !== undefined)) {
|
|
64
|
+
const g = getGlyphs();
|
|
65
|
+
const room = Math.min(uiWidth(), getEffectiveBodyWidth()) - stringWidth(level === 'warn' ? g.attention : g.info) - 1;
|
|
66
|
+
const cut = truncateToWidth(oneLine, room);
|
|
67
|
+
// Review M10: truncateToWidth turns tabs into two spaces — compare against
|
|
68
|
+
// the same normalisation so only a real cut is logged.
|
|
69
|
+
if (cut !== oneLine.replace(/\t/g, ' ')) {
|
|
70
|
+
recordFullNotice(level, message);
|
|
71
|
+
oneLine = cut;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return level === 'warn' ? formatAttention({ text: oneLine }) : formatInfo(oneLine);
|
|
46
75
|
}
|
|
47
76
|
/**
|
|
48
77
|
* Build the output emitter for the NON-Ink paths (classic REPL, one-shot).
|
|
@@ -55,7 +84,10 @@ export function formatNoticeLine(level, message) {
|
|
|
55
84
|
*
|
|
56
85
|
* The `write` sink defaults to process.stdout — injectable for tests.
|
|
57
86
|
*/
|
|
58
|
-
export function createClassicOutputEmitter(
|
|
87
|
+
export function createClassicOutputEmitter(
|
|
88
|
+
// Panels: through the stdout transcript flow (spacing); legacy: verbatim.
|
|
89
|
+
// Above the classic tool spinner when one is painting (live fix 2026-09-26).
|
|
90
|
+
write = (s) => writeAboveSpinner(s, writeStdout)) {
|
|
59
91
|
const emitter = new EventEmitter();
|
|
60
92
|
emitter.on('chunk', (s) => write(s));
|
|
61
93
|
emitter.on('warn', (msg) => write(formatNoticeLine('warn', msg)));
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// src/renderer/panel-rows.ts
|
|
2
|
+
//
|
|
3
|
+
// Piece 2 §10: every panel-like surface (whoami, /cost, /compact, /ui, /recap,
|
|
4
|
+
// goodbye, help) uses ONE heading style (bold) and dim label / plain value rows.
|
|
5
|
+
// Callers sanitize server- or model-supplied values before passing them in.
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import { paint } from './theme.js';
|
|
8
|
+
export function panelHeading(title, sub) {
|
|
9
|
+
return chalk.bold(title) + (sub ? ' ' + paint('dim', sub) : '');
|
|
10
|
+
}
|
|
11
|
+
export function panelRow(label, value, labelWidth = 12) {
|
|
12
|
+
return ' ' + paint('dim', label.padEnd(labelWidth)) + ' ' + value;
|
|
13
|
+
}
|
|
14
|
+
export function formatPanel(title, rows, sub) {
|
|
15
|
+
return [panelHeading(title, sub), ...rows];
|
|
16
|
+
}
|
|
@@ -15,6 +15,13 @@ import { WIDGET_CONTROL } from '../ui/widget-control.js'; // NEW IN v0.3
|
|
|
15
15
|
// kept commented-out as a breadcrumb; the file itself is retained in case
|
|
16
16
|
// any other skill still emits a widget-real block we want to catch.
|
|
17
17
|
import { renderQuestionCard } from './widget-fallback.js'; // NEW IN v0.3.1
|
|
18
|
+
import { ensureGrammars } from './syntax.js'; // 2026-09-26
|
|
19
|
+
import { getLook } from './look.js'; // 2026-09-26 polish
|
|
20
|
+
import { stepFence } from './fence-state.js'; // piece 2 Z1a: one fence rule
|
|
21
|
+
// Register the ABAP/CDS/BDEF/DDL hljs grammars now, exactly when syntax.ts
|
|
22
|
+
// used to self-register (it was loaded through abap-inline.js above), so
|
|
23
|
+
// marked-terminal's fenced blocks in renderMarkdown stay as they were.
|
|
24
|
+
ensureGrammars();
|
|
18
25
|
export function initialBufferState() {
|
|
19
26
|
return { pending: '', fenceCount: 0, widgetBuffer: '', insideWidget: false, orderedListCounter: 0 };
|
|
20
27
|
}
|
|
@@ -101,6 +108,17 @@ function renderPassthrough(chunk, state) {
|
|
|
101
108
|
// markdown.ts / abap-inline.ts (both /^```/m). UNCHANGED FROM v0.1.1.
|
|
102
109
|
const newFencesInChunk = (chunk.match(/^```/gm) || []).length;
|
|
103
110
|
const projectedFenceCount = state.fenceCount + newFencesInChunk;
|
|
111
|
+
if (getLook() === 'panels') {
|
|
112
|
+
// Panels: one fence-aware scan decides the flush point (fix round 1).
|
|
113
|
+
// Pending text only ever starts outside a fence (we never cut inside
|
|
114
|
+
// one), so scanning it from the start tracks fence state over all the
|
|
115
|
+
// text emitted so far. The legacy chunk-local ``` count is not used: it
|
|
116
|
+
// misses ~~~ fences and fences split across chunks.
|
|
117
|
+
const cut = panelsFlushPoint(combined);
|
|
118
|
+
state.pending = combined.slice(cut);
|
|
119
|
+
state.fenceCount = projectedFenceCount;
|
|
120
|
+
return cut === 0 ? '' : render(combined.slice(0, cut));
|
|
121
|
+
}
|
|
104
122
|
if (projectedFenceCount % 2 !== 0) {
|
|
105
123
|
// Inside an open fence — hold everything until fence closes.
|
|
106
124
|
state.pending = combined;
|
|
@@ -122,6 +140,54 @@ function renderPassthrough(chunk, state) {
|
|
|
122
140
|
state.fenceCount = projectedFenceCount;
|
|
123
141
|
return '';
|
|
124
142
|
}
|
|
143
|
+
// Panels (P5): a block that starts a list item, and a block indented under one.
|
|
144
|
+
const LIST_ITEM_START_RE = /^ {0,3}(?:\d{1,9}[.)]|[-*+])[ \t]/;
|
|
145
|
+
const LIST_CONTINUATION_RE = /^(?: {2,}|\t)\S/;
|
|
146
|
+
/**
|
|
147
|
+
* Panels flush point in `text` (index; 0 = flush nothing).
|
|
148
|
+
*
|
|
149
|
+
* Candidate cuts are the starts of lines that follow an empty line OUTSIDE a
|
|
150
|
+
* fenced code block (```…``` or ~~~…~~~, closed by the same character, at
|
|
151
|
+
* least as long, nothing after it). Only complete lines are scanned. Of the
|
|
152
|
+
* candidates, the last one is used, then moved back to the start of a
|
|
153
|
+
* still-open list (P5): the trailing run of blocks that are list items or
|
|
154
|
+
* indented item bodies is held when it contains a list item. A block is the
|
|
155
|
+
* text between two candidates, so a "* comment" or "- " line inside a fence
|
|
156
|
+
* is never seen as a list.
|
|
157
|
+
*/
|
|
158
|
+
function panelsFlushPoint(text) {
|
|
159
|
+
const cuts = [];
|
|
160
|
+
let fence;
|
|
161
|
+
let pos = 0;
|
|
162
|
+
for (;;) {
|
|
163
|
+
const nl = text.indexOf('\n', pos);
|
|
164
|
+
if (nl < 0)
|
|
165
|
+
break; // last line incomplete: not scanned
|
|
166
|
+
const line = text.slice(pos, nl);
|
|
167
|
+
const step = stepFence(fence, line); // shared with the "writing code…" label (fence-state.ts)
|
|
168
|
+
fence = step.fence;
|
|
169
|
+
if (!step.consumed && line === '' && pos > 0)
|
|
170
|
+
cuts.push(nl + 1);
|
|
171
|
+
pos = nl + 1;
|
|
172
|
+
}
|
|
173
|
+
if (cuts.length === 0)
|
|
174
|
+
return 0;
|
|
175
|
+
const last = cuts[cuts.length - 1];
|
|
176
|
+
// Blocks between consecutive cuts (the first starts at 0), up to `last`.
|
|
177
|
+
const starts = [0, ...cuts.slice(0, -1)];
|
|
178
|
+
let listStart;
|
|
179
|
+
for (let i = starts.length - 1; i >= 0; i--) {
|
|
180
|
+
const block = text.slice(starts[i], i + 1 < starts.length ? starts[i + 1] : last);
|
|
181
|
+
const firstLine = block.split('\n').find((l) => l.trim() !== '');
|
|
182
|
+
if (firstLine === undefined)
|
|
183
|
+
continue; // only blank lines
|
|
184
|
+
if (LIST_ITEM_START_RE.test(firstLine))
|
|
185
|
+
listStart = starts[i];
|
|
186
|
+
else if (!LIST_CONTINUATION_RE.test(firstLine))
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
return listStart ?? last;
|
|
190
|
+
}
|
|
125
191
|
// ────────────────────────────────────────────────────────────────────
|
|
126
192
|
// ORDERED-LIST RENUMBER — NEW
|
|
127
193
|
// Walks rendered output line-by-line and renumbers consecutive ordered
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import
|
|
1
|
+
import stringWidth from 'string-width';
|
|
2
|
+
import { paint } from './theme.js';
|
|
3
|
+
import { uiWidth, truncateToWidth } from './ui-width.js';
|
|
4
|
+
import { sanitizeControlChars } from './sanitize.js';
|
|
2
5
|
/**
|
|
3
6
|
* Progress chatter — keeps the tool-widget box alive while a tool runs.
|
|
4
7
|
*
|
|
@@ -15,17 +18,28 @@ import chalk from 'chalk';
|
|
|
15
18
|
const SPINNER_CHARS = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'];
|
|
16
19
|
const SPINNER_TICK_MS = 80;
|
|
17
20
|
const MESSAGE_ROTATE_MS = 3_000;
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
// Piece 2 §1.4: the chatter line is as wide as the UI (uiWidth()); the
|
|
22
|
+
// content area is that minus the "│ " and " │" edges.
|
|
23
|
+
const contentWidth = () => Math.max(10, uiWidth() - 4);
|
|
24
|
+
/**
|
|
25
|
+
* One chatter row, exactly `contentWidth() + 4` cells. The message can carry
|
|
26
|
+
* a tool arg ({name}), so it is neutralised and pre-cut with string-width
|
|
27
|
+
* BEFORE colouring: a wide or control-laden name can never widen the row or
|
|
28
|
+
* drive the terminal.
|
|
29
|
+
*/
|
|
30
|
+
function chatterLine(spinner, message) {
|
|
31
|
+
const w = contentWidth();
|
|
32
|
+
const text = truncateToWidth(`${spinner} ${sanitizeControlChars(message)}`, w);
|
|
33
|
+
const padded = text + ' '.repeat(Math.max(0, w - stringWidth(text)));
|
|
34
|
+
return paint('frame', '│ ') + paint('sap', padded) + paint('frame', ' │');
|
|
35
|
+
}
|
|
22
36
|
/**
|
|
23
37
|
* Per-tool message pools. Messages may include `{name}` / `{type}` placeholders
|
|
24
38
|
* — substituted from the tool's args at render time. If the arg is absent,
|
|
25
39
|
* the placeholder is dropped together with any surrounding whitespace.
|
|
26
40
|
*
|
|
27
|
-
* Keep messages short (≤ ~50 chars) so they fit inside the
|
|
28
|
-
*
|
|
41
|
+
* Keep messages short (≤ ~50 chars) so they fit inside the chatter
|
|
42
|
+
* content area (uiWidth() − 4) without truncation.
|
|
29
43
|
*/
|
|
30
44
|
const MESSAGES = {
|
|
31
45
|
sap_search_object: [
|
|
@@ -203,20 +217,12 @@ export function startProgress(params) {
|
|
|
203
217
|
}
|
|
204
218
|
const spinner = SPINNER_CHARS[spinnerIdx];
|
|
205
219
|
spinnerIdx = (spinnerIdx + 1) % SPINNER_CHARS.length;
|
|
206
|
-
const
|
|
207
|
-
const truncated = raw.length > BOX_WIDTH ? raw.slice(0, BOX_WIDTH - 1) + '…' : raw;
|
|
208
|
-
const padded = truncated.padEnd(BOX_WIDTH);
|
|
209
|
-
const line = chalk.dim('│ ') + chalk.cyan(padded) + chalk.dim(' │');
|
|
220
|
+
const line = chatterLine(spinner, substituted[messageIdx]);
|
|
210
221
|
// Cursor-up 1, erase current line, rewrite. Trailing \n puts us back below.
|
|
211
222
|
process.stdout.write(`\x1B[1A\x1B[2K${line}\n`);
|
|
212
223
|
};
|
|
213
224
|
// Paint the initial chatter line (no cursor-up — it occupies a fresh line).
|
|
214
|
-
|
|
215
|
-
const initialRaw = `${initialSpinner} ${substituted[0]}`;
|
|
216
|
-
const initialTruncated = initialRaw.length > BOX_WIDTH ? initialRaw.slice(0, BOX_WIDTH - 1) + '…' : initialRaw;
|
|
217
|
-
const initialPadded = initialTruncated.padEnd(BOX_WIDTH);
|
|
218
|
-
const initialLine = chalk.dim('│ ') + chalk.cyan(initialPadded) + chalk.dim(' │');
|
|
219
|
-
process.stdout.write(initialLine + '\n');
|
|
225
|
+
process.stdout.write(chatterLine(SPINNER_CHARS[0], substituted[0]) + '\n');
|
|
220
226
|
const interval = setInterval(render, SPINNER_TICK_MS);
|
|
221
227
|
// Don't keep the event loop alive waiting for this timer.
|
|
222
228
|
interval.unref?.();
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { setRenderingOverride, shouldUseInk } from './tty.js';
|
|
2
|
+
import { formatInfo } from './notice-shape.js';
|
|
3
|
+
/** The environment variable that forces the classic renderer for one launch. */
|
|
4
|
+
export const CLASSIC_ENV = 'CSPEACH_UI';
|
|
5
|
+
export const CLASSIC_RETIRING_NOTICE = 'Classic display is retiring · using the standard display';
|
|
6
|
+
export function resolveRenderingMode(stored, env = process.env) {
|
|
7
|
+
if (env[CLASSIC_ENV]?.trim().toLowerCase() === 'classic') {
|
|
8
|
+
return { mode: 'classic', storedClassicRetired: false };
|
|
9
|
+
}
|
|
10
|
+
if (stored === 'classic')
|
|
11
|
+
return { mode: 'auto', storedClassicRetired: true };
|
|
12
|
+
return { mode: stored, storedClassicRetired: false };
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Interactive REPL start: set the rendering override from the config (with the
|
|
16
|
+
* retiring rule above). When a stored `classic` was overridden AND this launch
|
|
17
|
+
* really gets Ink, rewrite the stored value to `auto` (best-effort — a failed
|
|
18
|
+
* save just retries next launch) and return the dim notice to show. Returns
|
|
19
|
+
* null otherwise.
|
|
20
|
+
*/
|
|
21
|
+
export async function applyStartupRendering(cfg, save) {
|
|
22
|
+
const r = resolveRenderingMode(cfg.ui.rendering);
|
|
23
|
+
setRenderingOverride(r.mode);
|
|
24
|
+
if (!r.storedClassicRetired || !shouldUseInk())
|
|
25
|
+
return null;
|
|
26
|
+
cfg.ui.rendering = 'auto';
|
|
27
|
+
try {
|
|
28
|
+
await save(cfg);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
/* best-effort — the value is still read as auto; the next launch retries */
|
|
32
|
+
}
|
|
33
|
+
return formatInfo(CLASSIC_RETIRING_NOTICE);
|
|
34
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// src/renderer/routing-line.ts
|
|
2
|
+
//
|
|
3
|
+
// Piece 2 §9: routing chatter is ONE dim line — "· Using /<skill>" when the
|
|
4
|
+
// router picked silently, "· Continuing /<skill>" for a follow-up, and
|
|
5
|
+
// "· Cancelled" when the picker was dismissed. No confidence percentage.
|
|
6
|
+
// formatInfo sanitizes the text, so a skill name cannot drive the terminal.
|
|
7
|
+
import { formatInfo } from './notice-shape.js';
|
|
8
|
+
export function formatRoutingLine(e) {
|
|
9
|
+
switch (e.kind) {
|
|
10
|
+
case 'using': return formatInfo(`Using /${e.skill}`);
|
|
11
|
+
case 'continuing': return formatInfo(`Continuing /${e.skill}`);
|
|
12
|
+
case 'cancelled': return formatInfo('Cancelled');
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// src/renderer/sanitize.ts
|
|
2
|
+
//
|
|
3
|
+
// Model- or user-supplied text (questions, labels, pasted answers) must not
|
|
4
|
+
// drive the terminal. Every C0/C1 control character except tab and CRLF /
|
|
5
|
+
// LF — a lone \r, ESC, BEL, NUL, DEL, CSI (\x9b) … — becomes a space, so it
|
|
6
|
+
// cannot move the cursor or start an escape sequence. Tabs and newlines are
|
|
7
|
+
// left to the caller (truncateToWidth / trace flattening handle them).
|
|
8
|
+
// eslint-disable-next-line no-control-regex
|
|
9
|
+
const CONTROL_CHARS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]|\r(?!\n)/g;
|
|
10
|
+
export function sanitizeControlChars(text) {
|
|
11
|
+
return text.replace(CONTROL_CHARS, ' ');
|
|
12
|
+
}
|
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
// C:\Users\Admin\ClaudeProjects\ABAPForge\cspeach-cli\src\renderer\severity.ts
|
|
2
|
-
import
|
|
3
|
-
const PILLS =
|
|
4
|
-
CRITICAL: (s) => chalk.bgRedBright.white.bold(s),
|
|
5
|
-
HIGH: (s) => chalk.bgRed.white(s),
|
|
6
|
-
MEDIUM: (s) => chalk.bgYellow.black(s),
|
|
7
|
-
LOW: (s) => chalk.bgGreen.black(s),
|
|
8
|
-
};
|
|
2
|
+
import { LEGACY_SEVERITY_PILLS } from './legacy-palette.js';
|
|
3
|
+
const PILLS = LEGACY_SEVERITY_PILLS;
|
|
9
4
|
const FENCE_RE = /^```/;
|
|
10
5
|
const ANSI_STRIP_RE = /\x1B\[[0-9;]*[mGKHFABCDsuJSTfrl]|\x1B\][^\x07]*\x07/g;
|
|
11
6
|
// A line that is part of a rendered cli-table3 block always starts with one of
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// src/renderer/startup-lines.ts
|
|
2
|
+
//
|
|
3
|
+
// Panels look (2026-09-26, piece 2 §10): exactly three lines (brand + sign-in,
|
|
4
|
+
// status, hint or resume), then one '!' line per thing that needs action.
|
|
5
|
+
// Legacy prints exactly the old lines (kept in repl.tsx). formatPromptEcho's
|
|
6
|
+
// legacy branch is today's string.
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
import stringWidth from 'string-width';
|
|
9
|
+
import { getLook } from './look.js';
|
|
10
|
+
import { paint, BRAND_HEX } from './theme.js';
|
|
11
|
+
import { formatAttention } from './notice-shape.js';
|
|
12
|
+
import { truncateToWidth, uiWidth } from './ui-width.js';
|
|
13
|
+
import { sanitizeControlChars } from './sanitize.js';
|
|
14
|
+
import { holdBrand } from './brand-settled.js';
|
|
15
|
+
export { brandSettled } from './brand-settled.js';
|
|
16
|
+
/** Folder budget on the panels status line (controller ruling R2). */
|
|
17
|
+
const STATUS_PATH_MAX = 20;
|
|
18
|
+
/**
|
|
19
|
+
* Longest tail of whole path segments, prefixed `…<sep>`, with total length
|
|
20
|
+
* ≤ max; the path unchanged if it already fits. If even the last segment is
|
|
21
|
+
* too long, its trailing characters are kept after `…`.
|
|
22
|
+
*/
|
|
23
|
+
export function shortenPath(p, max) {
|
|
24
|
+
if (p.length <= max)
|
|
25
|
+
return p;
|
|
26
|
+
const sep = p.includes('\\') ? '\\' : '/';
|
|
27
|
+
const parts = p.split(sep).filter((s) => s.length > 0);
|
|
28
|
+
const last = parts.pop() ?? p;
|
|
29
|
+
let tail = last;
|
|
30
|
+
if (1 + sep.length + tail.length > max) {
|
|
31
|
+
return '…' + last.slice(-(Math.max(1, max - 1)));
|
|
32
|
+
}
|
|
33
|
+
while (parts.length > 0) {
|
|
34
|
+
const next = parts[parts.length - 1] + sep + tail;
|
|
35
|
+
if (1 + sep.length + next.length > max)
|
|
36
|
+
break;
|
|
37
|
+
tail = next;
|
|
38
|
+
parts.pop();
|
|
39
|
+
}
|
|
40
|
+
return `…${sep}${tail}`;
|
|
41
|
+
}
|
|
42
|
+
/** Line 1 of the panels start screen. `signIn` (piece 2 §10) folds the account
|
|
43
|
+
* into the brand line, e.g. "signed in as a@b.com · team". */
|
|
44
|
+
export function panelsBrandLine(version, signIn) {
|
|
45
|
+
return panelsBrandBase(version) + panelsBrandSuffix(version, signIn);
|
|
46
|
+
}
|
|
47
|
+
function panelsBrandBase(version) {
|
|
48
|
+
return `🍑 ${chalk.hex(BRAND_HEX).bold('CSPeach')} ${paint('dim', 'v' + version)}`;
|
|
49
|
+
}
|
|
50
|
+
/** The part of line 1 after "🍑 CSPeach vX" — empty when there is no sign-in. */
|
|
51
|
+
function panelsBrandSuffix(version, signIn) {
|
|
52
|
+
if (!signIn)
|
|
53
|
+
return '';
|
|
54
|
+
// Line 1 never wraps: the fragment gets what is left of the UI width.
|
|
55
|
+
const used = stringWidth(`🍑 CSPeach v${version} · `);
|
|
56
|
+
return paint('dim', ` · ${truncateToWidth(signIn, Math.max(1, uiWidth() - used))}`);
|
|
57
|
+
}
|
|
58
|
+
const processSignals = {
|
|
59
|
+
once: (ev, h) => { process.once(ev, h); },
|
|
60
|
+
removeListener: (ev, h) => { process.removeListener(ev, h); },
|
|
61
|
+
exit: (code) => { process.exit(code); },
|
|
62
|
+
};
|
|
63
|
+
export function startEarlyBrand(p) {
|
|
64
|
+
const signals = p.signals ?? processSignals;
|
|
65
|
+
let done = false;
|
|
66
|
+
let settle;
|
|
67
|
+
const onSigint = () => {
|
|
68
|
+
if (done)
|
|
69
|
+
return;
|
|
70
|
+
done = true;
|
|
71
|
+
p.write('\n');
|
|
72
|
+
settle?.();
|
|
73
|
+
signals.exit(130);
|
|
74
|
+
};
|
|
75
|
+
if (p.tty) {
|
|
76
|
+
settle = holdBrand();
|
|
77
|
+
p.write(panelsBrandBase(p.version));
|
|
78
|
+
signals.once('SIGINT', onSigint);
|
|
79
|
+
}
|
|
80
|
+
const end = (text) => {
|
|
81
|
+
if (done)
|
|
82
|
+
return;
|
|
83
|
+
done = true;
|
|
84
|
+
if (p.tty)
|
|
85
|
+
signals.removeListener('SIGINT', onSigint);
|
|
86
|
+
if (text)
|
|
87
|
+
p.write(text);
|
|
88
|
+
settle?.();
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
finish(signIn) {
|
|
92
|
+
end((p.tty ? panelsBrandSuffix(p.version, signIn) : panelsBrandLine(p.version, signIn)) + '\n');
|
|
93
|
+
},
|
|
94
|
+
close() {
|
|
95
|
+
end(p.tty ? '\n' : '');
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The dim note saying where the SAP system came from (--sap-alias flag, or
|
|
101
|
+
* the resumed session). Legacy only: the panels status line already shows
|
|
102
|
+
* the system, and a 4th line would break §10's three-line screen.
|
|
103
|
+
*/
|
|
104
|
+
export function aliasSourceNote(source, alias) {
|
|
105
|
+
if (getLook() === 'panels')
|
|
106
|
+
return null;
|
|
107
|
+
return source === 'flag' ? chalk.dim(`Using --sap-alias ${alias}`) : chalk.dim(`Resumed on SAP ${alias}`);
|
|
108
|
+
}
|
|
109
|
+
export function panelsStatusLine(p) {
|
|
110
|
+
const segs = [p.standalone ? paint('dim', 'standalone (no SAP system)') : paint('sap', p.system)];
|
|
111
|
+
if (p.tools === 'files+commands')
|
|
112
|
+
segs.push(paint('warn', 'local files + commands on'));
|
|
113
|
+
else if (p.tools === 'files')
|
|
114
|
+
segs.push(paint('dim', 'files → cspeach-out'));
|
|
115
|
+
if (p.writeMode !== undefined && p.writeMode !== 'approval-gated' && p.writeMode !== 'advisory-only') {
|
|
116
|
+
segs.push(paint('warn', `writes: ${p.writeMode}`));
|
|
117
|
+
}
|
|
118
|
+
segs.push(paint('dim', shortenPath(p.cwd, STATUS_PATH_MAX)));
|
|
119
|
+
return ' ' + segs.join(paint('dim', ' · '));
|
|
120
|
+
}
|
|
121
|
+
/** Line 3 of the panels start screen (non-resume). */
|
|
122
|
+
export function panelsHintLine(tools) {
|
|
123
|
+
const text = tools === 'files+commands'
|
|
124
|
+
? 'turn off commands: cspeach config set local_build off · ? for shortcuts'
|
|
125
|
+
: '? for shortcuts · /help for commands';
|
|
126
|
+
return ' ' + paint('dim', text);
|
|
127
|
+
}
|
|
128
|
+
/** Line 3 of the panels start screen on --resume: the recap's "last session" fact.
|
|
129
|
+
* The fact quotes the user's last prompt, so control characters are neutralised. */
|
|
130
|
+
export function panelsResumeLine(last, priorTurns) {
|
|
131
|
+
const fact = last ?? `${priorTurns} prior turn${priorTurns === 1 ? '' : 's'}`;
|
|
132
|
+
return ' ' + paint('dim', truncateToWidth(sanitizeControlChars(`resumed · ${fact}`), Math.max(20, uiWidth() - 3)));
|
|
133
|
+
}
|
|
134
|
+
/** A "!" line for the start screen (no Next: line → exactly one line). */
|
|
135
|
+
export function panelsInterruptedNote(n) {
|
|
136
|
+
return formatAttention({ text: `${n} unfinished session${n === 1 ? '' : 's'}`, fact: 'cspeach --resume' }).trimEnd();
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Lines 2-3 of the panels start screen, then — only when something needs
|
|
140
|
+
* action — a blank line and one "!" notice per item (each already ends '\n').
|
|
141
|
+
* Always ends with one blank line before the input.
|
|
142
|
+
*/
|
|
143
|
+
export function panelsStartTail(p) {
|
|
144
|
+
const head = `${panelsStatusLine(p.status)}\n${p.third}\n`;
|
|
145
|
+
return p.notices.length === 0 ? head + '\n' : head + '\n' + p.notices.join('') + '\n';
|
|
146
|
+
}
|
|
147
|
+
export function formatPromptEcho(text, width) {
|
|
148
|
+
if (getLook() !== 'panels') {
|
|
149
|
+
return chalk.dim('\n──────────────────────────────────\n') + chalk.cyan(`› ${text}\n`);
|
|
150
|
+
}
|
|
151
|
+
return '\n' + paint('frame', '─'.repeat(Math.min(60, Math.max(20, width - 4)))) + '\n' + paint('dim', 'you ›') + ` ${text}\n`;
|
|
152
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import chalk from 'chalk';
|
|
2
1
|
import { formatCost } from '../cost/pricing.js';
|
|
3
2
|
import { creditsActive, formatCreditsLine, CREDITS_PENDING } from '../cost/credits.js';
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import { formatTurnReceipt, turnSpendText } from './footer-line.js';
|
|
4
|
+
import { formatAttention } from './notice-shape.js';
|
|
5
|
+
import { paint } from './theme.js';
|
|
6
|
+
import { uiWidth } from './ui-width.js';
|
|
7
|
+
import { getBoxChars } from '../ui/ascii-chars.js';
|
|
6
8
|
/**
|
|
7
9
|
* Phase 2 (2026-05-16) — when a session crosses this much non-cached input,
|
|
8
10
|
* we nudge the user to /compact. Threshold is "feel right" — tuned to fire
|
|
@@ -13,9 +15,9 @@ export const LARGE_SESSION_THRESHOLD = 100_000;
|
|
|
13
15
|
/**
|
|
14
16
|
* Prints a status footer after each turn ends.
|
|
15
17
|
*
|
|
16
|
-
* Format (
|
|
18
|
+
* Format (piece 2 restyle — rules span uiWidth()):
|
|
17
19
|
* ────────────────────────────────────────────────────────────────────
|
|
18
|
-
* session 870ed8cc
|
|
20
|
+
* session 870ed8cc · S4H-DEV · /abap-radar · 3 turns · 6,533 tok this turn · $0.12 · 2,100 cached · session: $0.54 · 4.2s
|
|
19
21
|
* ────────────────────────────────────────────────────────────────────
|
|
20
22
|
*
|
|
21
23
|
* H1 — `tokens` is the per-turn delta (input + output consumed THIS turn),
|
|
@@ -34,6 +36,20 @@ export const LARGE_SESSION_THRESHOLD = 100_000;
|
|
|
34
36
|
* v0.3 transition: this chalk status-footer-after-turn is replaced (not
|
|
35
37
|
* augmented) by the Ink persistent footer region in Plan 4c.
|
|
36
38
|
*/
|
|
39
|
+
/**
|
|
40
|
+
* The "session getting large" notice (piece 2, spec §8/§9) — a `!` attention
|
|
41
|
+
* line with the /compact next step. '' below LARGE_SESSION_THRESHOLD or when
|
|
42
|
+
* the caller passed no count.
|
|
43
|
+
*/
|
|
44
|
+
export function largeSessionNotice(sessionInputTokens) {
|
|
45
|
+
if (sessionInputTokens == null || sessionInputTokens < LARGE_SESSION_THRESHOLD)
|
|
46
|
+
return '';
|
|
47
|
+
return formatAttention({
|
|
48
|
+
text: 'session getting large',
|
|
49
|
+
fact: `${sessionInputTokens.toLocaleString('en-US')} input tokens`,
|
|
50
|
+
next: 'type /compact to summarise older turns, or /exit and start fresh for a new topic',
|
|
51
|
+
});
|
|
52
|
+
}
|
|
37
53
|
/**
|
|
38
54
|
* Format the status footer as a single string (with embedded newlines).
|
|
39
55
|
* Pulled out from `printStatusFooter` so the Ink path can route the
|
|
@@ -42,6 +58,16 @@ export const LARGE_SESSION_THRESHOLD = 100_000;
|
|
|
42
58
|
*/
|
|
43
59
|
export function formatStatusFooter(params) {
|
|
44
60
|
const { sessionId, sapAlias, skill, turnCount, tokens, cachedTokens, elapsedMs, costThisTurn, costSession, sessionInputTokens, variant, credits } = params;
|
|
61
|
+
// Piece 2 (spec §8) — Ink per-turn receipt: ONE dim line, this turn only.
|
|
62
|
+
// The session total and balance live in the footer line now.
|
|
63
|
+
if (variant === 'turn') {
|
|
64
|
+
return formatTurnReceipt({
|
|
65
|
+
skill,
|
|
66
|
+
tokens,
|
|
67
|
+
spend: turnSpendText({ costThisTurn, credits }),
|
|
68
|
+
ms: elapsedMs,
|
|
69
|
+
}) + '\n' + largeSessionNotice(sessionInputTokens);
|
|
70
|
+
}
|
|
45
71
|
const shortSession = sessionId.slice(0, 8);
|
|
46
72
|
const skillDisplay = skill.startsWith('/') ? skill : `/${skill}`;
|
|
47
73
|
const turns = `${turnCount} turn${turnCount === 1 ? '' : 's'}`;
|
|
@@ -50,8 +76,7 @@ export function formatStatusFooter(params) {
|
|
|
50
76
|
const elapsed = elapsedMs > 0 ? `${(elapsedMs / 1000).toFixed(1)}s` : '';
|
|
51
77
|
// Credits mode: the whole spend story collapses into ONE customer-facing
|
|
52
78
|
// segment (turn · session · balance) in place of the two dollar segments.
|
|
53
|
-
//
|
|
54
|
-
// covers the slim Ink receipt and the full classic receipt alike.
|
|
79
|
+
// (Classic receipt only — the Ink 'turn' variant returned above.)
|
|
55
80
|
const inCredits = creditsActive();
|
|
56
81
|
const costTurnStr = inCredits
|
|
57
82
|
? (credits ? formatCreditsLine(credits) : CREDITS_PENDING)
|
|
@@ -59,33 +84,26 @@ export function formatStatusFooter(params) {
|
|
|
59
84
|
const costSessStr = inCredits
|
|
60
85
|
? ''
|
|
61
86
|
: (costSession != null ? `session: ${formatCost(costSession)}` : '');
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
]
|
|
72
|
-
: [
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
];
|
|
83
|
-
const lines = ['', RULE, ' ' + parts.join(chalk.dim(' │ '))];
|
|
84
|
-
if (sessionInputTokens != null && sessionInputTokens >= LARGE_SESSION_THRESHOLD) {
|
|
85
|
-
lines.push(' ' + chalk.yellow(`⚠ session getting large (${sessionInputTokens.toLocaleString()} input tokens)`
|
|
86
|
-
+ ` — type /compact to summarise older turns, or /exit + cspeach (fresh) for a new topic`));
|
|
87
|
-
}
|
|
88
|
-
lines.push(RULE, '');
|
|
87
|
+
// Classic full receipt (piece 2 restyle — same facts, reviewed pin):
|
|
88
|
+
// rules at uiWidth() in the frame role, ' · ' separators, the large-session
|
|
89
|
+
// advisory as a `!` notice (largeSessionNotice).
|
|
90
|
+
const parts = [
|
|
91
|
+
`session ${shortSession}`,
|
|
92
|
+
sapAlias,
|
|
93
|
+
skillDisplay,
|
|
94
|
+
turns,
|
|
95
|
+
tokStr,
|
|
96
|
+
...(costTurnStr ? [costTurnStr] : []),
|
|
97
|
+
...(cachedStr ? [cachedStr] : []),
|
|
98
|
+
...(costSessStr ? [costSessStr] : []),
|
|
99
|
+
...(elapsed ? [elapsed] : []),
|
|
100
|
+
];
|
|
101
|
+
const rule = paint('frame', getBoxChars().h.repeat(uiWidth()));
|
|
102
|
+
const lines = ['', rule, ' ' + paint('dim', parts.join(' · '))];
|
|
103
|
+
const notice = largeSessionNotice(sessionInputTokens);
|
|
104
|
+
if (notice)
|
|
105
|
+
lines.push(notice.replace(/\n$/, ''));
|
|
106
|
+
lines.push(rule, '');
|
|
89
107
|
return lines.join('\n');
|
|
90
108
|
}
|
|
91
109
|
export function printStatusFooter(params) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// src/renderer/steering-echo.ts
|
|
2
|
+
//
|
|
3
|
+
// The receipt a mid-turn message leaves in scrollback (spec §7). ONE themed
|
|
4
|
+
// helper for both echo paths: App.beginSubmit (ui/app.tsx) and the fallback
|
|
5
|
+
// in repl.tsx handleSubmit for callers that bypass beginSubmit.
|
|
6
|
+
import { paint } from './theme.js';
|
|
7
|
+
import { sanitizeControlChars } from './sanitize.js';
|
|
8
|
+
/** Wave1 fast-follow — only the first line, clipped, with a line count. */
|
|
9
|
+
export const STEERING_ECHO_MAX = 60;
|
|
10
|
+
export function formatSteeringEcho(text) {
|
|
11
|
+
const lines = text.split('\n');
|
|
12
|
+
const first = lines[0] ?? '';
|
|
13
|
+
const extra = lines.length - 1;
|
|
14
|
+
const clipped = first.length > STEERING_ECHO_MAX;
|
|
15
|
+
let out = clipped ? first.slice(0, STEERING_ECHO_MAX) : first;
|
|
16
|
+
if (clipped || extra > 0)
|
|
17
|
+
out += '…';
|
|
18
|
+
if (extra > 0)
|
|
19
|
+
out += ` (+${extra} line${extra === 1 ? '' : 's'})`;
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* `↳ queued · <first line…>` in the dim role, newline-terminated. The text may
|
|
24
|
+
* come from a paste, so control characters are neutralised before it reaches
|
|
25
|
+
* scrollback.
|
|
26
|
+
*/
|
|
27
|
+
export function steeringEchoLine(text) {
|
|
28
|
+
return paint('dim', `↳ queued · ${sanitizeControlChars(formatSteeringEcho(text))}`) + '\n';
|
|
29
|
+
}
|
|
30
|
+
export function classifyMidTurnLine(text) {
|
|
31
|
+
const first = text.trim().split(/\s+/)[0] ?? '';
|
|
32
|
+
if (first === '/exit' || first === '/quit')
|
|
33
|
+
return { kind: 'exit' };
|
|
34
|
+
if (/^\/[A-Za-z][\w-]*$/.test(first))
|
|
35
|
+
return { kind: 'slash', command: first };
|
|
36
|
+
return { kind: 'steer' };
|
|
37
|
+
}
|
|
38
|
+
/** The one-line hint for a slash command typed mid-turn (dim, newline-terminated). */
|
|
39
|
+
export function midTurnSlashHint(command) {
|
|
40
|
+
return paint('dim', `↳ ${sanitizeControlChars(command)} runs after this turn · press Enter when it ends`) + '\n';
|
|
41
|
+
}
|