kritya 0.8.26-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/skills.js +2 -1
- 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/ApiKeySetupPrompt.js +2 -2
- package/dist/ui/App.js +106 -86
- 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 +1 -1
- package/dist/ui/StreamViewport.js +29 -0
- package/dist/ui/TextInput.js +139 -0
- package/dist/ui/TranscriptItem.js +5 -4
- package/dist/ui/openInEditor.js +34 -0
- 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,84 +91,21 @@ 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(() => { });
|
|
@@ -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") {
|
|
@@ -360,7 +380,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
360
380
|
costReport,
|
|
361
381
|
statusReport,
|
|
362
382
|
gitDiffStat,
|
|
363
|
-
exit,
|
|
383
|
+
exit: exitAfterFlush,
|
|
364
384
|
};
|
|
365
385
|
void runCommand(cmd, ctx);
|
|
366
386
|
};
|
|
@@ -455,7 +475,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
455
475
|
addItem({ kind: "info", text: `Attached ${images.length} image(s).` });
|
|
456
476
|
await runAgent(await expandMentions(text), images);
|
|
457
477
|
};
|
|
458
|
-
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:
|
|
459
479
|
// The workflow prefix rides on every branch, not just "thinking":
|
|
460
480
|
// during a phase you want to know which phase a tool call belongs
|
|
461
481
|
// to just as much as which phase is being reasoned about.
|
|
@@ -468,7 +488,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
468
488
|
? activity
|
|
469
489
|
: thinking
|
|
470
490
|
? "thinking… (Esc to cancel)"
|
|
471
|
-
: "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) => {
|
|
472
492
|
const text = value.trim();
|
|
473
493
|
setSteerInput("");
|
|
474
494
|
if (!text)
|
|
@@ -493,7 +513,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
493
513
|
value: s.file,
|
|
494
514
|
hint: `${s.date} · ${s.count} msgs`,
|
|
495
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) => {
|
|
496
|
-
setInput(
|
|
516
|
+
setInput(v.replace(/\t/g, ""));
|
|
497
517
|
setCmdIndex(0);
|
|
498
518
|
setFileIndex(0);
|
|
499
519
|
}, onSubmit: handleSubmit }, inputKey)] }), suggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [(() => {
|
|
@@ -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
|
@@ -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
|
+
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
import { Banner } from "./Banner.js";
|
|
4
4
|
import { Markdown } from "./Markdown.js";
|
|
5
5
|
import { toolOutputPreview } from "./toolOutputPreview.js";
|
|
6
|
-
|
|
6
|
+
/** Indent tool output is printed under (see the ` ` prefix below). */
|
|
7
|
+
const TOOL_INDENT = 4;
|
|
7
8
|
/** Renders one line of the transcript — a user message, assistant reply, tool call, info line, or banner. */
|
|
8
|
-
export function TranscriptItem({ item, verbose,
|
|
9
|
-
return (_jsxs(Box, { marginBottom: item.kind === "tool" ? 0 : 1, flexDirection: "column", children: [item.kind === "user" && (_jsxs(Text, { children: [_jsxs(Text, { bold: true, color: "green", children: ["\u276F", " "] }), item.text] })), item.kind === "assistant" && _jsx(Markdown, { text: item.text }), item.kind === "tool" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [item.error ? _jsx(Text, { color: "red", children: "\u2717" }) : _jsx(Text, { color: "green", children: "\u2713" }), " ", item.summary, item.resultSummary && !verbose ? ` — ${item.resultSummary}` : ""] }), item.output && item.output.trim() && (item.resultSummary === undefined || verbose) && (_jsx(Text, { dimColor: true, children: toolOutputPreview(item.output, verbose, Math.max(20,
|
|
9
|
+
export function TranscriptItem({ item, verbose, contentWidth }) {
|
|
10
|
+
return (_jsxs(Box, { marginBottom: item.kind === "tool" ? 0 : 1, flexDirection: "column", children: [item.kind === "user" && (_jsxs(Text, { children: [_jsxs(Text, { bold: true, color: "green", children: ["\u276F", " "] }), item.text] })), item.kind === "assistant" && _jsx(Markdown, { text: item.text, width: contentWidth }), item.kind === "tool" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [item.error ? _jsx(Text, { color: "red", children: "\u2717" }) : _jsx(Text, { color: "green", children: "\u2713" }), " ", item.summary, item.resultSummary && !verbose ? ` — ${item.resultSummary}` : ""] }), item.output && item.output.trim() && (item.resultSummary === undefined || verbose) && (_jsx(Text, { dimColor: true, children: toolOutputPreview(item.output, verbose, Math.max(20, contentWidth - TOOL_INDENT), item.error)
|
|
10
11
|
.split("\n")
|
|
11
12
|
.map((l) => ` ${l}`)
|
|
12
13
|
.join("\n") }))] })), item.kind === "info" && _jsx(Text, { dimColor: true, children: item.text }), item.kind === "banner" && _jsx(Banner, { subtitle: item.subtitle, compact: item.compact }), item.kind === "summary" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, color: "green", children: "\u2713 Completed" }), item.files.length > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Changed:" }), item.files.map((f) => (_jsxs(Text, { children: [" ", f] }, f)))] })) : (_jsx(Text, { dimColor: true, children: "No files changed." })), _jsxs(Text, { dimColor: true, children: ["Next: ", item.nextStep] })] }))] }));
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Opens a file in the user's editor, handing the terminal over while it runs.
|
|
4
|
+
*
|
|
5
|
+
* Ink's `suspendTerminal` stops rendering, stops consuming input, and puts
|
|
6
|
+
* the terminal back in the mode an interactive child expects (raw mode off,
|
|
7
|
+
* cursor visible, bracketed paste off). When the editor exits, Ink reapplies
|
|
8
|
+
* its own terminal state and repaints the whole UI from scratch, so the
|
|
9
|
+
* session continues exactly where it left off.
|
|
10
|
+
*
|
|
11
|
+
* If the user changed the file, the undo stack's existing file watcher
|
|
12
|
+
* reports it through `onExternalEdit` like any other outside edit — no
|
|
13
|
+
* extra change detection needed here.
|
|
14
|
+
*/
|
|
15
|
+
export async function openInEditor(suspendTerminal, absPath) {
|
|
16
|
+
await suspendTerminal(async () => {
|
|
17
|
+
const editor = process.env.VISUAL || process.env.EDITOR || "vi";
|
|
18
|
+
await new Promise((resolve) => {
|
|
19
|
+
let child;
|
|
20
|
+
try {
|
|
21
|
+
child = spawn(editor, [absPath], { stdio: "inherit" });
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Bad editor path — resolve instead of hanging the session.
|
|
25
|
+
resolve();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
// 'error' fires when the binary can't be launched at all.
|
|
29
|
+
child.on("error", () => resolve());
|
|
30
|
+
child.on("exit", () => resolve());
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
// Ink has restored the terminal and repainted by the time this resolves.
|
|
34
|
+
}
|