kritya 0.8.25-beta → 0.8.27-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,14 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { Banner } from "./Banner.js";
4
4
  import { Markdown } from "./Markdown.js";
5
5
  import { toolOutputPreview } from "./toolOutputPreview.js";
6
- import { terminalColumns } from "./viewport.js";
6
+ /** Indent tool output is printed under (see the ` ` prefix below). */
7
+ const TOOL_INDENT = 4;
7
8
  /** Renders one line of the transcript — a user message, assistant reply, tool call, info line, or banner. */
8
- export function TranscriptItem({ item, verbose, stdout }) {
9
- return (_jsxs(Box, { marginBottom: item.kind === "tool" ? 0 : 1, flexDirection: "column", children: [item.kind === "user" && (_jsxs(Text, { children: [_jsxs(Text, { bold: true, color: "green", children: ["\u276F", " "] }), item.text] })), item.kind === "assistant" && _jsx(Markdown, { text: item.text }), item.kind === "tool" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [item.error ? _jsx(Text, { color: "red", children: "\u2717" }) : _jsx(Text, { color: "green", children: "\u2713" }), " ", item.summary, item.resultSummary && !verbose ? ` — ${item.resultSummary}` : ""] }), item.output && item.output.trim() && (item.resultSummary === undefined || verbose) && (_jsx(Text, { dimColor: true, children: toolOutputPreview(item.output, verbose, Math.max(20, terminalColumns(stdout) - 6), item.error)
9
+ export function TranscriptItem({ item, verbose, contentWidth }) {
10
+ return (_jsxs(Box, { marginBottom: item.kind === "tool" ? 0 : 1, flexDirection: "column", children: [item.kind === "user" && (_jsxs(Text, { children: [_jsxs(Text, { bold: true, color: "green", children: ["\u276F", " "] }), item.text] })), item.kind === "assistant" && _jsx(Markdown, { text: item.text, width: contentWidth }), item.kind === "tool" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [item.error ? _jsx(Text, { color: "red", children: "\u2717" }) : _jsx(Text, { color: "green", children: "\u2713" }), " ", item.summary, item.resultSummary && !verbose ? ` — ${item.resultSummary}` : ""] }), item.output && item.output.trim() && (item.resultSummary === undefined || verbose) && (_jsx(Text, { dimColor: true, children: toolOutputPreview(item.output, verbose, Math.max(20, contentWidth - TOOL_INDENT), item.error)
10
11
  .split("\n")
11
12
  .map((l) => ` ${l}`)
12
- .join("\n") }))] })), item.kind === "info" && _jsx(Text, { dimColor: true, children: item.text }), item.kind === "banner" && _jsx(Banner, { subtitle: item.subtitle, compact: item.compact })] }));
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] })] }))] }));
13
14
  }
@@ -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
+ }
@@ -7,12 +7,27 @@ import { ProviderClient, RetryExhaustedError, friendlyProviderErrorHint, } from
7
7
  import { createSwitchyardClient } from "../provider/switchyardClient.js";
8
8
  import { SWITCHYARD_ROUTE_ID, resolveEffectiveModel } from "../provider/switchyardSidecar.js";
9
9
  import { KillSwitchError } from "../agent/killSwitch.js";
10
+ import { defaultSandboxMode, sandboxAvailable } from "../shell/sandbox.js";
10
11
  import { loadProjectState, nextPhase, PHASE_COMMAND, } from "../agent/workflow.js";
11
12
  import { tavilySearch } from "../tools/webSearch.js";
12
13
  import { killActiveNotice, useKillSwitch } from "./useKillSwitch.js";
13
14
  import { useUsageBudget } from "./useUsageBudget.js";
14
15
  import { useSessionResume } from "./useSessionResume.js";
15
16
  import { activePersistenceWarnings, onPersistenceWarning } from "../config/debug.js";
