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/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, useStdout } from "ink";
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 fg from "fast-glob";
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 { tailForViewport, terminalColumns, terminalRows } from "./viewport.js";
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
- const { stdout } = useStdout();
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
- * Ctrl-chord bookkeeping for the text inputs.
50
- *
51
- * Ink delivers a keypress to every `useInput` hook, and ink-text-input
52
- * ignores only Ctrl+C — so Ctrl+K and Ctrl+O run their shortcut *and* get
53
- * typed into the box as a bare "k"/"o". Hitting the kill switch left junk on
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
- fg("**/*", {
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, stdout: stdout }, item.id) }, staticKey), 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(Box, { marginBottom: 1, children: _jsx(Markdown, { text: tailForViewport(stream, terminalColumns(stdout), terminalRows(stdout)), streaming: true }) })) : null, tasks.length > 0 && (_jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: "blue", paddingX: 1, width: Math.min(terminalColumns(stdout) - 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:
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: (v) => setSteerInput((prev) => (isCtrlEcho(prev, v) ? prev : v)), onSubmit: (value) => {
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((prev) => (isCtrlEcho(prev, v) ? prev : v.replace(/\t/g, "")));
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 { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
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 "ink-text-input";
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
@@ -1,6 +1,6 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
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, useStdout } from "ink";
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 { stdout } = useStdout();
42
- const width = Math.max(20, terminalColumns(stdout) - 2);
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
  }
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from "react";
3
3
  import { Box, Text } from "ink";
4
4
  import { SelectList } from "./SelectList.js";
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useInput } from "ink";
3
3
  import { SelectList } from "./SelectList.js";
4
4
  const MAX_DIFF_LINES = 30;
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
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, }) {
@@ -1,12 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useState } from "react";
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 [frame, setFrame] = useState(0);
7
- useEffect(() => {
8
- const timer = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), 80);
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
  }
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
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
  /**
@@ -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 { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
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
- import { terminalColumns } from "./viewport.js";
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, stdout }) {
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, terminalColumns(stdout) - 6), item.error)
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
+ }