kritya 0.8.43-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.
- package/dist/commands/doctor.js +28 -5
- package/dist/commands/registry.js +14 -2
- package/dist/shell/sandbox.js +111 -0
- package/dist/ui/App.js +126 -2
- package/dist/ui/clipboard.js +83 -0
- package/dist/ui/codeBlock.js +49 -0
- package/dist/ui/historySearch.js +24 -0
- package/dist/ui/palette.js +14 -2
- package/dist/ui/recentCommands.js +73 -0
- package/package.json +1 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
|
34
|
-
|
|
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+
|
|
166
|
-
|
|
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);
|
package/dist/shell/sandbox.js
CHANGED
|
@@ -133,6 +133,117 @@ export function sandboxAvailable() {
|
|
|
133
133
|
export function defaultSandboxMode() {
|
|
134
134
|
return os.platform() === "win32" ? "strict" : "auto";
|
|
135
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
|
+
}
|
|
136
247
|
/** One-line reason sandboxing can't run here, for a fallback warning. */
|
|
137
248
|
export function sandboxUnavailableReason() {
|
|
138
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
|
+
}
|
package/dist/ui/palette.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|