@oh-my-pi/pi-coding-agent 15.10.0 → 15.10.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.
Files changed (176) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/dist/types/cli/dry-balance-cli.d.ts +15 -1
  3. package/dist/types/commit/analysis/conventional.d.ts +2 -2
  4. package/dist/types/commit/analysis/summary.d.ts +2 -2
  5. package/dist/types/commit/changelog/generate.d.ts +2 -2
  6. package/dist/types/commit/changelog/index.d.ts +2 -2
  7. package/dist/types/commit/map-reduce/index.d.ts +3 -3
  8. package/dist/types/commit/map-reduce/map-phase.d.ts +2 -2
  9. package/dist/types/commit/map-reduce/reduce-phase.d.ts +2 -2
  10. package/dist/types/commit/model-selection.d.ts +10 -4
  11. package/dist/types/config/api-key-resolver.d.ts +34 -0
  12. package/dist/types/config/model-registry.d.ts +17 -1
  13. package/dist/types/config/settings-schema.d.ts +9 -0
  14. package/dist/types/dap/config.d.ts +14 -1
  15. package/dist/types/dap/types.d.ts +10 -0
  16. package/dist/types/lsp/utils.d.ts +3 -2
  17. package/dist/types/modes/components/chat-block.d.ts +64 -0
  18. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  19. package/dist/types/modes/components/overlay-box.d.ts +17 -0
  20. package/dist/types/modes/components/plan-review-overlay.d.ts +59 -0
  21. package/dist/types/modes/components/plan-toc.d.ts +41 -0
  22. package/dist/types/modes/components/read-tool-group.d.ts +2 -0
  23. package/dist/types/modes/components/transcript-container.d.ts +11 -0
  24. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  25. package/dist/types/modes/controllers/event-controller.d.ts +0 -1
  26. package/dist/types/modes/controllers/extension-ui-controller.d.ts +0 -1
  27. package/dist/types/modes/controllers/input-controller.d.ts +1 -1
  28. package/dist/types/modes/controllers/streaming-reveal.d.ts +22 -0
  29. package/dist/types/modes/controllers/tan-command-controller.d.ts +6 -0
  30. package/dist/types/modes/interactive-mode.d.ts +15 -5
  31. package/dist/types/modes/theme/theme.d.ts +1 -1
  32. package/dist/types/modes/types.d.ts +18 -5
  33. package/dist/types/modes/utils/copy-targets.d.ts +21 -1
  34. package/dist/types/plan-mode/approved-plan.d.ts +27 -8
  35. package/dist/types/plan-mode/plan-protection.d.ts +4 -4
  36. package/dist/types/sdk.d.ts +2 -0
  37. package/dist/types/session/agent-session.d.ts +21 -0
  38. package/dist/types/session/messages.d.ts +12 -0
  39. package/dist/types/session/session-manager.d.ts +3 -1
  40. package/dist/types/slash-commands/types.d.ts +4 -6
  41. package/dist/types/task/executor.d.ts +7 -0
  42. package/dist/types/task/index.d.ts +1 -0
  43. package/dist/types/task/render.d.ts +3 -2
  44. package/dist/types/tools/archive-reader.d.ts +5 -0
  45. package/dist/types/tools/ast-edit.d.ts +3 -0
  46. package/dist/types/tools/ast-grep.d.ts +3 -0
  47. package/dist/types/tools/bash.d.ts +1 -0
  48. package/dist/types/tools/find.d.ts +8 -4
  49. package/dist/types/tools/grouped-file-output.d.ts +95 -12
  50. package/dist/types/tools/memory-render.d.ts +4 -1
  51. package/dist/types/tools/plan-mode-guard.d.ts +8 -9
  52. package/dist/types/tools/render-utils.d.ts +5 -9
  53. package/dist/types/tools/search.d.ts +4 -0
  54. package/dist/types/tools/sqlite-reader.d.ts +1 -0
  55. package/dist/types/tools/todo.d.ts +3 -2
  56. package/dist/types/tools/write.d.ts +3 -0
  57. package/dist/types/tui/output-block.d.ts +16 -4
  58. package/dist/types/tui/status-line.d.ts +3 -0
  59. package/dist/types/utils/enhanced-paste.d.ts +20 -0
  60. package/dist/types/web/search/providers/kimi.d.ts +1 -1
  61. package/package.json +9 -9
  62. package/src/auto-thinking/classifier.ts +5 -1
  63. package/src/cli/dry-balance-cli.ts +52 -17
  64. package/src/cli/gallery-cli.ts +4 -1
  65. package/src/cli/gallery-fixtures/misc.ts +29 -0
  66. package/src/commit/analysis/conventional.ts +2 -2
  67. package/src/commit/analysis/summary.ts +2 -2
  68. package/src/commit/changelog/generate.ts +2 -2
  69. package/src/commit/changelog/index.ts +2 -2
  70. package/src/commit/map-reduce/index.ts +3 -3
  71. package/src/commit/map-reduce/map-phase.ts +2 -2
  72. package/src/commit/map-reduce/reduce-phase.ts +2 -2
  73. package/src/commit/model-selection.ts +33 -9
  74. package/src/commit/pipeline.ts +4 -4
  75. package/src/config/api-key-resolver.ts +58 -0
  76. package/src/config/model-registry.ts +25 -2
  77. package/src/config/settings-schema.ts +10 -0
  78. package/src/config/settings.ts +20 -2
  79. package/src/dap/config.ts +41 -2
  80. package/src/dap/defaults.json +1 -0
  81. package/src/dap/session.ts +1 -0
  82. package/src/dap/types.ts +10 -0
  83. package/src/debug/index.ts +40 -54
  84. package/src/edit/renderer.ts +82 -78
  85. package/src/eval/__tests__/llm-bridge.test.ts +90 -31
  86. package/src/eval/llm-bridge.ts +8 -3
  87. package/src/goals/tools/goal-tool.ts +36 -26
  88. package/src/internal-urls/docs-index.generated.ts +6 -6
  89. package/src/lsp/utils.ts +3 -2
  90. package/src/main.ts +9 -7
  91. package/src/memories/index.ts +12 -5
  92. package/src/mnemopi/backend.ts +5 -1
  93. package/src/modes/acp/acp-agent.ts +33 -26
  94. package/src/modes/components/assistant-message.ts +2 -9
  95. package/src/modes/components/chat-block.ts +111 -0
  96. package/src/modes/components/copy-selector.ts +1 -44
  97. package/src/modes/components/custom-editor.ts +23 -0
  98. package/src/modes/components/custom-message.ts +1 -3
  99. package/src/modes/components/execution-shared.ts +1 -2
  100. package/src/modes/components/hook-message.ts +1 -3
  101. package/src/modes/components/overlay-box.ts +108 -0
  102. package/src/modes/components/plan-review-overlay.ts +799 -0
  103. package/src/modes/components/plan-toc.ts +138 -0
  104. package/src/modes/components/read-tool-group.ts +20 -4
  105. package/src/modes/components/skill-message.ts +0 -1
  106. package/src/modes/components/tips.txt +1 -0
  107. package/src/modes/components/todo-reminder.ts +0 -2
  108. package/src/modes/components/tool-execution.ts +68 -88
  109. package/src/modes/components/transcript-container.ts +84 -24
  110. package/src/modes/components/user-message.ts +1 -2
  111. package/src/modes/controllers/command-controller-shared.ts +7 -6
  112. package/src/modes/controllers/command-controller.ts +57 -55
  113. package/src/modes/controllers/event-controller.ts +41 -40
  114. package/src/modes/controllers/extension-ui-controller.ts +10 -73
  115. package/src/modes/controllers/input-controller.ts +124 -119
  116. package/src/modes/controllers/mcp-command-controller.ts +69 -60
  117. package/src/modes/controllers/selector-controller.ts +23 -25
  118. package/src/modes/controllers/streaming-reveal.ts +212 -0
  119. package/src/modes/controllers/tan-command-controller.ts +173 -0
  120. package/src/modes/interactive-mode.ts +169 -94
  121. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  122. package/src/modes/theme/theme-schema.json +1 -1
  123. package/src/modes/theme/theme.ts +8 -4
  124. package/src/modes/types.ts +18 -7
  125. package/src/modes/utils/copy-targets.ts +133 -27
  126. package/src/modes/utils/ui-helpers.ts +44 -46
  127. package/src/plan-mode/approved-plan.ts +66 -43
  128. package/src/plan-mode/plan-protection.ts +4 -4
  129. package/src/prompts/system/background-tan-dispatch.md +8 -0
  130. package/src/prompts/system/plan-mode-active.md +67 -58
  131. package/src/prompts/system/plan-mode-approved.md +1 -1
  132. package/src/sdk.ts +11 -37
  133. package/src/session/agent-session.ts +82 -6
  134. package/src/session/messages.ts +26 -0
  135. package/src/session/session-manager.ts +13 -5
  136. package/src/slash-commands/builtin-registry.ts +36 -9
  137. package/src/slash-commands/types.ts +4 -6
  138. package/src/task/executor.ts +5 -2
  139. package/src/task/index.ts +4 -0
  140. package/src/task/render.ts +212 -147
  141. package/src/tools/archive-reader.ts +64 -0
  142. package/src/tools/ask.ts +119 -164
  143. package/src/tools/ast-edit.ts +98 -71
  144. package/src/tools/ast-grep.ts +37 -43
  145. package/src/tools/bash.ts +50 -6
  146. package/src/tools/debug.ts +20 -8
  147. package/src/tools/fetch.ts +297 -7
  148. package/src/tools/find.ts +44 -30
  149. package/src/tools/gh-renderer.ts +81 -42
  150. package/src/tools/grouped-file-output.ts +272 -48
  151. package/src/tools/image-gen.ts +150 -103
  152. package/src/tools/inspect-image-renderer.ts +63 -41
  153. package/src/tools/inspect-image.ts +8 -1
  154. package/src/tools/job.ts +3 -4
  155. package/src/tools/memory-render.ts +4 -1
  156. package/src/tools/plan-mode-guard.ts +21 -39
  157. package/src/tools/read.ts +23 -16
  158. package/src/tools/render-utils.ts +21 -37
  159. package/src/tools/resolve.ts +14 -0
  160. package/src/tools/search-tool-bm25.ts +36 -23
  161. package/src/tools/search.ts +80 -78
  162. package/src/tools/sqlite-reader.ts +9 -12
  163. package/src/tools/todo.ts +118 -52
  164. package/src/tools/write.ts +81 -62
  165. package/src/tui/output-block.ts +60 -13
  166. package/src/tui/status-line.ts +5 -1
  167. package/src/utils/commit-message-generator.ts +9 -1
  168. package/src/utils/enhanced-paste.ts +202 -0
  169. package/src/utils/title-generator.ts +2 -1
  170. package/src/web/search/providers/anthropic.ts +25 -19
  171. package/src/web/search/providers/exa.ts +11 -3
  172. package/src/web/search/providers/kimi.ts +28 -17
  173. package/src/web/search/providers/parallel.ts +35 -24
  174. package/src/web/search/providers/synthetic.ts +8 -6
  175. package/src/web/search/providers/tavily.ts +9 -8
  176. package/src/web/search/providers/zai.ts +8 -6
