@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,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Control-key bursts (fix round 3, item 6).
|
|
3
|
+
*
|
|
4
|
+
* Ink parses each stdin chunk as ONE key. When ConPTY coalesces several key
|
|
5
|
+
* repeats into a single read (held Backspace or arrows while the event loop is
|
|
6
|
+
* busy), the chunk matches no key and arrives as "text" — the DEL bytes were
|
|
7
|
+
* then inserted into the draft as invisible characters. This walks such a
|
|
8
|
+
* chunk key by key and applies each to (value, cursor). It never inserts a
|
|
9
|
+
* control character.
|
|
10
|
+
*/
|
|
11
|
+
const ESC = '\u001b';
|
|
12
|
+
/** True when the chunk carries a control character (other than newline / tab). */
|
|
13
|
+
// eslint-disable-next-line no-control-regex
|
|
14
|
+
const HAS_CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f]/;
|
|
15
|
+
// CSI (ESC [ params final) or SS3 (ESC O final).
|
|
16
|
+
// eslint-disable-next-line no-control-regex
|
|
17
|
+
const ESCAPE_SEQ = /^\u001b(?:\[[0-9;?]*[@-~]|O[@-~])?/;
|
|
18
|
+
const STRIPPED_LEAD = /^(?:\[[0-9;?]*[A-Za-z~]|O[A-Za-z])/;
|
|
19
|
+
/**
|
|
20
|
+
* Remove every control character except newline and tab — the last guard
|
|
21
|
+
* before text lands in a draft (an ESC-bearing paste, a stray NUL).
|
|
22
|
+
*/
|
|
23
|
+
export function stripControlChars(s) {
|
|
24
|
+
// eslint-disable-next-line no-control-regex
|
|
25
|
+
// Whole CSI / SS3 sequences first (review M8): dropping only the ESC left
|
|
26
|
+
// a pasted ANSI log reading "[31mred".
|
|
27
|
+
return s
|
|
28
|
+
.replace(/\u001b(?:\[[0-9;?]*[@-~]|O[@-~])/g, '')
|
|
29
|
+
.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '');
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Split a chunk made ONLY of control keys (escape sequences, DEL, BS, other
|
|
33
|
+
* C0 controls) into one string per key. Ink's parser reads a chunk as a
|
|
34
|
+
* single key — "\x1b[D\x1b[D\x1b[D" parses as ONE Left and the rest is lost
|
|
35
|
+
* before useInput sees it — so InkStdinPasteGuard hands Ink one key per
|
|
36
|
+
* read(). Returns [chunk] (no split) when the chunk holds any printable text
|
|
37
|
+
* (text-input's applyKeyBurst handles mixed chunks) or a CR / LF (Enter and
|
|
38
|
+
* non-bracketed pastes keep their existing handling).
|
|
39
|
+
*/
|
|
40
|
+
export function splitControlKeys(chunk) {
|
|
41
|
+
if (chunk.length < 2 || chunk.includes('\r') || chunk.includes('\n'))
|
|
42
|
+
return [chunk];
|
|
43
|
+
const keys = [];
|
|
44
|
+
let rest = chunk;
|
|
45
|
+
while (rest.length > 0) {
|
|
46
|
+
const ch = rest[0];
|
|
47
|
+
if (ch === ESC) {
|
|
48
|
+
// eslint-disable-next-line no-control-regex
|
|
49
|
+
const m = /^\u001b(?:\[[0-9;?]*[@-~]|O[@-~])/.exec(rest);
|
|
50
|
+
if (!m)
|
|
51
|
+
return [chunk]; // a lone ESC or Alt+key — leave the chunk alone
|
|
52
|
+
keys.push(m[0]);
|
|
53
|
+
rest = rest.slice(m[0].length);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (!HAS_CONTROL.test(ch))
|
|
57
|
+
return [chunk]; // printable text — not a pure burst
|
|
58
|
+
keys.push(ch);
|
|
59
|
+
rest = rest.slice(1);
|
|
60
|
+
}
|
|
61
|
+
return keys.length > 1 ? keys : [chunk];
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Apply a multi-key chunk. Returns null when the chunk holds no control
|
|
65
|
+
* characters — the caller then inserts it as ordinary text.
|
|
66
|
+
*/
|
|
67
|
+
export function applyKeyBurst(value, cursor, input) {
|
|
68
|
+
if (!HAS_CONTROL.test(input))
|
|
69
|
+
return null;
|
|
70
|
+
// Ink strips the chunk's first ESC; put it back when the chunk starts with
|
|
71
|
+
// what is clearly the rest of an escape sequence.
|
|
72
|
+
let rest = STRIPPED_LEAD.test(input) ? ESC + input : input;
|
|
73
|
+
let v = value;
|
|
74
|
+
let c = Math.max(0, Math.min(cursor, value.length));
|
|
75
|
+
while (rest.length > 0) {
|
|
76
|
+
const ch = rest[0];
|
|
77
|
+
if (ch === ESC) {
|
|
78
|
+
const m = ESCAPE_SEQ.exec(rest);
|
|
79
|
+
const seq = m[0];
|
|
80
|
+
const final = seq[seq.length - 1];
|
|
81
|
+
if (seq.length > 1) {
|
|
82
|
+
if (final === 'D')
|
|
83
|
+
c = Math.max(0, c - 1);
|
|
84
|
+
else if (final === 'C')
|
|
85
|
+
c = Math.min(v.length, c + 1);
|
|
86
|
+
else if (final === 'H')
|
|
87
|
+
c = 0;
|
|
88
|
+
else if (final === 'F')
|
|
89
|
+
c = v.length;
|
|
90
|
+
else if (seq === ESC + '[3~' && c > 0) {
|
|
91
|
+
// Delete — same as the single-key path, which cannot tell the
|
|
92
|
+
// Delete key from Backspace (both parse as key.delete).
|
|
93
|
+
v = v.slice(0, c - 1) + v.slice(c);
|
|
94
|
+
c--;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
rest = rest.slice(seq.length || 1);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (ch === '\u007f' || ch === '\b') {
|
|
101
|
+
if (c > 0) {
|
|
102
|
+
v = v.slice(0, c - 1) + v.slice(c);
|
|
103
|
+
c--;
|
|
104
|
+
}
|
|
105
|
+
rest = rest.slice(1);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (HAS_CONTROL.test(ch)) {
|
|
109
|
+
rest = rest.slice(1); // any other control character is dropped
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
v = v.slice(0, c) + ch + v.slice(c);
|
|
113
|
+
c++;
|
|
114
|
+
rest = rest.slice(1);
|
|
115
|
+
}
|
|
116
|
+
return { value: v, cursor: c };
|
|
117
|
+
}
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import { isExactSlashMatch, filterSlashEntries } from '../repl/slash-picker.js';
|
|
22
22
|
import { pendingAtTokenPrefix } from '../repl/at-picker.js';
|
|
23
23
|
import { SKILL_CATALOG, SKILL_ALIASES } from '../skill-catalog.js';
|
|
24
|
+
import { getGlyphs } from '../renderer/glyphs.js';
|
|
24
25
|
export function nextResolutionStep(line, files) {
|
|
25
26
|
const trimmed = line.trim();
|
|
26
27
|
if (trimmed.startsWith('/') && !isExactSlashMatch(trimmed)) {
|
|
@@ -65,7 +66,7 @@ export function applySlashPick(pickedName, rest, files, fillIn = false) {
|
|
|
65
66
|
return {
|
|
66
67
|
kind: 'prefill',
|
|
67
68
|
text: `${name} `,
|
|
68
|
-
hint:
|
|
69
|
+
hint: `${getGlyphs().info} ${name} — add arguments or press Enter to run`,
|
|
69
70
|
};
|
|
70
71
|
}
|
|
71
72
|
return nextResolutionStep(`${name}${rest}`, files);
|
|
@@ -92,7 +93,7 @@ function emptyBodyGuard(line) {
|
|
|
92
93
|
return {
|
|
93
94
|
kind: 'prefill',
|
|
94
95
|
text: `/${aliasTarget} `,
|
|
95
|
-
hint:
|
|
96
|
+
hint: `${getGlyphs().info} ${head.toLowerCase()} is now /${aliasTarget} — type your prompt and press Enter`,
|
|
96
97
|
};
|
|
97
98
|
}
|
|
98
99
|
const isSkill = SKILL_CATALOG.some((s) => `/${s.name}` === head.toLowerCase());
|
|
@@ -100,7 +101,7 @@ function emptyBodyGuard(line) {
|
|
|
100
101
|
return {
|
|
101
102
|
kind: 'prefill',
|
|
102
103
|
text: `${head} `,
|
|
103
|
-
hint:
|
|
104
|
+
hint: `${getGlyphs().info} ${head} — type your prompt and press Enter`,
|
|
104
105
|
};
|
|
105
106
|
}
|
|
106
107
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// src/ui/live-area.ts
|
|
2
|
+
//
|
|
3
|
+
// Pure layout decisions for the Ink live area (CLI look piece 2, spec §2).
|
|
4
|
+
// Order, top to bottom: Body (<Static>) → checklist → card slot → status
|
|
5
|
+
// strip → input → footer → palettes. Ink 5 clears and redraws the WHOLE
|
|
6
|
+
// screen once the live region reaches the terminal height (ink.js
|
|
7
|
+
// `outputHeight >= stdout.rows`), so the live area is capped at rows − 2.
|
|
8
|
+
/** Precedence: model-driven blocking cards first, then user-opened panels. */
|
|
9
|
+
const PRECEDENCE = ['approval', 'confirm', 'ask', 'coaching', 'files', 'rewind', 'timeline', 'shortcuts'];
|
|
10
|
+
/** The ONE card shown in the slot (spec §1.6: one card at a time), or null. */
|
|
11
|
+
export function pickSlotOccupant(s) {
|
|
12
|
+
for (const k of PRECEDENCE)
|
|
13
|
+
if (s[k])
|
|
14
|
+
return k;
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
export const STATUS_ROWS = 1;
|
|
18
|
+
export const INPUT_BORDER_ROWS = 2;
|
|
19
|
+
export const FOOTER_ROWS = 1;
|
|
20
|
+
export const INPUT_MAX_LINES = 6;
|
|
21
|
+
/**
|
|
22
|
+
* Fixed height estimates (rows, margins and borders included) for each slot
|
|
23
|
+
* occupant, fed to liveBudget as `cardRows`. They cover the collapsed card.
|
|
24
|
+
* A task that owns a card with a variable height replaces its entry at the
|
|
25
|
+
* App call site with a real estimator (Q2: estimateAskCardRows, C1:
|
|
26
|
+
* estimateConfirmCardRows).
|
|
27
|
+
* approval margin 1 + border 2 + padding 2 + 3 fact rows + margin 1 + keys 1
|
|
28
|
+
* rewind margin 1 + border 2 + title 1 + margin 1 + more 2 + REWIND_VISIBLE_ROWS 9 + margin 1 + hint 1
|
|
29
|
+
* timeline margin 1 + border 2 + title 1 + margin 1 + more 2 + TIMELINE_VISIBLE_ROWS 9
|
|
30
|
+
* (S4a: both cards pin their rendered height to these in their own tests.)
|
|
31
|
+
*/
|
|
32
|
+
export const SLOT_ROWS_ESTIMATE = {
|
|
33
|
+
approval: 10,
|
|
34
|
+
confirm: 10,
|
|
35
|
+
ask: 12,
|
|
36
|
+
coaching: 12,
|
|
37
|
+
files: 14,
|
|
38
|
+
rewind: 18,
|
|
39
|
+
timeline: 16,
|
|
40
|
+
shortcuts: 14,
|
|
41
|
+
};
|
|
42
|
+
/** An open `/` or `@` palette: margin 1 + query 1 + ↑ more 1 + 8 rows + ↓ more 1 + hint 1. */
|
|
43
|
+
export const PALETTE_ROWS = 13;
|
|
44
|
+
/**
|
|
45
|
+
* Rows each live element may use so the whole live area stays ≤ rows − 2.
|
|
46
|
+
*
|
|
47
|
+
* Inputs are WANTED heights: `cardRows` is the card's full height INCLUDING
|
|
48
|
+
* option descriptions, `inputLines` the wrapped input's full height. (Passing
|
|
49
|
+
* the already-shrunk height would make descriptions flicker on and off.)
|
|
50
|
+
*
|
|
51
|
+
* Fold order over the cap (spec §2):
|
|
52
|
+
* 1. the checklist folds to its one summary line while a card is open;
|
|
53
|
+
* with no card it may use everything except one input line;
|
|
54
|
+
* 2. the input stops growing: min(inputLines, 6, what is left), never < 1;
|
|
55
|
+
* 3. card option descriptions drop (showDescriptions = false) when not
|
|
56
|
+
* even one input line fits beside the full card.
|
|
57
|
+
* The status row is always counted (conservative: it is hidden while a card
|
|
58
|
+
* is open, so the real frame is one row shorter).
|
|
59
|
+
*/
|
|
60
|
+
export function liveBudget(args) {
|
|
61
|
+
const cap = Math.max(0, args.rows - 2);
|
|
62
|
+
const fixed = STATUS_ROWS + INPUT_BORDER_ROWS + FOOTER_ROWS + Math.max(0, args.paletteRows);
|
|
63
|
+
const wanted = Math.max(0, args.checklistRows);
|
|
64
|
+
const card = Math.max(0, args.cardRows);
|
|
65
|
+
const checklistRows = card > 0
|
|
66
|
+
? Math.min(wanted, 1)
|
|
67
|
+
: Math.min(wanted, Math.max(Math.min(wanted, 1), cap - fixed - 1));
|
|
68
|
+
const remaining = cap - fixed - checklistRows - card;
|
|
69
|
+
const inputMaxLines = Math.max(1, Math.min(Math.max(1, args.inputLines), INPUT_MAX_LINES, remaining));
|
|
70
|
+
return { checklistRows, inputMaxLines, showDescriptions: remaining >= 1 };
|
|
71
|
+
}
|
package/dist/ui/login-banner.js
CHANGED
|
@@ -1,66 +1,77 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* - token usage ≥ 80% of input
|
|
2
|
+
* Login banner (REPL startup). Decision 8: verbose with budget + warning.
|
|
3
|
+
* The warning fires when:
|
|
4
|
+
* - token usage ≥ 80% of the input cap (managed-capped trials only)
|
|
5
5
|
* - trial expiry < 24 hours away
|
|
6
6
|
* - trial already expired
|
|
7
|
+
* Piece 2 §10: warning TEXT carries no glyph — callers print it through
|
|
8
|
+
* formatAttention ("!"). signInFragment() is the panels brand-line fold-in.
|
|
7
9
|
*/
|
|
10
|
+
import { sanitizeControlChars } from '../renderer/sanitize.js';
|
|
8
11
|
const MS_PER_HOUR = 3600 * 1000;
|
|
9
12
|
const MS_PER_DAY = 24 * MS_PER_HOUR;
|
|
10
|
-
|
|
11
|
-
// Non-trial path.
|
|
12
|
-
if (profile.trial_mode === null) {
|
|
13
|
-
return {
|
|
14
|
-
line: `Signed in as ${profile.email} (${profile.plan})`,
|
|
15
|
-
warningLine: null,
|
|
16
|
-
};
|
|
17
|
-
}
|
|
18
|
-
// Trial — figure out time left.
|
|
19
|
-
const now = Date.now();
|
|
13
|
+
function trialTime(profile, now) {
|
|
20
14
|
const ends = profile.trial_ends_at ? new Date(profile.trial_ends_at).getTime() : now;
|
|
21
15
|
const msLeft = ends - now;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (msLeft
|
|
25
|
-
timeFragment = 'EXPIRED';
|
|
26
|
-
isExpired = true;
|
|
27
|
-
}
|
|
28
|
-
else if (msLeft < MS_PER_DAY) {
|
|
16
|
+
if (msLeft <= 0)
|
|
17
|
+
return { fragment: 'EXPIRED', msLeft, expired: true };
|
|
18
|
+
if (msLeft < MS_PER_DAY) {
|
|
29
19
|
const hours = Math.max(1, Math.floor(msLeft / MS_PER_HOUR));
|
|
30
|
-
|
|
20
|
+
return { fragment: `${hours} hour${hours === 1 ? '' : 's'} left`, msLeft, expired: false };
|
|
31
21
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
22
|
+
const days = Math.floor(msLeft / MS_PER_DAY);
|
|
23
|
+
return { fragment: `${days} day${days === 1 ? '' : 's'} left`, msLeft, expired: false };
|
|
24
|
+
}
|
|
25
|
+
function inputPct(profile) {
|
|
26
|
+
const cap = profile.trial_token_cap_input ?? 0;
|
|
27
|
+
return { cap, pct: cap > 0 ? Math.round((profile.usage_input_tokens / cap) * 100) : 0 };
|
|
28
|
+
}
|
|
29
|
+
/** Server-supplied text (email, plan name) on one line with no control characters. */
|
|
30
|
+
function serverText(s) {
|
|
31
|
+
return sanitizeControlChars(String(s)).replace(/\s*\r?\n\s*/g, ' ').trim();
|
|
32
|
+
}
|
|
33
|
+
export function formatLoginBanner(profile) {
|
|
34
|
+
const email = serverText(profile.email);
|
|
35
|
+
if (profile.trial_mode === null) {
|
|
36
|
+
return { line: `Signed in as ${email} (${serverText(profile.plan)})`, warningLine: null };
|
|
35
37
|
}
|
|
38
|
+
const t = trialTime(profile, Date.now());
|
|
36
39
|
if (profile.trial_mode === 'byok') {
|
|
37
40
|
return {
|
|
38
|
-
line: `Signed in as ${
|
|
39
|
-
warningLine:
|
|
40
|
-
?
|
|
41
|
-
: (msLeft < MS_PER_DAY ?
|
|
41
|
+
line: `Signed in as ${email} — BYOK trial — ${t.fragment}`,
|
|
42
|
+
warningLine: t.expired
|
|
43
|
+
? 'Your trial has expired. Visit cspeach.dev/upgrade.'
|
|
44
|
+
: (t.msLeft < MS_PER_DAY ? `Trial expires soon — ${t.fragment}.` : null),
|
|
42
45
|
};
|
|
43
46
|
}
|
|
44
47
|
// managed-capped
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
const tokenFragment = capInput > 0
|
|
49
|
-
? ` — ${formatTokens(usedInput)}/${formatTokens(capInput)} tokens (${inputPct}%)`
|
|
48
|
+
const { cap, pct } = inputPct(profile);
|
|
49
|
+
const tokenFragment = cap > 0
|
|
50
|
+
? ` — ${formatTokens(profile.usage_input_tokens)}/${formatTokens(cap)} tokens (${pct}%)`
|
|
50
51
|
: '';
|
|
51
|
-
const line = `Signed in as ${
|
|
52
|
+
const line = `Signed in as ${email} — trial (managed) — ${t.fragment}${tokenFragment}`;
|
|
52
53
|
let warning = null;
|
|
53
|
-
if (
|
|
54
|
-
warning =
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
else if (msLeft < MS_PER_DAY) {
|
|
60
|
-
warning = `⚠ Trial expires in ${timeFragment} — upgrade at cspeach.dev/upgrade.`;
|
|
61
|
-
}
|
|
54
|
+
if (t.expired)
|
|
55
|
+
warning = 'Your trial has expired. Visit cspeach.dev/upgrade.';
|
|
56
|
+
else if (pct >= 80)
|
|
57
|
+
warning = `Trial token budget at ${pct}% — upgrade or switch to BYOK before hitting the cap.`;
|
|
58
|
+
else if (t.msLeft < MS_PER_DAY)
|
|
59
|
+
warning = `Trial expires in ${t.fragment} — upgrade at cspeach.dev/upgrade.`;
|
|
62
60
|
return { line, warningLine: warning };
|
|
63
61
|
}
|
|
62
|
+
/** Panels brand-line fold-in (piece 2 §10): "signed in as <email> · <plan or trial facts>". */
|
|
63
|
+
export function signInFragment(profile, now = Date.now()) {
|
|
64
|
+
const who = `signed in as ${serverText(profile.email)}`;
|
|
65
|
+
if (profile.trial_mode === null)
|
|
66
|
+
return `${who} · ${serverText(profile.plan)}`;
|
|
67
|
+
const t = trialTime(profile, now);
|
|
68
|
+
const time = t.expired ? 'trial expired' : t.fragment;
|
|
69
|
+
if (profile.trial_mode === 'byok')
|
|
70
|
+
return `${who} · BYOK trial · ${time}`;
|
|
71
|
+
const { cap, pct } = inputPct(profile);
|
|
72
|
+
const tokens = cap > 0 ? ` · ${formatTokens(profile.usage_input_tokens)} of ${formatTokens(cap)} tokens (${pct}%)` : '';
|
|
73
|
+
return `${who} · trial · ${time}${tokens}`;
|
|
74
|
+
}
|
|
64
75
|
// Always render with a `k` suffix so the banner column width stays stable.
|
|
65
76
|
// Sub-1k values that aren't zero render as `<1k` rather than the raw number.
|
|
66
77
|
function formatTokens(n) {
|
package/dist/ui/rewind-panel.js
CHANGED
|
@@ -1,35 +1,85 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import React, { useState } from 'react';
|
|
3
3
|
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import stringWidth from 'string-width';
|
|
4
5
|
import { hhmm, rewindRowLabel, packageUnknown } from '../rewind/format.js';
|
|
6
|
+
import { roleProps, selectedProps } from './role-props.js';
|
|
7
|
+
import { getGlyphs } from '../renderer/glyphs.js';
|
|
8
|
+
import { uiWidth, truncateToWidth } from '../renderer/ui-width.js';
|
|
9
|
+
import { sanitizeControlChars } from '../renderer/sanitize.js';
|
|
5
10
|
/**
|
|
6
|
-
* RewindPanel — UX Wave 3 / Task 3
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
11
|
+
* RewindPanel — UX Wave 3 / Task 3; a card-slot occupant since CLI look
|
|
12
|
+
* piece 2 (§10). The Esc-Esc (and /rewind) card that turns "undo for your
|
|
13
|
+
* SAP system" into a picker. <App> mounts it in the card slot; it closes on
|
|
14
|
+
* Esc / q, every row is pre-cut to the card width (string-width aware) and
|
|
15
|
+
* also wrap="truncate-end", and lists taller than REWIND_VISIBLE_ROWS scroll
|
|
16
|
+
* in a window (CommandPalette pattern).
|
|
12
17
|
*
|
|
13
|
-
* Row:
|
|
14
|
-
* - restorable → bold
|
|
15
|
-
* - restorable delete with an unknown recreate package →
|
|
16
|
-
* `
|
|
18
|
+
* Row: `❯ Wrote ZMNT_NOTE (TABL) · 14:32 · snapshot 12:01` (local times)
|
|
19
|
+
* - restorable → bold; the selected row is SAP blue + bold.
|
|
20
|
+
* - restorable delete with an unknown recreate package → its own dim
|
|
21
|
+
* `package unknown — restore may fail` line (rider c).
|
|
17
22
|
* - non-restorable → dim, with the candidate's reason.
|
|
18
23
|
*
|
|
19
24
|
* Enter on a restorable row swaps the footer hint for an inline confirm line
|
|
20
|
-
* (`restore … to snapshot 12:01? runs
|
|
25
|
+
* (`restore … to snapshot 12:01? runs write gates — [y]/[n]`). `y` fires
|
|
21
26
|
* onRestore(candidate) and closes; `n` / Esc returns to the list. The panel
|
|
22
27
|
* NEVER runs the write itself — onRestore bridges to the gated executor.
|
|
23
28
|
*
|
|
24
|
-
* Empty state: a single dim `
|
|
29
|
+
* Empty state: a single dim `No restorable writes this session` line that
|
|
25
30
|
* closes on any key.
|
|
31
|
+
*
|
|
32
|
+
* Height (spec §2 cap): border 2 + title 1 + margin 1 + ↑ more 1 +
|
|
33
|
+
* REWIND_VISIBLE_ROWS + ↓ more 1 + margin 1 + hint 1 = 17, plus the App's
|
|
34
|
+
* marginTop 1 = SLOT_ROWS_ESTIMATE.rewind (18). The window is measured in
|
|
35
|
+
* ROWS: a package-unknown candidate takes two.
|
|
26
36
|
*/
|
|
27
|
-
export const REWIND_VISIBLE_ROWS =
|
|
28
|
-
|
|
37
|
+
export const REWIND_VISIBLE_ROWS = 9;
|
|
38
|
+
/** Border 1 + paddingX 2, on each side. */
|
|
39
|
+
const CHROME_COLS = 6;
|
|
40
|
+
const rowsOf = (c) => (packageUnknown(c) ? 2 : 1);
|
|
41
|
+
/** First index past the window that starts at `start` (at least one row). */
|
|
42
|
+
function windowEnd(candidates, start) {
|
|
43
|
+
let end = start;
|
|
44
|
+
let used = 0;
|
|
45
|
+
while (end < candidates.length && (end === start || used + rowsOf(candidates[end]) <= REWIND_VISIBLE_ROWS)) {
|
|
46
|
+
used += rowsOf(candidates[end]);
|
|
47
|
+
end++;
|
|
48
|
+
}
|
|
49
|
+
return end;
|
|
50
|
+
}
|
|
51
|
+
/** The scroll window for `cursor`: starts three above it where possible and
|
|
52
|
+
* always contains it. */
|
|
53
|
+
export function rewindWindow(candidates, cursor) {
|
|
54
|
+
let maxOffset = candidates.length;
|
|
55
|
+
while (maxOffset > 0 && windowEnd(candidates, maxOffset - 1) === candidates.length)
|
|
56
|
+
maxOffset--;
|
|
57
|
+
let start = Math.max(0, Math.min(cursor - 3, maxOffset));
|
|
58
|
+
let end = windowEnd(candidates, start);
|
|
59
|
+
while (cursor >= end && start < cursor) {
|
|
60
|
+
start++;
|
|
61
|
+
end = windowEnd(candidates, start);
|
|
62
|
+
}
|
|
63
|
+
return { start, end };
|
|
64
|
+
}
|
|
65
|
+
/** The inline confirm question. A long object name is cut first so the
|
|
66
|
+
* `[y]/[n]` keys stay on screen. */
|
|
67
|
+
function confirmLine(c, inner) {
|
|
68
|
+
const tail = ` to snapshot ${hhmm(c.snapshotAt)}? runs write gates — [y]/[n]`;
|
|
69
|
+
const head = 'restore ';
|
|
70
|
+
const objBudget = Math.max(1, inner - stringWidth(head) - stringWidth(tail));
|
|
71
|
+
return truncateToWidth(`${head}${truncateToWidth(sanitizeControlChars(c.object), objBudget)}${tail}`, inner);
|
|
72
|
+
}
|
|
73
|
+
export function RewindPanel({ candidates, onRestore, onClose, width = uiWidth() }) {
|
|
29
74
|
const [cursor, setCursor] = useState(0);
|
|
30
75
|
const [confirming, setConfirming] = useState(null);
|
|
31
76
|
const empty = candidates.length === 0;
|
|
32
77
|
useInput((input, key) => {
|
|
78
|
+
// Ctrl+C closes the panel outright, nothing written (fix round 3, item 4).
|
|
79
|
+
if (key.ctrl && input === 'c') {
|
|
80
|
+
onClose();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
33
83
|
// Empty state: any key dismisses.
|
|
34
84
|
if (empty) {
|
|
35
85
|
onClose();
|
|
@@ -64,18 +114,20 @@ export function RewindPanel({ candidates, onRestore, onClose }) {
|
|
|
64
114
|
return;
|
|
65
115
|
}
|
|
66
116
|
});
|
|
67
|
-
const
|
|
68
|
-
const windowStart =
|
|
69
|
-
const visible = candidates.slice(windowStart,
|
|
70
|
-
const below = Math.max(0, candidates.length -
|
|
71
|
-
|
|
117
|
+
const inner = Math.max(1, width - CHROME_COLS);
|
|
118
|
+
const { start: windowStart, end: windowStop } = rewindWindow(candidates, cursor);
|
|
119
|
+
const visible = candidates.slice(windowStart, windowStop);
|
|
120
|
+
const below = Math.max(0, candidates.length - windowStop);
|
|
121
|
+
const scrolls = windowStart > 0 || below > 0;
|
|
122
|
+
const g = getGlyphs();
|
|
123
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: roleProps('frame').color, paddingX: 2, width: width, children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: truncateToWidth('Rewind · undo a write from this session', inner) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [empty && _jsx(Text, { ...roleProps('dim'), children: "No restorable writes this session" }), !empty && windowStart > 0 && _jsxs(Text, { ...roleProps('dim'), children: [" \u2191 ", windowStart, " more"] }), !empty && visible.map((c, i) => {
|
|
72
124
|
const idx = windowStart + i;
|
|
73
125
|
const sel = idx === cursor;
|
|
74
|
-
const
|
|
126
|
+
const row = truncateToWidth(`${sel ? `${g.cursor} ` : ' '}${rewindRowLabel(c)}`, inner);
|
|
75
127
|
return (_jsxs(React.Fragment, { children: [c.restorable
|
|
76
|
-
?
|
|
77
|
-
:
|
|
78
|
-
}), !empty && below > 0 && _jsxs(Text, {
|
|
128
|
+
? _jsx(Text, { ...(sel ? selectedProps() : { bold: true }), wrap: "truncate-end", children: row })
|
|
129
|
+
: _jsx(Text, { ...roleProps('dim'), wrap: "truncate-end", children: row }), packageUnknown(c) && (_jsx(Text, { ...roleProps('dim'), wrap: "truncate-end", children: truncateToWidth(' package unknown — restore may fail', inner) }))] }, idx));
|
|
130
|
+
}), !empty && below > 0 && _jsxs(Text, { ...roleProps('dim'), children: [" \u2193 ", below, " more"] })] }), _jsx(Box, { marginTop: 1, children: confirming ? (_jsx(Text, { ...roleProps('warn'), wrap: "truncate-end", children: confirmLine(confirming, inner) })) : (_jsx(Text, { ...roleProps('dim'), wrap: "truncate-end", children: empty
|
|
79
131
|
? 'any key to close'
|
|
80
|
-
: `${
|
|
132
|
+
: `${scrolls ? '↑↓ scroll · ' : ''}Enter restore · Esc/q close` })) })] }));
|
|
81
133
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// src/ui/role-props.ts
|
|
2
|
+
//
|
|
3
|
+
// Ink colour props from theme roles (CLI look piece 2, spec §1.2). Every
|
|
4
|
+
// migrated Ink component takes colour ONLY through this helper, so NO_COLOR
|
|
5
|
+
// reaches Ink whatever chalk instance Ink bundles. Under NO_COLOR it returns
|
|
6
|
+
// no colour props (bold survives); selection then shows through ❯ + bold.
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
import { PALETTE, CHIP_BG, CHIP_FG } from '../renderer/theme.js';
|
|
9
|
+
import { getBackground } from '../renderer/look.js';
|
|
10
|
+
import { isNoColor } from '../renderer/color-mode.js';
|
|
11
|
+
/** 16-colour names matching theme.ts BASIC (paint at chalk level 1). */
|
|
12
|
+
const BASIC_NAME = {
|
|
13
|
+
sap: 'blue', ok: 'green', warn: 'yellow', bad: 'red', frame: 'gray', dim: 'gray', brand: 'yellow',
|
|
14
|
+
};
|
|
15
|
+
export function roleProps(role, opts = {}) {
|
|
16
|
+
const bold = opts.bold ? { bold: true } : {};
|
|
17
|
+
if (colourOff())
|
|
18
|
+
return bold;
|
|
19
|
+
const color = chalk.level === 1 ? BASIC_NAME[role] : PALETTE[getBackground()][role];
|
|
20
|
+
return { color, ...bold };
|
|
21
|
+
}
|
|
22
|
+
/** The selected row: SAP blue + bold; NO_COLOR → bold only. */
|
|
23
|
+
export function selectedProps() {
|
|
24
|
+
return roleProps('sap', { bold: true });
|
|
25
|
+
}
|
|
26
|
+
function colourOff() {
|
|
27
|
+
return isNoColor() || chalk.level === 0;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A filled tag chip (e.g. APPROVAL) as Ink text + props, matching theme.chip():
|
|
31
|
+
* SAP-blue fill with white text; 16 colours → blue/white; NO_COLOR → `[TAG]` bold.
|
|
32
|
+
*/
|
|
33
|
+
export function inkChip(label) {
|
|
34
|
+
if (colourOff())
|
|
35
|
+
return { text: `[${label}]`, props: { bold: true } };
|
|
36
|
+
if (chalk.level === 1)
|
|
37
|
+
return { text: ` ${label} `, props: { backgroundColor: 'blue', color: 'white', bold: true } };
|
|
38
|
+
return { text: ` ${label} `, props: { backgroundColor: CHIP_BG, color: CHIP_FG, bold: true } };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Legacy look only (inside a getLook() === 'legacy' branch): today's literal
|
|
42
|
+
* Ink colour, unchanged while colour is on, so the legacy bytes stay the same;
|
|
43
|
+
* none under NO_COLOR or chalk level 0.
|
|
44
|
+
*/
|
|
45
|
+
export function legacyInkColor(name) {
|
|
46
|
+
return colourOff() ? undefined : name;
|
|
47
|
+
}
|
|
@@ -19,6 +19,7 @@ const DEFAULT_STATE = {
|
|
|
19
19
|
// the dollar rendering. Only an explicit managed+metered resolution flips it.
|
|
20
20
|
creditsMode: false, creditsSessionTotal: 0, creditsBalance: null,
|
|
21
21
|
creditsUnitLabel: 'credits', creditsPending: false,
|
|
22
|
+
standalone: false,
|
|
22
23
|
};
|
|
23
24
|
/**
|
|
24
25
|
* Rate limiter: calls `fn` at most once every `minIntervalMs`. Trailing-only:
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
2
3
|
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import stringWidth from 'string-width';
|
|
5
|
+
import { roleProps } from './role-props.js';
|
|
6
|
+
import { getGlyphs } from '../renderer/glyphs.js';
|
|
7
|
+
import { uiWidth, truncateToWidth } from '../renderer/ui-width.js';
|
|
8
|
+
import { toolLabel } from '../renderer/tool-labels.js';
|
|
9
|
+
import { sanitizeControlChars } from '../renderer/sanitize.js';
|
|
10
|
+
import { hhmm } from '../rewind/format.js';
|
|
3
11
|
// v1.1: add 'skipped' (approval rejection logs) and 'rolled_back' (snapshot restore).
|
|
4
12
|
// Both need session-schema bump (schema_version 2) — deferred.
|
|
5
13
|
const MUTATING_TOOLS = new Set([
|
|
@@ -22,11 +30,49 @@ export function toTimelineEntry(call) {
|
|
|
22
30
|
isError: call.is_error,
|
|
23
31
|
};
|
|
24
32
|
}
|
|
25
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Write rows shown at once. Height (spec §2 cap): border 2 + title 1 +
|
|
35
|
+
* margin 1 + ↑ more 1 + TIMELINE_VISIBLE_ROWS + ↓ more 1 = 15, plus the
|
|
36
|
+
* App's marginTop 1 = SLOT_ROWS_ESTIMATE.timeline (16). Longer histories
|
|
37
|
+
* scroll with ↑↓ and open on the newest writes.
|
|
38
|
+
*/
|
|
39
|
+
export const TIMELINE_VISIBLE_ROWS = 9;
|
|
40
|
+
/** Border 1 + paddingX 2, on each side. */
|
|
41
|
+
const CHROME_COLS = 6;
|
|
42
|
+
export function SessionTimeline({ entries, onClose, width = uiWidth() }) {
|
|
26
43
|
const writes = entries.filter((e) => MUTATING_TOOLS.has(e.toolName));
|
|
44
|
+
const maxOffset = Math.max(0, writes.length - TIMELINE_VISIBLE_ROWS);
|
|
45
|
+
const [offset, setOffset] = useState(maxOffset);
|
|
46
|
+
const start = Math.min(offset, maxOffset);
|
|
47
|
+
const visible = writes.slice(start, start + TIMELINE_VISIBLE_ROWS);
|
|
48
|
+
const below = writes.length - (start + visible.length);
|
|
49
|
+
const g = getGlyphs();
|
|
50
|
+
const inner = Math.max(1, width - CHROME_COLS);
|
|
27
51
|
useInput((input, key) => {
|
|
28
|
-
if (key.escape || input === 'q')
|
|
52
|
+
if (key.escape || input === 'q' || (key.ctrl && input === 'c')) {
|
|
29
53
|
onClose();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
// Functional updaters: a burst of arrows before a re-render must not
|
|
57
|
+
// collapse into one step. Clamped to [0, maxOffset].
|
|
58
|
+
if (key.upArrow) {
|
|
59
|
+
setOffset((o) => Math.max(0, Math.min(o, maxOffset) - 1));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (key.downArrow) {
|
|
63
|
+
setOffset((o) => Math.min(maxOffset, Math.max(0, o) + 1));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
30
66
|
});
|
|
31
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor:
|
|
67
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: roleProps('frame').color, paddingX: 2, width: width, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: ["Write history ", _jsx(Text, { ...roleProps('dim'), children: maxOffset > 0 ? '· ↑↓ scroll · Esc to close' : '· Esc to close' })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [writes.length === 0 && _jsx(Text, { ...roleProps('dim'), children: "No writes this session" }), start > 0 && _jsxs(Text, { ...roleProps('dim'), children: [" \u2191 ", start, " more"] }), visible.map((e, i) => {
|
|
68
|
+
const time = `${hhmm(e.timestamp)} `;
|
|
69
|
+
const glyph = e.isError ? g.failed : g.done;
|
|
70
|
+
const verb = toolLabel(e.toolName, {}).verb;
|
|
71
|
+
const type = e.objectType !== '-' ? ` (${sanitizeControlChars(e.objectType)})` : '';
|
|
72
|
+
const ms = ` · ${e.durationMs} ms`;
|
|
73
|
+
// Pre-cut the object name so the verb, type and duration stay visible.
|
|
74
|
+
const fixed = stringWidth(`${time}${glyph} ${verb} ${type}${ms}`);
|
|
75
|
+
const name = truncateToWidth(sanitizeControlChars(e.objectName), Math.max(1, inner - fixed));
|
|
76
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...roleProps('dim'), children: time }), _jsx(Text, { ...roleProps(e.isError ? 'bad' : 'ok'), children: glyph }), _jsxs(Text, { children: [" ", verb, " ", name, type] }), _jsx(Text, { ...roleProps('dim'), children: ms })] }, start + i));
|
|
77
|
+
}), below > 0 && _jsxs(Text, { ...roleProps('dim'), children: [" \u2193 ", below, " more"] })] })] }));
|
|
32
78
|
}
|