@oh-my-pi/pi-coding-agent 15.7.5 → 15.8.0

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 (146) hide show
  1. package/CHANGELOG.md +159 -182
  2. package/dist/types/async/job-manager.d.ts +3 -3
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/cli/claude-trace-cli.d.ts +54 -0
  5. package/dist/types/cli/session-picker.d.ts +10 -3
  6. package/dist/types/cli/update-cli.d.ts +17 -0
  7. package/dist/types/commands/launch.d.ts +3 -0
  8. package/dist/types/config/keybindings.d.ts +5 -0
  9. package/dist/types/config/settings-schema.d.ts +11 -2
  10. package/dist/types/config/settings.d.ts +13 -0
  11. package/dist/types/eval/__tests__/heartbeat.test.d.ts +1 -0
  12. package/dist/types/eval/concurrency-bridge.d.ts +26 -0
  13. package/dist/types/eval/heartbeat.d.ts +45 -0
  14. package/dist/types/eval/js/tool-bridge.d.ts +2 -1
  15. package/dist/types/extensibility/extensions/loader.d.ts +2 -2
  16. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  17. package/dist/types/extensibility/extensions/types.d.ts +24 -5
  18. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +11 -0
  19. package/dist/types/lsp/diagnostics-ledger.d.ts +10 -0
  20. package/dist/types/lsp/index.d.ts +2 -0
  21. package/dist/types/lsp/utils.d.ts +4 -0
  22. package/dist/types/main.d.ts +5 -0
  23. package/dist/types/modes/acp/acp-agent.d.ts +1 -1
  24. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  25. package/dist/types/modes/components/custom-editor.d.ts +3 -2
  26. package/dist/types/modes/components/hook-selector.d.ts +10 -1
  27. package/dist/types/modes/components/session-selector.d.ts +32 -5
  28. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  29. package/dist/types/modes/controllers/extension-ui-controller.d.ts +3 -3
  30. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  31. package/dist/types/modes/interactive-mode.d.ts +10 -3
  32. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  33. package/dist/types/modes/theme/theme.d.ts +1 -1
  34. package/dist/types/modes/types.d.ts +5 -3
  35. package/dist/types/registry/agent-registry.d.ts +1 -1
  36. package/dist/types/sdk.d.ts +2 -2
  37. package/dist/types/session/agent-session.d.ts +12 -3
  38. package/dist/types/session/history-storage.d.ts +16 -1
  39. package/dist/types/session/session-manager.d.ts +4 -0
  40. package/dist/types/task/output-manager.d.ts +6 -15
  41. package/dist/types/tools/ask.d.ts +8 -6
  42. package/dist/types/tools/eval-backends.d.ts +12 -0
  43. package/dist/types/tools/eval-render.d.ts +52 -0
  44. package/dist/types/tools/eval.d.ts +2 -35
  45. package/dist/types/tools/find.d.ts +0 -9
  46. package/dist/types/tools/index.d.ts +5 -12
  47. package/dist/types/tools/path-utils.d.ts +16 -0
  48. package/dist/types/tools/sqlite-reader.d.ts +25 -8
  49. package/dist/types/tui/output-block.d.ts +4 -3
  50. package/dist/types/utils/clipboard.d.ts +4 -0
  51. package/dist/types/web/kagi.d.ts +76 -0
  52. package/dist/types/web/search/providers/exa.d.ts +7 -1
  53. package/dist/types/web/search/providers/kagi.d.ts +1 -0
  54. package/examples/extensions/README.md +1 -0
  55. package/examples/extensions/thinking-note.ts +13 -0
  56. package/package.json +9 -9
  57. package/src/async/job-manager.ts +3 -3
  58. package/src/cli/args.ts +6 -2
  59. package/src/cli/claude-trace-cli.ts +783 -0
  60. package/src/cli/session-picker.ts +36 -10
  61. package/src/cli/update-cli.ts +35 -2
  62. package/src/commands/launch.ts +3 -0
  63. package/src/config/keybindings.ts +6 -0
  64. package/src/config/model-registry.ts +33 -4
  65. package/src/config/settings-schema.ts +12 -2
  66. package/src/config/settings.ts +23 -0
  67. package/src/discovery/claude-plugins.ts +7 -9
  68. package/src/discovery/claude.ts +41 -22
  69. package/src/edit/index.ts +23 -3
  70. package/src/eval/__tests__/agent-bridge.test.ts +148 -4
  71. package/src/eval/__tests__/heartbeat.test.ts +84 -0
  72. package/src/eval/__tests__/llm-bridge.test.ts +30 -0
  73. package/src/eval/agent-bridge.ts +44 -38
  74. package/src/eval/concurrency-bridge.ts +34 -0
  75. package/src/eval/heartbeat.ts +74 -0
  76. package/src/eval/js/executor.ts +13 -9
  77. package/src/eval/js/shared/prelude.txt +20 -17
  78. package/src/eval/js/tool-bridge.ts +5 -0
  79. package/src/eval/llm-bridge.ts +20 -14
  80. package/src/eval/py/executor.ts +14 -18
  81. package/src/eval/py/prelude.py +23 -15
  82. package/src/exec/bash-executor.ts +31 -5
  83. package/src/extensibility/extensions/loader.ts +16 -18
  84. package/src/extensibility/extensions/runner.ts +22 -17
  85. package/src/extensibility/extensions/types.ts +39 -5
  86. package/src/extensibility/plugins/legacy-pi-compat.ts +115 -131
  87. package/src/extensibility/skills.ts +0 -1
  88. package/src/internal-urls/docs-index.generated.ts +14 -13
  89. package/src/lsp/diagnostics-ledger.ts +51 -0
  90. package/src/lsp/index.ts +9 -22
  91. package/src/lsp/utils.ts +21 -0
  92. package/src/main.ts +92 -24
  93. package/src/modes/acp/acp-agent.ts +8 -4
  94. package/src/modes/acp/acp-event-mapper.ts +54 -4
  95. package/src/modes/components/assistant-message.ts +28 -1
  96. package/src/modes/components/custom-editor.ts +19 -7
  97. package/src/modes/components/hook-selector.ts +229 -44
  98. package/src/modes/components/oauth-selector.ts +12 -6
  99. package/src/modes/components/session-selector.ts +179 -24
  100. package/src/modes/components/tool-execution.ts +36 -7
  101. package/src/modes/controllers/command-controller.ts +2 -11
  102. package/src/modes/controllers/event-controller.ts +5 -2
  103. package/src/modes/controllers/extension-ui-controller.ts +6 -4
  104. package/src/modes/controllers/input-controller.ts +19 -16
  105. package/src/modes/controllers/selector-controller.ts +61 -21
  106. package/src/modes/interactive-mode.ts +127 -16
  107. package/src/modes/rpc/rpc-mode.ts +17 -6
  108. package/src/modes/theme/theme-schema.json +30 -0
  109. package/src/modes/theme/theme.ts +39 -2
  110. package/src/modes/types.ts +7 -3
  111. package/src/modes/utils/ui-helpers.ts +5 -2
  112. package/src/prompts/system/orchestrate-notice.md +5 -3
  113. package/src/prompts/system/workflow-notice.md +2 -2
  114. package/src/prompts/tools/ask.md +2 -1
  115. package/src/prompts/tools/eval.md +6 -6
  116. package/src/prompts/tools/find.md +1 -1
  117. package/src/prompts/tools/irc.md +6 -6
  118. package/src/prompts/tools/search.md +1 -1
  119. package/src/prompts/tools/task.md +1 -1
  120. package/src/registry/agent-registry.ts +1 -1
  121. package/src/sdk.ts +85 -31
  122. package/src/session/agent-session.ts +127 -57
  123. package/src/session/history-storage.ts +56 -12
  124. package/src/session/session-manager.ts +34 -0
  125. package/src/task/output-manager.ts +40 -48
  126. package/src/task/render.ts +3 -8
  127. package/src/tools/ask.ts +74 -32
  128. package/src/tools/browser/tab-worker.ts +8 -5
  129. package/src/tools/eval-backends.ts +38 -0
  130. package/src/tools/eval-render.ts +750 -0
  131. package/src/tools/eval.ts +27 -754
  132. package/src/tools/find.ts +5 -29
  133. package/src/tools/index.ts +8 -38
  134. package/src/tools/path-utils.ts +144 -1
  135. package/src/tools/read.ts +47 -0
  136. package/src/tools/renderers.ts +1 -1
  137. package/src/tools/search.ts +2 -27
  138. package/src/tools/sqlite-reader.ts +92 -9
  139. package/src/tools/write.ts +9 -1
  140. package/src/tui/output-block.ts +5 -4
  141. package/src/utils/clipboard.ts +38 -1
  142. package/src/utils/open.ts +37 -2
  143. package/src/web/kagi.ts +168 -49
  144. package/src/web/search/providers/anthropic.ts +1 -1
  145. package/src/web/search/providers/exa.ts +20 -86
  146. package/src/web/search/providers/kagi.ts +4 -0
