@oh-my-pi/pi-coding-agent 16.0.3 → 16.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/cli.js +697 -337
  3. package/dist/types/advisor/advise-tool.d.ts +9 -0
  4. package/dist/types/cli/args.d.ts +2 -0
  5. package/dist/types/cli/bench-cli.d.ts +6 -0
  6. package/dist/types/commands/launch.d.ts +6 -0
  7. package/dist/types/config/settings-schema.d.ts +92 -3
  8. package/dist/types/edit/file-snapshot-store.d.ts +2 -0
  9. package/dist/types/extensibility/extensions/runner.d.ts +5 -2
  10. package/dist/types/extensibility/extensions/types.d.ts +8 -7
  11. package/dist/types/extensibility/shared-events.d.ts +22 -1
  12. package/dist/types/main.d.ts +1 -0
  13. package/dist/types/modes/components/status-line/component.d.ts +1 -1
  14. package/dist/types/modes/components/status-line/context-thresholds.d.ts +0 -1
  15. package/dist/types/modes/rpc/rpc-types.d.ts +1 -1
  16. package/dist/types/modes/utils/context-usage.d.ts +12 -0
  17. package/dist/types/sdk.d.ts +3 -1
  18. package/dist/types/session/agent-session.d.ts +20 -0
  19. package/dist/types/session/session-persistence.d.ts +4 -0
  20. package/dist/types/tools/read.d.ts +1 -0
  21. package/dist/types/tui/code-cell.d.ts +2 -0
  22. package/dist/types/utils/image-vision-fallback.d.ts +28 -0
  23. package/dist/types/web/search/providers/base.d.ts +1 -0
  24. package/dist/types/web/search/providers/gemini.d.ts +1 -0
  25. package/package.json +12 -12
  26. package/src/advisor/__tests__/advisor.test.ts +59 -0
  27. package/src/advisor/advise-tool.ts +13 -0
  28. package/src/cli/args.ts +4 -0
  29. package/src/cli/bench-cli.ts +30 -7
  30. package/src/cli/flag-tables.ts +9 -0
  31. package/src/collab/host.ts +2 -2
  32. package/src/commands/launch.ts +6 -0
  33. package/src/config/settings-schema.ts +85 -3
  34. package/src/edit/file-snapshot-store.ts +12 -3
  35. package/src/eval/py/runner.py +44 -0
  36. package/src/extensibility/extensions/runner.ts +20 -2
  37. package/src/extensibility/extensions/types.ts +16 -5
  38. package/src/extensibility/shared-events.ts +24 -0
  39. package/src/internal-urls/docs-index.generated.ts +81 -81
  40. package/src/main.ts +18 -9
  41. package/src/modes/components/branch-summary-message.ts +1 -0
  42. package/src/modes/components/collab-prompt-message.ts +9 -7
  43. package/src/modes/components/compaction-summary-message.ts +1 -0
  44. package/src/modes/components/custom-message.ts +1 -0
  45. package/src/modes/components/footer.ts +6 -5
  46. package/src/modes/components/hook-message.ts +1 -0
  47. package/src/modes/components/read-tool-group.ts +9 -3
  48. package/src/modes/components/skill-message.ts +1 -0
  49. package/src/modes/components/status-line/component.ts +131 -14
  50. package/src/modes/components/status-line/context-thresholds.ts +0 -1
  51. package/src/modes/components/tips.txt +2 -1
  52. package/src/modes/components/todo-reminder.ts +1 -0
  53. package/src/modes/components/ttsr-notification.ts +1 -0
  54. package/src/modes/components/user-message.ts +6 -6
  55. package/src/modes/controllers/event-controller.ts +2 -7
  56. package/src/modes/controllers/selector-controller.ts +10 -3
  57. package/src/modes/interactive-mode.ts +4 -2
  58. package/src/modes/rpc/rpc-types.ts +1 -1
  59. package/src/modes/utils/context-usage.ts +28 -15
  60. package/src/prompts/system/system-prompt.md +2 -0
  61. package/src/prompts/tools/image-attachment-describe-system.md +8 -0
  62. package/src/prompts/tools/image-attachment-describe.md +10 -0
  63. package/src/sdk.ts +14 -18
  64. package/src/session/agent-session.ts +571 -235
  65. package/src/session/session-loader.ts +19 -32
  66. package/src/session/session-persistence.ts +27 -11
  67. package/src/ssh/connection-manager.ts +3 -2
  68. package/src/task/executor.ts +1 -1
  69. package/src/tools/image-gen.ts +67 -25
  70. package/src/tools/read.ts +54 -6
  71. package/src/tui/code-cell.ts +44 -3
  72. package/src/utils/image-vision-fallback.ts +197 -0
  73. package/src/web/search/index.ts +12 -0
  74. package/src/web/search/providers/base.ts +1 -0
  75. package/src/web/search/providers/gemini.ts +56 -18
