@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.
Files changed (111) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/dist/cli.js +3705 -4015
  3. package/dist/types/advisor/runtime.d.ts +3 -0
  4. package/dist/types/cli/args.d.ts +2 -5
  5. package/dist/types/cli/flag-tables.d.ts +2 -2
  6. package/dist/types/cli/session-picker.d.ts +4 -2
  7. package/dist/types/commands/launch.d.ts +1 -1
  8. package/dist/types/config/settings-schema.d.ts +32 -1
  9. package/dist/types/eval/agent-bridge.d.ts +19 -0
  10. package/dist/types/eval/js/shared/helpers.d.ts +1 -13
  11. package/dist/types/eval/js/shared/types.d.ts +1 -1
  12. package/dist/types/eval/js/worker-protocol.d.ts +1 -1
  13. package/dist/types/eval/py/executor.d.ts +1 -1
  14. package/dist/types/export/share.d.ts +8 -1
  15. package/dist/types/internal-urls/local-protocol.d.ts +18 -1
  16. package/dist/types/main.d.ts +2 -0
  17. package/dist/types/mcp/transports/stdio.d.ts +12 -1
  18. package/dist/types/modes/components/plugin-settings.d.ts +5 -0
  19. package/dist/types/modes/components/session-selector.d.ts +25 -0
  20. package/dist/types/modes/components/status-line/context-thresholds.d.ts +4 -3
  21. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  22. package/dist/types/secrets/obfuscator.d.ts +3 -3
  23. package/dist/types/task/isolation-runner.d.ts +128 -0
  24. package/dist/types/task/worktree.d.ts +14 -1
  25. package/dist/types/thinking.d.ts +15 -0
  26. package/dist/types/tools/eval-render.d.ts +3 -0
  27. package/dist/types/tools/eval.d.ts +11 -17
  28. package/dist/types/tools/todo.d.ts +26 -28
  29. package/dist/types/tui/output-block.d.ts +8 -0
  30. package/dist/types/utils/image-resize.d.ts +2 -0
  31. package/dist/types/utils/shell-snapshot.d.ts +10 -0
  32. package/dist/types/web/search/providers/exa.d.ts +2 -0
  33. package/dist/types/web/search/providers/perplexity.d.ts +17 -3
  34. package/package.json +12 -12
  35. package/scripts/build-binary.ts +18 -4
  36. package/src/advisor/__tests__/advisor.test.ts +114 -0
  37. package/src/advisor/runtime.ts +129 -1
  38. package/src/cli/args.ts +4 -5
  39. package/src/cli/flag-tables.ts +3 -3
  40. package/src/cli/gallery-fixtures/interaction.ts +6 -9
  41. package/src/cli/gallery-fixtures/shell.ts +15 -23
  42. package/src/cli/session-picker.ts +17 -3
  43. package/src/commands/launch.ts +3 -3
  44. package/src/config/model-registry.ts +12 -4
  45. package/src/config/settings-schema.ts +37 -1
  46. package/src/edit/renderer.ts +34 -12
  47. package/src/eval/__tests__/agent-bridge.test.ts +462 -3
  48. package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
  49. package/src/eval/__tests__/julia-prelude.test.ts +1 -30
  50. package/src/eval/__tests__/prelude-agent.test.ts +42 -8
  51. package/src/eval/agent-bridge.ts +301 -71
  52. package/src/eval/jl/prelude.jl +32 -227
  53. package/src/eval/jl/runner.jl +38 -12
  54. package/src/eval/js/shared/helpers.ts +1 -114
  55. package/src/eval/js/shared/prelude.txt +13 -27
  56. package/src/eval/js/shared/runtime.ts +0 -6
  57. package/src/eval/js/shared/types.ts +1 -1
  58. package/src/eval/js/worker-protocol.ts +1 -1
  59. package/src/eval/py/__tests__/prelude.test.ts +13 -0
  60. package/src/eval/py/executor.ts +1 -1
  61. package/src/eval/py/prelude.py +47 -105
  62. package/src/eval/py/runner.py +0 -6
  63. package/src/eval/rb/prelude.rb +21 -189
  64. package/src/eval/rb/runner.rb +116 -9
  65. package/src/exec/bash-executor.ts +44 -0
  66. package/src/export/html/tool-views.generated.js +29 -29
  67. package/src/export/share.ts +51 -28
  68. package/src/internal-urls/docs-index.generated.txt +1 -1
  69. package/src/internal-urls/local-protocol.ts +100 -53
  70. package/src/main.ts +15 -4
  71. package/src/mcp/transports/stdio.ts +20 -4
  72. package/src/modes/acp/acp-event-mapper.ts +7 -2
  73. package/src/modes/components/custom-editor.test.ts +22 -0
  74. package/src/modes/components/custom-editor.ts +10 -1
  75. package/src/modes/components/footer.ts +4 -3
  76. package/src/modes/components/plugin-settings.ts +7 -1
  77. package/src/modes/components/session-selector.ts +143 -29
  78. package/src/modes/components/status-line/component.ts +5 -1
  79. package/src/modes/components/status-line/context-thresholds.ts +11 -3
  80. package/src/modes/components/status-line/segments.ts +1 -1
  81. package/src/modes/components/status-line/types.ts +1 -0
  82. package/src/modes/controllers/command-controller.ts +6 -0
  83. package/src/modes/rpc/rpc-mode.ts +6 -0
  84. package/src/modes/utils/copy-targets.ts +7 -2
  85. package/src/prompts/system/system-prompt.md +6 -6
  86. package/src/prompts/system/workflow-notice.md +3 -3
  87. package/src/prompts/tools/bash.md +18 -2
  88. package/src/prompts/tools/eval.md +19 -19
  89. package/src/prompts/tools/search.md +1 -0
  90. package/src/prompts/tools/todo.md +1 -1
  91. package/src/secrets/obfuscator.ts +3 -9
  92. package/src/session/agent-session.ts +264 -52
  93. package/src/slash-commands/builtin-registry.ts +2 -1
  94. package/src/task/index.ts +61 -207
  95. package/src/task/isolation-runner.ts +354 -0
  96. package/src/task/worktree.ts +46 -9
  97. package/src/thinking.ts +20 -0
  98. package/src/tools/ask.ts +44 -38
  99. package/src/tools/bash.ts +9 -2
  100. package/src/tools/browser/tab-worker.ts +1 -1
  101. package/src/tools/eval-render.ts +34 -27
  102. package/src/tools/eval.ts +100 -103
  103. package/src/tools/index.ts +8 -1
  104. package/src/tools/read.ts +136 -60
  105. package/src/tools/todo.ts +60 -64
  106. package/src/tui/code-cell.ts +1 -1
  107. package/src/tui/output-block.ts +11 -0
  108. package/src/utils/image-resize.ts +30 -0
  109. package/src/utils/shell-snapshot.ts +63 -1
  110. package/src/web/search/providers/exa.ts +85 -1
  111. 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`/`append`) substitute for
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 localRoot = path.resolve(resolveLocalRoot(opts));
242
- await fs.mkdir(localRoot, { recursive: true });
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 (!stat.isFile()) {
289
- throw new Error(`local:// URL must resolve to a file or directory: ${url.href}`);
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(realTargetPath).text();
339
+ const content = await Bun.file(resolved.path).text();
293
340
  return {
294
341
  url: url.href,
295
342
  content,
296
- contentType: getContentType(realTargetPath),
343
+ contentType: getContentType(resolved.path),
297
344
  size: Buffer.byteLength(content, "utf-8"),
298
- sourcePath: realTargetPath,
299
- notes: ["Use write path local://<file> to persist large intermediate artifacts across turns."],
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
- return;
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
- return;
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
- return;
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
- /** Resolve the subprocess argv used to launch an MCP stdio server. */
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 resolvedCommand =
224
- (await resolveWindowsCommandPath(config.command, options.cwd, options.env)) ?? config.command;
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
- if (!isWindowsBatchCommand(resolvedCommand)) return { cmd: [resolvedCommand, ...args] };
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 cells = (args as EvalCellContainer).cells;
472
- if (!Array.isArray(cells) || cells.length === 0) {
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 contextPercentValue = contextUsage?.percent ?? 0;
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
  }