kritya 0.8.42-beta → 0.8.44-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.
@@ -10,7 +10,7 @@ import { isGitRepo } from "../agent/worktree.js";
10
10
  import { loadHooks } from "../hooks/hooks.js";
11
11
  import { loadProjectMcpServers, missingVars } from "../mcp/servers.js";
12
12
  import { scanSkillsDetailed, skillsDir, userSkillsDir } from "../agent/skills.js";
13
- import { defaultSandboxMode, sandboxAvailable, sandboxUnavailableReason, } from "../shell/sandbox.js";
13
+ import { defaultSandboxMode, runSandboxCanary, sandboxAvailable, sandboxUnavailableReason, } from "../shell/sandbox.js";
14
14
  import { SWITCHYARD_ROUTE_ID, resolveEffectiveModel } from "../provider/switchyardSidecar.js";
15
15
  import { gatedContentHash, isTrusted } from "../trust/trust.js";
16
16
  import { checkForUpdate, updateCheckDisabled } from "../update/check.js";
@@ -30,8 +30,10 @@ untrusted workspace).
30
30
 
31
31
  Checks: Node version against the package's engines, config file validity, the
32
32
  active provider and whether its key resolves and the endpoint answers, workspace
33
- trust and git state, MCP servers, sandbox availability, persistence/privacy
34
- settings, discovered skills and hooks, and whether a newer release exists.`;
33
+ trust and git state, MCP servers, sandbox availability and a live containment
34
+ canary (it tries to write outside the workspace and reports whether that was
35
+ blocked), persistence/privacy settings, discovered skills and hooks, and whether
36
+ a newer release exists.`;
35
37
  const MARKS = { ok: "✓", warn: "!", fail: "✗" };
36
38
  function ok(label) {
37
39
  return { level: "ok", label };
@@ -241,7 +243,7 @@ function workspaceSection(workspace, config) {
241
243
  }
242
244
  return { title: "Workspace", checks };
243
245
  }
