@cspeach/cli 0.9.0 → 1.1.0
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 +1 -1
- package/dist/agent/intent-system-prompt.js +1 -1
- package/dist/agent/loop.js +228 -26
- package/dist/agent/providers/license-gate.js +44 -0
- package/dist/agent/skill-checkpoint.js +1 -1
- package/dist/agent/tool-dispatch.js +15 -0
- package/dist/approvals/canonical.js +91 -0
- package/dist/approvals/jwt.js +39 -2
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +18 -3
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +284 -0
- package/dist/commands/config-show.js +20 -0
- package/dist/commands/export-audit.js +43 -0
- package/dist/commands/help.js +5 -0
- package/dist/commands/login.js +31 -14
- package/dist/commands/plan-audit-evidence.js +266 -0
- package/dist/commands/plan-audit.js +692 -0
- package/dist/commands/plan-chain.js +671 -0
- package/dist/commands/plan-continue.js +179 -0
- package/dist/commands/plan-gate.js +154 -0
- package/dist/commands/plan-model-tier.js +83 -0
- package/dist/commands/plan-resume.js +728 -46
- package/dist/config/loader.js +223 -5
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- package/dist/doctor/checks/_http-probe.js +1 -0
- package/dist/doctor/checks/cert.js +14 -3
- package/dist/doctor/checks/sap.js +30 -8
- package/dist/doctor/checks/system-roles.js +41 -0
- package/dist/doctor/checks/zcspeach.js +19 -4
- package/dist/doctor/run.js +2 -0
- package/dist/models/resolve.js +61 -0
- package/dist/models/server-config.js +155 -0
- package/dist/one-shot.js +76 -6
- package/dist/projects/answer-blockers.js +137 -0
- package/dist/projects/extract-cca.js +111 -17
- package/dist/projects/extract-modernize.js +4 -2
- package/dist/projects/extract-plan.js +184 -37
- package/dist/projects/extract-spec-gap.js +34 -7
- package/dist/projects/extract-test-coverage.js +4 -2
- package/dist/projects/extract-upgrade.js +116 -23
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +5 -2
- package/dist/projects/merge-cca.js +292 -0
- package/dist/projects/merge-upgrade.js +173 -0
- package/dist/projects/migration.js +103 -1
- package/dist/projects/output-paths.js +27 -0
- package/dist/projects/plan-run.js +285 -27
- package/dist/projects/plan-schema.js +136 -3
- package/dist/projects/promote-command.js +25 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +259 -21
- package/dist/projects/status.js +3 -1
- package/dist/projects/validate.js +1 -1
- package/dist/projects/workspace.js +164 -20
- package/dist/renderer/notices.js +64 -0
- package/dist/renderer/progress-chatter.js +8 -0
- package/dist/renderer/status-footer.js +22 -12
- package/dist/renderer/thinking-heartbeat.js +64 -8
- package/dist/renderer/todo-block.js +51 -0
- package/dist/renderer/tool-widget.js +55 -4
- package/dist/renderer/tty.js +43 -4
- package/dist/renderer/verify-chain.js +77 -0
- package/dist/repl/at-picker.js +60 -7
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +42 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/early-line-buffer.js +68 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/inquirer-guard.js +70 -5
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/numbered-menu.js +131 -0
- package/dist/repl/post-turn-status.js +26 -6
- package/dist/repl/rule8-detector.js +17 -2
- package/dist/repl/safety-confirm.js +111 -2
- package/dist/repl/safety-mode-state.js +19 -3
- package/dist/repl/slash-completer.js +5 -0
- package/dist/repl/slash-picker.js +10 -15
- package/dist/repl.js +1232 -95
- package/dist/rewind/candidates.js +194 -0
- package/dist/rewind/cli.js +137 -0
- package/dist/rewind/format.js +27 -0
- package/dist/rewind/restore.js +245 -0
- package/dist/router/classifier.js +150 -6
- package/dist/sap/capability-matrix.js +20 -0
- package/dist/sap/capability-matrix.json +11236 -0
- package/dist/sap/capability.js +146 -0
- package/dist/sap/connection-manager.js +19 -1
- package/dist/sap/onboarding.js +42 -4
- package/dist/session/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/pending.js +27 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +51 -40
- package/dist/skills/bundled-skills.js +272 -1
- package/dist/skills/promotion-dispatch.js +23 -0
- package/dist/tools/_command-shared.js +36 -12
- package/dist/tools/_filesystem-shared.js +139 -4
- package/dist/tools/_flag.js +25 -0
- package/dist/tools/approval.js +177 -26
- package/dist/tools/ask-question.js +400 -7
- package/dist/tools/capability/tool.js +74 -0
- package/dist/tools/dispatch-skill.js +22 -1
- package/dist/tools/extend-model/anchored-insert.js +1414 -0
- package/dist/tools/extend-model/tool.js +340 -0
- package/dist/tools/filesystem/extract-document.js +57 -0
- package/dist/tools/filesystem/file-edit.js +12 -2
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/file-write.js +11 -2
- package/dist/tools/filesystem/glob.js +11 -0
- package/dist/tools/filesystem/grep.js +10 -0
- package/dist/tools/filesystem/read-document.js +107 -0
- package/dist/tools/fiori/apply.js +50 -0
- package/dist/tools/fiori/bin.js +3 -0
- package/dist/tools/fiori/catalog/index.js +27 -0
- package/dist/tools/fiori/catalog/value-help.js +230 -0
- package/dist/tools/fiori/catalog/viz-chart.js +177 -0
- package/dist/tools/fiori/cli.js +71 -0
- package/dist/tools/fiori/deploy-config.js +73 -0
- package/dist/tools/fiori/fe-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +71 -0
- package/dist/tools/fiori/floorplan-map.js +19 -0
- package/dist/tools/fiori/i18n.js +39 -0
- package/dist/tools/fiori/manifest.js +70 -0
- package/dist/tools/fiori/render.js +77 -0
- package/dist/tools/fiori/samples/data/index.json +13602 -0
- package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
- package/dist/tools/fiori/samples/loader.js +248 -0
- package/dist/tools/fiori/samples/search.js +63 -0
- package/dist/tools/fiori/samples/types.js +2 -0
- package/dist/tools/fiori/scaffold.js +39 -0
- package/dist/tools/fiori/smoke/assertions.js +74 -0
- package/dist/tools/fiori/smoke/browser.js +52 -0
- package/dist/tools/fiori/smoke/driver.js +89 -0
- package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
- package/dist/tools/fiori/smoke/run-smoke.js +149 -0
- package/dist/tools/fiori/tools.js +681 -0
- package/dist/tools/fiori/types.js +1 -0
- package/dist/tools/local-build.js +86 -0
- package/dist/tools/local-files.js +31 -0
- package/dist/tools/project/_merge-shared.js +68 -0
- package/dist/tools/project/cca_merge.js +164 -0
- package/dist/tools/project/playbook_get.js +1 -1
- package/dist/tools/project/upgrade_merge_progress.js +206 -0
- package/dist/tools/sap-read.js +132 -20
- package/dist/tools/sap-write.js +550 -21
- package/dist/tools/shell/shell_exec.js +41 -6
- package/dist/tools/snapshot.js +63 -14
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/subagent/background_run.js +17 -1
- package/dist/tools/todo.js +144 -0
- package/dist/tools/transport-resolution.js +86 -0
- package/dist/tools/transport.js +224 -5
- package/dist/tools/write-mode.js +4 -0
- package/dist/ui/app.js +378 -21
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/body.js +13 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +120 -27
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +35 -8
- package/dist/ui/rewind-emitter.js +10 -0
- package/dist/ui/rewind-panel.js +81 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +214 -0
- package/dist/ui/todo-emitter.js +25 -0
- package/dist/ui/todo-panel.js +64 -0
- package/dist/ui/turn-status-emitter.js +50 -4
- package/dist/ui/turn-status.js +18 -3
- package/dist/ui/widgets/ask-form.js +242 -0
- package/dist/ui/widgets/ask-question-modal.js +21 -8
- package/package.json +22 -3
- package/bench/README.md +0 -78
- package/bench/prompts/abap-document-cds.md +0 -44
- package/bench/prompts/abap-explain-bdef-handler.md +0 -57
- package/bench/prompts/abap-test-method.md +0 -42
- package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
- package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
- package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
- package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
- package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
- package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
- package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
- package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
- package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
|
@@ -9,5 +9,19 @@ class AskQuestionEmitter extends EventEmitter {
|
|
|
9
9
|
this.emit('request', { ...params, resolve });
|
|
10
10
|
});
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* UX Wave 2 / Task 2 — v2 batched form request. Same no-listener fallback
|
|
14
|
+
* contract as request(): resolve cancelled (v2 shape — empty answers) so
|
|
15
|
+
* callers without a mounted App fall through to their own path.
|
|
16
|
+
*/
|
|
17
|
+
requestForm(form) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
if (this.listenerCount('request') === 0) {
|
|
20
|
+
resolve({ formId: form.formId, answers: [], cancelled: true });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
this.emit('request', { ...form, resolve });
|
|
24
|
+
});
|
|
25
|
+
}
|
|
12
26
|
}
|
|
13
27
|
export const askQuestionEmitter = new AskQuestionEmitter();
|
package/dist/ui/body.js
CHANGED
|
@@ -3,6 +3,7 @@ import React, { useReducer, useEffect } from 'react';
|
|
|
3
3
|
import { Text, Static } from 'ink';
|
|
4
4
|
import { WIDGET_COMPONENTS } from './widgets/component-registry.js';
|
|
5
5
|
import { WIDGET_CONTROL } from './widget-control.js';
|
|
6
|
+
import { formatNoticeLine } from '../renderer/notices.js';
|
|
6
7
|
/**
|
|
7
8
|
* Frame interval for batching streaming chunks before dispatching to the
|
|
8
9
|
* reducer (W2.2 — 2026-05-18). Ink already throttles its stdout writes to
|
|
@@ -162,9 +163,21 @@ export const Body = React.memo(function Body({ chunkEmitter }) {
|
|
|
162
163
|
// synchronously on unmount so end-of-stream output isn't dropped.
|
|
163
164
|
const batcher = createChunkBatcher((buffered) => dispatch({ chunk: buffered }));
|
|
164
165
|
const handler = (chunk) => batcher.push(chunk);
|
|
166
|
+
// B2 fix (2026-06-12) — consume the 'warn'/'info' notice channel.
|
|
167
|
+
// Tool handlers (transport-resolution Rule 9 notices, write-tool
|
|
168
|
+
// snapshot lines) emit these levels on the same emitter; before this
|
|
169
|
+
// they were silent no-ops in the live Ink REPL. Notices are formatted
|
|
170
|
+
// to a single line and pushed through the SAME batcher so their order
|
|
171
|
+
// relative to surrounding chunk text is preserved.
|
|
172
|
+
const warnHandler = (msg) => batcher.push(formatNoticeLine('warn', msg));
|
|
173
|
+
const infoHandler = (msg) => batcher.push(formatNoticeLine('info', msg));
|
|
165
174
|
chunkEmitter.on('chunk', handler);
|
|
175
|
+
chunkEmitter.on('warn', warnHandler);
|
|
176
|
+
chunkEmitter.on('info', infoHandler);
|
|
166
177
|
return () => {
|
|
167
178
|
chunkEmitter.off('chunk', handler);
|
|
179
|
+
chunkEmitter.off('warn', warnHandler);
|
|
180
|
+
chunkEmitter.off('info', infoHandler);
|
|
168
181
|
batcher.dispose();
|
|
169
182
|
};
|
|
170
183
|
}, [chunkEmitter]);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// cspeach-cli/src/ui/context-grid.tsx
|
|
2
|
+
//
|
|
3
|
+
// UX Wave 3 / Task 5 — render the /context composition grid to lines of text.
|
|
4
|
+
//
|
|
5
|
+
// One bar per segment, proportional to the exact billed prompt total (share of
|
|
6
|
+
// USED context), capped at 40 columns. Classic mode = the same lines minus
|
|
7
|
+
// color, so this is a single
|
|
8
|
+
// pure line-builder with a `color` flag rather than two renderers. The Ink
|
|
9
|
+
// dispatch path emits these lines through chunkEmitter; the classic path
|
|
10
|
+
// console.logs them — same seam as /cost and /export.
|
|
11
|
+
//
|
|
12
|
+
// Wave-1 invariant (binding): NO row may ever wrap. Every row is truncated end
|
|
13
|
+
// to the terminal width AS PLAIN TEXT FIRST, then color is applied to the
|
|
14
|
+
// already-truncated string — so colored lines are truncated identically to
|
|
15
|
+
// classic ones and an ANSI escape can never be split by the cut.
|
|
16
|
+
import chalk from 'chalk';
|
|
17
|
+
import { shouldUseUnicodeBoxChars } from '../renderer/tty.js';
|
|
18
|
+
import { buildContextReport } from '../session/context-report.js';
|
|
19
|
+
import { readCostLog } from '../cost/cost-log.js';
|
|
20
|
+
/** Bars never exceed this many columns (Wave-1 proportional-bar cap). */
|
|
21
|
+
const BAR_MAX = 40;
|
|
22
|
+
/** Segment labels are padded to this width so values line up. */
|
|
23
|
+
const LABEL_WIDTH = 22;
|
|
24
|
+
/**
|
|
25
|
+
* Values (`~130k`) are padded to this width so the bars that follow line up.
|
|
26
|
+
* `~` + up to 3 digits + `k` = 5; a couple of spare columns keep three-digit
|
|
27
|
+
* k-values aligned. The value sits BEFORE the bar (triage #14) so a narrow
|
|
28
|
+
* terminal truncates bar characters, never the number's digits.
|
|
29
|
+
*/
|
|
30
|
+
const VALUE_WIDTH = 7;
|
|
31
|
+
/** k-format a token count: 130400 → "130k". */
|
|
32
|
+
function k(n) {
|
|
33
|
+
return `${Math.round(n / 1000)}k`;
|
|
34
|
+
}
|
|
35
|
+
/** Truncate a plain string to `width` columns (end-truncation, no wrap). */
|
|
36
|
+
function truncateEnd(s, width) {
|
|
37
|
+
if (width <= 0)
|
|
38
|
+
return '';
|
|
39
|
+
return s.length > width ? s.slice(0, width) : s;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Build the /context grid as an array of ready-to-emit lines.
|
|
43
|
+
*
|
|
44
|
+
* The header shows the one exact number (lastTurnInput, in k). Every segment
|
|
45
|
+
* row shows a `~` (approx) token figure — the heuristic honesty marker. Bars
|
|
46
|
+
* are scaled against lastTurnInput so they read as "share of the USED
|
|
47
|
+
* context", and capped at BAR_MAX.
|
|
48
|
+
*/
|
|
49
|
+
export function renderContextGrid(report, opts = {}) {
|
|
50
|
+
const color = opts.color ?? true;
|
|
51
|
+
const width = opts.width ?? (typeof process !== 'undefined' && process.stdout?.columns ? process.stdout.columns : 80);
|
|
52
|
+
const unicode = shouldUseUnicodeBoxChars();
|
|
53
|
+
const fill = unicode ? '█' : '#';
|
|
54
|
+
const arrow = unicode ? '→' : '->';
|
|
55
|
+
const lines = [];
|
|
56
|
+
// ── header: context: 87k / 200k (43%) ── (the only exact number)
|
|
57
|
+
// Truncate the PLAIN text first, then color — so the width cut is identical
|
|
58
|
+
// in both modes and can never land inside an ANSI escape.
|
|
59
|
+
const pctInt = Math.round(report.pct * 100);
|
|
60
|
+
const headerText = truncateEnd(`context: ${k(report.lastTurnInput)} / ${k(report.windowTokens)} (${pctInt}%)`, width);
|
|
61
|
+
lines.push(color ? chalk.bold(headerText) : headerText);
|
|
62
|
+
lines.push('');
|
|
63
|
+
// Scale bars against the exact billed prompt total (lastTurnInput) so a
|
|
64
|
+
// segment's bar reads as its share of the USED context, capped at BAR_MAX.
|
|
65
|
+
// Fall back to the largest segment when there is no billed input yet (so an
|
|
66
|
+
// unbilled session still renders a readable grid).
|
|
67
|
+
const scaleBase = report.lastTurnInput > 0
|
|
68
|
+
? report.lastTurnInput
|
|
69
|
+
: Math.max(1, ...report.segments.map((s) => s.approxTokens));
|
|
70
|
+
for (const seg of report.segments) {
|
|
71
|
+
const barLen = Math.min(BAR_MAX, Math.max(0, Math.round((BAR_MAX * seg.approxTokens) / scaleBase)));
|
|
72
|
+
const bar = fill.repeat(barLen);
|
|
73
|
+
const label = seg.label.padEnd(LABEL_WIDTH).slice(0, LABEL_WIDTH);
|
|
74
|
+
const value = `~${k(seg.approxTokens)}`;
|
|
75
|
+
// Value BEFORE the bar (triage #14): a narrow terminal truncates the bar
|
|
76
|
+
// run, never the number's digits (value-after-bar clipped "~130k" → "~1").
|
|
77
|
+
// Plain, color-free row: identical bytes in both modes → truncation can
|
|
78
|
+
// never split an ANSI escape, and classic == Ink minus color trivially.
|
|
79
|
+
const row = `${label} ${value.padEnd(VALUE_WIDTH)} ${bar}`;
|
|
80
|
+
lines.push(truncateEnd(row, width));
|
|
81
|
+
}
|
|
82
|
+
if (report.hints.length > 0) {
|
|
83
|
+
lines.push('');
|
|
84
|
+
for (const h of report.hints) {
|
|
85
|
+
// Same order as the header: plain-truncate, THEN color.
|
|
86
|
+
const text = truncateEnd(`${arrow} ${h}`, width);
|
|
87
|
+
lines.push(color ? chalk.dim(text) : text);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return lines;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* `/context` command — read the session's cost log, build the composition
|
|
94
|
+
* report, render the grid, and emit it line by line through the caller's sink
|
|
95
|
+
* (chunkEmitter in Ink mode, console.log in classic mode — same seam as
|
|
96
|
+
* runCostCommand). Read-only, no network. `costEntries` is injectable for
|
|
97
|
+
* tests; otherwise it is read from the session's JSONL log.
|
|
98
|
+
*/
|
|
99
|
+
export async function runContextCommand(opts) {
|
|
100
|
+
const { session, emit } = opts;
|
|
101
|
+
const costEntries = opts.costEntries ?? (await readCostLog(session.id));
|
|
102
|
+
const report = buildContextReport(session, costEntries);
|
|
103
|
+
const lines = renderContextGrid(report, { color: opts.color });
|
|
104
|
+
emit('');
|
|
105
|
+
for (const line of lines)
|
|
106
|
+
emit(line);
|
|
107
|
+
emit('');
|
|
108
|
+
}
|
package/dist/ui/footer.js
CHANGED
|
@@ -1,44 +1,94 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import React, { useState, useEffect, useCallback } from 'react';
|
|
3
|
-
import { Box, Text, useInput } from 'ink';
|
|
4
|
-
|
|
2
|
+
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
3
|
+
import { Box, Text, useInput, useStdout } from 'ink';
|
|
4
|
+
// D29 paste fix (2026-06-12): vendored paste-safe replacement for
|
|
5
|
+
// ink-text-input — multi-chunk pastes were losing every chunk but the
|
|
6
|
+
// last (stale-prop closure in the upstream component). See text-input.tsx.
|
|
7
|
+
import TextInput from './text-input.js';
|
|
5
8
|
import Fuse from 'fuse.js';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
// Task 8 (ux-wave1) — persistence lives in the shared writer (one file,
|
|
10
|
+
// one dedup guard for the Footer, Ink handleSubmit, and classic readline).
|
|
11
|
+
// It resolves os.homedir() lazily so tests can point it at a temp dir.
|
|
12
|
+
import { appendHistoryEntry, readHistoryEntries, flattenHistoryEntry } from '../repl/history.js';
|
|
10
13
|
function loadHistory() {
|
|
11
|
-
|
|
12
|
-
return fs.readFileSync(HISTORY_FILE, 'utf8').split('\n').filter(Boolean);
|
|
13
|
-
}
|
|
14
|
-
catch {
|
|
15
|
-
return [];
|
|
16
|
-
}
|
|
14
|
+
return readHistoryEntries();
|
|
17
15
|
}
|
|
18
16
|
function appendHistory(line, history) {
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
// The shared writer flattens (newlines/tabs/C0 controls → ' '), trims,
|
|
18
|
+
// and skips consecutive duplicates (bash HISTCONTROL=ignoredups / CC
|
|
19
|
+
// parity). Mirror into the in-memory array ONLY when the entry was
|
|
20
|
+
// actually written, so arrow-up and Ctrl+R stay consistent with the
|
|
21
|
+
// file. Same flatten as the writer (tab-flatten fast-follow) so the
|
|
22
|
+
// mirror never diverges from the persisted entry.
|
|
23
|
+
const entry = flattenHistoryEntry(line);
|
|
24
|
+
if (appendHistoryEntry(entry))
|
|
25
|
+
history.push(entry);
|
|
25
26
|
}
|
|
26
27
|
const PEACH = '#FFCBA4';
|
|
27
|
-
|
|
28
|
+
/** The input prompt — its width feeds the display budget below. */
|
|
29
|
+
const PROMPT = 'CSPeach › ';
|
|
30
|
+
export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChange, prefill, disabled, onNotice, onBufferChange }) {
|
|
28
31
|
const [value, setValue] = useState('');
|
|
32
|
+
// Wave1 live-fix (Bug 1, statusline duplication) — the input row must
|
|
33
|
+
// NEVER soft-wrap in the terminal: Ink's log-update erases logical
|
|
34
|
+
// lines, not terminal rows, so one wrapped dynamic-frame row leaks the
|
|
35
|
+
// StatusLine into scrollback on every keystroke repaint (see the
|
|
36
|
+
// maxDisplayWidth doc in text-input.tsx for the ink source refs). Cap
|
|
37
|
+
// the displayed value at columns − prompt − 1 (the −1 keeps the row off
|
|
38
|
+
// the exact-width deferred-wrap edge on Windows Terminal). Re-rendered
|
|
39
|
+
// on stdout resize so a narrowed window tightens the budget promptly.
|
|
40
|
+
const { stdout } = useStdout();
|
|
41
|
+
const [, bumpOnResize] = useState(0);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!stdout)
|
|
44
|
+
return;
|
|
45
|
+
const onResize = () => bumpOnResize((n) => n + 1);
|
|
46
|
+
stdout.on('resize', onResize);
|
|
47
|
+
return () => { stdout.off('resize', onResize); };
|
|
48
|
+
}, [stdout]);
|
|
49
|
+
const columns = stdout?.columns || 80;
|
|
50
|
+
const inputBudget = Math.max(8, columns - PROMPT.length - 1);
|
|
29
51
|
const [history] = useState(loadHistory);
|
|
30
52
|
const [histIdx, setHistIdx] = useState(-1);
|
|
31
53
|
const [fuzzyMode, setFuzzyMode] = useState(false);
|
|
32
54
|
const [fuzzyQuery, setFuzzyQuery] = useState('');
|
|
33
55
|
const [fuzzyResults, setFuzzyResults] = useState([]);
|
|
56
|
+
// Task 7 (ux-wave1) — multiline continuation buffer. Holds COMPLETE
|
|
57
|
+
// lines (from trailing-`\` submits and multi-line pastes) until the
|
|
58
|
+
// final Enter joins them with the current input line into ONE message.
|
|
59
|
+
// The TextInput never sees this state — it stays single-line.
|
|
60
|
+
//
|
|
61
|
+
// bufferRef is the synchronous mirror of the same D29 shape as
|
|
62
|
+
// text-input's valueRef: a paste tail and its Enter can land in ONE
|
|
63
|
+
// stdin drain, so onPasteLines and handleSubmit may both run before
|
|
64
|
+
// React commits the state update. All reads/writes go through the ref;
|
|
65
|
+
// the state exists only to drive the indicator render.
|
|
66
|
+
const [buffer, setBuffer] = useState([]);
|
|
67
|
+
const bufferRef = useRef([]);
|
|
68
|
+
const updateBuffer = useCallback((lines) => {
|
|
69
|
+
bufferRef.current = lines;
|
|
70
|
+
setBuffer(lines);
|
|
71
|
+
}, []);
|
|
34
72
|
const fuse = React.useMemo(() => new Fuse(history, { threshold: 0.4 }), [history]);
|
|
35
73
|
// Mirror state into parent when it cares (e.g. App's ? key-binding gate).
|
|
36
74
|
useEffect(() => { onValueChange?.(value); }, [value, onValueChange]);
|
|
75
|
+
// Task 7 — mirror the buffer depth into App (live-trigger suppression).
|
|
76
|
+
useEffect(() => { onBufferChange?.(buffer.length); }, [buffer.length, onBufferChange]);
|
|
77
|
+
// Task 7 — complete lines carved out of a multi-line paste by the
|
|
78
|
+
// TextInput (see onPasteLines in text-input.tsx). Append, never submit.
|
|
79
|
+
const handlePasteLines = useCallback((lines) => {
|
|
80
|
+
updateBuffer([...bufferRef.current, ...lines]);
|
|
81
|
+
}, [updateBuffer]);
|
|
37
82
|
// If the parent pushes a prefill (palette pick, palette cancel restore,
|
|
38
83
|
// empty-body guard), pre-fill our input. Keyed on the nonce so identical
|
|
39
|
-
// text can re-fire.
|
|
84
|
+
// text can re-fire. Empty text is honoured too (Task 5): App's live
|
|
85
|
+
// key-triggers (`/`, `?`, Alt+M) push an empty prefill to SWALLOW the
|
|
86
|
+
// trigger character, which Ink also delivers to our TextInput — without
|
|
87
|
+
// the clear, the leaked char reappears once the overlay closes. Nonce 0
|
|
88
|
+
// is the untouched initial state (App never pushes nonce 0), so skip it
|
|
89
|
+
// rather than clearing on mount.
|
|
40
90
|
useEffect(() => {
|
|
41
|
-
if (prefill && prefill.
|
|
91
|
+
if (prefill && prefill.nonce > 0)
|
|
42
92
|
setValue(prefill.text);
|
|
43
93
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
44
94
|
}, [prefill?.nonce]);
|
|
@@ -70,6 +120,15 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
|
|
|
70
120
|
}
|
|
71
121
|
return;
|
|
72
122
|
}
|
|
123
|
+
// Task 7 — Esc discards the multiline draft. Runs AFTER the fuzzy
|
|
124
|
+
// branch (fuzzy Esc closes the search) and only while this hook is
|
|
125
|
+
// active at all (isActive: !disabled) — when a palette/modal overlay
|
|
126
|
+
// is up the Footer is disabled, so overlay-Esc never races this.
|
|
127
|
+
if (key.escape && bufferRef.current.length > 0) {
|
|
128
|
+
updateBuffer([]);
|
|
129
|
+
onNotice?.('multiline draft discarded');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
73
132
|
if (key.upArrow) {
|
|
74
133
|
const next = Math.min(histIdx + 1, history.length - 1);
|
|
75
134
|
setHistIdx(next);
|
|
@@ -82,8 +141,42 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
|
|
|
82
141
|
setValue(next === -1 ? '' : history[history.length - 1 - next] ?? '');
|
|
83
142
|
return;
|
|
84
143
|
}
|
|
85
|
-
|
|
144
|
+
// 2026-06-16 — go dormant while a modal (ask_question / coaching picker)
|
|
145
|
+
// owns input. Without this, arrow-key navigation INSIDE the modal also
|
|
146
|
+
// hits this handler (Ink delivers every keystroke to every active
|
|
147
|
+
// useInput), recalling a history line into the hidden Footer — so the
|
|
148
|
+
// last command "comes back" in the input once the modal closes. Mirrors
|
|
149
|
+
// why the TextInput is swapped for a placeholder when disabled.
|
|
150
|
+
}, { isActive: !disabled });
|
|
86
151
|
const handleSubmit = useCallback((line) => {
|
|
152
|
+
// Task 7 — trailing `\` continues the message: strip the backslash,
|
|
153
|
+
// hold the line (indentation preserved — this is for ABAP snippets),
|
|
154
|
+
// clear the input, submit nothing. A bare `\` inserts a blank line.
|
|
155
|
+
if (line.endsWith('\\')) {
|
|
156
|
+
updateBuffer([...bufferRef.current, line.slice(0, -1)]);
|
|
157
|
+
setHistIdx(-1);
|
|
158
|
+
setValue('');
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
// Task 7 — final submit with buffered lines: join into ONE message.
|
|
162
|
+
// The onSubmit contract is unchanged (one string, may contain \n) —
|
|
163
|
+
// App's beginSubmit / steering path takes it as a single message.
|
|
164
|
+
// The bare-`/` and `/history` intercepts are intentionally skipped
|
|
165
|
+
// here: mid-composition a `/` line is literal text (same pin as the
|
|
166
|
+
// live-trigger suppression in App).
|
|
167
|
+
if (bufferRef.current.length > 0) {
|
|
168
|
+
const joined = [...bufferRef.current, line].join('\n').trim();
|
|
169
|
+
updateBuffer([]);
|
|
170
|
+
setHistIdx(-1);
|
|
171
|
+
setValue('');
|
|
172
|
+
if (!joined)
|
|
173
|
+
return;
|
|
174
|
+
// The history file is one-entry-per-line; flatten so a multiline
|
|
175
|
+
// message doesn't split into N bogus entries on the next load.
|
|
176
|
+
appendHistory(joined.replace(/\n/g, ' '), history);
|
|
177
|
+
onSubmit(joined);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
87
180
|
if (!line.trim())
|
|
88
181
|
return;
|
|
89
182
|
appendHistory(line.trim(), history);
|
|
@@ -98,9 +191,9 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
|
|
|
98
191
|
return;
|
|
99
192
|
}
|
|
100
193
|
onSubmit(line.trim());
|
|
101
|
-
}, [onSubmit, onSlashTrigger, onHistoryTrigger, history]);
|
|
194
|
+
}, [onSubmit, onSlashTrigger, onHistoryTrigger, history, updateBuffer]);
|
|
102
195
|
if (fuzzyMode) {
|
|
103
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "History search (Ctrl+R)" }), _jsx(TextInput, { value: fuzzyQuery, onChange: setFuzzyQuery, placeholder: "type to search..." }), fuzzyResults.map((r, i) => _jsx(Text, { dimColor: i > 0, children: r }, i)), _jsx(Text, { dimColor: true, children: "Esc: cancel Enter: select" })] }));
|
|
196
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "History search (Ctrl+R)" }), _jsx(TextInput, { value: fuzzyQuery, onChange: setFuzzyQuery, placeholder: "type to search...", maxDisplayWidth: Math.max(8, columns - 5) }), fuzzyResults.map((r, i) => (_jsx(Text, { dimColor: i > 0, wrap: "truncate-end", children: flattenHistoryEntry(r) }, i))), _jsx(Text, { dimColor: true, children: "Esc: cancel Enter: select" })] }));
|
|
104
197
|
}
|
|
105
198
|
// 2026-05-17 — when a modal owns input (coaching picker / ask_question),
|
|
106
199
|
// render a dim placeholder INSTEAD of the live TextInput. Otherwise
|
|
@@ -108,7 +201,7 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
|
|
|
108
201
|
// Footer submission fires after the modal resolves → spurious picker
|
|
109
202
|
// for what the user thought was their modal answer.
|
|
110
203
|
if (disabled) {
|
|
111
|
-
return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children:
|
|
204
|
+
return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: PROMPT }), _jsx(Text, { dimColor: true, children: "(answer above to continue)" })] }));
|
|
112
205
|
}
|
|
113
|
-
return (_jsxs(Box, { children: [
|
|
206
|
+
return (_jsxs(Box, { flexDirection: "column", children: [buffer.length > 0 && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2026 ", buffer.length, " line", buffer.length === 1 ? '' : 's', " pending (Enter sends, \\ continues)"] })), _jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: PROMPT }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: handleSubmit, onPasteLines: handlePasteLines, maxDisplayWidth: inputBudget })] })] }));
|
|
114
207
|
}
|
package/dist/ui/header.js
CHANGED
|
@@ -4,6 +4,13 @@ import { useSapState } from './sap-state-store.js';
|
|
|
4
4
|
import { CLI_VERSION } from '../lib/version.js';
|
|
5
5
|
const PEACH = '#FFCBA4';
|
|
6
6
|
/**
|
|
7
|
+
* ⚠ SUPERSEDED (UX Wave 1, Task 4 — 2026-07) by ui/status-line.tsx, the
|
|
8
|
+
* bottom-anchored borderless statusline mounted in App between TurnStatus
|
|
9
|
+
* and Footer. This component is no longer rendered anywhere: Phase D6
|
|
10
|
+
* removed the persistent top Header (inline mode can't pin the frame top),
|
|
11
|
+
* and the statusline took over the SapStateStore display role. Kept for
|
|
12
|
+
* reference, not deleted.
|
|
13
|
+
*
|
|
7
14
|
* Single-line rich header (Phase D3, 2026-05-16) — back to one line per
|
|
8
15
|
* Laeeq's feedback that the two-line variant felt worse. Uses tighter
|
|
9
16
|
* separators and shorter labels so it fits cleanly on typical terminals
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
// pick, so a line flows slash → at → empty-body guard → submit no
|
|
20
20
|
// matter where it entered the pipeline.
|
|
21
21
|
import { isExactSlashMatch, filterSlashEntries } from '../repl/slash-picker.js';
|
|
22
|
-
import { pendingAtTokenPrefix
|
|
23
|
-
import { SKILL_CATALOG } from '../skill-catalog.js';
|
|
22
|
+
import { pendingAtTokenPrefix } from '../repl/at-picker.js';
|
|
23
|
+
import { SKILL_CATALOG, SKILL_ALIASES } from '../skill-catalog.js';
|
|
24
24
|
export function nextResolutionStep(line, files) {
|
|
25
25
|
const trimmed = line.trim();
|
|
26
26
|
if (trimmed.startsWith('/') && !isExactSlashMatch(trimmed)) {
|
|
@@ -38,19 +38,36 @@ export function nextResolutionStep(line, files) {
|
|
|
38
38
|
const filenames = files.map((f) => f.filename);
|
|
39
39
|
const atPrefix = pendingAtTokenPrefix(trimmed, filenames);
|
|
40
40
|
if (atPrefix !== null && files.length > 0) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
// Always open the picker when the line ends in an unresolved @-token —
|
|
42
|
+
// even when it narrows to a single file. The old single-match shortcut
|
|
43
|
+
// silently auto-resolved AND auto-submitted, so the user never saw which
|
|
44
|
+
// file was attached and couldn't keep typing (e.g. add `--scope`). Showing
|
|
45
|
+
// the palette + inserting-on-pick (App handles that) is the correct flow.
|
|
45
46
|
return { kind: 'open-at', prefix: atPrefix, line: trimmed };
|
|
46
47
|
}
|
|
47
48
|
// Empty workspace: nothing to pick — let the downstream @-resolution
|
|
48
49
|
// produce its normal "no file matching" message.
|
|
49
50
|
return emptyBodyGuard(trimmed);
|
|
50
51
|
}
|
|
51
|
-
/**
|
|
52
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Substitute the picked command head into the pending line and continue.
|
|
54
|
+
*
|
|
55
|
+
* `fillIn` (UX Wave 1, Task 5 — CC v2.1.19x behavior): true when the palette
|
|
56
|
+
* was opened by the LIVE bare-`/` trigger (original line empty). A pick then
|
|
57
|
+
* PREFILLS `/{command} ` into the Footer — never submits, not even for
|
|
58
|
+
* builtins that take no body — so the user can add arguments before Enter.
|
|
59
|
+
* The typed-and-submitted flow (fillIn=false, the default) keeps today's
|
|
60
|
+
* semantics: re-run the pipeline, which submits when the line is complete.
|
|
61
|
+
*/
|
|
62
|
+
export function applySlashPick(pickedName, rest, files, fillIn = false) {
|
|
53
63
|
const name = pickedName.startsWith('/') ? pickedName : `/${pickedName}`;
|
|
64
|
+
if (fillIn) {
|
|
65
|
+
return {
|
|
66
|
+
kind: 'prefill',
|
|
67
|
+
text: `${name} `,
|
|
68
|
+
hint: `↪ ${name} — add arguments or press Enter to run`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
54
71
|
return nextResolutionStep(`${name}${rest}`, files);
|
|
55
72
|
}
|
|
56
73
|
/** Substitute the picked filename into the rightmost @-token and continue. */
|
|
@@ -68,6 +85,16 @@ function emptyBodyGuard(line) {
|
|
|
68
85
|
const splitIdx = line.search(/\s/);
|
|
69
86
|
const head = splitIdx === -1 ? line : line.slice(0, splitIdx);
|
|
70
87
|
const body = splitIdx === -1 ? '' : line.slice(splitIdx).trim();
|
|
88
|
+
// Retired-but-redirected name with no body: prefill the NEW name so the
|
|
89
|
+
// user both learns the rename and doesn't dispatch an empty prompt.
|
|
90
|
+
const aliasTarget = SKILL_ALIASES[head.toLowerCase().slice(1)];
|
|
91
|
+
if (aliasTarget !== undefined && body.length === 0) {
|
|
92
|
+
return {
|
|
93
|
+
kind: 'prefill',
|
|
94
|
+
text: `/${aliasTarget} `,
|
|
95
|
+
hint: `↪ ${head.toLowerCase()} is now /${aliasTarget} — type your prompt and press Enter`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
71
98
|
const isSkill = SKILL_CATALOG.some((s) => `/${s.name}` === head.toLowerCase());
|
|
72
99
|
if (isSkill && body.length === 0) {
|
|
73
100
|
return {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
/**
|
|
3
|
+
* Open-request channel for the Rewind panel (UX Wave 3 / Task 3). The Esc-Esc
|
|
4
|
+
* chord opens the panel from inside <App>'s own useInput; the `/rewind` builtin
|
|
5
|
+
* lives in repl.tsx (a separate React-less dispatch), so it signals the mounted
|
|
6
|
+
* <App> through this singleton emitter — the same producer→consumer pattern as
|
|
7
|
+
* todoEmitter / approvalEmitter. In classic mode <App> is never mounted, this
|
|
8
|
+
* emitter has no listeners, and `/rewind` takes the inquirer path instead.
|
|
9
|
+
*/
|
|
10
|
+
export const rewindEmitter = new EventEmitter();
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { hhmm, rewindRowLabel, packageUnknown } from '../rewind/format.js';
|
|
5
|
+
/**
|
|
6
|
+
* RewindPanel — UX Wave 3 / Task 3. The Esc-Esc (and /rewind) overlay that
|
|
7
|
+
* turns "undo for your SAP system" into a picker. House style follows
|
|
8
|
+
* SessionTimeline / TodoPanel / CommandPalette: a bordered box mounted above
|
|
9
|
+
* the Footer by <App>, closes on Esc / q, every row wrap="truncate-end"
|
|
10
|
+
* (Wave-1 rule — no dynamic-frame row may soft-wrap), and lists longer than
|
|
11
|
+
* REWIND_VISIBLE_ROWS scroll in a window (CommandPalette pattern).
|
|
12
|
+
*
|
|
13
|
+
* Row: `▸ ZMNT_NOTE (TABL) · set_source · 14:32 · snapshot 12:01`
|
|
14
|
+
* - restorable → bold, cyan when the cursor is on it.
|
|
15
|
+
* - restorable delete with an unknown recreate package → a dim
|
|
16
|
+
* `(package unknown — restore may fail)` tail (rider c).
|
|
17
|
+
* - non-restorable → dim, with the candidate's reason.
|
|
18
|
+
*
|
|
19
|
+
* Enter on a restorable row swaps the footer hint for an inline confirm line
|
|
20
|
+
* (`restore … to snapshot 12:01? runs the write gates — [y]/[n]`). `y` fires
|
|
21
|
+
* onRestore(candidate) and closes; `n` / Esc returns to the list. The panel
|
|
22
|
+
* NEVER runs the write itself — onRestore bridges to the gated executor.
|
|
23
|
+
*
|
|
24
|
+
* Empty state: a single dim `no restorable writes this session` line that
|
|
25
|
+
* closes on any key.
|
|
26
|
+
*/
|
|
27
|
+
export const REWIND_VISIBLE_ROWS = 10;
|
|
28
|
+
export function RewindPanel({ candidates, onRestore, onClose }) {
|
|
29
|
+
const [cursor, setCursor] = useState(0);
|
|
30
|
+
const [confirming, setConfirming] = useState(null);
|
|
31
|
+
const empty = candidates.length === 0;
|
|
32
|
+
useInput((input, key) => {
|
|
33
|
+
// Empty state: any key dismisses.
|
|
34
|
+
if (empty) {
|
|
35
|
+
onClose();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (confirming) {
|
|
39
|
+
if (input === 'y' || input === 'Y') {
|
|
40
|
+
onRestore(confirming);
|
|
41
|
+
onClose();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
// n / N / Esc / q — back to the list, nothing written.
|
|
45
|
+
setConfirming(null);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (key.escape || input === 'q') {
|
|
49
|
+
onClose();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (key.upArrow) {
|
|
53
|
+
setCursor((cu) => Math.max(0, cu - 1));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (key.downArrow) {
|
|
57
|
+
setCursor((cu) => Math.min(candidates.length - 1, cu + 1));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (key.return) {
|
|
61
|
+
const c = candidates[cursor];
|
|
62
|
+
if (c && c.restorable)
|
|
63
|
+
setConfirming(c); // no-op on a dimmed non-restorable row
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
const maxOffset = Math.max(0, candidates.length - REWIND_VISIBLE_ROWS);
|
|
68
|
+
const windowStart = Math.max(0, Math.min(cursor - 3, maxOffset));
|
|
69
|
+
const visible = candidates.slice(windowStart, windowStart + REWIND_VISIBLE_ROWS);
|
|
70
|
+
const below = Math.max(0, candidates.length - (windowStart + REWIND_VISIBLE_ROWS));
|
|
71
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 2, paddingY: 1, width: 72, children: [_jsx(Text, { bold: true, color: "cyan", wrap: "truncate-end", children: "Rewind \u2014 undo a write this session \u00B7 times UTC" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [empty && _jsx(Text, { dimColor: true, children: "no restorable writes this session" }), !empty && windowStart > 0 && _jsxs(Text, { dimColor: true, children: [" \u2191 ", windowStart, " more"] }), !empty && visible.map((c, i) => {
|
|
72
|
+
const idx = windowStart + i;
|
|
73
|
+
const sel = idx === cursor;
|
|
74
|
+
const glyph = sel ? '▸ ' : ' ';
|
|
75
|
+
return (_jsxs(React.Fragment, { children: [c.restorable
|
|
76
|
+
? _jsxs(Text, { bold: true, color: sel ? 'cyan' : undefined, wrap: "truncate-end", children: [glyph, rewindRowLabel(c)] })
|
|
77
|
+
: _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [glyph, rewindRowLabel(c)] }), packageUnknown(c) && (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: " package unknown \u2014 restore may fail" }))] }, idx));
|
|
78
|
+
}), !empty && below > 0 && _jsxs(Text, { dimColor: true, children: [" \u2193 ", below, " more"] })] }), _jsx(Box, { marginTop: 1, children: confirming ? (_jsxs(Text, { color: "yellow", wrap: "truncate-end", children: ["restore ", confirming.object, " to snapshot ", hhmm(confirming.snapshotAt), "? runs write gates \u2014 [y]/[n]"] })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: empty
|
|
79
|
+
? 'any key to close'
|
|
80
|
+
: `${candidates.length > REWIND_VISIBLE_ROWS ? '↑↓ scroll · ' : ''}Enter restore · Esc/q close` })) })] }));
|
|
81
|
+
}
|
|
@@ -14,6 +14,7 @@ import React, { createContext, useContext } from 'react';
|
|
|
14
14
|
const DEFAULT_STATE = {
|
|
15
15
|
system: '', user: '', transport: null, locksHeld: [],
|
|
16
16
|
tokenUsageToday: 0, dollarSpendToday: 0, dollarCap: 100, recentTurns: [],
|
|
17
|
+
writeMode: null,
|
|
17
18
|
};
|
|
18
19
|
/**
|
|
19
20
|
* Rate limiter: calls `fn` at most once every `minIntervalMs`. Trailing-only:
|
|
@@ -6,6 +6,7 @@ const MUTATING_TOOLS = new Set([
|
|
|
6
6
|
'sap_set_source', 'sap_update_method', 'sap_delete_object', 'sap_activate',
|
|
7
7
|
'sap_create_object', 'sap_transport_release', 'sap_message_maintain',
|
|
8
8
|
'sap_number_range_intervals', 'sap_service_binding_publish',
|
|
9
|
+
'sap_service_binding_unpublish',
|
|
9
10
|
]);
|
|
10
11
|
export function toTimelineEntry(call) {
|
|
11
12
|
// args shape depends on the tool; extract defensively.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from 'ink';
|
|
3
|
+
import { useSapState } from './sap-state-store.js';
|
|
4
|
+
/**
|
|
5
|
+
* Persistent SAP statusline (UX Wave 1, Task 4) — Header reborn,
|
|
6
|
+
* bottom-anchored. Single borderless line rendered between <TurnStatus />
|
|
7
|
+
* and <Footer /> in the dynamic Ink frame:
|
|
8
|
+
*
|
|
9
|
+
* S4H │ S.LAEEQ │ tr K903417 │ approval-gated │ $0.54
|
|
10
|
+
*
|
|
11
|
+
* Descends from ui/header.tsx (Phase D3) minus the border (3 rows → 1) and
|
|
12
|
+
* the version/🍑 (the startup banner owns those). All fields come from the
|
|
13
|
+
* SapStateStore singleton, so transport creation, spend accrual, and
|
|
14
|
+
* write-mode cycling (Shift+Tab / Alt+M / /mode) re-render it live.
|
|
15
|
+
*
|
|
16
|
+
* The mode chip is omitted until the store's writeMode is seeded at REPL
|
|
17
|
+
* startup (null = pre-seed; only reachable in tests and the first ms of
|
|
18
|
+
* mount). Chip colors: advisory-only yellow / approval-gated green / auto
|
|
19
|
+
* cyan.
|
|
20
|
+
*/
|
|
21
|
+
const MODE_COLORS = {
|
|
22
|
+
'advisory-only': 'yellow',
|
|
23
|
+
'approval-gated': 'green',
|
|
24
|
+
auto: 'cyan',
|
|
25
|
+
};
|
|
26
|
+
export function StatusLine() {
|
|
27
|
+
const s = useSapState();
|
|
28
|
+
const spendDisplay = `$${s.dollarSpendToday.toFixed(2)}`;
|
|
29
|
+
// Wave1 live-fix (Bug 1) — one TRUNCATING <Text> instead of a <Box> of
|
|
30
|
+
// sibling Texts. The statusline lives in the dynamic Ink frame, where a
|
|
31
|
+
// row that soft-wraps in the terminal breaks log-update's logical-line
|
|
32
|
+
// erase accounting and leaks stale rows into scrollback (the live
|
|
33
|
+
// "statusline printed 11×" bug — see text-input.tsx maxDisplayWidth doc
|
|
34
|
+
// for the ink source refs). Nested <Text> children squash into one text
|
|
35
|
+
// node, so wrap="truncate-end" clips the whole row at the terminal
|
|
36
|
+
// width via ANSI-aware cli-truncate — this row can never wrap, whatever
|
|
37
|
+
// the field lengths.
|
|
38
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [s.system
|
|
39
|
+
? _jsx(Text, { color: "cyan", children: s.system })
|
|
40
|
+
: _jsx(Text, { dimColor: true, children: "no system" }), _jsx(Text, { dimColor: true, children: " \u2502 " }), _jsx(Text, { color: "white", children: s.user || '—' }), _jsx(Text, { dimColor: true, children: " \u2502 tr " }), s.transport
|
|
41
|
+
? _jsx(Text, { color: "yellow", children: s.transport })
|
|
42
|
+
: _jsx(Text, { dimColor: true, children: "none" }), s.writeMode && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: " \u2502 " }), _jsx(Text, { color: MODE_COLORS[s.writeMode], children: s.writeMode })] })), _jsx(Text, { dimColor: true, children: " \u2502 " }), _jsx(Text, { color: "green", children: spendDisplay })] }));
|
|
43
|
+
}
|