@cspeach/cli 1.0.0 → 1.1.1
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/dist/agent/loop.js +22 -9
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +189 -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/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-resume.js +588 -33
- package/dist/config/loader.js +128 -4
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- package/dist/doctor/checks/system-roles.js +41 -0
- 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 +25 -3
- package/dist/projects/extract-cca.js +3 -1
- package/dist/projects/extract-modernize.js +3 -1
- package/dist/projects/extract-plan.js +60 -6
- package/dist/projects/extract-test-coverage.js +3 -1
- package/dist/projects/extract-upgrade.js +3 -1
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +1 -1
- package/dist/projects/plan-run.js +137 -13
- package/dist/projects/plan-schema.js +73 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +26 -15
- 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 +37 -0
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +5 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/post-turn-status.js +24 -4
- package/dist/repl/slash-completer.js +5 -0
- package/dist/repl.js +954 -83
- 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/session/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +9 -3
- package/dist/skills/bundled-skills.js +71 -78
- package/dist/tools/approval.js +115 -7
- package/dist/tools/ask-question.js +304 -3
- package/dist/tools/extend-model/anchored-insert.js +604 -0
- package/dist/tools/extend-model/tool.js +162 -10
- package/dist/tools/fiori/fe-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +29 -3
- package/dist/tools/fiori/floorplan-map.js +19 -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/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 +328 -3
- package/dist/tools/local-build.js +11 -1
- package/dist/tools/sap-read.js +79 -11
- package/dist/tools/sap-write.js +24 -4
- package/dist/tools/snapshot.js +27 -1
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/todo.js +144 -0
- package/dist/ui/app.js +372 -19
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +109 -30
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +18 -2
- 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/status-line.js +43 -0
- package/dist/ui/text-input.js +72 -8
- 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 +17 -7
- package/package.json +4 -1
package/dist/ui/app.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
3
3
|
import { Box, useInput } from 'ink';
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import { Body } from './body.js';
|
|
@@ -9,10 +9,15 @@ import { CommandPalette } from './command-palette.js';
|
|
|
9
9
|
import { FilePalette } from './file-palette.js';
|
|
10
10
|
import { nextResolutionStep, applySlashPick } from './line-resolution.js';
|
|
11
11
|
import { TurnStatus } from './turn-status.js';
|
|
12
|
+
import { StatusLine } from './status-line.js';
|
|
12
13
|
import { turnStatusEmitter } from './turn-status-emitter.js';
|
|
13
14
|
import { listWorkspaceFileChoices } from '../repl/at-picker.js';
|
|
14
15
|
import { RichErrorBox } from './rich-error-box.js';
|
|
15
16
|
import { SessionTimeline, toTimelineEntry } from './session-timeline.js';
|
|
17
|
+
import { TodoPanel } from './todo-panel.js';
|
|
18
|
+
import { RewindPanel } from './rewind-panel.js';
|
|
19
|
+
import { rewindEmitter } from './rewind-emitter.js';
|
|
20
|
+
import { todoEmitter } from './todo-emitter.js';
|
|
16
21
|
import { approvalEmitter } from './approval-emitter.js';
|
|
17
22
|
import { errorEmitter } from './error-emitter.js';
|
|
18
23
|
import { SapStateContext, globalStore } from './sap-state-store.js';
|
|
@@ -20,25 +25,78 @@ import { coachingPickerEmitter } from './coaching-picker-emitter.js';
|
|
|
20
25
|
import { CoachingPicker } from './widgets/coaching-picker.js';
|
|
21
26
|
import { askQuestionEmitter } from './ask-question-emitter.js';
|
|
22
27
|
import { AskQuestionModal } from './widgets/ask-question-modal.js';
|
|
28
|
+
import { AskForm } from './widgets/ask-form.js';
|
|
29
|
+
import { runModeCommand } from '../repl/mode-cycle.js';
|
|
23
30
|
import { enqueueSteering } from '../agent/steering-queue.js';
|
|
31
|
+
/**
|
|
32
|
+
* Wave1 fast-follow — steering-echo truncation. The `↳ queued ▸` line
|
|
33
|
+
* used to echo the ENTIRE queued text; a queued multiline paste dumped a
|
|
34
|
+
* whole banner into scrollback (observed live). Show only the first line,
|
|
35
|
+
* clipped to STEERING_ECHO_MAX chars, with `… (+N lines)` when more lines
|
|
36
|
+
* were queued (`…` alone when just the first line was clipped). Exported
|
|
37
|
+
* for the format-pinning test.
|
|
38
|
+
*/
|
|
39
|
+
export const STEERING_ECHO_MAX = 60;
|
|
40
|
+
export function formatSteeringEcho(text) {
|
|
41
|
+
const lines = text.split('\n');
|
|
42
|
+
const first = lines[0] ?? '';
|
|
43
|
+
const extra = lines.length - 1;
|
|
44
|
+
const clipped = first.length > STEERING_ECHO_MAX;
|
|
45
|
+
let out = clipped ? first.slice(0, STEERING_ECHO_MAX) : first;
|
|
46
|
+
if (clipped || extra > 0)
|
|
47
|
+
out += '…';
|
|
48
|
+
if (extra > 0)
|
|
49
|
+
out += ` (+${extra} line${extra === 1 ? '' : 's'})`;
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
24
52
|
/**
|
|
25
53
|
* App — inline-mode Ink frame (Phase D6, 2026-05-17). After the
|
|
26
54
|
* alt-screen detour, reverted to CC's architecture:
|
|
27
55
|
*
|
|
28
|
-
*
|
|
56
|
+
* (startup banner) ← printed once by repl.tsx, becomes scrollback
|
|
29
57
|
* <Body chunkEmitter=… /> ← <Static> commits each node, becomes scrollback
|
|
30
58
|
* {coachingPending && …} ← inline overlays that mount above input
|
|
31
59
|
* {askQuestionPending && …}
|
|
60
|
+
* <TurnStatus /> ← dynamic, live turn progress
|
|
61
|
+
* <StatusLine /> ← dynamic, persistent SAP statusline (Task 4)
|
|
32
62
|
* <Footer /> ← dynamic, re-renders at cursor (sticky-ish)
|
|
33
63
|
*
|
|
34
64
|
* No alt-screen, no termHeight, no PgUp scroll state. The terminal's
|
|
35
65
|
* native scrollback handles all of it — mouse wheel, PgUp, search,
|
|
36
66
|
* everything just works because we render inline like a normal CLI.
|
|
37
67
|
*/
|
|
38
|
-
export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger, getToolCalls,
|
|
68
|
+
export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger, getToolCalls, getTodos, getRewindCandidates, onRewindRestore, sapRole, sapAlias, onInterrupt }) {
|
|
39
69
|
const [pendingApproval, setPendingApproval] = useState(null);
|
|
70
|
+
// Task 3 review I1(b): synchronous mirror + monotonic remount key — the same
|
|
71
|
+
// I2 treatment ask-question already has. A second approval request arriving
|
|
72
|
+
// before React re-renders must still see (and resolve) the first one, or the
|
|
73
|
+
// first producer's promise leaks and its await hangs forever (traced worst
|
|
74
|
+
// case: a turn's write approval arriving while a rewind restore's approval
|
|
75
|
+
// pends → wedged restore / permanently busy session).
|
|
76
|
+
const pendingApprovalRef = useRef(null);
|
|
77
|
+
const [approvalSeq, setApprovalSeq] = useState(0);
|
|
40
78
|
const [coachingPending, setCoachingPending] = useState(null);
|
|
79
|
+
// Task 3 review I1(c): synchronous mirror for the coaching overlay — the
|
|
80
|
+
// rewind open path re-checks refs after its async candidate load (state can
|
|
81
|
+
// lag an arrival that happened during the await).
|
|
82
|
+
const coachingPendingRef = useRef(null);
|
|
83
|
+
// UX Wave 2 / Task 2 — discriminated union: v1 single-question requests
|
|
84
|
+
// render the untouched <AskQuestionModal>; v2 form requests ('questions'
|
|
85
|
+
// in req) render <AskForm>. All the input gates below only check
|
|
86
|
+
// truthiness, so both shapes suppress the global handlers identically.
|
|
41
87
|
const [askQuestionPending, setAskQuestionPending] = useState(null);
|
|
88
|
+
// Review fix (I2): synchronous mirror of the pending request so a second
|
|
89
|
+
// request arriving before React re-renders can still see (and resolve)
|
|
90
|
+
// the first one — otherwise the first tool call's promise leaks and
|
|
91
|
+
// hangs forever.
|
|
92
|
+
const askQuestionPendingRef = useRef(null);
|
|
93
|
+
// Review fix (I2, + T2 rider): monotonic counter keying <AskForm> AND
|
|
94
|
+
// <AskQuestionModal> so a replacing request REMOUNTS the widget instead
|
|
95
|
+
// of React reusing the instance with stale state (v1→v1 overlap had the
|
|
96
|
+
// same stale-state exposure as v2→v2). Deliberately NOT the formId /
|
|
97
|
+
// question id (collision caveat — two requests may legitimately share
|
|
98
|
+
// one).
|
|
99
|
+
const [askQuestionSeq, setAskQuestionSeq] = useState(0);
|
|
42
100
|
// 2026-06-06 (Ink picker parity) — pre-dispatch line resolution. A
|
|
43
101
|
// submitted line flows through nextResolutionStep (line-resolution.ts);
|
|
44
102
|
// open-slash / open-at steps mount the matching palette seeded with the
|
|
@@ -48,9 +106,55 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
|
|
|
48
106
|
const [prefill, setPrefill] = useState({ text: '', nonce: 0 });
|
|
49
107
|
const [errorStack, setErrorStack] = useState([]);
|
|
50
108
|
const [showTimeline, setShowTimeline] = useState(false);
|
|
109
|
+
// Task 5 (ux-wave2) — Ctrl+T todo panel + the todoEmitter-fed list.
|
|
110
|
+
// Seeded from getTodos() so a RESUMED session shows its persisted list
|
|
111
|
+
// before any todo_set fires in this process.
|
|
112
|
+
const [showTodoPanel, setShowTodoPanel] = useState(false);
|
|
113
|
+
const [todos, setTodos] = useState(() => getTodos?.() ?? []);
|
|
114
|
+
// Task 3 (ux-wave3) — the Esc-Esc / /rewind panel + its lazily-loaded
|
|
115
|
+
// candidate list. Candidates come from an ASYNC accessor (snapshots.list
|
|
116
|
+
// reads fs), so the open handler loads them BEFORE flipping the panel on —
|
|
117
|
+
// the panel always renders against populated data, never a loading flash.
|
|
118
|
+
const [showRewindPanel, setShowRewindPanel] = useState(false);
|
|
119
|
+
const [rewindCandidates, setRewindCandidates] = useState([]);
|
|
120
|
+
// Two-Esc chord detector: timestamp of the last lone Esc seen at idle.
|
|
121
|
+
const lastEscRef = useRef(0);
|
|
51
122
|
const [footerValue, setFooterValue] = useState('');
|
|
123
|
+
// Task 7 (ux-wave1) — continuation-buffer depth mirrored up from the
|
|
124
|
+
// Footer. While a multiline draft is pending the input line is EMPTY
|
|
125
|
+
// between continuation lines, so every footerValue === '' live trigger
|
|
126
|
+
// (`/`, `@`, `?`, Shift+Tab/Alt+M) would fire mid-composition; a
|
|
127
|
+
// palette pick that prefills would orphan the buffered lines. Pinned:
|
|
128
|
+
// all live triggers stay dormant while the buffer is non-empty.
|
|
129
|
+
const [footerBufferCount, setFooterBufferCount] = useState(0);
|
|
130
|
+
// Wave-2 review I-1: model-driven overlays (approval / ask-question /
|
|
131
|
+
// coaching) must own input EXCLUSIVELY. The todo panel opens MID-TURN by
|
|
132
|
+
// design, so a request can arrive while it (or the ? timeline) is up —
|
|
133
|
+
// leaving BOTH useInputs active. Ink delivers every keypress to every
|
|
134
|
+
// active subscriber (no stopPropagation), so one Esc would close the
|
|
135
|
+
// panel AND fire the approval modal's onCancel — silently declining a
|
|
136
|
+
// pending write; `q` typed into a free-text ask-form answer would close
|
|
137
|
+
// the panel behind it; and panel + modal stacked can overflow a 24-row
|
|
138
|
+
// terminal. Fix at the arrival site: evict the passive panels BEFORE
|
|
139
|
+
// mounting the modal. (The user can Ctrl+T / ? again after answering.)
|
|
52
140
|
useEffect(() => {
|
|
53
|
-
const onReq = (req) =>
|
|
141
|
+
const onReq = (req) => {
|
|
142
|
+
// I1(b) — a replacing request must not leak the previous producer's
|
|
143
|
+
// promise (renderApprovalModalAndAwait or the rewind restore bridge
|
|
144
|
+
// would await forever). Resolve the old one as declined first — the
|
|
145
|
+
// decline shape both producers already handle.
|
|
146
|
+
const prev = pendingApprovalRef.current;
|
|
147
|
+
if (prev) {
|
|
148
|
+
prev.resolve({ approved: false, reason: 'superseded by a newer approval request' });
|
|
149
|
+
}
|
|
150
|
+
pendingApprovalRef.current = req;
|
|
151
|
+
setApprovalSeq((s) => s + 1);
|
|
152
|
+
setShowTodoPanel(false); // I-1 — evict passive panels, see block comment
|
|
153
|
+
setShowTimeline(false);
|
|
154
|
+
setShowRewindPanel(false); // Task 3 — rewind panel joins the eviction family
|
|
155
|
+
lastEscRef.current = 0; // M3 — a mounting overlay kills any armed Esc chord
|
|
156
|
+
setPendingApproval(req);
|
|
157
|
+
};
|
|
54
158
|
approvalEmitter.on('request', onReq);
|
|
55
159
|
return () => {
|
|
56
160
|
approvalEmitter.off('request', onReq);
|
|
@@ -58,6 +162,11 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
|
|
|
58
162
|
}, []);
|
|
59
163
|
useEffect(() => {
|
|
60
164
|
const onRequest = (req) => {
|
|
165
|
+
coachingPendingRef.current = req; // I1(c) — synchronous mirror
|
|
166
|
+
setShowTodoPanel(false); // I-1 — same eviction as approval/ask-question
|
|
167
|
+
setShowTimeline(false);
|
|
168
|
+
setShowRewindPanel(false); // Task 3 — rewind panel joins the eviction family
|
|
169
|
+
lastEscRef.current = 0; // M3 — a mounting overlay kills any armed Esc chord
|
|
61
170
|
setCoachingPending(req);
|
|
62
171
|
};
|
|
63
172
|
coachingPickerEmitter.on('request', onRequest);
|
|
@@ -67,11 +176,41 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
|
|
|
67
176
|
}, []);
|
|
68
177
|
useEffect(() => {
|
|
69
178
|
const onRequest = (req) => {
|
|
179
|
+
// Review fix (I2): a second request while one is pending must not
|
|
180
|
+
// leak the first promise (the earlier tool call would hang forever).
|
|
181
|
+
// Resolve it cancelled first, in the correct shape for its kind.
|
|
182
|
+
const prev = askQuestionPendingRef.current;
|
|
183
|
+
if (prev) {
|
|
184
|
+
if ('questions' in prev) {
|
|
185
|
+
prev.resolve({ formId: prev.formId, answers: [], cancelled: true });
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
prev.resolve({ answer: null, cancelled: true });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
askQuestionPendingRef.current = req;
|
|
192
|
+
setAskQuestionSeq((s) => s + 1);
|
|
193
|
+
setShowTodoPanel(false); // I-1 — evict passive panels, see approval block
|
|
194
|
+
setShowTimeline(false);
|
|
195
|
+
setShowRewindPanel(false); // Task 3 — rewind panel joins the eviction family
|
|
196
|
+
lastEscRef.current = 0; // M3 — a mounting overlay kills any armed Esc chord
|
|
70
197
|
setAskQuestionPending(req);
|
|
71
198
|
};
|
|
72
199
|
askQuestionEmitter.on('request', onRequest);
|
|
73
200
|
return () => { askQuestionEmitter.off('request', onRequest); };
|
|
74
201
|
}, []);
|
|
202
|
+
// Task 5 (ux-wave2) — todo_set fan-out → panel/strip state. Re-seed from
|
|
203
|
+
// getTodos AFTER subscribing (TurnStatus snapshot pattern): an update
|
|
204
|
+
// fired between mount and this effect would otherwise be missed.
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
const onUpdate = (t) => setTodos(t);
|
|
207
|
+
todoEmitter.on('update', onUpdate);
|
|
208
|
+
setTodos(getTodos?.() ?? []);
|
|
209
|
+
return () => { todoEmitter.off('update', onUpdate); };
|
|
210
|
+
// getTodos is stable for the life of the mount (repl.tsx passes a
|
|
211
|
+
// closure over the session object) — mount-once is intentional.
|
|
212
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
213
|
+
}, []);
|
|
75
214
|
useEffect(() => {
|
|
76
215
|
const onError = (err) => {
|
|
77
216
|
const id = crypto.randomUUID();
|
|
@@ -83,6 +222,31 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
|
|
|
83
222
|
errorEmitter.on('error', onError);
|
|
84
223
|
return () => { errorEmitter.off('error', onError); };
|
|
85
224
|
}, []);
|
|
225
|
+
// Task 3 (ux-wave3) — load candidates (async fs read) THEN show the panel, so
|
|
226
|
+
// it never renders against a stale/empty list. Shared by the Esc-Esc chord and
|
|
227
|
+
// the /rewind builtin (which signals through rewindEmitter — <App> is a
|
|
228
|
+
// separate tree from repl.tsx's dispatch). An empty list is a valid open: the
|
|
229
|
+
// panel shows its "no restorable writes this session" state.
|
|
230
|
+
const openRewind = useCallback(() => {
|
|
231
|
+
void (async () => {
|
|
232
|
+
const cands = getRewindCandidates ? await getRewindCandidates() : [];
|
|
233
|
+
// I1(c) — re-check AFTER the async load: a model-driven overlay request
|
|
234
|
+
// or a turn start may have arrived during the await, and mounting the
|
|
235
|
+
// panel now would stack it over (and key-fight) the modal. Refs, not
|
|
236
|
+
// state — state can lag an arrival inside the await window.
|
|
237
|
+
if (turnStatusEmitter.isBusy())
|
|
238
|
+
return;
|
|
239
|
+
if (pendingApprovalRef.current || coachingPendingRef.current || askQuestionPendingRef.current)
|
|
240
|
+
return;
|
|
241
|
+
setRewindCandidates(cands);
|
|
242
|
+
setShowRewindPanel(true);
|
|
243
|
+
})();
|
|
244
|
+
}, [getRewindCandidates]);
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
const onOpen = () => openRewind();
|
|
247
|
+
rewindEmitter.on('open', onOpen);
|
|
248
|
+
return () => { rewindEmitter.off('open', onOpen); };
|
|
249
|
+
}, [openRewind]);
|
|
86
250
|
// Run one step of the pre-dispatch pipeline and act on the outcome.
|
|
87
251
|
const handleStep = useCallback((step, original, files) => {
|
|
88
252
|
switch (step.kind) {
|
|
@@ -109,7 +273,12 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
|
|
|
109
273
|
// pick would have dispatched a SECOND concurrent turn into the same
|
|
110
274
|
// session. Keep the typed text in the input and tell the user; proper
|
|
111
275
|
// CC-style input queueing is a follow-up (docs/backlog.md).
|
|
112
|
-
|
|
276
|
+
// BUG-2b (2026-07-05) — isBusy() (active OR reserved), not just active.
|
|
277
|
+
// A turn's async pre-turn prep (classifier / plan-resume / guarded
|
|
278
|
+
// inquirer gate) runs BEFORE turnStatusEmitter.start() marks it active;
|
|
279
|
+
// reading .active alone left that window unguarded, so a second Enter
|
|
280
|
+
// dispatched a concurrent twin turn. reserve() covers the whole window.
|
|
281
|
+
if (turnStatusEmitter.isBusy()) {
|
|
113
282
|
// CC-style mid-turn steering (2026-06-08): the user typed a correction
|
|
114
283
|
// while the model works. Queue it — the turn loop injects it as a user
|
|
115
284
|
// message at the next round boundary — clear the box, and confirm it
|
|
@@ -117,7 +286,9 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
|
|
|
117
286
|
const t = line.trim();
|
|
118
287
|
if (t.length > 0) {
|
|
119
288
|
enqueueSteering(t);
|
|
120
|
-
|
|
289
|
+
// Fast-follow: echo a truncated receipt, not the full (possibly
|
|
290
|
+
// multiline-pasted) text — the model still gets `t` verbatim.
|
|
291
|
+
chunkEmitter.emit('chunk', `\x1b[36m↳ queued ▸ ${formatSteeringEcho(t)}\x1b[39m\n`);
|
|
121
292
|
}
|
|
122
293
|
setPrefill((p) => ({ text: '', nonce: p.nonce + 1 }));
|
|
123
294
|
return;
|
|
@@ -135,16 +306,174 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
|
|
|
135
306
|
}, [lineResolution]);
|
|
136
307
|
// `?` key opens the timeline when the footer is empty and no other overlay
|
|
137
308
|
// is active.
|
|
309
|
+
//
|
|
310
|
+
// Keystroke-leak note (Task 5, verified by probe): Ink delivers every
|
|
311
|
+
// keypress to EVERY active useInput subscriber — there is no
|
|
312
|
+
// stopPropagation — so a live-trigger character ALSO lands in the Footer's
|
|
313
|
+
// TextInput. Before Task 5 the `?` stayed hidden behind the overlay's
|
|
314
|
+
// disabled placeholder and reappeared in the input after Esc. The working
|
|
315
|
+
// mechanism is an explicit swallow: bump `prefill` with empty text, which
|
|
316
|
+
// the Footer applies on the next commit (footer.tsx honours empty prefills
|
|
317
|
+
// since Task 5). Every live trigger in this handler family does the same.
|
|
138
318
|
useInput((input, _key) => {
|
|
139
|
-
if (
|
|
319
|
+
if (footerBufferCount > 0)
|
|
320
|
+
return; // Task 7 — mid-composition, stay dormant
|
|
321
|
+
if (input === '?' && footerValue === '') {
|
|
140
322
|
setShowTimeline(true);
|
|
141
|
-
|
|
323
|
+
setPrefill((p) => ({ text: '', nonce: p.nonce + 1 })); // swallow leaked '?'
|
|
324
|
+
}
|
|
325
|
+
}, { isActive: !lineResolution && !showTimeline && !showTodoPanel && !showRewindPanel && !pendingApproval && !coachingPending && !askQuestionPending });
|
|
326
|
+
// Task 5 (ux-wave1) — live `/` opens the command palette immediately
|
|
327
|
+
// (CC-style), no Enter needed. Fill-in semantics: because the original
|
|
328
|
+
// line is empty, a pick PREFILLS `/{command} ` instead of running (the
|
|
329
|
+
// palette onSelect derives fillIn from lr.original === ''). Mid-turn the
|
|
330
|
+
// trigger stays dormant — same guard as beginSubmit — so `/` typed while
|
|
331
|
+
// the model works goes into the input as ordinary steering text.
|
|
332
|
+
useInput((input, _key) => {
|
|
333
|
+
if (input !== '/' || footerValue !== '')
|
|
334
|
+
return;
|
|
335
|
+
if (footerBufferCount > 0)
|
|
336
|
+
return; // Task 7 — mid-composition, stay dormant
|
|
337
|
+
if (turnStatusEmitter.isBusy())
|
|
338
|
+
return; // BUG-2b: steering, not palette (covers prep window)
|
|
339
|
+
setPrefill((p) => ({ text: '', nonce: p.nonce + 1 })); // swallow leaked '/'
|
|
340
|
+
handleSlashTrigger();
|
|
341
|
+
}, { isActive: !lineResolution && !showTimeline && !showTodoPanel && !showRewindPanel && !pendingApproval && !coachingPending && !askQuestionPending });
|
|
342
|
+
// Task 6 (ux-wave1) — live `@` opens the file palette with APPEND
|
|
343
|
+
// semantics: the partial line survives and the picked token lands where
|
|
344
|
+
// the user was typing (pick prefills `footerValue + '@' + name + ' '`
|
|
345
|
+
// via the existing at-mode onSelect below). Token-boundary rule: only
|
|
346
|
+
// on an empty footer or after a trailing space — `foo@bar` (emails,
|
|
347
|
+
// handles) keeps typing normally. Mid-turn the trigger stays dormant,
|
|
348
|
+
// same steering rationale as `/`.
|
|
349
|
+
//
|
|
350
|
+
// Cancel contract (reviewer spec-change): `original` INCLUDES the
|
|
351
|
+
// trigger `@`, so Esc restores `footerValue + '@'` — the `@` stays in
|
|
352
|
+
// the buffer as typed text. In this product a literal boundary `@` is
|
|
353
|
+
// CDS annotation vocabulary (`@Semantics.amount.currencyCode`,
|
|
354
|
+
// `@UI.lineItem`), so @→Esc must yield a typeable `@`, not an erase-
|
|
355
|
+
// and-retrigger loop (the restored line ends in '@', not space/empty,
|
|
356
|
+
// so the live trigger cannot re-fire while the user keeps typing).
|
|
357
|
+
//
|
|
358
|
+
// Swallow nuance vs `/`: this trigger also fires MID-LINE, so the
|
|
359
|
+
// leak-swallow prefill must carry the CURRENT footerValue — the Task-5
|
|
360
|
+
// empty-prefill trick would wipe the user's partial line along with
|
|
361
|
+
// the leaked `@`. (Both are one setValue in the Footer: text minus the
|
|
362
|
+
// leaked char. For `/` that text just happens to be empty.)
|
|
363
|
+
useInput((input, _key) => {
|
|
364
|
+
if (input !== '@')
|
|
365
|
+
return;
|
|
366
|
+
if (footerBufferCount > 0)
|
|
367
|
+
return; // Task 7 — mid-composition, stay dormant
|
|
368
|
+
if (footerValue !== '' && !footerValue.endsWith(' '))
|
|
369
|
+
return; // not a token boundary
|
|
370
|
+
if (turnStatusEmitter.isBusy())
|
|
371
|
+
return; // BUG-2b: steering, not palette (covers prep window)
|
|
372
|
+
const files = listWorkspaceFileChoices();
|
|
373
|
+
if (files.length === 0)
|
|
374
|
+
return; // nothing to pick — let `@` land as plain text
|
|
375
|
+
setPrefill((p) => ({ text: footerValue, nonce: p.nonce + 1 })); // swallow leaked '@', keep the line
|
|
376
|
+
setLineResolution({ mode: 'at', prefix: '', line: `${footerValue}@`, original: `${footerValue}@`, files });
|
|
377
|
+
}, { isActive: !lineResolution && !showTimeline && !showTodoPanel && !showRewindPanel && !pendingApproval && !coachingPending && !askQuestionPending });
|
|
378
|
+
// Task 3 (ux-wave1) — Shift+Tab / Alt+M cycle the session write mode.
|
|
379
|
+
// Same gating as the `?` handler: only when the footer is empty and no
|
|
380
|
+
// overlay owns input. Windows Terminal sends backtab as CSI Z, which Ink
|
|
381
|
+
// surfaces as key.tab + key.shift; Alt+M arrives as key.meta + 'm'.
|
|
382
|
+
// The cycle core lives in repl/mode-cycle.ts; the announce/refusal line
|
|
383
|
+
// comes back from runModeCommand and is committed dim to scrollback.
|
|
384
|
+
//
|
|
385
|
+
// Task 4 (ux-wave1) — the cycle base comes from the store, not a prop.
|
|
386
|
+
// repl.tsx seeds writeMode with the startup-EFFECTIVE mode (clamp of
|
|
387
|
+
// cfg.write_mode to the role ceiling) before render(). Byte-compat with
|
|
388
|
+
// the old cfg.write_mode prop: runModeCommand only uses the base as
|
|
389
|
+
// `clamp(sessionOverride ?? base, role)`, and clamp is idempotent —
|
|
390
|
+
// clamp(clamp(cfg, role), role) === clamp(cfg, role); after the first
|
|
391
|
+
// cycle the session override wins and the base is ignored entirely.
|
|
392
|
+
// globalStore.get() is synchronous (no rate-limiter lag — that only
|
|
393
|
+
// delays LISTENER notification, never the state itself).
|
|
394
|
+
useInput((input, key) => {
|
|
395
|
+
if (footerValue !== '')
|
|
396
|
+
return;
|
|
397
|
+
if (footerBufferCount > 0)
|
|
398
|
+
return; // Task 7 — mid-composition, stay dormant
|
|
399
|
+
const shiftTab = key.tab && key.shift;
|
|
400
|
+
const altM = key.meta && (input === 'm' || input === 'M');
|
|
401
|
+
if (!shiftTab && !altM)
|
|
402
|
+
return;
|
|
403
|
+
const { line, mode } = runModeCommand(undefined, globalStore.get().writeMode ?? 'approval-gated', sapRole, sapAlias);
|
|
404
|
+
chunkEmitter.emit('chunk', `\x1b[2m${line}\x1b[22m\n`);
|
|
405
|
+
globalStore.update({ writeMode: mode });
|
|
406
|
+
// Alt+M's 'm' also lands in the TextInput (see the leak note on the
|
|
407
|
+
// `?` handler) — swallow it. Backtab is harmless (TextInput ignores
|
|
408
|
+
// tab), but the swallow is a no-op for it anyway.
|
|
409
|
+
setPrefill((p) => ({ text: '', nonce: p.nonce + 1 }));
|
|
410
|
+
}, { isActive: !lineResolution && !showTimeline && !showTodoPanel && !showRewindPanel && !pendingApproval && !coachingPending && !askQuestionPending });
|
|
411
|
+
// Task 5 (ux-wave2) — Ctrl+T opens the todo panel. Same gate family as
|
|
412
|
+
// the Wave-1 live triggers (no overlay owns input, footer empty, no
|
|
413
|
+
// multiline draft) with ONE deliberate difference: mid-turn is ALLOWED —
|
|
414
|
+
// no turnStatusEmitter guard. Peeking at the model's task list WHILE it
|
|
415
|
+
// works is the point of the panel (the turn-status strip shows only the
|
|
416
|
+
// current item; Ctrl+T shows the whole plan). Closing is the panel's
|
|
417
|
+
// job (Esc/q/Ctrl+T in TodoPanel's useInput) — this handler is inactive
|
|
418
|
+
// while the panel is up, so the chord toggles instead of double-firing.
|
|
419
|
+
// Open re-reads getTodos(): cheap, and correct after a session resume
|
|
420
|
+
// (the emitter only fires on new todo_set calls). The leaked 't' is
|
|
421
|
+
// swallowed with an empty prefill — same mechanism as `?` / Alt+M
|
|
422
|
+
// (footerValue is '' by gate, so the empty prefill is lossless).
|
|
423
|
+
useInput((input, key) => {
|
|
424
|
+
if (!(key.ctrl && (input === 't' || input === 'T')))
|
|
425
|
+
return;
|
|
426
|
+
if (footerValue !== '')
|
|
427
|
+
return;
|
|
428
|
+
if (footerBufferCount > 0)
|
|
429
|
+
return; // Task 7 — mid-composition, stay dormant
|
|
430
|
+
setTodos(getTodos?.() ?? []);
|
|
431
|
+
setShowTodoPanel(true);
|
|
432
|
+
setPrefill((p) => ({ text: '', nonce: p.nonce + 1 })); // swallow leaked 't'
|
|
433
|
+
}, { isActive: !lineResolution && !showTimeline && !showTodoPanel && !showRewindPanel && !pendingApproval && !coachingPending && !askQuestionPending });
|
|
434
|
+
// Task 3 (ux-wave3) — Esc-Esc opens the rewind panel. Two Esc within 500ms
|
|
435
|
+
// at IDLE ONLY. The isActive gate is the full five-gate family (no overlay
|
|
436
|
+
// owns input, footer empty, no multiline draft) so a single Esc keeps its
|
|
437
|
+
// existing meaning everywhere: at idle it does nothing (this handler only
|
|
438
|
+
// ARMS on the first Esc — no side effect); while a multiline draft is
|
|
439
|
+
// buffered the Footer's discard-Esc owns it and this handler is inactive
|
|
440
|
+
// (footerBufferCount > 0 excluded), so the chord can never eat that Esc.
|
|
441
|
+
// Mid-turn is forbidden — a rewind while the model may be mid-write is
|
|
442
|
+
// unsafe — so the turn guard (isBusy covers the pre-turn reserve window too)
|
|
443
|
+
// both blocks opening AND disarms any half-formed chord.
|
|
444
|
+
useInput((_input, key) => {
|
|
445
|
+
if (!key.escape)
|
|
446
|
+
return;
|
|
447
|
+
if (turnStatusEmitter.isBusy()) {
|
|
448
|
+
lastEscRef.current = 0;
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const now = Date.now();
|
|
452
|
+
if (now - lastEscRef.current <= 500) {
|
|
453
|
+
lastEscRef.current = 0;
|
|
454
|
+
openRewind();
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
lastEscRef.current = now;
|
|
458
|
+
}
|
|
459
|
+
}, {
|
|
460
|
+
isActive: !lineResolution && !showTimeline && !showTodoPanel && !showRewindPanel
|
|
461
|
+
&& !pendingApproval && !coachingPending && !askQuestionPending
|
|
462
|
+
&& footerValue === '' && footerBufferCount === 0,
|
|
463
|
+
});
|
|
142
464
|
// Ctrl+C handler — abort in-flight turn or exit at idle.
|
|
143
465
|
useInput((input, key) => {
|
|
144
466
|
if (key.ctrl && input === 'c')
|
|
145
467
|
onInterrupt?.();
|
|
146
468
|
}, { isActive: true });
|
|
147
|
-
|
|
469
|
+
// I1(b) — clear ONLY when this request still owns the slot: a replacing
|
|
470
|
+
// request (resolved-as-superseded in onReq) may already be pending, and the
|
|
471
|
+
// old modal's resolve callback must not evict it.
|
|
472
|
+
const clearApproval = (req) => {
|
|
473
|
+
if (pendingApprovalRef.current === req)
|
|
474
|
+
pendingApprovalRef.current = null;
|
|
475
|
+
setPendingApproval((cur) => (cur === req ? null : cur));
|
|
476
|
+
};
|
|
148
477
|
// Bare `/` + Enter — browse the full catalogue. Same palette as the
|
|
149
478
|
// typed-prefix flow, just unfiltered, with nothing to restore on cancel.
|
|
150
479
|
const handleSlashTrigger = () => {
|
|
@@ -163,11 +492,28 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
|
|
|
163
492
|
: [];
|
|
164
493
|
return (_jsx(SapStateContext.Provider, { value: globalStore, children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Body, { chunkEmitter: chunkEmitter }), coachingPending && (_jsx(CoachingPicker, { params: coachingPending.params, onResolve: (result) => {
|
|
165
494
|
coachingPending.resolve(result);
|
|
495
|
+
if (coachingPendingRef.current === coachingPending) {
|
|
496
|
+
coachingPendingRef.current = null; // I1(c) — keep the mirror true
|
|
497
|
+
}
|
|
166
498
|
setCoachingPending(null);
|
|
167
|
-
} })), askQuestionPending && (_jsx(
|
|
499
|
+
} })), askQuestionPending && ('questions' in askQuestionPending ? (_jsx(AskForm, { request: askQuestionPending, onResolve: (result) => {
|
|
500
|
+
askQuestionPending.resolve(result);
|
|
501
|
+
// Only clear when this request is still the pending one — a
|
|
502
|
+
// replacing request (I2) may already own the slot.
|
|
503
|
+
if (askQuestionPendingRef.current === askQuestionPending) {
|
|
504
|
+
askQuestionPendingRef.current = null;
|
|
505
|
+
setAskQuestionPending(null);
|
|
506
|
+
}
|
|
507
|
+
} }, askQuestionSeq)) : (_jsx(AskQuestionModal, { params: askQuestionPending, onResolve: (result) => {
|
|
168
508
|
askQuestionPending.resolve(result);
|
|
169
|
-
|
|
170
|
-
|
|
509
|
+
if (askQuestionPendingRef.current === askQuestionPending) {
|
|
510
|
+
askQuestionPendingRef.current = null;
|
|
511
|
+
setAskQuestionPending(null);
|
|
512
|
+
}
|
|
513
|
+
} }, askQuestionSeq))), _jsx(TurnStatus, { inProgressTodo: todos.find((t) => t.status === 'in_progress')?.text ?? null }), _jsx(StatusLine, {}), _jsx(Footer, { onSubmit: beginSubmit, onSlashTrigger: handleSlashTrigger, onHistoryTrigger: handleHistoryTrigger, prefill: prefill, onValueChange: setFooterValue, onBufferChange: setFooterBufferCount,
|
|
514
|
+
// Task 7 — multiline announcements (draft discarded) land as
|
|
515
|
+
// dim scrollback lines, house style for Footer-adjacent notices.
|
|
516
|
+
onNotice: (t) => chunkEmitter.emit('chunk', `\x1b[2m${t}\x1b[22m\n`),
|
|
171
517
|
// 2026-05-17 — disable Footer input while any modal owns
|
|
172
518
|
// input, so keystrokes don't double-fire (modal answer +
|
|
173
519
|
// spurious Footer submission with same text).
|
|
@@ -175,10 +521,14 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
|
|
|
175
521
|
|| !!askQuestionPending
|
|
176
522
|
|| !!pendingApproval
|
|
177
523
|
|| !!lineResolution
|
|
178
|
-
|| showTimeline
|
|
524
|
+
|| showTimeline
|
|
525
|
+
|| showTodoPanel
|
|
526
|
+
|| showRewindPanel }), lineResolution?.mode === 'slash' && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(CommandPalette, { initialQuery: lineResolution.prefix, onSelect: (name) => {
|
|
179
527
|
const lr = lineResolution;
|
|
180
528
|
setLineResolution(null);
|
|
181
|
-
|
|
529
|
+
// Task 5 — empty original ⇔ palette opened by the live `/`
|
|
530
|
+
// trigger (or bare `/`+Enter): fill in, don't run.
|
|
531
|
+
handleStep(applySlashPick(name, lr.rest, lr.files, lr.original === ''), lr.original, lr.files);
|
|
182
532
|
}, onCancel: cancelResolution }) })), lineResolution?.mode === 'at' && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(FilePalette, { files: lineResolution.files, initialQuery: lineResolution.prefix, onSelect: (filename) => {
|
|
183
533
|
const lr = lineResolution;
|
|
184
534
|
setLineResolution(null);
|
|
@@ -187,14 +537,17 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
|
|
|
187
537
|
// (e.g. add `--scope ZMM_VENDOR_CHECK` after `--from @<file>`).
|
|
188
538
|
const resolved = lr.line.replace(/@\S*$/, `@${filename}`);
|
|
189
539
|
setPrefill((p) => ({ text: `${resolved} `, nonce: p.nonce + 1 }));
|
|
190
|
-
}, onCancel: cancelResolution }) })), errorStack.map((e) => (_jsx(Box, { children: _jsx(RichErrorBox, { code: e.code, message: e.body, hint: e.hint, severity: "error" }) }, e.id))), showTimeline && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(SessionTimeline, { entries: timelineEntries, onClose: () => setShowTimeline(false) }) })),
|
|
540
|
+
}, onCancel: cancelResolution }) })), errorStack.map((e) => (_jsx(Box, { children: _jsx(RichErrorBox, { code: e.code, message: e.body, hint: e.hint, severity: "error" }) }, e.id))), showTimeline && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(SessionTimeline, { entries: timelineEntries, onClose: () => setShowTimeline(false) }) })), showTodoPanel && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(TodoPanel, { todos: todos, onClose: () => setShowTodoPanel(false) }) })), showRewindPanel && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(RewindPanel, { candidates: rewindCandidates,
|
|
541
|
+
// Panel confirms, then fires the gated executor (repl.tsx assembles
|
|
542
|
+
// deps + bridges approval to the Ink modal) and closes itself.
|
|
543
|
+
onRestore: (c) => { onRewindRestore?.(c); }, onClose: () => setShowRewindPanel(false) }) })), pendingApproval && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(ApprovalModal, { change: pendingApproval.change, effectiveRisk: pendingApproval.effectiveRisk, transport: pendingApproval.transport, onApply: () => {
|
|
191
544
|
pendingApproval.resolve({ approved: true });
|
|
192
|
-
clearApproval();
|
|
545
|
+
clearApproval(pendingApproval);
|
|
193
546
|
}, onSkip: () => {
|
|
194
547
|
pendingApproval.resolve({ approved: false, reason: 'skipped' });
|
|
195
|
-
clearApproval();
|
|
548
|
+
clearApproval(pendingApproval);
|
|
196
549
|
}, onDiff: () => { }, onCancel: () => {
|
|
197
550
|
pendingApproval.resolve({ approved: false, reason: '__cancel_turn__' });
|
|
198
|
-
clearApproval();
|
|
199
|
-
} }) }))] }) }));
|
|
551
|
+
clearApproval(pendingApproval);
|
|
552
|
+
} }, approvalSeq) }))] }) }));
|
|
200
553
|
}
|
|
@@ -1,6 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { useState } from 'react';
|
|
3
3
|
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { verbLine, viewAffordance, toSourceLines } from '../approvals/op-labels.js';
|
|
5
|
+
// BUG-1 (2026-07-05 live smoke): the interactive approval modal mounted
|
|
6
|
+
// DEAD — every keypress ignored — when it appeared after a guarded
|
|
7
|
+
// /abap-plan turn. Root cause is identical to the one AskQuestionModal
|
|
8
|
+
// already solves: the approval plan-gate / run-lease takeover uses
|
|
9
|
+
// `@inquirer/prompts`, which toggles the terminal's raw mode (and pauses
|
|
10
|
+
// stdin) out from under Ink. When it releases, Ink's INTERNAL ref-count
|
|
11
|
+
// still thinks raw mode is on, so it does not re-assert it, and a modal
|
|
12
|
+
// mounted afterwards receives no keypresses. Reclaiming raw stdin on mount
|
|
13
|
+
// (direct process.stdin calls, because Ink's count is the thing that's
|
|
14
|
+
// stale) re-syncs the OS state to what Ink's useInput expects.
|
|
15
|
+
import { useReclaimRawStdin } from './widgets/ask-question-modal.js';
|
|
4
16
|
const LABEL_MAP = {
|
|
5
17
|
delete_op: 'delete op detected',
|
|
6
18
|
prod_namespace: 'PROD namespace',
|
|
@@ -9,19 +21,19 @@ const LABEL_MAP = {
|
|
|
9
21
|
non_prod_override: 'auto-approve policy: never',
|
|
10
22
|
escalated_in_session: '>5 changes this session',
|
|
11
23
|
};
|
|
12
|
-
function opIcon(op) {
|
|
13
|
-
const icons = { create: 'A', modify: 'M', delete: 'D', activate: '>', release: '^' };
|
|
14
|
-
return icons[op] ?? '?';
|
|
15
|
-
}
|
|
16
24
|
function riskColor(level) {
|
|
17
25
|
return level === 'high' ? 'red' : level === 'medium' ? 'yellow' : 'green';
|
|
18
26
|
}
|
|
19
27
|
export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSkip, onDiff, onCancel }) {
|
|
20
|
-
|
|
28
|
+
// BUG-1 — re-assert raw mode on mount so keypresses reach useInput even
|
|
29
|
+
// when an inquirer gate ran earlier in the turn (see import block).
|
|
30
|
+
useReclaimRawStdin();
|
|
31
|
+
const [viewing, setViewing] = useState(false);
|
|
32
|
+
const aff = viewAffordance(change);
|
|
21
33
|
useInput((input, key) => {
|
|
22
|
-
if (
|
|
34
|
+
if (viewing) {
|
|
23
35
|
if (key.escape) {
|
|
24
|
-
|
|
36
|
+
setViewing(false);
|
|
25
37
|
}
|
|
26
38
|
return;
|
|
27
39
|
}
|
|
@@ -33,8 +45,10 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
|
|
|
33
45
|
onSkip();
|
|
34
46
|
return;
|
|
35
47
|
}
|
|
36
|
-
|
|
37
|
-
|
|
48
|
+
// The review key is op-aware ([d] for modify, [v] for create/delete);
|
|
49
|
+
// display-only — it never changes the resolve contract.
|
|
50
|
+
if (aff && input === aff.key) {
|
|
51
|
+
setViewing(true);
|
|
38
52
|
onDiff();
|
|
39
53
|
return;
|
|
40
54
|
}
|
|
@@ -43,11 +57,30 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
|
|
|
43
57
|
return;
|
|
44
58
|
}
|
|
45
59
|
});
|
|
46
|
-
|
|
47
|
-
|
|
60
|
+
// Review sub-view — a diff (modify) or a source dump (create/delete).
|
|
61
|
+
if (viewing && aff) {
|
|
62
|
+
const isDiff = aff.key === 'd';
|
|
63
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { bold: true, children: [isDiff ? 'Diff' : aff.label, " \u2014 Esc to return"] }), change.diff ? (isDiff ? (change.diff.split('\n').map((line, i) => (_jsx(Text, { wrap: "truncate-end", color: line.startsWith('+') ? 'green' : line.startsWith('-') ? 'red' : 'gray', children: line }, i)))) : (toSourceLines(change.diff).map((line, i) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: "gray", children: [String(i + 1).padStart(4), " "] }), line] }, i))))) : (_jsx(Text, { dimColor: true, children: "(no source provided by the model)" }))] }));
|
|
48
64
|
}
|
|
49
|
-
const raisedLabel = effectiveRisk.raisedBy
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
65
|
+
const raisedLabel = effectiveRisk.raisedBy.map((r) => LABEL_MAP[r] ?? r).join(', ');
|
|
66
|
+
// Meta line: Package · Transport — each segment only when genuinely known.
|
|
67
|
+
// The handler resolves `transport` per op (see op-labels displayTransport:
|
|
68
|
+
// create-family may fall back to the session transport because the create
|
|
69
|
+
// tools do; modify/delete show only an explicit one; activate/release
|
|
70
|
+
// never carry one). Nothing resolvable ⇒ the segment is OMITTED — never a
|
|
71
|
+
// placeholder or a promised-but-unminted "new" transport.
|
|
72
|
+
const metaSegs = [];
|
|
73
|
+
if (change.package)
|
|
74
|
+
metaSegs.push(`Package ${change.package}`);
|
|
75
|
+
if (transport)
|
|
76
|
+
metaSegs.push(`Transport: ${transport}`);
|
|
77
|
+
// Op-aware verb line ("Create table ZMNT_NOTE"); delete gets the hot accent.
|
|
78
|
+
const verb = verbLine(change);
|
|
79
|
+
const verbColor = change.op === 'delete' ? 'red' : undefined;
|
|
80
|
+
// Keys row — one line, human words, truncate-end (Wave-1 no-wrap rule).
|
|
81
|
+
const keys = ['[a] Approve'];
|
|
82
|
+
if (aff)
|
|
83
|
+
keys.push(`[${aff.key}] ${aff.label}`);
|
|
84
|
+
keys.push('[s] Skip', '[c] Cancel turn');
|
|
85
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: riskColor(effectiveRisk.level), paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Write approval" }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: verbColor, children: verb }), change.op === 'create' ? _jsx(Text, { dimColor: true, children: " (new object)" }) : null] }) }), metaSegs.length > 0 && (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: metaSegs.join(' · ') })), _jsxs(Text, { color: riskColor(effectiveRisk.level), wrap: "truncate-end", children: ["Risk: ", effectiveRisk.level, raisedLabel ? ` (raised from low: ${raisedLabel})` : ''] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { wrap: "truncate-end", children: keys.join(' ') }) })] }));
|
|
53
86
|
}
|
|
@@ -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();
|