@@ -1,4 +1,6 @@
1
+ import { Database } from "bun:sqlite";
1
2
  import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
2
4
  import * as path from "node:path";
3
5
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
4
6
  import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
@@ -8,6 +10,7 @@ import { $which, ptree, truncate } from "@oh-my-pi/pi-utils";
8
10
  import { parseHTML } from "linkedom";
9
11
  import { LRUCache } from "lru-cache/raw";
10
12
  import type { Settings } from "../config/settings";
13
+ import { readEditableNotebookText } from "../edit/notebook";
11
14
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
12
15
  import { type Theme, theme } from "../modes/theme/theme";
13
16
  import type { ToolSession } from "../sdk";
@@ -22,10 +25,12 @@ import { specialHandlers } from "../web/scrapers";
22
25
  import type { RenderResult } from "../web/scrapers/types";
23
26
  import { finalizeOutput, loadPage, looksLikeHtml, MAX_OUTPUT_CHARS } from "../web/scrapers/types";
24
27
  import { convertWithMarkit, fetchBinary } from "../web/scrapers/utils";
28
+ import { type ArchiveFormat, listArchiveRoot, sniffArchiveFormat } from "./archive-reader";
25
29
  import { applyListLimit } from "./list-limit";
26
30
  import { formatStyledArtifactReference, type OutputMeta } from "./output-meta";
