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/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,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
- * 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(() => { });
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, stdout: stdout }, item.id) }, staticKey), 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:
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: (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) => {
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((prev) => (isCtrlEcho(prev, v) ? prev : v.replace(/\t/g, "")));
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 { 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
  /**
@@ -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:", " ", dryRunMode
13
- ? "dry-run"
14
- : planMode
15
- ? "plan"
16
- : acceptEdits
17
- ? `accept-edits (${autoApprovedCount} auto)`
18
- : "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" }) : "", " · /status for details"] }));
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
+ }