package/src/tools/find.ts CHANGED
@@ -16,6 +16,7 @@ import type { ToolSession } from ".";
16
16
  import { applyListLimit } from "./list-limit";
17
17
  import { formatFullOutputReference, type OutputMeta } from "./output-meta";
18
18
  import {
19
+ expandDelimitedPathEntries,
19
20
  formatPathRelativeToCwd,
20
21
  hasGlobPathChars,
21
22
  normalizePathLikeInput,
@@ -52,33 +53,6 @@ const DEFAULT_GLOB_TIMEOUT_MS = 5000;
52
53
  const MIN_GLOB_TIMEOUT_MS = 500;
53
54
  const MAX_GLOB_TIMEOUT_MS = 60_000;
54
55
 
55
- /**
56
- * Reject comma-separated path lists packed into a single array element
57
- * (`["a.py,b.py"]`). The schema is array-of-string; agents that pass a
58
- * single comma-joined element get silent no-matches otherwise.
59
- *
60
- * Commas inside brace expansion (`{a,b}`) are legitimate glob syntax and
61
- * must pass through.
62
- */
63
- export function validateFindPathInputs(paths: readonly string[]): void {
64
- for (const entry of paths) {
65
- let braceDepth = 0;
66
- for (let i = 0; i < entry.length; i++) {
67
- const ch = entry.charCodeAt(i);
68
- if (ch === 0x5c /* \ */ && i + 1 < entry.length) {
69
- i++;
70
- continue;
71
- }
72
- if (ch === 0x7b /* { */) braceDepth++;
73
- else if (ch === 0x7d /* } */) {
74
- if (braceDepth > 0) braceDepth--;
75
- } else if (ch === 0x2c /* , */ && braceDepth === 0) {
76
- throw new ToolError(`paths is an array — pass ["a", "b"] not ["a,b"] (got ${JSON.stringify(entry)})`);
77
- }
78
- }
79
- }
80
- }
81
-
82
56
  /**
83
57
  * Group find matches by their directory so the model doesn't pay repeated
84
58
  * tokens for shared path prefixes. Preserves the input order: groups appear in
@@ -180,8 +154,10 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
180
154
 
181
155
  return untilAborted(signal, async () => {
182
156
  const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
183
- validateFindPathInputs(paths);
184
- const rawPatterns = paths.map(input => normalizePathLikeInput(input).replace(/\\/g, "/"));
157
+ const rawPatternInputs = this.#customOps
158
+ ? paths
159
+ : await expandDelimitedPathEntries(paths, this.session.cwd, { splitter: parseFindPattern });
160
+ const rawPatterns = rawPatternInputs.map(input => normalizePathLikeInput(input).replace(/\\/g, "/"));
185
161
  const internalRouter = InternalUrlRouter.instance();
186
162
  const normalizedPatterns: string[] = [];
187
163
  for (const rawPattern of rawPatterns) {
@@ -1,7 +1,7 @@
1
1
  import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
2
2
  import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
3
3
  import type { ToolChoice } from "@oh-my-pi/pi-ai";
4
- import { $env, $flag, logger } from "@oh-my-pi/pi-utils";
4
+ import { logger } from "@oh-my-pi/pi-utils";
5
5
  import type { PromptTemplate } from "../config/prompt-templates";
6
6
  import type { Settings } from "../config/settings";
7
7
  import { EditTool } from "../edit";
@@ -34,6 +34,7 @@ import { BrowserTool } from "./browser";
34
34
  import { type CheckpointState, CheckpointTool, RewindTool } from "./checkpoint";
35
35
  import { DebugTool } from "./debug";
36
36
  import { EvalTool } from "./eval";
37
+ import { resolveEvalBackends } from "./eval-backends";
37
38
  import { FindTool } from "./find";
38
39
  import { GithubTool } from "./gh";
39
40
  import { InspectImageTool } from "./inspect-image";
@@ -74,6 +75,7 @@ export * from "./browser";
74
75
  export * from "./checkpoint";
75
76
  export * from "./debug";
76
77
  export * from "./eval";
78
+ export * from "./eval-backends";
77
79
  export * from "./find";
78
80
  export * from "./gh";
79
81
  export * from "./image-gen";
@@ -157,7 +159,7 @@ export interface ToolSession {
157
159
  getHindsightSessionState?: () => HindsightSessionState | undefined;
158
160
  /** Get Mnemopi runtime state for this agent session. */
159
161
  getMnemopiSessionState?: () => MnemopiSessionState | undefined;
160
- /** Agent identity used for IRC routing. Returns the registry id (e.g. "0-Main", "0-AuthLoader"). */
162
+ /** Agent identity used for IRC routing. Returns the registry id (e.g. "Main", "AuthLoader"). */
161
163
  getAgentId?: () => string | null;
162
164
  /** Look up a registered tool by name (used by the eval js backend's tool bridge). */
163
165
  getToolByName?: (name: string) => AgentTool | undefined;
@@ -257,6 +259,10 @@ export interface ToolSession {
257
259
  * by `getConflictHistory`. */
258
260
  conflictHistory?: import("./conflict-detect").ConflictHistory;
259
261
 
262
+ /** Per-session ledger of post-edit LSP diagnostics already surfaced to the
263
+ * model for each file. Lazily initialized by `getDiagnosticsLedger`. */
264
+ diagnosticsLedger?: import("../lsp/diagnostics-ledger").DiagnosticsLedger;
265
+
260
266
  /** Queue a hidden message to be injected at the next agent turn. */
261
267
  queueDeferredMessage?(message: CustomMessage): void;
262
268
  /** Get the active OpenTelemetry config so subagent dispatch can forward
@@ -331,42 +337,6 @@ export const HIDDEN_TOOLS: Record<string, ToolFactory> = {
331
337
 
332
338
  export type ToolName = keyof typeof BUILTIN_TOOLS;
333
339
 
334
- export interface EvalBackendsAllowance {
335
- python: boolean;
336
- js: boolean;
337
- }
338
-
339
- /**
340
- * Parse PI_PY / PI_JS environment variables. Each is a boolean flag; unset
341
- * means "not specified, defer to settings". Returns null when neither is set
342
- * so the caller can fall through to `readEvalBackendsAllowance` per key.
343
- */
344
- function getEvalBackendsFromEnv(): EvalBackendsAllowance | null {
345
- const pyEnv = $env.PI_PY;
346
- const jsEnv = $env.PI_JS;
347
- if (pyEnv === undefined && jsEnv === undefined) return null;
348
- return {
349
- python: pyEnv === undefined ? true : $flag("PI_PY"),
350
- js: jsEnv === undefined ? true : $flag("PI_JS"),
351
- };
352
- }
353
-
354
- /** Read per-backend allowance from settings (defaults true). */
355
- export function readEvalBackendsAllowance(session: ToolSession): EvalBackendsAllowance {
356
- return {
357
- python: session.settings.get("eval.py") ?? true,
358
- js: session.settings.get("eval.js") ?? true,
359
- };
360
- }
361
-
362
- /**
363
- * Materialize the active eval backend allowance: PI_PY / PI_JS env flags
364
- * override the per-key settings; otherwise settings (defaults true) win.
365
- */
366
- export function resolveEvalBackends(session: ToolSession): EvalBackendsAllowance {
367
- return getEvalBackendsFromEnv() ?? readEvalBackendsAllowance(session);
368
- }
369
-
370
340
  /**
371
341
  * Create tools from BUILTIN_TOOLS registry.
372
342
  */
@@ -379,6 +379,145 @@ export function hasGlobPathChars(filePath: string): boolean {
379
379
  return GLOB_PATH_CHARS.some(char => filePath.includes(char));
380
380
  }
381
381
 
382
+ type PathEntrySplitter = (item: string) => { basePath: string };
383
+
384
+ const TOP_LEVEL_WHITESPACE_RE = /\s/;
385
+
386
+ type DelimitedPathSplitMode = "comma" | "semicolon" | "whitespace" | "mixed";
387
+
388
+ function isDelimitedPathSeparator(ch: string, mode: DelimitedPathSplitMode): boolean {
389
+ if (mode === "comma") return ch === ",";
390
+ if (mode === "semicolon") return ch === ";";
391
+ if (mode === "whitespace") return TOP_LEVEL_WHITESPACE_RE.test(ch);
392
+ return ch === "," || ch === ";" || TOP_LEVEL_WHITESPACE_RE.test(ch);
393
+ }
394
+
395
+ function hasTopLevelPathDelimiter(entry: string): boolean {
396
+ let braceDepth = 0;
397
+ for (let i = 0; i < entry.length; i++) {
398
+ const ch = entry[i];
399
+ if (ch === "\\" && i + 1 < entry.length) {
400
+ i++;
401
+ continue;
402
+ }
403
+ if (ch === "{") {
404
+ braceDepth++;
405
+ continue;
406
+ }
407
+ if (ch === "}") {
408
+ if (braceDepth > 0) braceDepth--;
409
+ continue;
410
+ }
411
+ if (braceDepth === 0 && (ch === "," || ch === ";" || TOP_LEVEL_WHITESPACE_RE.test(ch))) {
412
+ return true;
413
+ }
414
+ }
415
+ return false;
416
+ }
417
+
418
+ function splitTopLevelDelimitedPath(entry: string, mode: DelimitedPathSplitMode): string[] {
419
+ const parts: string[] = [];
420
+ let braceDepth = 0;
421
+ let start = 0;
422
+ for (let i = 0; i < entry.length; i++) {
423
+ const ch = entry[i];
424
+ if (ch === "\\" && i + 1 < entry.length) {
425
+ i++;
426
+ continue;
427
+ }
428
+ if (ch === "{") {
429
+ braceDepth++;
430
+ continue;
431
+ }
432
+ if (ch === "}") {
433
+ if (braceDepth > 0) braceDepth--;
434
+ continue;
435
+ }
436
+ if (braceDepth !== 0 || !isDelimitedPathSeparator(ch, mode)) continue;
437
+ parts.push(entry.slice(start, i));
438
+ start = i + 1;
439
+ }
440
+ parts.push(entry.slice(start));
441
+ return parts;
442
+ }
443
+
444
+ async function delimitedPathPartResolves(entry: string, cwd: string, splitter: PathEntrySplitter): Promise<boolean> {
445
+ if (isInternalUrlPath(entry)) return true;
446
+ const peeled = splitPathAndSel(entry).path;
447
+ const { basePath } = splitter(peeled);
448
+ const absoluteBasePath = resolveToCwd(basePath, cwd);
449
+ try {
450
+ await fs.promises.stat(absoluteBasePath);
451
+ return true;
452
+ } catch (err) {
453
+ if (isEnoent(err)) return false;
454
+ throw err;
455
+ }
456
+ }
457
+
458
+ async function tryDelimitedPathSplit(
459
+ entry: string,
460
+ cwd: string,
461
+ splitter: PathEntrySplitter,
462
+ mode: DelimitedPathSplitMode,
463
+ requireAllParts: boolean,
464
+ ): Promise<string[] | null> {
465
+ const rawParts = splitTopLevelDelimitedPath(entry, mode);
466
+ if (rawParts.length < 2) return null;
467
+
468
+ const parts = rawParts.map(normalizePathLikeInput).filter(part => part.length > 0);
469
+ if (parts.length === 0) return null;
470
+ if (parts.length < 2 && rawParts.length === parts.length) return null;
471
+
472
+ const resolved = await Promise.all(parts.map(part => delimitedPathPartResolves(part, cwd, splitter)));
473
+ const valid = requireAllParts ? resolved.every(Boolean) : resolved.some(Boolean);
474
+ return valid ? parts : null;
475
+ }
476
+
477
+ /**
478
+ * Split one path-like entry whose multiple targets were flattened into one
479
+ * string. Existing paths are kept intact, so real filenames containing spaces,
480
+ * commas, or semicolons win over delimiter recovery.
481
+ */
482
+ export async function splitDelimitedPathEntry(
483
+ entry: string,
484
+ cwd: string,
485
+ options: { splitter?: PathEntrySplitter } = {},
486
+ ): Promise<string[] | null> {
487
+ const normalizedEntry = normalizePathLikeInput(entry);
488
+ if (!hasTopLevelPathDelimiter(normalizedEntry)) return null;
489
+ if (isInternalUrlPath(normalizedEntry)) return null;
490
+
491
+ const splitter = options.splitter ?? parseSearchPath;
492
+ const peeledEntry = splitPathAndSel(normalizedEntry).path;
493
+ if (!hasGlobPathChars(peeledEntry) && (await delimitedPathPartResolves(normalizedEntry, cwd, splitter))) {
494
+ return null;
495
+ }
496
+
497
+ return (
498
+ (await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "comma", false)) ??
499
+ (await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "semicolon", false)) ??
500
+ (await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "whitespace", true)) ??
501
+ (await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "mixed", true))
502
+ );
503
+ }
504
+
505
+ /** Expand delimited entries in-place while preserving unsplit entries. */
506
+ export async function expandDelimitedPathEntries(
507
+ entries: readonly string[],
508
+ cwd: string,
509
+ options: { splitter?: PathEntrySplitter } = {},
510
+ ): Promise<string[]> {
511
+ const expanded: string[] = [];
512
+ for (const entry of entries) {
513
+ const normalizedEntry = normalizePathLikeInput(entry);
514
+ const split = await splitDelimitedPathEntry(normalizedEntry, cwd, options);
515
+ if (split) expanded.push(...split);
516
+ else expanded.push(normalizedEntry);
517
+ }
518
+ return expanded;
519
+ }
520
+
382
521
  export interface ParsedSearchPath {
383
522
  basePath: string;
384
523
  glob?: string;
@@ -769,7 +908,11 @@ export interface ToolScopeResolution {
769
908
  */
770
909
  export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<ToolScopeResolution> {
771
910
  const { rawPaths: inputs, cwd, internalUrlAction } = opts;
772
- const rawPaths = inputs.map(normalizePathLikeInput);
911
+ const normalizedRawPaths = inputs.map(normalizePathLikeInput);
912
+ if (normalizedRawPaths.some(rawPath => rawPath.length === 0)) {
913
+ throw new ToolError("`paths` must contain non-empty paths or globs");
914
+ }
915
+ const rawPaths = await expandDelimitedPathEntries(normalizedRawPaths, cwd);
773
916
  if (rawPaths.some(rawPath => rawPath.length === 0)) {
774
917
  throw new ToolError("`paths` must contain non-empty paths or globs");
775
918
  }
package/src/tools/read.ts CHANGED
@@ -69,6 +69,7 @@ import {
69
69
  type LineRange,
70
70
  parseLineRanges,
71
71
  resolveReadPath,
72
+ splitDelimitedPathEntry,
72
73
  splitInternalUrlSel,
73
74
  splitPathAndSel,
74
75
  } from "./path-utils";
@@ -693,6 +694,50 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
693
694
  });
694
695
  }
