@danypops/pi-lector 0.8.1 → 0.9.1

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,9 +1,46 @@
1
1
  import type { EditOutcome } from "@danypops/lector";
2
+ import { renderDiffLines, renderTruncatedList, type TextMeasure } from "malevich-tui-components";
2
3
  import type { LectorTheme } from "./lector-tui-theme.ts";
3
4
 
4
- export function formatApplyPatchCall(args: { path?: unknown }, theme: LectorTheme): string {
5
+ /**
6
+ * The applied result (EditOutcome) only carries a hash transition, not the
7
+ * diff text itself -- the model already has what it submitted, so there's
8
+ * nothing to render as a Diff on the result side. The call's own patchText
9
+ * argument is the real diff content, so the colored preview lives here
10
+ * instead of formatApplyPatchResult, unlike git_diff's coloring (which
11
+ * lives on the result side because that's where GitDiffResult's diff text
12
+ * actually is).
13
+ */
14
+ const DEFAULT_VISIBLE_PATCH_LINES = 12;
15
+
16
+ export function formatApplyPatchCall(args: { path?: unknown; patchText?: unknown }, theme: LectorTheme, measure?: TextMeasure): string {
5
17
  const path = typeof args.path === "string" ? args.path : "";
6
- return `${theme.fg("toolTitle", theme.bold("apply_patch"))} ${theme.fg("accent", path)}`;
18
+ const header = `${theme.fg("toolTitle", theme.bold("apply_patch"))} ${theme.fg("accent", path)}`;
19
+ if (typeof args.patchText !== "string" || args.patchText.length === 0) return header;
20
+
21
+ const styledLines = renderDiffLines(
22
+ Number.MAX_SAFE_INTEGER,
23
+ args.patchText,
24
+ {
25
+ add: (s) => theme.fg("success", s),
26
+ remove: (s) => theme.fg("error", s),
27
+ context: (s) => theme.fg("dim", s),
28
+ hunk: (s) => theme.fg("accent", s),
29
+ header: (s) => theme.fg("muted", s),
30
+ },
31
+ measure,
32
+ );
33
+ // No expand affordance here -- renderCall has no `expanded` option (that's
34
+ // renderResult-only), so a call-time preview is always just a hard cap,
35
+ // never an "expand to see more" invitation the call slot can't honor.
36
+ const preview = renderTruncatedList({
37
+ items: styledLines,
38
+ expanded: false,
39
+ visibleCount: DEFAULT_VISIBLE_PATCH_LINES,
40
+ formatItem: (line) => line,
41
+ moreLine: (hidden) => theme.fg("dim", `... ${hidden} more line${hidden === 1 ? "" : "s"}`),
42
+ });
43
+ return [header, ...preview].join("\n");
7
44
  }
8
45
 
9
46
  export function formatApplyPatchResult(result: EditOutcome | undefined, theme: LectorTheme): string {
@@ -11,6 +11,7 @@ import type {
11
11
  WorkspaceMapResult,
12
12
  } from "@danypops/lector";
13
13
  import { keyHint, type ThemeColor } from "@earendil-works/pi-coding-agent";
14
+ import { renderTruncatedList } from "malevich-tui-components";
14
15
  import { colorForKind, formatLocation, type LectorTheme } from "./lector-tui-theme.ts";
15
16
 
16
17
  /**
@@ -41,16 +42,23 @@ function formatPositionalCall(toolName: string, args: { path?: unknown; line?: u
41
42
  return `${theme.fg("toolTitle", theme.bold(toolName))} ${theme.fg("accent", `${path}:${line}:${character}`)}`;
42
43
  }
43
44
 
45
+ function moreLine(theme: LectorTheme): (hidden: number) => string {
46
+ return (hidden) => theme.fg("dim", `... ${hidden} more (${keyHint("app.tools.expand", "to expand")})`);
47
+ }
48
+
44
49
  function formatLocationList(locations: readonly WorkspaceLocation[] | undefined, emptyMessage: string, expanded: boolean, theme: LectorTheme): string {
45
50
  if (!locations || locations.length === 0) return theme.fg("dim", emptyMessage);
46
51
 
47
- const displayCount = expanded ? locations.length : Math.min(locations.length, DEFAULT_VISIBLE_LOCATIONS);
48
- const lines = [theme.fg("muted", `${locations.length} location${locations.length === 1 ? "" : "s"}:`)];
49
- for (const location of locations.slice(0, displayCount)) {
50
- lines.push(` ${formatLocation(theme, location.path, location.line, location.character)}`);
51
- }
52
- const remaining = locations.length - displayCount;
53
- if (remaining > 0) lines.push(theme.fg("dim", `... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
52
+ const lines = [
53
+ theme.fg("muted", `${locations.length} location${locations.length === 1 ? "" : "s"}:`),
54
+ ...renderTruncatedList({
55
+ items: locations,
56
+ expanded,
57
+ visibleCount: DEFAULT_VISIBLE_LOCATIONS,
58
+ formatItem: (location) => ` ${formatLocation(theme, location.path, location.line, location.character)}`,
59
+ moreLine: moreLine(theme),
60
+ }),
61
+ ];
54
62
  return lines.join("\n");
55
63
  }
56
64
 
@@ -88,8 +96,14 @@ export function formatHoverResult(hover: Hover | undefined, expanded: boolean, t
88
96
  if (!hover) return theme.fg("dim", "No hover information available.");
89
97
  const lines = hover.contents.split("\n");
90
98
  if (expanded || lines.length <= HOVER_COLLAPSED_LINE_COUNT) return hover.contents;
91
- const remaining = lines.length - HOVER_COLLAPSED_LINE_COUNT;
92
- return `${lines.slice(0, HOVER_COLLAPSED_LINE_COUNT).join("\n")}\n${theme.fg("dim", `... ${remaining} more line${remaining === 1 ? "" : "s"} (${keyHint("app.tools.expand", "to expand")})`)}`;
99
+ const body = renderTruncatedList({
100
+ items: lines,
101
+ expanded: false,
102
+ visibleCount: HOVER_COLLAPSED_LINE_COUNT,
103
+ formatItem: (line) => line,
104
+ moreLine: (hidden) => theme.fg("dim", `... ${hidden} more line${hidden === 1 ? "" : "s"} (${keyHint("app.tools.expand", "to expand")})`),
105
+ });
106
+ return body.join("\n");
93
107
  }
94
108
 
95
109
  export function formatDocumentSymbolsCall(args: { path?: unknown }, theme: LectorTheme): string {
@@ -112,19 +126,22 @@ export function formatDocumentSymbolsResult(symbols: readonly DocumentSymbolEntr
112
126
 
113
127
  const flattened = flattenSymbols(symbols);
114
128
  const kindColumnWidth = Math.max(...flattened.map(({ entry }) => entry.kind.length));
115
- const displayCount = expanded ? flattened.length : Math.min(flattened.length, DEFAULT_VISIBLE_SYMBOLS);
116
- const lines = [theme.fg("muted", `${flattened.length} symbol${flattened.length === 1 ? "" : "s"}:`)];
117
-
118
- for (const { depth, entry } of flattened.slice(0, displayCount)) {
119
- const indent = " ".repeat(depth + 1);
120
- const kind = theme.fg(colorForKind(entry.kind), entry.kind.padEnd(kindColumnWidth));
121
- const name = theme.fg("text", theme.bold(entry.name));
122
- const location = formatLocation(theme, entry.range.path, entry.selectionRange.start.line, entry.selectionRange.start.character);
123
- lines.push(`${indent}${kind} ${name} ${location}`);
124
- }
125
-
126
- const remaining = flattened.length - displayCount;
127
- if (remaining > 0) lines.push(theme.fg("dim", `... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
129
+ const lines = [
130
+ theme.fg("muted", `${flattened.length} symbol${flattened.length === 1 ? "" : "s"}:`),
131
+ ...renderTruncatedList({
132
+ items: flattened,
133
+ expanded,
134
+ visibleCount: DEFAULT_VISIBLE_SYMBOLS,
135
+ formatItem: ({ depth, entry }) => {
136
+ const indent = " ".repeat(depth + 1);
137
+ const kind = theme.fg(colorForKind(entry.kind), entry.kind.padEnd(kindColumnWidth));
138
+ const name = theme.fg("text", theme.bold(entry.name));
139
+ const location = formatLocation(theme, entry.range.path, entry.selectionRange.start.line, entry.selectionRange.start.character);
140
+ return `${indent}${kind} ${name} ${location}`;
141
+ },
142
+ moreLine: moreLine(theme),
143
+ }),
144
+ ];
128
145
  return lines.join("\n");
129
146
  }
130
147
 
@@ -136,18 +153,21 @@ export function formatDiagnosticsCall(args: { path?: unknown }, theme: LectorThe
136
153
  export function formatDiagnosticsResult(diagnostics: readonly Diagnostic[] | undefined, expanded: boolean, theme: LectorTheme): string {
137
154
  if (!diagnostics || diagnostics.length === 0) return theme.fg("success", "No diagnostics.");
138
155
 
139
- const displayCount = expanded ? diagnostics.length : Math.min(diagnostics.length, DEFAULT_VISIBLE_DIAGNOSTICS);
140
- const lines = [theme.fg("muted", `${diagnostics.length} diagnostic${diagnostics.length === 1 ? "" : "s"}:`)];
141
-
142
- for (const diagnostic of diagnostics.slice(0, displayCount)) {
143
- const severity = theme.fg(DIAGNOSTIC_SEVERITY_COLOR[diagnostic.severity], theme.bold(diagnostic.severity));
144
- const location = formatLocation(theme, diagnostic.range.path, diagnostic.range.start.line, diagnostic.range.start.character);
145
- const origin = diagnostic.source ? theme.fg("dim", ` (${diagnostic.source}${diagnostic.code !== undefined ? ` ${diagnostic.code}` : ""})`) : "";
146
- lines.push(` ${severity} ${location} -- ${diagnostic.message}${origin}`);
147
- }
148
-
149
- const remaining = diagnostics.length - displayCount;
150
- if (remaining > 0) lines.push(theme.fg("dim", `... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
156
+ const lines = [
157
+ theme.fg("muted", `${diagnostics.length} diagnostic${diagnostics.length === 1 ? "" : "s"}:`),
158
+ ...renderTruncatedList({
159
+ items: diagnostics,
160
+ expanded,
161
+ visibleCount: DEFAULT_VISIBLE_DIAGNOSTICS,
162
+ formatItem: (diagnostic) => {
163
+ const severity = theme.fg(DIAGNOSTIC_SEVERITY_COLOR[diagnostic.severity], theme.bold(diagnostic.severity));
164
+ const location = formatLocation(theme, diagnostic.range.path, diagnostic.range.start.line, diagnostic.range.start.character);
165
+ const origin = diagnostic.source ? theme.fg("dim", ` (${diagnostic.source}${diagnostic.code !== undefined ? ` ${diagnostic.code}` : ""})`) : "";
166
+ return ` ${severity} ${location} -- ${diagnostic.message}${origin}`;
167
+ },
168
+ moreLine: moreLine(theme),
169
+ }),
170
+ ];
151
171
  return lines.join("\n");
152
172
  }
153
173
 
@@ -183,24 +203,32 @@ function formatPrepareCallHierarchyResult(items: readonly CallHierarchyEntry[] |
183
203
  function formatIncomingCallsResult(calls: readonly IncomingCall[] | undefined, expanded: boolean, theme: LectorTheme): string {
184
204
  if (!calls || calls.length === 0) return theme.fg("dim", "No incoming calls found.");
185
205
 
186
- const displayCount = expanded ? calls.length : Math.min(calls.length, DEFAULT_VISIBLE_CALLS);
187
- const lines = [theme.fg("muted", `${calls.length} caller${calls.length === 1 ? "" : "s"}:`)];
188
- for (const call of calls.slice(0, displayCount)) lines.push(` ${formatCallHierarchyEntry(call.from, theme)}`);
189
-
190
- const remaining = calls.length - displayCount;
191
- if (remaining > 0) lines.push(theme.fg("dim", `... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
206
+ const lines = [
207
+ theme.fg("muted", `${calls.length} caller${calls.length === 1 ? "" : "s"}:`),
208
+ ...renderTruncatedList({
209
+ items: calls,
210
+ expanded,
211
+ visibleCount: DEFAULT_VISIBLE_CALLS,
212
+ formatItem: (call) => ` ${formatCallHierarchyEntry(call.from, theme)}`,
213
+ moreLine: moreLine(theme),
214
+ }),
215
+ ];
192
216
  return lines.join("\n");
193
217
  }
194
218
 
195
219
  function formatOutgoingCallsResult(calls: readonly OutgoingCall[] | undefined, expanded: boolean, theme: LectorTheme): string {
196
220
  if (!calls || calls.length === 0) return theme.fg("dim", "No outgoing calls found.");
197
221
 
198
- const displayCount = expanded ? calls.length : Math.min(calls.length, DEFAULT_VISIBLE_CALLS);
199
- const lines = [theme.fg("muted", `${calls.length} callee${calls.length === 1 ? "" : "s"}:`)];
200
- for (const call of calls.slice(0, displayCount)) lines.push(` ${formatCallHierarchyEntry(call.to, theme)}`);
201
-
202
- const remaining = calls.length - displayCount;
203
- if (remaining > 0) lines.push(theme.fg("dim", `... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
222
+ const lines = [
223
+ theme.fg("muted", `${calls.length} callee${calls.length === 1 ? "" : "s"}:`),
224
+ ...renderTruncatedList({
225
+ items: calls,
226
+ expanded,
227
+ visibleCount: DEFAULT_VISIBLE_CALLS,
228
+ formatItem: (call) => ` ${formatCallHierarchyEntry(call.to, theme)}`,
229
+ moreLine: moreLine(theme),
230
+ }),
231
+ ];
204
232
  return lines.join("\n");
205
233
  }
206
234
 
@@ -221,12 +249,16 @@ export function formatReachableFromResult(symbols: readonly SymbolNode[] | undef
221
249
  if (!symbols || symbols.length === 0)
222
250
  return theme.fg("dim", "Nothing reachable at this position (the workspace's symbol graph may still be populating in the background -- retry shortly).");
223
251
 
224
- const displayCount = expanded ? symbols.length : Math.min(symbols.length, DEFAULT_VISIBLE_CALLS);
225
- const lines = [theme.fg("muted", `${symbols.length} reachable symbol${symbols.length === 1 ? "" : "s"}:`)];
226
- for (const symbol of symbols.slice(0, displayCount)) lines.push(` ${formatCallHierarchyEntry(symbol, theme)}`);
227
-
228
- const remaining = symbols.length - displayCount;
229
- if (remaining > 0) lines.push(theme.fg("dim", `... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
252
+ const lines = [
253
+ theme.fg("muted", `${symbols.length} reachable symbol${symbols.length === 1 ? "" : "s"}:`),
254
+ ...renderTruncatedList({
255
+ items: symbols,
256
+ expanded,
257
+ visibleCount: DEFAULT_VISIBLE_CALLS,
258
+ formatItem: (symbol) => ` ${formatCallHierarchyEntry(symbol, theme)}`,
259
+ moreLine: moreLine(theme),
260
+ }),
261
+ ];
230
262
  return lines.join("\n");
231
263
  }
232
264
 
@@ -240,21 +272,22 @@ export function formatWorkspaceMapResult(result: WorkspaceMapResult | undefined,
240
272
  if (!result || result.entries.length === 0)
241
273
  return theme.fg("dim", "No ranked symbols (the workspace's symbol graph may still be populating in the background -- retry shortly).");
242
274
 
243
- const displayCount = expanded ? result.entries.length : Math.min(result.entries.length, DEFAULT_VISIBLE_SYMBOLS);
244
275
  const lines = [
245
276
  theme.fg(
246
277
  "muted",
247
278
  `${result.entries.length} of ${result.totalRanked} ranked symbol${result.totalRanked === 1 ? "" : "s"}, most structurally central first:`,
248
279
  ),
280
+ ...renderTruncatedList({
281
+ items: result.entries,
282
+ expanded,
283
+ visibleCount: DEFAULT_VISIBLE_SYMBOLS,
284
+ formatItem: (entry) => {
285
+ const signature = entry.signature ? ` -- ${entry.signature}` : "";
286
+ return ` ${theme.fg(colorForKind(entry.kind), entry.kind)} ${theme.bold(entry.name)} ${formatLocation(theme, entry.path, entry.line, entry.character)}${signature}`;
287
+ },
288
+ moreLine: moreLine(theme),
289
+ truncationWarning: result.truncated ? theme.fg("warning", "budget-truncated -- raise --max-entries/--max-bytes for more") : undefined,
290
+ }),
249
291
  ];
250
- for (const entry of result.entries.slice(0, displayCount)) {
251
- const signature = entry.signature ? ` -- ${entry.signature}` : "";
252
- lines.push(
253
- ` ${theme.fg(colorForKind(entry.kind), entry.kind)} ${theme.bold(entry.name)} ${formatLocation(theme, entry.path, entry.line, entry.character)}${signature}`,
254
- );
255
- }
256
- const remaining = displayCount - result.entries.length;
257
- if (remaining < 0) lines.push(theme.fg("dim", `... ${-remaining} more (${keyHint("app.tools.expand", "to expand")})`));
258
- if (result.truncated) lines.push(theme.fg("warning", "budget-truncated -- raise --max-entries/--max-bytes for more"));
259
292
  return lines.join("\n");
260
293
  }
@@ -1,5 +1,6 @@
1
1
  import type { SymbolSearchResult, TextSearchResult, WorkspaceQueryOutcome } from "@danypops/lector";
2
2
  import { keyHint } from "@earendil-works/pi-coding-agent";
3
+ import { renderTruncatedList } from "malevich-tui-components";
3
4
  import { describeFindSymbolSources } from "./find-symbols-rendering.ts";
4
5
  import type { LectorTheme } from "./lector-tui-theme.ts";
5
6
 
@@ -35,12 +36,15 @@ export function formatFindSymbolsAcrossProjectsResult(
35
36
  lines.push(theme.fg("dim", " no symbols matched"));
36
37
  continue;
37
38
  }
38
- const displayCount = expanded ? outcome.result.symbols.length : Math.min(DEFAULT_VISIBLE_PER_WORKSPACE, outcome.result.symbols.length);
39
- for (const symbol of outcome.result.symbols.slice(0, displayCount)) {
40
- lines.push(` ${symbol.kind} ${symbol.name} -- ${symbol.location.path}:${symbol.location.line}:${symbol.location.character}`);
41
- }
42
- const remaining = outcome.result.symbols.length - displayCount;
43
- if (remaining > 0) lines.push(theme.fg("dim", ` ... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
39
+ lines.push(
40
+ ...renderTruncatedList({
41
+ items: outcome.result.symbols,
42
+ expanded,
43
+ visibleCount: DEFAULT_VISIBLE_PER_WORKSPACE,
44
+ formatItem: (symbol) => ` ${symbol.kind} ${symbol.name} -- ${symbol.location.path}:${symbol.location.line}:${symbol.location.character}`,
45
+ moreLine: (hidden) => theme.fg("dim", ` ... ${hidden} more (${keyHint("app.tools.expand", "to expand")})`),
46
+ }),
47
+ );
44
48
  }
45
49
  return lines.join("\n");
46
50
  }
@@ -59,13 +63,18 @@ export function formatSearchTextAcrossProjectsResult(
59
63
  lines.push(theme.fg("dim", " no matches"));
60
64
  continue;
61
65
  }
62
- const displayCount = expanded ? outcome.result.matches.length : Math.min(DEFAULT_VISIBLE_PER_WORKSPACE, outcome.result.matches.length);
63
- for (const match of outcome.result.matches.slice(0, displayCount)) {
64
- lines.push(` ${match.path}:${match.lineNumber}: ${match.line.replace(/\n$/, "")}`);
65
- }
66
- const remaining = outcome.result.matches.length - displayCount;
67
- if (remaining > 0) lines.push(theme.fg("dim", ` ... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
68
- if (outcome.result.truncated) lines.push(theme.fg("warning", " (this workspace's search was itself truncated by maxMatches/maxBytes)"));
66
+ lines.push(
67
+ ...renderTruncatedList({
68
+ items: outcome.result.matches,
69
+ expanded,
70
+ visibleCount: DEFAULT_VISIBLE_PER_WORKSPACE,
71
+ formatItem: (match) => ` ${match.path}:${match.lineNumber}: ${match.line.replace(/\n$/, "")}`,
72
+ moreLine: (hidden) => theme.fg("dim", ` ... ${hidden} more (${keyHint("app.tools.expand", "to expand")})`),
73
+ truncationWarning: outcome.result.truncated
74
+ ? theme.fg("warning", " (this workspace's search was itself truncated by maxMatches/maxBytes)")
75
+ : undefined,
76
+ }),
77
+ );
69
78
  }
70
79
  return lines.join("\n");
71
80
  }
@@ -1,5 +1,6 @@
1
1
  import type { FindFilesResult } from "@danypops/lector";
2
2
  import { keyHint } from "@earendil-works/pi-coding-agent";
3
+ import { renderTruncatedList } from "malevich-tui-components";
3
4
  import type { LectorTheme } from "./lector-tui-theme.ts";
4
5
 
5
6
  const DEFAULT_VISIBLE_PATHS = 40;
@@ -12,10 +13,13 @@ export function formatFindFilesCall(args: { directory?: unknown; patterns?: unkn
12
13
 
13
14
  export function formatFindFilesResult(result: FindFilesResult | undefined, expanded: boolean, theme: LectorTheme): string {
14
15
  if (!result || result.paths.length === 0) return theme.fg("dim", "No files found.");
15
- const displayCount = expanded ? result.paths.length : Math.min(DEFAULT_VISIBLE_PATHS, result.paths.length);
16
- const lines = result.paths.slice(0, displayCount).map((path) => theme.fg("accent", path));
17
- const remaining = result.paths.length - displayCount;
18
- if (remaining > 0) lines.push(theme.fg("dim", `... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
19
- if (result.truncated) lines.push(theme.fg("warning", "(listing itself was truncated by maxResults/maxBytes -- results are incomplete)"));
16
+ const lines = renderTruncatedList({
17
+ items: result.paths,
18
+ expanded,
19
+ visibleCount: DEFAULT_VISIBLE_PATHS,
20
+ formatItem: (path) => theme.fg("accent", path),
21
+ moreLine: (hidden) => theme.fg("dim", `... ${hidden} more (${keyHint("app.tools.expand", "to expand")})`),
22
+ truncationWarning: result.truncated ? theme.fg("warning", "(listing itself was truncated by maxResults/maxBytes -- results are incomplete)") : undefined,
23
+ });
20
24
  return lines.join("\n");
21
25
  }
@@ -1,5 +1,6 @@
1
1
  import type { SymbolSearchResult, WorkspaceSymbol } from "@danypops/lector";
2
2
  import { keyHint } from "@earendil-works/pi-coding-agent";
3
+ import { renderTruncatedList } from "malevich-tui-components";
3
4
  import { colorForKind, formatLocation, type LectorTheme } from "./lector-tui-theme.ts";
4
5
 
5
6
  /**
@@ -52,21 +53,18 @@ export function formatFindSymbolsResult(result: SymbolSearchResult | undefined,
52
53
  }
53
54
 
54
55
  const kindColumnWidth = Math.max(...symbols.map((symbol) => symbol.kind.length));
55
- const displayCount = expanded ? symbols.length : Math.min(symbols.length, DEFAULT_VISIBLE_RESULTS);
56
56
  const lines = [
57
57
  theme.fg("muted", source),
58
58
  ...sourceLines,
59
59
  theme.fg("muted", `${symbols.length} symbol${symbols.length === 1 ? "" : "s"} matching "${query}":`),
60
+ ...renderTruncatedList({
61
+ items: symbols,
62
+ expanded,
63
+ visibleCount: DEFAULT_VISIBLE_RESULTS,
64
+ formatItem: (symbol) => formatSymbolLine(symbol, theme, kindColumnWidth),
65
+ moreLine: (hidden) => theme.fg("dim", `... ${hidden} more (${keyHint("app.tools.expand", "to expand")})`),
66
+ }),
60
67
  ];
61
68
 
62
- for (const symbol of symbols.slice(0, displayCount)) {
63
- lines.push(formatSymbolLine(symbol, theme, kindColumnWidth));
64
- }
65
-
66
- const remaining = symbols.length - displayCount;
67
- if (remaining > 0) {
68
- lines.push(theme.fg("dim", `... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
69
- }
70
-
71
69
  return lines.join("\n");
72
70
  }
@@ -1,7 +1,12 @@
1
1
  import type { GitDiffResult, GitLogEntry, GitStatusSummary } from "@danypops/lector";
2
2
  import { keyHint } from "@earendil-works/pi-coding-agent";
3
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
+ import { renderDiffLines, renderTruncatedList, type TextMeasure } from "malevich-tui-components";
3
5
  import type { LectorTheme } from "./lector-tui-theme.ts";
4
6
 
7
+ /** Real ANSI-aware measurement, not Malevich's own ASCII-only default -- every diff line renderDiffLines receives is already theme.fg-styled. */
8
+ const measure: TextMeasure = { visibleWidth, truncateToWidth };
9
+
5
10
  const DEFAULT_VISIBLE_FILES = 20;
6
11
  const DEFAULT_VISIBLE_COMMITS = 10;
7
12
  const DEFAULT_VISIBLE_DIFF_LINES = 60;
@@ -29,6 +34,10 @@ export function formatGitResult(details: GitToolDetails | undefined, expanded: b
29
34
  return formatGitDiffResult(details.result, expanded, theme);
30
35
  }
31
36
 
37
+ function moreLine(theme: LectorTheme): (hidden: number) => string {
38
+ return (hidden) => theme.fg("dim", `... ${hidden} more (${keyHint("app.tools.expand", "to expand")})`);
39
+ }
40
+
32
41
  function formatGitStatusResult(summary: GitStatusSummary | undefined, expanded: boolean, theme: LectorTheme): string {
33
42
  if (!summary) return theme.fg("dim", "No status available.");
34
43
  const branch = summary.current ?? "(detached)";
@@ -38,34 +47,70 @@ function formatGitStatusResult(summary: GitStatusSummary | undefined, expanded:
38
47
  lines.push(theme.fg("dim", "working tree clean"));
39
48
  return lines.join("\n");
40
49
  }
41
- const displayCount = expanded ? summary.files.length : Math.min(DEFAULT_VISIBLE_FILES, summary.files.length);
42
- for (const file of summary.files.slice(0, displayCount)) {
43
- const code = `${file.indexStatus}${file.workingDirStatus}`;
44
- lines.push(file.renamedFrom ? `${code} ${file.renamedFrom} -> ${file.path}` : `${code} ${file.path}`);
45
- }
46
- const remaining = summary.files.length - displayCount;
47
- if (remaining > 0) lines.push(theme.fg("dim", `... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
50
+ lines.push(
51
+ ...renderTruncatedList({
52
+ items: summary.files,
53
+ expanded,
54
+ visibleCount: DEFAULT_VISIBLE_FILES,
55
+ formatItem: (file) => {
56
+ const code = `${file.indexStatus}${file.workingDirStatus}`;
57
+ return file.renamedFrom ? `${code} ${file.renamedFrom} -> ${file.path}` : `${code} ${file.path}`;
58
+ },
59
+ moreLine: moreLine(theme),
60
+ }),
61
+ );
48
62
  return lines.join("\n");
49
63
  }
50
64
 
51
65
  function formatGitLogResult(entries: readonly GitLogEntry[] | undefined, expanded: boolean, theme: LectorTheme): string {
52
66
  if (!entries || entries.length === 0) return theme.fg("dim", "No commits found.");
53
- const displayCount = expanded ? entries.length : Math.min(DEFAULT_VISIBLE_COMMITS, entries.length);
54
- const lines = entries
55
- .slice(0, displayCount)
56
- .map((entry) => `${theme.fg("accent", entry.sha.slice(0, 8))} ${entry.authoredAt} ${entry.authorName} -- ${entry.message}`);
57
- const remaining = entries.length - displayCount;
58
- if (remaining > 0) lines.push(theme.fg("dim", `... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
67
+ const lines = renderTruncatedList({
68
+ items: entries,
69
+ expanded,
70
+ visibleCount: DEFAULT_VISIBLE_COMMITS,
71
+ formatItem: (entry) => `${theme.fg("accent", entry.sha.slice(0, 8))} ${entry.authoredAt} ${entry.authorName} -- ${entry.message}`,
72
+ moreLine: moreLine(theme),
73
+ });
59
74
  return lines.join("\n");
60
75
  }
61
76
 
77
+ /**
78
+ * formatGitDiffResult returns a plain string (like every other renderer in
79
+ * this file), fed into a Text component by index.ts -- Text word-wraps to
80
+ * whatever width the host renders at, same as before this change. A real
81
+ * per-line width-aware ellipsis-truncation (the way Diff is meant to be
82
+ * used against a real terminal width, matching how Table already truncates
83
+ * an oversized cell) isn't available at this string-building stage, since
84
+ * renderResult's context carries no terminal width. Number.MAX_SAFE_INTEGER
85
+ * here means renderDiffLines' own truncation never fires -- a
86
+ * pathologically long single line still gets word-wrapped by Text rather
87
+ * than ellipsis-truncated, exactly as it did before this migration. This
88
+ * migration's actual scope is real +/- coloring; rendering git_diff as a
89
+ * genuine width-aware Component (fixing the wrap-vs-truncate tradeoff too)
90
+ * is the still-open "render file and Git diffs as bounded native Pi
91
+ * visuals" follow-up.
92
+ */
62
93
  function formatGitDiffResult(result: GitDiffResult | undefined, expanded: boolean, theme: LectorTheme): string {
63
94
  if (!result || result.diff.length === 0) return theme.fg("dim", "No differences.");
64
- const lines = result.diff.split("\n");
65
- const displayCount = expanded ? lines.length : Math.min(DEFAULT_VISIBLE_DIFF_LINES, lines.length);
66
- const shown = lines.slice(0, displayCount).join("\n");
67
- const remaining = lines.length - displayCount;
68
- const truncationNote = remaining > 0 ? `\n${theme.fg("dim", `... ${remaining} more lines (${keyHint("app.tools.expand", "to expand")})`)}` : "";
69
- const boundedNote = result.truncated ? `\n${theme.fg("warning", "(diff output itself was truncated by maxBytes)")}` : "";
70
- return shown + truncationNote + boundedNote;
95
+ const styledLines = renderDiffLines(
96
+ Number.MAX_SAFE_INTEGER,
97
+ result.diff,
98
+ {
99
+ add: (s) => theme.fg("success", s),
100
+ remove: (s) => theme.fg("error", s),
101
+ context: (s) => theme.fg("dim", s),
102
+ hunk: (s) => theme.fg("accent", s),
103
+ header: (s) => theme.fg("muted", s),
104
+ },
105
+ measure,
106
+ );
107
+ const lines = renderTruncatedList({
108
+ items: styledLines,
109
+ expanded,
110
+ visibleCount: DEFAULT_VISIBLE_DIFF_LINES,
111
+ formatItem: (line) => line,
112
+ 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,
114
+ });
115
+ return lines.join("\n");
71
116
  }
@@ -33,8 +33,13 @@ import {
33
33
  createWriteToolDefinition,
34
34
  type ExtensionAPI,
35
35
  } from "@earendil-works/pi-coding-agent";
36
- import { Text } from "@earendil-works/pi-tui";
36
+ import { Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
37
+ import { renderBoundedTable, type TextMeasure } from "malevich-tui-components";
37
38
  import { Type } from "typebox";
39
+
40
+ /** Real ANSI-aware measurement for Table -- Malevich's own default is ASCII-only, unsafe against theme-styled cell/header text. */
41
+ const tableMeasure: TextMeasure = { visibleWidth, truncateToWidth };
42
+
38
43
  import { createLectorApplyPatchOperations } from "./apply-patch-operations.ts";
39
44
  import { formatApplyPatchCall, formatApplyPatchResult } from "./apply-patch-rendering.ts";
40
45
  import { createLectorCodeIntelligenceOperations } from "./code-intelligence-operations.ts";
@@ -87,7 +92,16 @@ import { createReferenceBasedRenameOperations } from "./reference-based-rename-o
87
92
  import { createRenameOperations } from "./rename-operations.ts";
88
93
  import { createRepoCacheEvictOperations } from "./repo-cache-evict-operations.ts";
89
94
  import { createRepoCacheListOperations } from "./repo-cache-list-operations.ts";
90
- import { formatRepoCacheCall, formatRepoCacheEvictResult, formatRepoCacheListResult, formatRepoFetchResult } from "./repo-cache-rendering.ts";
95
+ import {
96
+ buildRepoCacheTableRows,
97
+ formatRepoCacheCall,
98
+ formatRepoCacheEvictResult,
99
+ formatRepoCacheListResult,
100
+ formatRepoFetchResult,
101
+ REPO_CACHE_TABLE_COLUMNS,
102
+ REPO_CACHE_VISIBLE_ROWS,
103
+ repoCacheMoreLine,
104
+ } from "./repo-cache-rendering.ts";
91
105
  import { createLectorRepoFetchOperations } from "./repo-fetch-operations.ts";
92
106
  import { createLectorSearchOperations } from "./search-operations.ts";
93
107
  import { formatSearchCall, formatSearchResult } from "./search-rendering.ts";
@@ -1261,7 +1275,7 @@ export default function (pi: ExtensionAPI) {
1261
1275
  },
1262
1276
  renderCall(args, theme, context) {
1263
1277
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1264
- text.setText(formatApplyPatchCall(args, theme));
1278
+ text.setText(formatApplyPatchCall(args, theme, tableMeasure));
1265
1279
  return text;
1266
1280
  },
1267
1281
  renderResult(result, { isPartial }, theme, context) {
@@ -1435,7 +1449,7 @@ export default function (pi: ExtensionAPI) {
1435
1449
  text.setText(formatRepoCacheCall(action, args, theme));
1436
1450
  return text;
1437
1451
  },
1438
- renderResult(result, { isPartial }, theme, context) {
1452
+ renderResult(result, { isPartial, expanded }, theme, context) {
1439
1453
  if (isPartial) return new Text(theme.fg("warning", "Working on repo cache..."), 0, 0);
1440
1454
  if (context.isError) {
1441
1455
  const errorText = result.content
@@ -1445,6 +1459,24 @@ export default function (pi: ExtensionAPI) {
1445
1459
  return new Text(theme.fg("error", errorText || "repo_cache failed"), 0, 0);
1446
1460
  }
1447
1461
  const details = result.details as RepoCacheToolDetails | undefined;
1462
+ // A non-empty list renders as a real, bounded Table -- the human channel actually shows
1463
+ // host/owner/repo/ref/registered/size/fetched, not just a bare count, capped at
1464
+ // REPO_CACHE_VISIBLE_ROWS since a cache can grow arbitrarily large even though maxResults
1465
+ // bounds any one page. Every other branch (empty list, fetch, evict) stays a plain Text line.
1466
+ if (details?.action === "list" && details.page.entries.length > 0) {
1467
+ // Rebuilt fresh on every call (not reused via context.lastComponent) since expanded is
1468
+ // fixed at BoundedTable construction, matching every other truncated-list renderer in this
1469
+ // codebase (they all call renderTruncatedList fresh with the current expanded each time).
1470
+ return renderBoundedTable({
1471
+ columns: REPO_CACHE_TABLE_COLUMNS,
1472
+ rows: buildRepoCacheTableRows(details.page.entries),
1473
+ expanded,
1474
+ visibleRowCount: REPO_CACHE_VISIBLE_ROWS,
1475
+ moreLine: repoCacheMoreLine(theme),
1476
+ measure: tableMeasure,
1477
+ headerStyle: (s) => theme.fg("muted", theme.bold(s)),
1478
+ });
1479
+ }
1448
1480
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1449
1481
  if (details?.action === "list") text.setText(formatRepoCacheListResult(details.page, theme));
1450
1482
  else if (details?.action === "evict") text.setText(formatRepoCacheEvictResult(details.result, theme));
@@ -1,4 +1,5 @@
1
1
  import type { PackageSourceOperationResult } from "@danypops/lector";
2
+ import { renderTruncatedList } from "malevich-tui-components";
2
3
  import type { LectorTheme } from "./lector-tui-theme.ts";
3
4
 
4
5
  const DEFAULT_VISIBLE_CANDIDATES = 5;
@@ -21,11 +22,17 @@ export function formatPackageSourceResult(result: PackageSourceOperationResult |
21
22
  ].join("\n");
22
23
  }
23
24
  if (outcome.status === "ambiguous") {
24
- const visible = expanded ? outcome.candidates : outcome.candidates.slice(0, DEFAULT_VISIBLE_CANDIDATES);
25
- const lines = [theme.fg("warning", `Ambiguous package source (${outcome.code})`)];
26
- for (const candidate of visible) lines.push(`${candidate.version} -- ${candidate.source}`);
27
- const hidden = outcome.candidates.length - visible.length;
28
- if (hidden > 0 || outcome.truncated) lines.push(theme.fg("dim", outcome.truncated ? "More candidates were truncated by the daemon." : `… ${hidden} more`));
25
+ const lines = [
26
+ theme.fg("warning", `Ambiguous package source (${outcome.code})`),
27
+ ...renderTruncatedList({
28
+ items: outcome.candidates,
29
+ expanded,
30
+ visibleCount: DEFAULT_VISIBLE_CANDIDATES,
31
+ formatItem: (candidate) => `${candidate.version} -- ${candidate.source}`,
32
+ moreLine: (hidden) => theme.fg("dim", `… ${hidden} more`),
33
+ truncationWarning: outcome.truncated ? theme.fg("dim", "More candidates were truncated by the daemon.") : undefined,
34
+ }),
35
+ ];
29
36
  return lines.join("\n");
30
37
  }
31
38
  if (outcome.status === "unauthenticated") {
@@ -1,6 +1,15 @@
1
- import type { CachedRepositoryPage, RepoFetchResult } from "@danypops/lector";
1
+ import type { CachedRepositoryEntry, CachedRepositoryPage, RepoFetchResult } from "@danypops/lector";
2
+ import { keyHint } from "@earendil-works/pi-coding-agent";
3
+ import type { TableColumn } from "malevich-tui-components";
2
4
  import type { LectorTheme } from "./lector-tui-theme.ts";
3
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
+
4
13
  type RepoCacheAction = "fetch" | "list" | "evict";
5
14
 
6
15
  export function formatRepoCacheCall(
@@ -31,11 +40,42 @@ export function formatRepoFetchResult(result: (RepoFetchResult & { workspaceId:
31
40
  return lines.join("\n");
32
41
  }
33
42
 
43
+ /** Empty-state fallback only -- a non-empty page renders as a real Table (see REPO_CACHE_TABLE_COLUMNS/buildRepoCacheTableRows) so the human channel actually shows what's cached, not just a bare count. */
34
44
  export function formatRepoCacheListResult(page: CachedRepositoryPage | undefined, theme: LectorTheme): string {
35
45
  const count = page?.entries.length ?? 0;
36
46
  return count === 0 ? theme.fg("dim", "no cached repositories") : theme.fg("success", `${count} cached repositor${count === 1 ? "y" : "ies"}`);
37
47
  }
38
48
 
49
+ export const REPO_CACHE_TABLE_COLUMNS: TableColumn[] = [
50
+ { header: "Repository", key: "repo" },
51
+ { header: "Ref", key: "ref" },
52
+ { header: "Registered", key: "registered" },
53
+ { header: "Size", key: "size" },
54
+ { header: "Fetched", key: "fetched" },
55
+ ];
56
+
57
+ /** Powers of 1024, one decimal past the first -- "cache size" is always at least a git checkout, so bytes/KB granularity is never useful here. */
58
+ function formatCacheSize(bytes: number): string {
59
+ const units = ["B", "KB", "MB", "GB", "TB"] as const;
60
+ let value = bytes;
61
+ let unitIndex = 0;
62
+ while (value >= 1024 && unitIndex < units.length - 1) {
63
+ value /= 1024;
64
+ unitIndex++;
65
+ }
66
+ return `${unitIndex === 0 ? value : value.toFixed(1)} ${units[unitIndex]}`;
67
+ }
68
+
69
+ export function buildRepoCacheTableRows(entries: readonly CachedRepositoryEntry[]): Record<string, string>[] {
70
+ return entries.map((entry) => ({
71
+ repo: `${entry.host}/${entry.owner}/${entry.repo}`,
72
+ ref: entry.requestedRef === entry.resolvedRef ? entry.resolvedRef : `${entry.requestedRef} -> ${entry.resolvedRef}`,
73
+ registered: entry.registeredWorkspaceId ?? "no",
74
+ size: formatCacheSize(entry.cacheSizeBytes),
75
+ fetched: new Date(entry.fetchedAt).toISOString(),
76
+ }));
77
+ }
78
+
39
79
  export function formatRepoCacheEvictResult(result: { evicted: boolean } | undefined, theme: LectorTheme): string {
40
80
  if (!result) return theme.fg("dim", "No result.");
41
81
  return result.evicted ? theme.fg("success", "evicted") : theme.fg("dim", "nothing cached for that reference");
@@ -1,5 +1,6 @@
1
1
  import type { TextSearchResult } from "@danypops/lector";
2
2
  import { keyHint } from "@earendil-works/pi-coding-agent";
3
+ import { renderTruncatedList } from "malevich-tui-components";
3
4
  import type { LectorTheme } from "./lector-tui-theme.ts";
4
5
 
5
6
  const DEFAULT_VISIBLE_MATCHES = 20;
@@ -12,10 +13,13 @@ export function formatSearchCall(args: { directory?: unknown; query?: unknown },
12
13
 
13
14
  export function formatSearchResult(result: TextSearchResult | undefined, expanded: boolean, theme: LectorTheme): string {
14
15
  if (!result || result.matches.length === 0) return theme.fg("dim", "No matches found.");
15
- const displayCount = expanded ? result.matches.length : Math.min(DEFAULT_VISIBLE_MATCHES, result.matches.length);
16
- const lines = result.matches.slice(0, displayCount).map((match) => `${theme.fg("accent", match.path)}:${match.lineNumber}: ${match.line.replace(/\n$/, "")}`);
17
- const remaining = result.matches.length - displayCount;
18
- if (remaining > 0) lines.push(theme.fg("dim", `... ${remaining} more (${keyHint("app.tools.expand", "to expand")})`));
19
- if (result.truncated) lines.push(theme.fg("warning", "(search itself was truncated by maxMatches/maxBytes -- results are incomplete)"));
16
+ const lines = renderTruncatedList({
17
+ items: result.matches,
18
+ expanded,
19
+ visibleCount: DEFAULT_VISIBLE_MATCHES,
20
+ formatItem: (match) => `${theme.fg("accent", match.path)}:${match.lineNumber}: ${match.line.replace(/\n$/, "")}`,
21
+ moreLine: (hidden) => theme.fg("dim", `... ${hidden} more (${keyHint("app.tools.expand", "to expand")})`),
22
+ truncationWarning: result.truncated ? theme.fg("warning", "(search itself was truncated by maxMatches/maxBytes -- results are incomplete)") : undefined,
23
+ });
20
24
  return lines.join("\n");
21
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-lector",
3
- "version": "0.8.1",
3
+ "version": "0.9.1",
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,8 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@danypops/vehicle-client": "^0.1.1",
22
- "@danypops/lector": "^0.10.0"
22
+ "@danypops/lector": "^0.10.0",
23
+ "malevich-tui-components": "^0.19.0"
23
24
  },
24
25
  "devDependencies": {
25
26
  "@earendil-works/pi-ai": "^0.81.1",