27
31
  import { type LineRange, parseLineRanges } from "./path-utils";
28
- import { formatExpandHint, getDomain, replaceTabs } from "./render-utils";
32
+ import { formatBytes, formatExpandHint, getDomain, replaceTabs } from "./render-utils";
33
+ import { listTables, looksLikeSqlite, renderTableList } from "./sqlite-reader";
29
34
  import { ToolAbortError, ToolError } from "./tool-errors";
30
35
  import { toolResult } from "./tool-result";
31
36
  import { clampTimeout } from "./tool-timeouts";
@@ -46,8 +51,6 @@ const CONVERTIBLE_MIMES = new Set([
46
51
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
47
52
  "application/rtf",
48
53
  "application/epub+zip",
49
- "application/x-ipynb+json",
50
- "application/zip",
51
54
  "image/png",
52
55
  "image/jpeg",
53
56
  "image/gif",
@@ -67,7 +70,6 @@ const CONVERTIBLE_EXTENSIONS = new Set([
67
70
  ".xlsx",
68
71
  ".rtf",
69
72
  ".epub",
70
- ".ipynb",
71
73
  ".png",
72
74
  ".jpg",
73
75
  ".jpeg",
@@ -78,6 +80,27 @@ const CONVERTIBLE_EXTENSIONS = new Set([
78
80
  ".ogg",
79
81
  ]);
80
82
 
83
+ const NOTEBOOK_MIMES = new Set(["application/x-ipynb+json"]);
84
+ const NOTEBOOK_EXTENSIONS = new Set([".ipynb"]);
85
+
86
+ const SQLITE_MIMES = new Set([
87
+ "application/vnd.sqlite3",
88
+ "application/x-sqlite3",
89
+ "application/sqlite3",
90
+ "application/sqlite",
91
+ ]);
92
+ const SQLITE_EXTENSIONS = new Set([".sqlite", ".sqlite3", ".db", ".db3"]);
93
+
94
+ const ARCHIVE_MIMES = new Set([
95
+ "application/zip",
96
+ "application/x-zip-compressed",
97
+ "application/x-tar",
98
+ "application/tar",
99
+ "application/gzip",
100
+ "application/x-gzip",
101
+ ]);
102
+ const ARCHIVE_EXTENSIONS = new Set([".zip", ".tar", ".tar.gz", ".tgz", ".gz"]);
103
+
81
104
  const IMAGE_MIME_BY_EXTENSION = new Map<string, string>([
82
105
  [".png", "image/png"],
83
106
  [".jpg", "image/jpeg"],
@@ -261,6 +284,12 @@ function normalizeMime(contentType: string): string {
261
284
  return contentType.split(";")[0].trim().toLowerCase();
262
285
  }
263
286
 
287
+ function getFilenameExtensionHint(filename: string): string {
288
+ const lower = filename.toLowerCase();
289
+ if (lower.endsWith(".tar.gz")) return ".tar.gz";
290
+ return path.extname(filename).toLowerCase();
291
+ }
292
+
264
293
  /**
265
294
  * Get extension from URL or Content-Disposition
266
295
  */
@@ -269,7 +298,7 @@ function getExtensionHint(url: string, contentDisposition?: string): string {
269
298
  if (contentDisposition) {
270
299
  const match = contentDisposition.match(/filename[*]?=["']?([^"';\n]+)/i);
271
300
  if (match) {
272
- const ext = path.extname(match[1]).toLowerCase();
301
+ const ext = getFilenameExtensionHint(match[1]);
273
302
  if (ext) return ext;
274
303
  }
275
304
  }
@@ -277,7 +306,7 @@ function getExtensionHint(url: string, contentDisposition?: string): string {
277
306
  // Fall back to URL path
278
307
  try {
279
308
  const pathname = new URL(url).pathname;
280
- const ext = path.extname(pathname).toLowerCase();
309
+ const ext = getFilenameExtensionHint(pathname);
281
310
  if (ext) return ext;
282
311
  } catch {}
283
312
 
@@ -738,6 +767,254 @@ type FetchRenderResult = RenderResult & {
738
767
  image?: FetchImagePayload;
739
768
  };
740
769
 
770
+ const BINARY_SAMPLE_CHARS = 4096;
771
+ const URL_ARCHIVE_LIST_LIMIT = 500;
772
+ const URL_SQLITE_LIST_LIMIT = 500;
773
+
774
+ function sampleLooksBinary(text: string): boolean {
775
+ const limit = Math.min(text.length, BINARY_SAMPLE_CHARS);
776
+ if (limit === 0) return false;
777
+
778
+ let replacementCount = 0;
779
+ for (let index = 0; index < limit; index++) {
780
+ const code = text.charCodeAt(index);
781
+ if (code === 0) return true;
782
+ if (code === 0xfffd) replacementCount++;
783
+ }
784
+
785
+ return replacementCount >= 3 && replacementCount / limit > 0.01;
786
+ }
787
+
788
+ function isNotebookHint(mime: string, extensionHint: string): boolean {
789
+ return NOTEBOOK_MIMES.has(mime) || NOTEBOOK_EXTENSIONS.has(extensionHint);
790
+ }
791
+
792
+ function isSqliteHint(mime: string, extensionHint: string): boolean {
793
+ return SQLITE_MIMES.has(mime) || SQLITE_EXTENSIONS.has(extensionHint);
794
+ }
795
+
796
+ function isArchiveHint(mime: string, extensionHint: string): boolean {
797
+ return ARCHIVE_MIMES.has(mime) || ARCHIVE_EXTENSIONS.has(extensionHint);
798
+ }
799
+
800
+ function getArchiveFormatHint(mime: string, extensionHint: string): ArchiveFormat | undefined {
801
+ if (extensionHint === ".zip" || mime === "application/zip" || mime === "application/x-zip-compressed") {
802
+ return "zip";
803
+ }
804
+ if (extensionHint === ".tar" || mime === "application/x-tar" || mime === "application/tar") {
805
+ return "tar";
806
+ }
807
+ if (
808
+ extensionHint === ".tar.gz" ||
809
+ extensionHint === ".tgz" ||
810
+ extensionHint === ".gz" ||
811
+ mime === "application/gzip" ||
812
+ mime === "application/x-gzip"
813
+ ) {
814
+ return "tar.gz";
815
+ }
816
+ return undefined;
817
+ }
818
+
819
+ function formatErrorMessage(error: unknown): string {
820
+ return error instanceof Error ? error.message : String(error);
821
+ }
822
+
823
+ function binaryContentType(mime: string): string {
824
+ return mime || "application/octet-stream";
825
+ }
826
+
827
+ function buildBinaryNotice(finalUrl: string, mime: string, byteLength?: number): string {
828
+ const size = byteLength === undefined ? "unknown size" : formatBytes(byteLength);
829
+ return `[Binary content: ${binaryContentType(mime)}, ${size}] ${finalUrl}`;
830
+ }
831
+
832
+ function buildBinaryPayloadResult(
833
+ url: string,
834
+ finalUrl: string,
835
+ mime: string,
836
+ method: string,
837
+ content: string,
838
+ fetchedAt: string,
839
+ notes: string[],
840
+ ): FetchRenderResult {
841
+ const output = finalizeOutput(content);
842
+ return {
843
+ url,
844
+ finalUrl,
845
+ contentType: binaryContentType(mime),
846
+ method,
847
+ content: output.content,
848
+ fetchedAt,
849
+ truncated: output.truncated,
850
+ notes,
851
+ };
852
+ }
853
+
854
+ async function withTempBinaryFile<T>(
855
+ prefix: string,
856
+ extension: string,
857
+ bytes: Uint8Array,
858
+ readTempFile: (tempPath: string) => Promise<T>,
859
+ ): Promise<T> {
860
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
861
+ const tempPath = path.join(tempDir, `payload${extension}`);
862
+ try {
863
+ await Bun.write(tempPath, bytes);
864
+ return await readTempFile(tempPath);
865
+ } finally {
866
+ await fs.rm(tempDir, { recursive: true, force: true });
867
+ }
868
+ }
869
+
870
+ async function renderNotebookPayload(bytes: Uint8Array, displayUrl: string): Promise<string> {
871
+ return withTempBinaryFile("omp-url-notebook-", ".ipynb", bytes, tempPath =>
872
+ readEditableNotebookText(tempPath, displayUrl),
873
+ );
874
+ }
875
+
876
+ async function renderSqlitePayload(bytes: Uint8Array): Promise<string> {
877
+ return withTempBinaryFile("omp-url-sqlite-", ".sqlite", bytes, async tempPath => {
878
+ let db: Database | null = null;
879
+ try {
880
+ db = new Database(tempPath, { readonly: true, strict: true });
881
+ db.run("PRAGMA busy_timeout = 3000");
882
+ const listLimit = applyListLimit(listTables(db), { limit: URL_SQLITE_LIST_LIMIT });
883
+ return renderTableList(listLimit.items);
884
+ } finally {
885
+ db?.close();
886
+ }
887
+ });
888
+ }
889
+
890
+ async function tryRenderBinaryPayload(
891
+ url: string,
892
+ finalUrl: string,
893
+ mime: string,
894
+ extHint: string,
895
+ rawContent: string,
896
+ timeout: number,
897
+ signal: AbortSignal | undefined,
898
+ fetchedAt: string,
899
+ notes: readonly string[],
900
+ ): Promise<FetchRenderResult | null> {
901
+ const hasNotebookHint = isNotebookHint(mime, extHint);
902
+ const hasSqliteHint = isSqliteHint(mime, extHint);
903
+ const hasArchiveHint = isArchiveHint(mime, extHint);
904
+ const rawLooksBinary = sampleLooksBinary(rawContent);
905
+ if (!hasNotebookHint && !hasSqliteHint && !hasArchiveHint && !rawLooksBinary) {
906
+ return null;
907
+ }
908
+
909
+ const resultNotes = [...notes];
910
+ const binary = await fetchBinary(finalUrl, timeout, signal);
911
+ if (!binary.ok) {
912
+ resultNotes.push(binary.error ? `Binary fetch failed: ${binary.error}` : "Binary fetch failed");
913
+ return buildBinaryPayloadResult(
914
+ url,
915
+ finalUrl,
916
+ mime,
917
+ "binary",
918
+ buildBinaryNotice(finalUrl, mime),
919
+ fetchedAt,
920
+ resultNotes,
921
+ );
922
+ }
923
+
924
+ const binaryExtHint = getExtensionHint(finalUrl, binary.contentDisposition) || extHint;
925
+ if (isNotebookHint(mime, binaryExtHint)) {
926
+ try {
927
+ return buildBinaryPayloadResult(
928
+ url,
929
+ finalUrl,
930
+ mime,
931
+ "notebook",
932
+ await renderNotebookPayload(binary.buffer, finalUrl),
933
+ fetchedAt,
934
+ resultNotes,
935
+ );
936
+ } catch (error) {
937
+ resultNotes.push(`Notebook rendering failed: ${formatErrorMessage(error)}`);
938
+ return buildBinaryPayloadResult(
939
+ url,
940
+ finalUrl,
941
+ mime,
942
+ "binary",
943
+ buildBinaryNotice(finalUrl, mime, binary.buffer.byteLength),
944
+ fetchedAt,
945
+ resultNotes,
946
+ );
947
+ }
948
+ }
949
+
950
+ if (isSqliteHint(mime, binaryExtHint) || looksLikeSqlite(binary.buffer)) {
951
+ try {
952
+ return buildBinaryPayloadResult(
953
+ url,
954
+ finalUrl,
955
+ mime,
956
+ "sqlite",
957
+ await renderSqlitePayload(binary.buffer),
958
+ fetchedAt,
959
+ resultNotes,
960
+ );
961
+ } catch (error) {
962
+ resultNotes.push(`SQLite rendering failed: ${formatErrorMessage(error)}`);
963
+ return buildBinaryPayloadResult(
964
+ url,
965
+ finalUrl,
966
+ mime,
967
+ "binary",
968
+ buildBinaryNotice(finalUrl, mime, binary.buffer.byteLength),
969
+ fetchedAt,
970
+ resultNotes,
971
+ );
972
+ }
973
+ }
974
+
975
+ const hintedArchiveFormat = getArchiveFormatHint(mime, binaryExtHint);
976
+ const shouldArchiveSniff = hintedArchiveFormat !== undefined || !isConvertible(mime, binaryExtHint);
977
+ const archiveFormat = hintedArchiveFormat ?? (shouldArchiveSniff ? sniffArchiveFormat(binary.buffer) : undefined);
978
+ if (archiveFormat) {
979
+ try {
980
+ return buildBinaryPayloadResult(
981
+ url,
982
+ finalUrl,
983
+ mime,
984
+ "archive",
985
+ await listArchiveRoot(binary.buffer, archiveFormat, { limit: URL_ARCHIVE_LIST_LIMIT }),
986
+ fetchedAt,
987
+ resultNotes,
988
+ );
989
+ } catch (error) {
990
+ resultNotes.push(`Archive rendering failed: ${formatErrorMessage(error)}`);
991
+ return buildBinaryPayloadResult(
992
+ url,
993
+ finalUrl,
994
+ mime,
995
+ "binary",
996
+ buildBinaryNotice(finalUrl, mime, binary.buffer.byteLength),
997
+ fetchedAt,
998
+ resultNotes,
999
+ );
1000
+ }
1001
+ }
1002
+
1003
+ if (rawLooksBinary) {
1004
+ return buildBinaryPayloadResult(
1005
+ url,
1006
+ finalUrl,
1007
+ mime,
1008
+ "binary",
1009
+ buildBinaryNotice(finalUrl, mime, binary.buffer.byteLength),
1010
+ fetchedAt,
1011
+ resultNotes,
1012
+ );
1013
+ }
1014
+
1015
+ return null;
1016
+ }
1017
+
741
1018
  // =============================================================================
742
1019
  // Unified Special Handler Dispatch
743
1020
  // =============================================================================
@@ -984,6 +1261,19 @@ async function renderUrl(
984
1261
  }
985
1262
  }
986
1263
 
1264
+ const binaryPayloadResult = await tryRenderBinaryPayload(
1265
+ url,
1266
+ finalUrl,
1267
+ mime,
1268
+ extHint,
1269
+ rawContent,
1270
+ timeout,
1271
+ signal,
1272
+ fetchedAt,
1273
+ notes,
1274
+ );
1275
+ if (binaryPayloadResult) return binaryPayloadResult;
1276
+
987
1277
  // Step 4: Handle non-HTML text content
988
1278
  const isHtml = mime.includes("html") || mime.includes("xhtml");
989
1279
  const isJson = mime.includes("json");
@@ -992,7 +1282,7 @@ async function renderUrl(
992
1282
  const isFeed = mime.includes("rss") || mime.includes("atom") || mime.includes("feed");
993
1283
 
994
1284
  // Raw mode skips every text-shaping branch below (JSON pretty-print, feed-to-markdown,
995
- // HTML extraction) and returns the response body verbatim. The image/markit branches
1285
+ // HTML extraction) and returns the response body verbatim. Binary-oriented branches
996
1286
  // above already ran because raw isn't useful for binary payloads.
997
1287
  if (raw) {
998
1288
  const output = finalizeOutput(rawContent);
package/src/tools/find.ts CHANGED
@@ -13,6 +13,7 @@ import findDescription from "../prompts/tools/find.md" with { type: "text" };
13
13
  import { type TruncationResult, truncateHead } from "../session/streaming-output";
14
14
  import { Ellipsis, fileHyperlink, renderFileList, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
15
15
  import type { ToolSession } from ".";
16
+ import { buildPathTree, walkPathTree } from "./grouped-file-output";
16
17
  import { applyListLimit } from "./list-limit";
17
18
  import { formatFullOutputReference, type OutputMeta } from "./output-meta";
18
19
  import {
@@ -54,34 +55,27 @@ const MIN_GLOB_TIMEOUT_MS = 500;
54
55
  const MAX_GLOB_TIMEOUT_MS = 60_000;
55
56
 
56
57
  /**
57
- * Group find matches by their directory so the model doesn't pay repeated
58
- * tokens for shared path prefixes. Preserves the input order: groups appear in
59
- * the order their first member was emitted (mtime-desc for native glob), and
60
- * within a group entries keep their relative order.
58
+ * Group find matches into a multi-level directory tree so the model doesn't pay
59
+ * repeated tokens for shared path prefixes. Single-child directory chains fold
60
+ * into one header (`# a/b/c/`), so a common prefix including an absolute root
61
+ * for out-of-cwd results — collapses to a single line. Each level adds one `#`;
62
+ * files are listed bare under the deepest directory header that owns them.
63
+ *
64
+ * Order follows the input (mtime-desc for native glob): a directory appears when
65
+ * its first member is emitted, and a node's own files precede its subdirectories.
61
66
  */
62
67
  export function formatFindGroupedOutput(paths: readonly string[]): string {
63
68
  if (paths.length === 0) return "";
64
- const groups = new Map<string, string[]>();
65
- for (const entry of paths) {
66
- const hasTrailingSlash = entry.endsWith("/");
67
- const trimmed = hasTrailingSlash ? entry.slice(0, -1) : entry;
68
- const slash = trimmed.lastIndexOf("/");
69
- const dir = slash === -1 ? "" : trimmed.slice(0, slash);
70
- const base = slash === -1 ? trimmed : trimmed.slice(slash + 1);
71
- const label = hasTrailingSlash ? `${base}/` : base;
72
- const list = groups.get(dir);
73
- if (list) list.push(label);
74
- else groups.set(dir, [label]);
75
- }
76
- const sections: string[] = [];
77
- for (const [dir, entries] of groups) {
78
- if (dir === "") {
79
- sections.push(entries.join("\n"));
69
+ const tree = buildPathTree(paths.map(entry => ({ path: entry, isDir: entry.endsWith("/") })));
70
+ const lines: string[] = [];
71
+ for (const event of walkPathTree(tree)) {
72
+ if (event.kind === "dir") {
73
+ lines.push(`${"#".repeat(event.depth + 1)} ${event.name}/`);
80
74
  } else {
81
- sections.push(`# ${dir}/\n${entries.join("\n")}`);
75
+ lines.push(event.name);
82
76
  }
83
77
  }
84
- return sections.join("\n\n");
78
+ return lines.join("\n");
85
79
  }
86
80
 
87
81
  export interface FindToolDetails {
@@ -434,6 +428,10 @@ function formatFindRenderPaths(paths: FindRenderArgs["paths"]): string | undefin
434
428
 
435
429
  const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
436
430
 
431
+ function findStatusIcon(uiTheme: Theme): string {
432
+ return uiTheme.fg("toolTitle", uiTheme.symbol("icon.search"));
433
+ }
434
+
437
435
  export const findToolRenderer = {
438
436
  inline: true,
439
437
  renderCall(args: FindRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
@@ -441,10 +439,16 @@ export const findToolRenderer = {
441
439
  if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
442
440
 
443
441
  const text = renderStatusLine(
444
- { icon: "pending", title: "Find", description: formatFindRenderPaths(args.paths) || "*", meta },
442
+ {
443
+ icon: "pending",
444
+ title: "Find",
445
+ titleColor: "toolTitle",
446
+ description: formatFindRenderPaths(args.paths) || "*",
447
+ meta,
448
+ },
445
449
  uiTheme,
446
450
  );
447
- return new Text(text, 0, 0);
451
+ return new Text(text, 1, 0);
448
452
  },
449
453
 
450
454
  renderResult(
@@ -457,7 +461,7 @@ export const findToolRenderer = {
457
461
 
458
462
  if (result.isError || details?.error) {
459
463
  const errorText = details?.error || result.content?.find(c => c.type === "text")?.text || "Unknown error";
460
- return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
464
+ return new Text(formatErrorMessage(errorText, uiTheme), 1, 0);
461
465
  }
462
466
 
463
467
  const hasDetailedData = details?.fileCount !== undefined;
@@ -470,14 +474,15 @@ export const findToolRenderer = {
470
474
  textContent.includes("No files found") ||
471
475
  textContent.trim() === ""
472
476
  ) {
473
- return new Text(formatEmptyMessage("No files found", uiTheme), 0, 0);
477
+ return new Text(formatEmptyMessage("No files found", uiTheme), 1, 0);
474
478
  }
475
479
 
476
480
  const lines = textContent.split("\n").filter(l => l.trim());
477
481
  const header = renderStatusLine(
478
482
  {
479
- icon: "success",
483
+ iconOverride: findStatusIcon(uiTheme),
480
484
  title: "Find",
485
+ titleColor: "toolTitle",
481
486
  description: formatFindRenderPaths(args?.paths),
482
487
  meta: [formatCount("file", lines.length)],
483
488
  },
@@ -498,6 +503,7 @@ export const findToolRenderer = {
498
503
  );
499
504
  return [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
500
505
  },
506
+ { paddingX: 1 },
501
507
  );
502
508
  }
503
509
 
@@ -513,20 +519,27 @@ export const findToolRenderer = {
513
519
 
514
520
  if (fileCount === 0) {
515
521
  const header = renderStatusLine(
516
- { icon: "warning", title: "Find", description: formatFindRenderPaths(args?.paths), meta: ["0 files"] },
522
+ {
523
+ icon: "warning",
524
+ title: "Find",
525
+ titleColor: "toolTitle",
526
+ description: formatFindRenderPaths(args?.paths),
527
+ meta: ["0 files"],
528
+ },
517
529
  uiTheme,
518
530
  );
519
531
  const lines = [header, formatEmptyMessage("No files found", uiTheme)];
520
532
  if (missingNote) lines.push(missingNote);
521
- return new Text(lines.join("\n"), 0, 0);
533
+ return new Text(lines.join("\n"), 1, 0);
522
534
  }
523
535
  const meta: string[] = [formatCount("file", fileCount)];
524
536
  if (details?.scopePath) meta.push(`in ${details.scopePath}`);
525
537
  if (truncated) meta.push(uiTheme.fg("warning", "truncated"));
526
538
  const header = renderStatusLine(
527
539
  {
528
- icon: truncated ? "warning" : "success",
540
+ ...(truncated ? { icon: "warning" as const } : { iconOverride: findStatusIcon(uiTheme) }),
529
541
  title: "Find",
542
+ titleColor: "toolTitle",
530
543
  description: formatFindRenderPaths(args?.paths),
531
544
  meta,
532
545
  },
@@ -565,6 +578,7 @@ export const findToolRenderer = {
565
578
  );
566
579
  return [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
567
580
  },
581
+ { paddingX: 1 },
568
582
  );
569
583
  },
570
584
  mergeCallAndResult: true,