@oh-my-pi/pi-coding-agent 15.9.3 → 15.9.67

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 (142) hide show
  1. package/CHANGELOG.md +74 -1
  2. package/dist/types/cli/classify-install-target.d.ts +5 -1
  3. package/dist/types/config/keybindings.d.ts +4 -1
  4. package/dist/types/config/settings-schema.d.ts +24 -5
  5. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  6. package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +1 -0
  7. package/dist/types/eval/backend.d.ts +6 -6
  8. package/dist/types/eval/bridge-timeout.d.ts +27 -0
  9. package/dist/types/eval/idle-timeout.d.ts +16 -14
  10. package/dist/types/eval/js/executor.d.ts +3 -3
  11. package/dist/types/eval/py/executor.d.ts +2 -2
  12. package/dist/types/eval/py/spawn-options.d.ts +58 -0
  13. package/dist/types/modes/components/assistant-message.d.ts +16 -0
  14. package/dist/types/modes/components/copy-selector.d.ts +22 -0
  15. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  16. package/dist/types/modes/components/error-banner.d.ts +11 -0
  17. package/dist/types/modes/components/model-selector.d.ts +1 -0
  18. package/dist/types/modes/components/tool-execution.d.ts +15 -0
  19. package/dist/types/modes/components/transcript-container.d.ts +1 -0
  20. package/dist/types/modes/components/user-message.d.ts +1 -1
  21. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  22. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  23. package/dist/types/modes/image-references.d.ts +17 -0
  24. package/dist/types/modes/interactive-mode.d.ts +8 -1
  25. package/dist/types/modes/types.d.ts +8 -1
  26. package/dist/types/modes/utils/copy-targets.d.ts +53 -0
  27. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  28. package/dist/types/session/blob-store.d.ts +12 -11
  29. package/dist/types/session/session-manager.d.ts +5 -3
  30. package/dist/types/system-prompt.d.ts +2 -0
  31. package/dist/types/tiny/title-client.d.ts +16 -1
  32. package/dist/types/tool-discovery/mode.d.ts +8 -0
  33. package/dist/types/tools/archive-reader.d.ts +5 -1
  34. package/dist/types/tools/eval-render.d.ts +8 -0
  35. package/dist/types/tools/render-utils.d.ts +25 -0
  36. package/dist/types/tui/code-cell.d.ts +6 -0
  37. package/dist/types/tui/hyperlink.d.ts +12 -0
  38. package/dist/types/tui/output-block.d.ts +11 -0
  39. package/dist/types/web/search/render.d.ts +1 -2
  40. package/package.json +9 -9
  41. package/src/autoresearch/dashboard.ts +11 -21
  42. package/src/cli/classify-install-target.ts +31 -5
  43. package/src/cli/claude-trace-cli.ts +13 -1
  44. package/src/cli/plugin-cli.ts +45 -0
  45. package/src/cli/web-search-cli.ts +0 -1
  46. package/src/config/keybindings.ts +58 -1
  47. package/src/config/model-registry.ts +54 -4
  48. package/src/config/settings-schema.ts +25 -5
  49. package/src/debug/raw-sse.ts +18 -4
  50. package/src/edit/file-snapshot-store.ts +1 -1
  51. package/src/edit/index.ts +1 -1
  52. package/src/edit/renderer.ts +7 -7
  53. package/src/edit/streaming.ts +1 -1
  54. package/src/eval/__tests__/agent-bridge.test.ts +100 -27
  55. package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
  56. package/src/eval/__tests__/idle-timeout.test.ts +26 -12
  57. package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
  58. package/src/eval/__tests__/llm-bridge.test.ts +10 -10
  59. package/src/eval/__tests__/shared-executors.test.ts +2 -2
  60. package/src/eval/agent-bridge.ts +4 -5
  61. package/src/eval/backend.ts +6 -6
  62. package/src/eval/bridge-timeout.ts +44 -0
  63. package/src/eval/idle-timeout.ts +33 -15
  64. package/src/eval/js/executor.ts +10 -10
  65. package/src/eval/llm-bridge.ts +4 -5
  66. package/src/eval/py/executor.ts +6 -6
  67. package/src/eval/py/kernel.ts +11 -1
  68. package/src/eval/py/spawn-options.ts +126 -0
  69. package/src/eval/py/tool-bridge.ts +43 -5
  70. package/src/export/ttsr.ts +9 -0
  71. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +31 -2
  72. package/src/extensibility/extensions/runner.ts +2 -0
  73. package/src/internal-urls/docs-index.generated.ts +9 -8
  74. package/src/lsp/client.ts +80 -2
  75. package/src/lsp/index.ts +38 -4
  76. package/src/lsp/render.ts +3 -3
  77. package/src/main.ts +8 -2
  78. package/src/modes/components/agent-dashboard.ts +13 -4
  79. package/src/modes/components/assistant-message.ts +44 -1
  80. package/src/modes/components/copy-selector.ts +249 -0
  81. package/src/modes/components/custom-editor.ts +14 -2
  82. package/src/modes/components/error-banner.ts +33 -0
  83. package/src/modes/components/extensions/extension-list.ts +17 -8
  84. package/src/modes/components/history-search.ts +19 -11
  85. package/src/modes/components/model-selector.ts +125 -29
  86. package/src/modes/components/oauth-selector.ts +28 -12
  87. package/src/modes/components/session-observer-overlay.ts +13 -15
  88. package/src/modes/components/session-selector.ts +24 -13
  89. package/src/modes/components/tool-execution.ts +71 -13
  90. package/src/modes/components/transcript-container.ts +93 -32
  91. package/src/modes/components/tree-selector.ts +19 -7
  92. package/src/modes/components/user-message-selector.ts +25 -14
  93. package/src/modes/components/user-message.ts +9 -2
  94. package/src/modes/controllers/command-controller.ts +0 -116
  95. package/src/modes/controllers/event-controller.ts +67 -12
  96. package/src/modes/controllers/input-controller.ts +33 -1
  97. package/src/modes/controllers/selector-controller.ts +38 -1
  98. package/src/modes/image-references.ts +111 -0
  99. package/src/modes/interactive-mode.ts +52 -17
  100. package/src/modes/theme/theme.ts +46 -10
  101. package/src/modes/types.ts +11 -2
  102. package/src/modes/utils/copy-targets.ts +254 -0
  103. package/src/modes/utils/ui-helpers.ts +23 -2
  104. package/src/prompts/ci-green-request.md +5 -3
  105. package/src/prompts/system/project-prompt.md +1 -0
  106. package/src/prompts/tools/ast-edit.md +1 -1
  107. package/src/prompts/tools/ast-grep.md +1 -1
  108. package/src/prompts/tools/read.md +1 -1
  109. package/src/prompts/tools/search.md +1 -1
  110. package/src/sdk.ts +17 -9
  111. package/src/session/agent-session.ts +43 -14
  112. package/src/session/blob-store.ts +96 -9
  113. package/src/session/session-manager.ts +19 -10
  114. package/src/slash-commands/builtin-registry.ts +3 -11
  115. package/src/system-prompt.ts +4 -0
  116. package/src/task/render.ts +38 -11
  117. package/src/tiny/title-client.ts +7 -1
  118. package/src/tool-discovery/mode.ts +24 -0
  119. package/src/tools/archive-reader.ts +339 -31
  120. package/src/tools/bash.ts +18 -8
  121. package/src/tools/browser/render.ts +5 -4
  122. package/src/tools/debug.ts +3 -3
  123. package/src/tools/eval-render.ts +24 -9
  124. package/src/tools/eval.ts +14 -19
  125. package/src/tools/fetch.ts +34 -14
  126. package/src/tools/gh.ts +65 -11
  127. package/src/tools/index.ts +6 -8
  128. package/src/tools/read.ts +65 -19
  129. package/src/tools/render-utils.ts +46 -0
  130. package/src/tools/search-tool-bm25.ts +4 -6
  131. package/src/tools/search.ts +60 -11
  132. package/src/tools/ssh.ts +21 -8
  133. package/src/tools/write.ts +17 -8
  134. package/src/tui/code-cell.ts +19 -4
  135. package/src/tui/hyperlink.ts +42 -7
  136. package/src/tui/output-block.ts +14 -0
  137. package/src/web/search/index.ts +2 -2
  138. package/src/web/search/render.ts +23 -55
  139. package/dist/types/eval/heartbeat.d.ts +0 -45
  140. package/src/eval/__tests__/heartbeat.test.ts +0 -84
  141. package/src/eval/heartbeat.ts +0 -74
  142. /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.test.d.ts} +0 -0
