@cspeach/cli 1.1.18 → 1.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -4
- package/dist/agent/anthropic-provider.js +30 -10
- package/dist/agent/cache-keepalive.js +162 -0
- package/dist/agent/cold-prune.js +116 -0
- package/dist/agent/loop.js +804 -159
- package/dist/agent/provider-shape.js +263 -0
- package/dist/agent/providers/ai-hub-provider.js +17 -2
- package/dist/agent/providers/byok-provider.js +33 -3
- package/dist/agent/providers/local-provider.js +12 -2
- package/dist/agent/repair-partial.js +125 -7
- package/dist/agent/summarise-via-provider.js +6 -1
- package/dist/agent/system-prompt.js +38 -0
- package/dist/agent/tool-dispatch.js +66 -21
- package/dist/agent/tool-loading-pin.js +100 -0
- package/dist/agent/turn-error-ux.js +77 -26
- package/dist/agent/turn-stream.js +25 -3
- package/dist/approvals/adt-type.js +27 -0
- package/dist/approvals/advisory-prompt.js +48 -0
- package/dist/approvals/advisory-render.js +25 -11
- package/dist/approvals/approval-prompt.js +74 -24
- package/dist/approvals/jwt.js +2 -1
- package/dist/approvals/render.js +64 -36
- package/dist/approvals/risk-floor.js +53 -2
- package/dist/auth/api-key.js +30 -16
- package/dist/auth/keychain.js +0 -0
- package/dist/auth/me.js +25 -7
- package/dist/cli.js +36 -6
- package/dist/commands/auto-compact.js +33 -16
- package/dist/commands/compact.js +49 -11
- package/dist/commands/config-set.js +16 -3
- package/dist/commands/config-show.js +18 -5
- package/dist/commands/cost.js +54 -35
- package/dist/commands/help.js +88 -50
- package/dist/commands/login.js +28 -19
- package/dist/commands/logout.js +7 -3
- package/dist/commands/plan-audit.js +1 -0
- package/dist/commands/plan-chain.js +71 -77
- package/dist/commands/plan-continue.js +1 -0
- package/dist/commands/plan-resume.js +20 -12
- package/dist/commands/whoami.js +23 -9
- package/dist/config/loader.js +70 -5
- package/dist/cost/cost-log.js +62 -2
- package/dist/cost/pricing.js +11 -6
- package/dist/doctor/checks/forge-rules.js +13 -3
- package/dist/doctor/checks/keychain.js +6 -4
- package/dist/doctor/run.js +101 -20
- package/dist/index.js +5 -2
- package/dist/lib/graceful-exit.js +56 -0
- package/dist/lib/piped-prompt.js +65 -0
- package/dist/lib/spill-labels.js +13 -0
- package/dist/lock-contention.js +2 -2
- package/dist/models/resolve.js +93 -2
- package/dist/models/server-config.js +158 -3
- package/dist/one-shot.js +150 -45
- package/dist/projects/answer-blockers.js +6 -1
- package/dist/projects/handover-md.js +15 -14
- package/dist/projects/image-attachments.js +15 -2
- package/dist/projects/plan-run.js +97 -54
- package/dist/projects/promote-command.js +11 -2
- package/dist/projects/save-command.js +3 -2
- package/dist/projects/status.js +2 -1
- package/dist/projects/yes-no.js +12 -0
- package/dist/renderer/abap-inline.js +18 -14
- package/dist/renderer/answer-trim.js +69 -0
- package/dist/renderer/banners.js +8 -8
- package/dist/renderer/brand-settled.js +19 -0
- package/dist/renderer/change-card.js +156 -0
- package/dist/renderer/checklist-format.js +130 -0
- package/dist/renderer/color-mode.js +45 -0
- package/dist/renderer/error-detail.js +104 -0
- package/dist/renderer/fence-state.js +46 -0
- package/dist/renderer/footer-line.js +100 -0
- package/dist/renderer/glyphs.js +25 -0
- package/dist/renderer/goodbye.js +38 -0
- package/dist/renderer/legacy-palette.js +41 -0
- package/dist/renderer/look.js +30 -0
- package/dist/renderer/markdown.js +299 -92
- package/dist/renderer/notice-log.js +72 -0
- package/dist/renderer/notice-shape.js +48 -0
- package/dist/renderer/notices.js +40 -8
- package/dist/renderer/panel-rows.js +16 -0
- package/dist/renderer/pipeline.js +66 -0
- package/dist/renderer/progress-chatter.js +23 -17
- package/dist/renderer/rendering-mode.js +34 -0
- package/dist/renderer/routing-line.js +14 -0
- package/dist/renderer/sanitize.js +12 -0
- package/dist/renderer/severity.js +2 -7
- package/dist/renderer/startup-lines.js +154 -0
- package/dist/renderer/status-footer.js +52 -34
- package/dist/renderer/steering-echo.js +41 -0
- package/dist/renderer/syntax.js +31 -12
- package/dist/renderer/tables.js +5 -1
- package/dist/renderer/theme.js +44 -0
- package/dist/renderer/thinking-heartbeat.js +33 -2
- package/dist/renderer/todo-block.js +10 -49
- package/dist/renderer/tool-labels.js +269 -0
- package/dist/renderer/tool-widget.js +245 -51
- package/dist/renderer/trace.js +42 -0
- package/dist/renderer/transcript-flow.js +132 -0
- package/dist/renderer/tty.js +34 -1
- package/dist/renderer/ui-width.js +55 -0
- package/dist/renderer/verify-chain.js +4 -2
- package/dist/renderer/widget-fallback.js +58 -65
- package/dist/repl/bracketed-paste.js +7 -1
- package/dist/repl/builtin-commands.js +19 -8
- package/dist/repl/credential-handover-gate.js +28 -0
- package/dist/repl/current-transport.js +13 -0
- package/dist/repl/early-line-buffer.js +5 -2
- package/dist/repl/file-picker.js +54 -12
- package/dist/repl/ink-stdin-guard.js +66 -0
- package/dist/repl/inquirer-guard.js +59 -16
- package/dist/repl/inquirer-theme.js +27 -33
- package/dist/repl/paste-marker.js +19 -0
- package/dist/repl/plan-turn-end.js +20 -0
- package/dist/repl/post-turn-status.js +43 -11
- package/dist/repl/reroute-turn.js +21 -0
- package/dist/repl/reset-tty-stdin.js +44 -0
- package/dist/repl/restore-guard.js +22 -0
- package/dist/repl/resume-standalone.js +63 -0
- package/dist/repl/rule8-detector.js +11 -3
- package/dist/repl/safety-confirm.js +161 -93
- package/dist/repl/session-spend-line.js +8 -4
- package/dist/repl/themed-prompts.js +19 -0
- package/dist/repl/ui-look-command.js +75 -0
- package/dist/repl/update-method-preview-hook.js +48 -5
- package/dist/repl.js +863 -339
- package/dist/rewind/cli.js +3 -2
- package/dist/rewind/format.js +14 -9
- package/dist/rewind/restore.js +11 -0
- package/dist/router/classifier.js +47 -4
- package/dist/router/intent-extractor.js +24 -8
- package/dist/router/piped-routing.js +23 -0
- package/dist/router/resume-routing.js +16 -0
- package/dist/router/routing-failure.js +57 -0
- package/dist/sap/first-run-choice.js +1 -1
- package/dist/sap/onboarding.js +3 -2
- package/dist/sap/standalone-onboarding.js +1 -1
- package/dist/sap/system-info.js +4 -2
- package/dist/sap/unreachable.js +28 -0
- package/dist/sap-errors/clean-error-text.js +182 -0
- package/dist/session/interrupt-reason.js +39 -0
- package/dist/session/recap.js +36 -24
- package/dist/session/repin-model.js +18 -0
- package/dist/session/resume.js +104 -36
- package/dist/session/store.js +84 -28
- package/dist/session/user-prompt.js +21 -0
- package/dist/skill-catalog.js +7 -0
- package/dist/skills/bundled-skills.js +76 -69
- package/dist/skills/preamble.js +75 -0
- package/dist/skills/source-manifest.js +11 -1
- package/dist/standards/standards-init.js +1 -1
- package/dist/test-helpers/answer-any.js +46 -0
- package/dist/tools/approval.js +27 -11
- package/dist/tools/ask-question.js +47 -5
- package/dist/tools/filesystem/file-read.js +11 -1
- package/dist/tools/filesystem/file-write.js +19 -2
- package/dist/tools/fiori/fe-extend.js +16 -1
- package/dist/tools/fiori/fe-scaffold.js +105 -3
- package/dist/tools/fiori/html-escapes.js +28 -0
- package/dist/tools/fiori/metadata/audit.js +605 -0
- package/dist/tools/fiori/metadata/references.js +104 -0
- package/dist/tools/fiori/metadata/types.js +8 -0
- package/dist/tools/fiori/preview/app-guard.js +215 -0
- package/dist/tools/fiori/preview/env.js +193 -0
- package/dist/tools/fiori/preview/readiness.js +127 -0
- package/dist/tools/fiori/preview/registry.js +412 -0
- package/dist/tools/fiori/preview/start.js +469 -0
- package/dist/tools/fiori/samples/loader.js +20 -5
- package/dist/tools/fiori/scaffold.js +18 -6
- package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
- package/dist/tools/fiori/smoke/app-driver.js +100 -0
- package/dist/tools/fiori/smoke/assertions.js +161 -24
- package/dist/tools/fiori/smoke/audit-columns.js +21 -0
- package/dist/tools/fiori/smoke/batch.js +273 -0
- package/dist/tools/fiori/smoke/draft-safety.js +241 -0
- package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
- package/dist/tools/fiori/smoke/driver.js +171 -4
- package/dist/tools/fiori/smoke/evidence.js +35 -0
- package/dist/tools/fiori/smoke/labels-codes.js +207 -0
- package/dist/tools/fiori/smoke/nav-error.js +26 -0
- package/dist/tools/fiori/smoke/run-smoke.js +155 -12
- package/dist/tools/fiori/tools.js +651 -7
- package/dist/tools/fiori/ui5-version.js +16 -0
- package/dist/tools/index.js +8 -0
- package/dist/tools/local-build.js +6 -0
- package/dist/tools/result-spill.js +238 -0
- package/dist/tools/sap-read.js +74 -15
- package/dist/tools/sap-write.js +122 -10
- package/dist/tools/shell/shell_exec.js +39 -12
- package/dist/tools/subagent/adt-serial.js +33 -0
- package/dist/tools/subagent/agent_run.js +2 -0
- package/dist/tools/subagent/read_agent.js +178 -0
- package/dist/tools/subagent/reader-prompt.js +48 -0
- package/dist/tools/syntax-state.js +67 -0
- package/dist/tools/todo.js +26 -19
- package/dist/tools/tool-loading.js +255 -0
- package/dist/tools/tool-output-read.js +117 -0
- package/dist/tools/transport-fit.js +462 -0
- package/dist/tools/transport-resolution.js +3 -1
- package/dist/tools/transport.js +39 -8
- package/dist/tools/verify.js +184 -8
- package/dist/ui/app.js +284 -146
- package/dist/ui/approval-modal.js +41 -15
- package/dist/ui/ask-answer-text.js +17 -0
- package/dist/ui/body.js +25 -6
- package/dist/ui/card-slot.js +98 -0
- package/dist/ui/checklist.js +9 -0
- package/dist/ui/coaching-picker-classic.js +8 -5
- package/dist/ui/command-palette.js +6 -4
- package/dist/ui/confirm-request.js +18 -0
- package/dist/ui/context-grid.js +2 -1
- package/dist/ui/file-palette.js +6 -4
- package/dist/ui/files-card-emitter.js +19 -0
- package/dist/ui/footer-line.js +16 -0
- package/dist/ui/footer.js +105 -104
- package/dist/ui/input-wrap.js +122 -0
- package/dist/ui/interrupt.js +103 -0
- package/dist/ui/key-burst.js +117 -0
- package/dist/ui/line-resolution.js +4 -3
- package/dist/ui/live-area.js +71 -0
- package/dist/ui/login-banner.js +54 -43
- package/dist/ui/rewind-panel.js +76 -24
- package/dist/ui/role-props.js +47 -0
- package/dist/ui/sap-state-store.js +2 -1
- package/dist/ui/session-timeline.js +49 -3
- package/dist/ui/skill-picker.js +5 -47
- package/dist/ui/text-input.js +128 -24
- package/dist/ui/todo-emitter.js +19 -0
- package/dist/ui/turn-status-emitter.js +65 -2
- package/dist/ui/turn-status.js +16 -19
- package/dist/ui/use-card-request.js +44 -0
- package/dist/ui/widgets/ask-card.js +521 -0
- package/dist/ui/widgets/bar-chart.js +11 -5
- package/dist/ui/widgets/coaching-picker.js +50 -55
- package/dist/ui/widgets/confirm-card.js +193 -0
- package/dist/ui/widgets/dep-graph.js +3 -2
- package/dist/ui/widgets/diff-viewer.js +6 -5
- package/dist/ui/widgets/files-card.js +97 -0
- package/dist/ui/widgets/question-card.js +2 -1
- package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
- package/dist/ui/widgets/shortcuts-sheet.js +25 -0
- package/dist/ui/widgets/stack-frames.js +2 -1
- package/dist/upgrade-check.js +39 -8
- package/package.json +12 -2
- package/test-harness/fe-draft/app.ts +188 -0
- package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
- package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
- package/test-harness/fe-draft/routes.ts +224 -0
- package/test-harness/fe-draft/routes.unit.test.ts +45 -0
- package/test-harness/fe-draft/server.ts +225 -0
- package/test-harness/fe-draft/variants.ts +196 -0
- package/vitest.smoke-e2e.config.ts +20 -0
package/dist/ui/skill-picker.js
CHANGED
|
@@ -1,50 +1,8 @@
|
|
|
1
|
-
import { select } from '@inquirer/prompts';
|
|
2
1
|
/**
|
|
3
|
-
* Sentinel value returned when the user cancels
|
|
4
|
-
*
|
|
5
|
-
*/
|
|
6
|
-
export const PICK_CANCELLED = Symbol('PICK_CANCELLED');
|
|
7
|
-
/**
|
|
8
|
-
* Arrow-key picker for the top-3 classifier candidates. Returns the chosen
|
|
9
|
-
* skill name. Each choice shows the skill, a trimmed reasoning line, and
|
|
10
|
-
* the confidence percentage.
|
|
2
|
+
* Sentinel value returned when the user cancels a skill picker (Esc / Ctrl+C
|
|
3
|
+
* or the Cancel entry). Callers re-prompt rather than dispatching.
|
|
11
4
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* - Pressing Ctrl+C inquirer raises ExitPromptError → we catch and treat as cancel
|
|
15
|
-
* (without Ctrl+C'ing the whole process)
|
|
5
|
+
* The old inquirer `pickSkill` that lived here had no callers and was removed
|
|
6
|
+
* in piece 2 (it rendered a confidence percentage, which §3 drops).
|
|
16
7
|
*/
|
|
17
|
-
export
|
|
18
|
-
const candidates = [result.skill, ...result.alternates].slice(0, 3);
|
|
19
|
-
const reasoning = (result.reasoning ?? '').slice(0, 60);
|
|
20
|
-
const confidencePct = Math.round(result.confidence * 100);
|
|
21
|
-
const choices = candidates.map((skill, idx) => {
|
|
22
|
-
// Top-1 carries the reasoning + confidence; alternates are simple names.
|
|
23
|
-
if (idx === 0) {
|
|
24
|
-
const suffix = reasoning
|
|
25
|
-
? ` — ${reasoning} (${confidencePct}%)`
|
|
26
|
-
: ` (${confidencePct}%)`;
|
|
27
|
-
return { name: `/${skill}${suffix}`, value: skill };
|
|
28
|
-
}
|
|
29
|
-
return { name: `/${skill}`, value: skill };
|
|
30
|
-
});
|
|
31
|
-
// Cancel entry — user can arrow-down past the candidates and pick this to
|
|
32
|
-
// go back to the REPL prompt and retype the request.
|
|
33
|
-
choices.push({ name: '↩ cancel (go back to prompt)', value: '__cancel__' });
|
|
34
|
-
try {
|
|
35
|
-
const chosen = await select({
|
|
36
|
-
message: 'Pick a skill — or ↩ to cancel:',
|
|
37
|
-
choices,
|
|
38
|
-
});
|
|
39
|
-
if (chosen === '__cancel__')
|
|
40
|
-
return PICK_CANCELLED;
|
|
41
|
-
return chosen;
|
|
42
|
-
}
|
|
43
|
-
catch (err) {
|
|
44
|
-
// inquirer throws ExitPromptError on Ctrl+C. Treat as cancel, not a crash.
|
|
45
|
-
const name = err?.name;
|
|
46
|
-
if (name === 'ExitPromptError')
|
|
47
|
-
return PICK_CANCELLED;
|
|
48
|
-
throw err;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
8
|
+
export const PICK_CANCELLED = Symbol('PICK_CANCELLED');
|
package/dist/ui/text-input.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
3
|
* PasteSafeTextInput — drop-in replacement for `ink-text-input` (D29,
|
|
4
4
|
* "chewed words" during seed-session paste; 2026-06-12).
|
|
@@ -23,12 +23,50 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
23
23
|
* Rendering, cursor drawing, placeholder, and mask behaviour are vendored
|
|
24
24
|
* verbatim from ink-text-input v6 (MIT, © Vadim Demedes) minus
|
|
25
25
|
* `highlightPastedText` (Footer never used it).
|
|
26
|
+
*
|
|
27
|
+
* Piece 2 (I1, spec §7) — `wrapWidth` switches rendering to the input box:
|
|
28
|
+
* the value is HARD-WRAPPED here (ui/input-wrap.ts: string-width cells,
|
|
29
|
+
* graphemes whole, tabs → 2 spaces) and shown as rows that grow to `maxLines`
|
|
30
|
+
* then scroll with the cursor row visible. ↑/↓ move between rows; the first /
|
|
31
|
+
* last row hand over to history (onHistoryUp / onHistoryDown). A paste of more
|
|
32
|
+
* than PASTE_FOLD_LINES lines becomes one atomic chip via onLargePaste.
|
|
33
|
+
* Without `wrapWidth` everything renders exactly as before (Ctrl+R search,
|
|
34
|
+
* question cards).
|
|
26
35
|
*/
|
|
27
36
|
import { useState, useEffect, useRef } from 'react';
|
|
28
|
-
import { Text, useInput } from 'ink';
|
|
37
|
+
import { Box, Text, useInput } from 'ink';
|
|
29
38
|
import chalk from 'chalk';
|
|
30
|
-
|
|
31
|
-
|
|
39
|
+
import stringWidth from 'string-width';
|
|
40
|
+
import { wrapInput, visibleRange, indexAt, pastedLineCount, INPUT_MAX_LINES, PASTE_FOLD_LINES, } from './input-wrap.js';
|
|
41
|
+
import { isNoColor } from '../renderer/color-mode.js';
|
|
42
|
+
import { paint } from '../renderer/theme.js';
|
|
43
|
+
import { applyKeyBurst, stripControlChars } from './key-burst.js';
|
|
44
|
+
/** An atomic chip ending exactly at the cursor — Backspace removes it whole. */
|
|
45
|
+
const CHIP_BEFORE_CURSOR = /\[(?:Image #\d+|Pasted \d+ lines(?: #\d+)?)\]$/;
|
|
46
|
+
const SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
47
|
+
/**
|
|
48
|
+
* Draw the fake cursor at cell `col` of a wrapped display row. After the last
|
|
49
|
+
* character it takes one cell. NO_COLOR has no inverse video: the end cursor
|
|
50
|
+
* shows as '_' and a mid-row cursor is not marked.
|
|
51
|
+
*/
|
|
52
|
+
export function drawCursor(line, col) {
|
|
53
|
+
let acc = 0;
|
|
54
|
+
let out = '';
|
|
55
|
+
let placed = false;
|
|
56
|
+
for (const { segment } of SEGMENTER.segment(line)) {
|
|
57
|
+
if (!placed && acc === col) {
|
|
58
|
+
out += isNoColor() ? segment : chalk.inverse(segment);
|
|
59
|
+
placed = true;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
out += segment;
|
|
63
|
+
}
|
|
64
|
+
acc += stringWidth(segment);
|
|
65
|
+
}
|
|
66
|
+
if (!placed)
|
|
67
|
+
out += isNoColor() ? '_' : chalk.inverse(' ');
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
32
70
|
/**
|
|
33
71
|
* Pure windowing core (exported for tests). Given the value length, the
|
|
34
72
|
* cursor position, the display budget (character cells) and whether the
|
|
@@ -50,7 +88,7 @@ export function computeDisplayWindow(valueLength, cursorOffset, budget, cursorVi
|
|
|
50
88
|
const end = Math.min(valueLength, start + budget);
|
|
51
89
|
return { start, end };
|
|
52
90
|
}
|
|
53
|
-
export function PasteSafeTextInput({ value: originalValue, placeholder = '', focus = true, mask, showCursor = true, onChange, onSubmit, onPasteLines, maxDisplayWidth, onPasteImage, }) {
|
|
91
|
+
export function PasteSafeTextInput({ value: originalValue, placeholder = '', focus = true, mask, showCursor = true, onChange, onSubmit, onPasteLines, maxDisplayWidth, onPasteImage, wrapWidth, maxLines, heldLines, prompt, onHistoryUp, onHistoryDown, onLargePaste, }) {
|
|
54
92
|
const [cursorOffset, setCursorOffset] = useState((originalValue || '').length);
|
|
55
93
|
// The paste-safety core: synchronous mirrors of value + cursor. The
|
|
56
94
|
// input handler reads/writes THESE, never the (possibly stale) props.
|
|
@@ -89,6 +127,10 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
|
|
|
89
127
|
setCursorOffset((previous) => previous > originalValue.length ? originalValue.length : previous);
|
|
90
128
|
}, [originalValue, focus, showCursor]);
|
|
91
129
|
const value = mask ? mask.repeat(originalValue.length) : originalValue;
|
|
130
|
+
const moveCursor = (i) => {
|
|
131
|
+
cursorRef.current = i;
|
|
132
|
+
setCursorOffset(i);
|
|
133
|
+
};
|
|
92
134
|
// Wave1 live-fix (Bug 1) — display-only horizontal window + control-char
|
|
93
135
|
// flattening. The mirrors, the props, and every handler above operate on
|
|
94
136
|
// the FULL value; only what's painted is windowed, so the row can never
|
|
@@ -124,11 +166,27 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
|
|
|
124
166
|
}
|
|
125
167
|
}
|
|
126
168
|
useInput((input, key) => {
|
|
127
|
-
if (key.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
(
|
|
169
|
+
if ((key.ctrl && input === 'c') || key.tab || (key.shift && key.tab))
|
|
170
|
+
return;
|
|
171
|
+
if (key.upArrow || key.downArrow) {
|
|
172
|
+
// Without wrapWidth: ignored, exactly as before (other consumers).
|
|
173
|
+
if (wrapWidth === undefined)
|
|
174
|
+
return;
|
|
175
|
+
// I1 — move between visual rows; the edges hand over to history.
|
|
176
|
+
const cur = valueRef.current;
|
|
177
|
+
const w = wrapInput(cur, cursorRef.current, wrapWidth);
|
|
178
|
+
if (key.upArrow) {
|
|
179
|
+
if (w.cursorLine > 0)
|
|
180
|
+
moveCursor(indexAt(cur, wrapWidth, w.cursorLine - 1, w.cursorCol));
|
|
181
|
+
else
|
|
182
|
+
onHistoryUp?.();
|
|
183
|
+
}
|
|
184
|
+
else if (w.cursorLine < w.lines.length - 1) {
|
|
185
|
+
moveCursor(indexAt(cur, wrapWidth, w.cursorLine + 1, w.cursorCol));
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
onHistoryDown?.();
|
|
189
|
+
}
|
|
132
190
|
return;
|
|
133
191
|
}
|
|
134
192
|
if (onPasteImage && input === 'v' && (key.ctrl || key.meta)) {
|
|
@@ -161,7 +219,18 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
|
|
|
161
219
|
// Task 7 — complete lines carved out of a newline-bearing paste
|
|
162
220
|
// chunk; reported to the parent AFTER the mirrors are updated.
|
|
163
221
|
let pasteLines = null;
|
|
164
|
-
|
|
222
|
+
// Fix round 3 (item 6) — several control keys coalesced into one chunk
|
|
223
|
+
// (held Backspace / arrows) match no single key; apply each in order.
|
|
224
|
+
// Pastes carrying newlines keep their own branch below.
|
|
225
|
+
const burst = !key.backspace && !key.delete && !key.leftArrow && !key.rightArrow
|
|
226
|
+
&& !input.includes('\n') && !input.includes('\r')
|
|
227
|
+
? applyKeyBurst(currentValue, cursorRef.current, input)
|
|
228
|
+
: null;
|
|
229
|
+
if (burst) {
|
|
230
|
+
nextValue = burst.value;
|
|
231
|
+
nextCursorOffset = burst.cursor;
|
|
232
|
+
}
|
|
233
|
+
else if (key.leftArrow) {
|
|
165
234
|
if (showCursor)
|
|
166
235
|
nextCursorOffset--;
|
|
167
236
|
}
|
|
@@ -170,7 +239,7 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
|
|
|
170
239
|
nextCursorOffset++;
|
|
171
240
|
}
|
|
172
241
|
else if (key.backspace || key.delete) {
|
|
173
|
-
const chip = onPasteImage
|
|
242
|
+
const chip = (onPasteImage || onLargePaste)
|
|
174
243
|
? CHIP_BEFORE_CURSOR.exec(currentValue.slice(0, cursorRef.current))
|
|
175
244
|
: null;
|
|
176
245
|
if (chip) {
|
|
@@ -197,16 +266,28 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
|
|
|
197
266
|
// compounds on the fragment — duplicate delivery of a completed
|
|
198
267
|
// line is structurally impossible, and every D29 mirror/echo
|
|
199
268
|
// mechanism below operates exactly as for a plain insert.
|
|
200
|
-
const cleanInput = input.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
269
|
+
const cleanInput = stripControlChars(input.replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
|
|
270
|
+
if (onLargePaste && pastedLineCount(cleanInput) > PASTE_FOLD_LINES) {
|
|
271
|
+
// I1 — a long paste folds to ONE atomic `[Pasted N lines]` chip at the
|
|
272
|
+
// cursor; the Footer keeps the text and expands it on submit. The
|
|
273
|
+
// mirror stays single-line, so the D29 invariants hold unchanged.
|
|
274
|
+
// The text is kept exactly as pasted (a trailing newline included), so
|
|
275
|
+
// what is typed after the chip still starts on a new line when sent.
|
|
276
|
+
const chip = onLargePaste(cleanInput);
|
|
277
|
+
nextValue = currentValue.slice(0, cursorRef.current) + chip + currentValue.slice(cursorRef.current);
|
|
278
|
+
nextCursorOffset = cursorRef.current + chip.length;
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
const combined = currentValue.slice(0, cursorRef.current) +
|
|
282
|
+
cleanInput +
|
|
283
|
+
currentValue.slice(cursorRef.current, currentValue.length);
|
|
284
|
+
const lastNl = combined.lastIndexOf('\n');
|
|
285
|
+
pasteLines = combined.slice(0, lastNl).split('\n');
|
|
286
|
+
nextValue = combined.slice(lastNl + 1);
|
|
287
|
+
// Cursor lands where the insertion ended, mapped into the fragment
|
|
288
|
+
// (0 when the paste ended on the newline itself).
|
|
289
|
+
nextCursorOffset = Math.max(0, cursorRef.current + cleanInput.length - (lastNl + 1));
|
|
290
|
+
}
|
|
210
291
|
}
|
|
211
292
|
else {
|
|
212
293
|
// 2026-09-17 — a single-line field (question form, question modal:
|
|
@@ -217,9 +298,9 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
|
|
|
217
298
|
// stray control characters. Join the lines with one space instead:
|
|
218
299
|
// the whole paste is visible and the answer is clean. Fields that do
|
|
219
300
|
// handle lines (the main prompt) never take this branch.
|
|
220
|
-
const insert = (input.includes('\n') || input.includes('\r'))
|
|
301
|
+
const insert = stripControlChars((input.includes('\n') || input.includes('\r'))
|
|
221
302
|
? input.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\n+/g, ' ')
|
|
222
|
-
: input;
|
|
303
|
+
: input);
|
|
223
304
|
nextValue =
|
|
224
305
|
currentValue.slice(0, cursorRef.current) +
|
|
225
306
|
insert +
|
|
@@ -244,6 +325,29 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
|
|
|
244
325
|
onChange(nextValue);
|
|
245
326
|
}
|
|
246
327
|
}, { isActive: focus });
|
|
328
|
+
if (wrapWidth !== undefined) {
|
|
329
|
+
// I1 — the input box body: held rows (dim) + the value's rows, windowed to
|
|
330
|
+
// maxLines with the cursor row visible. Rows already fit (wrapInput), and
|
|
331
|
+
// truncate-end guarantees no dynamic-frame row can soft-wrap.
|
|
332
|
+
const heldRows = (heldLines ?? []).flatMap((l) => wrapInput(l, -1, wrapWidth).lines);
|
|
333
|
+
const w = wrapInput(value, cursorRendered ? cursorOffset : -1, wrapWidth);
|
|
334
|
+
const all = [...heldRows, ...w.lines];
|
|
335
|
+
const cursorRow = cursorRendered ? heldRows.length + w.cursorLine : all.length - 1;
|
|
336
|
+
const { start, end } = visibleRange(all.length, cursorRow, maxLines ?? INPUT_MAX_LINES);
|
|
337
|
+
const lead = prompt ?? '';
|
|
338
|
+
const leadWidth = Math.max(1, stringWidth(lead));
|
|
339
|
+
const indent = ' '.repeat(stringWidth(lead));
|
|
340
|
+
return (_jsx(Box, { flexDirection: "column", children: all.slice(start, end).map((row, k) => {
|
|
341
|
+
const r = start + k;
|
|
342
|
+
const prefix = k > 0
|
|
343
|
+
? indent
|
|
344
|
+
: start === 0 ? chalk.bold(paint('sap', lead)) : paint('dim', '…'.padEnd(leadWidth));
|
|
345
|
+
const body = r < heldRows.length
|
|
346
|
+
? paint('dim', row)
|
|
347
|
+
: cursorRendered && r === cursorRow ? drawCursor(row, w.cursorCol) : row;
|
|
348
|
+
return _jsxs(Text, { wrap: "truncate-end", children: [prefix, body] }, r);
|
|
349
|
+
}) }));
|
|
350
|
+
}
|
|
247
351
|
return (_jsx(Text, { children: placeholder
|
|
248
352
|
? value.length > 0
|
|
249
353
|
? renderedValue
|
package/dist/ui/todo-emitter.js
CHANGED
|
@@ -23,3 +23,22 @@ import { EventEmitter } from 'node:events';
|
|
|
23
23
|
* <App> for stdout and corrupts the Ink frame.
|
|
24
24
|
*/
|
|
25
25
|
export const todoEmitter = new EventEmitter();
|
|
26
|
+
/*
|
|
27
|
+
* Piece 2 (spec §4) — second event: todoEmitter.emit('close') at turn end
|
|
28
|
+
* when the plan is finished; <App> hides the live checklist until the next
|
|
29
|
+
* 'update'. Emitted only by repl/plan-turn-end.ts.
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* "The plan changed this turn" — set by a TOP-LEVEL todo_set (ctx carries
|
|
33
|
+
* todoEmitter; subagent ctxs do not), consumed once by emitPlanTurnEnd so a
|
|
34
|
+
* turn that never touched the plan leaves no close line.
|
|
35
|
+
*/
|
|
36
|
+
let todoSetThisTurn = false;
|
|
37
|
+
export function markTodoSetThisTurn() {
|
|
38
|
+
todoSetThisTurn = true;
|
|
39
|
+
}
|
|
40
|
+
export function consumeTodoSetThisTurn() {
|
|
41
|
+
const v = todoSetThisTurn;
|
|
42
|
+
todoSetThisTurn = false;
|
|
43
|
+
return v;
|
|
44
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// the CC-style "always alive while working" indicator, B5 smoke
|
|
5
5
|
// feedback round 3: fresh-line heartbeats were not enough).
|
|
6
6
|
//
|
|
7
|
-
// Same singleton-emitter pattern as
|
|
7
|
+
// Same singleton-emitter pattern as approval-emitter / ask-question-emitter:
|
|
8
8
|
// the REPL marks turn start/stop around runTurn (try/finally so a
|
|
9
9
|
// thrown turn can never leave the row stuck), loop.ts narrates the
|
|
10
10
|
// current activity, and <TurnStatus /> renders it in the dynamic Ink
|
|
@@ -12,7 +12,7 @@
|
|
|
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, reserved: false, paused: false, label: '', startedAt: 0 };
|
|
15
|
+
state = { active: false, reserved: false, paused: false, routing: false, routingStartedAt: 0, label: '', startedAt: 0 };
|
|
16
16
|
/**
|
|
17
17
|
* BUG-3 (2026-07-05 live smoke) — the row vanished WHILE the model still
|
|
18
18
|
* worked. Root cause was the twin-turn overlap (BUG-2): turn 1 finished
|
|
@@ -25,6 +25,35 @@ class TurnStatusEmitter extends EventEmitter {
|
|
|
25
25
|
* of blanking the liveness row.
|
|
26
26
|
*/
|
|
27
27
|
depth = 0;
|
|
28
|
+
/**
|
|
29
|
+
* UI fix round (CLI 1.1.20) — the turn CLOCK pauses while the user is the
|
|
30
|
+
* one being waited on. Live: a question card waited ~7 min and the receipt
|
|
31
|
+
* read "420.2s". Every blocking prompt already pauses this emitter (card
|
|
32
|
+
* slot withFocusClaim, the loop's interactive-tool pause, the save hook),
|
|
33
|
+
* so the emitter keeps the paused time: `pausedTotal` is a process-wide
|
|
34
|
+
* monotonic sum of closed pause intervals, `pauseBegan` the open one.
|
|
35
|
+
* Receipts snapshot pausedMs() at turn start and subtract the growth.
|
|
36
|
+
*/
|
|
37
|
+
pausedTotal = 0;
|
|
38
|
+
pauseBegan = null;
|
|
39
|
+
/** pausedMs() when the current startedAt was set (the live row's base). */
|
|
40
|
+
pauseBase = 0;
|
|
41
|
+
closePause(now = Date.now()) {
|
|
42
|
+
if (this.pauseBegan === null)
|
|
43
|
+
return;
|
|
44
|
+
this.pausedTotal += Math.max(0, now - this.pauseBegan);
|
|
45
|
+
this.pauseBegan = null;
|
|
46
|
+
}
|
|
47
|
+
/** Total ms spent paused on user input so far (monotonic, includes an open pause). */
|
|
48
|
+
pausedMs(now = Date.now()) {
|
|
49
|
+
return this.pausedTotal + (this.pauseBegan === null ? 0 : Math.max(0, now - this.pauseBegan));
|
|
50
|
+
}
|
|
51
|
+
/** The live row's clock: time since the turn started, less time paused on the user. */
|
|
52
|
+
activeElapsedMs(now = Date.now()) {
|
|
53
|
+
if (!this.state.active)
|
|
54
|
+
return 0;
|
|
55
|
+
return Math.max(0, now - this.state.startedAt - (this.pausedMs(now) - this.pauseBase));
|
|
56
|
+
}
|
|
28
57
|
/**
|
|
29
58
|
* BUG-2b — mark a dispatch in-flight before the async pre-turn work runs.
|
|
30
59
|
* Invisible (no spinner); isBusy() reports it so the submit guard can
|
|
@@ -36,6 +65,20 @@ class TurnStatusEmitter extends EventEmitter {
|
|
|
36
65
|
this.state = { ...this.state, reserved: true };
|
|
37
66
|
this.emit('change', this.state);
|
|
38
67
|
}
|
|
68
|
+
/** Fix round 3 (item 8) — the classifier round-trip started. */
|
|
69
|
+
routingStart() {
|
|
70
|
+
if (this.state.routing)
|
|
71
|
+
return;
|
|
72
|
+
this.state = { ...this.state, routing: true, routingStartedAt: Date.now() };
|
|
73
|
+
this.emit('change', this.state);
|
|
74
|
+
}
|
|
75
|
+
/** Fix round 3 (item 8) — routing finished (any outcome). */
|
|
76
|
+
routingEnd() {
|
|
77
|
+
if (!this.state.routing)
|
|
78
|
+
return;
|
|
79
|
+
this.state = { ...this.state, routing: false, routingStartedAt: 0 };
|
|
80
|
+
this.emit('change', this.state);
|
|
81
|
+
}
|
|
39
82
|
/** BUG-2b — release the pre-turn reservation (dispatch lifecycle ended). */
|
|
40
83
|
release() {
|
|
41
84
|
if (!this.state.reserved)
|
|
@@ -48,11 +91,25 @@ class TurnStatusEmitter extends EventEmitter {
|
|
|
48
91
|
isBusy() {
|
|
49
92
|
return this.state.active || this.state.reserved;
|
|
50
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Counts top-level turns (depth 0 → 1). card-slot's focus claim keys its
|
|
96
|
+
* refcount on this so a claim left unsettled by an earlier turn (Ctrl+C
|
|
97
|
+
* mid-card) cannot suppress, or fake, a later turn's resume.
|
|
98
|
+
*/
|
|
99
|
+
turnCount = 0;
|
|
100
|
+
/** Identifies the current top-level turn; nested start() keeps it. */
|
|
101
|
+
turnGeneration() {
|
|
102
|
+
return this.turnCount;
|
|
103
|
+
}
|
|
51
104
|
start(label = 'thinking') {
|
|
52
105
|
// BUG-3 — nest instead of replace so an overlapping stop() can't blank
|
|
53
106
|
// the row (see the `depth` field doc). `...this.state` preserves the
|
|
54
107
|
// reserved flag set by reserve() at dispatch start.
|
|
108
|
+
if (this.depth === 0)
|
|
109
|
+
this.turnCount++;
|
|
55
110
|
this.depth++;
|
|
111
|
+
this.closePause();
|
|
112
|
+
this.pauseBase = this.pausedMs();
|
|
56
113
|
this.state = { ...this.state, active: true, paused: false, label, startedAt: Date.now() };
|
|
57
114
|
this.emit('change', this.state);
|
|
58
115
|
}
|
|
@@ -61,6 +118,7 @@ class TurnStatusEmitter extends EventEmitter {
|
|
|
61
118
|
activity(label) {
|
|
62
119
|
if (!this.state.active)
|
|
63
120
|
return;
|
|
121
|
+
this.closePause();
|
|
64
122
|
this.state = { ...this.state, label, paused: false };
|
|
65
123
|
this.emit('change', this.state);
|
|
66
124
|
}
|
|
@@ -68,6 +126,7 @@ class TurnStatusEmitter extends EventEmitter {
|
|
|
68
126
|
pause() {
|
|
69
127
|
if (!this.state.active || this.state.paused)
|
|
70
128
|
return;
|
|
129
|
+
this.pauseBegan = Date.now();
|
|
71
130
|
this.state = { ...this.state, paused: true };
|
|
72
131
|
this.emit('change', this.state);
|
|
73
132
|
}
|
|
@@ -75,6 +134,7 @@ class TurnStatusEmitter extends EventEmitter {
|
|
|
75
134
|
resume() {
|
|
76
135
|
if (!this.state.active || !this.state.paused)
|
|
77
136
|
return;
|
|
137
|
+
this.closePause();
|
|
78
138
|
this.state = { ...this.state, paused: false };
|
|
79
139
|
this.emit('change', this.state);
|
|
80
140
|
}
|
|
@@ -88,6 +148,9 @@ class TurnStatusEmitter extends EventEmitter {
|
|
|
88
148
|
this.depth--;
|
|
89
149
|
if (this.depth > 0)
|
|
90
150
|
return;
|
|
151
|
+
// A turn ended while a card waited (Ctrl+C / Esc on it): the wait was
|
|
152
|
+
// still the user's time, not the turn's.
|
|
153
|
+
this.closePause();
|
|
91
154
|
this.state = { ...this.state, active: false, paused: false, label: '', startedAt: 0 };
|
|
92
155
|
this.emit('change', this.state);
|
|
93
156
|
}
|
package/dist/ui/turn-status.js
CHANGED
|
@@ -1,33 +1,24 @@
|
|
|
1
|
-
import { jsxs as _jsxs, jsx as _jsx
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } 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';
|
|
5
|
+
import { roleProps } from './role-props.js';
|
|
5
6
|
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
6
7
|
const TICK_MS = 250;
|
|
7
|
-
const PEACH = '#FFCBA4';
|
|
8
8
|
function fmtElapsed(ms) {
|
|
9
|
-
|
|
9
|
+
// `now` is sampled at mount / on each tick, so the first frame after a turn
|
|
10
|
+
// starts can precede startedAt — never show "-1s".
|
|
11
|
+
const s = Math.max(0, Math.floor(ms / 1000));
|
|
10
12
|
if (s < 60)
|
|
11
13
|
return `${s}s`;
|
|
12
14
|
return `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
13
15
|
}
|
|
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
|
-
}
|
|
25
16
|
/**
|
|
26
17
|
* Persistent turn-status row (2026-06-07) — lives in the DYNAMIC Ink
|
|
27
18
|
* frame just above the Footer, so it repaints with the frame and
|
|
28
19
|
* vanishes the instant the turn ends. While a turn is in flight:
|
|
29
20
|
*
|
|
30
|
-
* ⠹
|
|
21
|
+
* ⠹ Reading ZCL_ORDER_API · 34s · ctrl+c to stop
|
|
31
22
|
*
|
|
32
23
|
* The label follows the loop's narration (thinking / tool name /
|
|
33
24
|
* writing response / save check). Its presence answers "is it still
|
|
@@ -38,7 +29,7 @@ export function truncateTodoStrip(text) {
|
|
|
38
29
|
* cursor #25 class of bug came from a raw-stdout spinner; this
|
|
39
30
|
* component cannot reproduce it). Guard: ink-cursor-invariant test.
|
|
40
31
|
*/
|
|
41
|
-
export function TurnStatus(
|
|
32
|
+
export function TurnStatus() {
|
|
42
33
|
const [state, setState] = useState(turnStatusEmitter.snapshot());
|
|
43
34
|
const [frame, setFrame] = useState(0);
|
|
44
35
|
const [now, setNow] = useState(Date.now());
|
|
@@ -56,19 +47,25 @@ export function TurnStatus({ inProgressTodo } = {}) {
|
|
|
56
47
|
// repaints the dynamic frame every 250ms and overdraws an inquirer
|
|
57
48
|
// approval picker / churns the Ink ask_question modal (stacked borders,
|
|
58
49
|
// lost keypress). The row resumes the instant the user answers.
|
|
59
|
-
|
|
50
|
+
const routingOnly = state.routing && !state.active;
|
|
51
|
+
if ((!state.active || state.paused) && !routingOnly)
|
|
60
52
|
return;
|
|
61
53
|
const interval = setInterval(() => {
|
|
62
54
|
setFrame((f) => (f + 1) % FRAMES.length);
|
|
63
55
|
setNow(Date.now());
|
|
64
56
|
}, TICK_MS);
|
|
65
57
|
return () => clearInterval(interval);
|
|
66
|
-
}, [state.active, state.paused]);
|
|
58
|
+
}, [state.active, state.paused, state.routing]);
|
|
59
|
+
// Fix round 3 (item 8) — routing (no turn yet): spinner + "routing…" +
|
|
60
|
+
// elapsed, no "ctrl+c to stop" (there is no turn to stop).
|
|
61
|
+
if (state.routing && !state.active) {
|
|
62
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { ...roleProps('sap'), children: [FRAMES[frame], " "] }), _jsx(Text, { children: "routing\u2026" }), _jsxs(Text, { ...roleProps('dim'), children: [" \u00B7 ", fmtElapsed(now - state.routingStartedAt)] })] }));
|
|
63
|
+
}
|
|
67
64
|
if (!state.active || state.paused)
|
|
68
65
|
return null;
|
|
69
66
|
// wrap="truncate-end" (wave1 live-fix, Bug 1): this row sits in the
|
|
70
67
|
// dynamic frame too — a long tool label must clip, never soft-wrap
|
|
71
68
|
// (wrapped dynamic-frame rows break Ink's erase accounting and leak
|
|
72
69
|
// stale rows into scrollback).
|
|
73
|
-
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, {
|
|
70
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { ...roleProps('sap'), children: [FRAMES[frame], " "] }), _jsx(Text, { children: state.label }), _jsxs(Text, { ...roleProps('dim'), children: [" \u00B7 ", fmtElapsed(turnStatusEmitter.activeElapsedMs(now)), " \u00B7 ctrl+c to stop"] })] }));
|
|
74
71
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// src/ui/use-card-request.ts
|
|
2
|
+
//
|
|
3
|
+
// The one "a request arrives → mount its card → resolve → clear" hook for the
|
|
4
|
+
// card slot (piece 2 §1.6). Same rules the ask/approval listeners in app.tsx
|
|
5
|
+
// learned the hard way:
|
|
6
|
+
// - a replacing request resolves the earlier one CANCELLED (no leaked promise);
|
|
7
|
+
// - `finish` clears only if the request still owns the slot;
|
|
8
|
+
// - unmount resolves an open request CANCELLED so a turn never wedges.
|
|
9
|
+
import { useEffect, useRef, useState } from 'react';
|
|
10
|
+
export function useCardRequest(subscribe, cancelled, onArrive) {
|
|
11
|
+
const [pending, setPending] = useState(null);
|
|
12
|
+
const [seq, setSeq] = useState(0);
|
|
13
|
+
const ref = useRef(null);
|
|
14
|
+
const arriveRef = useRef(onArrive);
|
|
15
|
+
arriveRef.current = onArrive;
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const unsubscribe = subscribe((req) => {
|
|
18
|
+
const prev = ref.current;
|
|
19
|
+
if (prev)
|
|
20
|
+
prev.resolve(cancelled);
|
|
21
|
+
ref.current = req;
|
|
22
|
+
setSeq((s) => s + 1);
|
|
23
|
+
arriveRef.current?.();
|
|
24
|
+
setPending(req);
|
|
25
|
+
});
|
|
26
|
+
return () => {
|
|
27
|
+
unsubscribe();
|
|
28
|
+
const open = ref.current;
|
|
29
|
+
ref.current = null;
|
|
30
|
+
if (open)
|
|
31
|
+
open.resolve(cancelled);
|
|
32
|
+
};
|
|
33
|
+
// Mount-once by design: subscribe/cancelled are stable for the App's life.
|
|
34
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
35
|
+
}, []);
|
|
36
|
+
const finish = (req, result) => {
|
|
37
|
+
req.resolve(result);
|
|
38
|
+
if (ref.current === req) {
|
|
39
|
+
ref.current = null;
|
|
40
|
+
setPending(null);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
return { pending, seq, finish };
|
|
44
|
+
}
|