@heyhuynhgiabuu/pi-pretty 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -24,9 +24,23 @@
24
24
  */
25
25
 
26
26
  import * as childProcess from "node:child_process";
27
- import { existsSync, mkdirSync, statSync } from "node:fs";
27
+ import { mkdirSync } from "node:fs";
28
28
  import { basename, dirname, extname, join, relative } from "node:path";
29
29
 
30
+ import type { FileFinder, FileItem, GrepResult, SearchResult } from "@ff-labs/fff-node";
31
+ import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
32
+ import type {
33
+ AgentToolResult,
34
+ AgentToolUpdateCallback,
35
+ BashToolInput,
36
+ ExtensionCommandContext,
37
+ ExtensionContext,
38
+ FindToolInput,
39
+ GrepToolInput,
40
+ LsToolInput,
41
+ ReadToolInput,
42
+ ToolRenderResultOptions,
43
+ } from "@mariozechner/pi-coding-agent";
30
44
  import { codeToANSI } from "@shikijs/cli";
31
45
  import type { BundledLanguage, BundledTheme } from "shiki";
32
46
 
@@ -53,8 +67,6 @@ const CACHE_LIMIT = envInt("PRETTY_CACHE_LIMIT", 128);
53
67
 
54
68
  let RST = "\x1b[0m";
55
69
  const BOLD = "\x1b[1m";
56
- const DIM = "\x1b[2m";
57
- const ITALIC = "\x1b[3m";
58
70
 
59
71
  const FG_LNUM = "\x1b[38;2;100;100;100m";
60
72
  const FG_DIM = "\x1b[38;2;80;80;80m";
@@ -63,36 +75,44 @@ const FG_GREEN = "\x1b[38;2;100;180;120m";
63
75
  const FG_RED = "\x1b[38;2;200;100;100m";
64
76
  const FG_YELLOW = "\x1b[38;2;220;180;80m";
65
77
  const FG_BLUE = "\x1b[38;2;100;140;220m";
66
- const FG_CYAN = "\x1b[38;2;80;190;190m";
67
78
  const FG_MUTED = "\x1b[38;2;139;148;158m";
68
- const FG_ORANGE = "\x1b[38;2;220;140;60m";
69
- const FG_PURPLE = "\x1b[38;2;170;120;200m";
70
-
71
- const BG_STDERR = "\x1b[48;2;40;25;25m";
72
79
 
73
80
  const BG_DEFAULT = "\x1b[49m";
74
- let BG_BASE = BG_DEFAULT; // tool box base bg — updated from theme's toolSuccessBg
81
+ let BG_BASE = BG_DEFAULT; // tool box success/base bg — updated from theme's toolSuccessBg
82
+ let BG_ERROR = BG_DEFAULT; // tool box error bg — updated from theme's toolErrorBg
83
+
84
+ type BgTheme = { getBgAnsi?: (key: string) => string };
85
+ type FgTheme = { fg: (key: string, text: string) => string };
75
86
 
76
87
  /** Parse an ANSI 24-bit color escape into { r, g, b }. Handles both fg (38;2) and bg (48;2). */
