@oh-my-pi/pi-coding-agent 8.12.4 → 8.12.7

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [8.12.7] - 2026-01-29
6
+
7
+ ### Fixed
8
+ - Fixed LSP servers showing as "unknown" in status display when server warmup fails
9
+ - Fixed Read tool loading entire file into memory when offset/limit was specified
10
+
5
11
  ## [8.12.2] - 2026-01-28
6
12
 
7
13
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "8.12.4",
3
+ "version": "8.12.7",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -79,12 +79,12 @@
79
79
  "test": "bun test"
80
80
  },
81
81
  "dependencies": {
82
- "@oh-my-pi/omp-stats": "8.12.4",
83
- "@oh-my-pi/pi-agent-core": "8.12.4",
84
- "@oh-my-pi/pi-ai": "8.12.4",
85
- "@oh-my-pi/pi-natives": "8.12.4",
86
- "@oh-my-pi/pi-tui": "8.12.4",
87
- "@oh-my-pi/pi-utils": "8.12.4",
82
+ "@oh-my-pi/omp-stats": "8.12.7",
83
+ "@oh-my-pi/pi-agent-core": "8.12.7",
84
+ "@oh-my-pi/pi-ai": "8.12.7",
85
+ "@oh-my-pi/pi-natives": "8.12.7",
86
+ "@oh-my-pi/pi-tui": "8.12.7",
87
+ "@oh-my-pi/pi-utils": "8.12.7",
88
88
  "@openai/agents": "^0.4.4",
89
89
  "@sinclair/typebox": "^0.34.48",
90
90
  "ajv": "^8.17.1",
package/src/lsp/index.ts CHANGED
@@ -107,7 +107,9 @@ export async function warmupLspServers(cwd: string, options?: LspWarmupOptions):
107
107
  }),
108
108
  );
109
109
 