17
+ /** Tool names whose successful calls mutate a file the user would want to review. */
18
+ const FILE_MUTATING_TOOLS = new Set([
19
+ "edit_file",
20
+ "write_file",
21
+ "edit_notebook",
22
+ "edit_spreadsheet",
23
+ "edit_pdf",
24
+ "write_document",
25
+ ]);
26
+ /** Pulls the path out of a tool summary like "Edit src/foo.ts" or "Write src/foo.ts (12 bytes)". */
27
+ function pathFromToolSummary(summary) {
28
+ const match = summary.match(/^\S+\s+(\S+)/);
29
+ return match?.[1];
30
+ }
16
31
  function initialToolStatus(name) {
17
32
  if (name.startsWith("mcp_"))
18
33
  return "contacting MCP server…";
@@ -102,8 +117,13 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
102
117
  * workflow's own `planMode`. */
103
118
  const [dryRunMode, setDryRunMode] = useState(false);
104
119
  const [acceptEdits, setAcceptEdits] = useState(false);
120
+ const [bypassMode, setBypassMode] = useState(false);
105
121
  const [autoApprovedCount, setAutoApprovedCount] = useState(0);
106
122
  const hasConfirmedAcceptEdits = useRef(false);
123
+ const hasConfirmedBypassMode = useRef(false);
124
+ /** Whether auto/bypass mode is even reachable — requires a real sandbox
125
+ * backend on this platform (Windows never has one; see sandboxAvailable). */
126
+ const sandboxActive = (config.sandboxExec ?? defaultSandboxMode()) !== "off" && sandboxAvailable();
107
127
  const abortRef = useRef(null);
108
128
  /** Tool calls currently running, keyed by call id — more than one when a
109
129
  * turn's read-only calls are dispatched in parallel. Rendered as live rows. */
@@ -192,12 +212,44 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
192
212
  "more for normal.",
193
213
  });
194
214
  };
