@oh-my-pi/pi-coding-agent 16.1.14 → 16.1.16
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/CHANGELOG.md +69 -0
- package/dist/cli.js +3705 -4015
- package/dist/types/advisor/runtime.d.ts +3 -0
- package/dist/types/cli/args.d.ts +2 -5
- package/dist/types/cli/flag-tables.d.ts +2 -2
- package/dist/types/cli/session-picker.d.ts +4 -2
- package/dist/types/commands/launch.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +32 -1
- package/dist/types/eval/agent-bridge.d.ts +19 -0
- package/dist/types/eval/js/shared/helpers.d.ts +1 -13
- package/dist/types/eval/js/shared/types.d.ts +1 -1
- package/dist/types/eval/js/worker-protocol.d.ts +1 -1
- package/dist/types/eval/py/executor.d.ts +1 -1
- package/dist/types/export/share.d.ts +8 -1
- package/dist/types/internal-urls/local-protocol.d.ts +18 -1
- package/dist/types/main.d.ts +2 -0
- package/dist/types/mcp/transports/stdio.d.ts +12 -1
- package/dist/types/modes/components/plugin-settings.d.ts +5 -0
- package/dist/types/modes/components/session-selector.d.ts +25 -0
- package/dist/types/modes/components/status-line/context-thresholds.d.ts +4 -3
- package/dist/types/modes/components/status-line/types.d.ts +1 -0
- package/dist/types/secrets/obfuscator.d.ts +3 -3
- package/dist/types/task/isolation-runner.d.ts +128 -0
- package/dist/types/task/worktree.d.ts +14 -1
- package/dist/types/thinking.d.ts +15 -0
- package/dist/types/tools/eval-render.d.ts +3 -0
- package/dist/types/tools/eval.d.ts +11 -17
- package/dist/types/tools/todo.d.ts +26 -28
- package/dist/types/tui/output-block.d.ts +8 -0
- package/dist/types/utils/image-resize.d.ts +2 -0
- package/dist/types/utils/shell-snapshot.d.ts +10 -0
- package/dist/types/web/search/providers/exa.d.ts +2 -0
- package/dist/types/web/search/providers/perplexity.d.ts +17 -3
- package/package.json +12 -12
- package/scripts/build-binary.ts +18 -4
- package/src/advisor/__tests__/advisor.test.ts +114 -0
- package/src/advisor/runtime.ts +129 -1
- package/src/cli/args.ts +4 -5
- package/src/cli/flag-tables.ts +3 -3
- package/src/cli/gallery-fixtures/interaction.ts +6 -9
- package/src/cli/gallery-fixtures/shell.ts +15 -23
- package/src/cli/session-picker.ts +17 -3
- package/src/commands/launch.ts +3 -3
- package/src/config/model-registry.ts +12 -4
- package/src/config/settings-schema.ts +37 -1
- package/src/edit/renderer.ts +34 -12
- package/src/eval/__tests__/agent-bridge.test.ts +462 -3
- package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
- package/src/eval/__tests__/julia-prelude.test.ts +1 -30
- package/src/eval/__tests__/prelude-agent.test.ts +42 -8
- package/src/eval/agent-bridge.ts +301 -71
- package/src/eval/jl/prelude.jl +32 -227
- package/src/eval/jl/runner.jl +38 -12
- package/src/eval/js/shared/helpers.ts +1 -114
- package/src/eval/js/shared/prelude.txt +13 -27
- package/src/eval/js/shared/runtime.ts +0 -6
- package/src/eval/js/shared/types.ts +1 -1
- package/src/eval/js/worker-protocol.ts +1 -1
- package/src/eval/py/__tests__/prelude.test.ts +13 -0
- package/src/eval/py/executor.ts +1 -1
- package/src/eval/py/prelude.py +47 -105
- package/src/eval/py/runner.py +0 -6
- package/src/eval/rb/prelude.rb +21 -189
- package/src/eval/rb/runner.rb +116 -9
- package/src/exec/bash-executor.ts +44 -0
- package/src/export/html/tool-views.generated.js +29 -29
- package/src/export/share.ts +51 -28
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/internal-urls/local-protocol.ts +100 -53
- package/src/main.ts +15 -4
- package/src/mcp/transports/stdio.ts +20 -4
- package/src/modes/acp/acp-event-mapper.ts +7 -2
- package/src/modes/components/custom-editor.test.ts +22 -0
- package/src/modes/components/custom-editor.ts +10 -1
- package/src/modes/components/footer.ts +4 -3
- package/src/modes/components/plugin-settings.ts +7 -1
- package/src/modes/components/session-selector.ts +143 -29
- package/src/modes/components/status-line/component.ts +5 -1
- package/src/modes/components/status-line/context-thresholds.ts +11 -3
- package/src/modes/components/status-line/segments.ts +1 -1
- package/src/modes/components/status-line/types.ts +1 -0
- package/src/modes/controllers/command-controller.ts +6 -0
- package/src/modes/rpc/rpc-mode.ts +6 -0
- package/src/modes/utils/copy-targets.ts +7 -2
- package/src/prompts/system/system-prompt.md +6 -6
- package/src/prompts/system/workflow-notice.md +3 -3
- package/src/prompts/tools/bash.md +18 -2
- package/src/prompts/tools/eval.md +19 -19
- package/src/prompts/tools/search.md +1 -0
- package/src/prompts/tools/todo.md +1 -1
- package/src/secrets/obfuscator.ts +3 -9
- package/src/session/agent-session.ts +264 -52
- package/src/slash-commands/builtin-registry.ts +2 -1
- package/src/task/index.ts +61 -207
- package/src/task/isolation-runner.ts +354 -0
- package/src/task/worktree.ts +46 -9
- package/src/thinking.ts +20 -0
- package/src/tools/ask.ts +44 -38
- package/src/tools/bash.ts +9 -2
- package/src/tools/browser/tab-worker.ts +1 -1
- package/src/tools/eval-render.ts +34 -27
- package/src/tools/eval.ts +100 -103
- package/src/tools/index.ts +8 -1
- package/src/tools/read.ts +136 -60
- package/src/tools/todo.ts +60 -64
- package/src/tui/code-cell.ts +1 -1
- package/src/tui/output-block.ts +11 -0
- package/src/utils/image-resize.ts +30 -0
- package/src/utils/shell-snapshot.ts +63 -1
- package/src/web/search/providers/exa.ts +85 -1
- package/src/web/search/providers/perplexity.ts +18 -6
|
@@ -157,7 +157,7 @@ export function resolveLocalUrlToPath(
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
/**
|
|
160
|
-
* On-disk roots the eval helpers (`read`/`write
|
|
160
|
+
* On-disk roots the eval helpers (`read`/`write`) substitute for
|
|
161
161
|
* internal-URL schemes so e.g. `write("local://x.md")` lands where a later
|
|
162
162
|
* `read local://x.md` resolves — instead of a literal `local:/` directory under
|
|
163
163
|
* the cwd (a stdlib `pathlib.Path`/`path.resolve` collapses `local://` to
|
|
@@ -169,6 +169,96 @@ export function buildEvalUrlRoots(options: LocalProtocolOptions): Record<string,
|
|
|
169
169
|
return { local: resolveLocalRoot(options) };
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
const LOCAL_WRITE_NOTE = "Use write path local://<file> to persist large intermediate artifacts across turns.";
|
|
173
|
+
|
|
174
|
+
type ResolvedLocalTarget =
|
|
175
|
+
| { kind: "listing"; root: string }
|
|
176
|
+
| { kind: "directory"; path: string }
|
|
177
|
+
| { kind: "file"; path: string; size: number };
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Resolve a local:// URL to its on-disk target with realpath + containment
|
|
181
|
+
* checks on the root, parent, and target so symlinks cannot escape the session
|
|
182
|
+
* local root. Does NOT read or decode file contents — callers decide how to
|
|
183
|
+
* consume the resolved path. Shared by {@link LocalProtocolHandler.resolve} and
|
|
184
|
+
* {@link resolveLocalUrlToFile}.
|
|
185
|
+
*/
|
|
186
|
+
async function resolveLocalTarget(url: InternalUrl, opts: LocalProtocolOptions): Promise<ResolvedLocalTarget> {
|
|
187
|
+
const localRoot = path.resolve(resolveLocalRoot(opts));
|
|
188
|
+
await fs.mkdir(localRoot, { recursive: true });
|
|
189
|
+
|
|
190
|
+
let resolvedRoot: string;
|
|
191
|
+
try {
|
|
192
|
+
resolvedRoot = await fs.realpath(localRoot);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (isEnoent(error)) {
|
|
195
|
+
throw new Error("Unable to initialize local:// root");
|
|
196
|
+
}
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const relativePath = extractRelativePath(url);
|
|
201
|
+
const targetPath = relativePath ? path.resolve(resolvedRoot, relativePath) : resolvedRoot;
|
|
202
|
+
ensureWithinRoot(targetPath, resolvedRoot);
|
|
203
|
+
|
|
204
|
+
if (targetPath === resolvedRoot) {
|
|
205
|
+
return { kind: "listing", root: resolvedRoot };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const parentDir = path.dirname(targetPath);
|
|
209
|
+
try {
|
|
210
|
+
const realParent = await fs.realpath(parentDir);
|
|
211
|
+
ensureWithinRoot(realParent, resolvedRoot);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (!isEnoent(error)) throw error;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let realTargetPath: string;
|
|
217
|
+
try {
|
|
218
|
+
realTargetPath = await fs.realpath(targetPath);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (isEnoent(error)) {
|
|
221
|
+
throw new Error(`Local file not found: ${url.href}`);
|
|
222
|
+
}
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
ensureWithinRoot(realTargetPath, resolvedRoot);
|
|
227
|
+
|
|
228
|
+
const stat = await fs.stat(realTargetPath);
|
|
229
|
+
if (stat.isDirectory()) {
|
|
230
|
+
return { kind: "directory", path: realTargetPath };
|
|
231
|
+
}
|
|
232
|
+
if (!stat.isFile()) {
|
|
233
|
+
throw new Error(`local:// URL must resolve to a file or directory: ${url.href}`);
|
|
234
|
+
}
|
|
235
|
+
return { kind: "file", path: realTargetPath, size: stat.size };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Resolve a local:// URL to a regular on-disk file, applying the same
|
|
240
|
+
* realpath + containment guarantees as {@link LocalProtocolHandler.resolve}
|
|
241
|
+
* but WITHOUT reading or UTF-8-decoding its contents. Returns null when there
|
|
242
|
+
* is no active session or when the URL targets the root listing or a directory;
|
|
243
|
+
* throws the handler's not-found and "escapes local root" errors for missing
|
|
244
|
+
* files and symlink escapes.
|
|
245
|
+
*
|
|
246
|
+
* Options are resolved via {@link LocalProtocolHandler.resolveOptions} so the
|
|
247
|
+
* caller-options → override → registry order matches router resolution exactly.
|
|
248
|
+
* The read tool uses this to detect and emit image files from their real path
|
|
249
|
+
* before the text-only resource contract would decode the binary into mojibake.
|
|
250
|
+
*/
|
|
251
|
+
export async function resolveLocalUrlToFile(
|
|
252
|
+
input: string | InternalUrl,
|
|
253
|
+
context?: ResolveContext,
|
|
254
|
+
): Promise<{ path: string; size: number } | null> {
|
|
255
|
+
const opts = LocalProtocolHandler.resolveOptions(context);
|
|
256
|
+
if (!opts) return null;
|
|
257
|
+
const url = typeof input === "string" ? parseLocalUrl(input) : input;
|
|
258
|
+
const resolved = await resolveLocalTarget(url, opts);
|
|
259
|
+
return resolved.kind === "file" ? { path: resolved.path, size: resolved.size } : null;
|
|
260
|
+
}
|
|
261
|
+
|
|
172
262
|
/**
|
|
173
263
|
* Protocol handler for local:// URLs.
|
|
174
264
|
*
|
|
@@ -238,65 +328,22 @@ export class LocalProtocolHandler implements ProtocolHandler {
|
|
|
238
328
|
throw new Error("No session - local:// unavailable");
|
|
239
329
|
}
|
|
240
330
|
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
let resolvedRoot: string;
|
|
245
|
-
try {
|
|
246
|
-
resolvedRoot = await fs.realpath(localRoot);
|
|
247
|
-
} catch (error) {
|
|
248
|
-
if (isEnoent(error)) {
|
|
249
|
-
throw new Error("Unable to initialize local:// root");
|
|
250
|
-
}
|
|
251
|
-
throw error;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const relativePath = extractRelativePath(url);
|
|
255
|
-
const targetPath = relativePath ? path.resolve(resolvedRoot, relativePath) : resolvedRoot;
|
|
256
|
-
ensureWithinRoot(targetPath, resolvedRoot);
|
|
257
|
-
|
|
258
|
-
if (targetPath === resolvedRoot) {
|
|
259
|
-
return buildListing(url, resolvedRoot);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const parentDir = path.dirname(targetPath);
|
|
263
|
-
try {
|
|
264
|
-
const realParent = await fs.realpath(parentDir);
|
|
265
|
-
ensureWithinRoot(realParent, resolvedRoot);
|
|
266
|
-
} catch (error) {
|
|
267
|
-
if (!isEnoent(error)) throw error;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
let realTargetPath: string;
|
|
271
|
-
try {
|
|
272
|
-
realTargetPath = await fs.realpath(targetPath);
|
|
273
|
-
} catch (error) {
|
|
274
|
-
if (isEnoent(error)) {
|
|
275
|
-
throw new Error(`Local file not found: ${url.href}`);
|
|
276
|
-
}
|
|
277
|
-
throw error;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
ensureWithinRoot(realTargetPath, resolvedRoot);
|
|
281
|
-
|
|
282
|
-
const stat = await fs.stat(realTargetPath);
|
|
283
|
-
if (stat.isDirectory()) {
|
|
284
|
-
return buildDirectoryResource(url.href, realTargetPath, [
|
|
285
|
-
"Use write path local://<file> to persist large intermediate artifacts across turns.",
|
|
286
|
-
]);
|
|
331
|
+
const resolved = await resolveLocalTarget(url, opts);
|
|
332
|
+
if (resolved.kind === "listing") {
|
|
333
|
+
return buildListing(url, resolved.root);
|
|
287
334
|
}
|
|
288
|
-
if (
|
|
289
|
-
|
|
335
|
+
if (resolved.kind === "directory") {
|
|
336
|
+
return buildDirectoryResource(url.href, resolved.path, [LOCAL_WRITE_NOTE]);
|
|
290
337
|
}
|
|
291
338
|
|
|
292
|
-
const content = await Bun.file(
|
|
339
|
+
const content = await Bun.file(resolved.path).text();
|
|
293
340
|
return {
|
|
294
341
|
url: url.href,
|
|
295
342
|
content,
|
|
296
|
-
contentType: getContentType(
|
|
343
|
+
contentType: getContentType(resolved.path),
|
|
297
344
|
size: Buffer.byteLength(content, "utf-8"),
|
|
298
|
-
sourcePath:
|
|
299
|
-
notes: [
|
|
345
|
+
sourcePath: resolved.path,
|
|
346
|
+
notes: [LOCAL_WRITE_NOTE],
|
|
300
347
|
};
|
|
301
348
|
}
|
|
302
349
|
|
package/src/main.ts
CHANGED
|
@@ -945,6 +945,7 @@ async function buildSessionOptions(
|
|
|
945
945
|
interface RunRootCommandDependencies {
|
|
946
946
|
createAgentSession?: typeof createAgentSession;
|
|
947
947
|
discoverAuthStorage?: typeof discoverAuthStorage;
|
|
948
|
+
selectSession?: typeof selectSession;
|
|
948
949
|
runAcpMode?: RunAcpMode;
|
|
949
950
|
settings?: Settings;
|
|
950
951
|
forceSetupWizard?: boolean;
|
|
@@ -1131,7 +1132,8 @@ export async function runRootCommand(
|
|
|
1131
1132
|
// (see issue #1668).
|
|
1132
1133
|
if (typeof parsedArgs.resume === "string" && !sessionManager) {
|
|
1133
1134
|
writeStartupNotice(parsedArgs, `${chalk.dim("Resume cancelled: session is in another project.")}\n`);
|
|
1134
|
-
|
|
1135
|
+
stopStartupWatchdog();
|
|
1136
|
+
process.exit(0);
|
|
1135
1137
|
}
|
|
1136
1138
|
|
|
1137
1139
|
// Handle --resume (no value): show session picker
|
|
@@ -1147,17 +1149,26 @@ export async function runRootCommand(
|
|
|
1147
1149
|
preloadedAllSessions = await logger.time("SessionManager.listAll", SessionManager.listAll);
|
|
1148
1150
|
if (preloadedAllSessions.length === 0) {
|
|
1149
1151
|
writeStartupNotice(parsedArgs, `${chalk.dim("No sessions found")}\n`);
|
|
1150
|
-
|
|
1152
|
+
stopStartupWatchdog();
|
|
1153
|
+
process.exit(0);
|
|
1151
1154
|
}
|
|
1152
1155
|
}
|
|
1153
1156
|
pauseStartupWatchdog();
|
|
1154
|
-
const selected = await logger.time("selectSession", selectSession, folderSessions, {
|
|
1157
|
+
const selected = await logger.time("selectSession", deps.selectSession ?? selectSession, folderSessions, {
|
|
1155
1158
|
allSessions: preloadedAllSessions,
|
|
1156
1159
|
});
|
|
1157
1160
|
resumeStartupWatchdog();
|
|
1158
1161
|
if (!selected) {
|
|
1159
1162
|
writeStartupNotice(parsedArgs, `${chalk.dim("No session selected")}\n`);
|
|
1160
|
-
|
|
1163
|
+
// Quit instead of returning: startup already armed long-lived handles
|
|
1164
|
+
// (theme watcher + SIGWINCH/macOS appearance listeners via initTheme,
|
|
1165
|
+
// settings save timer, model registry) that keep the event loop alive,
|
|
1166
|
+
// so a bare return hangs the process after the picker leaves the alt
|
|
1167
|
+
// screen. No session was built here, so there is nothing to flush. The
|
|
1168
|
+
// in-session `/resume` picker (selector-controller.ts) takes a different
|
|
1169
|
+
// onCancel that just closes the overlay — only this startup path exits.
|
|
1170
|
+
stopStartupWatchdog();
|
|
1171
|
+
process.exit(0);
|
|
1161
1172
|
}
|
|
1162
1173
|
// Resuming a session from another project: switch the process into that
|
|
1163
1174
|
// project's directory and refresh cwd-derived caches before the session is
|
|
@@ -212,7 +212,18 @@ function buildCmdExeCommand(command: string, args: readonly string[]): string {
|
|
|
212
212
|
return `"${quotedCommand}"`;
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
/**
|
|
215
|
+
/**
|
|
216
|
+
* Resolve the subprocess argv used to launch an MCP stdio server.
|
|
217
|
+
*
|
|
218
|
+
* On Windows, our PATH/PATHEXT walk may return `null` for a bare command
|
|
219
|
+
* (e.g. `npx`) — `Bun.env.PATH` empty under a restricted parent process,
|
|
220
|
+
* UNC/network mounts that reject `fs.access`, locked-down shells. The
|
|
221
|
+
* legacy fallback handed `Bun.spawn` the bare name, but `CreateProcess`
|
|
222
|
+
* only appends `.exe` for extensionless names — `.cmd`/`.bat` are never
|
|
223
|
+
* tried, so `npx` (which exists only as `npx.cmd` on Windows) crashes the
|
|
224
|
+
* subprocess immediately. When the resolver can't pin the command down,
|
|
225
|
+
* route through `cmd.exe /d /s /c` so Windows's own PATHEXT lookup runs.
|
|
226
|
+
*/
|
|
216
227
|
export async function resolveStdioSpawnCommand(
|
|
217
228
|
config: MCPStdioServerConfig,
|
|
218
229
|
options: ResolveStdioSpawnOptions,
|
|
@@ -220,11 +231,16 @@ export async function resolveStdioSpawnCommand(
|
|
|
220
231
|
const args = config.args ?? [];
|
|
221
232
|
if (options.platform !== "win32") return { cmd: [config.command, ...args] };
|
|
222
233
|
|
|
223
|
-
const
|
|
224
|
-
|
|
234
|
+
const resolved = await resolveWindowsCommandPath(config.command, options.cwd, options.env);
|
|
235
|
+
const resolvedCommand = resolved ?? config.command;
|
|
225
236
|
const npmShimCommand = await resolveWindowsNpmShimCommand(resolvedCommand, args, options.cwd);
|
|
226
237
|
if (npmShimCommand) return npmShimCommand;
|
|
227
|
-
|
|
238
|
+
|
|
239
|
+
// Direct-spawn only when we resolved to a concrete file AND its extension
|
|
240
|
+
// is not a batch script. Everything else (resolved .cmd/.bat, or an
|
|
241
|
+
// unresolved extensionless command) goes through cmd.exe so PATHEXT runs.
|
|
242
|
+
const needsCmdExe = resolved === null || isWindowsBatchCommand(resolvedCommand);
|
|
243
|
+
if (!needsCmdExe) return { cmd: [resolvedCommand, ...args] };
|
|
228
244
|
|
|
229
245
|
return {
|
|
230
246
|
cmd: [resolveComSpec(options.env), "/d", "/s", "/c", buildCmdExeCommand(resolvedCommand, args)],
|
|
@@ -468,8 +468,13 @@ function buildEvalStartText(args: unknown): string | undefined {
|
|
|
468
468
|
if (typeof args !== "object" || args === null || Array.isArray(args)) {
|
|
469
469
|
return undefined;
|
|
470
470
|
}
|
|
471
|
-
const
|
|
472
|
-
|
|
471
|
+
const container = args as EvalCellContainer & EvalCellLike;
|
|
472
|
+
const cells = Array.isArray(container.cells)
|
|
473
|
+
? container.cells
|
|
474
|
+
: typeof container.code === "string"
|
|
475
|
+
? [container]
|
|
476
|
+
: [];
|
|
477
|
+
if (cells.length === 0) {
|
|
473
478
|
return undefined;
|
|
474
479
|
}
|
|
475
480
|
const lines: string[] = [];
|
|
@@ -3,6 +3,7 @@ import { $ } from "bun";
|
|
|
3
3
|
import { getEditorTheme, initTheme } from "../theme/theme";
|
|
4
4
|
import {
|
|
5
5
|
CustomEditor,
|
|
6
|
+
extractBracketedImagePastePaths,
|
|
6
7
|
SPACE_HOLD_MECHANICAL_RUN,
|
|
7
8
|
SPACE_HOLD_RELEASE_MS,
|
|
8
9
|
SPACE_REPEAT_MAX_GAP_MS,
|
|
@@ -21,6 +22,12 @@ function makeEditor() {
|
|
|
21
22
|
const REPEAT_GAP_MS = 30;
|
|
22
23
|
/** A gap above the threshold — looks like a deliberate keypress. */
|
|
23
24
|
const TAP_GAP_MS = SPACE_REPEAT_MAX_GAP_MS + 80;
|
|
25
|
+
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
26
|
+
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
27
|
+
|
|
28
|
+
function bracketedPaste(text: string): string {
|
|
29
|
+
return `${BRACKETED_PASTE_START}${text}${BRACKETED_PASTE_END}`;
|
|
30
|
+
}
|
|
24
31
|
|
|
25
32
|
/** Feed `count` spaces `gapMs` apart on the fake clock. The first space of a run has no prior
|
|
26
33
|
* space, so its gap is effectively infinite and it always reads as a deliberate tap. */
|
|
@@ -66,6 +73,21 @@ describe("CustomEditor placeholder decoration", () => {
|
|
|
66
73
|
});
|
|
67
74
|
});
|
|
68
75
|
|
|
76
|
+
describe("CustomEditor bracketed image-path paste", () => {
|
|
77
|
+
it("leaves a pasted bare .png filename on the normal text path", () => {
|
|
78
|
+
expect(extractBracketedImagePastePaths(bracketedPaste("icon-photo-default.png"))).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("extracts explicit local image paths for attachment", () => {
|
|
82
|
+
expect(extractBracketedImagePastePaths(bracketedPaste("/tmp/icon-photo-default.png"))).toEqual([
|
|
83
|
+
"/tmp/icon-photo-default.png",
|
|
84
|
+
]);
|
|
85
|
+
expect(extractBracketedImagePastePaths(bracketedPaste("C:\\Users\\me\\icon-photo-default.png"))).toEqual([
|
|
86
|
+
"C:\\Users\\me\\icon-photo-default.png",
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
69
91
|
describe("CustomEditor space-hold push-to-talk", () => {
|
|
70
92
|
beforeAll(async () => {
|
|
71
93
|
await initTheme();
|
|
@@ -64,6 +64,9 @@ const BRACKETED_PASTE_END = "\x1b[201~";
|
|
|
64
64
|
const BRACKETED_IMAGE_PATH_REGEX = /\.(?:png|jpe?g|gif|webp)$/i;
|
|
65
65
|
const BRACKETED_IMAGE_PATH_BOUNDARY_REGEX = /\.(?:png|jpe?g|gif|webp)(?=$|["']?\s)/gi;
|
|
66
66
|
const SHELL_ESCAPED_PATH_CHAR_REGEX = /\\([\\\s'"()[\]{}&;<>|?*!$`])/g;
|
|
67
|
+
const URI_SCHEME_REGEX = /^[a-z][a-z0-9+.-]*:/i;
|
|
68
|
+
const FILE_URI_REGEX = /^file:\/\//i;
|
|
69
|
+
const WINDOWS_DRIVE_PATH_REGEX = /^[a-z]:[\\/]/i;
|
|
67
70
|
|
|
68
71
|
/** Max gap (ms) between two spaces for the later one to count as OS key auto-repeat rather than a
|
|
69
72
|
* deliberate press. OS auto-repeat is fast; a deliberate tap (even a fast one) is slower. */
|
|
@@ -118,6 +121,12 @@ function normalizePastedImagePath(path: string): string {
|
|
|
118
121
|
return unquoted.replace(SHELL_ESCAPED_PATH_CHAR_REGEX, "$1");
|
|
119
122
|
}
|
|
120
123
|
|
|
124
|
+
function isExplicitPastedImagePath(path: string): boolean {
|
|
125
|
+
if (WINDOWS_DRIVE_PATH_REGEX.test(path) || FILE_URI_REGEX.test(path)) return true;
|
|
126
|
+
if (URI_SCHEME_REGEX.test(path)) return false;
|
|
127
|
+
return path.includes("/") || path.includes("\\");
|
|
128
|
+
}
|
|
129
|
+
|
|
121
130
|
export function extractBracketedImagePastePaths(data: string): string[] | undefined {
|
|
122
131
|
if (!data.startsWith(BRACKETED_PASTE_START)) return undefined;
|
|
123
132
|
const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length);
|
|
@@ -139,7 +148,7 @@ export function extractBracketedImagePastePaths(data: string): string[] | undefi
|
|
|
139
148
|
if (boundaryEnd === undefined) continue;
|
|
140
149
|
|
|
141
150
|
const path = normalizePastedImagePath(pasted.slice(segmentStart, boundaryEnd));
|
|
142
|
-
if (!path || !BRACKETED_IMAGE_PATH_REGEX.test(path)) return undefined;
|
|
151
|
+
if (!path || !BRACKETED_IMAGE_PATH_REGEX.test(path) || !isExplicitPastedImagePath(path)) return undefined;
|
|
143
152
|
paths.push(path);
|
|
144
153
|
|
|
145
154
|
segmentStart = boundaryEnd;
|
|
@@ -142,7 +142,8 @@ export class FooterComponent implements Component {
|
|
|
142
142
|
// After compaction, tokens are unknown until the next LLM response.
|
|
143
143
|
const contextUsage = this.session.getContextUsage();
|
|
144
144
|
const contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0;
|
|
145
|
-
const
|
|
145
|
+
const contextTokens = contextUsage?.tokens ?? 0;
|
|
146
|
+
const contextPercentValue = contextWindow > 0 ? (contextUsage?.percent ?? 0) : null;
|
|
146
147
|
|
|
147
148
|
// Replace home directory with ~
|
|
148
149
|
let pwd = shortenPath(getProjectDir());
|
|
@@ -186,8 +187,8 @@ export class FooterComponent implements Component {
|
|
|
186
187
|
// Colorize context percentage based on usage
|
|
187
188
|
let contextPercentStr: string;
|
|
188
189
|
const autoIndicator = this.#autoCompactEnabled ? " (auto)" : "";
|
|
189
|
-
const contextPercentDisplay = `${formatContextUsage(contextPercentValue, contextWindow)}${autoIndicator}`;
|
|
190
|
-
if (contextUsage) {
|
|
190
|
+
const contextPercentDisplay = `${formatContextUsage(contextPercentValue, contextWindow, contextTokens)}${autoIndicator}`;
|
|
191
|
+
if (contextUsage && contextPercentValue !== null) {
|
|
191
192
|
const color = getContextUsageThemeColor(getContextUsageLevel(contextPercentValue, contextWindow));
|
|
192
193
|
contextPercentStr =
|
|
193
194
|
color === "statusLineContext" ? contextPercentDisplay : theme.fg(color, contextPercentDisplay);
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import {
|
|
12
12
|
Container,
|
|
13
13
|
Input,
|
|
14
|
+
matchesKey,
|
|
14
15
|
type SelectItem,
|
|
15
16
|
SelectList,
|
|
16
17
|
type SettingItem,
|
|
@@ -36,13 +37,18 @@ import { DynamicBorder } from "./dynamic-border";
|
|
|
36
37
|
|
|
37
38
|
/**
|
|
38
39
|
* Forwards a keystroke to `input`, but cancels via `onCancel` when the user presses Escape.
|
|
40
|
+
*
|
|
41
|
+
* Escape is decoded via `matchesKey` rather than a raw `\x1b` compare: inside the
|
|
42
|
+
* fullscreen settings overlay the kitty keyboard protocol is active (ghostty/kitty),
|
|
43
|
+
* where the Escape key arrives as the CSI-u sequence `\x1b[27u`, not a bare `\x1b`.
|
|
44
|
+
* The literal fallbacks preserve legacy single/double-escape on terminals without it.
|
|
39
45
|
*/
|
|
40
46
|
export function handleInputOrEscape(
|
|
41
47
|
data: string,
|
|
42
48
|
input: { handleInput(data: string): void },
|
|
43
49
|
onCancel: () => void,
|
|
44
50
|
): void {
|
|
45
|
-
if (data === "\x1b" || data === "\x1b\x1b") {
|
|
51
|
+
if (data === "\x1b" || data === "\x1b\x1b" || matchesKey(data, "escape")) {
|
|
46
52
|
onCancel();
|
|
47
53
|
return;
|
|
48
54
|
}
|