@danypops/pi-lector 0.9.1 → 0.9.3

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.
@@ -1,6 +1,8 @@
1
- import type { GitDiffResult, GitLogEntry, GitStatusSummary } from "@danypops/lector";
1
+ import type { GitDiffResult, GitLogEntry, GitStatusSummary, OperationOutputs } from "@danypops/lector";
2
2
  import { lectorClient, withWorkspace, workspaceForDirectory } from "./lector-client.ts";
3
3
 
4
+ type SymbolComparison = OperationOutputs["workspace.compareSymbolAcrossVersions"];
5
+
4
6
  /**
5
7
  * Thin wrappers over Lector's read-only git operations. `directory` is
6
8
  * required, same convention as find_symbols -- no implicit "whatever the
@@ -10,6 +12,7 @@ export interface GitOperations {
10
12
  status(directory: string): Promise<GitStatusSummary>;
11
13
  log(directory: string, maxCount: number): Promise<readonly GitLogEntry[]>;
12
14
  diff(directory: string, ref: string | undefined, maxBytes: number): Promise<GitDiffResult>;
15
+ compareSymbol(directory: string, path: string, symbolName: string, fromRef: string, toRef: string | undefined, maxBytes: number): Promise<SymbolComparison>;
13
16
  }
14
17
 
15
18
  export function createLectorGitOperations(): GitOperations {
@@ -42,5 +45,14 @@ export function createLectorGitOperations(): GitOperations {
42
45
  },
43
46
  );
44
47
  },
48
+ async compareSymbol(directory, path, symbolName, fromRef, toRef, maxBytes) {
49
+ return withWorkspace(
50
+ () => workspaceForDirectory(directory),
51
+ async ({ workspaceId }) => {
52
+ const client = await lectorClient();
53
+ return client.call("workspace.compareSymbolAcrossVersions", { workspaceId, path, symbolName, fromRef, toRef, maxBytes });
54
+ },
55
+ );
56
+ },
45
57
  };
46
58
  }
@@ -1,4 +1,4 @@
1
- import type { GitDiffResult, GitLogEntry, GitStatusSummary } from "@danypops/lector";
1
+ import type { GitDiffResult, GitLogEntry, GitStatusSummary, OperationOutputs } from "@danypops/lector";
2
2
  import { keyHint } from "@earendil-works/pi-coding-agent";
3
3
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
4
  import { renderDiffLines, renderTruncatedList, type TextMeasure } from "malevich-tui-components";
@@ -11,18 +11,31 @@ const DEFAULT_VISIBLE_FILES = 20;
11
11
  const DEFAULT_VISIBLE_COMMITS = 10;
12
12
  const DEFAULT_VISIBLE_DIFF_LINES = 60;
13
13
 
14
- export type GitAction = "status" | "log" | "diff";
14
+ export type GitAction = "status" | "log" | "diff" | "compare-symbol";
15
+
16
+ type SymbolComparison = OperationOutputs["workspace.compareSymbolAcrossVersions"];
15
17
 
16
18
  export interface GitToolDetails {
17
19
  readonly action: GitAction;
18
20
  readonly summary?: GitStatusSummary;
19
21
  readonly entries?: readonly GitLogEntry[];
20
22
  readonly result?: GitDiffResult;
23
+ readonly comparison?: SymbolComparison;
21
24
  }
22
25
 
23
- export function formatGitCall(args: { action?: unknown; directory?: unknown; ref?: unknown }, theme: LectorTheme): string {
26
+ export function formatGitCall(
27
+ args: { action?: unknown; directory?: unknown; ref?: unknown; path?: unknown; symbol?: unknown; fromRef?: unknown; toRef?: unknown },
28
+ theme: LectorTheme,
29
+ ): string {
24
30
  const action = typeof args.action === "string" ? args.action : "";
25
31
  const directory = typeof args.directory === "string" ? args.directory : "";
32
+ if (action === "compare-symbol") {
33
+ const path = typeof args.path === "string" ? args.path : "";
34
+ const symbol = typeof args.symbol === "string" ? args.symbol : "";
35
+ const fromRef = typeof args.fromRef === "string" ? args.fromRef : "";
36
+ const toRef = typeof args.toRef === "string" ? ` -> ${args.toRef}` : "";
37
+ return `${theme.fg("toolTitle", theme.bold("git"))} ${theme.fg("muted", action)} ${theme.fg("accent", `${directory}/${path}`)} (${symbol}) ${fromRef}${toRef}`;
38
+ }
26
39
  const ref = typeof args.ref === "string" ? ` ${args.ref}` : "";
27
40
  return `${theme.fg("toolTitle", theme.bold("git"))} ${theme.fg("muted", action)} ${theme.fg("accent", directory)}${ref}`;
28
41
  }
@@ -31,6 +44,7 @@ export function formatGitResult(details: GitToolDetails | undefined, expanded: b
31
44
  if (!details) return theme.fg("dim", "No result.");
32
45
  if (details.action === "status") return formatGitStatusResult(details.summary, expanded, theme);
33
46
  if (details.action === "log") return formatGitLogResult(details.entries, expanded, theme);
47
+ if (details.action === "compare-symbol") return formatCompareSymbolResult(details.comparison, expanded, theme);
34
48
  return formatGitDiffResult(details.result, expanded, theme);
35
49
  }
36
50
 
@@ -90,11 +104,11 @@ function formatGitLogResult(entries: readonly GitLogEntry[] | undefined, expande
90
104
  * is the still-open "render file and Git diffs as bounded native Pi
91
105
  * visuals" follow-up.
92
106
  */
