@oh-my-pi/pi-coding-agent 15.7.6 → 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 (95) hide show
  1. package/CHANGELOG.md +146 -198
  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 +2 -2
  10. package/dist/types/config/settings.d.ts +13 -0
  11. package/dist/types/eval/concurrency-bridge.d.ts +26 -0
  12. package/dist/types/eval/js/tool-bridge.d.ts +2 -1
  13. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +11 -0
  14. package/dist/types/main.d.ts +5 -0
  15. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  16. package/dist/types/modes/components/hook-selector.d.ts +3 -0
  17. package/dist/types/modes/components/session-selector.d.ts +32 -5
  18. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  19. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
  20. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  21. package/dist/types/modes/interactive-mode.d.ts +9 -2
  22. package/dist/types/modes/types.d.ts +4 -2
  23. package/dist/types/registry/agent-registry.d.ts +1 -1
  24. package/dist/types/sdk.d.ts +2 -2
  25. package/dist/types/session/agent-session.d.ts +4 -2
  26. package/dist/types/session/history-storage.d.ts +16 -1
  27. package/dist/types/session/session-manager.d.ts +4 -0
  28. package/dist/types/task/output-manager.d.ts +6 -15
  29. package/dist/types/tools/find.d.ts +0 -9
  30. package/dist/types/tools/index.d.ts +1 -1
  31. package/dist/types/tools/path-utils.d.ts +16 -0
  32. package/dist/types/tools/sqlite-reader.d.ts +25 -8
  33. package/dist/types/utils/clipboard.d.ts +4 -0
  34. package/dist/types/web/kagi.d.ts +76 -0
  35. package/dist/types/web/search/providers/exa.d.ts +7 -1
  36. package/dist/types/web/search/providers/kagi.d.ts +1 -0
  37. package/package.json +9 -9
  38. package/src/async/job-manager.ts +3 -3
  39. package/src/cli/args.ts +6 -2
  40. package/src/cli/claude-trace-cli.ts +783 -0
  41. package/src/cli/session-picker.ts +36 -10
  42. package/src/cli/update-cli.ts +35 -2
  43. package/src/commands/launch.ts +3 -0
  44. package/src/config/keybindings.ts +6 -0
  45. package/src/config/settings-schema.ts +2 -2
  46. package/src/config/settings.ts +23 -0
  47. package/src/discovery/claude-plugins.ts +7 -9
  48. package/src/eval/__tests__/agent-bridge.test.ts +58 -4
  49. package/src/eval/concurrency-bridge.ts +34 -0
  50. package/src/eval/js/shared/prelude.txt +20 -17
  51. package/src/eval/js/tool-bridge.ts +5 -0
  52. package/src/eval/py/prelude.py +23 -15
  53. package/src/extensibility/plugins/legacy-pi-compat.ts +115 -131
  54. package/src/extensibility/skills.ts +0 -1
  55. package/src/internal-urls/docs-index.generated.ts +11 -10
  56. package/src/main.ts +92 -24
  57. package/src/modes/acp/acp-event-mapper.ts +54 -4
  58. package/src/modes/components/custom-editor.ts +10 -0
  59. package/src/modes/components/hook-selector.ts +89 -31
  60. package/src/modes/components/oauth-selector.ts +12 -6
  61. package/src/modes/components/session-selector.ts +179 -24
  62. package/src/modes/components/tool-execution.ts +16 -3
  63. package/src/modes/controllers/command-controller.ts +2 -11
  64. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  65. package/src/modes/controllers/input-controller.ts +19 -1
  66. package/src/modes/controllers/selector-controller.ts +61 -21
  67. package/src/modes/interactive-mode.ts +125 -15
  68. package/src/modes/types.ts +5 -2
  69. package/src/prompts/system/orchestrate-notice.md +5 -3
  70. package/src/prompts/system/workflow-notice.md +2 -2
  71. package/src/prompts/tools/eval.md +5 -5
  72. package/src/prompts/tools/find.md +1 -1
  73. package/src/prompts/tools/irc.md +6 -6
  74. package/src/prompts/tools/search.md +1 -1
  75. package/src/prompts/tools/task.md +1 -1
  76. package/src/registry/agent-registry.ts +1 -1
  77. package/src/sdk.ts +85 -31
  78. package/src/session/agent-session.ts +62 -46
  79. package/src/session/history-storage.ts +56 -12
  80. package/src/session/session-manager.ts +34 -0
  81. package/src/task/output-manager.ts +40 -48
  82. package/src/task/render.ts +3 -8
  83. package/src/tools/browser/tab-worker.ts +8 -5
  84. package/src/tools/find.ts +5 -29
  85. package/src/tools/index.ts +1 -1
  86. package/src/tools/path-utils.ts +144 -1
  87. package/src/tools/read.ts +47 -0
  88. package/src/tools/search.ts +2 -27
  89. package/src/tools/sqlite-reader.ts +92 -9
  90. package/src/utils/clipboard.ts +38 -1
  91. package/src/utils/open.ts +37 -2
  92. package/src/web/kagi.ts +168 -49
  93. package/src/web/search/providers/anthropic.ts +1 -1
  94. package/src/web/search/providers/exa.ts +20 -86
  95. package/src/web/search/providers/kagi.ts +4 -0