@@ -0,0 +1,24 @@
1
+ import type { Settings } from "../config/settings";
2
+ import type { SettingValue } from "../config/settings-schema";
3
+
4
+ export const TOOL_DISCOVERY_AUTO_THRESHOLD = 40;
5
+ export const TOOL_DISCOVERY_SEARCH_TOOL_NAME = "search_tool_bm25";
6
+
7
+ export type ToolDiscoveryModeSetting = SettingValue<"tools.discoveryMode">;
8
+ export type EffectiveToolDiscoveryMode = Exclude<ToolDiscoveryModeSetting, "auto">;
9
+
10
+ export function countToolsForAutoDiscovery(toolNames: Iterable<string>): number {
11
+ let count = 0;
12
+ for (const name of toolNames) {
13
+ if (name !== TOOL_DISCOVERY_SEARCH_TOOL_NAME) count++;
14
+ }
15
+ return count;
16
+ }
17
+
18
+ export function resolveEffectiveToolDiscoveryMode(settings: Settings, toolCount: number): EffectiveToolDiscoveryMode {
19
+ const configuredMode = settings.get("tools.discoveryMode");
20
+ if (configuredMode === "all" || configuredMode === "mcp-only") return configuredMode;
21
+ if (settings.get("mcp.discoveryMode")) return "mcp-only";
22
+ if (configuredMode === "auto" && toolCount > TOOL_DISCOVERY_AUTO_THRESHOLD) return "mcp-only";
23
+ return "off";
24
+ }
@@ -1,10 +1,6 @@
1
- import { ToolError } from "./tool-errors";
1
+ import { inflateSync, strFromU8 } from "fflate";
2
2
 
3
- let fflateModulePromise: Promise<typeof import("fflate")> | undefined;
4
- async function loadFflate(): Promise<typeof import("fflate")> {
5
- if (!fflateModulePromise) fflateModulePromise = import("fflate");
6
- return fflateModulePromise;
7
- }
3
+ import { ToolError } from "./tool-errors";
8
4
 
