kritya 0.8.26-beta → 0.8.28-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.
@@ -1,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import path from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
+ import { writeStderr } from "../stderr.js";
4
5
  import { VERSION } from "../version.js";
5
6
  import { NOOP_TRACER } from "../telemetry/tracer.js";
6
7
  import { McpAuthRequiredError } from "./oauth.js";
@@ -644,14 +645,14 @@ function exposedToolName(server, toolName) {
644
645
  * is registered on the transport so `tools/call` can mirror argument values
645
646
  * into `Mcp-Param-{Name}` headers later — see `ModernHttpTransport.
646
647
  * setToolHeaderMap`/`send`. Logged the same way other "skip with a warning"
647
- * MCP failures are (`process.stderr.write("kritya: ...")`).
648
+ * MCP failures are (`writeStderr("kritya: ...")`).
648
649
  */
649
650
  function filterHeaderAnnotatedTools(server, specs, transport) {
650
651
  const kept = [];
651
652
  for (const spec of specs) {
652
653
  const result = validateToolHeaders(spec.inputSchema);
653
654
  if (!result.ok) {
654
- process.stderr.write(`kritya: MCP server "${server}" tool "${spec.name}" excluded — invalid x-mcp-header ` +
655
+ writeStderr(`kritya: MCP server "${server}" tool "${spec.name}" excluded — invalid x-mcp-header ` +
655
656
  `annotation: ${result.reason}\n`);
656
657
  continue;
657
658
  }
@@ -675,7 +676,7 @@ function filterSchemaSafeTools(server, specs) {
675
676
  for (const spec of specs) {
676
677
  const result = checkSchemaSafety(spec.inputSchema);
677
678
  if (!result.ok) {
678
- process.stderr.write(`kritya: MCP server "${server}" tool "${spec.name}" excluded — unsafe inputSchema: ` +
679
+ writeStderr(`kritya: MCP server "${server}" tool "${spec.name}" excluded — unsafe inputSchema: ` +
679
680
  `${result.reason}\n`);
680
681
  continue;
681
682
  }
@@ -852,7 +853,7 @@ export async function connectServer(name, cfg, trace) {
852
853
  }
853
854
  else {
854
855
  status.error = err instanceof Error ? err.message : String(err);
855
- process.stderr.write(`kritya: MCP server "${name}" failed to start: ${status.error}\n`);
856
+ writeStderr(`kritya: MCP server "${name}" failed to start: ${status.error}\n`);
856
857
  span.setStatus("ERROR", status.error);
857
858
  // status.error comes from the (untrusted) server process's own error
858
859
  // output, which could echo back an env var or header value it was
@@ -1,5 +1,5 @@
1
1
  import { lookup as dnsLookup } from "node:dns/promises";
2
- import { Agent } from "undici";
2
+ import { Agent, Dispatcher1Wrapper } from "undici";
3
3
  /**
4
4
  * Shared "is this host on a private/internal network" check, used by both
5
5
  * fetch_url (src/tools/fetchUrl.ts) and the MCP HTTP transport guard
@@ -210,7 +210,7 @@ export function assertSafeUrl(label, url) {
210
210
  * it's safe to share across every call site that uses the same policy.
211
211
  */
212
212
  function createPinnedDispatcher(isBlockedAddress) {
213
- return new Agent({
213
+ return new Dispatcher1Wrapper(new Agent({
214
214
  connect: {
215
215
  lookup(hostname, options, callback) {
216
216
  dnsLookup(hostname, { all: true })
@@ -235,7 +235,7 @@ function createPinnedDispatcher(isBlockedAddress) {
235
235
  .catch((err) => callback(err, []));
236
236
  },
237
237
  },
238
- });
238
+ }));
239
239
  }
240
240
  /** For callers that never allow loopback either (fetch_url). */
241
241
  export const pinnedDispatcher = createPinnedDispatcher(isPrivateOrLoopbackHost);
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { writeStderr } from "../stderr.js";
4
5
  /**
5
6
  * Scans each root for `<name>/plugin.json` folders, in name order, and reports
6
7
  * both what loaded and why anything didn't. Roots that don't exist are
@@ -73,7 +74,7 @@ export function scanPluginsDetailed(roots) {
73
74
  return { loaded: [...seen.values()], skipped };
74
75
  }
75
76
  let warnSink = (message) => {
76
- process.stderr.write(`kritya: ${message}\n`);
77
+ writeStderr(`kritya: ${message}\n`);
77
78
  };
78
79
  function warn(message) {
79
80
  warnSink(message);
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { writeStderr } from "../stderr.js";
3
4
  function isServerConfig(v) {
4
5
  if (!v || typeof v !== "object")
5
6
  return false;
@@ -65,7 +66,7 @@ export function scanPluginMcpServers(plugins) {
65
66
  * map for labeling `/mcp` output. Skips are reported via `warn` (defaults to
66
67
  * stderr, like scanPlugins/scanSkills).
67
68
  */
68
- export function loadPluginMcpServers(plugins, warn = (m) => process.stderr.write(`kritya: ${m}\n`)) {
69
+ export function loadPluginMcpServers(plugins, warn = (m) => writeStderr(`kritya: ${m}\n`)) {
69
70
  const { loaded, skipped } = scanPluginMcpServers(plugins);
70
71
  for (const s of skipped) {
71
72
  warn(`skipping mcp server "${s.name}" from plugin "${s.pluginName}": ${s.reason}`);
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import fs from "node:fs/promises";
11
11
  import path from "node:path";
12
- import fg from "fast-glob";
12
+ import { glob } from "tinyglobby";
13
13
  import { isPathSafe, resolveSafe } from "../tools/common.js";
14
14
  import { loadIgnorePatterns } from "../tools/ignore.js";
15
15
  import { CODE_EXTENSIONS, extractSymbols } from "./symbols.js";
@@ -88,13 +88,13 @@ export async function buildRepoMap(workspace, subdir = ".", options = {}) {
88
88
  const opts = { ...DEFAULTS, ...options };
89
89
  const root = resolveSafe(workspace, subdir || ".");
90
90
  const scopeLabel = path.relative(workspace, root) || "(workspace root)";
91
- const found = await fg("**/*", {
91
+ const found = await glob("**/*", {
92
92
  cwd: root,
93
93
  dot: false,
94
94
  onlyFiles: true,
95
95
  followSymbolicLinks: false,
96
+ expandDirectories: false,
96
97
  ignore: ["**/node_modules/**", "**/.git/**", ...loadIgnorePatterns(workspace)],
97
- suppressErrors: true,
98
98
  });
99
99
  // Keep only source files, then rank by path so the maxFiles cap (which bounds
100
100
  // how many files we actually read) retains the most important ones.
package/dist/stderr.js ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Where a warning goes while someone else owns the terminal.
3
+ *
4
+ * Ink owns the terminal for the whole time the UI is up. It keeps its frame in
5
+ * a known place, and anything that reaches the terminal *through* Ink is
6
+ * written between clearing that frame and repainting it — so the text lands
7
+ * above the UI and stays there. A raw `process.stderr.write` bypasses all of
8
+ * that: the bytes land wherever the cursor happened to be, in the middle of
9
+ * the frame, and the next repaint erases and redraws against a line count that
10
+ * no longer matches the screen. The warning comes out garbled and takes the
11
+ * frame with it.
12
+ *
13
+ * Ink patches `console` for exactly this reason, but `console.error` is not
14
+ * available to the plain module functions that raise these warnings — an agent
15
+ * loop, an MCP client, a plugin loader — and a hook (`useStderr`) cannot be
16
+ * called from one. So the UI registers a sink on mount and clears it on
17
+ * unmount, and everything else goes through `writeStderr`, which uses the sink
18
+ * when there is one and raw stderr when there is not: CLI subcommands, headless
19
+ * runs, and anything before the UI mounts or after it is torn down.
20
+ *
21
+ * Deliberately not used by `crash.ts`: by the time a crash handler runs, the
22
+ * UI is being torn down and its frame state is not trustworthy, so the crash
23
+ * report goes straight to stderr.
24
+ */
25
+ let sink;
26
+ /** Installed by the mounted UI. Pass `undefined` on unmount. */
27
+ export function setStderrSink(fn) {
28
+ sink = fn;
29
+ }
30
+ /** Write a warning to stderr, through the UI when one is mounted. */
31
+ export function writeStderr(text) {
32
+ if (sink) {
33
+ try {
34
+ sink(text);
35
+ return;
36
+ }
37
+ catch {
38
+ // A sink that throws must not cost us the warning itself.
39
+ }
40
+ }
41
+ try {
42
+ process.stderr.write(text);
43
+ }
44
+ catch {
45
+ // stderr is gone; there is nowhere left to put it.
46
+ }
47
+ }
@@ -11,6 +11,17 @@ export const NOOP_METER = {
11
11
  stop() { },
12
12
  };
13
13
  export const DEFAULT_LATENCY_BOUNDS_MS = [10, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000];
14
+ /**
15
+ * Buckets for Ink's per-frame `onRender` timing.
16
+ *
17
+ * The default latency bounds are useless here: drawing a frame is a
18
+ * sub-millisecond to low-single-digit-millisecond affair, so every sample would
19
+ * land in the first bucket and the histogram would say nothing. These cluster
20
+ * below the frame budget instead, and 33ms is the edge that matters — Ink caps
21
+ * rendering at 30fps (1000 / 30 ≈ 33.3ms), so a sample past it is a frame the
22
+ * UI could not draw on time.
23
+ */
24
+ export const RENDER_BOUNDS_MS = [0.25, 0.5, 1, 2, 4, 8, 16, 24, 33, 66, 133];
14
25
  function nowUnixNano() {
15
26
  return (BigInt(Date.now()) * 1000000n).toString();
16
27
  }
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { CONFIG_DIR } from "../config/config.js";
5
5
  import { hardenWindowsDir } from "../config/winAcl.js";
6
6
  import { debugLog, warnPersistenceFailure } from "../config/debug.js";
7
+ import { writeStderr } from "../stderr.js";
7
8
  import { VERSION } from "../version.js";
8
9
  import { encodeSpan, postOtlp } from "./otlp.js";
9
10
  function nowUnixNano() {
@@ -164,12 +165,7 @@ function fileSink(file) {
164
165
  }
165
166
  function consoleSink() {
166
167
  return (span) => {
167
- try {
168
- process.stderr.write(`[otel] ${JSON.stringify(span)}\n`);
169
- }
170
- catch {
171
- // best-effort
172
- }
168
+ writeStderr(`[otel] ${JSON.stringify(span)}\n`);
173
169
  };
174
170
  }
175
171
  function resourceFor(sessionId) {
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import fg from "fast-glob";
2
+ import { glob } from "tinyglobby";
3
3
  import { isPathSafe, truncateResult, countLines } from "./common.js";
4
4
  import { loadIgnorePatterns } from "./ignore.js";
5
5
  export const globTool = {
@@ -17,14 +17,14 @@ export const globTool = {
17
17
  summarize: (args) => `Glob ${args.pattern}`,
18
18
  resultSummary: (output) => countLines(output, "match", "matches"),
19
19
  async execute(args, ctx) {
20
- // Models on Windows sometimes emit backslash paths; fast-glob needs forward slashes.
21
- const files = await fg(String(args.pattern).replaceAll("\\", "/"), {
20
+ // Models on Windows sometimes emit backslash paths; glob patterns need forward slashes.
21
+ const files = await glob(String(args.pattern).replaceAll("\\", "/"), {
22
22
  cwd: ctx.workspace,
23
23
  dot: false,
24
24
  onlyFiles: true,
25
25
  followSymbolicLinks: false,
26
+ expandDirectories: false,
26
27
  ignore: ["**/node_modules/**", "**/.git/**", ...loadIgnorePatterns(ctx.workspace)],
27
- suppressErrors: true,
28
28
  });
29
29
  const safeFiles = files.filter((f) => isPathSafe(ctx.workspace, path.join(ctx.workspace, f)));
30
30
  const capped = safeFiles.sort().slice(0, 200);
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import fg from "fast-glob";
3
+ import { glob } from "tinyglobby";
4
4
  import { isPathSafe, resolveSafe, safeCompileRegex, truncateResult, meaningfulLines, } from "./common.js";
5
5
  import { loadIgnorePatterns } from "./ignore.js";
6
6
  const MAX_MATCHES = 200;
@@ -34,14 +34,14 @@ export const grepTool = {
34
34
  async execute(args, ctx) {
35
35
  const regex = safeCompileRegex(String(args.pattern));
36
36
  const searchRoot = resolveSafe(ctx.workspace, String(args.path ?? "."));
37
- // Models on Windows sometimes emit backslash paths; fast-glob needs forward slashes.
38
- const files = await fg(String(args.include ?? "**/*").replaceAll("\\", "/"), {
37
+ // Models on Windows sometimes emit backslash paths; glob patterns need forward slashes.
38
+ const files = await glob(String(args.include ?? "**/*").replaceAll("\\", "/"), {
39
39
  cwd: searchRoot,
40
40
  dot: false,
41
41
  onlyFiles: true,
42
42
  followSymbolicLinks: false,
43
+ expandDirectories: false,
43
44
  ignore: ["**/node_modules/**", "**/.git/**", ...loadIgnorePatterns(ctx.workspace)],
44
- suppressErrors: true,
45
45
  });
46
46
  const matches = [];
47
47
  for (const file of files) {
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  const IGNORE_FILE = ".krityaignore";
4
- /** Converts one gitignore-style line into fast-glob ignore pattern(s). */
4
+ /** Converts one gitignore-style line into glob ignore pattern(s). */
5
5
  function toGlobPatterns(line) {
6
6
  let pattern = line.trim();
7
7
  if (!pattern || pattern.startsWith("#") || pattern.startsWith("!"))
@@ -15,7 +15,7 @@ function toGlobPatterns(line) {
15
15
  const base = anchored ? pattern : `**/${pattern}`;
16
16
  return isDir ? [`${base}/**`] : [base, `${base}/**`];
17
17
  }
18
- /** Reads `<workspace>/.krityaignore` (gitignore-style, one pattern per line) as fast-glob ignore patterns. */
18
+ /** Reads `<workspace>/.krityaignore` (gitignore-style, one pattern per line) as glob ignore patterns. */
19
19
  export function loadIgnorePatterns(workspace) {
20
20
  let raw;
21
21
  try {
@@ -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
  /**
6
6
  * Shown once, only when the active provider has no API key resolved, on the
7
7
  * interactive TTY path (never from headless — see runHeadless in
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, }) {