110
- for (const result of results) {
110
+ for (let i = 0; i < results.length; i++) {
111
+ const result = results[i];
112
+ const [name, serverConfig] = lspServers[i];
111
113
  if (result.status === "fulfilled") {
112
114
  servers.push({
113
115
  name: result.value.name,
@@ -115,12 +117,11 @@ export async function warmupLspServers(cwd: string, options?: LspWarmupOptions):
115
117
  fileTypes: result.value.fileTypes,
116
118
  });
117
119
  } else {
118
- // Extract server name from error if possible
119
120
  const errorMsg = result.reason?.message ?? String(result.reason);
120
121
  servers.push({
121
- name: "unknown",
122
+ name,
122
123
  status: "error",
123
- fileTypes: [],
124
+ fileTypes: serverConfig.fileTypes,
124
125
  error: errorMsg,
125
126
  });
126
127
  }
@@ -11,6 +11,17 @@ export interface VisualTruncateResult {
11
11
  skippedCount: number;
12
12
  }
13
13
 
14
+ const textCache = new Map<number, Text>();
15
+
16
+ function getCachedText(paddingX: number): Text {
17
+ let text = textCache.get(paddingX);
18
+ if (!text) {
19
+ text = new Text("", paddingX, 0);
20
+ textCache.set(paddingX, text);
21
+ }
22
+ return text;
23
+ }
24
+
14
25
  /**
15
26
  * Truncate text to a maximum number of visual lines (from the end).
16
27
  * This accounts for line wrapping based on terminal width.
@@ -34,7 +45,10 @@ export function truncateToVisualLines(
34
45
  }
35
46
 
36
47
  // Create a temporary Text component to render and get visual lines
37
- const tempText = new Text(text, paddingX, 0);
48
+ const tempText = getCachedText(paddingX);
49
+ if (tempText.getText() !== text) {
50
+ tempText.setText(text);
51
+ }
38
52
  const allVisualLines = tempText.render(width);
39
53
 
40
54
  if (allVisualLines.length <= maxVisualLines) {
@@ -1180,8 +1180,8 @@ const langMap: Record<string, SymbolKey> = {
1180
1180
  };
1181
1181
 
1182
1182
  export class Theme {
1183
- private fgColors: Map<ThemeColor, string>;
1184
- private bgColors: Map<ThemeBg, string>;
1183
+ private fgColors: Record<ThemeColor, string>;
1184
+ private bgColors: Record<ThemeBg, string>;
1185
1185
  private mode: ColorMode;
1186
1186
  private symbols: SymbolMap;
1187
1187
  private symbolPreset: SymbolPreset;
@@ -1190,18 +1190,18 @@ export class Theme {
1190
1190
  fgColors: Record<ThemeColor, string | number>,
1191
1191
  bgColors: Record<ThemeBg, string | number>,
1192
1192
  mode: ColorMode,
1193
- symbolPreset: SymbolPreset = "unicode",
1194
- symbolOverrides: Record<string, string> = {},
1193
+ symbolPreset: SymbolPreset,
1194
+ symbolOverrides: Partial<Record<SymbolKey, string>>,
1195
1195
  ) {
1196
1196
  this.mode = mode;
1197
1197
  this.symbolPreset = symbolPreset;
1198
- this.fgColors = new Map();
1198
+ this.fgColors = {} as Record<ThemeColor, string>;
1199
1199
  for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) {
1200
- this.fgColors.set(key, fgAnsi(value, mode));
1200
+ this.fgColors[key] = fgAnsi(value, mode);
1201
1201
  }
1202
- this.bgColors = new Map();
1202
+ this.bgColors = {} as Record<ThemeBg, string>;
1203
1203
  for (const [key, value] of Object.entries(bgColors) as [ThemeBg, string | number][]) {
1204
- this.bgColors.set(key, bgAnsi(value, mode));
1204
+ this.bgColors[key] = bgAnsi(value, mode);
1205
1205
  }
1206
1206
  // Build symbol map from preset + overrides
1207
1207
  const baseSymbols = SYMBOL_PRESETS[symbolPreset];
@@ -1216,13 +1216,13 @@ export class Theme {
1216
1216
  }
1217
1217
 
1218
1218
  fg(color: ThemeColor, text: string): string {
1219
- const ansi = this.fgColors.get(color);
1219
+ const ansi = this.fgColors[color];
1220
1220
  if (!ansi) throw new Error(`Unknown theme color: ${color}`);
1221
1221
  return `${ansi}${text}\x1b[39m`; // Reset only foreground color
1222
1222
  }
1223
1223
 
1224
1224
  bg(color: ThemeBg, text: string): string {
1225
- const ansi = this.bgColors.get(color);
1225
+ const ansi = this.bgColors[color];
1226
1226
  if (!ansi) throw new Error(`Unknown theme background color: ${color}`);
1227
1227
  return `${ansi}${text}\x1b[49m`; // Reset only background color
1228
1228
  }
@@ -1248,13 +1248,13 @@ export class Theme {
1248
1248
  }
1249
1249
 
1250
1250
  getFgAnsi(color: ThemeColor): string {
1251
- const ansi = this.fgColors.get(color);
1251
+ const ansi = this.fgColors[color];
1252
1252
  if (!ansi) throw new Error(`Unknown theme color: ${color}`);
1253
1253
  return ansi;
1254
1254
  }
1255
1255
 
1256
1256
  getBgAnsi(color: ThemeBg): string {
1257
- const ansi = this.bgColors.get(color);
1257
+ const ansi = this.bgColors[color];
1258
1258
  if (!ansi) throw new Error(`Unknown theme background color: ${color}`);
1259
1259
  return ansi;
1260
1260
  }
package/src/tools/ask.ts CHANGED
@@ -304,7 +304,10 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
304
304
 
305
305
  // Determine timeout based on settings and plan mode
306
306
  const planModeEnabled = this.session.getPlanModeState?.()?.enabled ?? false;
307
- const settingsTimeout = this.session.settingsManager?.getAskTimeout() ?? DEFAULT_ASK_TIMEOUT_MS;
307
+ // getAskTimeout returns: number (ms), null (disabled), or undefined (no settingsManager)
308
+ // Only fall back to default if undefined; preserve null as "disabled"
309
+ const rawTimeout = this.session.settingsManager?.getAskTimeout();
310
+ const settingsTimeout = rawTimeout === undefined ? DEFAULT_ASK_TIMEOUT_MS : rawTimeout;
308
311
  const timeout = planModeEnabled ? null : settingsTimeout;
309
312
 
310
313
  // Send notification if waiting and not suppressed
package/src/tools/read.ts CHANGED
@@ -43,6 +43,87 @@ function isRemoteMountPath(absolutePath: string): boolean {
43
43
  return absolutePath.startsWith(REMOTE_MOUNT_PREFIX);
44
44
  }
45
45
 
46
+ /**
47
+ * Stream lines from a file, collecting only the requested range.
48
+ * Avoids loading the entire file into memory for large files.
49
+ *
50
+ * @param filePath - Path to the file
51
+ * @param startLine - 0-indexed start line
52
+ * @param maxLinesToCollect - Maximum lines to collect (from startLine)
53
+ * @param maxBytes - Maximum bytes to collect
54
+ * @returns Collected lines, total line count, and truncation info
55
+ */
56
+ async function streamLinesFromFile(
57
+ filePath: string,
58
+ startLine: number,
59
+ maxLinesToCollect: number,
60
+ maxBytes: number,
61
+ ): Promise<{
62
+ lines: string[];
63
+ totalFileLines: number;
64
+ collectedBytes: number;
65
+ stoppedByByteLimit: boolean;
66
+ }> {
67
+ const stream = Bun.file(filePath).stream();
68
+ const decoder = new TextDecoder();
69
+
70
+ const collectedLines: string[] = [];
71
+ let lineIndex = 0;
72
+ let collectedBytes = 0;
73
+ let stoppedByByteLimit = false;
74
+ let buffer = "";
75
+ let doneCollecting = false;
76
+
77
+ for await (const chunk of stream) {
78
+ buffer += decoder.decode(chunk, { stream: true });
79
+
80
+ for (let newlinePos = buffer.indexOf("\n"); newlinePos !== -1; newlinePos = buffer.indexOf("\n")) {
81
+ const line = buffer.slice(0, newlinePos);
82
+ buffer = buffer.slice(newlinePos + 1);
83
+
84
+ if (!doneCollecting && lineIndex >= startLine) {
85
+ const lineBytes = Buffer.byteLength(line, "utf-8") + (collectedLines.length > 0 ? 1 : 0);
86
+
87
+ if (collectedBytes + lineBytes > maxBytes && collectedLines.length > 0) {
88
+ stoppedByByteLimit = true;
89
+ doneCollecting = true;
90
+ } else if (collectedLines.length < maxLinesToCollect) {
91
+ collectedLines.push(line);
92
+ collectedBytes += lineBytes;
93
+ if (collectedLines.length >= maxLinesToCollect) {
94
+ doneCollecting = true;
95
+ }
96
+ } else {
97
+ doneCollecting = true;
98
+ }
99
+ }
100
+
101
+ lineIndex++;
102
+ }
103
+ }
104
+
105
+ // Handle remaining buffer (last line without trailing newline)
106
+ if (buffer.length > 0) {
107
+ if (!doneCollecting && lineIndex >= startLine && collectedLines.length < maxLinesToCollect) {
108
+ const lineBytes = Buffer.byteLength(buffer, "utf-8") + (collectedLines.length > 0 ? 1 : 0);
109
+ if (collectedBytes + lineBytes <= maxBytes || collectedLines.length === 0) {
110
+ collectedLines.push(buffer);
111
+ collectedBytes += lineBytes;
112
+ } else {
113
+ stoppedByByteLimit = true;
114
+ }
115
+ }
116
+ lineIndex++;
117
+ }
118
+
119
+ return {
120
+ lines: collectedLines,
121
+ totalFileLines: lineIndex,
122
+ collectedBytes,
123
+ stoppedByByteLimit,
124
+ };
125
+ }
126
+
46
127
  // Maximum image file size (20MB) - larger images will be rejected to prevent OOM during serialization
47
128
  const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
48
129
  const MAX_FUZZY_RESULTS = 5;
@@ -514,40 +595,51 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
514
595
  content = [{ type: "text", text: `[Cannot read ${ext} file: conversion failed]` }];
515
596
  }
516
597
  } else {
517
- // Read as text
518
- const file = Bun.file(absolutePath);
519
- const textContent = await file.text();
520
- const allLines = textContent.split("\n");
521
- const totalFileLines = allLines.length;
522
-
523
- // Apply offset if specified (1-indexed to 0-indexed)
598
+ // Read as text using streaming to avoid loading huge files into memory
524
599
  const startLine = offset ? Math.max(0, offset - 1) : 0;
525
600
  const startLineDisplay = startLine + 1; // For display (1-indexed)
526
601
 
602
+ // Calculate how many lines to collect: user limit or default truncation limit
603
+ const maxLinesToCollect = limit !== undefined ? limit : DEFAULT_MAX_LINES;
604
+
605
+ // Stream the file, collecting only the needed lines
606
+ const streamResult = await streamLinesFromFile(absolutePath, startLine, maxLinesToCollect, DEFAULT_MAX_BYTES);
607
+
608
+ const { lines: collectedLines, totalFileLines, collectedBytes, stoppedByByteLimit } = streamResult;
609
+
527
610
  // Check if offset is out of bounds - return graceful message instead of throwing
528
- if (startLine >= allLines.length) {
611
+ if (startLine >= totalFileLines) {
529
612
  const suggestion =
530
- allLines.length === 0
613
+ totalFileLines === 0
531
614
  ? "The file is empty."
532
- : `Use offset=1 to read from the start, or offset=${allLines.length} to read the last line.`;
615
+ : `Use offset=1 to read from the start, or offset=${totalFileLines} to read the last line.`;
533
616
  return toolResult<ReadToolDetails>()
534
- .text(`Offset ${offset} is beyond end of file (${allLines.length} lines total). ${suggestion}`)
617
+ .text(`Offset ${offset} is beyond end of file (${totalFileLines} lines total). ${suggestion}`)
535
618
  .done();
536
619
  }
537
620
 
538
- // If limit is specified by user, use it; otherwise we'll let truncateHead decide
539
- let selectedContent: string;
540
- let userLimitedLines: number | undefined;
541
- if (limit !== undefined) {
542
- const endLine = Math.min(startLine + limit, allLines.length);
543
- selectedContent = allLines.slice(startLine, endLine).join("\n");
544
- userLimitedLines = endLine - startLine;
545
- } else {
546
- selectedContent = allLines.slice(startLine).join("\n");
547
- }
548
-
549
- // Apply truncation (respects both line and byte limits)
550
- const truncation = truncateHead(selectedContent);
621
+ // Build the selected content from collected lines
622
+ const selectedContent = collectedLines.join("\n");
623
+ const userLimitedLines = limit !== undefined ? collectedLines.length : undefined;
624
+
625
+ // Build truncation result from streaming data
626
+ const totalSelectedLines = totalFileLines - startLine;
627
+ const totalSelectedBytes = collectedBytes; // We don't know exact total bytes without reading all
628
+ const wasTruncated = collectedLines.length < totalSelectedLines || stoppedByByteLimit;
629
+
630
+ const truncation: TruncationResult = {
631
+ content: selectedContent,
632
+ truncated: wasTruncated,
633
+ truncatedBy: stoppedByByteLimit ? "bytes" : wasTruncated ? "lines" : null,
634
+ totalLines: totalSelectedLines,
635
+ totalBytes: totalSelectedBytes,
636
+ outputLines: collectedLines.length,
637
+ outputBytes: collectedBytes,
638
+ lastLinePartial: false,
639
+ firstLineExceedsLimit: collectedLines.length === 0 && totalFileLines > startLine,
640
+ maxLines: DEFAULT_MAX_LINES,
641
+ maxBytes: DEFAULT_MAX_BYTES,
642
+ };
551
643
 
552
644
  // Add line numbers if requested (uses setting default if not specified)
553
645
  const shouldAddLineNumbers = lines ?? this.defaultLineNumbers;
@@ -566,7 +658,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
566
658
  let outputText: string;
567
659
 
568
660
  if (truncation.firstLineExceedsLimit) {
569
- const firstLine = allLines[startLine] ?? "";
661
+ const firstLine = collectedLines[0] ?? "";
570
662
  const firstLineBytes = Buffer.byteLength(firstLine, "utf-8");
571
663
  const snippet = truncateStringToBytesFromStart(firstLine, DEFAULT_MAX_BYTES);
572
664
 
@@ -592,8 +684,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
592
684
  result: truncation,
593
685
  options: { direction: "head", startLine: startLineDisplay, totalFileLines },
594
686
  };
595
- } else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
596
- const remaining = allLines.length - (startLine + userLimitedLines);
687
+ } else if (userLimitedLines !== undefined && startLine + userLimitedLines < totalFileLines) {
688
+ const remaining = totalFileLines - (startLine + userLimitedLines);
597
689
  const nextOffset = startLine + userLimitedLines + 1;
598
690
 
599
691
  outputText = shouldAddLineNumbers