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/ui/Spinner.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
3
|
-
import { Text } from "ink";
|
|
2
|
+
import { Text, useAnimation } from "ink";
|
|
4
3
|
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
4
|
+
/** Milliseconds between frames — the spinner's twelve-frames-a-second beat. */
|
|
5
|
+
const FRAME_INTERVAL_MS = 80;
|
|
6
|
+
/**
|
|
7
|
+
* The spinner that turns beside the working… label. Ink drives this from one
|
|
8
|
+
* animation timer shared by the whole app — no private `setInterval` to own or
|
|
9
|
+
* clean up, and the beat comes from elapsed time, so a busy machine skips a
|
|
10
|
+
* frame instead of drifting late.
|
|
11
|
+
*/
|
|
5
12
|
export function Spinner({ label }) {
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
return () => clearInterval(timer);
|
|
10
|
-
}, []);
|
|
11
|
-
return (_jsxs(Text, { color: "yellow", children: [FRAMES[frame], " ", _jsx(Text, { dimColor: true, children: label })] }));
|
|
13
|
+
const { frame } = useAnimation({ interval: FRAME_INTERVAL_MS });
|
|
14
|
+
// `frame` counts up forever; the glyph list is what wraps it back around.
|
|
15
|
+
return (_jsxs(Text, { color: "yellow", children: [FRAMES[frame % FRAMES.length], " ", _jsx(Text, { dimColor: true, children: label })] }));
|
|
12
16
|
}
|
package/dist/ui/StatusLine.js
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box } from "ink";
|
|
3
|
+
import { streamRows } from "./viewport.js";
|
|
4
|
+
/**
|
|
5
|
+
* Holds a streaming answer to the rows left under the live region, scrolling
|
|
6
|
+
* to its tail as it grows.
|
|
7
|
+
*
|
|
8
|
+
* Ink erases its live region by rewinding a line count, which can only reach
|
|
9
|
+
* what is still on screen. Let the streaming text grow past the terminal's
|
|
10
|
+
* height and the erase silently comes up short, stranding a partial copy in
|
|
11
|
+
* the scrollback — then the finished message prints below it and the answer
|
|
12
|
+
* appears twice. Capping the live region keeps the erase exact; the whole
|
|
13
|
+
* answer is printed once when the turn ends.
|
|
14
|
+
*
|
|
15
|
+
* This used to be done by hand: word-wrap every line to estimate the rows it
|
|
16
|
+
* would render at, then slice off the tail that didn't fit. Ink 8 renders
|
|
17
|
+
* `maxHeight` and `overflow="hidden"` natively, so the box does the capping.
|
|
18
|
+
*
|
|
19
|
+
* `justifyContent="flex-end"` is what pins the view to the tail: a column
|
|
20
|
+
* whose content overflows a capped height lines up against its end, keeping
|
|
21
|
+
* the newest text visible. It costs nothing while the answer still fits — the
|
|
22
|
+
* box is then only as tall as its content and the text grows downward as you
|
|
23
|
+
* would expect. `flexShrink={0}` on the content is what makes that work: Yoga
|
|
24
|
+
* would otherwise shrink the child back down to the cap, leaving the top on
|
|
25
|
+
* screen and the tail cut off.
|
|
26
|
+
*/
|
|
27
|
+
export function StreamViewport({ rows, children }) {
|
|
28
|
+
return (_jsx(Box, { flexDirection: "column", maxHeight: streamRows(rows), overflow: "hidden", justifyContent: "flex-end", marginBottom: 1, children: _jsx(Box, { flexDirection: "column", flexShrink: 0, children: children }) }));
|
|
29
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
import { Box, Text, measureElement, useCursor, useInput, usePaste } from "ink";
|
|
4
|
+
import stringWidth from "string-width";
|
|
5
|
+
import wrapAnsi from "wrap-ansi";
|
|
6
|
+
/**
|
|
7
|
+
* Where the caret lands in already-rendered text: the row and column the
|
|
8
|
+
* terminal cursor has to sit on once the text wraps.
|
|
9
|
+
*
|
|
10
|
+
* Wrapped with the very same call ink makes when it lays the text out
|
|
11
|
+
* (`wrapText` → `wrapAnsi(text, width, { trim: false, hard: true })`), so the
|
|
12
|
+
* two can't disagree about where a line breaks. Re-deriving the break point by
|
|
13
|
+
* dividing the column count would: `wrapAnsi` leaves the space that ends a
|
|
14
|
+
* wrapped line on that line, so a greedy wrapper lands the next line one column
|
|
15
|
+
* to the left and the caret drifts a cell per wrap.
|
|
16
|
+
*
|
|
17
|
+
* One deliberate approximation: a line that fills the width exactly reports
|
|
18
|
+
* `col === width`, i.e. the column just past its last character. That is where
|
|
19
|
+
* the terminal's own pending-wrap cursor sits, and the alternative — jumping to
|
|
20
|
+
* column 0 of the following row — is just as defensible and just as wrong the
|
|
21
|
+
* other half of the time.
|
|
22
|
+
*/
|
|
23
|
+
export function caretAt(before, width) {
|
|
24
|
+
const lines = wrapAnsi(before, Math.max(1, width), { trim: false, hard: true }).split("\n");
|
|
25
|
+
const last = lines[lines.length - 1] ?? "";
|
|
26
|
+
return { row: lines.length - 1, col: stringWidth(last) };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The prompt's text field.
|
|
30
|
+
*
|
|
31
|
+
* Hand-rolled rather than borrowed from ink-text-input, for two reasons that
|
|
32
|
+
* both come down to ink 8 finally exposing the terminal cursor. `useCursor`
|
|
33
|
+
* puts the *real* cursor at the caret, which is what an IME needs to place its
|
|
34
|
+
* composition popup — ink-text-input fakes the cursor with a reverse-video
|
|
35
|
+
* character, so typing Chinese, Japanese or Korean puts the popup in the wrong
|
|
36
|
+
* place. And `usePaste` turns on bracketed paste, so a pasted block arrives as
|
|
37
|
+
* one marked string we can normalize, instead of a run of raw keystrokes whose
|
|
38
|
+
* carriage returns survive into the prompt and into the message sent to the
|
|
39
|
+
* model.
|
|
40
|
+
*/
|
|
41
|
+
export function TextInput({ value, onChange, onSubmit, placeholder, mask, isActive = true, }) {
|
|
42
|
+
const [cursor, setCursorOffset] = useState(value.length);
|
|
43
|
+
const [origin, setOrigin] = useState(null);
|
|
44
|
+
const boxRef = useRef(null);
|
|
45
|
+
const { setCursorPosition } = useCursor();
|
|
46
|
+
// The caller owns the value, so it can move under us — cleared on submit,
|
|
47
|
+
// replaced by history recall. Clamping during render rather than in an effect
|
|
48
|
+
// keeps the caret correct in the same pass.
|
|
49
|
+
const offset = Math.min(cursor, value.length);
|
|
50
|
+
const display = mask ? mask.repeat(value.length) : value;
|
|
51
|
+
const beforeCaret = mask ? mask.repeat(offset) : display.slice(0, offset);
|
|
52
|
+
// Re-measure after every commit; setOrigin bails out when nothing moved, so
|
|
53
|
+
// a steady prompt re-renders zero extra times. Ink computes layout in
|
|
54
|
+
// resetAfterCommit, which runs *before* layout effects, so the first pass
|
|
55
|
+
// already sees a real layout. The zero check is belt and braces: a Box that
|
|
56
|
+
// genuinely has no room yet should leave the cursor hidden rather than pin it
|
|
57
|
+
// to the terminal origin.
|
|
58
|
+
useLayoutEffect(() => {
|
|
59
|
+
const node = boxRef.current;
|
|
60
|
+
if (!node)
|
|
61
|
+
return;
|
|
62
|
+
const { x, y, clientWidth } = measureElement(node);
|
|
63
|
+
if (clientWidth === 0)
|
|
64
|
+
return;
|
|
65
|
+
setOrigin((prev) => prev && prev.x === x && prev.y === y && prev.width === clientWidth
|
|
66
|
+
? prev
|
|
67
|
+
: { x, y, width: clientWidth });
|
|
68
|
+
});
|
|
69
|
+
// Publishing the caret has to happen during render. useCursor() forwards it
|
|
70
|
+
// from an insertion effect, which runs *before* layout effects, so a position
|
|
71
|
+
// set from an effect would always reach the terminal one render late — and a
|
|
72
|
+
// cursor that trails the caret by a keystroke is worse than none for an IME.
|
|
73
|
+
if (isActive && origin) {
|
|
74
|
+
const { row, col } = caretAt(beforeCaret, Math.max(1, origin.width));
|
|
75
|
+
setCursorPosition({ x: origin.x + col, y: origin.y + row });
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
setCursorPosition(undefined);
|
|
79
|
+
}
|
|
80
|
+
useInput((input, key) => {
|
|
81
|
+
// Ctrl/meta chords are the app's shortcuts (Ctrl+K kill switch, Ctrl+O
|
|
82
|
+
// verbose), never text. Swallowing them here is what stops them being
|
|
83
|
+
// typed into the box as a bare letter.
|
|
84
|
+
if (key.ctrl || key.meta)
|
|
85
|
+
return;
|
|
86
|
+
// Keys the surrounding app owns: up/down drive suggestions and history,
|
|
87
|
+
// Tab completes, Escape cancels.
|
|
88
|
+
if (key.upArrow || key.downArrow || key.tab || key.escape)
|
|
89
|
+
return;
|
|
90
|
+
if (key.return) {
|
|
91
|
+
onSubmit?.(value);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (key.leftArrow) {
|
|
95
|
+
setCursorOffset(Math.max(0, offset - 1));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (key.rightArrow) {
|
|
99
|
+
setCursorOffset(Math.min(value.length, offset + 1));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (key.home) {
|
|
103
|
+
setCursorOffset(0);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (key.end) {
|
|
107
|
+
setCursorOffset(value.length);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// Ink reports these separately — backspace is DEL, forward-delete is
|
|
111
|
+
// CSI 3~ — so they delete on opposite sides of the caret.
|
|
112
|
+
if (key.backspace) {
|
|
113
|
+
if (offset === 0)
|
|
114
|
+
return;
|
|
115
|
+
onChange(value.slice(0, offset - 1) + value.slice(offset));
|
|
116
|
+
setCursorOffset(offset - 1);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (key.delete) {
|
|
120
|
+
if (offset >= value.length)
|
|
121
|
+
return;
|
|
122
|
+
onChange(value.slice(0, offset) + value.slice(offset + 1));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (!input)
|
|
126
|
+
return;
|
|
127
|
+
// Bracketed paste normally routes to usePaste below. A terminal without
|
|
128
|
+
// it still delivers the block here, so normalize on both paths.
|
|
129
|
+
const inserted = input.replace(/\r\n?/g, "\n");
|
|
130
|
+
onChange(value.slice(0, offset) + inserted + value.slice(offset));
|
|
131
|
+
setCursorOffset(offset + inserted.length);
|
|
132
|
+
}, { isActive });
|
|
133
|
+
usePaste((text) => {
|
|
134
|
+
const inserted = text.replace(/\r\n?/g, "\n");
|
|
135
|
+
onChange(value.slice(0, offset) + inserted + value.slice(offset));
|
|
136
|
+
setCursorOffset(offset + inserted.length);
|
|
137
|
+
}, { isActive });
|
|
138
|
+
return (_jsx(Box, { ref: boxRef, flexGrow: 1, children: value.length === 0 && placeholder ? (_jsx(Text, { dimColor: true, children: placeholder })) : (_jsx(Text, { children: display })) }));
|
|
139
|
+
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
import { Banner } from "./Banner.js";
|
|
4
4
|
import { Markdown } from "./Markdown.js";
|
|
5
5
|
import { toolOutputPreview } from "./toolOutputPreview.js";
|
|
6
|
-
|
|
6
|
+
/** Indent tool output is printed under (see the ` ` prefix below). */
|
|
7
|
+
const TOOL_INDENT = 4;
|
|
7
8
|
/** Renders one line of the transcript — a user message, assistant reply, tool call, info line, or banner. */
|
|
8
|
-
export function TranscriptItem({ item, verbose,
|
|
9
|
-
return (_jsxs(Box, { marginBottom: item.kind === "tool" ? 0 : 1, flexDirection: "column", children: [item.kind === "user" && (_jsxs(Text, { children: [_jsxs(Text, { bold: true, color: "green", children: ["\u276F", " "] }), item.text] })), item.kind === "assistant" && _jsx(Markdown, { text: item.text }), item.kind === "tool" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [item.error ? _jsx(Text, { color: "red", children: "\u2717" }) : _jsx(Text, { color: "green", children: "\u2713" }), " ", item.summary, item.resultSummary && !verbose ? ` — ${item.resultSummary}` : ""] }), item.output && item.output.trim() && (item.resultSummary === undefined || verbose) && (_jsx(Text, { dimColor: true, children: toolOutputPreview(item.output, verbose, Math.max(20,
|
|
9
|
+
export function TranscriptItem({ item, verbose, contentWidth }) {
|
|
10
|
+
return (_jsxs(Box, { marginBottom: item.kind === "tool" ? 0 : 1, flexDirection: "column", children: [item.kind === "user" && (_jsxs(Text, { children: [_jsxs(Text, { bold: true, color: "green", children: ["\u276F", " "] }), item.text] })), item.kind === "assistant" && _jsx(Markdown, { text: item.text, width: contentWidth }), item.kind === "tool" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [item.error ? _jsx(Text, { color: "red", children: "\u2717" }) : _jsx(Text, { color: "green", children: "\u2713" }), " ", item.summary, item.resultSummary && !verbose ? ` — ${item.resultSummary}` : ""] }), item.output && item.output.trim() && (item.resultSummary === undefined || verbose) && (_jsx(Text, { dimColor: true, children: toolOutputPreview(item.output, verbose, Math.max(20, contentWidth - TOOL_INDENT), item.error)
|
|
10
11
|
.split("\n")
|
|
11
12
|
.map((l) => ` ${l}`)
|
|
12
13
|
.join("\n") }))] })), item.kind === "info" && _jsx(Text, { dimColor: true, children: item.text }), item.kind === "banner" && _jsx(Banner, { subtitle: item.subtitle, compact: item.compact }), item.kind === "summary" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, color: "green", children: "\u2713 Completed" }), item.files.length > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Changed:" }), item.files.map((f) => (_jsxs(Text, { children: [" ", f] }, f)))] })) : (_jsx(Text, { dimColor: true, children: "No files changed." })), _jsxs(Text, { dimColor: true, children: ["Next: ", item.nextStep] })] }))] }));
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Opens a file in the user's editor, handing the terminal over while it runs.
|
|
4
|
+
*
|
|
5
|
+
* Ink's `suspendTerminal` stops rendering, stops consuming input, and puts
|
|
6
|
+
* the terminal back in the mode an interactive child expects (raw mode off,
|
|
7
|
+
* cursor visible, bracketed paste off). When the editor exits, Ink reapplies
|
|
8
|
+
* its own terminal state and repaints the whole UI from scratch, so the
|
|
9
|
+
* session continues exactly where it left off.
|
|
10
|
+
*
|
|
11
|
+
* If the user changed the file, the undo stack's existing file watcher
|
|
12
|
+
* reports it through `onExternalEdit` like any other outside edit — no
|
|
13
|
+
* extra change detection needed here.
|
|
14
|
+
*/
|
|
15
|
+
export async function openInEditor(suspendTerminal, absPath) {
|
|
16
|
+
await suspendTerminal(async () => {
|
|
17
|
+
const editor = process.env.VISUAL || process.env.EDITOR || "vi";
|
|
18
|
+
await new Promise((resolve) => {
|
|
19
|
+
let child;
|
|
20
|
+
try {
|
|
21
|
+
child = spawn(editor, [absPath], { stdio: "inherit" });
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Bad editor path — resolve instead of hanging the session.
|
|
25
|
+
resolve();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
// 'error' fires when the binary can't be launched at all.
|
|
29
|
+
child.on("error", () => resolve());
|
|
30
|
+
child.on("exit", () => resolve());
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
// Ink has restored the terminal and repainted by the time this resolves.
|
|
34
|
+
}
|
package/dist/ui/viewport.js
CHANGED
|
@@ -1,38 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Rows the live region below the streaming answer needs: the prompt box, the
|
|
3
|
+
* spinner and steer input, the status line, and a little breathing room. The
|
|
4
|
+
* live answer is clipped to what is left, so the prompt stays on screen while
|
|
5
|
+
* it streams.
|
|
6
|
+
*/
|
|
3
7
|
const RESERVED_ROWS = 8;
|
|
4
8
|
/** Never collapse the live view to nothing, however short the terminal. */
|
|
5
9
|
const MIN_ROWS = 4;
|
|
6
|
-
/**
|
|
7
|
-
* Rows held back against an estimate that comes in low. Underestimating by
|
|
8
|
-
* even one row brings back the bug this file exists to prevent, and the only
|
|
9
|
-
* cost of overestimating is showing slightly less of the live text.
|
|
10
|
-
*/
|
|
11
|
-
const SAFETY_ROWS = 4;
|
|
12
|
-
/** Columns the renderer itself works in: the terminal, less its padding. */
|
|
13
|
-
const RENDER_INSET = 2;
|
|
14
|
-
/** A table row wraps its cells; assume the worst rather than guess low. */
|
|
15
|
-
const TABLE_ROW_HEIGHT = 3;
|
|
16
|
-
/**
|
|
17
|
-
* Terminal rows a line will occupy once rendered. Measured with the same
|
|
18
|
-
* word-wrapping the renderer uses — dividing width by columns assumes words
|
|
19
|
-
* can be split mid-word, which under-counts every wrapped paragraph.
|
|
20
|
-
*/
|
|
21
|
-
function rowsFor(line, columns) {
|
|
22
|
-
const width = Math.max(1, columns - RENDER_INSET);
|
|
23
|
-
const wrapped = wrapInline(line, width).length;
|
|
24
|
-
return line.includes("|") ? Math.max(wrapped, TABLE_ROW_HEIGHT) : Math.max(1, wrapped);
|
|
25
|
-
}
|
|
26
|
-
/**
|
|
27
|
-
* The tail of a streaming answer that fits the viewport.
|
|
28
|
-
*
|
|
29
|
-
* Ink erases its live region by rewinding a line count, which can only reach
|
|
30
|
-
* what is still on screen. Let the streaming text grow past the terminal's
|
|
31
|
-
* height and the erase silently comes up short, stranding a partial copy in
|
|
32
|
-
* the scrollback — then the finished message prints below it and the answer
|
|
33
|
-
* appears twice. Showing only what fits keeps the erase exact; the whole
|
|
34
|
-
* answer is printed once when the turn ends.
|
|
35
|
-
*/
|
|
36
10
|
/**
|
|
37
11
|
* The terminal's size, falling back to 80x24 when it isn't known.
|
|
38
12
|
*
|
|
@@ -50,22 +24,15 @@ export function terminalColumns(stdout) {
|
|
|
50
24
|
export function terminalRows(stdout) {
|
|
51
25
|
return stdout?.rows || 24;
|
|
52
26
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
if (start === 0)
|
|
66
|
-
return text;
|
|
67
|
-
const tail = lines.slice(start);
|
|
68
|
-
// Reopen a fence the cut landed inside, so the tail doesn't render as prose.
|
|
69
|
-
const fencesDropped = lines.slice(0, start).filter((l) => l.trimStart().startsWith("```")).length;
|
|
70
|
-
return fencesDropped % 2 === 1 ? `\`\`\`\n${tail.join("\n")}` : tail.join("\n");
|
|
27
|
+
/**
|
|
28
|
+
* Rows the streaming answer may occupy: the terminal, less the live region
|
|
29
|
+
* that has to stay visible below it.
|
|
30
|
+
*
|
|
31
|
+
* This used to be the budget for slicing the answer by hand — wrapping every
|
|
32
|
+
* line to estimate the rows it would render at, then keeping the tail that
|
|
33
|
+
* fit. Ink 8 clips a `<Box>` natively (see StreamViewport.tsx), so the only
|
|
34
|
+
* thing left to decide is how much room the live region gets.
|
|
35
|
+
*/
|
|
36
|
+
export function streamRows(rows) {
|
|
37
|
+
return Math.max(MIN_ROWS, rows - RESERVED_ROWS);
|
|
71
38
|
}
|
package/dist/undo/undo.js
CHANGED
|
@@ -25,6 +25,8 @@ export class UndoStack {
|
|
|
25
25
|
entries = [];
|
|
26
26
|
redoStack = [];
|
|
27
27
|
turn = 0;
|
|
28
|
+
/** The file most recently snapshotted — i.e. last touched by the agent. */
|
|
29
|
+
lastTouched = null;
|
|
28
30
|
// File-watcher checkpointing. Scoped to files kritya has itself snapshotted
|
|
29
31
|
// this session — never a whole-repo recursive watch — so this can't pick up
|
|
30
32
|
// noise from build output, node_modules, or unrelated files, and can't leak
|
|
@@ -43,6 +45,7 @@ export class UndoStack {
|
|
|
43
45
|
}
|
|
44
46
|
/** Capture the current state of a file before it is modified. */
|
|
45
47
|
snapshot(absPath, relPath) {
|
|
48
|
+
this.lastTouched = { absPath, relPath };
|
|
46
49
|
this.entries.push({ relPath, absPath, content: readOrNull(absPath), turn: this.turn });
|
|
47
50
|
this.evictOldestTurnsOverCap();
|
|
48
51
|
// A fresh change invalidates any redo history.
|
|
@@ -51,6 +54,14 @@ export class UndoStack {
|
|
|
51
54
|
this.watchForExternalEdits(absPath, relPath);
|
|
52
55
|
this.scheduleOwnWriteSync(absPath);
|
|
53
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* The file the agent touched most recently this session, if any. Used by
|
|
59
|
+
* the Ctrl+E "open in $EDITOR" flow so there is always a sensible default
|
|
60
|
+
* target without the user naming a path.
|
|
61
|
+
*/
|
|
62
|
+
lastTouchedFile() {
|
|
63
|
+
return this.lastTouched;
|
|
64
|
+
}
|
|
54
65
|
/**
|
|
55
66
|
* Re-read the file once the own-write grace window closes, and record that
|
|
56
67
|
* as what we believe is on disk.
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CONFIG_DIR } from "../config/config.js";
|
|
4
|
+
import { debugLog } from "../config/debug.js";
|
|
5
|
+
import { writeFileAtomicSync } from "../atomicWrite.js";
|
|
6
|
+
import { VERSION } from "../version.js";
|
|
7
|
+
/**
|
|
8
|
+
* "Is a newer kritya out?" — a courtesy notice, so every path here is
|
|
9
|
+
* best-effort by design: it never throws, never blocks for long, and never
|
|
10
|
+
* runs when it would be in the way (see `updateCheckDisabled` and the TTY gate
|
|
11
|
+
* at the call sites). A version check that can break `kritya --version` or
|
|
12
|
+
* stall startup would be a worse bug than the staleness it reports.
|
|
13
|
+
*/
|
|
14
|
+
export const UPDATE_CHECK_DISABLED_ENV = "KRITYA_NO_UPDATE_CHECK";
|
|
15
|
+
const CACHE_FILE = path.join(CONFIG_DIR, "update-check.json");
|
|
16
|
+
/** How long a cached answer stays good. One registry hit per day, per machine. */
|
|
17
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
18
|
+
/** Kept short: this is a nicety, and it sits in front of a user's shell prompt. */
|
|
19
|
+
const REQUEST_TIMEOUT_MS = 2_000;
|
|
20
|
+
const REGISTRY_URL = "https://registry.npmjs.org/kritya";
|
|
21
|
+
/** npm's abbreviated packument — a fraction of the full document's size. */
|
|
22
|
+
const PACKUMENT_ACCEPT = "application/vnd.npm.install-v1+json";
|
|
23
|
+
export function updateCheckDisabled() {
|
|
24
|
+
const value = process.env[UPDATE_CHECK_DISABLED_ENV];
|
|
25
|
+
return value !== undefined && /^(1|true|yes|on)$/i.test(value.trim());
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Semver precedence: -1 if a < b, 0 if equal, 1 if a > b.
|
|
29
|
+
*
|
|
30
|
+
* Hand-rolled rather than pulled from a dependency because the only shapes
|
|
31
|
+
* this ever sees are our own release tags (`0.8.27-beta`), and a full semver
|
|
32
|
+
* implementation would be more code than it saves. Follows §11 where it
|
|
33
|
+
* matters: a release outranks any prerelease of the same core, and numeric
|
|
34
|
+
* prerelease identifiers rank below alphanumeric ones.
|
|
35
|
+
*/
|
|
36
|
+
export function compareVersions(a, b) {
|
|
37
|
+
const parse = (value) => {
|
|
38
|
+
const [core, ...rest] = value.trim().replace(/^v/, "").split("-");
|
|
39
|
+
return { nums: core.split(".").map((n) => Number(n) || 0), pre: rest.join("-") };
|
|
40
|
+
};
|
|
41
|
+
const left = parse(a);
|
|
42
|
+
const right = parse(b);
|
|
43
|
+
for (let i = 0; i < 3; i += 1) {
|
|
44
|
+
const x = left.nums[i] ?? 0;
|
|
45
|
+
const y = right.nums[i] ?? 0;
|
|
46
|
+
if (x !== y)
|
|
47
|
+
return x < y ? -1 : 1;
|
|
48
|
+
}
|
|
49
|
+
if (left.pre === right.pre)
|
|
50
|
+
return 0;
|
|
51
|
+
if (!left.pre)
|
|
52
|
+
return 1;
|
|
53
|
+
if (!right.pre)
|
|
54
|
+
return -1;
|
|
55
|
+
const li = left.pre.split(".");
|
|
56
|
+
const ri = right.pre.split(".");
|
|
57
|
+
for (let i = 0; i < Math.max(li.length, ri.length); i += 1) {
|
|
58
|
+
const x = li[i];
|
|
59
|
+
const y = ri[i];
|
|
60
|
+
if (x === undefined)
|
|
61
|
+
return -1;
|
|
62
|
+
if (y === undefined)
|
|
63
|
+
return 1;
|
|
64
|
+
const xNum = /^\d+$/.test(x);
|
|
65
|
+
const yNum = /^\d+$/.test(y);
|
|
66
|
+
if (xNum && yNum) {
|
|
67
|
+
const dx = Number(x);
|
|
68
|
+
const dy = Number(y);
|
|
69
|
+
if (dx !== dy)
|
|
70
|
+
return dx < dy ? -1 : 1;
|
|
71
|
+
}
|
|
72
|
+
else if (xNum !== yNum) {
|
|
73
|
+
return xNum ? -1 : 1;
|
|
74
|
+
}
|
|
75
|
+
else if (x !== y) {
|
|
76
|
+
return x < y ? -1 : 1;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The newest version across a packument's dist-tags.
|
|
83
|
+
*
|
|
84
|
+
* Deliberately the maximum of *all* tags rather than a single channel: this
|
|
85
|
+
* package currently only publishes prereleases under `beta`, while `latest`
|
|
86
|
+
* lags behind by design (it is moved by hand — see publish.yml). Reading only
|
|
87
|
+
* `latest` would mean the notice never fires for the people actually running
|
|
88
|
+
* betas.
|
|
89
|
+
*/
|
|
90
|
+
export function newestVersion(distTags) {
|
|
91
|
+
let newest = null;
|
|
92
|
+
for (const version of Object.values(distTags ?? {})) {
|
|
93
|
+
if (typeof version !== "string" || !version)
|
|
94
|
+
continue;
|
|
95
|
+
if (!newest || compareVersions(version, newest) > 0)
|
|
96
|
+
newest = version;
|
|
97
|
+
}
|
|
98
|
+
return newest;
|
|
99
|
+
}
|
|
100
|
+
export function isOutdated(current, latest) {
|
|
101
|
+
return latest !== null && compareVersions(latest, current) > 0;
|
|
102
|
+
}
|
|
103
|
+
/** The user-facing notice, or null when there's nothing worth saying. */
|
|
104
|
+
export function updateNotice(current, latest) {
|
|
105
|
+
if (!isOutdated(current, latest))
|
|
106
|
+
return null;
|
|
107
|
+
return (`A newer version of kritya is available: ${current} → ${latest}\n` +
|
|
108
|
+
` npm install -g kritya@${latest}`);
|
|
109
|
+
}
|
|
110
|
+
function readCache(file) {
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
113
|
+
if (typeof parsed.checkedAt !== "number" || typeof parsed.latest !== "string")
|
|
114
|
+
return null;
|
|
115
|
+
return { checkedAt: parsed.checkedAt, latest: parsed.latest };
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
// Missing on first run, or corrupt — either way, just check the registry.
|
|
119
|
+
debugLog(`readUpdateCache(${file})`, err);
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function writeCache(file, entry) {
|
|
124
|
+
try {
|
|
125
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
126
|
+
writeFileAtomicSync(file, `${JSON.stringify(entry, null, 2)}\n`, { mode: 0o600 });
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
// Not being able to remember the answer only costs one extra request.
|
|
130
|
+
debugLog(`writeUpdateCache(${file})`, err);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Resolve whether a newer version exists. Never rejects.
|
|
135
|
+
*
|
|
136
|
+
* A cached answer younger than the TTL is returned as-is; otherwise the
|
|
137
|
+
* registry is asked with a hard 2s timeout and the result is cached. Any
|
|
138
|
+
* failure resolves to `source: "unavailable"` — being offline is not an error
|
|
139
|
+
* worth showing anyone.
|
|
140
|
+
*/
|
|
141
|
+
export async function checkForUpdate(options = {}) {
|
|
142
|
+
const current = VERSION;
|
|
143
|
+
if (updateCheckDisabled()) {
|
|
144
|
+
return { current, latest: null, outdated: false, source: "disabled" };
|
|
145
|
+
}
|
|
146
|
+
const now = options.now ?? Date.now();
|
|
147
|
+
const cacheFile = options.cacheFile ?? CACHE_FILE;
|
|
148
|
+
if (!options.force) {
|
|
149
|
+
const cached = readCache(cacheFile);
|
|
150
|
+
if (cached && now - cached.checkedAt < CACHE_TTL_MS) {
|
|
151
|
+
return {
|
|
152
|
+
current,
|
|
153
|
+
latest: cached.latest,
|
|
154
|
+
outdated: isOutdated(current, cached.latest),
|
|
155
|
+
source: "cache",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
161
|
+
const response = await fetchImpl(REGISTRY_URL, {
|
|
162
|
+
headers: { accept: PACKUMENT_ACCEPT },
|
|
163
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
164
|
+
});
|
|
165
|
+
if (!response.ok)
|
|
166
|
+
throw new Error(`registry returned HTTP ${response.status}`);
|
|
167
|
+
const doc = (await response.json());
|
|
168
|
+
const latest = newestVersion(doc["dist-tags"]);
|
|
169
|
+
if (!latest)
|
|
170
|
+
throw new Error("registry response contained no dist-tags");
|
|
171
|
+
writeCache(cacheFile, { checkedAt: now, latest });
|
|
172
|
+
return { current, latest, outdated: isOutdated(current, latest), source: "registry" };
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
debugLog(`checkForUpdate(${REGISTRY_URL})`, err);
|
|
176
|
+
return {
|
|
177
|
+
current,
|
|
178
|
+
latest: null,
|
|
179
|
+
outdated: false,
|
|
180
|
+
source: "unavailable",
|
|
181
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** The notice to print for a user, or null. Never throws. */
|
|
186
|
+
export async function updateNoticeForUser(options = {}) {
|
|
187
|
+
const status = await checkForUpdate(options);
|
|
188
|
+
return updateNotice(status.current, status.latest);
|
|
189
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kritya",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.28-beta",
|
|
4
4
|
"description": "Kritya — a lean, provider-agnostic terminal coding agent (NVIDIA, OpenAI, OpenRouter, Groq, Ollama, and more)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"!dist/electron/**"
|
|
25
25
|
],
|
|
26
26
|
"engines": {
|
|
27
|
-
"node": ">=22"
|
|
27
|
+
"node": ">=22.19.0"
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
30
|
"build": "tsc",
|
|
@@ -37,8 +37,12 @@
|
|
|
37
37
|
"format:check": "prettier --check .",
|
|
38
38
|
"check:audit": "node scripts/check-audit.mjs",
|
|
39
39
|
"check:install-scripts": "node scripts/check-install-scripts.mjs",
|
|
40
|
+
"check:package": "node scripts/check-package.mjs",
|
|
41
|
+
"check:release": "node scripts/check-release.mjs",
|
|
42
|
+
"check:smoke": "node scripts/smoke-install.mjs",
|
|
43
|
+
"check:typescript-setup": "node scripts/check-typescript-setup.mjs",
|
|
40
44
|
"link": "npm run build && npm link",
|
|
41
|
-
"prepublishOnly": "npm run build",
|
|
45
|
+
"prepublishOnly": "npm run build && npm run check:package && npm run check:smoke",
|
|
42
46
|
"prepare": "husky",
|
|
43
47
|
"electron:dev": "npm run build && electron .",
|
|
44
48
|
"electron:build": "npm run build && electron-builder"
|
|
@@ -57,9 +61,7 @@
|
|
|
57
61
|
"dependencies": {
|
|
58
62
|
"docx": "^9.7.1",
|
|
59
63
|
"exceljs": "^4.4.0",
|
|
60
|
-
"
|
|
61
|
-
"ink": "^7.1.1",
|
|
62
|
-
"ink-text-input": "^6.0.0",
|
|
64
|
+
"ink": "^8.0.0",
|
|
63
65
|
"jszip": "^3.10.1",
|
|
64
66
|
"mammoth": "^1.12.2",
|
|
65
67
|
"openai": "^7.10.0",
|
|
@@ -68,7 +70,9 @@
|
|
|
68
70
|
"pptxgenjs": "^4.0.1",
|
|
69
71
|
"react": "^19.2.8",
|
|
70
72
|
"string-width": "^8.2.2",
|
|
71
|
-
"
|
|
73
|
+
"tinyglobby": "^0.2.17",
|
|
74
|
+
"undici": "^8.11.2",
|
|
75
|
+
"wrap-ansi": "^10.0.2"
|
|
72
76
|
},
|
|
73
77
|
"devDependencies": {
|
|
74
78
|
"@eslint/js": "^10.0.1",
|
|
@@ -82,14 +86,15 @@
|
|
|
82
86
|
"husky": "^9.1.7",
|
|
83
87
|
"ink-testing-library": "^4.0.0",
|
|
84
88
|
"lint-staged": "^17.4.1",
|
|
85
|
-
"prettier": "^3.
|
|
89
|
+
"prettier": "^3.9.0",
|
|
86
90
|
"tsx": "^4.23.13",
|
|
87
|
-
"typescript": "
|
|
91
|
+
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
92
|
+
"@typescript/native": "npm:typescript@^7.0.2",
|
|
88
93
|
"typescript-eslint": "^8.69.0"
|
|
89
94
|
},
|
|
90
95
|
"overrides": {
|
|
91
|
-
"brace-expansion": "^5.0.
|
|
92
|
-
"fast-uri": "^3.1.
|
|
96
|
+
"brace-expansion": "^5.0.12",
|
|
97
|
+
"fast-uri": "^3.1.8",
|
|
93
98
|
"exceljs": {
|
|
94
99
|
"uuid": "^11.1.1",
|
|
95
100
|
"unzipper": "^0.12.3",
|
|
@@ -118,10 +123,7 @@
|
|
|
118
123
|
"publish": null,
|
|
119
124
|
"files": [
|
|
120
125
|
"dist/**/*",
|
|
121
|
-
"electron/**/*"
|
|
122
|
-
"package.json",
|
|
123
|
-
"!src/**/*",
|
|
124
|
-
"!scripts/**/*"
|
|
126
|
+
"electron/**/*"
|
|
125
127
|
],
|
|
126
128
|
"win": {
|
|
127
129
|
"target": "nsis"
|