695
696
 
697
+ async #tryReadDelimitedPaths(
698
+ readPath: string,
699
+ signal?: AbortSignal,
700
+ ): Promise<AgentToolResult<ReadToolDetails> | null> {
701
+ const parts = await splitDelimitedPathEntry(readPath, this.session.cwd);
702
+ if (!parts) return null;
703
+
704
+ const notice = `Note: interpreted as ${parts.length} paths: ${parts.join(", ")}`;
705
+ const notes = [notice];
706
+ const content: Array<TextContent | ImageContent> = [];
707
+ let pendingText = notice;
708
+ const flushText = () => {
709
+ if (pendingText.length === 0) return;
710
+ content.push({ type: "text", text: pendingText });
711
+ pendingText = "";
712
+ };
713
+ const appendText = (text: string) => {
714
+ pendingText = pendingText.length > 0 ? `${pendingText}\n\n${text}` : text;
715
+ };
716
+
717
+ for (const part of parts) {
718
+ try {
719
+ const result = await this.execute("read-delimited-part", { path: part }, signal);
720
+ for (const block of result.content) {
721
+ if (block.type === "text") {
722
+ appendText(block.text);
723
+ continue;
724
+ }
725
+ flushText();
726
+ content.push(block);
727
+ }
728
+ } catch (error) {
729
+ if (error instanceof ToolAbortError || signal?.aborted) throw error;
730
+ const message = error instanceof Error ? error.message : String(error);
731
+ const errorNote = `Could not read ${part}: ${message}`;
732
+ notes.push(errorNote);
733
+ appendText(`[${errorNote}]`);
734
+ }
735
+ }
736
+ flushText();
737
+
738
+ return toolResult<ReadToolDetails>({ notes }).content(content).done();
739
+ }
740
+
696
741
  async #resolveArchiveReadPath(readPath: string, signal?: AbortSignal): Promise<ResolvedArchiveReadPath | null> {