244
- function safetySection(config) {
246
+ function safetySection(config, workspace) {
245
247
  const checks = [];
246
248
  const mode = config.sandboxExec ?? defaultSandboxMode();
247
249
  const available = sandboxAvailable();
@@ -261,6 +263,27 @@ function safetySection(config) {
261
263
  checks.push(warn(`sandboxExec is "${mode}" but ${sandboxUnavailableReason()} — sandboxed commands ` +
262
264
  `fall back to unsandboxed execution with a warning`));
263
265
  }
266
+ // A live self-test, not a policy inspection: availability says the wrapper
267
+ // can be *built*, but only running a canary proves this machine actually
268
+ // contains it. Skipped (never failed) when there is no backend — that case
269
+ // is already reported by the check above.
270
+ if (available && mode !== "off") {
271
+ const canary = runSandboxCanary(workspace);
272
+ for (const probe of canary.probes) {
273
+ if (probe.informational) {
274
+ checks.push(ok(probe.what));
275
+ }
276
+ else {
277
+ checks.push(probe.held ? ok(`sandbox canary: ${probe.what}`) : fail(`sandbox canary: ${probe.what}`));
278
+ }
279
+ }
280
+ }
281
+ else if (available) {
282
+ checks.push(ok("sandbox canary: skipped (sandboxExec is off)"));
283
+ }
284
+ else {
285
+ checks.push(ok("sandbox canary: skipped (no sandbox backend available)"));
286
+ }
264
287
  const privacy = privacyModeFor(config);
265
288
  checks.push(privacy
266
289
  ? warn("privacy mode is ON — transcripts, audit logs and telemetry are not written")
@@ -320,7 +343,7 @@ export async function collectDiagnostics(options = {}) {
320
343
  }
321
344
  sections.push(provider);
322
345
  sections.push(workspaceSection(workspace, config));
323
- sections.push(safetySection(config));
346
+ sections.push(safetySection(config, workspace));
324
347
  sections.push(extensionsSection(workspace, config));
325
348
  return sections;
326
349
  }
@@ -9,6 +9,7 @@ import { pluginsDir, pluginSkillsRoots, scanPlugins, scanPluginsDetailed, userPl
9
9
  import { scanPluginMcpServers } from "../plugins/mcp.js";
10
10
  import { listProviders } from "../config/config.js";
11
11
  import { expandCommand } from "./custom.js";
12
+ import { recordCommandUse } from "../ui/recentCommands.js";
12
13
  import { artifactPath, clearProjectState, loadProjectState, parseIdea, parsePhase, phaseBlocker, phasePrompt, renameProject, staleArtifacts, PHASE_COMMAND, PHASE_ORDER, PHASE_SUMMARY, saveProjectState, slugify, } from "../agent/workflow.js";
13
14
  const WORKFLOW = "Workflow";
14
15
  const SESSION = "Session";
@@ -162,8 +163,9 @@ Also: @path/to/file attaches a file to your message (with autocomplete).
162
163
  MCP servers can also contribute their own /server-prompt commands.
163
164
  Project memory: put standing instructions in KRITYA.md at your workspace root.
164
165
  Keys: Esc cancels · Tab completes · Shift+Tab cycles normal/accept-edits/dry-run
165
- mode · ↑/↓ recalls history · Ctrl+O toggles full tool output · Ctrl+K is the
166
- kill switch (stops everything until /kill off) · Ctrl+C exits`;
166
+ mode · ↑/↓ recalls history · Ctrl+R searches history · Ctrl+P opens the command
167
+ palette · Ctrl+B copies the last code block · Ctrl+O toggles full tool output ·
168
+ Ctrl+K is the kill switch (stops everything until /kill off) · Ctrl+C exits`;
167
169
  /**
168
170
  * A phase boundary is the one place compaction is unambiguously safe: the
169
171
  * phase that just ended wrote its conclusions to a durable artifact on disk,
@@ -833,6 +835,16 @@ export async function runCommand(cmd, ctx) {
833
835
  });
834
836
  return;
835
837
  }
838
+ // Rank the palette by what a user actually runs. Recorded here rather than
839
+ // when the palette inserts the command: insertion only fills the input line
840
+ // (the user may edit or abandon it), so that signal would rank
841
+ // typed-and-abandoned commands above the ones really used. An unknown command
842
+ // is not recorded — it never ran. See ui/recentCommands.ts.
843
+ const known = handlers[cmd] !== undefined ||
844
+ ctx.customCommands.some((c) => c.name === cmd) ||
845
+ mcpPrompts().some((p) => p.command === cmd);
846
+ if (known)
847
+ recordCommandUse(cmd);
836
848
  const handler = handlers[cmd];
837
849
  if (handler)
838
850
  return handler(ctx);
@@ -3,9 +3,29 @@ import fs from "node:fs";
3
3
  import { createRequire } from "node:module";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
+ import { CONFIG_DIR, loadDotEnv } from "../config/config.js";
6
7
  import { classifyDanger } from "../permissions/danger.js";
7
8
  let cachedTool;
8
9
  let cachedMxcExec;
10
+ let dotEnvLoaded = false;
11
+ /**
12
+ * Load `~/.kritya/.env` before the backend is resolved, once per process.
13
+ *
14
+ * `locateMxcExecutable` reads `KRITYA_MXC_EXEC` and `MXC_BIN_DIR` from the
15
+ * environment, but the sandbox module is callable from frontends that never
16
+ * load the global `.env` themselves (the tool executor reaches
17
+ * `sandboxAvailable()` directly). Without this, a user who configured the MXC
18
+ * path in `.env` would have it honoured only when some *other* entry point
19
+ * happened to load the file first — and because `sandboxTool()` memoizes its
20
+ * answer, a miss could not self-correct later in the session. Loading here
21
+ * makes discovery independent of who calls it first.
22
+ */
23
+ function loadSandboxEnv() {
24
+ if (dotEnvLoaded)
25
+ return;
26
+ dotEnvLoaded = true;
27
+ loadDotEnv([path.join(CONFIG_DIR, ".env")]);
28
+ }
9
29
  function commandExists(bin) {
10
30
  const finder = os.platform() === "win32" ? "where" : "which";
11
31
  try {
@@ -73,11 +93,13 @@ function mxcExecutable() {
73
93
  export function resetSandboxToolCache() {
74
94
  cachedTool = undefined;
75
95
  cachedMxcExec = undefined;
96
+ dotEnvLoaded = false;
76
97
  }
77
98
  /** Which sandbox backend (if any) is usable on this platform, cached after the first check. */
78
99
  function sandboxTool() {
79
100
  if (cachedTool !== undefined)
80
101
  return cachedTool;
102
+ loadSandboxEnv();
81
103
  const platform = os.platform();
82
104
  if (platform === "linux") {
83
105
  cachedTool = commandExists("bwrap") ? "bwrap" : null;
@@ -111,6 +133,117 @@ export function sandboxAvailable() {
111
133
  export function defaultSandboxMode() {
112
134
  return os.platform() === "win32" ? "strict" : "auto";
113
135
  }
136
+ /** Quoted for `cmd.exe` (Windows), where the canary command runs. */
137
+ function windowsQuote(value) {
138
+ return `"${value.replace(/"/g, '""')}"`;
139
+ }
140
+ /**
141
+ * Turns the observed state of the canary's two marker files into probes.
142
+ *
143
+ * Split out from `runSandboxCanary` so the decision — the part that can be
144
+ * wrong — is testable without a backend to spawn. `escaped` is "the file
145
+ * outside the workspace exists", `wroteInside` is "the file inside does".
146
+ *
147
+ * The three-way logic matters: `escaped && wroteInside` is a breached sandbox;
148
+ * `!escaped && wroteInside` is a healthy one; and `!escaped && !wroteInside`
149
+ * means the command never reached the shell at all, which must NOT be read as
150
+ * containment — that is why the inside write is a probe rather than a
151
+ * throwaway control.
152
+ */
153
+ export function canaryProbes(escaped, wroteInside) {
154
+ return [
155
+ {
156
+ what: "a write outside the workspace (system temp dir) was blocked",
157
+ held: !escaped,
158
+ },
159
+ {
160
+ what: "a write inside the workspace succeeded (the sandbox is not simply refusing everything)",
161
+ held: wroteInside,
162
+ },
163
+ {
164
+ what: "reads outside the workspace (incl. ~/.kritya/.env) are open by design",
165
+ held: false,
166
+ informational: true,
167
+ },
168
+ ];
169
+ }
170
+ /**
171
+ * A live containment check: run a canary *inside* the sandbox that tries to
172
+ * write outside the workspace, then report whether the write was actually
173
+ * blocked.
174
+ *
175
+ * CI can only test the policy this module *builds*; it cannot prove the
176
+ * backend on the user's machine enforces it (an MXC too old for
177
+ * `processcontainer`, a stale `wxc-exec.exe`, or a host missing the one-time
178
+ * privilege prep all change the answer without changing a line of code here).
179
+ * This closes that gap the only honest way — by trying to escape and looking
180
+ * at the result. It writes at most one marker file, in the system temp dir,
181
+ * and removes it either way.
182
+ *
183
+ * Only the *write* probe is a containment claim: reads outside the workspace
184
+ * are open by design (see `buildSandboxedCommand`), so the read probe is
185
+ * reported but never counted as a failure.
186
+ */
187
+ export function runSandboxCanary(workspace) {
188
+ const reason = sandboxUnavailableReason();
189
+ if (!sandboxAvailable()) {
190
+ return { ran: false, reason, probes: [] };
191
+ }
192
+ // The escape target: outside the workspace, guaranteed writable by the real
193
+ // user, so "the file is not there afterwards" can only mean the sandbox
194
+ // blocked it. A path *inside* the workspace would prove nothing.
195
+ const escapeTarget = path.join(os.tmpdir(), `kritya-canary-escape-${process.pid}.txt`);
196
+ const insideTarget = path.join(workspace, `.kritya-canary-inside-${process.pid}.txt`);
197
+ const cleanup = () => {
198
+ for (const f of [escapeTarget, insideTarget]) {
199
+ try {
200
+ fs.rmSync(f, { force: true });
201
+ }
202
+ catch {
203
+ /* best effort — a file that is not there is the expected case */
204
+ }
205
+ }
206
+ };
207
+ cleanup(); // in case a previous run left a marker behind
208
+ const isWindows = os.platform() === "win32";
209
+ const writeOutside = isWindows
210
+ ? `echo canary> ${windowsQuote(escapeTarget)}`
211
+ : `printf canary > ${JSON.stringify(escapeTarget)}`;
212
+ const writeInside = isWindows
213
+ ? `echo canary> ${windowsQuote(insideTarget)}`
214
+ : `printf canary > ${JSON.stringify(insideTarget)}`;
215
+ // The canary exits 0 regardless: reaching the shell at all is the point, and
216
+ // a nonzero exit from a *blocked* write is not something the caller needs.
217
+ const script = isWindows
218
+ ? `${writeOutside} & ${writeInside} & exit /b 0`
219
+ : `${writeOutside}; ${writeInside}; true`;
220
+ const wrapped = buildSandboxedCommand(script, workspace);
221
+ if (!wrapped) {
222
+ return { ran: false, reason, probes: [] };
223
+ }
224
+ const opts = wrapped.env && process.env ? { ...process.env, ...wrapped.env } : process.env;
225
+ try {
226
+ spawnSync(wrapped.cmd, wrapped.args, {
227
+ cwd: workspace,
228
+ env: opts,
229
+ timeout: 30_000,
230
+ windowsHide: true,
231
+ stdio: "ignore",
232
+ });
233
+ }
234
+ catch {
235
+ // A backend that cannot even start the container means containment is not
236
+ // in force — report the escape as *not* held rather than crashing doctor.
237
+ cleanup();
238
+ wrapped.cleanup?.();
239
+ return { ran: true, probes: canaryProbes(true, false) };
240
+ }
241
+ wrapped.cleanup?.();
242
+ const escaped = fs.existsSync(escapeTarget);
243
+ const wroteInside = fs.existsSync(insideTarget);
244
+ cleanup();
245
+ return { ran: true, probes: canaryProbes(escaped, wroteInside) };
246
+ }
114
247
  /** One-line reason sandboxing can't run here, for a fallback warning. */
115
248
  export function sandboxUnavailableReason() {
116
249
  const platform = os.platform();
package/dist/ui/App.js CHANGED
@@ -18,6 +18,10 @@ import { ElicitationPrompt } from "./ElicitationPrompt.js";
18
18
  import { PermissionPrompt } from "./PermissionPrompt.js";
19
19
  import { SelectList } from "./SelectList.js";
20
20
  import { buildPaletteItems } from "./palette.js";
21
+ import { searchHistory } from "./historySearch.js";
22
+ import { extractLastCodeBlock } from "./codeBlock.js";
23
+ import { copyToClipboard } from "./clipboard.js";
24
+ import { loadCommandRecency } from "./recentCommands.js";
21
25
  import { Spinner } from "./Spinner.js";
22
26
  import { StatusLine } from "./StatusLine.js";
23
27
  import { StreamViewport } from "./StreamViewport.js";
@@ -31,6 +35,26 @@ const MENTION_RE = /(^|\s)@([^\s@]*)$/;
31
35
  const MENTION_ALL_RE = /(?:^|\s)@([^\s@]+)/g;
32
36
  const MAX_MENTION_CHARS = 8000;
33
37
  const IMAGE_RE = /\.(png|jpe?g|gif|webp|bmp)$/i;
38
+ /**
39
+ * The most recent complete code block the *agent* wrote, newest first.
40
+ *
41
+ * Walks the transcript backwards so the newest block wins — the user asking
42
+ * "copy that" almost always means the one just shown — and reads only
43
+ * `assistant` items, because a block the user pasted is not the agent's.
44
+ * `extractLastCodeBlock` returns null for a still-open fence, so a block that
45
+ * is mid-stream is skipped rather than copied half-written.
46
+ */
47
+ function findLastAssistantCodeBlock(items) {
48
+ for (let i = items.length - 1; i >= 0; i--) {
49
+ const item = items[i];
50
+ if (item.kind !== "assistant")
51
+ continue;
52
+ const block = extractLastCodeBlock(item.text);
53
+ if (block)
54
+ return block;
55
+ }
56
+ return null;
57
+ }
34
58
  export function App({ agent, workspace, modelRef, providerRef, config, resumedCount, resumedName, initialTasks, undoStack, uiBridge, resumeSessions, customCommands = [], mcpToolCount = 0, onSwitchClient, onRequestPermissionReady, onRequestElicitationReady, privacyMode, firstLaunch, }) {
35
59
  const { exit, suspendTerminal, waitUntilRenderFlush } = useApp();
36
60
  /**
@@ -88,6 +112,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
88
112
  const [steerInput, setSteerInput] = useState("");
89
113
  const [resumeFilter, setResumeFilter] = useState("");
90
114
  const [paletteFilter, setPaletteFilter] = useState("");
115
+ const [historyFilter, setHistoryFilter] = useState("");
91
116
  const [cmdIndex, setCmdIndex] = useState(0);
92
117
  const [fileIndex, setFileIndex] = useState(0);
93
118
  const [fileList, setFileList] = useState([]);
@@ -95,6 +120,15 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
95
120
  const [elapsed, setElapsed] = useState(0);
96
121
  const inputHistory = useRef([]);
97
122
  const histIndex = useRef(-1); // -1 means "current, not browsing history"
123
+ // The Ctrl+R cursor: an index into the *filtered* history list (0 = newest
124
+ // match). A ref, not `cmdIndex`, so the palette's suggestion cursor and the
125
+ // history cursor can never clobber each other.
126
+ const historyCursor = useRef(0);
127
+ // Command recency for the palette's ordering. Read lazily when the palette
128
+ // opens rather than at mount: /doctor and a long session both run commands,
129
+ // and the file on disk is the source of truth. A ref (not state) because it
130
+ // only feeds the palette's sort — nothing else re-renders on it.
131
+ const commandRecency = useRef({});
98
132
  // Ctrl-chord bookkeeping used to live here: Ink delivers a keypress to every
99
133
  // `useInput` hook, and ink-text-input swallowed only Ctrl+C, so Ctrl+K and
100
134
  // Ctrl+O ran their shortcut *and* typed a bare "k"/"o" into the prompt — which
@@ -234,6 +268,33 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
234
268
  editorBusy.current = false;
235
269
  }
236
270
  }, [suspendTerminal, undoStack, addItem]);
271
+ // Ctrl+B: copy the agent's most recent complete code block. Scans the
272
+ // transcript backwards so the newest block wins, and only assistant text is
273
+ // considered — a user's own pasted block is not "the agent's most recent".
274
+ const copyLastCodeBlock = useCallback(async () => {
275
+ const block = findLastAssistantCodeBlock(items);
276
+ if (!block) {
277
+ addItem({
278
+ kind: "info",
279
+ text: "Ctrl+B: no code block in this transcript yet.",
280
+ });
281
+ return;
282
+ }
283
+ const ok = await copyToClipboard(block.code);
284
+ const lines = block.code.split("\n").length;
285
+ if (ok) {
286
+ addItem({
287
+ kind: "info",
288
+ text: `Copied the last code block (${lines} line${lines === 1 ? "" : "s"}${block.lang ? `, ${block.lang}` : ""}) to the clipboard.`,
289
+ });
290
+ }
291
+ else {
292
+ addItem({
293
+ kind: "info",
294
+ text: "Ctrl+B: no clipboard tool found (Linux needs wl-copy, xclip or xsel).",
295
+ });
296
+ }
297
+ }, [items, addItem]);
237
298
  useInput((_input, key) => {
238
299
  // Ctrl+K is the kill switch, and it comes before every other binding and
239
300
  // phase check on purpose: a panic button that only works from the idle
@@ -251,6 +312,9 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
251
312
  if (key.ctrl && _input === "p") {
252
313
  if (phase === "input") {
253
314
  setPaletteFilter("");
315
+ // Re-read on each open so a command run earlier in this session is
316
+ // reflected; a failed read degrades to the previous map, never throws.
317
+ commandRecency.current = loadCommandRecency();
254
318
  setPhase("palette");
255
319
  }
256
320
  else if (phase === "palette") {
@@ -258,6 +322,22 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
258
322
  }
259
323
  return;
260
324
  }
325
+ // Ctrl+R opens reverse history search — the whole in-memory history,
326
+ // filtered by fragment. ↑/↓ already walks history one entry at a time,
327
+ // which is no help for an entry forty back. Toggles closed like Ctrl+P.
328
+ // Idle prompt only, so it can't race a running turn.
329
+ if (key.ctrl && _input === "r") {
330
+ if (phase === "input") {
331
+ setHistoryFilter("");
332
+ histIndex.current = -1;
333
+ historyCursor.current = 0;
334
+ setPhase("history");
335
+ }
336
+ else if (phase === "history") {
337
+ setPhase("input");
338
+ }
339
+ return;
340
+ }
261
341
  if (key.escape && phase === "working") {
262
342
  setActivity("cancelling…");
263
343
  abortRef.current?.abort();
@@ -278,6 +358,14 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
278
358
  void openInEditorFlow();
279
359
  return;
280
360
  }
361
+ // Ctrl+B copies the agent's most recent code block to the clipboard. Works
362
+ // from any phase: it reads the transcript, not the input line, so it is
363
+ // safe mid-turn (and useful exactly then — the block just appeared) and
364
+ // unlike Ctrl+E it cannot race the agent over a shared resource.
365
+ if (key.ctrl && _input === "b") {
366
+ void copyLastCodeBlock();
367
+ return;
368
+ }
281
369
  // Shift+Tab cycles normal → accept-edits → plan → normal. Only from the
282
370
  // plain input state — not mid-permission-prompt, mid-resume-picker, etc.
283
371
  if (key.tab && key.shift && phase === "input") {
@@ -337,6 +425,37 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
337
425
  setPaletteFilter((f) => f + _input);
338
426
  }
339
427
  }
428
+ if (phase === "history") {
429
+ // Newest-first, so index 0 is the most recent match.
430
+ const matches = searchHistory([...inputHistory.current].reverse(), historyFilter);
431
+ if (key.backspace || key.delete) {
432
+ setHistoryFilter((f) => f.slice(0, -1));
433
+ historyCursor.current = 0; // a changed query invalidates the old selection
434
+ }
435
+ else if (key.upArrow) {
436
+ historyCursor.current = Math.max(historyCursor.current - 1, 0);
437
+ }
438
+ else if (key.downArrow) {
439
+ historyCursor.current = Math.min(historyCursor.current + 1, Math.max(matches.length - 1, 0));
440
+ }
441
+ else if (key.return) {
442
+ const picked = matches[Math.min(historyCursor.current, matches.length - 1)];
443
+ if (picked) {
444
+ setInput(picked.text);
445
+ setInputKey((k) => k + 1);
446
+ }
447
+ histIndex.current = -1; // the ↑/↓ browser starts fresh from the input
448
+ setPhase("input");
449
+ }
450
+ else if (key.escape) {
451
+ setPhase("input");
452
+ }
453
+ else if (_input && !key.tab) {
454
+ setHistoryFilter((f) => f + _input);
455
+ historyCursor.current = 0;
456
+ }
457
+ return;
458
+ }
340
459
  if (suggestions.length) {
341
460
  if (key.upArrow)
342
461
  setCmdIndex((i) => (i - 1 + suggestions.length) % suggestions.length);
@@ -424,7 +543,12 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
424
543
  setPhase("input");
425
544
  }
426
545
  };
427
- const paletteItems = buildPaletteItems(allCommands, agent.listCheckpoints(), paletteFilter);
546
+ const paletteItems = buildPaletteItems(allCommands, agent.listCheckpoints(), paletteFilter, commandRecency.current);
547
+ // Reverse history search rows. Built from the in-memory history (there is no
548
+ // disk history), newest first, which is why the list is reversed: the entry
549
+ // just typed is the one Ctrl+R should offer first.
550
+ const historyMatches = searchHistory([...inputHistory.current].reverse(), historyFilter);
551
+ const historySelected = Math.min(historyCursor.current, Math.max(historyMatches.length - 1, 0));
428
552
  const expandMentions = async (text) => {
429
553
  const mentions = [...new Set([...text.matchAll(MENTION_ALL_RE)].map((m) => m[1]))];
430
554
  let extra = "";
@@ -557,7 +681,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
557
681
  label: s.name || s.title,
558
682
  value: s.file,
559
683
  hint: `${s.shortId} · ${s.date} · ${s.count} msgs`,
560
- })), onSelect: onResumeSelect, onCancel: () => onResumeSelect("") })] })), phase === "palette" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["Command palette", " ", _jsx(Text, { dimColor: true, children: "(type to filter \u00B7 \u2191\u2193 navigate \u00B7 Enter selects \u00B7 Esc closes \u00B7 Ctrl+P toggles)" })] }), paletteFilter ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "filter: " }), paletteFilter] })) : null, _jsx(SelectList, { items: paletteItems, onSelect: onPaletteSelect, onCancel: () => setPhase("input") })] })), 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) => {
684
+ })), onSelect: onResumeSelect, onCancel: () => onResumeSelect("") })] })), phase === "palette" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["Command palette", " ", _jsx(Text, { dimColor: true, children: "(type to filter \u00B7 \u2191\u2193 navigate \u00B7 Enter selects \u00B7 Esc closes \u00B7 Ctrl+P toggles)" })] }), paletteFilter ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "filter: " }), paletteFilter] })) : null, _jsx(SelectList, { items: paletteItems, onSelect: onPaletteSelect, onCancel: () => setPhase("input") })] })), phase === "history" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "yellow", children: ["History", " ", _jsx(Text, { dimColor: true, children: "(type to search \u00B7 \u2191\u2193 navigate \u00B7 Enter selects \u00B7 Esc closes \u00B7 Ctrl+R toggles)" })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "query: " }), historyFilter || _jsx(Text, { dimColor: true, children: "(all recent prompts)" })] }), historyMatches.length === 0 ? (_jsx(Text, { dimColor: true, children: " no matches" })) : (historyMatches.slice(0, 10).map((m, i) => (_jsxs(Text, { color: i === historySelected ? "green" : undefined, children: [i === historySelected ? "❯ " : " ", m.text] }, `${m.index}:${m.text}`))))] })), 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) => {
561
685
  setInput(v.replace(/\t/g, ""));
562
686
  setCmdIndex(0);
563
687
  setFileIndex(0);
@@ -0,0 +1,83 @@
1
+ import { spawn } from "node:child_process";
2
+ import os from "node:os";
3
+ /**
4
+ * Copies text to the system clipboard.
5
+ *
6
+ * Deliberately shelling out rather than taking a dependency: every OS already
7
+ * ships a clipboard command, and the alternatives (a native module, an OSC 52
8
+ * escape) either add a build step or only work in terminals that opt in. OSC 52
9
+ * was tempting — it needs no subprocess and works over SSH — but it silently
10
+ * does nothing in terminals that refuse it, and this feature is only useful if
11
+ * the paste actually lands.
12
+ *
13
+ * Returns true only when the write completed cleanly, so the caller can say
14
+ * "copied" or "couldn't copy" honestly rather than claiming success blindly.
15
+ */
16
+ /** The clipboard command for this platform, or null where there is none. */
17
+ function clipboardCommand() {
18
+ switch (os.platform()) {
19
+ case "darwin":
20
+ return { cmd: "pbcopy", args: [] };
21
+ case "win32":
22
+ // `clip` is present on every Windows install; `clip.exe` is what
23
+ // Git Bash and WSL resolve it to, and CreateProcess finds either.
24
+ return { cmd: "clip", args: [] };
25
+ default:
26
+ // Linux/BSD: Wayland first (wl-copy), then X11 (xclip, then xsel).
27
+ // There is no "just use the right one" — which is available depends on
28
+ // the session — so the caller gets null and reports it as unavailable
29
+ // rather than picking a tool that will exit with ENOENT.
30
+ return null;
31
+ }
32
+ }
33
+ /**
34
+ * Linux has no single guaranteed clipboard tool, so probe the two common ones.
35
+ * Kept separate from the platform switch so the probe is a plain lookup the
36
+ * tests can reason about.
37
+ */
38
+ export function linuxClipboardCandidates() {
39
+ return [
40
+ { cmd: "wl-copy", args: [] },
41
+ { cmd: "xclip", args: ["-selection", "clipboard"] },
42
+ { cmd: "xsel", args: ["--clipboard", "--input"] },
43
+ ];
44
+ }
45
+ function writeTo(cmd, args, text) {
46
+ return new Promise((resolve) => {
47
+ let child;
48
+ try {
49
+ child = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] });
50
+ }
51
+ catch {
52
+ resolve(false);
53
+ return;
54
+ }
55
+ // A tool that is not installed fails here; a missing binary is not an
56
+ // error worth surfacing, just "this method didn't work".
57
+ child.on("error", () => resolve(false));
58
+ child.on("close", (code) => resolve(code === 0));
59
+ try {
60
+ child.stdin.end(text);
61
+ }
62
+ catch {
63
+ resolve(false);
64
+ }
65
+ });
66
+ }
67
+ /**
68
+ * Copy `text` to the clipboard. Resolves false (never rejects) when no
69
+ * clipboard method is available, so a keybinding can report the failure in the
70
+ * transcript instead of crashing the session.
71
+ */
72
+ export async function copyToClipboard(text) {
73
+ const direct = clipboardCommand();
74
+ if (direct)
75
+ return writeTo(direct.cmd, direct.args, text);
76
+ if (os.platform() === "linux") {
77
+ for (const candidate of linuxClipboardCandidates()) {
78
+ if (await writeTo(candidate.cmd, candidate.args, text))
79
+ return true;
80
+ }
81
+ }
82
+ return false;
83
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Pulls the last fenced code block out of agent output, so one keystroke can
3
+ * copy it to the clipboard.
4
+ *
5
+ * The agent writes fenced blocks (` ```ts … ``` `) constantly and copying one
6
+ * by hand means selecting across a wrapped, scrollable transcript — fiddly at
7
+ * best and wrong at worst. This finds the last complete block and returns just
8
+ * its body, with the language tag kept separately for the caller's message.
9
+ *
10
+ * Only *complete* blocks count: an unterminated fence (output still streaming)
11
+ * would otherwise hand back a half-written snippet. The fence parsing mirrors
12
+ * Markdown.tsx (`line.trimStart().startsWith("```")`), including the rule that
13
+ * the info string is whatever follows the three backticks.
14
+ */
15
+ /**
16
+ * The last complete fenced block in `text`, or null when there is none.
17
+ *
18
+ * A block with no closing fence is ignored, not returned partially — that is
19
+ * what makes this safe to call while output is still arriving.
20
+ */
21
+ export function extractLastCodeBlock(text) {
22
+ const lines = text.split("\n");
23
+ let inCode = false;
24
+ let lang = "";
25
+ let current = [];
26
+ let last = null;
27
+ for (const line of lines) {
28
+ if (line.trimStart().startsWith("```")) {
29
+ if (inCode) {
30
+ // Closing fence: the block is complete, so it becomes the latest
31
+ // candidate. A later block simply overwrites it.
32
+ last = { code: current.join("\n"), lang };
33
+ current = [];
34
+ lang = "";
35
+ inCode = false;
36
+ }
37
+ else {
38
+ lang = line.trim().slice(3).trim().toLowerCase();
39
+ inCode = true;
40
+ }
41
+ continue;
42
+ }
43
+ if (inCode)
44
+ current.push(line);
45
+ }
46
+ // A trailing opener that never closed leaves `inCode` true with `last` still
47
+ // holding the previous complete block — which is the one to return.
48
+ return last;
49
+ }
@@ -0,0 +1,24 @@
1
+ import { fuzzyMatch } from "./palette.js";
2
+ /**
3
+ * Match `query` against `history` (newest-first is the expected order) and
4
+ * return the entries that match, preserving that order. An empty query matches
5
+ * everything, which is what makes Ctrl+R useful before a fragment is typed:
6
+ * it shows the recent prompts immediately.
7
+ *
8
+ * Duplicates are collapsed by text, keeping the newest occurrence — pressing
9
+ * Ctrl+R after typing a prompt three times should not offer it three times.
10
+ */
11
+ export function searchHistory(history, query) {
12
+ const seen = new Set();
13
+ const out = [];
14
+ for (let index = 0; index < history.length; index++) {
15
+ const text = history[index];
16
+ if (!fuzzyMatch(text, query))
17
+ continue;
18
+ if (seen.has(text))
19
+ continue;
20
+ seen.add(text);
21
+ out.push({ text, index });
22
+ }
23
+ return out;
24
+ }
@@ -24,10 +24,22 @@ export function fuzzyMatch(haystack, needle) {
24
24
  * lookup — `cmd:/rename` inserts the command into the input line (never runs
25
25
  * it blind: a fuzzy finder that executes on Enter is how conversations get
26
26
  * cleared by accident), `checkpoint:<name>` rewinds to that checkpoint.
27
+ *
28
+ * `recency` (command name → last-run epoch ms) floats recently *used*
29
+ * commands above the rest. Only the unfiltered ordering is affected: once a
30
+ * user is typing a fragment they are looking for that fragment, and reordering
31
+ * under the cursor as matches arrive would be the opposite of helpful. Within
32
+ * each group the sort is stable, so the declaration order stays the tiebreak
33
+ * and the list is still predictable on a fresh install (`recency` omitted).
27
34
  */
28
- export function buildPaletteItems(commands, checkpoints, filter) {
35
+ export function buildPaletteItems(commands, checkpoints, filter, recency) {
29
36
  const items = [];
30
- for (const c of commands) {
37
+ // Sort first, filter second: filtering cannot change the relative order, and
38
+ // doing it once here avoids re-sorting an already-sorted list per item.
39
+ const ordered = !recency || filter.trim()
40
+ ? commands
41
+ : [...commands].sort((a, b) => (recency[b.name] ?? 0) - (recency[a.name] ?? 0));
42
+ for (const c of ordered) {
31
43
  if (!fuzzyMatch(`${c.name} ${c.description}`, filter))
32
44
  continue;
33
45
  items.push({ label: c.name, value: `cmd:${c.name}`, hint: c.description });
@@ -0,0 +1,73 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { CONFIG_DIR } from "../config/config.js";
4
+ import { hardenWindowsDir } from "../config/winAcl.js";
5
+ import { debugLog } from "../config/debug.js";
6
+ /**
7
+ * When each slash command was last run, so the palette can float the ones a
8
+ * user actually reaches for to the top (the way VS Code's recent list does).
9
+ *
10
+ * Deliberately keyed on *execution*, not on insertion: the palette inserts a
11
+ * command into the input line rather than running it (a fuzzy finder that runs
12
+ * on Enter is how conversations get cleared by accident), so a recency signal
13
+ * recorded at insert time would rank the commands someone typed-and-abandoned
14
+ * above the ones they ran. The write happens in `runCommand` for exactly that
15
+ * reason.
16
+ *
17
+ * Only commands are tracked. Checkpoints are ephemeral (they live for one
18
+ * session) and have no stable identity to rank across launches.
19
+ *
20
+ * Same persistence shape as bannerSeen.ts / aiDisclosure.ts: a JSON map under
21
+ * ~/.kritya, 0o700 dir, 0o600 file, ACL-hardened on Windows, and a `storeFile`
22
+ * parameter as the test seam.
23
+ */
24
+ const RECENT_FILE = path.join(CONFIG_DIR, "recent-commands.json");
25
+ /** How many commands the file remembers. Small: this only breaks ties. */
26
+ const MAX_TRACKED = 40;
27
+ function loadStore(storeFile) {
28
+ try {
29
+ const parsed = JSON.parse(fs.readFileSync(storeFile, "utf8"));
30
+ if (!parsed || typeof parsed !== "object")
31
+ return {};
32
+ const out = {};
33
+ for (const [name, at] of Object.entries(parsed)) {
34
+ if (typeof at === "number" && Number.isFinite(at))
35
+ out[name] = at;
36
+ }
37
+ return out;
38
+ }
39
+ catch (err) {
40
+ // Missing is normal (nothing used yet); malformed just means the ordering
41
+ // starts fresh, which is worth being able to see but never fatal.
42
+ debugLog(`loadStore(${storeFile})`, err);
43
+ return {};
44
+ }
45
+ }
46
+ /** The recorded recency map, for passing into `buildPaletteItems`. */
47
+ export function loadCommandRecency(storeFile = RECENT_FILE) {
48
+ return loadStore(storeFile);
49
+ }
50
+ /**
51
+ * Record that `name` was just run. Never throws: a palette ordering hint is
52
+ * not worth failing a command over, so a read-only home directory degrades to
53
+ * "the order just doesn't change".
54
+ */
55
+ export function recordCommandUse(name, storeFile = RECENT_FILE, at = Date.now()) {
56
+ try {
57
+ const store = loadStore(storeFile);
58
+ store[name] = at;
59
+ // Bound the file: keep the most recent MAX_TRACKED, drop the rest. Without
60
+ // this it grows by one key per distinct command forever, and the names of
61
+ // commands a user tried once a year ago are not worth a line each.
62
+ const entries = Object.entries(store).sort((a, b) => b[1] - a[1]);
63
+ const trimmed = {};
64
+ for (const [key, value] of entries.slice(0, MAX_TRACKED))
65
+ trimmed[key] = value;
66
+ fs.mkdirSync(path.dirname(storeFile), { recursive: true, mode: 0o700 });
67
+ hardenWindowsDir(path.dirname(storeFile));
68
+ fs.writeFileSync(storeFile, JSON.stringify(trimmed, null, 2) + "\n", { mode: 0o600 });
69
+ }
70
+ catch (err) {
71
+ debugLog(`recordCommandUse(${name})`, err);
72
+ }
73
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.42-beta",
3
+ "version": "0.8.44-beta",
4
4
  "description": "Kritya — Superintelligence at your command line",
5
5
  "type": "module",
6
6
  "publishConfig": {