9
5
  export type ArchiveFormat = "zip" | "tar" | "tar.gz";
10
6
 
@@ -35,7 +31,11 @@ interface TarStorage {
35
31
 
36
32
  interface ZipStorage {
37
33
  type: "zip";
38
- bytes: Uint8Array;
34
+ archivePath: string;
35
+ compressedSize: number;
36
+ compression: number;
37
+ flags: number;
38
+ localHeaderOffset: number;
39
39
  }
40
40
 
41
41
  type EntryStorage = TarStorage | ZipStorage;
@@ -123,6 +123,321 @@ function getArchiveFormatFromPath(filePath: string): ArchiveFormat | undefined {
123
123
  return undefined;
124
124
  }
125
125
 
126
+ const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
127
+ const ZIP_CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50;
128
+ const ZIP64_EOCD_SIGNATURE = 0x06064b50;
129
+ const ZIP64_EOCD_LOCATOR_SIGNATURE = 0x07064b50;
130
+ const ZIP_EOCD_SIGNATURE = 0x06054b50;
131
+ const ZIP_EOCD_MIN_LENGTH = 22;
132
+ const ZIP_EOCD_MAX_COMMENT_LENGTH = 0xffff;
133
+ const ZIP64_EOCD_LOCATOR_LENGTH = 20;
134
+ const ZIP_STORED_COMPRESSION = 0;
135
+ const ZIP_DEFLATE_COMPRESSION = 8;
136
+ const ZIP_UTF8_FLAG = 0x0800;
137
+ const ZIP_ENCRYPTED_FLAG = 0x0001;
138
+ const ZIP_UINT16_MAX = 0xffff;
139
+ const ZIP_UINT32_MAX = 0xffffffff;
140
+ const ZIP_UINT32_RANGE = 0x100000000;
141
+
142
+ interface ZipCentralDirectoryInfo {
143
+ entries: number;
144
+ offset: number;
145
+ size: number;
146
+ }
147
+
148
+ interface Zip64EntryValues {
149
+ compressedSize: number;
150
+ uncompressedSize: number;
151
+ localHeaderOffset: number;
152
+ diskStart: number;
153
+ }
154
+
155
+ interface Zip64EntryPlaceholders {
156
+ compressedSize: boolean;
157
+ uncompressedSize: boolean;
158
+ localHeaderOffset: boolean;
159
+ diskStart: boolean;
160
+ }
161
+
162
+ function readUInt16LE(bytes: Uint8Array, offset: number): number {
163
+ return bytes[offset]! | (bytes[offset + 1]! << 8);
164
+ }
165
+
166
+ function readUInt32LE(bytes: Uint8Array, offset: number): number {
167
+ return (bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16) | (bytes[offset + 3]! << 24)) >>> 0;
168
+ }
169
+
170
+ function readUInt64LEAsNumber(bytes: Uint8Array, offset: number): number {
171
+ const value = readUInt32LE(bytes, offset) + readUInt32LE(bytes, offset + 4) * ZIP_UINT32_RANGE;
172
+ if (!Number.isSafeInteger(value)) {
173
+ throw new ToolError("ZIP archive uses offsets or sizes too large to read safely");
174
+ }
175
+ return value;
176
+ }
177
+
178
+ async function readZipRange(filePath: string, start: number, end: number): Promise<Uint8Array> {
179
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start) {
180
+ throw new ToolError("Invalid ZIP archive range");
181
+ }
182
+
183
+ const bytes = await Bun.file(filePath).slice(start, end).bytes();
184
+ if (bytes.byteLength !== end - start) {
185
+ throw new ToolError("Invalid ZIP archive: truncated data");
186
+ }
187
+ return bytes;
188
+ }
189
+
190
+ function findEndOfCentralDirectory(tail: Uint8Array): number {
191
+ for (let offset = tail.byteLength - ZIP_EOCD_MIN_LENGTH; offset >= 0; offset--) {
192
+ if (readUInt32LE(tail, offset) !== ZIP_EOCD_SIGNATURE) continue;
193
+ const commentLength = readUInt16LE(tail, offset + 20);
194
+ if (offset + ZIP_EOCD_MIN_LENGTH + commentLength === tail.byteLength) return offset;
195
+ }
196
+
197
+ throw new ToolError("Invalid ZIP archive: missing end of central directory");
198
+ }
199
+
200
+ async function readZip64CentralDirectoryInfo(
201
+ filePath: string,
202
+ tail: Uint8Array,
203
+ tailStart: number,
204
+ eocdOffset: number,
205
+ ): Promise<ZipCentralDirectoryInfo | undefined> {
206
+ const locatorOffset = eocdOffset - ZIP64_EOCD_LOCATOR_LENGTH;
207
+ if (locatorOffset < 0) return undefined;
208
+
209
+ const locator =
210
+ locatorOffset >= tailStart
211
+ ? tail.subarray(locatorOffset - tailStart, locatorOffset - tailStart + ZIP64_EOCD_LOCATOR_LENGTH)
212
+ : await readZipRange(filePath, locatorOffset, eocdOffset);
213
+ if (readUInt32LE(locator, 0) !== ZIP64_EOCD_LOCATOR_SIGNATURE) return undefined;
214
+
215
+ const zip64EocdDisk = readUInt32LE(locator, 4);
216
+ const zip64EocdOffset = readUInt64LEAsNumber(locator, 8);
217
+ const totalDisks = readUInt32LE(locator, 16);
218
+ if (zip64EocdDisk !== 0 || totalDisks > 1) {
219
+ throw new ToolError("Multi-disk ZIP archives are not supported");
220
+ }
221
+
222
+ const record = await readZipRange(filePath, zip64EocdOffset, zip64EocdOffset + 56);
223
+ if (readUInt32LE(record, 0) !== ZIP64_EOCD_SIGNATURE) {
224
+ throw new ToolError("Invalid ZIP archive: missing ZIP64 end of central directory");
225
+ }
226
+ if (readUInt32LE(record, 16) !== 0 || readUInt32LE(record, 20) !== 0) {
227
+ throw new ToolError("Multi-disk ZIP archives are not supported");
228
+ }
229
+
230
+ return {
231
+ entries: readUInt64LEAsNumber(record, 32),
232
+ size: readUInt64LEAsNumber(record, 40),
233
+ offset: readUInt64LEAsNumber(record, 48),
234
+ };
235
+ }
236
+
237
+ async function readZipCentralDirectoryInfo(filePath: string, fileSize: number): Promise<ZipCentralDirectoryInfo> {
238
+ if (fileSize < ZIP_EOCD_MIN_LENGTH) {
239
+ throw new ToolError("Invalid ZIP archive: missing end of central directory");
240
+ }
241
+
242
+ const tailLength = Math.min(fileSize, ZIP_EOCD_MIN_LENGTH + ZIP_EOCD_MAX_COMMENT_LENGTH);
243
+ const tailStart = fileSize - tailLength;
244
+ const tail = await readZipRange(filePath, tailStart, fileSize);
245
+ const eocdIndex = findEndOfCentralDirectory(tail);
246
+ const eocdOffset = tailStart + eocdIndex;
247
+
248
+ if (readUInt16LE(tail, eocdIndex + 4) !== 0 || readUInt16LE(tail, eocdIndex + 6) !== 0) {
249
+ throw new ToolError("Multi-disk ZIP archives are not supported");
250
+ }
251
+
252
+ let entries = readUInt16LE(tail, eocdIndex + 10);
253
+ let size = readUInt32LE(tail, eocdIndex + 12);
254
+ let offset = readUInt32LE(tail, eocdIndex + 16);
255
+ const needsZip64 = entries === ZIP_UINT16_MAX || size === ZIP_UINT32_MAX || offset === ZIP_UINT32_MAX;
256
+ const zip64Info = await readZip64CentralDirectoryInfo(filePath, tail, tailStart, eocdOffset);
257
+ if (zip64Info) {
258
+ ({ entries, size, offset } = zip64Info);
259
+ } else if (needsZip64) {
260
+ throw new ToolError("Invalid ZIP archive: missing ZIP64 central directory metadata");
261
+ }
262
+
263
+ if (offset + size > fileSize) {
264
+ throw new ToolError("Invalid ZIP archive: central directory exceeds file size");
265
+ }
266
+
267
+ return { entries, offset, size };
268
+ }
269
+
270
+ function readZip64EntryValues(
271
+ extra: Uint8Array,
272
+ placeholders: Zip64EntryPlaceholders,
273
+ current: Zip64EntryValues,
274
+ ): Zip64EntryValues {
275
+ if (
276
+ !placeholders.compressedSize &&
277
+ !placeholders.uncompressedSize &&
278
+ !placeholders.localHeaderOffset &&
279
+ !placeholders.diskStart
280
+ ) {
281
+ return current;
282
+ }
283
+
284
+ let offset = 0;
285
+ while (offset + 4 <= extra.byteLength) {
286
+ const headerId = readUInt16LE(extra, offset);
287
+ const dataSize = readUInt16LE(extra, offset + 2);
288
+ const dataStart = offset + 4;
289
+ const dataEnd = dataStart + dataSize;
290
+ if (dataEnd > extra.byteLength) {
291
+ throw new ToolError("Invalid ZIP archive: malformed extra field");
292
+ }
293
+
294
+ if (headerId === 0x0001) {
295
+ let cursor = dataStart;
296
+ let uncompressedSize = current.uncompressedSize;
297
+ let compressedSize = current.compressedSize;
298
+ let localHeaderOffset = current.localHeaderOffset;
299
+ let diskStart = current.diskStart;
300
+
301
+ if (placeholders.uncompressedSize) {
302
+ if (cursor + 8 > dataEnd) throw new ToolError("Invalid ZIP archive: malformed ZIP64 extra field");
303
+ uncompressedSize = readUInt64LEAsNumber(extra, cursor);
304
+ cursor += 8;
305
+ }
306
+ if (placeholders.compressedSize) {
307
+ if (cursor + 8 > dataEnd) throw new ToolError("Invalid ZIP archive: malformed ZIP64 extra field");
308
+ compressedSize = readUInt64LEAsNumber(extra, cursor);
309
+ cursor += 8;
310
+ }
311
+ if (placeholders.localHeaderOffset) {
312
+ if (cursor + 8 > dataEnd) throw new ToolError("Invalid ZIP archive: malformed ZIP64 extra field");
313
+ localHeaderOffset = readUInt64LEAsNumber(extra, cursor);
314
+ cursor += 8;
315
+ }
316
+ if (placeholders.diskStart) {
317
+ if (cursor + 4 > dataEnd) throw new ToolError("Invalid ZIP archive: malformed ZIP64 extra field");
318
+ diskStart = readUInt32LE(extra, cursor);
319
+ }
320
+
321
+ return { compressedSize, uncompressedSize, localHeaderOffset, diskStart };
322
+ }
323
+
324
+ offset = dataEnd;
325
+ }
326
+
327
+ throw new ToolError("Invalid ZIP archive: missing ZIP64 extra field");
328
+ }
329
+
330
+ function parseZipCentralDirectory(
331
+ filePath: string,
332
+ centralDirectory: Uint8Array,
333
+ expectedEntries: number,
334
+ ): ArchiveIndexEntry[] {
335
+ const entries: ArchiveIndexEntry[] = [];
336
+ let offset = 0;
337
+
338
+ for (let index = 0; index < expectedEntries; index++) {
339
+ if (offset + 46 > centralDirectory.byteLength) {
340
+ throw new ToolError("Invalid ZIP archive: truncated central directory");
341
+ }
342
+ if (readUInt32LE(centralDirectory, offset) !== ZIP_CENTRAL_DIRECTORY_HEADER_SIGNATURE) {
343
+ throw new ToolError("Invalid ZIP archive: malformed central directory");
344
+ }
345
+
346
+ const flags = readUInt16LE(centralDirectory, offset + 8);
347
+ const compression = readUInt16LE(centralDirectory, offset + 10);
348
+ const compressedSizeRaw = readUInt32LE(centralDirectory, offset + 20);
349
+ const uncompressedSizeRaw = readUInt32LE(centralDirectory, offset + 24);
350
+ const fileNameLength = readUInt16LE(centralDirectory, offset + 28);
351
+ const extraLength = readUInt16LE(centralDirectory, offset + 30);
352
+ const commentLength = readUInt16LE(centralDirectory, offset + 32);
353
+ const diskStartRaw = readUInt16LE(centralDirectory, offset + 34);
354
+ const localHeaderOffsetRaw = readUInt32LE(centralDirectory, offset + 42);
355
+ const nameStart = offset + 46;
356
+ const extraStart = nameStart + fileNameLength;
357
+ const entryEnd = extraStart + extraLength + commentLength;
358
+ if (entryEnd > centralDirectory.byteLength) {
359
+ throw new ToolError("Invalid ZIP archive: truncated central directory entry");
360
+ }
361
+
362
+ const rawPath = strFromU8(centralDirectory.subarray(nameStart, extraStart), (flags & ZIP_UTF8_FLAG) === 0);
363
+ const normalizedPath = normalizeArchiveEntryPath(rawPath);
364
+ if (normalizedPath) {
365
+ const values = readZip64EntryValues(
366
+ centralDirectory.subarray(extraStart, extraStart + extraLength),
367
+ {
368
+ compressedSize: compressedSizeRaw === ZIP_UINT32_MAX,
369
+ uncompressedSize: uncompressedSizeRaw === ZIP_UINT32_MAX,
370
+ localHeaderOffset: localHeaderOffsetRaw === ZIP_UINT32_MAX,
371
+ diskStart: diskStartRaw === ZIP_UINT16_MAX,
372
+ },
373
+ {
374
+ compressedSize: compressedSizeRaw,
375
+ uncompressedSize: uncompressedSizeRaw,
376
+ localHeaderOffset: localHeaderOffsetRaw,
377
+ diskStart: diskStartRaw,
378
+ },
379
+ );
380
+ if (values.diskStart !== 0) {
381
+ throw new ToolError("Multi-disk ZIP archives are not supported");
382
+ }
383
+
384
+ const isDirectory = isArchiveDirectoryName(rawPath);
385
+ entries.push({
386
+ path: normalizedPath,
387
+ isDirectory,
388
+ size: isDirectory ? 0 : values.uncompressedSize,
389
+ storage: isDirectory
390
+ ? undefined
391
+ : {
392
+ type: "zip",
393
+ archivePath: filePath,
394
+ compressedSize: values.compressedSize,
395
+ compression,
396
+ flags,
397
+ localHeaderOffset: values.localHeaderOffset,
398
+ },
399
+ });
400
+ }
401
+
402
+ offset = entryEnd;
403
+ }
404
+
405
+ return entries;
406
+ }
407
+
408
+ async function readZipFileBytes(storage: ZipStorage, uncompressedSize: number): Promise<Uint8Array> {
409
+ if ((storage.flags & ZIP_ENCRYPTED_FLAG) !== 0) {
410
+ throw new ToolError("Encrypted ZIP entries are not supported");
411
+ }
412
+
413
+ const localHeader = await readZipRange(
414
+ storage.archivePath,
415
+ storage.localHeaderOffset,
416
+ storage.localHeaderOffset + 30,
417
+ );
418
+ if (readUInt32LE(localHeader, 0) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) {
419
+ throw new ToolError("Invalid ZIP archive: malformed local file header");
420
+ }
421
+
422
+ const fileNameLength = readUInt16LE(localHeader, 26);
423
+ const extraLength = readUInt16LE(localHeader, 28);
424
+ const dataStart = storage.localHeaderOffset + 30 + fileNameLength + extraLength;
425
+ const compressedBytes = await readZipRange(storage.archivePath, dataStart, dataStart + storage.compressedSize);
426
+
427
+ if (storage.compression === ZIP_STORED_COMPRESSION) {
428
+ return compressedBytes;
429
+ }
430
+ if (storage.compression !== ZIP_DEFLATE_COMPRESSION) {
431
+ throw new ToolError(`Unsupported ZIP compression method: ${storage.compression}`);
432
+ }
433
+
434
+ try {
435
+ return inflateSync(compressedBytes, { out: new Uint8Array(uncompressedSize) });
436
+ } catch (error) {
437
+ throw new ToolError(error instanceof Error ? error.message : String(error));
438
+ }
439
+ }
440
+
126
441
  async function readTarEntries(bytes: Uint8Array): Promise<ArchiveIndexEntry[]> {
127
442
  let archive: Bun.Archive;
128
443
  try {
@@ -155,29 +470,19 @@ async function readTarEntries(bytes: Uint8Array): Promise<ArchiveIndexEntry[]> {
155
470
  return entries;
156
471
  }
157
472
 
158
- async function readZipEntries(bytes: Uint8Array): Promise<ArchiveIndexEntry[]> {
159
- const { unzipSync } = await loadFflate();
160
- let files: Record<string, Uint8Array>;
161
- try {
162
- files = unzipSync(bytes);
163
- } catch (error) {
164
- throw new ToolError(error instanceof Error ? error.message : String(error));
473
+ async function readZipEntries(filePath: string): Promise<ArchiveIndexEntry[]> {
474
+ const fileSize = Bun.file(filePath).size;
475
+ if (!Number.isSafeInteger(fileSize)) {
476
+ throw new ToolError("ZIP archive is too large to read safely");
165
477
  }
166
478
 
167
- const entries: ArchiveIndexEntry[] = [];
168
- for (const [rawPath, fileBytes] of Object.entries(files)) {
169
- const normalizedPath = normalizeArchiveEntryPath(rawPath);
170
- if (!normalizedPath) continue;
171
- const isDirectory = isArchiveDirectoryName(rawPath);
172
- entries.push({
173
- path: normalizedPath,
174
- isDirectory,
175
- size: isDirectory ? 0 : fileBytes.byteLength,
176
- storage: isDirectory ? undefined : { type: "zip", bytes: fileBytes },
177
- });
178
- }
179
-
180
- return entries;
479
+ const directoryInfo = await readZipCentralDirectoryInfo(filePath, fileSize);
480
+ const centralDirectory = await readZipRange(
481
+ filePath,
482
+ directoryInfo.offset,
483
+ directoryInfo.offset + directoryInfo.size,
484
+ );
485
+ return parseZipCentralDirectory(filePath, centralDirectory, directoryInfo.entries);
181
486
  }
182
487
 
183
488
  export function parseArchivePathCandidates(filePath: string): ArchivePathCandidate[] {
@@ -297,7 +602,10 @@ export class ArchiveReader {
297
602
  throw new ToolError(`Archive file '${normalizedPath}' has no readable storage`);
298
603
  }
299
604
 
300
- const bytes = entry.storage.type === "tar" ? await entry.storage.file.bytes() : entry.storage.bytes;
605
+ const bytes =
606
+ entry.storage.type === "tar"
607
+ ? await entry.storage.file.bytes()
608
+ : await readZipFileBytes(entry.storage, entry.size);
301
609
 
302
610
  return {
303
611
  path: entry.path,
@@ -315,7 +623,7 @@ export async function openArchive(filePath: string): Promise<ArchiveReader> {
315
623
  throw new ToolError(`Unsupported archive format: ${filePath}`);
316
624
  }
317
625
 
318
- const bytes = await Bun.file(filePath).bytes();
319
- const entries = format === "zip" ? await readZipEntries(bytes) : await readTarEntries(bytes);
626
+ const entries =
627
+ format === "zip" ? await readZipEntries(filePath) : await readTarEntries(await Bun.file(filePath).bytes());
320
628
  return new ArchiveReader(format, entries);
321
629
  }
package/src/tools/bash.ts CHANGED
@@ -20,7 +20,7 @@ import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
20
20
  import type { ClientBridgeTerminalExitStatus, ClientBridgeTerminalOutput } from "../session/client-bridge";
21
21
  import { DEFAULT_MAX_BYTES, streamTailUpdates, TailBuffer } from "../session/streaming-output";
22
22
  import { renderStatusLine } from "../tui";
23
- import { CachedOutputBlock } from "../tui/output-block";
23
+ import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block";
24
24
  import { getSixelLineMask } from "../utils/sixel";
25
25
  import type { ToolSession } from ".";
26
26
  import { truncateForPrompt } from "./approval";
@@ -31,7 +31,7 @@ import { canUseInteractiveBashPty } from "./bash-pty-selection";
31
31
  import { expandInternalUrls, type InternalUrlExpansionOptions } from "./bash-skill-urls";
32
32
  import { formatStyledTruncationWarning, type OutputMeta, stripOutputNotice } from "./output-meta";
33
33
  import { resolveToCwd } from "./path-utils";
34
- import { formatToolWorkingDirectory, replaceTabs } from "./render-utils";
34
+ import { capPreviewLines, formatToolWorkingDirectory, replaceTabs } from "./render-utils";
35
35
  import { ToolAbortError, ToolError } from "./tool-errors";
36
36
  import { toolResult } from "./tool-result";
37
37
  import { clampTimeout, TOOL_TIMEOUTS } from "./tool-timeouts";
@@ -1083,16 +1083,22 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1083
1083
  const cmdLines = formatBashCommandLines(renderArgs, uiTheme);
1084
1084
  const header = renderStatusLine({ icon: "pending", title }, uiTheme);
1085
1085
  const outputBlock = new CachedOutputBlock();
1086
- return {
1086
+ return markFramedBlockComponent({
1087
1087
  render: (width: number): string[] =>
1088
1088
  outputBlock.render(
1089
- { header, state: "pending", sections: [{ lines: cmdLines }], width, animate: true },
1089
+ {
1090
+ header,
1091
+ state: "pending",
1092
+ sections: [{ lines: capPreviewLines(cmdLines, uiTheme, { expanded: options.expanded }) }],
1093
+ width,
1094
+ animate: true,
1095
+ },
1090
1096
  uiTheme,
1091
1097
  ),
1092
1098
  invalidate: () => {
1093
1099
  outputBlock.invalidate();
1094
1100
  },
1095
- };
1101
+ });
1096
1102
  },
1097
1103
 
1098
1104
  renderResult(
@@ -1114,7 +1120,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1114
1120
  const details = result.details;
1115
1121
  const outputBlock = new CachedOutputBlock();
1116
1122
 
1117
- return {
1123
+ return markFramedBlockComponent({
1118
1124
  render: (width: number): string[] => {
1119
1125
  // REACTIVE: read mutable options at render time
1120
1126
  const { renderContext } = options;
@@ -1201,7 +1207,11 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1201
1207
  header,
1202
1208
  state: options.isPartial ? "pending" : isError ? "error" : "success",
1203
1209
  sections: [
1204
- { lines: cmdLines ?? [] },
1210
+ {
1211
+ lines: options.isPartial
1212
+ ? capPreviewLines(cmdLines ?? [], uiTheme, { expanded })
1213
+ : (cmdLines ?? []),
1214
+ },
1205
1215
  { label: uiTheme.fg("toolTitle", "Output"), lines: outputLines },
1206
1216
  ],
1207
1217
  width,
@@ -1213,7 +1223,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1213
1223
  invalidate: () => {
1214
1224
  outputBlock.invalidate();
1215
1225
  },
1216
- };
1226
+ });
1217
1227
  },
1218
1228
  mergeCallAndResult: true,
1219
1229
  inline: true,
@@ -9,7 +9,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
9
9
  import { Text } from "@oh-my-pi/pi-tui";
10
10
  import type { RenderResultOptions } from "../../extensibility/custom-tools/types";
11
11
  import type { Theme } from "../../modes/theme/theme";
12
- import { Hasher, renderCodeCell, renderStatusLine } from "../../tui";
12
+ import { Hasher, isFramedBlockComponent, markFramedBlockComponent, renderCodeCell, renderStatusLine } from "../../tui";
13
13
  import type { BrowserToolDetails } from "../browser";
14
14
  import { formatStyledTruncationWarning, stripOutputNotice } from "../output-meta";
15
15
  import { replaceTabs, shortenPath } from "../render-utils";
@@ -65,13 +65,14 @@ function dropTrailingBlankLines(text: string): string {
65
65
 
66
66
  function appendLine(component: Component, line: string | undefined): Component {
67
67
  if (!line) return component;
68
- return {
68
+ const wrapped = {
69
69
  render: (width: number): string[] => {
70
70
  const base = component.render(width);
71
71
  return [...base, line];
72
72
  },
73
73
  invalidate: () => component.invalidate?.(),
74
74
  };
75
+ return isFramedBlockComponent(component) ? markFramedBlockComponent(wrapped) : wrapped;
75
76
  }
76
77
 
77
78
  function renderRunCell(
@@ -93,7 +94,7 @@ function renderRunCell(
93
94
  const title = titleParts.join(" · ");
94
95
 
95
96
  let cached: { key: bigint; width: number; lines: string[] } | undefined;
96
- return {
97
+ return markFramedBlockComponent({
97
98
  render: (width: number): string[] => {
98
99
  const expanded = options.renderContext?.expanded ?? options.expanded;
99
100
  const previewLines = options.renderContext?.previewLines ?? BROWSER_DEFAULT_PREVIEW_LINES;
@@ -131,7 +132,7 @@ function renderRunCell(
131
132
  invalidate: () => {
132
133
  cached = undefined;
133
134
  },
134
- };
135
+ });
135
136
  }
136
137
 
137
138
  function renderOpenOrCloseLine(
@@ -36,7 +36,7 @@ import {
36
36
  import type { Theme } from "../modes/theme/theme";
37
37
  import debugDescription from "../prompts/tools/debug.md" with { type: "text" };
38
38
  import { renderStatusLine } from "../tui";
39
- import { CachedOutputBlock } from "../tui/output-block";
39
+ import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block";
40
40
  import type { ToolSession } from ".";
41
41
  import { truncateForPrompt } from "./approval";
42
42
  import type { OutputMeta } from "./output-meta";
@@ -581,7 +581,7 @@ export const debugToolRenderer = {
581
581
  args?: DebugRenderArgs,
582
582
  ): Component {
583
583
  const outputBlock = new CachedOutputBlock();
584
- return {
584
+ return markFramedBlockComponent({
585
585
  render(width: number): string[] {
586
586
  const action = (args?.action ?? result.details?.action ?? "debug").replaceAll("_", " ");
587
587
  const status = options.isPartial ? "running" : result.isError ? "error" : "success";
@@ -620,7 +620,7 @@ export const debugToolRenderer = {
620
620
  invalidate() {
621
621
  outputBlock.invalidate();
622
622
  },
623
- };
623
+ });
624
624
  },
625
625
  mergeCallAndResult: true,
626
626
  inline: true,
@@ -18,7 +18,7 @@ import { formatContextUsage } from "../modes/components/status-line/context-thre
18
18
  import { truncateToVisualLines } from "../modes/components/visual-truncate";
19
19
  import { shimmerEnabled } from "../modes/theme/shimmer";
20
20
  import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
21
- import { borderShimmerTick, renderCodeCell } from "../tui";
21
+ import { borderShimmerTick, markFramedBlockComponent, renderCodeCell } from "../tui";
22
22
  import {
23
23
  JSON_TREE_MAX_DEPTH_COLLAPSED,
24
24
  JSON_TREE_MAX_DEPTH_EXPANDED,
@@ -39,8 +39,15 @@ import {
39
39
  truncateToWidth,
40
40
  wrapBrackets,
41
41
  } from "./render-utils";
42
-
43
42
  export const EVAL_DEFAULT_PREVIEW_LINES = 10;
43
+ /**
44
+ * Rows of source kept in the *pending* eval preview. The window follows the
45
+ * streaming edge (newest lines pinned to the bottom) so you can watch the code
46
+ * being written, while staying bounded — a volatile tool block taller than the
47
+ * viewport would otherwise strand its scrolled-off head out of native scrollback
48
+ * on ED3-risk terminals. Matches the streaming windows used by edit/write.
49
+ */
50
+ export const EVAL_STREAMING_PREVIEW_LINES = 12;
44
51
 
45
52
  function languageForHighlighter(language: EvalLanguage | undefined): "python" | "javascript" {
46
53
  return language === "js" ? "javascript" : "python";
@@ -490,10 +497,10 @@ export const evalToolRenderer = {
490
497
 
491
498
  let cached: { key: string; width: number; result: string[] } | undefined;
492
499
 
493
- return {
500
+ return markFramedBlockComponent({
494
501
  render: (width: number): string[] => {
495
502
  const animate = options.isPartial && shimmerEnabled();
496
- const key = `${animate ? borderShimmerTick() : 0}|${cells.map(c => `${c.language}:${c.title ?? ""}:${c.code.length}`).join("|")}`;
503
+ const key = `${animate ? borderShimmerTick() : 0}|${options.expanded ? 1 : 0}|${cells.map(c => `${c.language}:${c.title ?? ""}:${c.code.length}`).join("|")}`;
497
504
  if (cached && cached.key === key && cached.width === width) {
498
505
  return cached.result;
499
506
  }
@@ -510,8 +517,16 @@ export const evalToolRenderer = {
510
517
  title: cell.title,
511
518
  status: "pending",
512
519
  width,
513
- codeMaxLines: EVAL_DEFAULT_PREVIEW_LINES,
514
- expanded: true,
520
+ codeMaxLines: EVAL_STREAMING_PREVIEW_LINES,
521
+ // Follow the streaming edge with a bounded tail window so the
522
+ // newest source stays visible as it is written, instead of
523
+ // rendering every line of a >100-line `code` — which would
524
+ // overflow the viewport and, because a tool block is volatile
525
+ // (it collapses to a capped result), strand its scrolled-off head
526
+ // out of native scrollback, cutting the box top. `Ctrl+O` lifts
527
+ // the window via `expanded` for a deliberate full view.
528
+ codeTail: true,
529
+ expanded: options.expanded,
515
530
  animate,
516
531
  },
517
532
  uiTheme,
@@ -527,7 +542,7 @@ export const evalToolRenderer = {
527
542
  invalidate: () => {
528
543
  cached = undefined;
529
544
  },
530
- };
545
+ });
531
546
  },
532
547
 
533
548
  renderResult(
@@ -571,7 +586,7 @@ export const evalToolRenderer = {
571
586
  if (cellResults && cellResults.length > 0) {
572
587
  let cached: { key: string; width: number; result: string[] } | undefined;
573
588
 
574
- return {
589
+ return markFramedBlockComponent({
575
590
  render: (width: number): string[] => {
576
591
  const expanded = options.renderContext?.expanded ?? options.expanded;
577
592
  const previewLines = options.renderContext?.previewLines ?? EVAL_DEFAULT_PREVIEW_LINES;
@@ -649,7 +664,7 @@ export const evalToolRenderer = {
649
664
  invalidate: () => {
650
665
  cached = undefined;
651
666
  },
652
- };
667
+ });
653
668
  }
654
669
 
655
670
  const displayOutput = output;