697
742
  const candidates = parseArchivePathCandidates(readPath);
698
743
  for (const candidate of candidates) {
@@ -1596,6 +1641,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1596
1641
  }
1597
1642
 
1598
1643
  if (!suffixResolution) {
1644
+ const delimitedResult = await this.#tryReadDelimitedPaths(readPath, signal);
1645
+ if (delimitedResult) return delimitedResult;
1599
1646
  throw new ToolError(`Path '${localReadPath}' not found`);
1600
1647
  }
1601
1648
  } else {
@@ -17,7 +17,7 @@ import { astGrepToolRenderer } from "./ast-grep";
17
17
  import { bashToolRenderer } from "./bash";
18
18
  import { browserToolRenderer } from "./browser/render";
19
19
  import { debugToolRenderer } from "./debug";
20
- import { evalToolRenderer } from "./eval";
20
+ import { evalToolRenderer } from "./eval-render";
21
21
  import { findToolRenderer } from "./find";
22
22
  import { githubToolRenderer } from "./gh-renderer";
23
23
  import { inspectImageToolRenderer } from "./inspect-image-renderer";
@@ -30,6 +30,7 @@ import { formatGroupedFiles } from "./grouped-file-output";
30
30
  import { formatMatchLine } from "./match-line-format";
31
31
  import { formatFullOutputReference, type OutputMeta } from "./output-meta";
32
32
  import {
33
+ expandDelimitedPathEntries,
33
34
  hasGlobPathChars,
34
35
  isLineInRanges,
35
36
  type LineRange,
@@ -93,29 +94,6 @@ export const SINGLE_FILE_MATCHES = 200;
93
94
  * pagination headroom so the caller can see total file count. */
94
95
  const INTERNAL_TOTAL_CAP = 2000;
95
96
 
96
- /**
97
- * Detect a `,` that is not inside a `{…}` brace expansion. Used to catch
98
- * `paths: ["a,b"]` mistakes where the caller flattened multiple entries
99
- * into a single string instead of passing a JSON array of strings.
100
- */
101
- function containsTopLevelComma(entry: string): boolean {
102
- let depth = 0;
103
- for (let i = 0; i < entry.length; i++) {
104
- const ch = entry[i];
105
- if (ch === "\\" && i + 1 < entry.length) {
106
- i++;
107
- continue;
108
- }
109
- if (ch === "{") depth++;
110
- else if (ch === "}") {
111
- if (depth > 0) depth--;
112
- } else if (ch === "," && depth === 0) {
113
- return true;
114
- }
115
- }
116
- return false;
117
- }
118
-
119
97
  /**
120
98
  * Parsed `paths` entry — a path (possibly archive-shaped) plus an optional
121
99
  * line-range selector peeled off the trailing `:N-M` (or `:N+K`, `:N,M`, …)
@@ -146,9 +124,6 @@ function parsePathSpecs(rawEntries: readonly string[]): SearchPathSpec[] {
146
124
  clean = split.path;
147
125
  ranges = parsed;
148
126
  }
149
- if (containsTopLevelComma(clean)) {
150
- throw new ToolError('paths is an array — pass ["a", "b"] not ["a,b"]');
151
- }
152
127
  specs.push({ original: entry, clean, ranges });
153
128
  }
154
129
  return specs;
@@ -663,7 +638,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
663
638
  if (normalizedSkip < 0 || !Number.isFinite(normalizedSkip)) {
664
639
  throw new ToolError("Skip must be a non-negative number");
665
640
  }
666
- const rawEntries = toPathList(rawPaths);
641
+ const rawEntries = await expandDelimitedPathEntries(toPathList(rawPaths), this.session.cwd);
667
642
  const pathSpecs = parsePathSpecs(rawEntries);
668
643
  const paths = pathSpecs.map(spec => spec.clean);
669
644
  const {
@@ -12,6 +12,15 @@ const MAX_QUERY_LIMIT = 500;
12
12
  const MAX_RENDER_WIDTH = 120;
13
13
  const MAX_COLUMN_WIDTH = 40;
14
14
  const MIN_COLUMN_WIDTH = 1;
15
+ /**
16
+ * Upper bound on rows scanned when counting a table for the listing. SQLite has
17
+ * no stored row count, so `COUNT(*)` is a full b-tree scan — multi-second on a
18
+ * multi-GB database, and `bun:sqlite` runs it synchronously on the JS thread
19
+ * that also drives the TUI, freezing rendering and input. The listing instead
20
+ * trusts the planner's `sqlite_stat1` estimate for large tables and only counts
21
+ * exactly when a table is provably small, reading at most this many rows.
22
+ */
23
+ const ROW_COUNT_PROBE_CAP = 50_000;
15
24
 
16
25
  type SqliteBinding = Exclude<SQLQueryBindings, Record<string, unknown>>;
17
26
 
@@ -26,6 +35,11 @@ interface SqliteCountRow {
26
35
  count: number;
27
36
  }
28
37
 
38
+ interface SqliteStat1Row {
39
+ tbl: string;
40
+ stat: string | null;
41
+ }
42
+
29
43
  interface SqliteTableInfoRow {
30
44
  cid: number;
31
45
  name: string;
@@ -50,6 +64,23 @@ export type SqliteSelector =
50
64
 
51
65
  export type SqliteRowLookup = { kind: "pk"; column: string; type: string } | { kind: "rowid" };
52
66
 
67
+ /**
68
+ * Row count for a table in the listing.
69
+ * - `exact`: counted in full (the table is small enough to count cheaply).
70
+ * - `estimate`: the planner's `sqlite_stat1` figure; the table is too large to
71
+ * scan, so this may be stale.
72
+ * - `atLeast`: a lower bound; counting was capped before reaching the end.
73
+ */
74
+ export type TableRowCount =
75
+ | { kind: "exact"; rows: number }
76
+ | { kind: "estimate"; rows: number }
77
+ | { kind: "atLeast"; rows: number };
78
+
79
+ export interface SqliteTableSummary {
80
+ name: string;
81
+ count: TableRowCount;
82
+ }
83
+
53
84
  function splitSqliteRemainder(remainder: string): { subPath: string; queryString: string } {
54
85
  const queryIndex = remainder.indexOf("?");
55
86
  if (queryIndex === -1) {
@@ -495,20 +526,61 @@ export function parseSqliteSelector(subPath: string, queryString: string): Sqlit
495
526
  return { kind: "schema", table, sampleLimit: DEFAULT_SCHEMA_SAMPLE_LIMIT };
496
527
  }
497
528
 
498
- export function listTables(db: Database): { name: string; rowCount: number }[] {
529
+ /**
530
+ * Reads the planner's per-table row estimate from `sqlite_stat1` (populated by
531
+ * `ANALYZE`). The first integer of each `stat` string is the number of rows in
532
+ * that index; for a full (non-partial) index it equals the table's row count,
533
+ * so the max across a table's entries is the table estimate. Returns an empty
534
+ * map when the database was never analyzed. One small indexed read — no scan.
535
+ */
536
+ function loadRowEstimates(db: Database): Map<string, number> {
537
+ const estimates = new Map<string, number>();
538
+ const hasStat1 = db
539
+ .prepare<Pick<SqliteMasterRow, "name">, []>(
540
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'sqlite_stat1'",
541
+ )
542
+ .get();
543
+ if (!hasStat1) return estimates;
544
+
545
+ for (const { tbl, stat } of db.prepare<SqliteStat1Row, []>("SELECT tbl, stat FROM sqlite_stat1").all()) {
546
+ if (!stat) continue;
547
+ const rows = Number.parseInt(stat, 10);
548
+ if (!Number.isFinite(rows)) continue;
549
+ const prev = estimates.get(tbl);
550
+ if (prev === undefined || rows > prev) estimates.set(tbl, rows);
551
+ }
552
+ return estimates;
553
+ }
554
+
555
+ /**
556
+ * Counts a table while reading at most `cap + 1` rows. Returns an exact count
557
+ * when the table holds `cap` rows or fewer, otherwise a lower bound of `cap`.
558
+ * Bounds the worst-case scan so a stale or missing estimate can never trigger a
559
+ * full-table scan on the JS thread.
560
+ */
561
+ function probeRowCount(db: Database, table: string, cap: number): TableRowCount {
562
+ const sql = `SELECT COUNT(*) AS count FROM (SELECT 1 FROM ${quoteSqliteIdentifier(table)} LIMIT ${cap + 1})`;
563
+ const counted = db.prepare<SqliteCountRow, []>(sql).get()?.count ?? 0;
564
+ return counted > cap ? { kind: "atLeast", rows: cap } : { kind: "exact", rows: counted };
565
+ }
566
+
567
+ export function listTables(db: Database, options: { probeCap?: number } = {}): SqliteTableSummary[] {
568
+ const cap = options.probeCap ?? ROW_COUNT_PROBE_CAP;
499
569
  const names = db
500
570
  .prepare<Pick<SqliteMasterRow, "name">, []>(
501
571
  "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name COLLATE NOCASE",
502
572
  )
503
573
  .all();
574
+ const estimates = loadRowEstimates(db);
504
575
 
505
576
  return names.map(({ name }) => {
506
- const countRow =
507
- db.prepare<SqliteCountRow, []>(`SELECT COUNT(*) AS count FROM ${quoteSqliteIdentifier(name)}`).get() ?? null;
508
- return {
509
- name,
510
- rowCount: countRow?.count ?? 0,
511
- };
577
+ const estimate = estimates.get(name);
578
+ // Trust the planner only when it says the table is too large to count
579
+ // cheaply; otherwise count exactly (bounded), which also corrects a
580
+ // stale-low estimate without ever scanning more than `cap` rows.
581
+ const count: TableRowCount =
582
+ estimate !== undefined && estimate > cap ? { kind: "estimate", rows: estimate } : probeRowCount(db, name, cap);
583
+ return { name, count };
512
584
  });
513
585
  }
514
586
 
@@ -679,13 +751,24 @@ export function deleteRowByRowId(db: Database, table: string, key: string): numb
679
751
  return statement.run(binding).changes;
680
752
  }
681
753
 
682
- export function renderTableList(tables: { name: string; rowCount: number }[]): string {
754
+ function formatRowCount(count: TableRowCount): string {
755
+ switch (count.kind) {
756
+ case "exact":
757
+ return `${count.rows} rows`;
758
+ case "estimate":
759
+ return `~${count.rows} rows`;
760
+ case "atLeast":
761
+ return `${count.rows}+ rows`;
762
+ }
763
+ }
764
+
765
+ export function renderTableList(tables: SqliteTableSummary[]): string {
683
766
  if (tables.length === 0) {
684
767
  return "(no tables)";
685
768
  }
686
769
 
687
770
  return tables
688
- .map(table => truncateToWidth(replaceTabs(`${table.name} (${table.rowCount} rows)`), MAX_RENDER_WIDTH))
771
+ .map(table => truncateToWidth(replaceTabs(`${table.name} (${formatRowCount(table.count)})`), MAX_RENDER_WIDTH))
689
772
  .join("\n");
690
773
  }
691
774
 
@@ -15,6 +15,7 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
15
15
  import { InternalUrlRouter } from "../internal-urls";
16
16
  import { parseInternalUrl } from "../internal-urls/parse";
17
17
  import { createLspWritethrough, type FileDiagnosticsResult, type WritethroughCallback, writethroughNoop } from "../lsp";
18
+ import { getDiagnosticsLedger } from "../lsp/diagnostics-ledger";
18
19
  import { getLanguageFromPath, highlightCode, type Theme } from "../modes/theme/theme";
19
20
  import writeDescription from "../prompts/tools/write.md" with { type: "text" };
20
21
  import type { ToolSession } from "../sdk";
@@ -278,8 +279,15 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
278
279
  const enableLsp = session.enableLsp ?? true;
279
280
  const enableFormat = enableLsp && session.settings.get("lsp.formatOnWrite");
280
281
  const enableDiagnostics = enableLsp && session.settings.get("lsp.diagnosticsOnWrite");
282
+ const dedup = enableDiagnostics && session.settings.get("lsp.diagnosticsDeduplicate");
281
283
  this.#writethrough = enableLsp
282
- ? createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics })
284
+ ? createLspWritethrough(session.cwd, {
285
+ enableFormat,
286
+ enableDiagnostics,
287
+ transformDiagnostics: dedup
288
+ ? (path, result) => getDiagnosticsLedger(session).reduce(path, result)
289
+ : undefined,
290
+ })
283
291
  : writethroughNoop;
284
292
  this.description = prompt.render(writeDescription);
285
293
  }
@@ -19,7 +19,7 @@ export interface OutputBlockOptions {
19
19
  animate?: boolean;
20
20
  }
21
21
 
22
- const BORDER_SHIMMER_TICK_MS = 50;
22
+ const BORDER_SHIMMER_TICK_MS = 16;
23
23
  /** Duration of one full left↔right↔left bounce of the bottom-edge segment, in
24
24
  * ms. Position is derived from the wall clock against this fixed cycle so a
25
25
  * resize only nudges the segment proportionally instead of teleporting it. */
@@ -28,9 +28,10 @@ const BORDER_BOUNCE_MS = 3000;
28
28
  const BORDER_SEGMENT_LEN = 8;
29
29
 
30
30
  /**
31
- * Monotonic frame counter for animated borders. Quantized coarse enough to
32
- * coalesce multiple render passes inside one frame, fine enough to advance on
33
- * every spinner interval so cached blocks re-render while the segment travels.
31
+ * Monotonic frame counter for animated borders, quantized to the TUI's ~16ms
32
+ * render cap so the cache key advances once per ~60fps frame fine enough for a
33
+ * smooth segment sweep, coarse enough to coalesce multiple render passes that
34
+ * land inside the same frame.
34
35
  */
35
36
  export function borderShimmerTick(): number {
36
37
  return Math.floor(Date.now() / BORDER_SHIMMER_TICK_MS);
@@ -119,7 +119,7 @@ async function readImageViaPowerShell(): Promise<ClipboardImage | null> {
119
119
  if (!b64) return null;
120
120
  const bytes = Buffer.from(b64, "base64");
121
121
  if (bytes.byteLength === 0) return null;
122
- return { data: new Uint8Array(bytes), mimeType: "image/png" };
122
+ return { data: bytes, mimeType: "image/png" };
123
123
  } catch {
124
124
  return null;
125
125
  }
@@ -154,3 +154,40 @@ export async function readImageFromClipboard(): Promise<ClipboardImage | null> {
154
154
 
155
155
  return (await native.readImageFromClipboard()) ?? null;
156
156
  }
157
+
158
+ /**
159
+ * Read plain text from the system clipboard.
160
+ */
161
+ export async function readTextFromClipboard(): Promise<string> {
162
+ try {
163
+ const p = process.platform;
164
+ if (p === "darwin") {
165
+ return execSync("pbpaste", { encoding: "utf8", timeout: 2000 }).toString();
166
+ }
167
+ if (p === "win32") {
168
+ return execSync('powershell.exe -NoProfile -Command "Get-Clipboard"', {
169
+ encoding: "utf8",
170
+ timeout: 2000,
171
+ }).toString();
172
+ }
173
+ if (process.env.TERMUX_VERSION) {
174
+ return execSync("termux-clipboard-get", { encoding: "utf8", timeout: 2000 }).toString();
175
+ }
176
+ const hasWaylandDisplay = Boolean(process.env.WAYLAND_DISPLAY);
177
+ const hasX11Display = Boolean(process.env.DISPLAY);
178
+ if (hasWaylandDisplay) {
179
+ try {
180
+ return execSync("wl-paste --type text/plain --no-newline", { encoding: "utf8", timeout: 2000 }).toString();
181
+ } catch {
182
+ if (hasX11Display) {
183
+ return execSync("xclip -selection clipboard -o", { encoding: "utf8", timeout: 2000 }).toString();
184
+ }
185
+ }
186
+ } else if (hasX11Display) {
187
+ return execSync("xclip -selection clipboard -o", { encoding: "utf8", timeout: 2000 }).toString();
188
+ }
189
+ } catch (error) {
190
+ logger.warn("clipboard: failed to read clipboard text", { error: String(error) });
191
+ }
192
+ return "";
193
+ }