@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.
Files changed (100) hide show
  1. package/dist/agent/loop.js +22 -9
  2. package/dist/approvals/op-labels.js +124 -0
  3. package/dist/approvals/render.js +42 -36
  4. package/dist/cli.js +15 -0
  5. package/dist/commands/compact.js +28 -2
  6. package/dist/commands/config-set.js +189 -0
  7. package/dist/commands/config-show.js +20 -0
  8. package/dist/commands/export-audit.js +43 -0
  9. package/dist/commands/help.js +5 -0
  10. package/dist/commands/plan-audit-evidence.js +266 -0
  11. package/dist/commands/plan-audit.js +692 -0
  12. package/dist/commands/plan-chain.js +671 -0
  13. package/dist/commands/plan-continue.js +179 -0
  14. package/dist/commands/plan-gate.js +154 -0
  15. package/dist/commands/plan-resume.js +588 -33
  16. package/dist/config/loader.js +128 -4
  17. package/dist/config/model-defaults.js +14 -0
  18. package/dist/cost/pricing.js +27 -1
  19. package/dist/doctor/checks/system-roles.js +41 -0
  20. package/dist/doctor/run.js +2 -0
  21. package/dist/models/resolve.js +61 -0
  22. package/dist/models/server-config.js +155 -0
  23. package/dist/one-shot.js +25 -3
  24. package/dist/projects/extract-cca.js +3 -1
  25. package/dist/projects/extract-modernize.js +3 -1
  26. package/dist/projects/extract-plan.js +60 -6
  27. package/dist/projects/extract-test-coverage.js +3 -1
  28. package/dist/projects/extract-upgrade.js +3 -1
  29. package/dist/projects/handover-md.js +195 -0
  30. package/dist/projects/index.js +1 -1
  31. package/dist/projects/plan-run.js +137 -13
  32. package/dist/projects/plan-schema.js +73 -0
  33. package/dist/projects/run-lease.js +157 -0
  34. package/dist/projects/save-command.js +26 -15
  35. package/dist/renderer/status-footer.js +22 -12
  36. package/dist/renderer/thinking-heartbeat.js +64 -8
  37. package/dist/renderer/todo-block.js +51 -0
  38. package/dist/renderer/tool-widget.js +37 -0
  39. package/dist/repl/bracketed-paste.js +28 -19
  40. package/dist/repl/builtin-commands.js +5 -0
  41. package/dist/repl/current-transport.js +10 -0
  42. package/dist/repl/history.js +86 -0
  43. package/dist/repl/ink-stdin-guard.js +64 -0
  44. package/dist/repl/mode-ceiling.js +16 -0
  45. package/dist/repl/mode-cycle.js +104 -0
  46. package/dist/repl/post-turn-status.js +24 -4
  47. package/dist/repl/slash-completer.js +5 -0
  48. package/dist/repl.js +954 -83
  49. package/dist/rewind/candidates.js +194 -0
  50. package/dist/rewind/cli.js +137 -0
  51. package/dist/rewind/format.js +27 -0
  52. package/dist/rewind/restore.js +245 -0
  53. package/dist/session/audit-export.js +459 -0
  54. package/dist/session/context-report.js +163 -0
  55. package/dist/session/recap.js +160 -0
  56. package/dist/skill-catalog.js +9 -3
  57. package/dist/skills/bundled-skills.js +71 -78
  58. package/dist/tools/approval.js +115 -7
  59. package/dist/tools/ask-question.js +304 -3
  60. package/dist/tools/extend-model/anchored-insert.js +604 -0
  61. package/dist/tools/extend-model/tool.js +162 -10
  62. package/dist/tools/fiori/fe-extend.js +76 -0
  63. package/dist/tools/fiori/fe-scaffold.js +29 -3
  64. package/dist/tools/fiori/floorplan-map.js +19 -0
  65. package/dist/tools/fiori/samples/data/index.json +13602 -0
  66. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  67. package/dist/tools/fiori/samples/loader.js +248 -0
  68. package/dist/tools/fiori/samples/search.js +63 -0
  69. package/dist/tools/fiori/samples/types.js +2 -0
  70. package/dist/tools/fiori/smoke/assertions.js +74 -0
  71. package/dist/tools/fiori/smoke/browser.js +52 -0
  72. package/dist/tools/fiori/smoke/driver.js +89 -0
  73. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  74. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  75. package/dist/tools/fiori/tools.js +328 -3
  76. package/dist/tools/local-build.js +11 -1
  77. package/dist/tools/sap-read.js +79 -11
  78. package/dist/tools/sap-write.js +24 -4
  79. package/dist/tools/snapshot.js +27 -1
  80. package/dist/tools/subagent/agent_run.js +27 -3
  81. package/dist/tools/todo.js +144 -0
  82. package/dist/ui/app.js +372 -19
  83. package/dist/ui/approval-modal.js +49 -16
  84. package/dist/ui/ask-question-emitter.js +14 -0
  85. package/dist/ui/context-grid.js +108 -0
  86. package/dist/ui/footer.js +109 -30
  87. package/dist/ui/header.js +7 -0
  88. package/dist/ui/line-resolution.js +18 -2
  89. package/dist/ui/rewind-emitter.js +10 -0
  90. package/dist/ui/rewind-panel.js +81 -0
  91. package/dist/ui/sap-state-store.js +1 -0
  92. package/dist/ui/status-line.js +43 -0
  93. package/dist/ui/text-input.js +72 -8
  94. package/dist/ui/todo-emitter.js +25 -0
  95. package/dist/ui/todo-panel.js +64 -0
  96. package/dist/ui/turn-status-emitter.js +50 -4
  97. package/dist/ui/turn-status.js +18 -3
  98. package/dist/ui/widgets/ask-form.js +242 -0
  99. package/dist/ui/widgets/ask-question-modal.js +17 -7
  100. 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
