@cspeach/cli 0.9.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/agent/intent-system-prompt.js +1 -1
- package/dist/agent/loop.js +228 -26
- package/dist/agent/providers/license-gate.js +44 -0
- package/dist/agent/skill-checkpoint.js +1 -1
- package/dist/agent/tool-dispatch.js +15 -0
- package/dist/approvals/canonical.js +91 -0
- package/dist/approvals/jwt.js +39 -2
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +18 -3
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +284 -0
- package/dist/commands/config-show.js +20 -0
- package/dist/commands/export-audit.js +43 -0
- package/dist/commands/help.js +5 -0
- package/dist/commands/login.js +31 -14
- package/dist/commands/plan-audit-evidence.js +266 -0
- package/dist/commands/plan-audit.js +692 -0
- package/dist/commands/plan-chain.js +671 -0
- package/dist/commands/plan-continue.js +179 -0
- package/dist/commands/plan-gate.js +154 -0
- package/dist/commands/plan-model-tier.js +83 -0
- package/dist/commands/plan-resume.js +728 -46
- package/dist/config/loader.js +223 -5
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- package/dist/doctor/checks/_http-probe.js +1 -0
- package/dist/doctor/checks/cert.js +14 -3
- package/dist/doctor/checks/sap.js +30 -8
- package/dist/doctor/checks/system-roles.js +41 -0
- package/dist/doctor/checks/zcspeach.js +19 -4
- package/dist/doctor/run.js +2 -0
- package/dist/models/resolve.js +61 -0
- package/dist/models/server-config.js +155 -0
- package/dist/one-shot.js +76 -6
- package/dist/projects/answer-blockers.js +137 -0
- package/dist/projects/extract-cca.js +111 -17
- package/dist/projects/extract-modernize.js +4 -2
- package/dist/projects/extract-plan.js +184 -37
- package/dist/projects/extract-spec-gap.js +34 -7
- package/dist/projects/extract-test-coverage.js +4 -2
- package/dist/projects/extract-upgrade.js +116 -23
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +5 -2
- package/dist/projects/merge-cca.js +292 -0
- package/dist/projects/merge-upgrade.js +173 -0
- package/dist/projects/migration.js +103 -1
- package/dist/projects/output-paths.js +27 -0
- package/dist/projects/plan-run.js +285 -27
- package/dist/projects/plan-schema.js +136 -3
- package/dist/projects/promote-command.js +25 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +259 -21
- package/dist/projects/status.js +3 -1
- package/dist/projects/validate.js +1 -1
- package/dist/projects/workspace.js +164 -20
- package/dist/renderer/notices.js +64 -0
- package/dist/renderer/progress-chatter.js +8 -0
- package/dist/renderer/status-footer.js +22 -12
- package/dist/renderer/thinking-heartbeat.js +64 -8
- package/dist/renderer/todo-block.js +51 -0
- package/dist/renderer/tool-widget.js +55 -4
- package/dist/renderer/tty.js +43 -4
- package/dist/renderer/verify-chain.js +77 -0
- package/dist/repl/at-picker.js +60 -7
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +42 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/early-line-buffer.js +68 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/inquirer-guard.js +70 -5
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/numbered-menu.js +131 -0
- package/dist/repl/post-turn-status.js +26 -6
- package/dist/repl/rule8-detector.js +17 -2
- package/dist/repl/safety-confirm.js +111 -2
- package/dist/repl/safety-mode-state.js +19 -3
- package/dist/repl/slash-completer.js +5 -0
- package/dist/repl/slash-picker.js +10 -15
- package/dist/repl.js +1232 -95
- package/dist/rewind/candidates.js +194 -0
- package/dist/rewind/cli.js +137 -0
- package/dist/rewind/format.js +27 -0
- package/dist/rewind/restore.js +245 -0
- package/dist/router/classifier.js +150 -6
- package/dist/sap/capability-matrix.js +20 -0
- package/dist/sap/capability-matrix.json +11236 -0
- package/dist/sap/capability.js +146 -0
- package/dist/sap/connection-manager.js +19 -1
- package/dist/sap/onboarding.js +42 -4
- package/dist/session/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/pending.js +27 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +51 -40
- package/dist/skills/bundled-skills.js +272 -1
- package/dist/skills/promotion-dispatch.js +23 -0
- package/dist/tools/_command-shared.js +36 -12
- package/dist/tools/_filesystem-shared.js +139 -4
- package/dist/tools/_flag.js +25 -0
- package/dist/tools/approval.js +177 -26
- package/dist/tools/ask-question.js +400 -7
- package/dist/tools/capability/tool.js +74 -0
- package/dist/tools/dispatch-skill.js +22 -1
- package/dist/tools/extend-model/anchored-insert.js +1414 -0
- package/dist/tools/extend-model/tool.js +340 -0
- package/dist/tools/filesystem/extract-document.js +57 -0
- package/dist/tools/filesystem/file-edit.js +12 -2
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/file-write.js +11 -2
- package/dist/tools/filesystem/glob.js +11 -0
- package/dist/tools/filesystem/grep.js +10 -0
- package/dist/tools/filesystem/read-document.js +107 -0
- package/dist/tools/fiori/apply.js +50 -0
- package/dist/tools/fiori/bin.js +3 -0
- package/dist/tools/fiori/catalog/index.js +27 -0
- package/dist/tools/fiori/catalog/value-help.js +230 -0
- package/dist/tools/fiori/catalog/viz-chart.js +177 -0
- package/dist/tools/fiori/cli.js +71 -0
- package/dist/tools/fiori/deploy-config.js +73 -0
- package/dist/tools/fiori/fe-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +71 -0
- package/dist/tools/fiori/floorplan-map.js +19 -0
- package/dist/tools/fiori/i18n.js +39 -0
- package/dist/tools/fiori/manifest.js +70 -0
- package/dist/tools/fiori/render.js +77 -0
- package/dist/tools/fiori/samples/data/index.json +13602 -0
- package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
- package/dist/tools/fiori/samples/loader.js +248 -0
- package/dist/tools/fiori/samples/search.js +63 -0
- package/dist/tools/fiori/samples/types.js +2 -0
- package/dist/tools/fiori/scaffold.js +39 -0
- package/dist/tools/fiori/smoke/assertions.js +74 -0
- package/dist/tools/fiori/smoke/browser.js +52 -0
- package/dist/tools/fiori/smoke/driver.js +89 -0
- package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
- package/dist/tools/fiori/smoke/run-smoke.js +149 -0
- package/dist/tools/fiori/tools.js +681 -0
- package/dist/tools/fiori/types.js +1 -0
- package/dist/tools/local-build.js +86 -0
- package/dist/tools/local-files.js +31 -0
- package/dist/tools/project/_merge-shared.js +68 -0
- package/dist/tools/project/cca_merge.js +164 -0
- package/dist/tools/project/playbook_get.js +1 -1
- package/dist/tools/project/upgrade_merge_progress.js +206 -0
- package/dist/tools/sap-read.js +132 -20
- package/dist/tools/sap-write.js +550 -21
- package/dist/tools/shell/shell_exec.js +41 -6
- package/dist/tools/snapshot.js +63 -14
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/subagent/background_run.js +17 -1
- package/dist/tools/todo.js +144 -0
- package/dist/tools/transport-resolution.js +86 -0
- package/dist/tools/transport.js +224 -5
- package/dist/tools/write-mode.js +4 -0
- package/dist/ui/app.js +378 -21
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/body.js +13 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +120 -27
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +35 -8
- package/dist/ui/rewind-emitter.js +10 -0
- package/dist/ui/rewind-panel.js +81 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +214 -0
- package/dist/ui/todo-emitter.js +25 -0
- package/dist/ui/todo-panel.js +64 -0
- package/dist/ui/turn-status-emitter.js +50 -4
- package/dist/ui/turn-status.js +18 -3
- package/dist/ui/widgets/ask-form.js +242 -0
- package/dist/ui/widgets/ask-question-modal.js +21 -8
- package/package.json +22 -3
- package/bench/README.md +0 -78
- package/bench/prompts/abap-document-cds.md +0 -44
- package/bench/prompts/abap-explain-bdef-handler.md +0 -57
- package/bench/prompts/abap-test-method.md +0 -42
- package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
- package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
- package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
- package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
- package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
- package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
- package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
- package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
- package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* PasteSafeTextInput — drop-in replacement for `ink-text-input` (D29,
|
|
4
|
+
* "chewed words" during seed-session paste; 2026-06-12).
|
|
5
|
+
*
|
|
6
|
+
* ROOT CAUSE of the chewing, verified by repro test: Ink's App drains ALL
|
|
7
|
+
* pending stdin chunks in one synchronous loop (components/App.js
|
|
8
|
+
* `handleReadable`: `while ((chunk = stdin.read()) !== null) emit('input')`),
|
|
9
|
+
* and a large terminal paste arrives as MULTIPLE chunks. ink-text-input
|
|
10
|
+
* computes `nextValue = value.slice(0, cursor) + input + …` from the
|
|
11
|
+
* `value` PROP of the last committed render — a stale closure for every
|
|
12
|
+
* chunk after the first in the same tick. With chunks A then B, B's
|
|
13
|
+
* handler still sees the pre-A value, so A is silently dropped:
|
|
14
|
+
* `stdin.write('hello '); stdin.write('world')` → value `"world"`.
|
|
15
|
+
*
|
|
16
|
+
* FIX: mirror value + cursor in refs that are updated SYNCHRONOUSLY inside
|
|
17
|
+
* the input handler, so consecutive same-tick chunks compound. The refs
|
|
18
|
+
* re-sync from props on commit, so external value pushes (submit-clear,
|
|
19
|
+
* palette prefill) still win between keystrokes. This also fixes the
|
|
20
|
+
* sibling hazard: an Enter that lands in the same drain as the paste tail
|
|
21
|
+
* now submits the COMPLETE value instead of the stale prop.
|
|
22
|
+
*
|
|
23
|
+
* Rendering, cursor drawing, placeholder, and mask behaviour are vendored
|
|
24
|
+
* verbatim from ink-text-input v6 (MIT, © Vadim Demedes) minus
|
|
25
|
+
* `highlightPastedText` (Footer never used it).
|
|
26
|
+
*/
|
|
27
|
+
import { useState, useEffect, useRef } from 'react';
|
|
28
|
+
import { Text, useInput } from 'ink';
|
|
29
|
+
import chalk from 'chalk';
|
|
30
|
+
/**
|
|
31
|
+
* Pure windowing core (exported for tests). Given the value length, the
|
|
32
|
+
* cursor position, the display budget (character cells) and whether the
|
|
33
|
+
* fake cursor is rendered, return the [start, end) slice of the value to
|
|
34
|
+
* display. Invariants:
|
|
35
|
+
* - (end - start) + (cursor-at-end cell ? 1 : 0) <= budget
|
|
36
|
+
* - start <= cursorOffset <= start + budget - 1 (cursor always visible)
|
|
37
|
+
* - tail-biased: typing at the end shows the end of the value.
|
|
38
|
+
*/
|
|
39
|
+
export function computeDisplayWindow(valueLength, cursorOffset, budget, cursorVisible) {
|
|
40
|
+
if (!budget || budget <= 0)
|
|
41
|
+
return { start: 0, end: valueLength };
|
|
42
|
+
const virtualLength = valueLength + (cursorVisible ? 1 : 0); // trailing cursor cell
|
|
43
|
+
if (virtualLength <= budget)
|
|
44
|
+
return { start: 0, end: valueLength };
|
|
45
|
+
let start = virtualLength - budget; // default: show the tail
|
|
46
|
+
if (cursorOffset < start)
|
|
47
|
+
start = cursorOffset; // scrolled left past the window
|
|
48
|
+
const end = Math.min(valueLength, start + budget);
|
|
49
|
+
return { start, end };
|
|
50
|
+
}
|
|
51
|
+
export function PasteSafeTextInput({ value: originalValue, placeholder = '', focus = true, mask, showCursor = true, onChange, onSubmit, onPasteLines, maxDisplayWidth, }) {
|
|
52
|
+
const [cursorOffset, setCursorOffset] = useState((originalValue || '').length);
|
|
53
|
+
// The paste-safety core: synchronous mirrors of value + cursor. The
|
|
54
|
+
// input handler reads/writes THESE, never the (possibly stale) props.
|
|
55
|
+
const valueRef = useRef(originalValue);
|
|
56
|
+
const cursorRef = useRef(cursorOffset);
|
|
57
|
+
// Values we've sent through onChange whose prop echo hasn't been
|
|
58
|
+
// observed yet. Needed because passive effects flush LATE: with N
|
|
59
|
+
// same-tick chunks, the echo effect for chunk k can run AFTER the
|
|
60
|
+
// handler for chunk k+1, and blindly assigning `valueRef.current =
|
|
61
|
+
// originalValue` would rewind the mirror to a stale value (observed in
|
|
62
|
+
// the repro as words landing out of order). An echo — even a stale
|
|
63
|
+
// one — must never overwrite the mirror; only EXTERNAL pushes may.
|
|
64
|
+
const pendingEchoes = useRef([]);
|
|
65
|
+
// Re-sync the mirrors on EXTERNAL value pushes only (parent setValue('')
|
|
66
|
+
// after submit, palette prefill). Echoes of our own onChange — current
|
|
67
|
+
// or stale — are recognised via pendingEchoes and skipped: the mirror
|
|
68
|
+
// is already at or ahead of them.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const echoIdx = pendingEchoes.current.indexOf(originalValue);
|
|
71
|
+
if (echoIdx !== -1) {
|
|
72
|
+
pendingEchoes.current.splice(0, echoIdx + 1);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
pendingEchoes.current = [];
|
|
76
|
+
valueRef.current = originalValue;
|
|
77
|
+
if (cursorRef.current > originalValue.length) {
|
|
78
|
+
cursorRef.current = originalValue.length;
|
|
79
|
+
setCursorOffset(originalValue.length);
|
|
80
|
+
}
|
|
81
|
+
}, [originalValue]);
|
|
82
|
+
// Clamp rendered cursor when the value shrinks under it (vendored
|
|
83
|
+
// semantic from ink-text-input's first effect).
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!focus || !showCursor)
|
|
86
|
+
return;
|
|
87
|
+
setCursorOffset((previous) => previous > originalValue.length ? originalValue.length : previous);
|
|
88
|
+
}, [originalValue, focus, showCursor]);
|
|
89
|
+
const value = mask ? mask.repeat(originalValue.length) : originalValue;
|
|
90
|
+
// Wave1 live-fix (Bug 1) — display-only horizontal window + control-char
|
|
91
|
+
// flattening. The mirrors, the props, and every handler above operate on
|
|
92
|
+
// the FULL value; only what's painted is windowed, so the row can never
|
|
93
|
+
// soft-wrap in the terminal (see maxDisplayWidth doc). Tabs are flattened
|
|
94
|
+
// to one space IN THE DISPLAY ONLY: string-width measures '\t' as 0 while
|
|
95
|
+
// terminals render up to 8 cells — the exact measurement drift that broke
|
|
96
|
+
// Ink's erase accounting live.
|
|
97
|
+
const cursorRendered = showCursor && focus;
|
|
98
|
+
const win = computeDisplayWindow(value.length, cursorOffset, maxDisplayWidth, cursorRendered);
|
|
99
|
+
const displayCursor = cursorOffset - win.start; // may equal display length (end cell)
|
|
100
|
+
// Left-clip marker: swap the first visible char for '…' (same cell
|
|
101
|
+
// count, so the budget still holds) unless the cursor sits on that cell.
|
|
102
|
+
const clipMarker = win.start > 0 && displayCursor !== 0;
|
|
103
|
+
const displayValue = (clipMarker ? '…' : '') +
|
|
104
|
+
value.slice(win.start + (clipMarker ? 1 : 0), win.end).replace(/\t/g, ' ');
|
|
105
|
+
let renderedValue = displayValue;
|
|
106
|
+
let renderedPlaceholder = placeholder ? chalk.grey(placeholder) : undefined;
|
|
107
|
+
// Fake cursor — vendored rendering from ink-text-input v6, over the
|
|
108
|
+
// windowed display string.
|
|
109
|
+
if (cursorRendered) {
|
|
110
|
+
renderedPlaceholder =
|
|
111
|
+
placeholder.length > 0
|
|
112
|
+
? chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1))
|
|
113
|
+
: chalk.inverse(' ');
|
|
114
|
+
renderedValue = displayValue.length > 0 ? '' : chalk.inverse(' ');
|
|
115
|
+
let i = 0;
|
|
116
|
+
for (const char of displayValue) {
|
|
117
|
+
renderedValue += i === displayCursor ? chalk.inverse(char) : char;
|
|
118
|
+
i++;
|
|
119
|
+
}
|
|
120
|
+
if (displayValue.length > 0 && displayCursor === displayValue.length) {
|
|
121
|
+
renderedValue += chalk.inverse(' ');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
useInput((input, key) => {
|
|
125
|
+
if (key.upArrow ||
|
|
126
|
+
key.downArrow ||
|
|
127
|
+
(key.ctrl && input === 'c') ||
|
|
128
|
+
key.tab ||
|
|
129
|
+
(key.shift && key.tab)) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (key.return) {
|
|
133
|
+
// valueRef (not the prop) — so an Enter in the same stdin drain as
|
|
134
|
+
// the paste tail submits the complete line.
|
|
135
|
+
if (onSubmit)
|
|
136
|
+
onSubmit(valueRef.current);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const currentValue = valueRef.current;
|
|
140
|
+
let nextCursorOffset = cursorRef.current;
|
|
141
|
+
let nextValue = currentValue;
|
|
142
|
+
// Task 7 — complete lines carved out of a newline-bearing paste
|
|
143
|
+
// chunk; reported to the parent AFTER the mirrors are updated.
|
|
144
|
+
let pasteLines = null;
|
|
145
|
+
if (key.leftArrow) {
|
|
146
|
+
if (showCursor)
|
|
147
|
+
nextCursorOffset--;
|
|
148
|
+
}
|
|
149
|
+
else if (key.rightArrow) {
|
|
150
|
+
if (showCursor)
|
|
151
|
+
nextCursorOffset++;
|
|
152
|
+
}
|
|
153
|
+
else if (key.backspace || key.delete) {
|
|
154
|
+
if (cursorRef.current > 0) {
|
|
155
|
+
nextValue =
|
|
156
|
+
currentValue.slice(0, cursorRef.current - 1) +
|
|
157
|
+
currentValue.slice(cursorRef.current, currentValue.length);
|
|
158
|
+
nextCursorOffset--;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else if (onPasteLines && (input.includes('\n') || input.includes('\r'))) {
|
|
162
|
+
// Task 7 — multi-line paste chunk. Normalise CRLF / bare CR
|
|
163
|
+
// (Windows Terminal converts pasted newlines to \r in raw mode),
|
|
164
|
+
// insert at the cursor as usual, then split at the LAST newline:
|
|
165
|
+
// everything before it is complete lines for the parent's
|
|
166
|
+
// continuation buffer; the tail fragment becomes the new value.
|
|
167
|
+
// Because the fragment (not the full paste) lands in the
|
|
168
|
+
// synchronous mirror, a follow-up chunk of the same drain
|
|
169
|
+
// compounds on the fragment — duplicate delivery of a completed
|
|
170
|
+
// line is structurally impossible, and every D29 mirror/echo
|
|
171
|
+
// mechanism below operates exactly as for a plain insert.
|
|
172
|
+
const cleanInput = input.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
173
|
+
const combined = currentValue.slice(0, cursorRef.current) +
|
|
174
|
+
cleanInput +
|
|
175
|
+
currentValue.slice(cursorRef.current, currentValue.length);
|
|
176
|
+
const lastNl = combined.lastIndexOf('\n');
|
|
177
|
+
pasteLines = combined.slice(0, lastNl).split('\n');
|
|
178
|
+
nextValue = combined.slice(lastNl + 1);
|
|
179
|
+
// Cursor lands where the insertion ended, mapped into the fragment
|
|
180
|
+
// (0 when the paste ended on the newline itself).
|
|
181
|
+
nextCursorOffset = Math.max(0, cursorRef.current + cleanInput.length - (lastNl + 1));
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
nextValue =
|
|
185
|
+
currentValue.slice(0, cursorRef.current) +
|
|
186
|
+
input +
|
|
187
|
+
currentValue.slice(cursorRef.current, currentValue.length);
|
|
188
|
+
nextCursorOffset += input.length;
|
|
189
|
+
}
|
|
190
|
+
if (nextCursorOffset < 0)
|
|
191
|
+
nextCursorOffset = 0;
|
|
192
|
+
if (nextCursorOffset > nextValue.length)
|
|
193
|
+
nextCursorOffset = nextValue.length;
|
|
194
|
+
// Synchronous mirror update FIRST — the next same-tick chunk builds
|
|
195
|
+
// on this, not on the stale render.
|
|
196
|
+
valueRef.current = nextValue;
|
|
197
|
+
cursorRef.current = nextCursorOffset;
|
|
198
|
+
setCursorOffset(nextCursorOffset);
|
|
199
|
+
if (pasteLines)
|
|
200
|
+
onPasteLines?.(pasteLines);
|
|
201
|
+
if (nextValue !== currentValue) {
|
|
202
|
+
// Register the outgoing value so its (possibly late) prop echo is
|
|
203
|
+
// recognised and never rewinds the mirror.
|
|
204
|
+
pendingEchoes.current.push(nextValue);
|
|
205
|
+
onChange(nextValue);
|
|
206
|
+
}
|
|
207
|
+
}, { isActive: focus });
|
|
208
|
+
return (_jsx(Text, { children: placeholder
|
|
209
|
+
? value.length > 0
|
|
210
|
+
? renderedValue
|
|
211
|
+
: renderedPlaceholder
|
|
212
|
+
: renderedValue }));
|
|
213
|
+
}
|
|
214
|
+
export default PasteSafeTextInput;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
/**
|
|
3
|
+
* UX Wave 2 / Task 4 — singleton update channel from the `todo_set` tool
|
|
4
|
+
* (producer) to `<App>` (consumer, Task 5: Ctrl+T panel + turn-status
|
|
5
|
+
* strip). Mirror of approvalEmitter: the tool emits, App subscribes in a
|
|
6
|
+
* useEffect and pushes the list into its state.
|
|
7
|
+
*
|
|
8
|
+
* Event contract (Task 5 renders from this):
|
|
9
|
+
* todoEmitter.emit('update', todos) // todos: TodoItem[]
|
|
10
|
+
*
|
|
11
|
+
* Fire-and-forget by design — unlike askQuestionEmitter there is no
|
|
12
|
+
* resolve callback because nothing about the tool result depends on the
|
|
13
|
+
* UI. The tool reaches this singleton via `ctx.todoEmitter` (Task 5):
|
|
14
|
+
* top-level entry points (repl.tsx, one-shot.ts) wire it into their
|
|
15
|
+
* ToolContext; agent_run's child ctx DELIBERATELY omits it — same seam
|
|
16
|
+
* as chunkEmitter — so a subagent's todo_set is fully suppressed and can
|
|
17
|
+
* never clobber the parent's panel. With no emitter on the ctx, or no
|
|
18
|
+
* listener attached (classic REPL, one-shot, headless), the tool behaves
|
|
19
|
+
* identically: state still lands on session.todos, only the live panel
|
|
20
|
+
* is absent.
|
|
21
|
+
*
|
|
22
|
+
* Never spawn a sibling Ink root for the panel — that fights the main
|
|
23
|
+
* <App> for stdout and corrupts the Ink frame.
|
|
24
|
+
*/
|
|
25
|
+
export const todoEmitter = new EventEmitter();
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
/**
|
|
5
|
+
* TodoPanel — UX Wave 2 / Task 5. The Ctrl+T overlay rendering the
|
|
6
|
+
* model-maintained task list (`todo_set` tool → session.todos). House
|
|
7
|
+
* style follows SessionTimeline: bordered box mounted above the Footer
|
|
8
|
+
* by <App>, closes on Esc / q — plus Ctrl+T itself (toggle; App's
|
|
9
|
+
* open-handler goes inactive while the panel is up, so the chord cannot
|
|
10
|
+
* double-fire).
|
|
11
|
+
*
|
|
12
|
+
* Row glyphs: ▣ completed (dim) · ▶ in_progress (peach, bold) ·
|
|
13
|
+
* □ pending. Every row is wrap="truncate-end" — Wave-1 rule: no
|
|
14
|
+
* dynamic-frame row may ever soft-wrap (wrapped rows break Ink's erase
|
|
15
|
+
* accounting and leak stale rows into scrollback).
|
|
16
|
+
*
|
|
17
|
+
* Lists longer than TODO_VISIBLE_ROWS get a scrolling window
|
|
18
|
+
* (CommandPalette pattern): ↑/↓ move the window, hidden counts show as
|
|
19
|
+
* "↑ N more" / "↓ N more". The initial window anchors the in_progress
|
|
20
|
+
* item ~3 rows from the top so the current step is visible on open.
|
|
21
|
+
*/
|
|
22
|
+
const PEACH = '#FFCBA4';
|
|
23
|
+
export const TODO_VISIBLE_ROWS = 12;
|
|
24
|
+
/** Pure header-counts formatter, exported for tests. */
|
|
25
|
+
export function formatTodoCounts(todos) {
|
|
26
|
+
const done = todos.filter((t) => t.status === 'completed').length;
|
|
27
|
+
const active = todos.filter((t) => t.status === 'in_progress').length;
|
|
28
|
+
const open = todos.filter((t) => t.status === 'pending').length;
|
|
29
|
+
return `Tasks — ${done} done · ${active} active · ${open} open`;
|
|
30
|
+
}
|
|
31
|
+
export function TodoPanel({ todos, onClose }) {
|
|
32
|
+
const maxOffset = Math.max(0, todos.length - TODO_VISIBLE_ROWS);
|
|
33
|
+
// Anchor the initial window so the in_progress item sits ~3 rows from
|
|
34
|
+
// the top (CommandPalette's cursor rule, with the active item as cursor).
|
|
35
|
+
const [offset, setOffset] = useState(() => {
|
|
36
|
+
const idx = todos.findIndex((t) => t.status === 'in_progress');
|
|
37
|
+
if (idx <= 3)
|
|
38
|
+
return 0;
|
|
39
|
+
return Math.min(idx - 3, maxOffset);
|
|
40
|
+
});
|
|
41
|
+
useInput((input, key) => {
|
|
42
|
+
if (key.escape || input === 'q' || (key.ctrl && (input === 't' || input === 'T'))) {
|
|
43
|
+
onClose();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (key.upArrow)
|
|
47
|
+
setOffset((o) => Math.max(0, o - 1));
|
|
48
|
+
if (key.downArrow)
|
|
49
|
+
setOffset((o) => Math.min(maxOffset, o + 1));
|
|
50
|
+
});
|
|
51
|
+
const clamped = Math.min(offset, maxOffset);
|
|
52
|
+
const visible = todos.slice(clamped, clamped + TODO_VISIBLE_ROWS);
|
|
53
|
+
const below = Math.max(0, todos.length - (clamped + TODO_VISIBLE_ROWS));
|
|
54
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 2, paddingY: 1, width: 72, children: [_jsx(Text, { bold: true, color: "cyan", wrap: "truncate-end", children: formatTodoCounts(todos) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [todos.length === 0 && (_jsx(Text, { dimColor: true, children: "(no tasks yet \u2014 the model maintains this list on multi-step work)" })), clamped > 0 && _jsxs(Text, { dimColor: true, children: [" \u2191 ", clamped, " more"] }), visible.map((t, i) => {
|
|
55
|
+
const key = `${clamped + i}`;
|
|
56
|
+
if (t.status === 'completed') {
|
|
57
|
+
return _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u25A3 ", t.text] }, key);
|
|
58
|
+
}
|
|
59
|
+
if (t.status === 'in_progress') {
|
|
60
|
+
return _jsxs(Text, { color: PEACH, bold: true, wrap: "truncate-end", children: ["\u25B6 ", t.text] }, key);
|
|
61
|
+
}
|
|
62
|
+
return _jsxs(Text, { wrap: "truncate-end", children: ["\u25A1 ", t.text] }, key);
|
|
63
|
+
}), below > 0 && _jsxs(Text, { dimColor: true, children: [" \u2193 ", below, " more"] })] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [todos.length > TODO_VISIBLE_ROWS ? '↑↓ scroll · ' : '', "Esc/q/Ctrl+T to close"] }) })] }));
|
|
64
|
+
}
|
|
@@ -12,9 +12,48 @@
|
|
|
12
12
|
// listeners, and every call is a no-op.
|
|
13
13
|
import { EventEmitter } from 'node:events';
|
|
14
14
|
class TurnStatusEmitter extends EventEmitter {
|
|
15
|
-
state = { active: false, paused: false, label: '', startedAt: 0 };
|
|
15
|
+
state = { active: false, reserved: false, paused: false, label: '', startedAt: 0 };
|
|
16
|
+
/**
|
|
17
|
+
* BUG-3 (2026-07-05 live smoke) — the row vanished WHILE the model still
|
|
18
|
+
* worked. Root cause was the twin-turn overlap (BUG-2): turn 1 finished
|
|
19
|
+
* and its stop() cleared `active` while turn 2 was still running, because
|
|
20
|
+
* start/stop toggled a single boolean with no nesting. A depth counter
|
|
21
|
+
* makes stop() clear the row only when the OUTERMOST turn ends. In the
|
|
22
|
+
* BUG-2-fixed world turns are strictly sequential (depth only ever 0↔1),
|
|
23
|
+
* so this is defense-in-depth: any residual overlap (e.g. the /reroute
|
|
24
|
+
* mini-turn, or a future concurrency path) now degrades gracefully instead
|
|
25
|
+
* of blanking the liveness row.
|
|
26
|
+
*/
|
|
27
|
+
depth = 0;
|
|
28
|
+
/**
|
|
29
|
+
* BUG-2b — mark a dispatch in-flight before the async pre-turn work runs.
|
|
30
|
+
* Invisible (no spinner); isBusy() reports it so the submit guard can
|
|
31
|
+
* queue a concurrent line as steering instead of starting a twin turn.
|
|
32
|
+
*/
|
|
33
|
+
reserve() {
|
|
34
|
+
if (this.state.reserved)
|
|
35
|
+
return;
|
|
36
|
+
this.state = { ...this.state, reserved: true };
|
|
37
|
+
this.emit('change', this.state);
|
|
38
|
+
}
|
|
39
|
+
/** BUG-2b — release the pre-turn reservation (dispatch lifecycle ended). */
|
|
40
|
+
release() {
|
|
41
|
+
if (!this.state.reserved)
|
|
42
|
+
return;
|
|
43
|
+
this.state = { ...this.state, reserved: false };
|
|
44
|
+
this.emit('change', this.state);
|
|
45
|
+
}
|
|
46
|
+
/** "A turn is running OR imminent" — the predicate the submit/live-trigger
|
|
47
|
+
* guards must use so the async pre-turn window (BUG-2b) is covered. */
|
|
48
|
+
isBusy() {
|
|
49
|
+
return this.state.active || this.state.reserved;
|
|
50
|
+
}
|
|
16
51
|
start(label = 'thinking') {
|
|
17
|
-
|
|
52
|
+
// BUG-3 — nest instead of replace so an overlapping stop() can't blank
|
|
53
|
+
// the row (see the `depth` field doc). `...this.state` preserves the
|
|
54
|
+
// reserved flag set by reserve() at dispatch start.
|
|
55
|
+
this.depth++;
|
|
56
|
+
this.state = { ...this.state, active: true, paused: false, label, startedAt: Date.now() };
|
|
18
57
|
this.emit('change', this.state);
|
|
19
58
|
}
|
|
20
59
|
/** Update the activity label; ignored when no turn is active. Clears pause —
|
|
@@ -40,9 +79,16 @@ class TurnStatusEmitter extends EventEmitter {
|
|
|
40
79
|
this.emit('change', this.state);
|
|
41
80
|
}
|
|
42
81
|
stop() {
|
|
43
|
-
|
|
82
|
+
// BUG-3 — decrement the nesting depth; only clear the row when the
|
|
83
|
+
// outermost turn ends. Guard on depth (not `active`) and clamp at 0 so a
|
|
84
|
+
// spurious extra stop() is a harmless no-op. `reserved` is intentionally
|
|
85
|
+
// NOT cleared here — release() owns the pre-turn reservation lifecycle.
|
|
86
|
+
if (this.depth === 0)
|
|
87
|
+
return;
|
|
88
|
+
this.depth--;
|
|
89
|
+
if (this.depth > 0)
|
|
44
90
|
return;
|
|
45
|
-
this.state = { active: false, paused: false, label: '', startedAt: 0 };
|
|
91
|
+
this.state = { ...this.state, active: false, paused: false, label: '', startedAt: 0 };
|
|
46
92
|
this.emit('change', this.state);
|
|
47
93
|
}
|
|
48
94
|
snapshot() {
|
package/dist/ui/turn-status.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useState, useEffect } from 'react';
|
|
3
3
|
import { Text } from 'ink';
|
|
4
4
|
import { turnStatusEmitter } from './turn-status-emitter.js';
|
|
@@ -11,6 +11,17 @@ function fmtElapsed(ms) {
|
|
|
11
11
|
return `${s}s`;
|
|
12
12
|
return `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Task 5 (ux-wave2) — cap for the in-progress-todo strip appended to the
|
|
16
|
+
* status row. The row already carries spinner + label + elapsed + hint;
|
|
17
|
+
* 40 chars of todo text keeps the whole line comfortably inside a normal
|
|
18
|
+
* terminal (and wrap="truncate-end" clips the rest regardless).
|
|
19
|
+
*/
|
|
20
|
+
export const TODO_STRIP_MAX = 40;
|
|
21
|
+
/** ≤40 chars, '…' as the 40th when clipped. Exported for tests. */
|
|
22
|
+
export function truncateTodoStrip(text) {
|
|
23
|
+
return text.length > TODO_STRIP_MAX ? `${text.slice(0, TODO_STRIP_MAX - 1)}…` : text;
|
|
24
|
+
}
|
|
14
25
|
/**
|
|
15
26
|
* Persistent turn-status row (2026-06-07) — lives in the DYNAMIC Ink
|
|
16
27
|
* frame just above the Footer, so it repaints with the frame and
|
|
@@ -27,7 +38,7 @@ function fmtElapsed(ms) {
|
|
|
27
38
|
* cursor #25 class of bug came from a raw-stdout spinner; this
|
|
28
39
|
* component cannot reproduce it). Guard: ink-cursor-invariant test.
|
|
29
40
|
*/
|
|
30
|
-
export function TurnStatus() {
|
|
41
|
+
export function TurnStatus({ inProgressTodo } = {}) {
|
|
31
42
|
const [state, setState] = useState(turnStatusEmitter.snapshot());
|
|
32
43
|
const [frame, setFrame] = useState(0);
|
|
33
44
|
const [now, setNow] = useState(Date.now());
|
|
@@ -55,5 +66,9 @@ export function TurnStatus() {
|
|
|
55
66
|
}, [state.active, state.paused]);
|
|
56
67
|
if (!state.active || state.paused)
|
|
57
68
|
return null;
|
|
58
|
-
|
|
69
|
+
// wrap="truncate-end" (wave1 live-fix, Bug 1): this row sits in the
|
|
70
|
+
// dynamic frame too — a long tool label must clip, never soft-wrap
|
|
71
|
+
// (wrapped dynamic-frame rows break Ink's erase accounting and leak
|
|
72
|
+
// stale rows into scrollback).
|
|
73
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: PEACH, children: [FRAMES[frame], " "] }), _jsx(Text, { children: state.label }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", fmtElapsed(now - state.startedAt), " \u00B7 ctrl+c to interrupt"] }), inProgressTodo ? (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: " \u00B7 " }), _jsxs(Text, { color: PEACH, children: ["\u25B6 ", truncateTodoStrip(inProgressTodo)] })] })) : null] }));
|
|
59
74
|
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* AskForm — UX Wave 2 / Task 2: the multi-question Ink form for
|
|
4
|
+
* ask_question v2 batched requests (the CC-parity flagship interaction).
|
|
5
|
+
*
|
|
6
|
+
* One request = 1..4 questions answered in a single visit. Visual
|
|
7
|
+
* de-clutter pass (2026-07, product-owner review): no border box — the
|
|
8
|
+
* chip row + indentation is the form's identity; the approval modal keeps
|
|
9
|
+
* its border deliberately (danger checkpoint):
|
|
10
|
+
*
|
|
11
|
+
* ✓ env name style ← chips (two-space gaps)
|
|
12
|
+
*
|
|
13
|
+
* Which naming style?
|
|
14
|
+
* Used for every generated object
|
|
15
|
+
* ▸ snake_case lv_/lt_ prefixes
|
|
16
|
+
* CamelCase modern style
|
|
17
|
+
* ✎ Other (type your own)
|
|
18
|
+
* [ Submit ] ← ONLY once all answered
|
|
19
|
+
* Enter pick · Tab next · Esc cancel ← one context-sensitive hint
|
|
20
|
+
*
|
|
21
|
+
* Binding rules (Task-2 brief + de-clutter pass):
|
|
22
|
+
* - Chips: one per question (`header`), two-space gaps (no pipes);
|
|
23
|
+
* current = peach + bold, answered = dim with `✓ ` prefix,
|
|
24
|
+
* unanswered = dim. ONE blank line separates the chip row from the
|
|
25
|
+
* question text. Tab/→ next, Shift+Tab/← previous — all INSIDE the
|
|
26
|
+
* form; App's global handlers (mode-cycle, `?`, `/`, `@`) are already
|
|
27
|
+
* gated off by askQuestionPending.
|
|
28
|
+
* - Options: arrow-key list, label + dim description, CommandPalette-style
|
|
29
|
+
* scroll window; multiSelect ⇒ Space toggles ▣/□, Enter confirms the
|
|
30
|
+
* set; single ⇒ Enter picks. ALWAYS a final `✎ Other (type your own)`
|
|
31
|
+
* row → inline windowed TextInput; a typed answer flags `custom: true`.
|
|
32
|
+
* freeText questions render the input directly (no escape row, and the
|
|
33
|
+
* typed answer is the EXPECTED path — no custom flag, per the Task-1
|
|
34
|
+
* AskFormAnswer contract: custom marks the Other ESCAPE only).
|
|
35
|
+
* - Answering advances to the next UNANSWERED question (wrapping); when
|
|
36
|
+
* none remain, focus lands on [ Submit ]. Enter there resolves ALL
|
|
37
|
+
* answers at once, in question order. Tab back re-answers freely.
|
|
38
|
+
* Single-question forms (n === 1) auto-submit on answer — no extra
|
|
39
|
+
* Enter vs the v1 modal; the Submit affordance is multi-question
|
|
40
|
+
* review only (review round M2).
|
|
41
|
+
* - Esc — DELIBERATE DEVIATION from the brief's "Esc anywhere cancels",
|
|
42
|
+
* reviewer-accepted (review round I1): on an OPTIONED question with
|
|
43
|
+
* the Other input focused, Esc first returns focus to the options
|
|
44
|
+
* list (the typed draft is KEPT in otherDrafts); Esc at options
|
|
45
|
+
* level — and anywhere else, including freeText questions (no inner
|
|
46
|
+
* level) — cancels the WHOLE form → { formId, answers: [],
|
|
47
|
+
* cancelled: true } (v1 cancel contract shape per Task 1).
|
|
48
|
+
* - Dynamic-frame safety (Wave-1 lesson): every row truncates (NOTHING
|
|
49
|
+
* may wrap), options scroll, total height ≤ MAX_FORM_ROWS.
|
|
50
|
+
* - No React state keyed on formId (collision caveat from review) — all
|
|
51
|
+
* per-question state lives in arrays keyed by question INDEX within
|
|
52
|
+
* this request object.
|
|
53
|
+
*/
|
|
54
|
+
import { useState } from 'react';
|
|
55
|
+
import { Box, Text, useInput } from 'ink';
|
|
56
|
+
// Wave-1 windowed input — display can never wrap the row (D29 paste-safe).
|
|
57
|
+
import TextInput from '../text-input.js';
|
|
58
|
+
import { askHelpLine, useReclaimRawStdin } from './ask-question-modal.js';
|
|
59
|
+
const PEACH = '#FFCBA4';
|
|
60
|
+
/** Option rows visible at once — the rest scroll (CommandPalette style). */
|
|
61
|
+
export const VISIBLE_OPTION_ROWS = 5;
|
|
62
|
+
/**
|
|
63
|
+
* Hard cap on total rendered rows — pinned by test. Re-derived for the
|
|
64
|
+
* de-clutter pass (border gone = −2 rows; blank chip-gap row = +1; the
|
|
65
|
+
* Submit row only renders once all questions are answered).
|
|
66
|
+
*
|
|
67
|
+
* Worst case — all answered, user Tabbed BACK into a scrolled optioned
|
|
68
|
+
* question for review (Submit row visible below it):
|
|
69
|
+
* chips + blank + question + context + "↑ more" + VISIBLE_OPTION_ROWS
|
|
70
|
+
* + "↓ more" + Other + Submit + help = 14.
|
|
71
|
+
* Pre-completion the Submit row is absent → 13.
|
|
72
|
+
*/
|
|
73
|
+
export const MAX_FORM_ROWS = 14;
|
|
74
|
+
/** Display window for the Other/freeText input — the row must never wrap. */
|
|
75
|
+
const OTHER_INPUT_WIDTH = 50;
|
|
76
|
+
export function AskForm({ request, onResolve }) {
|
|
77
|
+
useReclaimRawStdin();
|
|
78
|
+
const questions = request.questions;
|
|
79
|
+
const n = questions.length;
|
|
80
|
+
// Focus slots: 0..n-1 = questions, n = the [ Submit ] row (reachable
|
|
81
|
+
// only once every question has an answer).
|
|
82
|
+
const [focus, setFocus] = useState(0);
|
|
83
|
+
// Per-question state, keyed by question INDEX (never formId): option
|
|
84
|
+
// cursor (0..options.length — the last row is Other), multi-select
|
|
85
|
+
// sets, Other draft text, and the committed answer.
|
|
86
|
+
const [cursors, setCursors] = useState(() => questions.map(() => 0));
|
|
87
|
+
const [multiSel, setMultiSel] = useState(() => questions.map(() => new Set()));
|
|
88
|
+
const [otherDrafts, setOtherDrafts] = useState(() => questions.map(() => ''));
|
|
89
|
+
const [answers, setAnswers] = useState(() => questions.map(() => null));
|
|
90
|
+
const isSubmit = focus === n;
|
|
91
|
+
const q = isSubmit ? undefined : questions[focus];
|
|
92
|
+
const otherRow = q ? q.options.length : 0;
|
|
93
|
+
const cursor = isSubmit ? 0 : cursors[focus];
|
|
94
|
+
// The inline TextInput owns typing whenever the question is freeText or
|
|
95
|
+
// the cursor sits on the Other row. While it does, the form-level
|
|
96
|
+
// handler must NOT interpret space/Enter/←/→ (Ink delivers every key to
|
|
97
|
+
// every active useInput — there is no stopPropagation).
|
|
98
|
+
const otherFocused = q != null && (q.freeText || cursor === otherRow);
|
|
99
|
+
const allAnswered = answers.every((a) => a !== null);
|
|
100
|
+
const cancel = () => onResolve({ formId: request.formId, answers: [], cancelled: true });
|
|
101
|
+
const submit = (finalAnswers) => onResolve({
|
|
102
|
+
formId: request.formId,
|
|
103
|
+
// Question order, always — regardless of the order the user
|
|
104
|
+
// actually answered in.
|
|
105
|
+
answers: questions.map((qq, i) => ({
|
|
106
|
+
id: qq.id,
|
|
107
|
+
answer: finalAnswers[i]?.answer ?? null,
|
|
108
|
+
...(finalAnswers[i]?.custom ? { custom: true } : {}),
|
|
109
|
+
})),
|
|
110
|
+
});
|
|
111
|
+
/**
|
|
112
|
+
* Commit an answer, then advance to the next UNANSWERED question
|
|
113
|
+
* (wrapping past the end); none left → focus [ Submit ] — except
|
|
114
|
+
* single-question forms, which resolve immediately (M2: no extra Enter
|
|
115
|
+
* vs the v1 modal).
|
|
116
|
+
*/
|
|
117
|
+
const answerAndAdvance = (i, value, custom) => {
|
|
118
|
+
const next = answers.map((a, j) => j === i ? { answer: value, ...(custom ? { custom: true } : {}) } : a);
|
|
119
|
+
for (let step = 1; step <= n; step++) {
|
|
120
|
+
const j = (i + step) % n;
|
|
121
|
+
if (next[j] === null) {
|
|
122
|
+
setAnswers(next);
|
|
123
|
+
setFocus(j);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (n === 1) {
|
|
128
|
+
submit(next);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
setAnswers(next);
|
|
132
|
+
setFocus(n);
|
|
133
|
+
};
|
|
134
|
+
useInput((input, key) => {
|
|
135
|
+
if (key.escape) {
|
|
136
|
+
// Two-level Esc (I1, documented deviation — see file header): the
|
|
137
|
+
// Other input focused on an OPTIONED question → back to the options
|
|
138
|
+
// list, draft kept. freeText has no inner level; the options level
|
|
139
|
+
// cancels the whole form.
|
|
140
|
+
if (q && !q.freeText && otherFocused) {
|
|
141
|
+
setCursors((prev) => prev.map((c, j) => (j === focus ? otherRow - 1 : c)));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
cancel();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// Chip navigation. Tab/Shift+Tab always work (TextInput ignores tab);
|
|
148
|
+
// ←/→ only while the inline text input doesn't own horizontal cursor
|
|
149
|
+
// movement.
|
|
150
|
+
const maxSlot = allAnswered ? n : n - 1;
|
|
151
|
+
if ((key.tab && !key.shift) || (!otherFocused && key.rightArrow)) {
|
|
152
|
+
setFocus((f) => (f >= maxSlot ? 0 : f + 1));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if ((key.tab && key.shift) || (!otherFocused && key.leftArrow)) {
|
|
156
|
+
setFocus((f) => (f <= 0 ? maxSlot : f - 1));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (isSubmit) {
|
|
160
|
+
if (key.return && allAnswered)
|
|
161
|
+
submit(answers);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (!q)
|
|
165
|
+
return;
|
|
166
|
+
// ↑/↓ move the option cursor — also FROM the Other row back into the
|
|
167
|
+
// list (TextInput ignores vertical arrows, so no conflict).
|
|
168
|
+
if (!q.freeText) {
|
|
169
|
+
if (key.upArrow) {
|
|
170
|
+
setCursors((prev) => prev.map((c, j) => (j === focus ? Math.max(0, c - 1) : c)));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (key.downArrow) {
|
|
174
|
+
setCursors((prev) => prev.map((c, j) => (j === focus ? Math.min(otherRow, c + 1) : c)));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Everything below is typing territory while the Other input has focus.
|
|
179
|
+
if (otherFocused)
|
|
180
|
+
return;
|
|
181
|
+
if (q.multiSelect && input === ' ') {
|
|
182
|
+
const opt = q.options[cursor];
|
|
183
|
+
if (opt) {
|
|
184
|
+
setMultiSel((prev) => prev.map((s, j) => {
|
|
185
|
+
if (j !== focus)
|
|
186
|
+
return s;
|
|
187
|
+
const nextSet = new Set(s);
|
|
188
|
+
if (nextSet.has(opt.value))
|
|
189
|
+
nextSet.delete(opt.value);
|
|
190
|
+
else
|
|
191
|
+
nextSet.add(opt.value);
|
|
192
|
+
return nextSet;
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (key.return) {
|
|
198
|
+
if (q.multiSelect) {
|
|
199
|
+
const values = q.options
|
|
200
|
+
.filter((o) => multiSel[focus].has(o.value))
|
|
201
|
+
.map((o) => o.value);
|
|
202
|
+
if (values.length === 0)
|
|
203
|
+
return; // nothing toggled yet — toggle or use Other
|
|
204
|
+
answerAndAdvance(focus, values.join(','));
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
const opt = q.options[cursor];
|
|
208
|
+
if (opt)
|
|
209
|
+
answerAndAdvance(focus, opt.value);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}, { isActive: true });
|
|
213
|
+
// CommandPalette-style scroll window over the options (the Other row is
|
|
214
|
+
// rendered separately and is always visible).
|
|
215
|
+
const opts = q?.options ?? [];
|
|
216
|
+
const winStart = Math.max(0, Math.min(cursor - 3, opts.length - VISIBLE_OPTION_ROWS));
|
|
217
|
+
const visible = opts.slice(winStart, winStart + VISIBLE_OPTION_ROWS);
|
|
218
|
+
const below = Math.max(0, opts.length - (winStart + VISIBLE_OPTION_ROWS));
|
|
219
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, flexShrink: 0, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { wrap: "truncate-end", children: questions.map((qq, i) => {
|
|
220
|
+
const cur = i === focus;
|
|
221
|
+
const done = answers[i] !== null;
|
|
222
|
+
return (_jsxs(Text, { color: cur ? PEACH : undefined, bold: cur, dimColor: !cur, children: [i > 0 ? ' ' : '', done ? '✓ ' : '', qq.header ?? qq.id] }, qq.id));
|
|
223
|
+
}) }) }), isSubmit ? (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: "All questions answered \u2014 Tab/\u2190 to review, Enter to submit \u00B7 Esc cancel" })) : q ? (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: q.question }), q.context && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: q.context }), !q.freeText && (_jsxs(_Fragment, { children: [winStart > 0 && _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u2191 ", winStart, " more"] }), visible.map((o, vi) => {
|
|
224
|
+
const idx = winStart + vi;
|
|
225
|
+
const isCur = !otherFocused && idx === cursor;
|
|
226
|
+
const mark = q.multiSelect
|
|
227
|
+
? (multiSel[focus].has(o.value) ? '▣ ' : '□ ')
|
|
228
|
+
: '';
|
|
229
|
+
// v1 never renders the `recommended` flag — the convention
|
|
230
|
+
// is skills writing "(recommended)" INTO the label. Render
|
|
231
|
+
// the same dim convention only when the label lacks it.
|
|
232
|
+
const rec = o.recommended === true && !/\brecommended\b/i.test(o.label);
|
|
233
|
+
return (_jsxs(Text, { wrap: "truncate-end", color: isCur ? PEACH : undefined, bold: isCur, children: [isCur ? '▸ ' : ' ', mark, o.label, rec && _jsx(Text, { dimColor: true, children: " (recommended)" }), o.description && _jsxs(Text, { dimColor: true, children: [" ", o.description] })] }, o.value));
|
|
234
|
+
}), below > 0 && _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u2193 ", below, " more"] })] })), _jsxs(Box, { children: [q.freeText ? (_jsx(Text, { color: PEACH, wrap: "truncate-end", children: '> ' })) : (_jsxs(Text, { color: otherFocused ? PEACH : undefined, bold: otherFocused, wrap: "truncate-end", children: [otherFocused ? '▸ ' : ' ', "\u270E Other (type your own)", otherFocused ? ': ' : ''] })), otherFocused && (_jsx(TextInput, { value: otherDrafts[focus], onChange: (v) => setOtherDrafts((prev) => prev.map((d, j) => (j === focus ? v : d))), onSubmit: (v) => {
|
|
235
|
+
const trimmed = v.trim();
|
|
236
|
+
if (trimmed.length === 0)
|
|
237
|
+
return; // nothing typed — ignore
|
|
238
|
+
// custom marks the Other ESCAPE on an optioned question;
|
|
239
|
+
// freeText typing is the expected path (Task-1 contract).
|
|
240
|
+
answerAndAdvance(focus, trimmed, !q.freeText);
|
|
241
|
+
}, maxDisplayWidth: OTHER_INPUT_WIDTH }))] })] })) : null, allAnswered && (_jsxs(Text, { wrap: "truncate-end", color: isSubmit ? PEACH : undefined, bold: isSubmit, dimColor: !isSubmit, children: [isSubmit ? '▸ ' : ' ', "[ Submit ]"] })), q && (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: askHelpLine(q.freeText ? 'freeText' : q.multiSelect ? 'multi' : 'pick', { single: n === 1 }) }))] }));
|
|
242
|
+
}
|