@@ -4,7 +4,7 @@ import { BlobStore, isBlobRef, resolveImageData, resolveImageDataUrl } from "./b
4
4
  import { buildSessionContext } from "./session-context";
5
5
  import type { FileEntry, SessionEntry, SessionHeader } from "./session-entries";
6
6
  import { migrateToCurrentVersion } from "./session-migrations";
7
- import { isImageBlock } from "./session-persistence";
7
+ import { isImageBlock, isImageDataPayload } from "./session-persistence";
8
8
  import { FileSessionStorage, type SessionStorage } from "./session-storage";
9
9
 
10
10
  /** Exported for compaction.test.ts */
@@ -44,9 +44,19 @@ function hasImageUrl(value: unknown): value is { image_url: string } {
44
44
  return typeof value === "object" && value !== null && "image_url" in value && typeof value.image_url === "string";
45
45
  }
46
46
 
47
- async function resolvePersistedImageUrlRefs(value: unknown, blobStore: BlobStore): Promise<void> {
47
+ function shouldResolveImagePayload(value: unknown, key: string | undefined): value is { data: string } {
48
+ if (!isImageDataPayload(value) || !isBlobRef(value.data)) return false;
49
+ return (key === "content" && isImageBlock(value)) || key === "images";
50
+ }
51
+
52
+ async function resolvePersistedBlobRefs(value: unknown, blobStore: BlobStore, key?: string): Promise<void> {
53
+ if (shouldResolveImagePayload(value, key)) {
54
+ value.data = await resolveImageData(blobStore, value.data);
55
+ return;
56
+ }
57
+
48
58
  if (Array.isArray(value)) {
49
- await Promise.all(value.map(item => resolvePersistedImageUrlRefs(item, blobStore)));
59
+ await Promise.all(value.map(item => resolvePersistedBlobRefs(item, blobStore, key)));
50
60
  return;
51
61
  }
52
62
 
@@ -56,38 +66,15 @@ async function resolvePersistedImageUrlRefs(value: unknown, blobStore: BlobStore
56
66
  value.image_url = await resolveImageDataUrl(blobStore, value.image_url);
57
67
  }
58
68
 
59
- await Promise.all(Object.values(value).map(item => resolvePersistedImageUrlRefs(item, blobStore)));
69
+ await Promise.all(
70
+ Object.entries(value).map(([childKey, item]) => resolvePersistedBlobRefs(item, blobStore, childKey)),
71
+ );
60
72
  }
61
73
 
62
74
  export async function resolveBlobRefsInEntries(entries: FileEntry[], blobStore: BlobStore): Promise<void> {
63
- const promises: Promise<void>[] = [];
64
-
65
- for (const entry of entries) {
66
- if (entry.type === "session") continue;
67
-
68
- let contentArray: unknown[] | undefined;
69
- if (entry.type === "message" && "content" in entry.message && Array.isArray(entry.message.content)) {
70
- contentArray = entry.message.content;
71
- } else if (entry.type === "custom_message" && Array.isArray(entry.content)) {
72
- contentArray = entry.content;
73
- }
74
-
75
- if (contentArray) {
76
- for (const block of contentArray) {
77
- if (isImageBlock(block) && isBlobRef(block.data)) {
78
- promises.push(
79
- resolveImageData(blobStore, block.data).then(resolved => {
80
- block.data = resolved;
81
- }),
82
- );
83
- }
84
- }
85
- }
86
-
87
- promises.push(resolvePersistedImageUrlRefs(entry, blobStore));
88
- }
89
-
90
- await Promise.all(promises);
75
+ await Promise.all(
76
+ entries.filter(entry => entry.type !== "session").map(entry => resolvePersistedBlobRefs(entry, blobStore)),
77
+ );
91
78
  }
92
79
 
93
80
  /**
@@ -36,10 +36,33 @@ export function isImageBlock(value: unknown): value is { type: "image"; data: st
36
36
  );
37
37
  }
38
38
 
39
+ function isImageMimeType(value: unknown): value is string {
40
+ return typeof value === "string" && value.toLowerCase().startsWith("image/");
41
+ }
42
+
43
+ export function isImageDataPayload(value: unknown): value is { data: string; mimeType?: string } {
44
+ return (
45
+ typeof value === "object" &&
46
+ value !== null &&
47
+ "data" in value &&
48
+ typeof (value as { data?: string }).data === "string" &&
49
+ (isImageBlock(value) || ("mimeType" in value && isImageMimeType((value as { mimeType?: unknown }).mimeType)))
50
+ );
51
+ }
52
+
53
+ function shouldExternalizeImagePayload(
54
+ value: unknown,
55
+ key: string | undefined,
56
+ ): value is { data: string; mimeType?: string } {
57
+ if (!isImageDataPayload(value)) return false;
58
+ if (isBlobRef(value.data) || value.data.length < BLOB_EXTERNALIZE_THRESHOLD) return false;
59
+ return (key === TEXT_CONTENT_KEY && isImageBlock(value)) || key === "images";
60
+ }
61
+
39
62
  /**
40
63
  * Recursively truncate large strings in an object for session persistence.
41
64
  * - Truncates any oversized string fields (key-agnostic)
42
- * - Replaces oversized image blocks with text notices
65
+ * - Externalizes oversized image payloads to blob refs
43
66
  * - Updates lineCount when content is truncated
44
67
  * - Returns original object if no changes needed (structural sharing)
45
68
  *
@@ -50,6 +73,9 @@ export function isImageBlock(value: unknown): value is { type: "image"; data: st
50
73
  */
51
74
  function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?: string): unknown {
52
75
  if (obj === null || obj === undefined) return obj;
76
+ if (shouldExternalizeImagePayload(obj, key)) {
77
+ return { ...obj, data: externalizeImageDataSync(blobStore, obj.data, obj.mimeType) };
78
+ }
53
79
 
54
80
  if (typeof obj === "string") {
55
81
  if (key === "image_url" && isImageDataUrl(obj)) {
@@ -72,16 +98,6 @@ function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?: string
72
98
  const result: unknown[] = new Array(obj.length);
73
99
  for (let i = 0; i < obj.length; i++) {
74
100
  const item = obj[i];
75
- if (
76
- key === TEXT_CONTENT_KEY &&
77
- isImageBlock(item) &&
78
- !isBlobRef(item.data) &&
79
- item.data.length >= BLOB_EXTERNALIZE_THRESHOLD
80
- ) {
81
- changed = true;
82
- result[i] = { ...item, data: externalizeImageDataSync(blobStore, item.data, item.mimeType) };
83
- continue;
84
- }
85
101
  const newItem = truncateForPersistence(item, blobStore, key);
86
102
  if (newItem !== item) changed = true;
87
103
  result[i] = newItem;
@@ -65,7 +65,7 @@ async function deleteHostInfoFromDisk(hostName: string): Promise<void> {
65
65
  }
66
66
  }
67
67
 
68
- async function validateKeyPermissions(keyPath?: string): Promise<void> {
68
+ async function validateKeyPermissions(keyPath?: string, platform: SshPlatform = process.platform): Promise<void> {
69
69
  if (!keyPath) return;
70
70
  let stats: fs.Stats;
71
71
  try {
@@ -79,6 +79,7 @@ async function validateKeyPermissions(keyPath?: string): Promise<void> {
79
79
  if (!stats.isFile()) {
80
80
  throw new Error(`SSH key is not a file: ${keyPath}`);
81
81
  }
82
+ if (platform === "win32") return;
82
83
  const mode = stats.mode & 0o777;
83
84
  if ((mode & 0o077) !== 0) {
84
85
  throw new Error(`SSH key permissions must be 600 or stricter: ${keyPath}`);
@@ -402,7 +403,7 @@ export async function buildRemoteCommand(
402
403
  command: string,
403
404
  options?: SSHArgsOptions,
404
405
  ): Promise<string[]> {
405
- await validateKeyPermissions(host.keyPath);
406
+ await validateKeyPermissions(host.keyPath, options?.platform);
406
407
  return [...buildCommonArgs(host, options), buildSshTarget(host.username, host.host), command];
407
408
  }
408
409
 
@@ -2117,8 +2117,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2117
2117
  void session.abort();
2118
2118
  }
2119
2119
 
2120
+ const pendingExtensionMessages: Array<Promise<unknown>> = [];
2120
2121
  const extensionRunner = session.extensionRunner;
2121
- const pendingExtensionMessages: Promise<unknown>[] = [];
2122
2122
  if (extensionRunner) {
2123
2123
  extensionRunner.initialize(
2124
2124
  {
@@ -21,8 +21,8 @@ import {
21
21
  } from "@oh-my-pi/pi-utils";
22
22
  import { z } from "zod/v4";
23
23
  import packageJson from "../../package.json" with { type: "json" };
24
-
25
24
  import { isAuthenticated, type ModelRegistry } from "../config/model-registry";
25
+ import { settings } from "../config/settings";
26
26
  import type { CustomTool } from "../extensibility/custom-tools/types";
27
27
  import { ohMyPiXAIUserAgent, resolveXAIHttpCredentials } from "../lib/xai-http";
28
28
  import imageGenDescription from "../prompts/tools/image-gen.md" with { type: "text" };
@@ -38,7 +38,8 @@ const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
38
38
  const OPENAI_IMAGE_OUTPUT_FORMAT = "webp";
39
39
  const OPENAI_IMAGE_MIME_TYPE = "image/webp";
40
40
 
41
- const ANTIGRAVITY_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
41
+ const DEFAULT_ANTIGRAVITY_ENDPOINT_PROD = "https://daily-cloudcode-pa.googleapis.com";
42
+ const DEFAULT_ANTIGRAVITY_ENDPOINT_SANDBOX = "https://daily-cloudcode-pa.sandbox.googleapis.com";
42
43
  const IMAGE_SYSTEM_INSTRUCTION =
43
44
  "You are an AI image generator. Generate images based on user descriptions. Focus on creating high-quality, visually appealing images that match the user's request.";
44
45
 
@@ -1164,33 +1165,74 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1164
1165
  resolvedImages,
1165
1166
  );
1166
1167
 
1167
- const resp = await fetchImpl(`${ANTIGRAVITY_ENDPOINT}/v1internal:streamGenerateContent?alt=sse`, {
1168
- method: "POST",
1169
- headers: {
1170
- Authorization: `Bearer ${bearer}`,
1171
- "Content-Type": "application/json",
1172
- Accept: "text/event-stream",
1173
- "User-Agent": getAntigravityUserAgent(),
1174
- },
1175
- body: JSON.stringify(requestBody),
1176
- signal: requestSignal,
1177
- });
1168
+ let endpoints = [DEFAULT_ANTIGRAVITY_ENDPOINT_PROD, DEFAULT_ANTIGRAVITY_ENDPOINT_SANDBOX];
1169
+ try {
1170
+ const mode = settings.get("providers.antigravityEndpoint");
1171
+ if (mode === "production") {
1172
+ endpoints = [DEFAULT_ANTIGRAVITY_ENDPOINT_PROD];
1173
+ } else if (mode === "sandbox") {
1174
+ endpoints = [DEFAULT_ANTIGRAVITY_ENDPOINT_SANDBOX];
1175
+ }
1176
+ } catch {
1177
+ // Ignored
1178
+ }
1178
1179
 
1179
- if (!resp.ok) {
1180
- const errorText = await resp.text();
1181
- let message = errorText;
1180
+ let resp: Response | undefined;
1181
+ let lastError: Error | undefined;
1182
+
1183
+ for (let i = 0; i < endpoints.length; i++) {
1184
+ const endpoint = endpoints[i];
1185
+ const isLastEndpoint = i === endpoints.length - 1;
1182
1186
  try {
1183
- const parsedErr = JSON.parse(errorText) as { error?: { message?: string } };
1184
- message = parsedErr.error?.message ?? message;
1185
- } catch {
1186
- // Keep raw text.
1187
+ resp = await fetchImpl(`${endpoint}/v1internal:streamGenerateContent?alt=sse`, {
1188
+ method: "POST",
1189
+ headers: {
1190
+ Authorization: `Bearer ${bearer}`,
1191
+ "Content-Type": "application/json",
1192
+ Accept: "text/event-stream",
1193
+ "User-Agent": getAntigravityUserAgent(),
1194
+ },
1195
+ body: JSON.stringify(requestBody),
1196
+ signal: requestSignal,
1197
+ });
1198
+
1199
+ if (resp.ok) {
1200
+ break;
1201
+ }
1202
+
1203
+ const errorText = await resp.text();
1204
+ let message = errorText;
1205
+ try {
1206
+ const parsedErr = JSON.parse(errorText) as { error?: { message?: string } };
1207
+ message = parsedErr.error?.message ?? message;
1208
+ } catch {
1209
+ // Keep raw text.
1210
+ }
1211
+
1212
+ lastError = new ProviderHttpError(
1213
+ `Antigravity image request failed (${resp.status}): ${message}`,
1214
+ resp.status,
1215
+ { headers: resp.headers },
1216
+ );
1217
+
1218
+ if (resp.status === 429 || (resp.status >= 500 && resp.status < 600)) {
1219
+ if (!isLastEndpoint) {
1220
+ continue;
1221
+ }
1222
+ }
1223
+ break;
1224
+ } catch (error) {
1225
+ lastError = error as Error;
1226
+ if (isLastEndpoint) {
1227
+ break;
1228
+ }
1187
1229
  }
1188
- throw new ProviderHttpError(
1189
- `Antigravity image request failed (${resp.status}): ${message}`,
1190
- resp.status,
1191
- { headers: resp.headers },
1192
- );
1193
1230
  }
1231
+
1232
+ if (!resp?.ok) {
1233
+ throw lastError ?? new Error("Antigravity image generation failed");
1234
+ }
1235
+
1194
1236
  return resp;
1195
1237
  },
1196
1238
  { signal: requestSignal },
package/src/tools/read.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  canonicalSnapshotKey,
15
15
  getFileSnapshotStore,
16
16
  recordFileSnapshot,
17
+ recordSeenLines,
17
18
  recordSeenLinesFromBody,
18
19
  SNAPSHOT_MAX_BYTES,
19
20
  } from "../edit/file-snapshot-store";
@@ -288,6 +289,20 @@ function countTextLines(text: string): number {
288
289
  return text.split("\n").length;
289
290
  }
290
291
 
292
+ function contiguousLineNumbers(startLine: number, count: number): number[] {
293
+ const lines: number[] = [];
294
+ for (let offset = 0; offset < count; offset++) lines.push(startLine + offset);
295
+ return lines;
296
+ }
297
+
298
+ function lineNumbersFromEntries(entries: readonly LineEntry[]): number[] {
299
+ const lines: number[] = [];
300
+ for (const entry of entries) {
301
+ if (entry.kind === "line") lines.push(entry.lineNumber);
302
+ }
303
+ return lines;
304
+ }
305
+
291
306
  /** Inclusive line range describing one elided span in a structural summary. */
292
307
  interface ElidedRange {
293
308
  start: number;
@@ -674,7 +689,11 @@ export interface ReadToolDetails {
674
689
  /** Raw text + start line for user-visible TUI rendering, set when content is text-like.
675
690
  * Mirrors the same lines the model receives but without hashline/line-number prefixes,
676
691
  * so the TUI can render the file content with its own gutter without re-parsing the formatted text. */
677
- displayContent?: { text: string; startLine: number };
692
+ displayContent?: {
693
+ text: string;
694
+ startLine: number;
695
+ lineNumbers?: Array<number | null>;
696
+ };
678
697
  summary?: { lines: number; elidedSpans: number; elidedLines: number };
679
698
  /** Number of unresolved git conflicts surfaced by this read (TUI uses for inline `⚠ N` badge). */
680
699
  conflictCount?: number;
@@ -1041,8 +1060,15 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1041
1060
  )
1042
1061
  : undefined;
1043
1062
  let emittedHashlineHeader = false;
1063
+ let seenLines: number[] | undefined;
1044
1064
  const formatText = (content: string, startNum: number): string => {
1045
- details.displayContent = { text: content, startLine: startNum };
1065
+ const lineCount = countTextLines(content);
1066
+ details.displayContent = {
1067
+ text: content,
1068
+ startLine: startNum,
1069
+ lineNumbers: Array.from({ length: lineCount }, (_, i) => startNum + i),
1070
+ };
1071
+ if (shouldAddHashLines) seenLines = contiguousLineNumbers(startNum, countTextLines(content));
1046
1072
  const formatted = formatTextWithMode(content, startNum, shouldAddHashLines, shouldAddLineNumbers);
1047
1073
  if (!hashContext || emittedHashlineHeader) return formatted;
1048
1074
  emittedHashlineHeader = true;
@@ -1053,7 +1079,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1053
1079
  details.displayContent = {
1054
1080
  text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS),
1055
1081
  startLine: firstLine?.kind === "line" ? firstLine.lineNumber : startNum,
1082
+ lineNumbers: entries.map(entry => (entry.kind === "line" ? entry.lineNumber : null)),
1056
1083
  };
1084
+ if (shouldAddHashLines) seenLines = lineNumbersFromEntries(entries);
1057
1085
  const formatted = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers);
1058
1086
  if (!hashContext || emittedHashlineHeader) return formatted;
1059
1087
  emittedHashlineHeader = true;
@@ -1121,6 +1149,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1121
1149
  : formatLineEntries(buildLineEntries(endLine), startLineDisplay);
1122
1150
  }
1123
1151
 
1152
+ if (hashContext?.tag && options.sourcePath && seenLines) {
1153
+ recordSeenLines(this.session, options.sourcePath, hashContext.tag, seenLines);
1154
+ }
1124
1155
  resultBuilder.text(outputText);
1125
1156
  if (truncationInfo) {
1126
1157
  resultBuilder.truncation(truncationInfo.result, truncationInfo.options);
@@ -1165,6 +1196,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1165
1196
  : undefined;
1166
1197
  let emittedHashlineHeader = false;
1167
1198
 
1199
+ let seenLines: number[] | undefined;
1168
1200
  const resultBuilder = toolResult(details);
1169
1201
  if (options.sourcePath) resultBuilder.sourcePath(options.sourcePath);
1170
1202
  if (options.sourceUrl) resultBuilder.sourceUrl(options.sourceUrl);
@@ -1190,11 +1222,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1190
1222
  outputText = rawParts.length > 0 ? rawParts.join("\n\n…\n\n") : "";
1191
1223
  } else if (visibleSpans.length > 0) {
1192
1224
  const entries = buildLineEntriesWithBlockContext(allLines, visibleSpans, { path: options.sourcePath });
1225
+ if (shouldAddHashLines) seenLines = lineNumbersFromEntries(entries);
1193
1226
  const firstLine = entries.find(entry => entry.kind === "line");
1194
1227
  if (firstLine?.kind === "line") {
1195
1228
  details.displayContent = {
1196
1229
  text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS),
1197
1230
  startLine: firstLine.lineNumber,
1231
+ lineNumbers: entries.map(entry => (entry.kind === "line" ? entry.lineNumber : null)),
1198
1232
  };
1199
1233
  }
1200
1234
  const formatted = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers);
@@ -1208,6 +1242,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1208
1242
  }
1209
1243
  const finalText =
1210
1244
  notices.length > 0 ? (outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n")) : outputText;
1245
+ if (hashContext?.tag && options.sourcePath && seenLines) {
1246
+ recordSeenLines(this.session, options.sourcePath, hashContext.tag, seenLines);
1247
+ }
1211
1248
  resultBuilder.text(finalText);
1212
1249
  return resultBuilder.done();
1213
1250
  }
@@ -1229,7 +1266,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1229
1266
  ): Promise<{
1230
1267
  outputText: string;
1231
1268
  columnTruncated: number;
1232
- displayContent?: { text: string; startLine: number };
1269
+ displayContent?: { text: string; startLine: number; lineNumbers?: Array<number | null> };
1233
1270
  bridgeResult?: AgentToolResult<ReadToolDetails>;
1234
1271
  }> {
1235
1272
  const rawSelector = isRawSelector(parsed);
@@ -1266,7 +1303,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1266
1303
  const displayLineByNumber = new Map<number, string>();
1267
1304
  const fullLines = rawSelector ? undefined : await readBracketContextFullLines(absolutePath, fileSize);
1268
1305
  let columnTruncated = 0;
1269
- let displayContent: { text: string; startLine: number } | undefined;
1306
+ let displayContent: { text: string; startLine: number; lineNumbers?: Array<number | null> } | undefined;
1270
1307
 
1271
1308
  for (const range of ranges) {
1272
1309
  const rangeStart = range.startLine - 1; // 0-indexed
@@ -1349,6 +1386,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1349
1386
  displayContent = {
1350
1387
  text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS),
1351
1388
  startLine: firstLine?.kind === "line" ? firstLine.lineNumber : (visibleSpans[0]?.startLine ?? 1),
1389
+ lineNumbers: entries.map(entry => (entry.kind === "line" ? entry.lineNumber : null)),
1352
1390
  };
1353
1391
  outputText = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers);
1354
1392
  } else {
@@ -2266,10 +2304,17 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2266
2304
  }
2267
2305
  }
2268
2306
 
2269
- let capturedDisplayContent: { text: string; startLine: number } | undefined;
2307
+ let capturedDisplayContent:
2308
+ | { text: string; startLine: number; lineNumbers?: Array<number | null> }
2309
+ | undefined;
2270
2310
  let emittedHashlineHeader = false;
2271
2311
  const formatText = (text: string, startNum: number): string => {
2272
- capturedDisplayContent = { text, startLine: startNum };
2312
+ const lineCount = countTextLines(text);
2313
+ capturedDisplayContent = {
2314
+ text,
2315
+ startLine: startNum,
2316
+ lineNumbers: Array.from({ length: lineCount }, (_, i) => startNum + i),
2317
+ };
2273
2318
  const formatted = formatTextWithMode(text, startNum, shouldAddHashLines, shouldAddLineNumbers);
2274
2319
  if (!hashContext || emittedHashlineHeader) return formatted;
2275
2320
  emittedHashlineHeader = true;
@@ -2296,6 +2341,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2296
2341
  capturedDisplayContent = {
2297
2342
  text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS),
2298
2343
  startLine: firstLine?.kind === "line" ? firstLine.lineNumber : startLineDisplay,
2344
+ lineNumbers: entries.map(entry => (entry.kind === "line" ? entry.lineNumber : null)),
2299
2345
  };
2300
2346
  const formatted = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers);
2301
2347
  if (!hashContext || emittedHashlineHeader) return formatted;
@@ -2902,6 +2948,8 @@ export const readToolRenderer = {
2902
2948
  status: "complete",
2903
2949
  output: warningLines.length > 0 ? warningLines.join("\n") : undefined,
2904
2950
  expanded,
2951
+ codeStartLine: details?.displayContent?.startLine,
2952
+ codeLineNumbers: details?.displayContent?.lineNumbers,
2905
2953
  width,
2906
2954
  },
2907
2955
  uiTheme,
@@ -33,6 +33,8 @@ export interface CodeCellOptions {
33
33
  codeTail?: boolean;
34
34
  expanded?: boolean;
35
35
  width: number;
36
+ codeStartLine?: number;
37
+ codeLineNumbers?: Array<number | null>;
36
38
  }
37
39
 
38
40
  function getState(status?: CodeCellOptions["status"]): State | undefined {
@@ -99,7 +101,17 @@ function collapseCarriageReturns(line: string): string {
99
101
  return idx < 0 ? line : line.slice(idx + 1);
100
102
  }
101
103
  export function renderCodeCell(options: CodeCellOptions, theme: Theme): string[] {
102
- const { code, language, output, expanded = false, outputMaxLines = 6, codeMaxLines = 12, width } = options;
104
+ const {
105
+ code,
106
+ language,
107
+ output,
108
+ expanded = false,
109
+ outputMaxLines = 6,
110
+ codeMaxLines = 12,
111
+ width,
112
+ codeStartLine,
113
+ codeLineNumbers,
114
+ } = options;
103
115
  const { title, meta } = formatHeader(options, theme);
104
116
  const state = getState(options.status);
105
117
 
@@ -111,16 +123,45 @@ export function renderCodeCell(options: CodeCellOptions, theme: Theme): string[]
111
123
  const startIndex = tail ? rawCodeLines.length - maxCodeLines : 0;
112
124
  const visibleCode = rawCodeLines.slice(startIndex, startIndex + maxCodeLines).join("\n");
113
125
  const codeLines = highlightCode(visibleCode, language);
126
+
127
+ let visibleLineNumbers: Array<number | null> | undefined;
128
+ let lineNumberWidth = 0;
129
+ if (codeLineNumbers) {
130
+ visibleLineNumbers = codeLineNumbers.slice(startIndex, startIndex + maxCodeLines);
131
+ } else if (codeStartLine !== undefined) {
132
+ visibleLineNumbers = Array.from({ length: maxCodeLines }, (_, i) => codeStartLine + startIndex + i);
133
+ }
134
+
135
+ if (visibleLineNumbers) {
136
+ const validLineNums = visibleLineNumbers.filter((n): n is number => n !== null && n !== undefined);
137
+ const maxVal = validLineNums.length > 0 ? Math.max(...validLineNums) : 0;
138
+ if (maxVal > 0) {
139
+ lineNumberWidth = Math.max(2, String(maxVal).length);
140
+ }
141
+ }
142
+
143
+ if (lineNumberWidth > 0 && visibleLineNumbers) {
144
+ for (let i = 0; i < codeLines.length; i++) {
145
+ const lineNum = visibleLineNumbers[i];
146
+ const gutter =
147
+ lineNum !== null && lineNum !== undefined
148
+ ? String(lineNum).padStart(lineNumberWidth, " ")
149
+ : " ".repeat(lineNumberWidth);
150
+ codeLines[i] = theme.fg("dim", `${gutter} `) + codeLines[i];
151
+ }
152
+ }
153
+
114
154
  if (hiddenCodeLines > 0) {
115
155
  const hint = formatExpandHint(theme, expanded, hiddenCodeLines > 0);
156
+ const gutterPad = lineNumberWidth > 0 ? " ".repeat(lineNumberWidth + 1) : "";
116
157
  if (tail) {
117
158
  // Earlier rows scrolled above the live tail window — mark them on top so
118
159
  // the newest streamed line stays pinned to the bottom of the box.
119
160
  const earlier = `… ${hiddenCodeLines} earlier line${hiddenCodeLines === 1 ? "" : "s"}${hint ? ` ${hint}` : ""}`;
120
- codeLines.unshift(theme.fg("dim", earlier));
161
+ codeLines.unshift(theme.fg("dim", gutterPad + earlier));
121
162
  } else {
122
163
  const moreLine = `${formatMoreItems(hiddenCodeLines, "line")}${hint ? ` ${hint}` : ""}`;
123
- codeLines.push(theme.fg("dim", moreLine));
164
+ codeLines.push(theme.fg("dim", gutterPad + moreLine));
124
165
  }
125
166
  }
126
167