93
- function formatGitDiffResult(result: GitDiffResult | undefined, expanded: boolean, theme: LectorTheme): string {
94
- if (!result || result.diff.length === 0) return theme.fg("dim", "No differences.");
107
+ /** Shared by formatGitDiffResult and formatCompareSymbolResult -- both display a real unified-diff string, styled and bounded the same way. */
108
+ function renderStyledDiffLines(diff: string, truncatedUpstream: boolean, expanded: boolean, theme: LectorTheme): string {
95
109
  const styledLines = renderDiffLines(
96
110
  Number.MAX_SAFE_INTEGER,
97
- result.diff,
111
+ diff,
98
112
  {
99
113
  add: (s) => theme.fg("success", s),
100
114
  remove: (s) => theme.fg("error", s),
@@ -110,7 +124,20 @@ function formatGitDiffResult(result: GitDiffResult | undefined, expanded: boolea
110
124
  visibleCount: DEFAULT_VISIBLE_DIFF_LINES,
111
125
  formatItem: (line) => line,
112
126
  moreLine: (hidden) => theme.fg("dim", `... ${hidden} more line${hidden === 1 ? "" : "s"} (${keyHint("app.tools.expand", "to expand")})`),
113
- truncationWarning: result.truncated ? theme.fg("warning", "(diff output itself was truncated by maxBytes)") : undefined,
127
+ truncationWarning: truncatedUpstream ? theme.fg("warning", "(diff output itself was truncated by maxBytes)") : undefined,
114
128
  });
115
129
  return lines.join("\n");
116
130
  }
131
+
132
+ function formatGitDiffResult(result: GitDiffResult | undefined, expanded: boolean, theme: LectorTheme): string {
133
+ if (!result || result.diff.length === 0) return theme.fg("dim", "No differences.");
134
+ return renderStyledDiffLines(result.diff, result.truncated, expanded, theme);
135
+ }
136
+
137
+ function formatCompareSymbolResult(comparison: SymbolComparison | undefined, expanded: boolean, theme: LectorTheme): string {
138
+ if (!comparison) return theme.fg("dim", "No result.");
139
+ const header = theme.fg("accent", `${comparison.path} (${comparison.symbolName}) -- ${comparison.fromRef} -> ${comparison.toRef}`);
140
+ if (comparison.status === "both-missing") return `${header}\n${theme.fg("dim", "symbol found at neither version")}`;
141
+ if (comparison.status === "unchanged") return `${header}\n${theme.fg("dim", "unchanged")}`;
142
+ return `${header}\n${renderStyledDiffLines(comparison.diff, comparison.truncated, expanded, theme)}`;
143
+ }
@@ -14,6 +14,7 @@ import type {
14
14
  MutationHistoryEntry,
15
15
  NpmPackageCandidate,
16
16
  OperationOutputs,
17
+ PackageEcosystem,
17
18
  PackageSourceOperationResult,
18
19
  RepoFetchResult,
19
20
  SourcegraphCodeCandidate,
@@ -25,7 +26,7 @@ import type {
25
26
  WorkspaceMapResult,
26
27
  WorkspaceQueryOutcome,
27
28
  } from "@danypops/lector";
28
- import { DEFAULT_EXTERNAL_SEARCH_MAX_RESULTS } from "@danypops/lector";
29
+ import { DEFAULT_EXTERNAL_SEARCH_MAX_RESULTS, PACKAGE_ECOSYSTEMS } from "@danypops/lector";
29
30
  import {
30
31
  type AgentToolResult,
31
32
  createEditToolDefinition,
@@ -85,8 +86,18 @@ import { createLectorLineEditOperations } from "./line-edit-operations.ts";
85
86
  import { formatLineEditCall, formatLineEditResult } from "./line-edit-rendering.ts";
86
87
  import { createMutationHistoryOperations } from "./mutation-history-operations.ts";
87
88
  import { isFilesystemRoot, nearestGitRoot } from "./nearest-workspace-root.ts";
88
- import { createLectorPackageSourceOperations } from "./package-source-operations.ts";
89
- import { formatPackageSourceCall, formatPackageSourceResult } from "./package-source-rendering.ts";
89
+ import { createLectorPackageSourceOperations, type PackageSourceListPage } from "./package-source-operations.ts";
90
+ import {
91
+ buildPackageSourceListTableRows,
92
+ formatPackageSourceCall,
93
+ formatPackageSourceCleanResult,
94
+ formatPackageSourceListResult,
95
+ formatPackageSourceRemoveResult,
96
+ formatPackageSourceResult,
97
+ PACKAGE_SOURCE_LIST_TABLE_COLUMNS,
98
+ PACKAGE_SOURCE_LIST_VISIBLE_ROWS,
99
+ packageSourceListMoreLine,
100
+ } from "./package-source-rendering.ts";
90
101
  import { createLectorReadOperations } from "./read-operations.ts";
91
102
  import { createReferenceBasedRenameOperations } from "./reference-based-rename-operations.ts";
92
103
  import { createRenameOperations } from "./rename-operations.ts";
@@ -1033,17 +1044,26 @@ export default function (pi: ExtensionAPI) {
1033
1044
  name: "git",
1034
1045
  label: "Git",
1035
1046
  description:
1036
- "Working tree status, recent commit log, and unified diff for a real git repository, in one tool. Fails clearly if `directory` is not inside a git repository. ACTIONS: status (working tree state, ahead/behind tracking), log (recent commits, bounded by maxCount), diff (unified diff against `ref`, defaulting to HEAD, bounded by maxBytes).",
1037
- promptSnippet: "Show a repository's status, log, or diff",
1047
+ "Working tree status, recent commit log, unified diff, and one symbol's own declaration diff across two versions, for a real git repository, in one tool. Fails clearly if `directory` is not inside a git repository. ACTIONS: status (working tree state, ahead/behind tracking), log (recent commits, bounded by maxCount), diff (unified diff against `ref`, defaulting to HEAD, bounded by maxBytes), compare-symbol (a named symbol's own declaration text diffed between fromRef and toRef, or fromRef and the current working tree when toRef is omitted -- tree-sitter syntactic tier only, TypeScript/JavaScript files only, no project-aware cross-reference resolution).",
1048
+ promptSnippet: "Show a repository's status, log, diff, or one symbol's diff across versions",
1038
1049
  promptGuidelines: [
1039
- "maxCount is required for action=log; maxBytes is required for action=diff -- every bounded query needs its bound stated explicitly, never defaulted silently.",
1050
+ "maxCount is required for action=log; maxBytes is required for action=diff/compare-symbol -- every bounded query needs its bound stated explicitly, never defaulted silently.",
1051
+ "path, symbol, and fromRef are required for action=compare-symbol; toRef is optional and means 'the current working tree' when omitted.",
1040
1052
  ],
1041
1053
  parameters: Type.Object({
1042
- action: Type.String({ description: "status | log | diff" }),
1054
+ action: Type.String({ description: "status | log | diff | compare-symbol" }),
1043
1055
  directory: Type.String({ description: "Directory inside the repository to check, absolute or relative to the current working directory" }),
1044
1056
  maxCount: Type.Optional(Type.Number({ description: "Maximum number of commits to return, most recent first -- required for action=log" })),
1045
1057
  ref: Type.Optional(Type.String({ description: "Ref to diff against; defaults to HEAD -- only used for action=diff" })),
1046
- maxBytes: Type.Optional(Type.Number({ description: "Maximum diff size in bytes before truncating -- required for action=diff" })),
1058
+ maxBytes: Type.Optional(
1059
+ Type.Number({ description: "Maximum diff/comparison size in bytes before truncating -- required for action=diff/compare-symbol" }),
1060
+ ),
1061
+ path: Type.Optional(Type.String({ description: "File path (relative to directory) containing the symbol -- required for action=compare-symbol" })),
1062
+ symbol: Type.Optional(Type.String({ description: "Exact symbol name to compare -- required for action=compare-symbol" })),
1063
+ fromRef: Type.Optional(Type.String({ description: "Git ref for the 'before' version -- required for action=compare-symbol" })),
1064
+ toRef: Type.Optional(
1065
+ Type.String({ description: "Git ref for the 'after' version; omit to compare against the current working tree -- action=compare-symbol only" }),
1066
+ ),
1047
1067
  }),
1048
1068
  async execute(_toolCallId, params) {
1049
1069
  const directory = resolve(cwd, params.directory);
@@ -1066,6 +1086,13 @@ export default function (pi: ExtensionAPI) {
1066
1086
  const details: GitToolDetails = { action: "diff", result };
1067
1087
  return { content: [{ type: "text", text: result.diff.length === 0 ? "No differences." : result.diff }], details };
1068
1088
  }
1089
+ if (params.action === "compare-symbol") {
1090
+ if (!params.path || !params.symbol || !params.fromRef) throw new Error("git action=compare-symbol requires path, symbol, and fromRef");
1091
+ if (params.maxBytes === undefined) throw new Error("git action=compare-symbol requires maxBytes");
1092
+ const comparison = await gitOperations.compareSymbol(directory, params.path, params.symbol, params.fromRef, params.toRef, params.maxBytes);
1093
+ const details: GitToolDetails = { action: "compare-symbol", comparison };
1094
+ return { content: [{ type: "text", text: JSON.stringify(comparison) }], details };
1095
+ }
1069
1096
  throw new Error(`unknown git action: ${String(params.action)}`);
1070
1097
  },
1071
1098
  renderCall(args, theme, context) {
@@ -1355,23 +1382,78 @@ export default function (pi: ExtensionAPI) {
1355
1382
  },
1356
1383
  });
1357
1384
 
1385
+ type PackageSourceToolDetails =
1386
+ | { readonly action: "resolve"; readonly result: PackageSourceOperationResult }
1387
+ | { readonly action: "list"; readonly page: PackageSourceListPage }
1388
+ | { readonly action: "remove"; readonly result: { removed: boolean } }
1389
+ | { readonly action: "clean"; readonly result: { removed: number; skipped: number } };
1390
+
1391
+ /** A real type guard, not an assertion -- params.ecosystem arrives as a bare TypeBox string; this is the one place that turns it into PackageEcosystem, with a runtime check backing the narrowing. */
1392
+ function isPackageEcosystem(value: string): value is PackageEcosystem {
1393
+ return (PACKAGE_ECOSYSTEMS as readonly string[]).includes(value);
1394
+ }
1395
+ function optionalPackageEcosystem(value: string | undefined): PackageEcosystem | undefined {
1396
+ if (value === undefined) return undefined;
1397
+ if (!isPackageEcosystem(value)) throw new Error(`ecosystem must be one of ${PACKAGE_ECOSYSTEMS.join(", ")}; got "${value}"`);
1398
+ return value;
1399
+ }
1400
+ function requirePackageEcosystem(value: string | undefined): PackageEcosystem {
1401
+ if (value === undefined || !isPackageEcosystem(value)) {
1402
+ throw new Error(`ecosystem must be one of ${PACKAGE_ECOSYSTEMS.join(", ")}; got "${value ?? ""}"`);
1403
+ }
1404
+ return value;
1405
+ }
1406
+
1358
1407
  const packageSourceOperations = createLectorPackageSourceOperations();
1359
1408
  pi.registerTool({
1360
1409
  name: "package_source",
1361
1410
  label: "Package Source",
1362
1411
  description:
1363
- "Resolve an installed npm package to verified exact repository source. Uses the project's lockfile, bounded registry metadata, and an exact Git ref/commit; registers verified source as a read-only workspace for the other Lector tools.",
1364
- promptSnippet: "Resolve an installed npm package to exact read-only source",
1412
+ "Resolve, list, remove, or clean bookkeeping for installed npm packages resolved to verified exact repository source. action=resolve uses the project's lockfile, bounded registry metadata, and an exact Git ref/commit; registers verified source as a read-only workspace for the other Lector tools. action=list reports every package coordinate already resolved this way -- no re-resolution, no network. action=remove drops one bookkeeping entry by its exact ecosystem/name/resolvedVersion; refuses if it is still a currently-registered workspace. action=clean removes every non-in-use entry, optionally scoped to one ecosystem. Neither remove nor clean deletes the underlying repo_cache disk entry -- use repo_cache(action=evict) for that, since a monorepo can share one checkout across several package coordinates.",
1413
+ promptSnippet: "Resolve, list, remove, or clean verified package source bookkeeping",
1365
1414
  parameters: Type.Object({
1366
- directory: Type.String({ description: "Project directory containing the npm-family lockfile" }),
1367
- name: Type.String({ description: "Installed package name, including scope when present" }),
1368
- version: Type.Optional(Type.String({ description: "Exact installed version; required when the lockfile contains several versions" })),
1415
+ action: Type.Optional(Type.Union([Type.Literal("resolve"), Type.Literal("list"), Type.Literal("remove"), Type.Literal("clean")])),
1416
+ directory: Type.Optional(Type.String({ description: "Required for action=resolve -- project directory containing the npm-family lockfile" })),
1417
+ name: Type.Optional(Type.String({ description: "Required for action=resolve/remove -- installed package name, including scope when present" })),
1418
+ version: Type.Optional(
1419
+ Type.String({ description: "action=resolve only -- exact installed version; required when the lockfile contains several versions" }),
1420
+ ),
1369
1421
  registry: Type.Optional(Type.String({ description: "npm registry URL; defaults to the public npm registry" })),
1422
+ ecosystem: Type.Optional(Type.String({ description: "Required for action=remove; optional filter for action=list/clean" })),
1423
+ resolvedVersion: Type.Optional(Type.String({ description: "Required for action=remove -- the exact resolved version to remove" })),
1424
+ text: Type.Optional(Type.String({ description: "action=list only -- case-insensitive substring match across ecosystem/name/resolvedVersion" })),
1425
+ maxResults: Type.Optional(Type.Number({ description: "Required for action=list -- maximum entries to return in this page" })),
1426
+ cursor: Type.Optional(Type.String({ description: "action=list only -- opaque cursor from a prior call's nextCursor, to fetch the next page" })),
1370
1427
  }),
1371
- async execute(_toolCallId, params) {
1428
+ async execute(_toolCallId, params): Promise<AgentToolResult<PackageSourceToolDetails>> {
1429
+ if (params.action === "list") {
1430
+ if (params.maxResults === undefined) throw new Error("package_source action=list requires maxResults");
1431
+ const page = await packageSourceOperations.list({
1432
+ ecosystem: optionalPackageEcosystem(params.ecosystem),
1433
+ text: params.text,
1434
+ maxResults: params.maxResults,
1435
+ cursor: params.cursor,
1436
+ });
1437
+ return { content: [{ type: "text", text: JSON.stringify(page) }], details: { action: "list", page } };
1438
+ }
1439
+ if (params.action === "remove") {
1440
+ if (!params.name || !params.resolvedVersion) throw new Error("package_source action=remove requires ecosystem, name, and resolvedVersion");
1441
+ const result = await packageSourceOperations.remove(
1442
+ requirePackageEcosystem(params.ecosystem),
1443
+ params.registry ?? null,
1444
+ params.name,
1445
+ params.resolvedVersion,
1446
+ );
1447
+ return { content: [{ type: "text", text: JSON.stringify(result) }], details: { action: "remove", result } };
1448
+ }
1449
+ if (params.action === "clean") {
1450
+ const result = await packageSourceOperations.clean(optionalPackageEcosystem(params.ecosystem));
1451
+ return { content: [{ type: "text", text: JSON.stringify(result) }], details: { action: "clean", result } };
1452
+ }
1453
+ if (!params.directory || !params.name) throw new Error("package_source action=resolve requires directory and name");
1372
1454
  const directory = resolve(cwd, params.directory);
1373
1455
  const result = await packageSourceOperations.resolve(directory, params.name, params.version ?? null, params.registry ?? null);
1374
- return { content: [{ type: "text", text: JSON.stringify(result) }], details: { result } };
1456
+ return { content: [{ type: "text", text: JSON.stringify(result) }], details: { action: "resolve", result } };
1375
1457
  },
1376
1458
  renderCall(args, theme, context) {
1377
1459
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
@@ -1379,7 +1461,7 @@ export default function (pi: ExtensionAPI) {
1379
1461
  return text;
1380
1462
  },
1381
1463
  renderResult(result, { expanded, isPartial }, theme, context) {
1382
- if (isPartial) return new Text(theme.fg("warning", "Resolving package source..."), 0, 0);
1464
+ if (isPartial) return new Text(theme.fg("warning", "Working on package source..."), 0, 0);
1383
1465
  if (context.isError) {
1384
1466
  const errorText = result.content
1385
1467
  .filter((block) => block.type === "text")
@@ -1387,9 +1469,26 @@ export default function (pi: ExtensionAPI) {
1387
1469
  .join("\n");
1388
1470
  return new Text(theme.fg("error", errorText || "package_source failed"), 0, 0);
1389
1471
  }
1390
- const details = result.details as { result?: PackageSourceOperationResult } | undefined;
1472
+ const details = result.details as PackageSourceToolDetails | undefined;
1473
+ // A non-empty list renders as a real, bounded Table -- the human channel actually shows
1474
+ // package/workspace/path/size, not just a bare count, capped at PACKAGE_SOURCE_LIST_VISIBLE_ROWS
1475
+ // since the index can grow arbitrarily large even though maxResults bounds any one page.
1476
+ if (details?.action === "list" && details.page.entries.length > 0) {
1477
+ return renderBoundedTable({
1478
+ columns: PACKAGE_SOURCE_LIST_TABLE_COLUMNS,
1479
+ rows: buildPackageSourceListTableRows(details.page.entries),
1480
+ expanded,
1481
+ visibleRowCount: PACKAGE_SOURCE_LIST_VISIBLE_ROWS,
1482
+ moreLine: packageSourceListMoreLine(theme),
1483
+ measure: tableMeasure,
1484
+ headerStyle: (s) => theme.fg("muted", theme.bold(s)),
1485
+ });
1486
+ }
1391
1487
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1392
- text.setText(formatPackageSourceResult(details?.result, expanded, theme));
1488
+ if (details?.action === "list") text.setText(formatPackageSourceListResult(details.page, theme));
1489
+ else if (details?.action === "remove") text.setText(formatPackageSourceRemoveResult(details.result, theme));
1490
+ else if (details?.action === "clean") text.setText(formatPackageSourceCleanResult(details.result, theme));
1491
+ else text.setText(formatPackageSourceResult(details?.action === "resolve" ? details.result : undefined, expanded, theme));
1393
1492
  return text;
1394
1493
  },
1395
1494
  });
@@ -1,8 +1,16 @@
1
- import { DEFAULT_PACKAGE_SOURCE_BOUNDS, type PackageSourceOperationResult } from "@danypops/lector";
1
+ import { DEFAULT_PACKAGE_SOURCE_BOUNDS, type PackageEcosystem, type PackageSourceListEntry, type PackageSourceOperationResult } from "@danypops/lector";
2
2
  import { lectorClient } from "./lector-client.ts";
3
3
 
4
+ export interface PackageSourceListPage {
5
+ readonly entries: readonly PackageSourceListEntry[];
6
+ readonly nextCursor: string | null;
7
+ }
8
+
4
9
  export interface PackageSourceOperations {
5
10
  resolve(directory: string, name: string, requestedVersion: string | null, registry: string | null): Promise<PackageSourceOperationResult>;
11
+ list(options: { ecosystem?: PackageEcosystem; text?: string; maxResults: number; cursor?: string }): Promise<PackageSourceListPage>;
12
+ remove(ecosystem: PackageEcosystem, registry: string | null, name: string, resolvedVersion: string): Promise<{ removed: boolean }>;
13
+ clean(ecosystem: PackageEcosystem | undefined): Promise<{ removed: number; skipped: number }>;
6
14
  }
7
15
 
8
16
  export function createLectorPackageSourceOperations(): PackageSourceOperations {
@@ -17,5 +25,17 @@ export function createLectorPackageSourceOperations(): PackageSourceOperations {
17
25
  bounds: DEFAULT_PACKAGE_SOURCE_BOUNDS,
18
26
  });
19
27
  },
28
+ async list(options) {
29
+ const client = await lectorClient();
30
+ return client.call("package.listSources", options);
31
+ },
32
+ async remove(ecosystem, registry, name, resolvedVersion) {
33
+ const client = await lectorClient();
34
+ return client.call("package.removeSource", { ecosystem, registry, name, resolvedVersion });
35
+ },
36
+ async clean(ecosystem) {
37
+ const client = await lectorClient();
38
+ return client.call("package.cleanSources", { ecosystem });
39
+ },
20
40
  };
21
41
  }
@@ -1,14 +1,36 @@
1
- import type { PackageSourceOperationResult } from "@danypops/lector";
2
- import { renderTruncatedList } from "malevich-tui-components";
1
+ import type { PackageSourceListEntry, PackageSourceOperationResult } from "@danypops/lector";
2
+ import { keyHint } from "@earendil-works/pi-coding-agent";
3
+ import { renderTruncatedList, type TableColumn } from "malevich-tui-components";
3
4
  import type { LectorTheme } from "./lector-tui-theme.ts";
4
5
 
5
6
  const DEFAULT_VISIBLE_CANDIDATES = 5;
6
7
 
7
- export function formatPackageSourceCall(args: { directory?: unknown; name?: unknown; version?: unknown }, theme: LectorTheme): string {
8
+ /** Table has no row-count bound of its own; the underlying index can grow arbitrarily large even though package.listSources's own maxResults bounds any one page. Mirrors REPO_CACHE_VISIBLE_ROWS. */
9
+ export const PACKAGE_SOURCE_LIST_VISIBLE_ROWS = 20;
10
+
11
+ type PackageSourceAction = "resolve" | "list" | "remove" | "clean";
12
+
13
+ export function formatPackageSourceCall(
14
+ args: { action?: unknown; directory?: unknown; name?: unknown; version?: unknown; ecosystem?: unknown; resolvedVersion?: unknown; text?: unknown },
15
+ theme: LectorTheme,
16
+ ): string {
17
+ const label = theme.fg("toolTitle", theme.bold("package_source"));
18
+ const action: PackageSourceAction = args.action === "list" || args.action === "remove" || args.action === "clean" ? args.action : "resolve";
19
+ if (action === "list") {
20
+ const text = typeof args.text === "string" && args.text.length > 0 ? ` ${theme.fg("dim", args.text)}` : "";
21
+ return `${label} ${theme.fg("accent", "list")}${text}`;
22
+ }
23
+ if (action === "remove" || action === "clean") {
24
+ const name = typeof args.name === "string" ? args.name : "";
25
+ const version = typeof args.resolvedVersion === "string" ? `@${args.resolvedVersion}` : "";
26
+ const ecosystem = typeof args.ecosystem === "string" ? args.ecosystem : "";
27
+ const identity = name ? `${name}${version}` : ecosystem;
28
+ return `${label} ${theme.fg("accent", action)}${identity ? ` ${theme.fg("dim", identity)}` : ""}`;
29
+ }
8
30
  const name = typeof args.name === "string" ? args.name : "";
9
31
  const version = typeof args.version === "string" ? `@${args.version}` : "";
10
32
  const directory = typeof args.directory === "string" ? args.directory : "";
11
- return `${theme.fg("toolTitle", theme.bold("package_source"))} ${theme.fg("accent", `${name}${version}`)} ${theme.fg("dim", directory)}`.trim();
33
+ return `${label} ${theme.fg("accent", `${name}${version}`)} ${theme.fg("dim", directory)}`.trim();
12
34
  }
13
35
 
14
36
  export function formatPackageSourceResult(result: PackageSourceOperationResult | undefined, expanded: boolean, theme: LectorTheme): string {
@@ -42,3 +64,54 @@ export function formatPackageSourceResult(result: PackageSourceOperationResult |
42
64
  if (outcome.status === "mismatched") return theme.fg("error", `Source mismatch (${outcome.code}): expected ${outcome.expected}, got ${outcome.actual}.`);
43
65
  return theme.fg("warning", `Source unavailable (${outcome.code}).`);
44
66
  }
67
+
68
+ /** Empty-state fallback only -- a non-empty page renders as a real Table (see buildPackageSourceListTableRows) so the human channel actually shows what's resolved, not just a bare count. Mirrors formatRepoCacheListResult. */
69
+ export function formatPackageSourceListResult(
70
+ page: { entries: readonly PackageSourceListEntry[]; nextCursor?: string | null } | undefined,
71
+ theme: LectorTheme,
72
+ ): string {
73
+ const count = page?.entries.length ?? 0;
74
+ return count === 0 ? theme.fg("dim", "no resolved package sources") : theme.fg("success", `${count} resolved package source${count === 1 ? "" : "s"}`);
75
+ }
76
+
77
+ /** Powers of 1024, one decimal past the first. Mirrors repo-cache-rendering.ts's own formatCacheSize -- kept as a small local duplicate rather than a shared export, matching that file's own precedent. */
78
+ function formatCacheSize(bytes: number): string {
79
+ const units = ["B", "KB", "MB", "GB", "TB"] as const;
80
+ let value = bytes;
81
+ let unitIndex = 0;
82
+ while (value >= 1024 && unitIndex < units.length - 1) {
83
+ value /= 1024;
84
+ unitIndex++;
85
+ }
86
+ return `${unitIndex === 0 ? value : value.toFixed(1)} ${units[unitIndex]}`;
87
+ }
88
+
89
+ export function buildPackageSourceListTableRows(entries: readonly PackageSourceListEntry[]): Record<string, string>[] {
90
+ return entries.map((entry) => ({
91
+ package: `${entry.name}@${entry.resolvedVersion}`,
92
+ workspace: entry.workspaceId,
93
+ path: entry.cachePath,
94
+ size: entry.cacheSizeBytes === null ? "unknown" : formatCacheSize(entry.cacheSizeBytes),
95
+ }));
96
+ }
97
+
98
+ export const PACKAGE_SOURCE_LIST_TABLE_COLUMNS: TableColumn[] = [
99
+ { header: "Package", key: "package" },
100
+ { header: "Workspace", key: "workspace" },
101
+ { header: "Path", key: "path" },
102
+ { header: "Size", key: "size" },
103
+ ];
104
+
105
+ export function packageSourceListMoreLine(theme: LectorTheme): (hiddenCount: number) => string {
106
+ return (hiddenCount) => theme.fg("dim", `... ${hiddenCount} more (${keyHint("app.tools.expand", "to expand")})`);
107
+ }
108
+
109
+ export function formatPackageSourceRemoveResult(result: { removed: boolean } | undefined, theme: LectorTheme): string {
110
+ if (!result) return theme.fg("dim", "No result.");
111
+ return result.removed ? theme.fg("success", "removed") : theme.fg("dim", "not recorded for that coordinate");
112
+ }
113
+
114
+ export function formatPackageSourceCleanResult(result: { removed: number; skipped: number } | undefined, theme: LectorTheme): string {
115
+ if (!result) return theme.fg("dim", "No result.");
116
+ return theme.fg("success", `removed ${result.removed}, skipped ${result.skipped} (still in use)`);
117
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-lector",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "Pi host adapter for Lector: overrides read/write/edit with a daemon-backed, hash-guarded filesystem",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@danypops/vehicle-client": "^0.1.1",
22
- "@danypops/lector": "^0.10.0",
22
+ "@danypops/lector": "^0.12.0",
23
23
  "malevich-tui-components": "^0.19.0"
24
24
  },
25
25
  "devDependencies": {