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.
- package/README.md +36 -6
- package/dist/agent/skills.js +2 -1
- package/dist/commands/doctor.js +380 -0
- package/dist/config/config.js +2 -0
- package/dist/config/debug.js +3 -12
- package/dist/config/models.js +18 -0
- package/dist/index.js +56 -3
- package/dist/mcp/client.js +5 -4
- package/dist/net/urlSafety.js +3 -3
- package/dist/plugins/discover.js +2 -1
- package/dist/plugins/mcp.js +2 -1
- package/dist/repomap/repoMap.js +3 -3
- package/dist/stderr.js +47 -0
- package/dist/telemetry/metrics.js +11 -0
- package/dist/telemetry/tracer.js +2 -6
- package/dist/tools/glob.js +4 -4
- package/dist/tools/grep.js +4 -4
- package/dist/tools/ignore.js +2 -2
- package/dist/ui/ApiKeySetupPrompt.js +2 -2
- package/dist/ui/App.js +106 -86
- package/dist/ui/ElicitationPrompt.js +2 -2
- package/dist/ui/Markdown.js +5 -5
- package/dist/ui/McpTrustPrompt.js +1 -1
- package/dist/ui/PermissionPrompt.js +1 -1
- package/dist/ui/SelectList.js +1 -1
- package/dist/ui/Spinner.js +12 -8
- package/dist/ui/StatusLine.js +1 -1
- package/dist/ui/StreamViewport.js +29 -0
- package/dist/ui/TextInput.js +139 -0
- package/dist/ui/TranscriptItem.js +5 -4
- package/dist/ui/openInEditor.js +34 -0
- package/dist/ui/viewport.js +17 -50
- package/dist/undo/undo.js +11 -0
- package/dist/update/check.js +189 -0
- package/package.json +17 -15
package/dist/mcp/client.js
CHANGED
|
@@ -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 (`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/net/urlSafety.js
CHANGED
|
@@ -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);
|
package/dist/plugins/discover.js
CHANGED
|
@@ -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
|
-
|
|
77
|
+
writeStderr(`kritya: ${message}\n`);
|
|
77
78
|
};
|
|
78
79
|
function warn(message) {
|
|
79
80
|
warnSink(message);
|
package/dist/plugins/mcp.js
CHANGED
|
@@ -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) =>
|
|
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}`);
|
package/dist/repomap/repoMap.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import fs from "node:fs/promises";
|
|
11
11
|
import path from "node:path";
|
|
12
|
-
import
|
|
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
|
|
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
|
}
|
package/dist/telemetry/tracer.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/dist/tools/glob.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import
|
|
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;
|
|
21
|
-
const files = await
|
|
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);
|
package/dist/tools/grep.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import
|
|
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;
|
|
38
|
-
const files = await
|
|
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) {
|
package/dist/tools/ignore.js
CHANGED
|
@@ -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
|
|
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
|
|
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 {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState } from "react";
|
|
3
3
|
import { Box, Text, useInput } from "ink";
|
|
4
|
-
import TextInput from "
|
|
4
|
+
import { TextInput } from "./TextInput.js";
|
|
5
5
|
/**
|
|
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,
|
|
4
|
-
import TextInput from "ink-text-input";
|
|
3
|
+
import { Box, Static, Text, useApp, useBoxMetrics, useInput, useStderr, useWindowSize, } from "ink";
|
|
5
4
|
import fs from "node:fs";
|
|
6
|
-
import
|
|
5
|
+
import { glob } from "tinyglobby";
|
|
7
6
|
import stringWidth from "string-width";
|
|
8
7
|
import { gitDiffStat } from "../git/git.js";
|
|
9
8
|
import { modelDisplaySlug } from "../config/models.js";
|
|
9
|
+
import { setStderrSink } from "../stderr.js";
|
|
10
|
+
import { openInEditor } from "./openInEditor.js";
|
|
10
11
|
import { SessionStore } from "../session/store.js";
|
|
11
12
|
import { defaultSandboxMode, sandboxAvailable } from "../shell/sandbox.js";
|
|
12
13
|
import { resolveSafe } from "../tools/common.js";
|
|
@@ -18,8 +19,10 @@ import { PermissionPrompt } from "./PermissionPrompt.js";
|
|
|
18
19
|
import { SelectList } from "./SelectList.js";
|
|
19
20
|
import { Spinner } from "./Spinner.js";
|
|
20
21
|
import { StatusLine } from "./StatusLine.js";
|
|
22
|
+
import { StreamViewport } from "./StreamViewport.js";
|
|
23
|
+
import { TextInput } from "./TextInput.js";
|
|
21
24
|
import { TranscriptItem } from "./TranscriptItem.js";
|
|
22
|
-
import {
|
|
25
|
+
import { terminalColumns, terminalRows } from "./viewport.js";
|
|
23
26
|
import { BUILTIN_COMMANDS, runCommand } from "../commands/registry.js";
|
|
24
27
|
import { mcpPrompts, mcpResources } from "../mcp/client.js";
|
|
25
28
|
import { useAgent } from "./useAgent.js";
|
|
@@ -27,12 +30,58 @@ const MENTION_RE = /(^|\s)@([^\s@]*)$/;
|
|
|
27
30
|
const MENTION_ALL_RE = /(?:^|\s)@([^\s@]+)/g;
|
|
28
31
|
const MAX_MENTION_CHARS = 8000;
|
|
29
32
|
const IMAGE_RE = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
|
30
|
-
// Erase screen + scrollback, then home the cursor (same sequence Ink itself
|
|
31
|
-
// uses for its own "content taller than the terminal" clear path).
|
|
32
|
-
const CLEAR_TERMINAL = "\x1b[2J\x1b[3J\x1b[H";
|
|
33
33
|
export function App({ agent, workspace, modelRef, providerRef, config, resumedCount, initialTasks, undoStack, uiBridge, resumeSessions, customCommands = [], mcpToolCount = 0, onSwitchClient, onRequestPermissionReady, onRequestElicitationReady, privacyMode, firstLaunch, }) {
|
|
34
|
-
const { exit } = useApp();
|
|
35
|
-
|
|
34
|
+
const { exit, suspendTerminal, waitUntilRenderFlush } = useApp();
|
|
35
|
+
/**
|
|
36
|
+
* Leave, but not until the screen has caught up.
|
|
37
|
+
*
|
|
38
|
+
* Ink's `exit()` tears the tree down in the same tick it is called, so a
|
|
39
|
+
* state update made in that same tick — the last line of a transcript, a
|
|
40
|
+
* "goodbye" item — is committed to a tree that is already being unmounted and
|
|
41
|
+
* never reaches the terminal. Verified: `setState(x); exit();` drops `x`,
|
|
42
|
+
* while deferring `exit()` by one macrotask draws it. `waitUntilRenderFlush()`
|
|
43
|
+
* is Ink's own barrier for exactly this — it yields so React can commit, then
|
|
44
|
+
* settles the throttled frame timer and waits for the write to drain.
|
|
45
|
+
*/
|
|
46
|
+
const exitAfterFlush = useCallback(async () => {
|
|
47
|
+
await waitUntilRenderFlush();
|
|
48
|
+
exit();
|
|
49
|
+
}, [exit, waitUntilRenderFlush]);
|
|
50
|
+
/**
|
|
51
|
+
* Hand warnings raised outside React a way through Ink.
|
|
52
|
+
*
|
|
53
|
+
* Agent loops, MCP clients and plugin loaders are plain module functions, so
|
|
54
|
+
* they cannot call useStderr() themselves and had been writing straight to
|
|
55
|
+
* process.stderr — which Ink knows nothing about, so the text landed in the
|
|
56
|
+
* middle of the frame and the next repaint drew against the wrong line count.
|
|
57
|
+
* Registering Ink's own writer for the lifetime of the UI puts them back
|
|
58
|
+
* inside the clear-write-repaint sandwich. Cleared on unmount so the fallback
|
|
59
|
+
* is raw stderr again for anything that outlives the UI. See src/stderr.ts.
|
|
60
|
+
*/
|
|
61
|
+
const { write: writeStderr } = useStderr();
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
setStderrSink(writeStderr);
|
|
64
|
+
return () => setStderrSink(undefined);
|
|
65
|
+
}, [writeStderr]);
|
|
66
|
+
// Ink 8 types `stdout` as a plain Node stream, so the terminal's dimensions
|
|
67
|
+
// come from useWindowSize() rather than stdout.columns / stdout.rows — and
|
|
68
|
+
// it drives the re-layout on resize by itself. Kritya used to wipe the screen
|
|
69
|
+
// and remount <Static> on every column change, because Ink erased its live
|
|
70
|
+
// region against a stale line count after the terminal reflowed already-
|
|
71
|
+
// printed lines. Ink 8 fixed that class of bug itself: it redraws the frame
|
|
72
|
+
// when the terminal loses rows, keeps the content above the frame when rows
|
|
73
|
+
// shrink, and preserves scrollback — which the old `\x1b[3J` clear threw
|
|
74
|
+
// away. So the workaround is gone and nothing here watches for resizes.
|
|
75
|
+
const windowSize = useWindowSize();
|
|
76
|
+
const columns = terminalColumns(windowSize);
|
|
77
|
+
const rows = terminalRows(windowSize);
|
|
78
|
+
// The transcript column's real width, measured rather than assumed. The
|
|
79
|
+
// wrapped renderers lay out to this, so if the chrome around them ever gains
|
|
80
|
+
// padding the prose still breaks at the right column. Falls back to the
|
|
81
|
+
// terminal width for the first frame, before layout has run.
|
|
82
|
+
const rootRef = useRef(null);
|
|
83
|
+
const { clientWidth } = useBoxMetrics(rootRef);
|
|
84
|
+
const contentWidth = clientWidth || columns;
|
|
36
85
|
const [input, setInput] = useState("");
|
|
37
86
|
const [inputKey, setInputKey] = useState(0);
|
|
38
87
|
const [steerInput, setSteerInput] = useState("");
|
|
@@ -42,84 +91,21 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
42
91
|
const [fileList, setFileList] = useState([]);
|
|
43
92
|
const [verbose, setVerbose] = useState(false);
|
|
44
93
|
const [elapsed, setElapsed] = useState(0);
|
|
45
|
-
const [staticKey, setStaticKey] = useState(0);
|
|
46
94
|
const inputHistory = useRef([]);
|
|
47
95
|
const histIndex = useRef(-1); // -1 means "current, not browsing history"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
* the prompt line, which the next Enter would have sent as a message.
|
|
55
|
-
*
|
|
56
|
-
* The two hooks can fire in either order, so both directions are covered:
|
|
57
|
-
* `ctrlChord` makes onChange drop the echoed letter when the shortcut ran
|
|
58
|
-
* first, and re-setting the value from `inputRef` undoes it when the input
|
|
59
|
-
* ran first. `inputRef` tracks committed state, so it always holds the value
|
|
60
|
-
* from before this keypress.
|
|
61
|
-
*/
|
|
62
|
-
const ctrlChord = useRef(null);
|
|
63
|
-
const inputRef = useRef("");
|
|
64
|
-
const steerInputRef = useRef("");
|
|
65
|
-
useEffect(() => {
|
|
66
|
-
inputRef.current = input;
|
|
67
|
-
}, [input]);
|
|
68
|
-
useEffect(() => {
|
|
69
|
-
steerInputRef.current = steerInput;
|
|
70
|
-
}, [steerInput]);
|
|
71
|
-
const noteCtrlChord = (letter) => {
|
|
72
|
-
ctrlChord.current = letter;
|
|
73
|
-
setInput(inputRef.current);
|
|
74
|
-
setSteerInput(steerInputRef.current);
|
|
75
|
-
setTimeout(() => {
|
|
76
|
-
ctrlChord.current = null;
|
|
77
|
-
}, 0);
|
|
78
|
-
};
|
|
79
|
-
/** The change a Ctrl chord echoed into the box, rather than a real keystroke. */
|
|
80
|
-
const isCtrlEcho = (prev, next) => ctrlChord.current !== null && next === prev + ctrlChord.current;
|
|
81
|
-
// Terminals that reflow their buffer on resize (e.g. VS Code's xterm.js)
|
|
82
|
-
// rewrap already-printed full-width lines (banner art, box borders) to the
|
|
83
|
-
// new column count on their own, before Ink's next redraw arrives. Ink then
|
|
84
|
-
// erases based on a stale line count from before that reflow, so it clears
|
|
85
|
-
// the wrong number of rows and leaves stale copies of boxes/banners behind
|
|
86
|
-
// — compounding on every resize. There's no way to reflow in place safely,
|
|
87
|
-
// so once the column count actually settles on a new value (ignoring
|
|
88
|
-
// height-only resizes, e.g. VS Code adding a split terminal), wipe the
|
|
89
|
-
// screen ourselves and remount <Static> so Ink reprints everything fresh
|
|
90
|
-
// onto a blank canvas. The manual clear is safe here specifically because
|
|
91
|
-
// the forced remount immediately triggers Ink's own static-flush path,
|
|
92
|
-
// which resyncs its internal line-count bookkeeping — it's only unsafe to
|
|
93
|
-
// write raw ANSI without following it with something that resets that
|
|
94
|
-
// bookkeeping.
|
|
95
|
-
const lastColumns = useRef(stdout?.columns);
|
|
96
|
-
useEffect(() => {
|
|
97
|
-
if (!stdout)
|
|
98
|
-
return;
|
|
99
|
-
let timer;
|
|
100
|
-
const onResize = () => {
|
|
101
|
-
clearTimeout(timer);
|
|
102
|
-
timer = setTimeout(() => {
|
|
103
|
-
if (stdout.columns === lastColumns.current)
|
|
104
|
-
return;
|
|
105
|
-
lastColumns.current = stdout.columns;
|
|
106
|
-
stdout.write(CLEAR_TERMINAL);
|
|
107
|
-
setStaticKey((k) => k + 1);
|
|
108
|
-
}, 150);
|
|
109
|
-
};
|
|
110
|
-
stdout.on("resize", onResize);
|
|
111
|
-
return () => {
|
|
112
|
-
clearTimeout(timer);
|
|
113
|
-
stdout.off("resize", onResize);
|
|
114
|
-
};
|
|
115
|
-
}, [stdout]);
|
|
96
|
+
// Ctrl-chord bookkeeping used to live here: Ink delivers a keypress to every
|
|
97
|
+
// `useInput` hook, and ink-text-input swallowed only Ctrl+C, so Ctrl+K and
|
|
98
|
+
// Ctrl+O ran their shortcut *and* typed a bare "k"/"o" into the prompt — which
|
|
99
|
+
// the next Enter would have sent as a message. Kritya's own TextInput drops
|
|
100
|
+
// every ctrl/meta chord before it reaches the buffer, so there is nothing left
|
|
101
|
+
// to undo and the workaround is gone.
|
|
116
102
|
const refreshFileList = useCallback(() => {
|
|
117
|
-
|
|
103
|
+
glob("**/*", {
|
|
118
104
|
cwd: workspace,
|
|
119
105
|
dot: false,
|
|
120
106
|
onlyFiles: true,
|
|
107
|
+
expandDirectories: false,
|
|
121
108
|
ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", ...loadIgnorePatterns(workspace)],
|
|
122
|
-
suppressErrors: true,
|
|
123
109
|
})
|
|
124
110
|
.then((files) => setFileList(files.sort().slice(0, 2000)))
|
|
125
111
|
.catch(() => { });
|
|
@@ -221,13 +207,37 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
221
207
|
setInputKey((k) => k + 1);
|
|
222
208
|
setFileIndex(0);
|
|
223
209
|
};
|
|
210
|
+
// Ctrl+E opens the file the agent touched most recently in the user's
|
|
211
|
+
// $EDITOR, suspending the UI while the editor owns the terminal. Ink
|
|
212
|
+
// restores everything and repaints from scratch on resume; a hand-edit is
|
|
213
|
+
// reported by the undo stack's watcher via onExternalEdit like any other
|
|
214
|
+
// outside edit. Guarded against double presses — suspending twice throws.
|
|
215
|
+
const editorBusy = useRef(false);
|
|
216
|
+
const openInEditorFlow = useCallback(async () => {
|
|
217
|
+
if (editorBusy.current)
|
|
218
|
+
return;
|
|
219
|
+
const target = undoStack.lastTouchedFile();
|
|
220
|
+
if (!target) {
|
|
221
|
+
addItem({
|
|
222
|
+
kind: "info",
|
|
223
|
+
text: "Ctrl+E: the agent hasn't touched any file yet this session.",
|
|
224
|
+
});
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
editorBusy.current = true;
|
|
228
|
+
try {
|
|
229
|
+
await openInEditor(suspendTerminal, target.absPath);
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
editorBusy.current = false;
|
|
233
|
+
}
|
|
234
|
+
}, [suspendTerminal, undoStack, addItem]);
|
|
224
235
|
useInput((_input, key) => {
|
|
225
236
|
// Ctrl+K is the kill switch, and it comes before every other binding and
|
|
226
237
|
// phase check on purpose: a panic button that only works from the idle
|
|
227
238
|
// input line is not a panic button. It fires mid-stream, mid-tool, and
|
|
228
239
|
// while a permission prompt is on screen.
|
|
229
240
|
if (key.ctrl && _input === "k") {
|
|
230
|
-
noteCtrlChord(_input);
|
|
231
241
|
if (killed)
|
|
232
242
|
return; // already stopped; /kill off is the way back
|
|
233
243
|
engageKill("Ctrl+K");
|
|
@@ -239,10 +249,20 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
239
249
|
}
|
|
240
250
|
// Ctrl+O toggles showing full tool output.
|
|
241
251
|
if (key.ctrl && _input === "o") {
|
|
242
|
-
noteCtrlChord(_input);
|
|
243
252
|
setVerbose((v) => !v);
|
|
244
253
|
return;
|
|
245
254
|
}
|
|
255
|
+
// Ctrl+E opens the most recently agent-touched file in $EDITOR. Only
|
|
256
|
+
// from the idle prompt — never mid-turn, so the editor can't race the
|
|
257
|
+
// agent over the same file.
|
|
258
|
+
if (key.ctrl && _input === "e") {
|
|
259
|
+
if (phase !== "input") {
|
|
260
|
+
setActivity("Ctrl+E works from the idle prompt — stop the current turn first.");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
void openInEditorFlow();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
246
266
|
// Shift+Tab cycles normal → accept-edits → plan → normal. Only from the
|
|
247
267
|
// plain input state — not mid-permission-prompt, mid-resume-picker, etc.
|
|
248
268
|
if (key.tab && key.shift && phase === "input") {
|
|
@@ -360,7 +380,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
360
380
|
costReport,
|
|
361
381
|
statusReport,
|
|
362
382
|
gitDiffStat,
|
|
363
|
-
exit,
|
|
383
|
+
exit: exitAfterFlush,
|
|
364
384
|
};
|
|
365
385
|
void runCommand(cmd, ctx);
|
|
366
386
|
};
|
|
@@ -455,7 +475,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
455
475
|
addItem({ kind: "info", text: `Attached ${images.length} image(s).` });
|
|
456
476
|
await runAgent(await expandMentions(text), images);
|
|
457
477
|
};
|
|
458
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item) => _jsx(TranscriptItem, { item: item, verbose: verbose,
|
|
478
|
+
return (_jsxs(Box, { ref: rootRef, flexDirection: "column", children: [_jsx(Static, { items: items, children: (item) => (_jsx(TranscriptItem, { item: item, verbose: verbose, contentWidth: contentWidth }, item.id)) }), firstLaunch && items.length <= 1 && phase === "input" && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: "Try asking:" }), _jsx(Text, { dimColor: true, children: " \u00B7 Explain this codebase" }), _jsx(Text, { dimColor: true, children: " \u00B7 Find and fix failing tests" }), _jsx(Text, { dimColor: true, children: " \u00B7 Review my uncommitted changes" }), _jsx(Text, { dimColor: true, children: " \u00B7 Add a new feature" }), _jsx(Text, { dimColor: true, children: " \u00B7 /help for commands" })] })), stream ? (_jsx(StreamViewport, { rows: rows, children: _jsx(Markdown, { text: stream, streaming: true, width: contentWidth }) })) : null, tasks.length > 0 && (_jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: "blue", paddingX: 1, width: Math.min(columns - 2, Math.max(...tasks.map((t) => stringWidth(t.text))) + 6), children: tasks.map((t, i) => (_jsxs(Text, { color: t.status === "done" ? "green" : t.status === "in_progress" ? "yellow" : undefined, dimColor: t.status === "pending", children: [t.status === "done" ? "☑" : t.status === "in_progress" ? "◐" : "☐", " ", t.text] }, i))) })), phase === "working" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Spinner, { label:
|
|
459
479
|
// The workflow prefix rides on every branch, not just "thinking":
|
|
460
480
|
// during a phase you want to know which phase a tool call belongs
|
|
461
481
|
// to just as much as which phase is being reasoned about.
|
|
@@ -468,7 +488,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
468
488
|
? activity
|
|
469
489
|
: thinking
|
|
470
490
|
? "thinking… (Esc to cancel)"
|
|
471
|
-
: "working… (Esc to cancel)") }), inFlight.length > 1 && (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: inFlight.map((t) => (_jsxs(Text, { dimColor: true, children: ["\u00B7 ", t.status ? `${t.summary} — ${t.status}` : t.summary] }, t.id))) })), _jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [_jsx(Text, { color: "yellow", children: "\u21B3 " }), _jsx(TextInput, { value: steerInput, onChange:
|
|
491
|
+
: "working… (Esc to cancel)") }), inFlight.length > 1 && (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: inFlight.map((t) => (_jsxs(Text, { dimColor: true, children: ["\u00B7 ", t.status ? `${t.summary} — ${t.status}` : t.summary] }, t.id))) })), _jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [_jsx(Text, { color: "yellow", children: "\u21B3 " }), _jsx(TextInput, { value: steerInput, onChange: setSteerInput, onSubmit: (value) => {
|
|
472
492
|
const text = value.trim();
|
|
473
493
|
setSteerInput("");
|
|
474
494
|
if (!text)
|
|
@@ -493,7 +513,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
493
513
|
value: s.file,
|
|
494
514
|
hint: `${s.date} · ${s.count} msgs`,
|
|
495
515
|
})), onSelect: onResumeSelect, onCancel: () => onResumeSelect("") })] })), phase === "input" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { color: "green", children: "\u276F " }), _jsx(TextInput, { value: input, onChange: (v) => {
|
|
496
|
-
setInput(
|
|
516
|
+
setInput(v.replace(/\t/g, ""));
|
|
497
517
|
setCmdIndex(0);
|
|
498
518
|
setFileIndex(0);
|
|
499
519
|
}, onSubmit: handleSubmit }, inputKey)] }), suggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [(() => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState } from "react";
|
|
3
3
|
import { Box, Text, useInput } from "ink";
|
|
4
|
-
import TextInput from "
|
|
4
|
+
import { TextInput } from "./TextInput.js";
|
|
5
5
|
import { SelectList } from "./SelectList.js";
|
|
6
6
|
/**
|
|
7
7
|
* One field at a time, same reasoning as McpTrustPrompt's one-server-at-a-time
|
package/dist/ui/Markdown.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import React from "react";
|
|
3
|
-
import { Box, Text,
|
|
3
|
+
import { Box, Text, useWindowSize } from "ink";
|
|
4
4
|
import stringWidth from "string-width";
|
|
5
5
|
import { tokenizeLine } from "./highlight.js";
|
|
6
6
|
import { parseInline, tokensWidth, wrapInline } from "./inline.js";
|
|
@@ -37,9 +37,9 @@ function Marked({ text, width, marker, indent, ...style }) {
|
|
|
37
37
|
* bullets, quotes, rules, tables, and inline emphasis. Deliberately
|
|
38
38
|
* lightweight — not a full implementation.
|
|
39
39
|
*/
|
|
40
|
-
export function Markdown({ text, streaming = false }) {
|
|
41
|
-
const
|
|
42
|
-
const width = Math.max(20, terminalColumns(
|
|
40
|
+
export function Markdown({ text, streaming = false, width: measuredWidth, }) {
|
|
41
|
+
const windowSize = useWindowSize();
|
|
42
|
+
const width = Math.max(20, measuredWidth ?? terminalColumns(windowSize) - 2);
|
|
43
43
|
const blocks = React.useMemo(() => renderBlocks(text, width, streaming), [text, width, streaming]);
|
|
44
44
|
return _jsx(Box, { flexDirection: "column", children: blocks });
|
|
45
45
|
}
|
package/dist/ui/SelectList.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState } from "react";
|
|
3
3
|
import { Box, Text, useInput } from "ink";
|
|
4
4
|
export function SelectList({ items, onSelect, onCancel, }) {
|