77
88
  function parseAnsiRgb(ansi: string): { r: number; g: number; b: number } | null {
78
- const m = ansi.match(/\x1b\[(?:38|48);2;(\d+);(\d+);(\d+)m/);
89
+ const m = ansi.match(new RegExp(`${ESC_RE}\\[(?:38|48);2;(\\d+);(\\d+);(\\d+)m`));
79
90
  return m ? { r: +m[1], g: +m[2], b: +m[3] } : null;
80
91
  }
81
92
 
82
- /** Read toolSuccessBg from the pi theme and update BG_BASE + RST.
93
+ function getThemeBgAnsi(theme: BgTheme, key: string): string | null {
94
+ try {
95
+ const bgAnsi = theme.getBgAnsi?.(key);
96
+ return bgAnsi && parseAnsiRgb(bgAnsi) ? bgAnsi : null;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /** Read themed tool backgrounds and update BG_BASE / BG_ERROR + RST.
83
103
  * Call once when theme is first available. Idempotent. */
84
104
  let _bgBaseResolved = false;
85
- function resolveBaseBackground(theme: any): void {
105
+ function resolveBaseBackground(theme: BgTheme | null | undefined): void {
86
106
  if (_bgBaseResolved || !theme?.getBgAnsi) return;
87
107
  _bgBaseResolved = true;
88
- try {
89
- const bgAnsi = theme.getBgAnsi("toolSuccessBg");
90
- const parsed = parseAnsiRgb(bgAnsi);
91
- if (parsed) {
92
- BG_BASE = bgAnsi;
93
- RST = `\x1b[0m${BG_BASE}`;
94
- }
95
- } catch { /* ignore — keep defaults */ }
108
+
109
+ BG_BASE = getThemeBgAnsi(theme, "toolSuccessBg") ?? BG_DEFAULT;
110
+ BG_ERROR = getThemeBgAnsi(theme, "toolErrorBg") ?? BG_BASE;
111
+ RST = `\x1b[0m${BG_BASE}`;
112
+ }
113
+
114
+ function renderToolError(error: string, theme: FgTheme): string {
115
+ return fillToolBackground(`\n${theme.fg("error", error)}`, BG_ERROR);
96
116
  }
97
117
 
98
118
  const ESC_RE = "\u001b";
@@ -126,9 +146,29 @@ function strip(s: string): string {
126
146
  return s.replace(ANSI_RE, "");
127
147
  }
128
148
 
149
+ function preserveToolBackground(ansi: string, bg: string): string {
150
+ return ansi.replace(ANSI_CAPTURE_RE, (seq, params: string) => {
151
+ const codes = params.split(";");
152
+ return params === "0" || codes.includes("49") ? `${seq}${bg}` : seq;
153
+ });
154
+ }
155
+
156
+ function fillToolBackground(text: string, bg = BG_BASE): string {
157
+ const width = termW();
158
+ return text
159
+ .split("\n")
160
+ .map((line) => {
161
+ const normalized = preserveToolBackground(line, bg);
162
+ const padding = Math.max(0, width - strip(normalized).length);
163
+ return `${bg}${normalized}${" ".repeat(padding)}${RST}`;
164
+ })
165
+ .join("\n");
166
+ }
167
+
129
168
  function termW(): number {
169
+ const stderrWithColumns = process.stderr as NodeJS.WriteStream & { columns?: number };
130
170
  const raw =
131
- process.stdout.columns || (process.stderr as any).columns || Number.parseInt(process.env.COLUMNS ?? "", 10) || 200;
171
+ process.stdout.columns || stderrWithColumns.columns || Number.parseInt(process.env.COLUMNS ?? "", 10) || 200;
132
172
  return Math.max(80, Math.min(raw - 4, 210));
133
173
  }
134
174
 
@@ -424,7 +464,6 @@ const USE_ICONS = ICONS_MODE !== "none" && ICONS_MODE !== "off";
424
464
 
425
465
  // Nerd Font codepoints + ANSI color per file type
426
466
  const NF_DIR = `${FG_BLUE}\ue5ff${RST}`; // folder
427
- const NF_DIR_OPEN = `${FG_BLUE}\ue5fe${RST}`; // folder open
428
467
  const NF_DEFAULT = `${FG_DIM}\uf15b${RST}`; // generic file
429
468
 
430
469
  const EXT_ICON: Record<string, string> = {
@@ -644,7 +683,7 @@ function renderBashOutput(text: string, exitCode: number | null): { summary: str
644
683
  }
645
684
 
646
685
  /** Render ls output as a tree view with icons. */
647
- function renderTree(text: string, basePath: string): string {
686
+ function renderTree(text: string, _basePath: string): string {
648
687
  const lines = text.trim().split("\n").filter(Boolean);
649
688
  if (!lines.length) return `${FG_DIM}(empty directory)${RST}`;
650
689
 
@@ -687,7 +726,8 @@ function renderFindResults(text: string): string {
687
726
  const dir = dirname(trimmed) || ".";
688
727
  const file = basename(trimmed);
689
728
  if (!groups.has(dir)) groups.set(dir, []);
690
- groups.get(dir)!.push(file);
729
+ const bucket = groups.get(dir);
730
+ if (bucket) bucket.push(file);
691
731
  }
692
732
 
693
733
  const out: string[] = [];
@@ -717,7 +757,6 @@ async function renderGrepResults(text: string, pattern: string): Promise<string>
717
757
  const lines = text.split("\n");
718
758
  if (!lines.length || (lines.length === 1 && !lines[0].trim())) return `${FG_DIM}(no matches)${RST}`;
719
759
 
720
- const tw = termW();
721
760
  const out: string[] = [];
722
761
  let currentFile = "";
723
762
  let count = 0;
@@ -773,14 +812,140 @@ async function renderGrepResults(text: string, pattern: string): Promise<string>
773
812
  // If not, falls back to wrapping SDK tools (current behavior).
774
813
  // ---------------------------------------------------------------------------
775
814
 
815
+ type ToolTextContent = TextContent;
816
+ type ToolImageContent = ImageContent;
817
+ type ToolContent = TextContent | ImageContent;
818
+ type ToolResultLike<TDetails = unknown> = AgentToolResult<TDetails | undefined>;
819
+ type TextComponentLike = { setText(value: string): void; getText?: () => string };
820
+ type TextComponentCtor = new (text?: string, x?: number, y?: number) => TextComponentLike;
821
+ type ThemeLike = BgTheme & FgTheme & { bold: (text: string) => string };
822
+ type RenderContextLike<TState extends Record<string, string | undefined> = Record<string, string | undefined>> = {
823
+ lastComponent?: TextComponentLike;
824
+ state: TState;
825
+ expanded: boolean;
826
+ isError: boolean;
827
+ invalidate: () => void;
828
+ };
829
+ type SessionContextLike = ExtensionContext;
830
+ type CommandContextLike = ExtensionCommandContext;
831
+ type ToolExecutor<TParams, TDetails = unknown> = (
832
+ toolCallId: string,
833
+ params: TParams,
834
+ signal?: AbortSignal,
835
+ onUpdate?: AgentToolUpdateCallback<TDetails | undefined>,
836
+ ctx?: ExtensionContext,
837
+ ) => Promise<ToolResultLike<TDetails>>;
838
+ type ToolFactory<TParams, TDetails = unknown> = (cwd: string) => {
839
+ name?: string;
840
+ description?: string;
841
+ label?: string;
842
+ parameters?: unknown;
843
+ execute: ToolExecutor<TParams, TDetails>;
844
+ };
845
+ type PiPrettySdk = {
846
+ createReadToolDefinition?: ToolFactory<ReadToolInput>;
847
+ createReadTool?: ToolFactory<ReadToolInput>;
848
+ createBashToolDefinition?: ToolFactory<BashToolInput>;
849
+ createBashTool?: ToolFactory<BashToolInput>;
850
+ createLsToolDefinition?: ToolFactory<LsToolInput>;
851
+ createLsTool?: ToolFactory<LsToolInput>;
852
+ createFindToolDefinition?: ToolFactory<FindToolInput>;
853
+ createFindTool?: ToolFactory<FindToolInput>;
854
+ createGrepToolDefinition?: ToolFactory<GrepToolInput>;
855
+ createGrepTool?: ToolFactory<GrepToolInput>;
856
+ getAgentDir?: () => string;
857
+ };
858
+ type PiPrettyApi = {
859
+ registerTool: (tool: unknown) => void;
860
+ registerCommand: (
861
+ name: string,
862
+ command: {
863
+ description?: string;
864
+ handler: (args: string, ctx: CommandContextLike) => Promise<void> | void;
865
+ },
866
+ ) => void;
867
+ on: (event: string, handler: (event: unknown, ctx: SessionContextLike) => Promise<void> | void) => void;
868
+ };
869
+ type OptionalFffModule = { FileFinder: typeof FileFinder };
870
+ type FffBackedFinder = FileFinder;
871
+ type ReadParams = ReadToolInput;
872
+ type BashParams = BashToolInput;
873
+ type LsParams = LsToolInput;
874
+ type FindParams = FindToolInput;
875
+ type GrepParams = GrepToolInput;
876
+ type MultiGrepParams = {
877
+ patterns: string[];
878
+ constraints?: string;
879
+ context?: number;
880
+ limit?: number;
881
+ };
882
+ type GrepRenderState = { _gk?: string; _gt?: string };
883
+ type MultiGrepRenderState = { _mgk?: string; _mgt?: string };
884
+ type FindResultDetails = { _type: "findResult"; text: string; pattern: string; matchCount: number };
885
+ type GrepResultDetails = { _type: "grepResult"; text: string; pattern: string; matchCount: number };
886
+ type RenderDetails =
887
+ | { _type: "readImage"; filePath: string; data: string; mimeType: string }
888
+ | { _type: "readFile"; filePath: string; content: string; offset: number; lineCount: number }
889
+ | { _type: "bashResult"; text: string; exitCode: number | null; command: string }
890
+ | { _type: "lsResult"; text: string; path: string; entryCount: number }
891
+ | FindResultDetails
892
+ | GrepResultDetails;
893
+
894
+ function isTextContent(content: ToolContent): content is ToolTextContent {
895
+ return content.type === "text";
896
+ }
897
+
898
+ function isImageContent(content: ToolContent): content is ToolImageContent {
899
+ return content.type === "image";
900
+ }
901
+
902
+ function getTextContent(result: ToolResultLike): string {
903
+ return (
904
+ result.content
905
+ ?.filter(isTextContent)
906
+ .map((content) => content.text || "")
907
+ .join("\n") ?? ""
908
+ );
909
+ }
910
+
911
+ function setResultDetails<T>(result: ToolResultLike, details: T): void {
912
+ result.details = details;
913
+ }
914
+
915
+ function makeTextResult<TDetails>(text: string, details: TDetails): ToolResultLike<TDetails> {
916
+ return {
917
+ content: [{ type: "text", text }],
918
+ details,
919
+ };
920
+ }
921
+
922
+ function appendNotices(text: string, notices: string[]): string {
923
+ return notices.length ? `${text}\n\n[${notices.join(". ")}]` : text;
924
+ }
925
+
926
+ function countRipgrepMatches(text: string): number {
927
+ return text
928
+ .trim()
929
+ .split("\n")
930
+ .filter((line) => /^.+?[:-]\d+[:-]/.test(line)).length;
931
+ }
932
+
933
+ function getErrorMessage(error: unknown): string {
934
+ return error instanceof Error ? error.message : String(error);
935
+ }
936
+
776
937
  const _cursorStore = new CursorStore();
777
- let _fffModule: any = null;
778
- let _fffFinder: any = null;
938
+ let _fffModule: OptionalFffModule | null = null;
939
+ let _fffFinder: FffBackedFinder | null = null;
779
940
  let _fffPartialIndex = false;
780
941
  let _fffDbDir: string | null = null;
781
942
  const FFF_SCAN_TIMEOUT = 15_000;
782
943
 
783
- async function fffEnsureFinder(cwd: string): Promise<any> {
944
+ function getPiPrettyFffDir(agentDir: string): string {
945
+ return join(agentDir, "pi-pretty", "fff");
946
+ }
947
+
948
+ async function fffEnsureFinder(cwd: string): Promise<FffBackedFinder | null> {
784
949
  if (_fffFinder && !_fffFinder.isDestroyed) return _fffFinder;
785
950
  if (!_fffModule || !_fffDbDir) return null;
786
951
 
@@ -817,20 +982,20 @@ function fffDestroy(): void {
817
982
  * In production, omit `deps` — the extension uses require() to load them.
818
983
  */
819
984
  export interface PiPrettyDeps {
820
- sdk: any;
821
- TextComponent: any;
822
- fffModule?: any;
985
+ sdk: PiPrettySdk;
986
+ TextComponent: TextComponentCtor;
987
+ fffModule?: OptionalFffModule;
823
988
  }
824
989
 
825
- export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
826
- let createReadTool: any;
827
- let createBashTool: any;
828
- let createLsTool: any;
829
- let createFindTool: any;
830
- let createGrepTool: any;
831
- let TextComponent: any;
990
+ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps): void {
991
+ let createReadTool: ToolFactory<ReadToolInput> | undefined;
992
+ let createBashTool: ToolFactory<BashToolInput> | undefined;
993
+ let createLsTool: ToolFactory<LsToolInput> | undefined;
994
+ let createFindTool: ToolFactory<FindToolInput> | undefined;
995
+ let createGrepTool: ToolFactory<GrepToolInput> | undefined;
996
+ let TextComponent: TextComponentCtor;
832
997
 
833
- let sdk: any;
998
+ let sdk: PiPrettySdk;
834
999
 
835
1000
  if (deps) {
836
1001
  // Test path: use injected dependencies, reset module state
@@ -868,13 +1033,13 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
868
1033
  // FFF initialization (optional — graceful fallback to SDK)
869
1034
  // ===================================================================
870
1035
 
871
- const getAgentDir = (sdk as any).getAgentDir;
1036
+ const getAgentDir = sdk.getAgentDir;
872
1037
  if (!deps) {
873
1038
  // Only try require() in production — tests inject fffModule via deps
874
1039
  try {
875
1040
  _fffModule = require("@ff-labs/fff-node");
876
1041
  if (getAgentDir) {
877
- _fffDbDir = join(getAgentDir(), "fff");
1042
+ _fffDbDir = getPiPrettyFffDir(getAgentDir());
878
1043
  try {
879
1044
  mkdirSync(_fffDbDir, { recursive: true });
880
1045
  } catch {}
@@ -883,25 +1048,25 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
883
1048
  /* FFF not installed — SDK tools will be used */
884
1049
  }
885
1050
  } else if (_fffModule && getAgentDir) {
886
- _fffDbDir = join(getAgentDir(), "fff");
1051
+ _fffDbDir = getPiPrettyFffDir(getAgentDir());
887
1052
  try {
888
1053
  mkdirSync(_fffDbDir, { recursive: true });
889
1054
  } catch {}
890
1055
  }
891
1056
 
892
- pi.on("session_start", async (_event: any, ctx: any) => {
1057
+ pi.on("session_start", async (_event, ctx) => {
893
1058
  // Try dynamic import if sync require failed (ESM-only package)
894
1059
  if (!_fffModule) {
895
1060
  try {
896
- // @ts-ignore optional dependency, may not be installed
897
- _fffModule = await import("@ff-labs/fff-node");
1061
+ const imported = await import("@ff-labs/fff-node");
1062
+ _fffModule = { FileFinder: imported.FileFinder };
898
1063
  } catch {}
899
1064
  }
900
1065
  if (!_fffModule) return;
901
1066
 
902
1067
  if (!_fffDbDir) {
903
1068
  const agentDir = getAgentDir?.() ?? join(home, ".pi/agent");
904
- _fffDbDir = join(agentDir, "fff");
1069
+ _fffDbDir = getPiPrettyFffDir(agentDir);
905
1070
  try {
906
1071
  mkdirSync(_fffDbDir, { recursive: true });
907
1072
  } catch {}
@@ -915,8 +1080,8 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
915
1080
  ctx.ui?.setStatus?.("fff", "FFF indexed");
916
1081
  setTimeout(() => ctx.ui?.setStatus?.("fff", undefined), 3000);
917
1082
  }
918
- } catch (e: any) {
919
- ctx.ui?.notify?.(`FFF init failed: ${e.message}`, "error");
1083
+ } catch (error: unknown) {
1084
+ ctx.ui?.notify?.(`FFF init failed: ${getErrorMessage(error)}`, "error");
920
1085
  }
921
1086
  });
922
1087
 
@@ -934,69 +1099,68 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
934
1099
  ...origRead,
935
1100
  name: "read",
936
1101
 
937
- async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
938
- const result = await origRead.execute(tid, params, sig, upd, ctx);
1102
+ async execute(
1103
+ tid: string,
1104
+ params: ReadParams,
1105
+ sig: AbortSignal | undefined,
1106
+ upd: AgentToolUpdateCallback<unknown> | undefined,
1107
+ ctx: ExtensionContext,
1108
+ ) {
1109
+ const result = (await origRead.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
939
1110
 
940
1111
  const fp = params.path ?? "";
941
1112
  const offset = params.offset ?? 1;
942
1113
 
943
- // Check for image content
944
- const imageBlock = result.content?.find((c: any) => c.type === "image");
1114
+ const imageBlock = result.content?.find(isImageContent);
945
1115
  if (imageBlock) {
946
- (result as any).details = {
1116
+ setResultDetails(result, {
947
1117
  _type: "readImage",
948
1118
  filePath: fp,
949
1119
  data: imageBlock.data,
950
1120
  mimeType: imageBlock.mimeType ?? "image/png",
951
- };
1121
+ });
952
1122
  return result;
953
1123
  }
954
1124
 
955
- // Extract text content for rendering
956
- const textContent = result.content
957
- ?.filter((c: any) => c.type === "text")
958
- .map((c: any) => c.text || "")
959
- .join("\n");
960
-
1125
+ const textContent = getTextContent(result);
961
1126
  if (textContent && fp) {
962
1127
  const lineCount = textContent.split("\n").length;
963
- (result as any).details = {
1128
+ setResultDetails(result, {
964
1129
  _type: "readFile",
965
1130
  filePath: fp,
966
1131
  content: textContent,
967
1132
  offset,
968
1133
  lineCount,
969
- };
1134
+ });
970
1135
  }
971
1136
 
972
1137
  return result;
973
1138
  },
974
1139
 
975
- renderCall(args: any, theme: any, ctx: any) {
1140
+ renderCall(args: ReadParams, theme: ThemeLike, ctx: RenderContextLike) {
976
1141
  resolveBaseBackground(theme);
977
- const fp = args?.path ?? "";
1142
+ const fp = args.path ?? "";
978
1143
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
979
- const offset = args?.offset ? ` ${theme.fg("muted", `from line ${args.offset}`)}` : "";
980
- const limit = args?.limit ? ` ${theme.fg("muted", `(${args.limit} lines)`)}` : "";
981
- text.setText(`${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", sp(fp))}${offset}${limit}`);
1144
+ const offset = args.offset ? ` ${theme.fg("muted", `from line ${args.offset}`)}` : "";
1145
+ const limit = args.limit ? ` ${theme.fg("muted", `(${args.limit} lines)`)}` : "";
1146
+ text.setText(
1147
+ fillToolBackground(
1148
+ `${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", sp(fp))}${offset}${limit}`,
1149
+ ),
1150
+ );
982
1151
  return text;
983
1152
  },
984
1153
 
985
- renderResult(result: any, _opt: any, theme: any, ctx: any) {
1154
+ renderResult(result: ToolResultLike, _opt: unknown, theme: ThemeLike, ctx: RenderContextLike) {
986
1155
  resolveBaseBackground(theme);
987
1156
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
988
1157
 
989
1158
  if (ctx.isError) {
990
- const e =
991
- result.content
992
- ?.filter((c: any) => c.type === "text")
993
- .map((c: any) => c.text || "")
994
- .join("\n") ?? "Error";
995
- text.setText(`\n${theme.fg("error", e)}`);
1159
+ text.setText(renderToolError(getTextContent(result) || "Error", theme));
996
1160
  return text;
997
1161
  }
998
1162
 
999
- const d = result.details;
1163
+ const d = result.details as RenderDetails | undefined;
1000
1164
 
1001
1165
  // Image rendering
1002
1166
  if (d?._type === "readImage") {
@@ -1016,7 +1180,9 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1016
1180
  out.push(` ${FG_YELLOW}${passthroughWarning}${RST}`);
1017
1181
  } else if (protocol === "kitty") {
1018
1182
  if (d.mimeType && d.mimeType !== "image/png") {
1019
- out.push(` ${FG_YELLOW}Kitty/Ghostty inline preview currently supports PNG payloads (got ${d.mimeType})${RST}`);
1183
+ out.push(
1184
+ ` ${FG_YELLOW}Kitty/Ghostty inline preview currently supports PNG payloads (got ${d.mimeType})${RST}`,
1185
+ );
1020
1186
  } else {
1021
1187
  const imgCols = Math.min(tw - 4, 80);
1022
1188
  out.push(renderKittyImage(d.data, { cols: imgCols }));
@@ -1034,7 +1200,7 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1034
1200
  }
1035
1201
 
1036
1202
  out.push(rule(tw));
1037
- text.setText(out.join("\n"));
1203
+ text.setText(fillToolBackground(out.join("\n")));
1038
1204
  return text;
1039
1205
  }
1040
1206
 
@@ -1043,24 +1209,25 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1043
1209
  if (ctx.state._rk !== key) {
1044
1210
  ctx.state._rk = key;
1045
1211
  const info = `${FG_DIM}${d.lineCount} lines${RST}`;
1046
- ctx.state._rt = ` ${info}`;
1212
+ ctx.state._rt = fillToolBackground(` ${info}`);
1047
1213
 
1048
1214
  const maxShow = ctx.expanded ? d.lineCount : MAX_PREVIEW_LINES;
1049
1215
  renderFileContent(d.content, d.filePath, d.offset, maxShow)
1050
1216
  .then((rendered: string) => {
1051
1217
  if (ctx.state._rk !== key) return;
1052
- ctx.state._rt = ` ${info}\n${rendered}`;
1218
+ ctx.state._rt = fillToolBackground(` ${info}\n${rendered}`);
1053
1219
  ctx.invalidate();
1054
1220
  })
1055
1221
  .catch(() => {});
1056
1222
  }
1057
- text.setText(ctx.state._rt ?? ` ${FG_DIM}${d.lineCount} lines${RST}`);
1223
+ text.setText(ctx.state._rt ?? fillToolBackground(` ${FG_DIM}${d.lineCount} lines${RST}`));
1058
1224
  return text;
1059
1225
  }
1060
1226
 
1061
1227
  // Fallback
1062
- const fallback = result.content?.[0]?.text ?? "read";
1063
- text.setText(` ${theme.fg("dim", String(fallback).slice(0, 120))}`);
1228
+ const fallback = result.content?.[0];
1229
+ const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "read";
1230
+ text.setText(fillToolBackground(` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`));
1064
1231
  return text;
1065
1232
  },
1066
1233
  });
@@ -1076,69 +1243,65 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1076
1243
  ...origBash,
1077
1244
  name: "bash",
1078
1245
 
1079
- async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
1080
- const result = await origBash.execute(tid, params, sig, upd, ctx);
1246
+ async execute(
1247
+ tid: string,
1248
+ params: BashParams,
1249
+ sig: AbortSignal | undefined,
1250
+ upd: AgentToolUpdateCallback<unknown> | undefined,
1251
+ ctx: ExtensionContext,
1252
+ ) {
1253
+ const result = (await origBash.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
1254
+ const textContent = getTextContent(result);
1081
1255
 
1082
- const textContent = result.content
1083
- ?.filter((c: any) => c.type === "text")
1084
- .map((c: any) => c.text || "")
1085
- .join("\n");
1086
-
1087
- // Try to extract exit code from the output
1088
1256
  let exitCode: number | null = 0;
1089
1257
  if (textContent) {
1090
1258
  const exitMatch = textContent.match(/(?:exit code|exited with|exit status)[:\s]*(\d+)/i);
1091
1259
  if (exitMatch) exitCode = Number(exitMatch[1]);
1092
- // Check for common error indicators
1093
1260
  if (textContent.includes("command not found") || textContent.includes("No such file")) {
1094
1261
  exitCode = 1;
1095
1262
  }
1096
1263
  }
1097
1264
 
1098
- (result as any).details = {
1265
+ setResultDetails(result, {
1099
1266
  _type: "bashResult",
1100
1267
  text: textContent ?? "",
1101
1268
  exitCode,
1102
1269
  command: params.command ?? "",
1103
- };
1270
+ });
1104
1271
 
1105
1272
  return result;
1106
1273
  },
1107
1274
 
1108
- renderCall(args: any, theme: any, ctx: any) {
1109
- resolveBaseBackground(theme);
1110
- const cmd = args?.command ?? "";
1275
+ renderCall(args: BashParams, theme: ThemeLike, ctx: RenderContextLike) {
1276
+ resolveBaseBackground(theme);
1277
+ const cmd = args.command ?? "";
1111
1278
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1112
- const timeout = args?.timeout ? ` ${theme.fg("muted", `(${args.timeout}s timeout)`)}` : "";
1279
+ const timeout = args.timeout ? ` ${theme.fg("muted", `(${args.timeout}s timeout)`)}` : "";
1113
1280
  text.setText(
1114
- `${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", cmd.length > 80 ? cmd.slice(0, 77) + "…" : cmd)}${timeout}`,
1281
+ fillToolBackground(
1282
+ `${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", cmd.length > 80 ? `${cmd.slice(0, 77)}…` : cmd)}${timeout}`,
1283
+ ),
1115
1284
  );
1116
1285
  return text;
1117
1286
  },
1118
1287
 
1119
- renderResult(result: any, _opt: any, theme: any, ctx: any) {
1120
- resolveBaseBackground(theme);
1288
+ renderResult(result: ToolResultLike, _opt: unknown, theme: ThemeLike, ctx: RenderContextLike) {
1289
+ resolveBaseBackground(theme);
1121
1290
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1122
1291
 
1123
1292
  if (ctx.isError) {
1124
- const e =
1125
- result.content
1126
- ?.filter((c: any) => c.type === "text")
1127
- .map((c: any) => c.text || "")
1128
- .join("\n") ?? "Error";
1129
- text.setText(`\n${theme.fg("error", e)}`);
1293
+ text.setText(renderToolError(getTextContent(result) || "Error", theme));
1130
1294
  return text;
1131
1295
  }
1132
1296
 
1133
- const d = result.details;
1297
+ const d = result.details as RenderDetails | undefined;
1134
1298
  if (d?._type === "bashResult") {
1135
- const { summary, body } = renderBashOutput(d.text, d.exitCode);
1299
+ const { summary } = renderBashOutput(d.text, d.exitCode);
1136
1300
  const lines = d.text.split("\n");
1137
1301
  const lineCount = lines.length;
1138
1302
  const lineInfo = lineCount > 1 ? ` ${FG_DIM}(${lineCount} lines)${RST}` : "";
1139
1303
  const header = ` ${summary}${lineInfo}`;
1140
1304
 
1141
- // Show output content
1142
1305
  if (d.text.trim()) {
1143
1306
  const maxShow = ctx.expanded ? lineCount : MAX_PREVIEW_LINES;
1144
1307
  const show = lines.slice(0, maxShow);
@@ -1151,15 +1314,16 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1151
1314
  if (lineCount > maxShow) {
1152
1315
  out.push(`${FG_DIM} … ${lineCount - maxShow} more lines${RST}`);
1153
1316
  }
1154
- text.setText(out.join("\n"));
1317
+ text.setText(fillToolBackground(out.join("\n")));
1155
1318
  } else {
1156
- text.setText(header);
1319
+ text.setText(fillToolBackground(header));
1157
1320
  }
1158
1321
  return text;
1159
1322
  }
1160
1323
 
1161
- const fallback = result.content?.[0]?.text ?? "done";
1162
- text.setText(` ${theme.fg("dim", String(fallback).slice(0, 120))}`);
1324
+ const fallback = result.content?.[0];
1325
+ const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "done";
1326
+ text.setText(fillToolBackground(` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`));
1163
1327
  return text;
1164
1328
  },
1165
1329
  });
@@ -1176,59 +1340,56 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1176
1340
  ...origLs,
1177
1341
  name: "ls",
1178
1342
 
1179
- async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
1180
- const result = await origLs.execute(tid, params, sig, upd, ctx);
1181
-
1182
- const textContent = result.content
1183
- ?.filter((c: any) => c.type === "text")
1184
- .map((c: any) => c.text || "")
1185
- .join("\n");
1186
-
1343
+ async execute(
1344
+ tid: string,
1345
+ params: LsParams,
1346
+ sig: AbortSignal | undefined,
1347
+ upd: AgentToolUpdateCallback<unknown> | undefined,
1348
+ ctx: ExtensionContext,
1349
+ ) {
1350
+ const result = (await origLs.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
1351
+ const textContent = getTextContent(result);
1187
1352
  const fp = params.path ?? cwd;
1188
1353
  const entryCount = textContent ? textContent.trim().split("\n").filter(Boolean).length : 0;
1189
1354
 
1190
- (result as any).details = {
1355
+ setResultDetails(result, {
1191
1356
  _type: "lsResult",
1192
1357
  text: textContent ?? "",
1193
1358
  path: fp,
1194
1359
  entryCount,
1195
- };
1360
+ });
1196
1361
 
1197
1362
  return result;
1198
1363
  },
1199
1364
 
1200
- renderCall(args: any, theme: any, ctx: any) {
1201
- resolveBaseBackground(theme);
1202
- const fp = args?.path ?? ".";
1365
+ renderCall(args: LsParams, theme: ThemeLike, ctx: RenderContextLike) {
1366
+ resolveBaseBackground(theme);
1367
+ const fp = args.path ?? ".";
1203
1368
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1204
- text.setText(`${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", sp(fp))}`);
1369
+ text.setText(fillToolBackground(`${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", sp(fp))}`));
1205
1370
  return text;
1206
1371
  },
1207
1372
 
1208
- renderResult(result: any, _opt: any, theme: any, ctx: any) {
1209
- resolveBaseBackground(theme);
1373
+ renderResult(result: ToolResultLike, _opt: unknown, theme: ThemeLike, ctx: RenderContextLike) {
1374
+ resolveBaseBackground(theme);
1210
1375
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1211
1376
 
1212
1377
  if (ctx.isError) {
1213
- const e =
1214
- result.content
1215
- ?.filter((c: any) => c.type === "text")
1216
- .map((c: any) => c.text || "")
1217
- .join("\n") ?? "Error";
1218
- text.setText(`\n${theme.fg("error", e)}`);
1378
+ text.setText(renderToolError(getTextContent(result) || "Error", theme));
1219
1379
  return text;
1220
1380
  }
1221
1381
 
1222
- const d = result.details;
1382
+ const d = result.details as RenderDetails | undefined;
1223
1383
  if (d?._type === "lsResult" && d.text) {
1224
1384
  const tree = renderTree(d.text, d.path);
1225
1385
  const info = `${FG_DIM}${d.entryCount} entries${RST}`;
1226
- text.setText(` ${info}\n${tree}`);
1386
+ text.setText(fillToolBackground(` ${info}\n${tree}`));
1227
1387
  return text;
1228
1388
  }
1229
1389
 
1230
- const fallback = result.content?.[0]?.text ?? "listed";
1231
- text.setText(` ${theme.fg("dim", String(fallback).slice(0, 120))}`);
1390
+ const fallback = result.content?.[0];
1391
+ const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "listed";
1392
+ text.setText(fillToolBackground(` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`));
1232
1393
  return text;
1233
1394
  },
1234
1395
  });
@@ -1245,37 +1406,36 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1245
1406
  ...origFind,
1246
1407
  name: "find",
1247
1408
 
1248
- async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
1409
+ async execute(
1410
+ tid: string,
1411
+ params: FindParams,
1412
+ sig: AbortSignal | undefined,
1413
+ upd: unknown,
1414
+ ctx: ExtensionContext,
1415
+ ) {
1249
1416
  // Try FFF first (frecency-ranked, SIMD-accelerated)
1250
1417
  if (_fffFinder && !_fffFinder.isDestroyed) {
1251
1418
  try {
1252
1419
  const effectiveLimit = Math.max(1, params.limit ?? 200);
1253
- let query = params.pattern ?? "";
1420
+ let query = params.pattern;
1254
1421
  if (params.path) query = `${params.path} ${query}`;
1255
1422
 
1256
1423
  const searchResult = _fffFinder.fileSearch(query, { pageSize: effectiveLimit });
1257
1424
  if (searchResult.ok) {
1258
- const items = searchResult.value.items.slice(0, effectiveLimit);
1259
- let textContent = items.map((i: any) => i.relativePath).join("\n");
1260
- const matchCount = items.length;
1261
-
1425
+ const search: SearchResult = searchResult.value;
1426
+ const items: FileItem[] = search.items.slice(0, effectiveLimit);
1262
1427
  const notices: string[] = [];
1263
1428
  if (_fffPartialIndex) notices.push("Warning: partial file index");
1264
1429
  if (items.length >= effectiveLimit) notices.push(`${effectiveLimit} limit reached`);
1265
- if (searchResult.value.totalMatched > items.length) {
1266
- notices.push(`${searchResult.value.totalMatched} total matches`);
1267
- }
1268
- if (notices.length) textContent += `\n\n[${notices.join(". ")}]`;
1269
-
1270
- return {
1271
- content: [{ type: "text", text: textContent }],
1272
- details: {
1273
- _type: "findResult",
1274
- text: textContent,
1275
- pattern: params.pattern ?? "",
1276
- matchCount,
1277
- },
1278
- };
1430
+ if (search.totalMatched > items.length) notices.push(`${search.totalMatched} total matches`);
1431
+
1432
+ const textContent = appendNotices(items.map((item) => item.relativePath).join("\n"), notices);
1433
+ return makeTextResult<FindResultDetails>(textContent, {
1434
+ _type: "findResult",
1435
+ text: textContent,
1436
+ pattern: params.pattern,
1437
+ matchCount: items.length,
1438
+ });
1279
1439
  }
1280
1440
  } catch {
1281
1441
  /* fall through to SDK */
@@ -1283,45 +1443,42 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1283
1443
  }
1284
1444
 
1285
1445
  // SDK fallback
1286
- const result = await origFind.execute(tid, params, sig, upd, ctx);
1287
-
1288
- const textContent = result.content
1289
- ?.filter((c: any) => c.type === "text")
1290
- .map((c: any) => c.text || "")
1291
- .join("\n");
1292
-
1446
+ const result = await origFind.execute(tid, params, sig, upd as never, ctx);
1447
+ const textContent = getTextContent(result);
1293
1448
  const matchCount = textContent ? textContent.trim().split("\n").filter(Boolean).length : 0;
1294
1449
 
1295
- (result as any).details = {
1450
+ setResultDetails<FindResultDetails>(result, {
1296
1451
  _type: "findResult",
1297
- text: textContent ?? "",
1298
- pattern: params.pattern ?? "",
1452
+ text: textContent,
1453
+ pattern: params.pattern,
1299
1454
  matchCount,
1300
- };
1455
+ });
1301
1456
 
1302
1457
  return result;
1303
1458
  },
1304
1459
 
1305
- renderCall(args: any, theme: any, ctx: any) {
1306
- resolveBaseBackground(theme);
1307
- const pattern = args?.pattern ?? "";
1308
- const path = args?.path ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}` : "";
1460
+ renderCall(args: FindParams, theme: ThemeLike, ctx: RenderContextLike) {
1461
+ resolveBaseBackground(theme);
1462
+ const pattern = args.pattern ?? "";
1463
+ const path = args.path ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}` : "";
1309
1464
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1310
- text.setText(`${theme.fg("toolTitle", theme.bold("find"))} ${theme.fg("accent", pattern)}${path}`);
1465
+ text.setText(
1466
+ fillToolBackground(`${theme.fg("toolTitle", theme.bold("find"))} ${theme.fg("accent", pattern)}${path}`),
1467
+ );
1311
1468
  return text;
1312
1469
  },
1313
1470
 
1314
- renderResult(result: any, _opt: any, theme: any, ctx: any) {
1315
- resolveBaseBackground(theme);
1471
+ renderResult(
1472
+ result: ToolResultLike<FindResultDetails>,
1473
+ _opt: ToolRenderResultOptions,
1474
+ theme: ThemeLike,
1475
+ ctx: RenderContextLike,
1476
+ ) {
1477
+ resolveBaseBackground(theme);
1316
1478
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1317
1479
 
1318
1480
  if (ctx.isError) {
1319
- const e =
1320
- result.content
1321
- ?.filter((c: any) => c.type === "text")
1322
- .map((c: any) => c.text || "")
1323
- .join("\n") ?? "Error";
1324
- text.setText(`\n${theme.fg("error", e)}`);
1481
+ text.setText(renderToolError(getTextContent(result) || "Error", theme));
1325
1482
  return text;
1326
1483
  }
1327
1484
 
@@ -1329,12 +1486,13 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1329
1486
  if (d?._type === "findResult" && d.text) {
1330
1487
  const rendered = renderFindResults(d.text);
1331
1488
  const info = `${FG_DIM}${d.matchCount} files${RST}`;
1332
- text.setText(` ${info}\n${rendered}`);
1489
+ text.setText(fillToolBackground(` ${info}\n${rendered}`));
1333
1490
  return text;
1334
1491
  }
1335
1492
 
1336
- const fallback = result.content?.[0]?.text ?? "found";
1337
- text.setText(` ${theme.fg("dim", String(fallback).slice(0, 120))}`);
1493
+ const fallback = result.content?.[0];
1494
+ const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "found";
1495
+ text.setText(fillToolBackground(` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`));
1338
1496
  return text;
1339
1497
  },
1340
1498
  });
@@ -1351,19 +1509,23 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1351
1509
  ...origGrep,
1352
1510
  name: "grep",
1353
1511
 
1354
- async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
1512
+ async execute(
1513
+ tid: string,
1514
+ params: GrepParams,
1515
+ sig: AbortSignal | undefined,
1516
+ upd: unknown,
1517
+ ctx: ExtensionContext,
1518
+ ) {
1355
1519
  // Try FFF first (SIMD-accelerated, frecency-ranked)
1356
1520
  if (_fffFinder && !_fffFinder.isDestroyed) {
1357
1521
  try {
1358
1522
  const effectiveLimit = Math.max(1, params.limit ?? 100);
1359
- let query = params.pattern ?? "";
1523
+ let query = params.pattern;
1360
1524
  if (params.glob) query = `${params.glob} ${query}`;
1361
1525
  else if (params.path) query = `${params.path} ${query}`;
1362
1526
 
1363
- const mode = params.literal ? "plain" : "regex";
1364
-
1365
1527
  const grepResult = _fffFinder.grep(query, {
1366
- mode,
1528
+ mode: params.literal ? "plain" : "regex",
1367
1529
  smartCase: !params.ignoreCase,
1368
1530
  maxMatchesPerFile: Math.min(effectiveLimit, 50),
1369
1531
  cursor: null,
@@ -1372,31 +1534,23 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1372
1534
  });
1373
1535
 
1374
1536
  if (grepResult.ok) {
1375
- const result = grepResult.value;
1376
- let textContent = fffFormatGrepText(result.items, effectiveLimit);
1377
- const matchCount = Math.min(result.items.length, effectiveLimit);
1378
-
1537
+ const grep: GrepResult = grepResult.value;
1379
1538
  const notices: string[] = [];
1380
1539
  if (_fffPartialIndex) notices.push("Warning: partial file index");
1381
- if (result.items.length >= effectiveLimit) notices.push(`${effectiveLimit} limit reached`);
1382
- if ((result as any).regexFallbackError) {
1383
- notices.push(`Regex failed: ${(result as any).regexFallbackError}, used literal match`);
1384
- }
1385
- if (result.nextCursor) {
1386
- const cursorId = _cursorStore.store(result.nextCursor);
1540
+ if (grep.items.length >= effectiveLimit) notices.push(`${effectiveLimit} limit reached`);
1541
+ if (grep.regexFallbackError) notices.push(`Regex failed: ${grep.regexFallbackError}, used literal match`);
1542
+ if (grep.nextCursor) {
1543
+ const cursorId = _cursorStore.store(grep.nextCursor);
1387
1544
  notices.push(`More results available. Use cursor="${cursorId}" to continue`);
1388
1545
  }
1389
- if (notices.length) textContent += `\n\n[${notices.join(". ")}]`;
1390
-
1391
- return {
1392
- content: [{ type: "text", text: textContent }],
1393
- details: {
1394
- _type: "grepResult",
1395
- text: textContent,
1396
- pattern: params.pattern ?? "",
1397
- matchCount,
1398
- },
1399
- };
1546
+
1547
+ const textContent = appendNotices(fffFormatGrepText(grep.items, effectiveLimit), notices);
1548
+ return makeTextResult<GrepResultDetails>(textContent, {
1549
+ _type: "grepResult",
1550
+ text: textContent,
1551
+ pattern: params.pattern,
1552
+ matchCount: Math.min(grep.items.length, effectiveLimit),
1553
+ });
1400
1554
  }
1401
1555
  } catch {
1402
1556
  /* fall through to SDK */
@@ -1404,51 +1558,45 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1404
1558
  }
1405
1559
 
1406
1560
  // SDK fallback
1407
- const result = await origGrep.execute(tid, params, sig, upd, ctx);
1408
-
1409
- const textContent = result.content
1410
- ?.filter((c: any) => c.type === "text")
1411
- .map((c: any) => c.text || "")
1412
- .join("\n");
1413
-
1414
- const matchCount = textContent
1415
- ? textContent
1416
- .trim()
1417
- .split("\n")
1418
- .filter((l: string) => l.match(/^.+?[:\-]\d+[:\-]/)).length
1419
- : 0;
1561
+ const result = await origGrep.execute(tid, params, sig, upd as never, ctx);
1562
+ const textContent = getTextContent(result);
1563
+ const matchCount = textContent ? countRipgrepMatches(textContent) : 0;
1420
1564
 
1421
- (result as any).details = {
1565
+ setResultDetails<GrepResultDetails>(result, {
1422
1566
  _type: "grepResult",
1423
- text: textContent ?? "",
1424
- pattern: params.pattern ?? "",
1567
+ text: textContent,
1568
+ pattern: params.pattern,
1425
1569
  matchCount,
1426
- };
1570
+ });
1427
1571
 
1428
1572
  return result;
1429
1573
  },
1430
1574
 
1431
- renderCall(args: any, theme: any, ctx: any) {
1432
- resolveBaseBackground(theme);
1433
- const pattern = args?.pattern ?? "";
1434
- const path = args?.path ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}` : "";
1435
- const glob = args?.glob ? ` ${theme.fg("muted", `(${args.glob})`)}` : "";
1575
+ renderCall(args: GrepParams, theme: ThemeLike, ctx: RenderContextLike) {
1576
+ resolveBaseBackground(theme);
1577
+ const pattern = args.pattern ?? "";
1578
+ const path = args.path ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}` : "";
1579
+ const glob = args.glob ? ` ${theme.fg("muted", `(${args.glob})`)}` : "";
1436
1580
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1437
- text.setText(`${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", pattern)}${path}${glob}`);
1581
+ text.setText(
1582
+ fillToolBackground(
1583
+ `${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", pattern)}${path}${glob}`,
1584
+ ),
1585
+ );
1438
1586
  return text;
1439
1587
  },
1440
1588
 
1441
- renderResult(result: any, _opt: any, theme: any, ctx: any) {
1442
- resolveBaseBackground(theme);
1589
+ renderResult(
1590
+ result: ToolResultLike<GrepResultDetails>,
1591
+ _opt: ToolRenderResultOptions,
1592
+ theme: ThemeLike,
1593
+ ctx: RenderContextLike<GrepRenderState>,
1594
+ ) {
1595
+ resolveBaseBackground(theme);
1443
1596
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1444
1597
 
1445
1598
  if (ctx.isError) {
1446
- const e =
1447
- result.content
1448
- ?.filter((c: any) => c.type === "text")
1449
- .map((c: any) => c.text || "")
1450
- .join("\n") ?? "Error";
1451
- text.setText(`\n${theme.fg("error", e)}`);
1599
+ text.setText(renderToolError(getTextContent(result) || "Error", theme));
1452
1600
  return text;
1453
1601
  }
1454
1602
 
@@ -1458,22 +1606,23 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1458
1606
  if (ctx.state._gk !== key) {
1459
1607
  ctx.state._gk = key;
1460
1608
  const info = `${FG_DIM}${d.matchCount} matches${RST}`;
1461
- ctx.state._gt = ` ${info}`;
1609
+ ctx.state._gt = fillToolBackground(` ${info}`);
1462
1610
 
1463
1611
  renderGrepResults(d.text, d.pattern)
1464
1612
  .then((rendered: string) => {
1465
1613
  if (ctx.state._gk !== key) return;
1466
- ctx.state._gt = ` ${info}\n${rendered}`;
1614
+ ctx.state._gt = fillToolBackground(` ${info}\n${rendered}`);
1467
1615
  ctx.invalidate();
1468
1616
  })
1469
1617
  .catch(() => {});
1470
1618
  }
1471
- text.setText(ctx.state._gt ?? ` ${FG_DIM}${d.matchCount} matches${RST}`);
1619
+ text.setText(ctx.state._gt ?? fillToolBackground(` ${FG_DIM}${d.matchCount} matches${RST}`));
1472
1620
  return text;
1473
1621
  }
1474
1622
 
1475
- const fallback = result.content?.[0]?.text ?? "searched";
1476
- text.setText(` ${theme.fg("dim", String(fallback).slice(0, 120))}`);
1623
+ const fallback = result.content?.[0];
1624
+ const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "searched";
1625
+ text.setText(fillToolBackground(` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`));
1477
1626
  return text;
1478
1627
  },
1479
1628
  });
@@ -1493,8 +1642,7 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1493
1642
  "Patterns are literal text — never escape special characters.",
1494
1643
  "Use the constraints parameter for file filtering ('*.rs', 'src/', '!test/').",
1495
1644
  ].join(" "),
1496
- promptSnippet:
1497
- "Multi-pattern OR search across file contents (FFF: SIMD-accelerated, frecency-ranked)",
1645
+ promptSnippet: "Multi-pattern OR search across file contents (FFF: SIMD-accelerated, frecency-ranked)",
1498
1646
  promptGuidelines: [
1499
1647
  "Use multi_grep when you need to find multiple identifiers at once (OR logic).",
1500
1648
  "Include all naming conventions: snake_case, PascalCase, camelCase variants.",
@@ -1526,21 +1674,21 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1526
1674
  required: ["patterns"],
1527
1675
  },
1528
1676
 
1529
- async execute(_tid: string, params: any, sig: any, _upd: any, _ctx: any) {
1530
- if (sig?.aborted) return { content: [{ type: "text", text: "Aborted" }], details: {} };
1677
+ async execute(
1678
+ _tid: string,
1679
+ params: MultiGrepParams,
1680
+ sig: AbortSignal | undefined,
1681
+ _upd: unknown,
1682
+ _ctx: ExtensionContext,
1683
+ ) {
1684
+ if (sig?.aborted) return makeTextResult("Aborted", {});
1531
1685
 
1532
1686
  if (!params.patterns || params.patterns.length === 0) {
1533
- return {
1534
- content: [{ type: "text", text: "Error: patterns array must have at least 1 element" }],
1535
- details: { error: "empty patterns" },
1536
- };
1687
+ return makeTextResult("Error: patterns array must have at least 1 element", { error: "empty patterns" });
1537
1688
  }
1538
1689
 
1539
1690
  if (!_fffFinder || _fffFinder.isDestroyed) {
1540
- return {
1541
- content: [{ type: "text", text: "FFF not initialized. Wait for session start or run /fff-rescan." }],
1542
- details: {},
1543
- };
1691
+ return makeTextResult("FFF not initialized. Wait for session start or run /fff-rescan.", {});
1544
1692
  }
1545
1693
 
1546
1694
  try {
@@ -1557,68 +1705,61 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1557
1705
  });
1558
1706
 
1559
1707
  if (!grepResult.ok) {
1560
- return {
1561
- content: [{ type: "text", text: `multi_grep error: ${grepResult.error}` }],
1562
- details: { error: grepResult.error },
1563
- };
1708
+ return makeTextResult(`multi_grep error: ${grepResult.error}`, { error: grepResult.error });
1564
1709
  }
1565
1710
 
1566
- const result = grepResult.value;
1567
- let textContent = fffFormatGrepText(result.items, effectiveLimit);
1568
- const matchCount = Math.min(result.items.length, effectiveLimit);
1569
-
1711
+ const grep: GrepResult = grepResult.value;
1570
1712
  const notices: string[] = [];
1571
1713
  if (_fffPartialIndex) notices.push("Warning: partial file index");
1572
- if (result.items.length >= effectiveLimit) notices.push(`${effectiveLimit} limit reached`);
1573
- if (result.nextCursor) {
1574
- const cursorId = _cursorStore.store(result.nextCursor);
1714
+ if (grep.items.length >= effectiveLimit) notices.push(`${effectiveLimit} limit reached`);
1715
+ if (grep.nextCursor) {
1716
+ const cursorId = _cursorStore.store(grep.nextCursor);
1575
1717
  notices.push(`More results: cursor="${cursorId}"`);
1576
1718
  }
1577
- if (notices.length) textContent += `\n\n[${notices.join(". ")}]`;
1578
-
1579
- return {
1580
- content: [{ type: "text", text: textContent }],
1581
- details: {
1582
- _type: "grepResult",
1583
- text: textContent,
1584
- pattern: params.patterns.join(" | "),
1585
- matchCount,
1586
- },
1587
- };
1588
- } catch (e: any) {
1589
- return {
1590
- content: [{ type: "text", text: `multi_grep error: ${e.message}` }],
1591
- details: { error: e.message },
1592
- };
1719
+
1720
+ const textContent = appendNotices(fffFormatGrepText(grep.items, effectiveLimit), notices);
1721
+ return makeTextResult<GrepResultDetails>(textContent, {
1722
+ _type: "grepResult",
1723
+ text: textContent,
1724
+ pattern: params.patterns.join(" | "),
1725
+ matchCount: Math.min(grep.items.length, effectiveLimit),
1726
+ });
1727
+ } catch (error: unknown) {
1728
+ const message = getErrorMessage(error);
1729
+ return makeTextResult(`multi_grep error: ${message}`, { error: message });
1593
1730
  }
1594
1731
  },
1595
1732
 
1596
- renderCall(args: any, theme: any, ctx: any) {
1733
+ renderCall(args: MultiGrepParams, theme: ThemeLike, ctx: RenderContextLike) {
1597
1734
  resolveBaseBackground(theme);
1598
- const patterns = args?.patterns ?? [];
1599
- const constraints = args?.constraints;
1735
+ const patterns = args.patterns ?? [];
1736
+ const constraints = args.constraints;
1600
1737
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1601
1738
  let content =
1602
1739
  theme.fg("toolTitle", theme.bold("multi_grep")) +
1603
1740
  " " +
1604
- theme.fg("accent", patterns.map((p: string) => `"${p}"`).join(", "));
1741
+ theme.fg("accent", patterns.map((p) => `"${p}"`).join(", "));
1605
1742
  if (constraints) content += theme.fg("muted", ` (${constraints})`);
1606
1743
  text.setText(content);
1607
1744
  return text;
1608
1745
  },
1609
1746
 
1610
- renderResult(result: any, _opt: any, theme: any, ctx: any) {
1747
+ renderResult(
1748
+ result: ToolResultLike<GrepResultDetails | { error?: string }>,
1749
+ _opt: ToolRenderResultOptions,
1750
+ theme: ThemeLike,
1751
+ ctx: RenderContextLike<MultiGrepRenderState>,
1752
+ ) {
1611
1753
  resolveBaseBackground(theme);
1612
1754
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1613
1755
 
1614
1756
  if (ctx.isError) {
1615
- const e = result.content?.[0]?.text ?? "Error";
1616
- text.setText(`\n${theme.fg("error", e)}`);
1757
+ text.setText(`\n${theme.fg("error", getTextContent(result) || "Error")}`);
1617
1758
  return text;
1618
1759
  }
1619
1760
 
1620
1761
  const d = result.details;
1621
- if (d?._type === "grepResult" && d.text) {
1762
+ if (d && "_type" in d && d._type === "grepResult" && d.text) {
1622
1763
  const key = `mgrep:${d.pattern}:${d.matchCount}:${termW()}`;
1623
1764
  if (ctx.state._mgk !== key) {
1624
1765
  ctx.state._mgk = key;
@@ -1637,8 +1778,9 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1637
1778
  return text;
1638
1779
  }
1639
1780
 
1640
- const fallback = result.content?.[0]?.text ?? "searched";
1641
- text.setText(` ${theme.fg("dim", String(fallback).slice(0, 120))}`);
1781
+ const fallback = result.content?.[0];
1782
+ const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "searched";
1783
+ text.setText(` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`);
1642
1784
  return text;
1643
1785
  },
1644
1786
  });
@@ -1651,7 +1793,7 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1651
1793
  if (_fffModule) {
1652
1794
  pi.registerCommand("fff-health", {
1653
1795
  description: "Show FFF file finder health and indexer status",
1654
- handler: async (_args: any, ctx: any) => {
1796
+ handler: async (_args: string, ctx: CommandContextLike) => {
1655
1797
  if (!_fffFinder || _fffFinder.isDestroyed) {
1656
1798
  ctx.ui?.notify?.("FFF not initialized", "warning");
1657
1799
  return;
@@ -1675,7 +1817,9 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1675
1817
 
1676
1818
  const progress = _fffFinder.getScanProgress();
1677
1819
  if (progress.ok) {
1678
- lines.push(`Scanning: ${progress.value.isScanning ? "yes" : "no"} (${progress.value.scannedFilesCount} files)`);
1820
+ lines.push(
1821
+ `Scanning: ${progress.value.isScanning ? "yes" : "no"} (${progress.value.scannedFilesCount} files)`,
1822
+ );
1679
1823
  }
1680
1824
 
1681
1825
  ctx.ui?.notify?.(lines.join("\n"), "info");
@@ -1684,7 +1828,7 @@ export default function piPrettyExtension(pi: any, deps?: PiPrettyDeps): void {
1684
1828
 
1685
1829
  pi.registerCommand("fff-rescan", {
1686
1830
  description: "Trigger FFF to rescan files",
1687
- handler: async (_args: any, ctx: any) => {
1831
+ handler: async (_args: string, ctx: CommandContextLike) => {
1688
1832
  if (!_fffFinder || _fffFinder.isDestroyed) {
1689
1833
  ctx.ui?.notify?.("FFF not initialized", "warning");
1690
1834
  return;