@danypops/pi-lector 0.9.0 → 0.9.2

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
+ }
@@ -34,7 +34,7 @@ import {
34
34
  type ExtensionAPI,
35
35
  } from "@earendil-works/pi-coding-agent";
36
36
  import { Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
37
- import { Table, type TextMeasure } from "malevich-tui-components";
37
+ import { renderBoundedTable, type TextMeasure } from "malevich-tui-components";
38
38
  import { Type } from "typebox";
39
39
 
40
40
  /** Real ANSI-aware measurement for Table -- Malevich's own default is ASCII-only, unsafe against theme-styled cell/header text. */
@@ -99,6 +99,8 @@ import {
99
99
  formatRepoCacheListResult,
100
100
  formatRepoFetchResult,
101
101
  REPO_CACHE_TABLE_COLUMNS,
102
+ REPO_CACHE_VISIBLE_ROWS,
103
+ repoCacheMoreLine,
102
104
  } from "./repo-cache-rendering.ts";
103
105
  import { createLectorRepoFetchOperations } from "./repo-fetch-operations.ts";
104
106
  import { createLectorSearchOperations } from "./search-operations.ts";
@@ -1031,17 +1033,26 @@ export default function (pi: ExtensionAPI) {
1031
1033
  name: "git",
1032
1034
  label: "Git",
1033
1035
  description:
1034
- "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).",
1035
- promptSnippet: "Show a repository's status, log, or diff",
1036
+ "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).",
1037
+ promptSnippet: "Show a repository's status, log, diff, or one symbol's diff across versions",
1036
1038
  promptGuidelines: [
1037
- "maxCount is required for action=log; maxBytes is required for action=diff -- every bounded query needs its bound stated explicitly, never defaulted silently.",
1039
+ "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.",
1040
+ "path, symbol, and fromRef are required for action=compare-symbol; toRef is optional and means 'the current working tree' when omitted.",
1038
1041
  ],
1039
1042
  parameters: Type.Object({
1040
- action: Type.String({ description: "status | log | diff" }),
1043
+ action: Type.String({ description: "status | log | diff | compare-symbol" }),
1041
1044
  directory: Type.String({ description: "Directory inside the repository to check, absolute or relative to the current working directory" }),
1042
1045
  maxCount: Type.Optional(Type.Number({ description: "Maximum number of commits to return, most recent first -- required for action=log" })),
1043
1046
  ref: Type.Optional(Type.String({ description: "Ref to diff against; defaults to HEAD -- only used for action=diff" })),
1044
- maxBytes: Type.Optional(Type.Number({ description: "Maximum diff size in bytes before truncating -- required for action=diff" })),
1047
+ maxBytes: Type.Optional(
1048
+ Type.Number({ description: "Maximum diff/comparison size in bytes before truncating -- required for action=diff/compare-symbol" }),
1049
+ ),
1050
+ path: Type.Optional(Type.String({ description: "File path (relative to directory) containing the symbol -- required for action=compare-symbol" })),
1051
+ symbol: Type.Optional(Type.String({ description: "Exact symbol name to compare -- required for action=compare-symbol" })),
1052
+ fromRef: Type.Optional(Type.String({ description: "Git ref for the 'before' version -- required for action=compare-symbol" })),
1053
+ toRef: Type.Optional(
1054
+ Type.String({ description: "Git ref for the 'after' version; omit to compare against the current working tree -- action=compare-symbol only" }),
1055
+ ),
1045
1056
  }),
1046
1057
  async execute(_toolCallId, params) {
1047
1058
  const directory = resolve(cwd, params.directory);
@@ -1064,6 +1075,13 @@ export default function (pi: ExtensionAPI) {
1064
1075
  const details: GitToolDetails = { action: "diff", result };
1065
1076
  return { content: [{ type: "text", text: result.diff.length === 0 ? "No differences." : result.diff }], details };
1066
1077
  }
1078
+ if (params.action === "compare-symbol") {
1079
+ if (!params.path || !params.symbol || !params.fromRef) throw new Error("git action=compare-symbol requires path, symbol, and fromRef");
1080
+ if (params.maxBytes === undefined) throw new Error("git action=compare-symbol requires maxBytes");
1081
+ const comparison = await gitOperations.compareSymbol(directory, params.path, params.symbol, params.fromRef, params.toRef, params.maxBytes);
1082
+ const details: GitToolDetails = { action: "compare-symbol", comparison };
1083
+ return { content: [{ type: "text", text: JSON.stringify(comparison) }], details };
1084
+ }
1067
1085
  throw new Error(`unknown git action: ${String(params.action)}`);
1068
1086
  },
1069
1087
  renderCall(args, theme, context) {
@@ -1447,7 +1465,7 @@ export default function (pi: ExtensionAPI) {
1447
1465
  text.setText(formatRepoCacheCall(action, args, theme));
1448
1466
  return text;
1449
1467
  },
1450
- renderResult(result, { isPartial }, theme, context) {
1468
+ renderResult(result, { isPartial, expanded }, theme, context) {
1451
1469
  if (isPartial) return new Text(theme.fg("warning", "Working on repo cache..."), 0, 0);
1452
1470
  if (context.isError) {
1453
1471
  const errorText = result.content
@@ -1457,16 +1475,23 @@ export default function (pi: ExtensionAPI) {
1457
1475
  return new Text(theme.fg("error", errorText || "repo_cache failed"), 0, 0);
1458
1476
  }
1459
1477
  const details = result.details as RepoCacheToolDetails | undefined;
1460
- // A non-empty list renders as a real Table -- the human channel actually shows
1461
- // host/owner/repo/ref/registered/size/fetched, not just a bare count. Every other
1462
- // branch (empty list, fetch, evict) stays a plain Text line as before.
1478
+ // A non-empty list renders as a real, bounded Table -- the human channel actually shows
1479
+ // host/owner/repo/ref/registered/size/fetched, not just a bare count, capped at
1480
+ // REPO_CACHE_VISIBLE_ROWS since a cache can grow arbitrarily large even though maxResults
1481
+ // bounds any one page. Every other branch (empty list, fetch, evict) stays a plain Text line.
1463
1482
  if (details?.action === "list" && details.page.entries.length > 0) {
1464
- const table =
1465
- context.lastComponent instanceof Table
1466
- ? context.lastComponent
1467
- : new Table({ columns: REPO_CACHE_TABLE_COLUMNS, rows: [], measure: tableMeasure, headerStyle: (s) => theme.fg("muted", theme.bold(s)) });
1468
- table.setRows(buildRepoCacheTableRows(details.page.entries));
1469
- return table;
1483
+ // Rebuilt fresh on every call (not reused via context.lastComponent) since expanded is
1484
+ // fixed at BoundedTable construction, matching every other truncated-list renderer in this
1485
+ // codebase (they all call renderTruncatedList fresh with the current expanded each time).
1486
+ return renderBoundedTable({
1487
+ columns: REPO_CACHE_TABLE_COLUMNS,
1488
+ rows: buildRepoCacheTableRows(details.page.entries),
1489
+ expanded,
1490
+ visibleRowCount: REPO_CACHE_VISIBLE_ROWS,
1491
+ moreLine: repoCacheMoreLine(theme),
1492
+ measure: tableMeasure,
1493
+ headerStyle: (s) => theme.fg("muted", theme.bold(s)),
1494
+ });
1470
1495
  }
1471
1496
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1472
1497
  if (details?.action === "list") text.setText(formatRepoCacheListResult(details.page, theme));
@@ -1,7 +1,15 @@
1
1
  import type { CachedRepositoryEntry, CachedRepositoryPage, RepoFetchResult } from "@danypops/lector";
2
+ import { keyHint } from "@earendil-works/pi-coding-agent";
2
3
  import type { TableColumn } from "malevich-tui-components";
3
4
  import type { LectorTheme } from "./lector-tui-theme.ts";
4
5
 
6
+ /** Table has no row-count bound of its own; a cache can grow arbitrarily large even though repo_cache's own `maxResults` bounds any one page, so the display itself still needs a cap independent of that. */
7
+ export const REPO_CACHE_VISIBLE_ROWS = 20;
8
+
9
+ export function repoCacheMoreLine(theme: LectorTheme): (hiddenCount: number) => string {
10
+ return (hiddenCount) => theme.fg("dim", `... ${hiddenCount} more (${keyHint("app.tools.expand", "to expand")})`);
11
+ }
12
+
5
13
  type RepoCacheAction = "fetch" | "list" | "evict";
6
14
 
7
15
  export function formatRepoCacheCall(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-lector",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
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,8 +19,8 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@danypops/vehicle-client": "^0.1.1",
22
- "@danypops/lector": "^0.10.0",
23
- "malevich-tui-components": "^0.17.0"
22
+ "@danypops/lector": "^0.11.0",
23
+ "malevich-tui-components": "^0.19.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@earendil-works/pi-ai": "^0.81.1",