195
- /** Cycles normal → accept-edits → dry-run → normal. First entry into accept-edits this session pauses on a confirmation instead of switching immediately. */
215
+ const enterBypassMode = () => {
216
+ hasConfirmedBypassMode.current = true;
217
+ agent.bypassMode = true;
218
+ setBypassMode(true);
219
+ setAutoApprovedCount(0);
220
+ addItem({
221
+ kind: "info",
222
+ text: "Auto mode ON — EVERY tool call auto-approves, including destructive shell commands " +
223
+ "(rm -rf, force-push, etc.). Relies on the sandbox, not a human, to contain damage. " +
224
+ "Shift+Tab again for normal.",
225
+ });
226
+ };
227
+ /**
228
+ * Cycles normal → accept-edits → dry-run → auto (only when the sandbox is
229
+ * active) → normal. First entry into accept-edits or auto mode this session
230
+ * pauses on a confirmation instead of switching immediately. When the
231
+ * sandbox isn't active, auto mode is skipped and dry-run goes straight back
232
+ * to normal, same as before this mode existed.
233
+ */
196
234
  const cycleMode = () => {
235
+ if (bypassMode) {
236
+ agent.bypassMode = false;
237
+ setBypassMode(false);
238
+ addItem({ kind: "info", text: "Auto mode OFF — back to normal, prompts resume." });
239
+ return;
240
+ }
197
241
  if (dryRunMode) {
198
242
  agent.dryRunMode = false;
199
243
  setDryRunMode(false);
200
- addItem({ kind: "info", text: "Dry-run mode OFF — the agent can make changes again." });
244
+ if (!sandboxActive) {
245
+ addItem({ kind: "info", text: "Dry-run mode OFF — the agent can make changes again." });
246
+ return;
247
+ }
248
+ if (!hasConfirmedBypassMode.current) {
249
+ setPhase("confirmBypassMode");
250
+ return;
251
+ }
252
+ enterBypassMode();
201
253
  return;
202
254
  }
203
255
  if (acceptEdits) {
@@ -222,6 +274,13 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
222
274
  if (confirmed)
223
275
  enterAcceptEdits();
224
276
  };
277
+ const onBypassModeConfirm = (confirmed) => {
278
+ setPhase("input");
279
+ if (confirmed)
280
+ enterBypassMode();
281
+ else
282
+ addItem({ kind: "info", text: "Auto mode declined — back to normal." });
283
+ };
225
284
  const setModelEverywhere = (id) => {
226
285
  modelRef.current = id;
227
286
  setModel(id);
@@ -387,6 +446,8 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
387
446
  const ac = new AbortController();
388
447
  abortRef.current = ac;
389
448
  setInFlight([]);
449
+ const changedFiles = new Set();
450
+ let turnSucceeded = false;
390
451
  try {
391
452
  await agent.runTurn(text, {
392
453
  onTextDelta: (delta) => {
@@ -408,6 +469,11 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
408
469
  },
409
470
  onToolEnd: (id, name, summary, preview, isError, resultSummary) => {
410
471
  setInFlight((prev) => prev.filter((t) => t.id !== id));
472
+ if (!isError && FILE_MUTATING_TOOLS.has(name)) {
473
+ const p = pathFromToolSummary(summary);
474
+ if (p)
475
+ changedFiles.add(p);
476
+ }
411
477
  if (name !== "update_tasks")
412
478
  addItem({
413
479
  kind: "tool",
@@ -439,6 +505,7 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
439
505
  recordUsage(usage);
440
506
  },
441
507
  }, ac.signal, images);
508
+ turnSucceeded = true;
442
509
  }
443
510
  catch (err) {
444
511
  const isAbort = (err instanceof Error && err.name === "AbortError") ||
@@ -476,6 +543,13 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
476
543
  addItem({ kind: "info", text });
477
544
  }
478
545
  finally {
546
+ if (turnSucceeded && changedFiles.size > 0) {
547
+ addItem({
548
+ kind: "summary",
549
+ files: [...changedFiles],
550
+ nextStep: "review with /diff · revert with /undo",
551
+ });
552
+ }
479
553
  abortRef.current = null;
480
554
  setInFlight([]);
481
555
  setStream("");
@@ -554,9 +628,13 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
554
628
  releaseKill,
555
629
  acceptEdits,
556
630
  setAcceptEdits,
631
+ bypassMode,
632
+ setBypassMode,
633
+ sandboxActive,
557
634
  autoApprovedCount,
558
635
  cycleMode,
559
636
  onAcceptEditsConfirm,
637
+ onBypassModeConfirm,
560
638
  abortRef,
561
639
  setModelEverywhere,
562
640
  setProviderEverywhere,
@@ -1,38 +1,12 @@
1
- import { wrapInline } from "./inline.js";
2
- /** Rows below the streaming region: prompt, status line, spinner, breathing room. */
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
- export function tailForViewport(text, columns, rows) {
54
- const budget = Math.max(MIN_ROWS, rows - RESERVED_ROWS - SAFETY_ROWS);
55
- const lines = text.split("\n");
56
- let used = 0;
57
- let start = lines.length;
58
- while (start > 0) {
59
- const next = used + rowsFor(lines[start - 1], columns);
60
- if (next > budget)
61
- break;
62
- used = next;
63
- start--;
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.25-beta",
3
+ "version": "0.8.27-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,6 +37,7 @@
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:typescript-setup": "node scripts/check-typescript-setup.mjs",
40
41
  "link": "npm run build && npm link",
41
42
  "prepublishOnly": "npm run build",
42
43
  "prepare": "husky",
@@ -57,9 +58,7 @@
57
58
  "dependencies": {
58
59
  "docx": "^9.7.1",
59
60
  "exceljs": "^4.4.0",
60
- "fast-glob": "^3.3.3",
61
- "ink": "^7.1.1",
62
- "ink-text-input": "^6.0.0",
61
+ "ink": "^8.0.0",
63
62
  "jszip": "^3.10.1",
64
63
  "mammoth": "^1.12.2",
65
64
  "openai": "^7.10.0",
@@ -68,7 +67,9 @@
68
67
  "pptxgenjs": "^4.0.1",
69
68
  "react": "^19.2.8",
70
69
  "string-width": "^8.2.2",
71
- "undici": "^7.29.0"
70
+ "tinyglobby": "^0.2.17",
71
+ "undici": "^8.11.2",
72
+ "wrap-ansi": "^10.0.2"
72
73
  },
73
74
  "devDependencies": {
74
75
  "@eslint/js": "^10.0.1",
@@ -82,14 +83,15 @@
82
83
  "husky": "^9.1.7",
83
84
  "ink-testing-library": "^4.0.0",
84
85
  "lint-staged": "^17.4.1",
85
- "prettier": "^3.3.0",
86
+ "prettier": "^3.9.0",
86
87
  "tsx": "^4.23.13",
87
- "typescript": "^6.0.3",
88
+ "typescript": "npm:@typescript/typescript6@^6.0.2",
89
+ "@typescript/native": "npm:typescript@^7.0.2",
88
90
  "typescript-eslint": "^8.69.0"
89
91
  },
90
92
  "overrides": {
91
- "brace-expansion": "^5.0.8",
92
- "fast-uri": "^3.1.7",
93
+ "brace-expansion": "^5.0.12",
94
+ "fast-uri": "^3.1.8",
93
95
  "exceljs": {
94
96
  "uuid": "^11.1.1",
95
97
  "unzipper": "^0.12.3",
@@ -118,10 +120,7 @@
118
120
  "publish": null,
119
121
  "files": [
120
122
  "dist/**/*",
121
- "electron/**/*",
122
- "package.json",
123
- "!src/**/*",
124
- "!scripts/**/*"
123
+ "electron/**/*"
125
124
  ],
126
125
  "win": {
127
126
  "target": "nsis"