- * <Header /> ← renders once, becomes scrollback
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, writeMode, onInterrupt }) {
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) => setPendingApproval(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
- if (turnStatusEmitter.snapshot().active) {
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
- chunkEmitter.emit('chunk', `\x1b[36m↳ queued ▸ ${t}\x1b[39m\n`);
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 (input === '?' && footerValue === '')
319
+ if (footerBufferCount > 0)
320
+ return; // Task 7 — mid-composition, stay dormant
321
+ if (input === '?' && footerValue === '') {
140
322
  setShowTimeline(true);
141
- }, { isActive: !lineResolution && !showTimeline && !pendingApproval && !coachingPending });
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
- const clearApproval = () => setPendingApproval(null);
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(AskQuestionModal, { params: askQuestionPending, onResolve: (result) => {
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
- setAskQuestionPending(null);
170
- } })), _jsx(TurnStatus, {}), _jsx(Footer, { onSubmit: beginSubmit, onSlashTrigger: handleSlashTrigger, onHistoryTrigger: handleHistoryTrigger, prefill: prefill, onValueChange: setFooterValue,
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 }), lineResolution?.mode === 'slash' && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(CommandPalette, { initialQuery: lineResolution.prefix, onSelect: (name) => {
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
- handleStep(applySlashPick(name, lr.rest, lr.files), lr.original, lr.files);
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) }) })), pendingApproval && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(ApprovalModal, { change: pendingApproval.change, effectiveRisk: pendingApproval.effectiveRisk, transport: pendingApproval.transport, onApply: () => {
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 { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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
- const [showingDiff, setShowingDiff] = useState(false);
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 (showingDiff) {
34
+ if (viewing) {
23
35
  if (key.escape) {
24
- setShowingDiff(false);
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
- if (input === 'd') {
37
- setShowingDiff(true);
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
- if (showingDiff && change.diff) {
47
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Diff \\u2014 Esc to return" }), change.diff.split('\n').map((line, i) => (_jsx(Text, { color: line.startsWith('+') ? 'green' : line.startsWith('-') ? 'red' : 'gray', children: line }, i)))] }));
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
- .map((r) => LABEL_MAP[r] ?? r)
51
- .join(', ');
52
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: riskColor(effectiveRisk.level), paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Approval requested" }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { bold: true, color: riskColor(effectiveRisk.level), children: [" ", opIcon(change.op), " "] }), _jsx(Text, { bold: true, children: change.object }), _jsxs(Text, { color: "gray", children: [" (", change.type, ")"] })] }), _jsxs(Text, { color: riskColor(effectiveRisk.level), children: ["Risk: ", effectiveRisk.level, raisedLabel ? ` (raised from low: ${raisedLabel})` : ''] }), transport && _jsxs(Text, { color: "gray", children: ["Transport: ", transport] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { children: "[a] Apply [d] Show diff" }), _jsx(Text, { children: "[s] Skip [c] Cancel turn" })] })] }));
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();