@@ -159,7 +159,7 @@ export interface ToolSession {
159
159
  getHindsightSessionState?: () => HindsightSessionState | undefined;
160
160
  /** Get Mnemopi runtime state for this agent session. */
161
161
  getMnemopiSessionState?: () => MnemopiSessionState | undefined;
162
- /** 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"). */
163
163
  getAgentId?: () => string | null;
164
164
  /** Look up a registered tool by name (used by the eval js backend's tool bridge). */
165
165
  getToolByName?: (name: string) => AgentTool | undefined;
@@ -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 {
@@ -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
 
@@ -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
+ }
package/src/utils/open.ts CHANGED
@@ -1,3 +1,36 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as url from "node:url";
4
+ import * as piUtils from "@oh-my-pi/pi-utils";
5
+
6
+ const URL_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
7
+
8
+ function getExistingWslLocalPath(urlOrPath: string): string | undefined {
9
+ if (
10
+ process.platform !== "linux" ||
11
+ !(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) ||
12
+ !piUtils.$which("wslview")
13
+ ) {
14
+ return undefined;
15
+ }
16
+
17
+ try {
18
+ const localPath = urlOrPath.startsWith("file://")
19
+ ? url.fileURLToPath(urlOrPath)
20
+ : URL_SCHEME_PATTERN.test(urlOrPath)
21
+ ? undefined
22
+ : path.resolve(urlOrPath);
23
+ if (!localPath || !fs.existsSync(localPath)) return undefined;
24
+
25
+ const result = Bun.spawnSync(["wslpath", "-w", localPath], { stdout: "pipe", stderr: "ignore" });
26
+ if (result.exitCode !== 0) return undefined;
27
+
28
+ return result.stdout.toString().trim() || undefined;
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ }
33
+
1
34
  /** Open a URL or file path in the default browser/application. Best-effort, never throws. */
2
35
  export function openPath(urlOrPath: string): void {
3
36
  let cmd: string[];
@@ -8,9 +41,11 @@ export function openPath(urlOrPath: string): void {
8
41
  case "win32":
9
42
  cmd = ["rundll32", "url.dll,FileProtocolHandler", urlOrPath];
10
43
  break;
11
- default:
12
- cmd = ["xdg-open", urlOrPath];
44
+ default: {
45
+ const wslPath = getExistingWslLocalPath(urlOrPath);
46
+ cmd = wslPath ? ["wslview", wslPath] : ["xdg-open", urlOrPath];
13
47
  break;
48
+ }
14
49
  }
15
50
  try {
16
51
  Bun.spawn(cmd, { stdin: "ignore", stdout: "ignore", stderr: "ignore" });