kritya 0.8.25-beta → 0.8.27-beta
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 -2
- package/dist/agent/loop.js +9 -0
- package/dist/agent/skills.js +2 -1
- package/dist/agent/toolExecutor.js +10 -1
- package/dist/commands/registry.js +4 -0
- package/dist/config/config.js +2 -0
- package/dist/config/debug.js +3 -12
- package/dist/config/models.js +18 -0
- package/dist/index.js +25 -2
- package/dist/mcp/client.js +5 -4
- package/dist/net/urlSafety.js +3 -3
- package/dist/plugins/discover.js +2 -1
- package/dist/plugins/mcp.js +2 -1
- package/dist/repomap/repoMap.js +3 -3
- package/dist/stderr.js +47 -0
- package/dist/telemetry/metrics.js +11 -0
- package/dist/telemetry/tracer.js +2 -6
- package/dist/tools/glob.js +4 -4
- package/dist/tools/grep.js +4 -4
- package/dist/tools/ignore.js +2 -2
- package/dist/ui/AiDisclosurePrompt.js +1 -1
- package/dist/ui/ApiKeySetupPrompt.js +2 -2
- package/dist/ui/App.js +120 -89
- package/dist/ui/ElicitationPrompt.js +2 -2
- package/dist/ui/Markdown.js +5 -5
- package/dist/ui/McpTrustPrompt.js +1 -1
- package/dist/ui/PermissionPrompt.js +1 -1
- package/dist/ui/SelectList.js +1 -1
- package/dist/ui/Spinner.js +12 -8
- package/dist/ui/StatusLine.js +11 -9
- package/dist/ui/StreamViewport.js +29 -0
- package/dist/ui/TextInput.js +139 -0
- package/dist/ui/TranscriptItem.js +6 -5
- package/dist/ui/openInEditor.js +34 -0
- package/dist/ui/useAgent.js +80 -2
- package/dist/ui/viewport.js +17 -50
- package/dist/undo/undo.js +11 -0
- package/package.json +13 -14
package/dist/ui/App.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
-
import { Box, Static, Text, useApp, useInput,
|
|
4
|
-
import TextInput from "ink-text-input";
|
|
3
|
+
import { Box, Static, Text, useApp, useBoxMetrics, useInput, useStderr, useWindowSize, } from "ink";
|
|
5
4
|
import fs from "node:fs";
|
|
6
|
-
import
|
|
5
|
+
import { glob } from "tinyglobby";
|
|
7
6
|
import stringWidth from "string-width";
|
|
8
7
|
import { gitDiffStat } from "../git/git.js";
|
|
9
8
|
import { modelDisplaySlug } from "../config/models.js";
|
|
9
|
+
import { setStderrSink } from "../stderr.js";
|
|
10
|
+
import { openInEditor } from "./openInEditor.js";
|
|
10
11
|
import { SessionStore } from "../session/store.js";
|
|
11
12
|
import { defaultSandboxMode, sandboxAvailable } from "../shell/sandbox.js";
|
|
12
13
|
import { resolveSafe } from "../tools/common.js";
|
|
@@ -18,8 +19,10 @@ import { PermissionPrompt } from "./PermissionPrompt.js";
|
|
|
18
19
|
import { SelectList } from "./SelectList.js";
|
|
19
20
|
import { Spinner } from "./Spinner.js";
|
|
20
21
|
import { StatusLine } from "./StatusLine.js";
|
|
22
|
+
import { StreamViewport } from "./StreamViewport.js";
|
|
23
|
+
import { TextInput } from "./TextInput.js";
|
|
21
24
|
import { TranscriptItem } from "./TranscriptItem.js";
|
|
22
|
-
import {
|
|
25
|
+
import { terminalColumns, terminalRows } from "./viewport.js";
|
|
23
26
|
import { BUILTIN_COMMANDS, runCommand } from "../commands/registry.js";
|
|
24
27
|
import { mcpPrompts, mcpResources } from "../mcp/client.js";
|
|
25
28
|
import { useAgent } from "./useAgent.js";
|
|
@@ -27,12 +30,58 @@ const MENTION_RE = /(^|\s)@([^\s@]*)$/;
|
|
|
27
30
|
const MENTION_ALL_RE = /(?:^|\s)@([^\s@]+)/g;
|
|
28
31
|
const MAX_MENTION_CHARS = 8000;
|
|
29
32
|
const IMAGE_RE = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
|
30
|
-
// Erase screen + scrollback, then home the cursor (same sequence Ink itself
|
|
31
|
-
// uses for its own "content taller than the terminal" clear path).
|
|
32
|
-
const CLEAR_TERMINAL = "\x1b[2J\x1b[3J\x1b[H";
|
|
33
33
|
export function App({ agent, workspace, modelRef, providerRef, config, resumedCount, initialTasks, undoStack, uiBridge, resumeSessions, customCommands = [], mcpToolCount = 0, onSwitchClient, onRequestPermissionReady, onRequestElicitationReady, privacyMode, firstLaunch, }) {
|
|
34
|
-
const { exit } = useApp();
|
|
35
|
-
|
|
34
|
+
const { exit, suspendTerminal, waitUntilRenderFlush } = useApp();
|
|
35
|
+
/**
|
|
36
|
+
* Leave, but not until the screen has caught up.
|
|
37
|
+
*
|
|
38
|
+
* Ink's `exit()` tears the tree down in the same tick it is called, so a
|
|
39
|
+
* state update made in that same tick — the last line of a transcript, a
|
|
40
|
+
* "goodbye" item — is committed to a tree that is already being unmounted and
|
|
41
|
+
* never reaches the terminal. Verified: `setState(x); exit();` drops `x`,
|
|
42
|
+
* while deferring `exit()` by one macrotask draws it. `waitUntilRenderFlush()`
|
|
43
|
+
* is Ink's own barrier for exactly this — it yields so React can commit, then
|
|
44
|
+
* settles the throttled frame timer and waits for the write to drain.
|
|
45
|
+
*/
|
|
46
|
+
const exitAfterFlush = useCallback(async () => {
|
|
47
|
+
await waitUntilRenderFlush();
|
|
48
|
+
exit();
|
|
49
|
+
}, [exit, waitUntilRenderFlush]);
|
|
50
|
+
/**
|
|
51
|
+
* Hand warnings raised outside React a way through Ink.
|
|
52
|
+
*
|
|
53
|
+
* Agent loops, MCP clients and plugin loaders are plain module functions, so
|
|
54
|
+
* they cannot call useStderr() themselves and had been writing straight to
|
|
55
|
+
* process.stderr — which Ink knows nothing about, so the text landed in the
|
|
56
|
+
* middle of the frame and the next repaint drew against the wrong line count.
|
|
57
|
+
* Registering Ink's own writer for the lifetime of the UI puts them back
|
|
58
|
+
* inside the clear-write-repaint sandwich. Cleared on unmount so the fallback
|
|
59
|
+
* is raw stderr again for anything that outlives the UI. See src/stderr.ts.
|
|
60
|
+
*/
|
|
61
|
+
const { write: writeStderr } = useStderr();
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
setStderrSink(writeStderr);
|
|
64
|
+
return () => setStderrSink(undefined);
|
|
65
|
+
}, [writeStderr]);
|
|
66
|
+
// Ink 8 types `stdout` as a plain Node stream, so the terminal's dimensions
|
|
67
|
+
// come from useWindowSize() rather than stdout.columns / stdout.rows — and
|
|
68
|
+
// it drives the re-layout on resize by itself. Kritya used to wipe the screen
|
|
69
|
+
// and remount <Static> on every column change, because Ink erased its live
|
|
70
|
+
// region against a stale line count after the terminal reflowed already-
|
|
71
|
+
// printed lines. Ink 8 fixed that class of bug itself: it redraws the frame
|
|
72
|
+
// when the terminal loses rows, keeps the content above the frame when rows
|
|
73
|
+
// shrink, and preserves scrollback — which the old `\x1b[3J` clear threw
|
|
74
|
+
// away. So the workaround is gone and nothing here watches for resizes.
|
|
75
|
+
const windowSize = useWindowSize();
|
|
76
|
+
const columns = terminalColumns(windowSize);
|
|
77
|
+
const rows = terminalRows(windowSize);
|
|
78
|
+
// The transcript column's real width, measured rather than assumed. The
|
|
79
|
+
// wrapped renderers lay out to this, so if the chrome around them ever gains
|
|
80
|
+
// padding the prose still breaks at the right column. Falls back to the
|
|
81
|
+
// terminal width for the first frame, before layout has run.
|
|
82
|
+
const rootRef = useRef(null);
|
|
83
|
+
const { clientWidth } = useBoxMetrics(rootRef);
|
|
84
|
+
const contentWidth = clientWidth || columns;
|
|
36
85
|
const [input, setInput] = useState("");
|
|
37
86
|
const [inputKey, setInputKey] = useState(0);
|
|
38
87
|
const [steerInput, setSteerInput] = useState("");
|
|
@@ -42,90 +91,27 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
42
91
|
const [fileList, setFileList] = useState([]);
|
|
43
92
|
const [verbose, setVerbose] = useState(false);
|
|
44
93
|
const [elapsed, setElapsed] = useState(0);
|
|
45
|
-
const [staticKey, setStaticKey] = useState(0);
|
|
46
94
|
const inputHistory = useRef([]);
|
|
47
95
|
const histIndex = useRef(-1); // -1 means "current, not browsing history"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
* the prompt line, which the next Enter would have sent as a message.
|
|
55
|
-
*
|
|
56
|
-
* The two hooks can fire in either order, so both directions are covered:
|
|
57
|
-
* `ctrlChord` makes onChange drop the echoed letter when the shortcut ran
|
|
58
|
-
* first, and re-setting the value from `inputRef` undoes it when the input
|
|
59
|
-
* ran first. `inputRef` tracks committed state, so it always holds the value
|
|
60
|
-
* from before this keypress.
|
|
61
|
-
*/
|
|
62
|
-
const ctrlChord = useRef(null);
|
|
63
|
-
const inputRef = useRef("");
|
|
64
|
-
const steerInputRef = useRef("");
|
|
65
|
-
useEffect(() => {
|
|
66
|
-
inputRef.current = input;
|
|
67
|
-
}, [input]);
|
|
68
|
-
useEffect(() => {
|
|
69
|
-
steerInputRef.current = steerInput;
|
|
70
|
-
}, [steerInput]);
|
|
71
|
-
const noteCtrlChord = (letter) => {
|
|
72
|
-
ctrlChord.current = letter;
|
|
73
|
-
setInput(inputRef.current);
|
|
74
|
-
setSteerInput(steerInputRef.current);
|
|
75
|
-
setTimeout(() => {
|
|
76
|
-
ctrlChord.current = null;
|
|
77
|
-
}, 0);
|
|
78
|
-
};
|
|
79
|
-
/** The change a Ctrl chord echoed into the box, rather than a real keystroke. */
|
|
80
|
-
const isCtrlEcho = (prev, next) => ctrlChord.current !== null && next === prev + ctrlChord.current;
|
|
81
|
-
// Terminals that reflow their buffer on resize (e.g. VS Code's xterm.js)
|
|
82
|
-
// rewrap already-printed full-width lines (banner art, box borders) to the
|
|
83
|
-
// new column count on their own, before Ink's next redraw arrives. Ink then
|
|
84
|
-
// erases based on a stale line count from before that reflow, so it clears
|
|
85
|
-
// the wrong number of rows and leaves stale copies of boxes/banners behind
|
|
86
|
-
// — compounding on every resize. There's no way to reflow in place safely,
|
|
87
|
-
// so once the column count actually settles on a new value (ignoring
|
|
88
|
-
// height-only resizes, e.g. VS Code adding a split terminal), wipe the
|
|
89
|
-
// screen ourselves and remount <Static> so Ink reprints everything fresh
|
|
90
|
-
// onto a blank canvas. The manual clear is safe here specifically because
|
|
91
|
-
// the forced remount immediately triggers Ink's own static-flush path,
|
|
92
|
-
// which resyncs its internal line-count bookkeeping — it's only unsafe to
|
|
93
|
-
// write raw ANSI without following it with something that resets that
|
|
94
|
-
// bookkeeping.
|
|
95
|
-
const lastColumns = useRef(stdout?.columns);
|
|
96
|
-
useEffect(() => {
|
|
97
|
-
if (!stdout)
|
|
98
|
-
return;
|
|
99
|
-
let timer;
|
|
100
|
-
const onResize = () => {
|
|
101
|
-
clearTimeout(timer);
|
|
102
|
-
timer = setTimeout(() => {
|
|
103
|
-
if (stdout.columns === lastColumns.current)
|
|
104
|
-
return;
|
|
105
|
-
lastColumns.current = stdout.columns;
|
|
106
|
-
stdout.write(CLEAR_TERMINAL);
|
|
107
|
-
setStaticKey((k) => k + 1);
|
|
108
|
-
}, 150);
|
|
109
|
-
};
|
|
110
|
-
stdout.on("resize", onResize);
|
|
111
|
-
return () => {
|
|
112
|
-
clearTimeout(timer);
|
|
113
|
-
stdout.off("resize", onResize);
|
|
114
|
-
};
|
|
115
|
-
}, [stdout]);
|
|
96
|
+
// Ctrl-chord bookkeeping used to live here: Ink delivers a keypress to every
|
|
97
|
+
// `useInput` hook, and ink-text-input swallowed only Ctrl+C, so Ctrl+K and
|
|
98
|
+
// Ctrl+O ran their shortcut *and* typed a bare "k"/"o" into the prompt — which
|
|
99
|
+
// the next Enter would have sent as a message. Kritya's own TextInput drops
|
|
100
|
+
// every ctrl/meta chord before it reaches the buffer, so there is nothing left
|
|
101
|
+
// to undo and the workaround is gone.
|
|
116
102
|
const refreshFileList = useCallback(() => {
|
|
117
|
-
|
|
103
|
+
glob("**/*", {
|
|
118
104
|
cwd: workspace,
|
|
119
105
|
dot: false,
|
|
120
106
|
onlyFiles: true,
|
|
107
|
+
expandDirectories: false,
|
|
121
108
|
ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", ...loadIgnorePatterns(workspace)],
|
|
122
|
-
suppressErrors: true,
|
|
123
109
|
})
|
|
124
110
|
.then((files) => setFileList(files.sort().slice(0, 2000)))
|
|
125
111
|
.catch(() => { });
|
|
126
112
|
}, [workspace]);
|
|
127
113
|
useEffect(refreshFileList, [refreshFileList]);
|
|
128
|
-
const { items, addItem, phase, setPhase, stream, thinking, activity, setActivity, runningPhase, setRunningPhase, workflow, refreshWorkflow, permission, elicitation, inFlight, model, provider, servedModel, totalUsage, totalCost, tasks, setTasks, ctxPct, setCtxPct, tokenBudget, budgetPct, budgetUsed, budgetStopped, resetBudget, setBudgetLimit, branch, planMode, setPlanMode, dryRunMode, killed, killReason, engageKill, releaseKill, acceptEdits, setAcceptEdits, autoApprovedCount, cycleMode, onAcceptEditsConfirm, abortRef, setModelEverywhere, setProviderEverywhere, costReport, runAgent, runWebSearch, onPermissionDecision, onElicitationDecision, onResumeSelect, requestPermission, requestElicitation, persistenceWarningCount, } = useAgent({
|
|
114
|
+
const { items, addItem, phase, setPhase, stream, thinking, activity, setActivity, runningPhase, setRunningPhase, workflow, refreshWorkflow, permission, elicitation, inFlight, model, provider, servedModel, totalUsage, totalCost, tasks, setTasks, ctxPct, setCtxPct, tokenBudget, budgetPct, budgetUsed, budgetStopped, resetBudget, setBudgetLimit, branch, planMode, setPlanMode, dryRunMode, killed, killReason, engageKill, releaseKill, acceptEdits, setAcceptEdits, bypassMode, setBypassMode, autoApprovedCount, cycleMode, onAcceptEditsConfirm, onBypassModeConfirm, abortRef, setModelEverywhere, setProviderEverywhere, costReport, runAgent, runWebSearch, onPermissionDecision, onElicitationDecision, onResumeSelect, requestPermission, requestElicitation, persistenceWarningCount, } = useAgent({
|
|
129
115
|
agent,
|
|
130
116
|
workspace,
|
|
131
117
|
modelRef,
|
|
@@ -221,13 +207,37 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
221
207
|
setInputKey((k) => k + 1);
|
|
222
208
|
setFileIndex(0);
|
|
223
209
|
};
|
|
210
|
+
// Ctrl+E opens the file the agent touched most recently in the user's
|
|
211
|
+
// $EDITOR, suspending the UI while the editor owns the terminal. Ink
|
|
212
|
+
// restores everything and repaints from scratch on resume; a hand-edit is
|
|
213
|
+
// reported by the undo stack's watcher via onExternalEdit like any other
|
|
214
|
+
// outside edit. Guarded against double presses — suspending twice throws.
|
|
215
|
+
const editorBusy = useRef(false);
|
|
216
|
+
const openInEditorFlow = useCallback(async () => {
|
|
217
|
+
if (editorBusy.current)
|
|
218
|
+
return;
|
|
219
|
+
const target = undoStack.lastTouchedFile();
|
|
220
|
+
if (!target) {
|
|
221
|
+
addItem({
|
|
222
|
+
kind: "info",
|
|
223
|
+
text: "Ctrl+E: the agent hasn't touched any file yet this session.",
|
|
224
|
+
});
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
editorBusy.current = true;
|
|
228
|
+
try {
|
|
229
|
+
await openInEditor(suspendTerminal, target.absPath);
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
editorBusy.current = false;
|
|
233
|
+
}
|
|
234
|
+
}, [suspendTerminal, undoStack, addItem]);
|
|
224
235
|
useInput((_input, key) => {
|
|
225
236
|
// Ctrl+K is the kill switch, and it comes before every other binding and
|
|
226
237
|
// phase check on purpose: a panic button that only works from the idle
|
|
227
238
|
// input line is not a panic button. It fires mid-stream, mid-tool, and
|
|
228
239
|
// while a permission prompt is on screen.
|
|
229
240
|
if (key.ctrl && _input === "k") {
|
|
230
|
-
noteCtrlChord(_input);
|
|
231
241
|
if (killed)
|
|
232
242
|
return; // already stopped; /kill off is the way back
|
|
233
243
|
engageKill("Ctrl+K");
|
|
@@ -239,10 +249,20 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
239
249
|
}
|
|
240
250
|
// Ctrl+O toggles showing full tool output.
|
|
241
251
|
if (key.ctrl && _input === "o") {
|
|
242
|
-
noteCtrlChord(_input);
|
|
243
252
|
setVerbose((v) => !v);
|
|
244
253
|
return;
|
|
245
254
|
}
|
|
255
|
+
// Ctrl+E opens the most recently agent-touched file in $EDITOR. Only
|
|
256
|
+
// from the idle prompt — never mid-turn, so the editor can't race the
|
|
257
|
+
// agent over the same file.
|
|
258
|
+
if (key.ctrl && _input === "e") {
|
|
259
|
+
if (phase !== "input") {
|
|
260
|
+
setActivity("Ctrl+E works from the idle prompt — stop the current turn first.");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
void openInEditorFlow();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
246
266
|
// Shift+Tab cycles normal → accept-edits → plan → normal. Only from the
|
|
247
267
|
// plain input state — not mid-permission-prompt, mid-resume-picker, etc.
|
|
248
268
|
if (key.tab && key.shift && phase === "input") {
|
|
@@ -258,6 +278,15 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
258
278
|
onAcceptEditsConfirm(false);
|
|
259
279
|
return;
|
|
260
280
|
}
|
|
281
|
+
// First-time auto/bypass-mode confirmation: Yes proceeds, anything else cancels.
|
|
282
|
+
if (phase === "confirmBypassMode") {
|
|
283
|
+
const c = _input.toLowerCase();
|
|
284
|
+
if (c === "y" || key.return)
|
|
285
|
+
onBypassModeConfirm(true);
|
|
286
|
+
else if (c === "n" || key.escape)
|
|
287
|
+
onBypassModeConfirm(false);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
261
290
|
// History recall with ↑/↓ when no autocomplete popup is open.
|
|
262
291
|
if (phase === "input" && !suggestions.length && !fileSuggestions.length) {
|
|
263
292
|
const hist = inputHistory.current;
|
|
@@ -320,6 +349,8 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
320
349
|
planMode,
|
|
321
350
|
acceptEdits,
|
|
322
351
|
setAcceptEdits,
|
|
352
|
+
bypassMode,
|
|
353
|
+
setBypassMode,
|
|
323
354
|
tokenBudget,
|
|
324
355
|
budgetPct,
|
|
325
356
|
budgetUsed,
|
|
@@ -349,7 +380,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
349
380
|
costReport,
|
|
350
381
|
statusReport,
|
|
351
382
|
gitDiffStat,
|
|
352
|
-
exit,
|
|
383
|
+
exit: exitAfterFlush,
|
|
353
384
|
};
|
|
354
385
|
void runCommand(cmd, ctx);
|
|
355
386
|
};
|
|
@@ -444,7 +475,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
444
475
|
addItem({ kind: "info", text: `Attached ${images.length} image(s).` });
|
|
445
476
|
await runAgent(await expandMentions(text), images);
|
|
446
477
|
};
|
|
447
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item) => _jsx(TranscriptItem, { item: item, verbose: verbose,
|
|
478
|
+
return (_jsxs(Box, { ref: rootRef, flexDirection: "column", children: [_jsx(Static, { items: items, children: (item) => (_jsx(TranscriptItem, { item: item, verbose: verbose, contentWidth: contentWidth }, item.id)) }), firstLaunch && items.length <= 1 && phase === "input" && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: "Try asking:" }), _jsx(Text, { dimColor: true, children: " \u00B7 Explain this codebase" }), _jsx(Text, { dimColor: true, children: " \u00B7 Find and fix failing tests" }), _jsx(Text, { dimColor: true, children: " \u00B7 Review my uncommitted changes" }), _jsx(Text, { dimColor: true, children: " \u00B7 Add a new feature" }), _jsx(Text, { dimColor: true, children: " \u00B7 /help for commands" })] })), stream ? (_jsx(StreamViewport, { rows: rows, children: _jsx(Markdown, { text: stream, streaming: true, width: contentWidth }) })) : null, tasks.length > 0 && (_jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: "blue", paddingX: 1, width: Math.min(columns - 2, Math.max(...tasks.map((t) => stringWidth(t.text))) + 6), children: tasks.map((t, i) => (_jsxs(Text, { color: t.status === "done" ? "green" : t.status === "in_progress" ? "yellow" : undefined, dimColor: t.status === "pending", children: [t.status === "done" ? "☑" : t.status === "in_progress" ? "◐" : "☐", " ", t.text] }, i))) })), phase === "working" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Spinner, { label:
|
|
448
479
|
// The workflow prefix rides on every branch, not just "thinking":
|
|
449
480
|
// during a phase you want to know which phase a tool call belongs
|
|
450
481
|
// to just as much as which phase is being reasoned about.
|
|
@@ -457,7 +488,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
457
488
|
? activity
|
|
458
489
|
: thinking
|
|
459
490
|
? "thinking… (Esc to cancel)"
|
|
460
|
-
: "working… (Esc to cancel)") }), inFlight.length > 1 && (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: inFlight.map((t) => (_jsxs(Text, { dimColor: true, children: ["\u00B7 ", t.status ? `${t.summary} — ${t.status}` : t.summary] }, t.id))) })), _jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [_jsx(Text, { color: "yellow", children: "\u21B3 " }), _jsx(TextInput, { value: steerInput, onChange:
|
|
491
|
+
: "working… (Esc to cancel)") }), inFlight.length > 1 && (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: inFlight.map((t) => (_jsxs(Text, { dimColor: true, children: ["\u00B7 ", t.status ? `${t.summary} — ${t.status}` : t.summary] }, t.id))) })), _jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [_jsx(Text, { color: "yellow", children: "\u21B3 " }), _jsx(TextInput, { value: steerInput, onChange: setSteerInput, onSubmit: (value) => {
|
|
461
492
|
const text = value.trim();
|
|
462
493
|
setSteerInput("");
|
|
463
494
|
if (!text)
|
|
@@ -471,7 +502,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
471
502
|
}
|
|
472
503
|
addItem({ kind: "user", text: `${text} (queued)` });
|
|
473
504
|
void expandMentions(text).then((t) => agent.queueSteer(t));
|
|
474
|
-
}, placeholder: "steer the agent\u2026 (Enter to queue)" })] })] })), phase === "permission" && permission && (_jsx(PermissionPrompt, { toolName: permission.toolName, summary: permission.summary, diff: permission.diff, warning: permission.warning, onDecision: onPermissionDecision })), phase === "elicitation" && elicitation && (_jsx(ElicitationPrompt, { message: elicitation.message, fields: elicitation.fields, onDecision: onElicitationDecision })), phase === "confirmMode" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, color: "green", children: "Switch to accept-edits mode?" }), _jsx(Text, { children: "File writes and edits will auto-approve without asking." }), _jsx(Text, { dimColor: true, children: "Destructive shell commands (rm -rf, force-push, etc.) always still ask, in every mode." }), _jsx(Text, { dimColor: true, children: "Shift+Tab again moves to dry-run mode; once more back to normal." }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "green", children: "Yes (y)" }), " \u00B7 No (n/Esc)"] }) })] })), phase === "model" && (_jsx(ModelPicker, { current: model, customModels: config.customModels ?? [], onSelect: (id) => {
|
|
505
|
+
}, placeholder: "steer the agent\u2026 (Enter to queue)" })] })] })), phase === "permission" && permission && (_jsx(PermissionPrompt, { toolName: permission.toolName, summary: permission.summary, diff: permission.diff, warning: permission.warning, onDecision: onPermissionDecision })), phase === "elicitation" && elicitation && (_jsx(ElicitationPrompt, { message: elicitation.message, fields: elicitation.fields, onDecision: onElicitationDecision })), phase === "confirmMode" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, color: "green", children: "Switch to accept-edits mode?" }), _jsx(Text, { children: "File writes and edits will auto-approve without asking." }), _jsx(Text, { dimColor: true, children: "Destructive shell commands (rm -rf, force-push, etc.) always still ask, in every mode." }), _jsx(Text, { dimColor: true, children: "Shift+Tab again moves to dry-run mode; once more back to normal." }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "green", children: "Yes (y)" }), " \u00B7 No (n/Esc)"] }) })] })), phase === "confirmBypassMode" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 1, children: [_jsx(Text, { bold: true, color: "red", children: "Switch to auto mode? (sandbox-gated)" }), _jsx(Text, { children: "EVERY tool call will auto-approve, including destructive shell commands (rm -rf, force-push, etc.) \u2014 nothing will ask first." }), _jsx(Text, { dimColor: true, children: "The sandbox \u2014 not a human \u2014 is what contains any damage this mode causes." }), _jsx(Text, { dimColor: true, children: "Shift+Tab again goes back to normal." }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "red", children: "Yes (y)" }), " \u00B7 No (n/Esc)"] }) })] })), phase === "model" && (_jsx(ModelPicker, { current: model, customModels: config.customModels ?? [], onSelect: (id) => {
|
|
475
506
|
setPhase("input");
|
|
476
507
|
setModelEverywhere(id);
|
|
477
508
|
}, onCancel: () => setPhase("input") })), phase === "resume" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "magenta", children: ["Resume a session ", _jsx(Text, { dimColor: true, children: "(type to search \u00B7 Esc for a fresh one)" })] }), resumeFilter ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "filter: " }), resumeFilter] })) : null, _jsx(SelectList, { items: (resumeSessions ?? [])
|
|
@@ -482,7 +513,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
482
513
|
value: s.file,
|
|
483
514
|
hint: `${s.date} · ${s.count} msgs`,
|
|
484
515
|
})), onSelect: onResumeSelect, onCancel: () => onResumeSelect("") })] })), phase === "input" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { color: "green", children: "\u276F " }), _jsx(TextInput, { value: input, onChange: (v) => {
|
|
485
|
-
setInput(
|
|
516
|
+
setInput(v.replace(/\t/g, ""));
|
|
486
517
|
setCmdIndex(0);
|
|
487
518
|
setFileIndex(0);
|
|
488
519
|
}, onSubmit: handleSubmit }, inputKey)] }), suggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [(() => {
|
|
@@ -496,7 +527,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
496
527
|
lastCategory = c.category;
|
|
497
528
|
return (_jsxs(Fragment, { children: [showHeader && _jsx(Text, { dimColor: true, children: c.category ?? "Other" }), _jsxs(Text, { color: i === selectedCmd ? "cyan" : undefined, children: [i === selectedCmd ? "❯ " : " ", _jsx(Text, { bold: i === selectedCmd, children: c.name }), _jsxs(Text, { dimColor: true, children: [" \u2014 ", c.description] })] })] }, c.name));
|
|
498
529
|
});
|
|
499
|
-
})(), _jsx(Text, { dimColor: true, children: "\u2191\u2193 select \u00B7 Tab/Enter select \u00B7 Enter again to run" })] })), fileSuggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [fileSuggestions.map((f, i) => (_jsxs(Text, { color: i === selectedFile ? "cyan" : undefined, children: [i === selectedFile ? "❯ " : " ", f] }, f))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 select \u00B7 Tab/Enter attach file" })] }))] })), _jsx(Box, { children: _jsx(StatusLine, { killed: killed, killReason: killReason, dryRunMode: dryRunMode, planMode: planMode, acceptEdits: acceptEdits, autoApprovedCount: autoApprovedCount, provider: provider, model: servedModel && servedModel !== model
|
|
530
|
+
})(), _jsx(Text, { dimColor: true, children: "\u2191\u2193 select \u00B7 Tab/Enter select \u00B7 Enter again to run" })] })), fileSuggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [fileSuggestions.map((f, i) => (_jsxs(Text, { color: i === selectedFile ? "cyan" : undefined, children: [i === selectedFile ? "❯ " : " ", f] }, f))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 select \u00B7 Tab/Enter attach file" })] }))] })), _jsx(Box, { children: _jsx(StatusLine, { killed: killed, killReason: killReason, dryRunMode: dryRunMode, planMode: planMode, acceptEdits: acceptEdits, bypassMode: bypassMode, autoApprovedCount: autoApprovedCount, provider: provider, model: servedModel && servedModel !== model
|
|
500
531
|
? `${model}-${modelDisplaySlug(servedModel)}`
|
|
501
532
|
: model, workflow: workflow, branch: branch, budgetPct: budgetPct, budgetStopped: budgetStopped, totalCost: totalCost, sandboxActive: (config.sandboxExec ?? defaultSandboxMode()) !== "off" && sandboxAvailable(), persistenceWarningCount: persistenceWarningCount, privacyMode: privacyMode }) })] }));
|
|
502
533
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState } from "react";
|
|
3
3
|
import { Box, Text, useInput } from "ink";
|
|
4
|
-
import TextInput from "
|
|
4
|
+
import { TextInput } from "./TextInput.js";
|
|
5
5
|
import { SelectList } from "./SelectList.js";
|
|
6
6
|
/**
|
|
7
7
|
* One field at a time, same reasoning as McpTrustPrompt's one-server-at-a-time
|
package/dist/ui/Markdown.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import React from "react";
|
|
3
|
-
import { Box, Text,
|
|
3
|
+
import { Box, Text, useWindowSize } from "ink";
|
|
4
4
|
import stringWidth from "string-width";
|
|
5
5
|
import { tokenizeLine } from "./highlight.js";
|
|
6
6
|
import { parseInline, tokensWidth, wrapInline } from "./inline.js";
|
|
@@ -37,9 +37,9 @@ function Marked({ text, width, marker, indent, ...style }) {
|
|
|
37
37
|
* bullets, quotes, rules, tables, and inline emphasis. Deliberately
|
|
38
38
|
* lightweight — not a full implementation.
|
|
39
39
|
*/
|
|
40
|
-
export function Markdown({ text, streaming = false }) {
|
|
41
|
-
const
|
|
42
|
-
const width = Math.max(20, terminalColumns(
|
|
40
|
+
export function Markdown({ text, streaming = false, width: measuredWidth, }) {
|
|
41
|
+
const windowSize = useWindowSize();
|
|
42
|
+
const width = Math.max(20, measuredWidth ?? terminalColumns(windowSize) - 2);
|
|
43
43
|
const blocks = React.useMemo(() => renderBlocks(text, width, streaming), [text, width, streaming]);
|
|
44
44
|
return _jsx(Box, { flexDirection: "column", children: blocks });
|
|
45
45
|
}
|
package/dist/ui/SelectList.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState } from "react";
|
|
3
3
|
import { Box, Text, useInput } from "ink";
|
|
4
4
|
export function SelectList({ items, onSelect, onCancel, }) {
|
package/dist/ui/Spinner.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
3
|
-
import { Text } from "ink";
|
|
2
|
+
import { Text, useAnimation } from "ink";
|
|
4
3
|
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
4
|
+
/** Milliseconds between frames — the spinner's twelve-frames-a-second beat. */
|
|
5
|
+
const FRAME_INTERVAL_MS = 80;
|
|
6
|
+
/**
|
|
7
|
+
* The spinner that turns beside the working… label. Ink drives this from one
|
|
8
|
+
* animation timer shared by the whole app — no private `setInterval` to own or
|
|
9
|
+
* clean up, and the beat comes from elapsed time, so a busy machine skips a
|
|
10
|
+
* frame instead of drifting late.
|
|
11
|
+
*/
|
|
5
12
|
export function Spinner({ label }) {
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
return () => clearInterval(timer);
|
|
10
|
-
}, []);
|
|
11
|
-
return (_jsxs(Text, { color: "yellow", children: [FRAMES[frame], " ", _jsx(Text, { dimColor: true, children: label })] }));
|
|
13
|
+
const { frame } = useAnimation({ interval: FRAME_INTERVAL_MS });
|
|
14
|
+
// `frame` counts up forever; the glyph list is what wraps it back around.
|
|
15
|
+
return (_jsxs(Text, { color: "yellow", children: [FRAMES[frame % FRAMES.length], " ", _jsx(Text, { dimColor: true, children: label })] }));
|
|
12
16
|
}
|
package/dist/ui/StatusLine.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Text } from "ink";
|
|
3
3
|
import { displayModelId } from "../config/models.js";
|
|
4
4
|
/**
|
|
@@ -8,12 +8,14 @@ import { displayModelId } from "../config/models.js";
|
|
|
8
8
|
* moved to the on-demand `/status` command — see CommandContext.statusReport
|
|
9
9
|
* in src/commands/registry.ts.
|
|
10
10
|
*/
|
|
11
|
-
export function StatusLine({ killed, killReason, dryRunMode, planMode, acceptEdits, autoApprovedCount, provider, model, workflow, branch, budgetPct, budgetStopped, totalCost, sandboxActive, persistenceWarningCount, privacyMode, }) {
|
|
12
|
-
return (_jsxs(Text, { dimColor: true, children: [killed ? (_jsxs(Text, { bold: true, color: "red", children: ["\u26D4 KILLED", killReason ? ` (${killReason})` : "", " \u00B7", " "] })) : (""), _jsxs(Text, { color: dryRunMode || planMode ? "cyan" : acceptEdits ? "green" : undefined, children: ["mode:", " ",
|
|
13
|
-
?
|
|
14
|
-
:
|
|
15
|
-
? "
|
|
16
|
-
:
|
|
17
|
-
?
|
|
18
|
-
:
|
|
11
|
+
export function StatusLine({ killed, killReason, dryRunMode, planMode, acceptEdits, bypassMode, autoApprovedCount, provider, model, workflow, branch, budgetPct, budgetStopped, totalCost, sandboxActive, persistenceWarningCount, privacyMode, }) {
|
|
12
|
+
return (_jsxs(Text, { dimColor: true, children: [killed ? (_jsxs(Text, { bold: true, color: "red", children: ["\u26D4 KILLED", killReason ? ` (${killReason})` : "", " \u00B7", " "] })) : (""), _jsxs(Text, { color: bypassMode ? "red" : dryRunMode || planMode ? "cyan" : acceptEdits ? "green" : undefined, children: ["mode:", " ", bypassMode
|
|
13
|
+
? `auto (${autoApprovedCount} auto)`
|
|
14
|
+
: dryRunMode
|
|
15
|
+
? "dry-run"
|
|
16
|
+
: planMode
|
|
17
|
+
? "plan"
|
|
18
|
+
: acceptEdits
|
|
19
|
+
? `accept-edits (${autoApprovedCount} auto)`
|
|
20
|
+
: "default", " · "] }), displayModelId(provider, model), _jsxs(Text, { color: sandboxActive ? "green" : "red", children: [" ", "\u00B7 ", sandboxActive ? "🔒 sandbox:active" : "🔓 sandbox:inactive"] }), workflow ? (_jsxs(Text, { color: "magenta", children: [" ", "\u00B7 \u2691 ", workflow.name, ":", workflow.phase] })) : (""), branch ? ` · ⎇ ${branch}` : "", budgetStopped || budgetPct >= 80 ? (_jsxs(Text, { color: budgetStopped ? "red" : "yellow", children: [" \u00B7 budget ", budgetPct, "%"] })) : (""), totalCost > 0 ? ` · $${totalCost.toFixed(4)}` : "", persistenceWarningCount > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 \u26A0 persistence warnings: ", persistenceWarningCount] })) : (""), privacyMode ? _jsx(Text, { color: "cyan", children: " \u00B7 privacy:on" }) : "", " · /diff review · /undo revert · Ctrl+K stop · /status for details"] }));
|
|
19
21
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box } from "ink";
|
|
3
|
+
import { streamRows } from "./viewport.js";
|
|
4
|
+
/**
|
|
5
|
+
* Holds a streaming answer to the rows left under the live region, scrolling
|
|
6
|
+
* to its tail as it grows.
|
|
7
|
+
*
|
|
8
|
+
* Ink erases its live region by rewinding a line count, which can only reach
|
|
9
|
+
* what is still on screen. Let the streaming text grow past the terminal's
|
|
10
|
+
* height and the erase silently comes up short, stranding a partial copy in
|
|
11
|
+
* the scrollback — then the finished message prints below it and the answer
|
|
12
|
+
* appears twice. Capping the live region keeps the erase exact; the whole
|
|
13
|
+
* answer is printed once when the turn ends.
|
|
14
|
+
*
|
|
15
|
+
* This used to be done by hand: word-wrap every line to estimate the rows it
|
|
16
|
+
* would render at, then slice off the tail that didn't fit. Ink 8 renders
|
|
17
|
+
* `maxHeight` and `overflow="hidden"` natively, so the box does the capping.
|
|
18
|
+
*
|
|
19
|
+
* `justifyContent="flex-end"` is what pins the view to the tail: a column
|
|
20
|
+
* whose content overflows a capped height lines up against its end, keeping
|
|
21
|
+
* the newest text visible. It costs nothing while the answer still fits — the
|
|
22
|
+
* box is then only as tall as its content and the text grows downward as you
|
|
23
|
+
* would expect. `flexShrink={0}` on the content is what makes that work: Yoga
|
|
24
|
+
* would otherwise shrink the child back down to the cap, leaving the top on
|
|
25
|
+
* screen and the tail cut off.
|
|
26
|
+
*/
|
|
27
|
+
export function StreamViewport({ rows, children }) {
|
|
28
|
+
return (_jsx(Box, { flexDirection: "column", maxHeight: streamRows(rows), overflow: "hidden", justifyContent: "flex-end", marginBottom: 1, children: _jsx(Box, { flexDirection: "column", flexShrink: 0, children: children }) }));
|
|
29
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
import { Box, Text, measureElement, useCursor, useInput, usePaste } from "ink";
|
|
4
|
+
import stringWidth from "string-width";
|
|
5
|
+
import wrapAnsi from "wrap-ansi";
|
|
6
|
+
/**
|
|
7
|
+
* Where the caret lands in already-rendered text: the row and column the
|
|
8
|
+
* terminal cursor has to sit on once the text wraps.
|
|
9
|
+
*
|
|
10
|
+
* Wrapped with the very same call ink makes when it lays the text out
|
|
11
|
+
* (`wrapText` → `wrapAnsi(text, width, { trim: false, hard: true })`), so the
|
|
12
|
+
* two can't disagree about where a line breaks. Re-deriving the break point by
|
|
13
|
+
* dividing the column count would: `wrapAnsi` leaves the space that ends a
|
|
14
|
+
* wrapped line on that line, so a greedy wrapper lands the next line one column
|
|
15
|
+
* to the left and the caret drifts a cell per wrap.
|
|
16
|
+
*
|
|
17
|
+
* One deliberate approximation: a line that fills the width exactly reports
|
|
18
|
+
* `col === width`, i.e. the column just past its last character. That is where
|
|
19
|
+
* the terminal's own pending-wrap cursor sits, and the alternative — jumping to
|
|
20
|
+
* column 0 of the following row — is just as defensible and just as wrong the
|
|
21
|
+
* other half of the time.
|
|
22
|
+
*/
|
|
23
|
+
export function caretAt(before, width) {
|
|
24
|
+
const lines = wrapAnsi(before, Math.max(1, width), { trim: false, hard: true }).split("\n");
|
|
25
|
+
const last = lines[lines.length - 1] ?? "";
|
|
26
|
+
return { row: lines.length - 1, col: stringWidth(last) };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The prompt's text field.
|
|
30
|
+
*
|
|
31
|
+
* Hand-rolled rather than borrowed from ink-text-input, for two reasons that
|
|
32
|
+
* both come down to ink 8 finally exposing the terminal cursor. `useCursor`
|
|
33
|
+
* puts the *real* cursor at the caret, which is what an IME needs to place its
|
|
34
|
+
* composition popup — ink-text-input fakes the cursor with a reverse-video
|
|
35
|
+
* character, so typing Chinese, Japanese or Korean puts the popup in the wrong
|
|
36
|
+
* place. And `usePaste` turns on bracketed paste, so a pasted block arrives as
|
|
37
|
+
* one marked string we can normalize, instead of a run of raw keystrokes whose
|
|
38
|
+
* carriage returns survive into the prompt and into the message sent to the
|
|
39
|
+
* model.
|
|
40
|
+
*/
|
|
41
|
+
export function TextInput({ value, onChange, onSubmit, placeholder, mask, isActive = true, }) {
|
|
42
|
+
const [cursor, setCursorOffset] = useState(value.length);
|
|
43
|
+
const [origin, setOrigin] = useState(null);
|
|
44
|
+
const boxRef = useRef(null);
|
|
45
|
+
const { setCursorPosition } = useCursor();
|
|
46
|
+
// The caller owns the value, so it can move under us — cleared on submit,
|
|
47
|
+
// replaced by history recall. Clamping during render rather than in an effect
|
|
48
|
+
// keeps the caret correct in the same pass.
|
|
49
|
+
const offset = Math.min(cursor, value.length);
|
|
50
|
+
const display = mask ? mask.repeat(value.length) : value;
|
|
51
|
+
const beforeCaret = mask ? mask.repeat(offset) : display.slice(0, offset);
|
|
52
|
+
// Re-measure after every commit; setOrigin bails out when nothing moved, so
|
|
53
|
+
// a steady prompt re-renders zero extra times. Ink computes layout in
|
|
54
|
+
// resetAfterCommit, which runs *before* layout effects, so the first pass
|
|
55
|
+
// already sees a real layout. The zero check is belt and braces: a Box that
|
|
56
|
+
// genuinely has no room yet should leave the cursor hidden rather than pin it
|
|
57
|
+
// to the terminal origin.
|
|
58
|
+
useLayoutEffect(() => {
|
|
59
|
+
const node = boxRef.current;
|
|
60
|
+
if (!node)
|
|
61
|
+
return;
|
|
62
|
+
const { x, y, clientWidth } = measureElement(node);
|
|
63
|
+
if (clientWidth === 0)
|
|
64
|
+
return;
|
|
65
|
+
setOrigin((prev) => prev && prev.x === x && prev.y === y && prev.width === clientWidth
|
|
66
|
+
? prev
|
|
67
|
+
: { x, y, width: clientWidth });
|
|
68
|
+
});
|
|
69
|
+
// Publishing the caret has to happen during render. useCursor() forwards it
|
|
70
|
+
// from an insertion effect, which runs *before* layout effects, so a position
|
|
71
|
+
// set from an effect would always reach the terminal one render late — and a
|
|
72
|
+
// cursor that trails the caret by a keystroke is worse than none for an IME.
|
|
73
|
+
if (isActive && origin) {
|
|
74
|
+
const { row, col } = caretAt(beforeCaret, Math.max(1, origin.width));
|
|
75
|
+
setCursorPosition({ x: origin.x + col, y: origin.y + row });
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
setCursorPosition(undefined);
|
|
79
|
+
}
|
|
80
|
+
useInput((input, key) => {
|
|
81
|
+
// Ctrl/meta chords are the app's shortcuts (Ctrl+K kill switch, Ctrl+O
|
|
82
|
+
// verbose), never text. Swallowing them here is what stops them being
|
|
83
|
+
// typed into the box as a bare letter.
|
|
84
|
+
if (key.ctrl || key.meta)
|
|
85
|
+
return;
|
|
86
|
+
// Keys the surrounding app owns: up/down drive suggestions and history,
|
|
87
|
+
// Tab completes, Escape cancels.
|
|
88
|
+
if (key.upArrow || key.downArrow || key.tab || key.escape)
|
|
89
|
+
return;
|
|
90
|
+
if (key.return) {
|
|
91
|
+
onSubmit?.(value);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (key.leftArrow) {
|
|
95
|
+
setCursorOffset(Math.max(0, offset - 1));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (key.rightArrow) {
|
|
99
|
+
setCursorOffset(Math.min(value.length, offset + 1));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (key.home) {
|
|
103
|
+
setCursorOffset(0);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (key.end) {
|
|
107
|
+
setCursorOffset(value.length);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// Ink reports these separately — backspace is DEL, forward-delete is
|
|
111
|
+
// CSI 3~ — so they delete on opposite sides of the caret.
|
|
112
|
+
if (key.backspace) {
|
|
113
|
+
if (offset === 0)
|
|
114
|
+
return;
|
|
115
|
+
onChange(value.slice(0, offset - 1) + value.slice(offset));
|
|
116
|
+
setCursorOffset(offset - 1);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (key.delete) {
|
|
120
|
+
if (offset >= value.length)
|
|
121
|
+
return;
|
|
122
|
+
onChange(value.slice(0, offset) + value.slice(offset + 1));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (!input)
|
|
126
|
+
return;
|
|
127
|
+
// Bracketed paste normally routes to usePaste below. A terminal without
|
|
128
|
+
// it still delivers the block here, so normalize on both paths.
|
|
129
|
+
const inserted = input.replace(/\r\n?/g, "\n");
|
|
130
|
+
onChange(value.slice(0, offset) + inserted + value.slice(offset));
|
|
131
|
+
setCursorOffset(offset + inserted.length);
|
|
132
|
+
}, { isActive });
|
|
133
|
+
usePaste((text) => {
|
|
134
|
+
const inserted = text.replace(/\r\n?/g, "\n");
|
|
135
|
+
onChange(value.slice(0, offset) + inserted + value.slice(offset));
|
|
136
|
+
setCursorOffset(offset + inserted.length);
|
|
137
|
+
}, { isActive });
|
|
138
|
+
return (_jsx(Box, { ref: boxRef, flexGrow: 1, children: value.length === 0 && placeholder ? (_jsx(Text, { dimColor: true, children: placeholder })) : (_jsx(Text, { children: display })) }));
|
|
139
|
+
}
|