@xynogen/pix-9router 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,61 +18,150 @@ function stubComponent() {
18
18
  return { component, get: () => captured };
19
19
  }
20
20
 
21
+ function context(stub: ReturnType<typeof stubComponent>, overrides: Record<string, unknown> = {}) {
22
+ return {
23
+ lastComponent: stub.component as never,
24
+ isError: false,
25
+ state: {},
26
+ expanded: false,
27
+ invalidate: () => {},
28
+ ...overrides,
29
+ } as never;
30
+ }
31
+
32
+ const config = {
33
+ tool: "fetch",
34
+ target: (_details: { _type: "fetchResult"; chars: number }) => "https://example.com",
35
+ meta: (details: { _type: "fetchResult"; chars: number }) => `${details.chars} chars`,
36
+ };
37
+
21
38
  test("renderCall shows title + accent arg", () => {
22
39
  const stub = stubComponent();
23
40
  const rc = makeRenderCall<{ url: string }>("fetch", (a) => a.url);
24
- rc({ url: "https://x.com" }, theme, {
25
- lastComponent: stub.component as never,
26
- isError: false,
27
- });
41
+ rc({ url: "https://x.com" }, theme, context(stub));
28
42
  expect(stub.get()).toBe("[toolTitle]*fetch* [accent]https://x.com");
29
43
  });
30
44
 
31
- test("renderResult dims every line", () => {
45
+ test("renderCall hides only an effectively collapsed call row", () => {
46
+ const stub = stubComponent();
47
+ const rc = makeRenderCall<{ url: string }>("fetch", (a) => a.url);
48
+ rc({ url: "https://x.com" }, theme, context(stub, { state: { collapsed: true } }));
49
+ expect(stub.get()).toBe("");
50
+
51
+ rc(
52
+ { url: "https://x.com" },
53
+ theme,
54
+ context(stub, { state: { collapsed: true }, expanded: true }),
55
+ );
56
+ expect(stub.get()).toBe("[toolTitle]*fetch* [accent]https://x.com");
57
+ });
58
+
59
+ test("renderResult renders structured compact details after collapse", () => {
60
+ const stub = stubComponent();
61
+ const rr = makeRenderResult(config);
62
+ rr(
63
+ {
64
+ content: [{ type: "text", text: "full body" }],
65
+ details: { _type: "fetchResult", chars: 9 },
66
+ } as never,
67
+ { expanded: false, isPartial: false },
68
+ theme,
69
+ context(stub, { state: { collapsed: true } }),
70
+ );
71
+ expect(stub.get()).toContain("[success]✓");
72
+ expect(stub.get()).toContain("https://example.com");
73
+ expect(stub.get()).toContain("9 chars");
74
+ });
75
+
76
+ test("partial updates keep detail visible and do not schedule collapse", () => {
32
77
  const stub = stubComponent();
33
- const rr = makeRenderResult();
78
+ const state: Record<string, unknown> = {};
79
+ const rr = makeRenderResult(config);
34
80
  rr(
35
- { content: [{ type: "text", text: "a\nb" }] } as never,
81
+ {
82
+ content: [{ type: "text", text: "streaming body" }],
83
+ details: { _type: "fetchResult", chars: 14 },
84
+ } as never,
85
+ { expanded: false, isPartial: true },
86
+ theme,
87
+ context(stub, { state }),
88
+ );
89
+ expect(stub.get()).toContain("streaming body");
90
+ expect(state.timer).toBeUndefined();
91
+ });
92
+
93
+ test("expanded mode restores full joined text", () => {
94
+ const stub = stubComponent();
95
+ const rr = makeRenderResult(config);
96
+ rr(
97
+ {
98
+ content: [
99
+ { type: "text", text: "first" },
100
+ { type: "image", data: "ignored", mimeType: "image/png" },
101
+ { type: "text", text: "second" },
102
+ ],
103
+ details: { _type: "fetchResult", chars: 12 },
104
+ } as never,
36
105
  { expanded: true, isPartial: false },
37
106
  theme,
38
- { lastComponent: stub.component as never, isError: false },
107
+ context(stub, { state: { collapsed: true }, expanded: true }),
39
108
  );
40
- expect(stub.get()).toBe(" [dim]a\n [dim]b");
109
+ expect(stub.get()).toBe(" [dim]first\n [dim]second");
41
110
  });
42
111
 
43
- test("renderResult caps preview when not expanded", () => {
112
+ test("renderResult caps the normal preview", () => {
44
113
  const stub = stubComponent();
45
- const rr = makeRenderResult();
114
+ const rr = makeRenderResult(config);
46
115
  const body = Array.from({ length: 37 }, (_, i) => `L${i}`).join("\n");
47
116
  rr(
48
- { content: [{ type: "text", text: body }] } as never,
117
+ {
118
+ content: [{ type: "text", text: body }],
119
+ details: { _type: "fetchResult", chars: body.length },
120
+ } as never,
49
121
  { expanded: false, isPartial: false },
50
122
  theme,
51
- { lastComponent: stub.component as never, isError: false },
123
+ context(stub, { state: { timer: 1 } }),
52
124
  );
53
- expect(stub.get()).toContain("… 5 more lines"); // 37 lines − 32 cap
125
+ expect(stub.get()).toContain("… 5 more lines");
54
126
  });
55
127
 
56
- test("renderResult renders empty body marker", () => {
57
- const stub = stubComponent();
58
- const rr = makeRenderResult();
128
+ test("warning and error callbacks select shared status glyphs", () => {
129
+ for (const [status, glyph] of [
130
+ ["warning", "⚡"],
131
+ ["error", "✗"],
132
+ ] as const) {
133
+ const stub = stubComponent();
134
+ const rr = makeRenderResult({ ...config, status: () => status });
135
+ rr(
136
+ {
137
+ content: [{ type: "text", text: "body" }],
138
+ details: { _type: "fetchResult", chars: 4 },
139
+ } as never,
140
+ { expanded: false, isPartial: false },
141
+ theme,
142
+ context(stub, { state: { collapsed: true } }),
143
+ );
144
+ expect(stub.get()).toContain(`[${status}]${glyph}`);
145
+ }
146
+ });
147
+
148
+ test("renderResult renders empty and errors without compacting", () => {
149
+ const rr = makeRenderResult(config);
150
+ const empty = stubComponent();
59
151
  rr(
60
152
  { content: [{ type: "text", text: " " }] } as never,
61
153
  { expanded: false, isPartial: false },
62
154
  theme,
63
- { lastComponent: stub.component as never, isError: false },
155
+ context(empty),
64
156
  );
65
- expect(stub.get()).toBe(" [dim](empty)");
66
- });
157
+ expect(empty.get()).toBe(" [dim](empty)");
67
158
 
68
- test("renderResult uses error token on failure", () => {
69
- const stub = stubComponent();
70
- const rr = makeRenderResult();
159
+ const failed = stubComponent();
71
160
  rr(
72
161
  { content: [{ type: "text", text: "boom" }] } as never,
73
162
  { expanded: false, isPartial: false },
74
163
  theme,
75
- { lastComponent: stub.component as never, isError: true },
164
+ context(failed, { isError: true, state: { collapsed: true } }),
76
165
  );
77
- expect(stub.get()).toBe(" [error]boom");
166
+ expect(failed.get()).toBe(" [error]boom");
78
167
  });
package/src/render.ts CHANGED
@@ -16,6 +16,12 @@ import type {
16
16
  ToolRenderResultOptions,
17
17
  } from "@earendil-works/pi-coding-agent";
18
18
  import type { Component } from "@earendil-works/pi-tui";
19
+ import { type CollapseState, tickCollapse } from "@xynogen/pix-data/collapse";
20
+ import {
21
+ type CollapsedToolStatus,
22
+ formatCollapsedToolRow,
23
+ hideCollapsedToolCall,
24
+ } from "@xynogen/pix-pretty/utils";
19
25
 
20
26
  interface TextLike extends Component {
21
27
  setText(text: string): void;
@@ -26,6 +32,16 @@ interface TextLike extends Component {
26
32
  interface RenderCtx {
27
33
  lastComponent: Component | undefined;
28
34
  isError: boolean;
35
+ state: Record<string, unknown>;
36
+ expanded: boolean;
37
+ invalidate: () => void;
38
+ }
39
+
40
+ interface CompactRendererConfig<TDetails> {
41
+ tool: string;
42
+ target: (details: TDetails) => string;
43
+ meta: (details: TDetails) => string;
44
+ status?: (details: TDetails) => CollapsedToolStatus;
29
45
  }
30
46
 
31
47
  type TextCtor = new (text?: string, padX?: number, padY?: number) => TextLike;
@@ -51,9 +67,11 @@ function getText(lastComponent: Component | undefined): TextLike {
51
67
  throw new Error("pi-tui Text component unavailable");
52
68
  }
53
69
 
54
- function firstText(result: AgentToolResult<unknown>): string {
55
- const block = result.content?.find((c) => c.type === "text");
56
- return (block as { text?: string } | undefined)?.text ?? "";
70
+ function allText(result: AgentToolResult<unknown>): string {
71
+ return (result.content ?? [])
72
+ .filter((block): block is { type: "text"; text: string } => block.type === "text")
73
+ .map((block) => block.text)
74
+ .join("\n");
57
75
  }
58
76
 
59
77
  function dimBody(body: string, theme: Theme, expanded: boolean): string {
@@ -72,14 +90,20 @@ function dimBody(body: string, theme: Theme, expanded: boolean): string {
72
90
  export function makeRenderCall<TArgs>(title: string, pickArg: (args: TArgs) => string) {
73
91
  return (args: TArgs, theme: Theme, ctx: RenderCtx): Component => {
74
92
  const text = getText(ctx.lastComponent);
93
+ if (
94
+ hideCollapsedToolCall(ctx.state as CollapseState, ctx.expanded, (value) =>
95
+ text.setText(value),
96
+ )
97
+ )
98
+ return text;
75
99
  const arg = pickArg(args);
76
100
  text.setText(`${theme.fg("toolTitle", theme.bold(title))} ${theme.fg("accent", arg)}`);
77
101
  return text;
78
102
  };
79
103
  }
80
104
 
81
- /** Build a `renderResult` that dims the whole result body. */
82
- export function makeRenderResult() {
105
+ /** Build a `renderResult` with a bounded detail preview and optional compact terminal row. */
106
+ export function makeRenderResult<TDetails>(config?: CompactRendererConfig<TDetails>) {
83
107
  return (
84
108
  result: AgentToolResult<unknown>,
85
109
  opts: ToolRenderResultOptions,
@@ -87,7 +111,7 @@ export function makeRenderResult() {
87
111
  ctx: RenderCtx,
88
112
  ): Component => {
89
113
  const text = getText(ctx.lastComponent);
90
- const body = firstText(result);
114
+ const body = allText(result);
91
115
 
92
116
  if (ctx.isError) {
93
117
  text.setText(` ${theme.fg("error", body || "Error")}`);
@@ -98,6 +122,25 @@ export function makeRenderResult() {
98
122
  return text;
99
123
  }
100
124
 
125
+ const details = result.details as TDetails | undefined;
126
+ if (
127
+ !opts.isPartial &&
128
+ config &&
129
+ details &&
130
+ tickCollapse(config.tool, ctx.state as CollapseState, ctx.invalidate, opts.expanded)
131
+ ) {
132
+ text.setText(
133
+ formatCollapsedToolRow(
134
+ theme,
135
+ config.tool,
136
+ config.target(details),
137
+ config.meta(details),
138
+ config.status?.(details) ?? "success",
139
+ ),
140
+ );
141
+ return text;
142
+ }
143
+
101
144
  text.setText(dimBody(body, theme, opts.expanded));
102
145
  return text;
103
146
  };
package/src/search.ts CHANGED
@@ -15,6 +15,35 @@ import { makeRenderCall, makeRenderResult } from "./render.js";
15
15
 
16
16
  const REQUEST_TIMEOUT_MS = 30_000;
17
17
 
18
+ type RouterOutcome = "running" | "fallback" | "success" | "cancelled" | "error";
19
+ type SearchType = "web" | "news";
20
+
21
+ export interface SearchResultDetails {
22
+ _type: "searchResult";
23
+ outcome: RouterOutcome;
24
+ query: string;
25
+ searchType: SearchType;
26
+ source?: "api" | "curl-fallback" | "failed";
27
+ resultCount?: number;
28
+ }
29
+
30
+ export interface SearchParams {
31
+ query: string;
32
+ search_type?: SearchType;
33
+ max_results?: number;
34
+ }
35
+
36
+ interface SearchResult {
37
+ content: { type: "text"; text: string }[];
38
+ details: SearchResultDetails;
39
+ isError?: boolean;
40
+ }
41
+
42
+ interface SearchDependencies {
43
+ apiPost: typeof apiPost;
44
+ curl: typeof curl;
45
+ }
46
+
18
47
  function auth(): string | undefined {
19
48
  return process.env.ROUTER_API_KEY;
20
49
  }
@@ -84,25 +113,22 @@ interface SearchResultItem {
84
113
  author?: string | null;
85
114
  }
86
115
 
87
- /**
88
- * Format a raw exa search response as a compact markdown list.
89
- * Falls back to pretty-printed JSON when the shape is unexpected.
90
- */
91
- export function formatSearchResults(raw: string): string {
116
+ /** Parse and format a raw exa search response with its authoritative result count. */
117
+ export function parseSearchResults(raw: string): { text: string; count: number } {
92
118
  let parsed: unknown;
93
119
  try {
94
120
  parsed = JSON.parse(raw);
95
121
  } catch {
96
- return raw.slice(0, 20_000);
122
+ return { text: raw.slice(0, 20_000), count: 0 };
97
123
  }
98
124
 
99
125
  const obj = parsed as Record<string, unknown>;
100
126
  const results = obj?.results;
101
127
  if (!Array.isArray(results)) {
102
- return JSON.stringify(parsed, null, 2).slice(0, 20_000);
128
+ return { text: JSON.stringify(parsed, null, 2).slice(0, 20_000), count: 0 };
103
129
  }
104
130
 
105
- if (results.length === 0) return "No results.";
131
+ if (results.length === 0) return { text: "No results.", count: 0 };
106
132
 
107
133
  const lines: string[] = [];
108
134
  results.forEach((r: SearchResultItem, i: number) => {
@@ -119,7 +145,148 @@ export function formatSearchResults(raw: string): string {
119
145
  lines.push("");
120
146
  });
121
147
 
122
- return lines.join("\n").trim().slice(0, 20_000);
148
+ return {
149
+ text: lines.join("\n").trim().slice(0, 20_000),
150
+ count: results.length,
151
+ };
152
+ }
153
+
154
+ /** Format a raw exa search response as a compact markdown list. */
155
+ export function formatSearchResults(raw: string): string {
156
+ return parseSearchResults(raw).text;
157
+ }
158
+
159
+ function isCancelled(error: unknown, signal: AbortSignal | undefined): boolean {
160
+ return signal?.aborted === true || (error instanceof DOMException && error.name === "AbortError");
161
+ }
162
+
163
+ export async function executeSearch(
164
+ params: SearchParams,
165
+ signal?: AbortSignal,
166
+ onUpdate?: (result: SearchResult) => void,
167
+ dependencies: SearchDependencies = { apiPost, curl },
168
+ ): Promise<SearchResult> {
169
+ const max = Math.min(params.max_results ?? 5, 10);
170
+ const searchType = params.search_type ?? "web";
171
+ let apiMsg = "";
172
+
173
+ try {
174
+ onUpdate?.({
175
+ content: [{ type: "text", text: `Searching (${searchType}): ${params.query}...` }],
176
+ details: {
177
+ _type: "searchResult",
178
+ outcome: "running",
179
+ query: params.query,
180
+ searchType,
181
+ },
182
+ });
183
+
184
+ const raw = await dependencies.apiPost(
185
+ "/search",
186
+ {
187
+ model: "exa",
188
+ query: params.query,
189
+ search_type: searchType,
190
+ max_results: max,
191
+ },
192
+ signal,
193
+ );
194
+ const parsed = parseSearchResults(raw);
195
+
196
+ return {
197
+ content: [{ type: "text", text: parsed.text }],
198
+ details: {
199
+ _type: "searchResult",
200
+ outcome: "success",
201
+ query: params.query,
202
+ searchType,
203
+ source: "api",
204
+ resultCount: parsed.count,
205
+ },
206
+ };
207
+ } catch (apiErr: unknown) {
208
+ apiMsg = apiErr instanceof Error ? apiErr.message : String(apiErr);
209
+ if (isCancelled(apiErr, signal)) {
210
+ return {
211
+ content: [{ type: "text", text: `Search cancelled.\nAPI: ${apiMsg}` }],
212
+ details: {
213
+ _type: "searchResult",
214
+ outcome: "cancelled",
215
+ query: params.query,
216
+ searchType,
217
+ },
218
+ };
219
+ }
220
+ onUpdate?.({
221
+ content: [
222
+ {
223
+ type: "text",
224
+ text: `API failed: ${apiMsg}\nFalling back to curl...`,
225
+ },
226
+ ],
227
+ details: {
228
+ _type: "searchResult",
229
+ outcome: "running",
230
+ query: params.query,
231
+ searchType,
232
+ },
233
+ });
234
+ }
235
+
236
+ try {
237
+ const body = JSON.stringify({
238
+ model: "exa",
239
+ query: params.query,
240
+ search_type: searchType,
241
+ max_results: max,
242
+ });
243
+ const raw = await dependencies.curl([
244
+ "-X",
245
+ "POST",
246
+ "-H",
247
+ "Content-Type: application/json",
248
+ ...(auth() ? ["-H", `Authorization: Bearer ${auth()}`] : []),
249
+ "-d",
250
+ body,
251
+ `${routerBaseUrl()}/search`,
252
+ ]);
253
+ const parsed = parseSearchResults(raw);
254
+
255
+ return {
256
+ content: [
257
+ {
258
+ type: "text",
259
+ text: `[FALLBACK — curl] API called via curl instead of fetch.\n\n${parsed.text.slice(0, 19_500)}`,
260
+ },
261
+ ],
262
+ details: {
263
+ _type: "searchResult",
264
+ outcome: "fallback",
265
+ query: params.query,
266
+ searchType,
267
+ source: "curl-fallback",
268
+ resultCount: parsed.count,
269
+ },
270
+ };
271
+ } catch (curlErr: unknown) {
272
+ const curlMsg = curlErr instanceof Error ? curlErr.message : String(curlErr);
273
+ return {
274
+ content: [
275
+ {
276
+ type: "text",
277
+ text: `Search failed (both API and curl).\nAPI: ${apiMsg}\nCurl: ${curlMsg}`,
278
+ },
279
+ ],
280
+ details: {
281
+ _type: "searchResult",
282
+ outcome: "error",
283
+ query: params.query,
284
+ searchType,
285
+ source: "failed",
286
+ },
287
+ isError: true,
288
+ };
289
+ }
123
290
  }
124
291
 
125
292
  export default function registerSearch(pi: ExtensionAPI): void {
@@ -133,8 +300,33 @@ export default function registerSearch(pi: ExtensionAPI): void {
133
300
  "Use search when you need up-to-date information or facts from the web.",
134
301
  "Set search_type='web' for general web results, search_type='news' for recent news articles.",
135
302
  ],
136
- renderCall: makeRenderCall("search", (args) => String(args.query ?? "")),
137
- renderResult: makeRenderResult(),
303
+ renderCall: makeRenderCall<unknown>("search", (args) =>
304
+ String((args as Partial<SearchParams>).query ?? ""),
305
+ ),
306
+ renderResult: (
307
+ (renderTerminal) => (result, options, theme, context) =>
308
+ renderTerminal(result, options, theme, {
309
+ ...context,
310
+ isError: context.isError && options.expanded,
311
+ })
312
+ )(
313
+ makeRenderResult<SearchResultDetails>({
314
+ tool: "search",
315
+ target: (details) => `“${details.query}”`,
316
+ meta: (details) => {
317
+ if (details.outcome === "error") return "failed";
318
+ if (details.outcome === "cancelled") return "cancelled";
319
+ const count = details.resultCount ?? 0;
320
+ return `${count} ${count === 1 ? "result" : "results"} · ${details.searchType}`;
321
+ },
322
+ status: (details) =>
323
+ details.outcome === "error"
324
+ ? "error"
325
+ : details.outcome === "fallback" || details.outcome === "cancelled"
326
+ ? "warning"
327
+ : "success",
328
+ }),
329
+ ),
138
330
  parameters: Type.Object({
139
331
  query: Type.String({ description: "Search query" }),
140
332
  search_type: StringEnum(["web", "news"] as const, {
@@ -150,94 +342,7 @@ export default function registerSearch(pi: ExtensionAPI): void {
150
342
  }),
151
343
 
152
344
  async execute(_toolCallId, params, signal, onUpdate) {
153
- const max = Math.min(params.max_results ?? 5, 10);
154
- const searchType = params.search_type ?? "web";
155
- let apiMsg = "";
156
-
157
- try {
158
- onUpdate?.({
159
- content: [
160
- {
161
- type: "text",
162
- text: `Searching (${searchType}): ${params.query}...`,
163
- },
164
- ],
165
- details: undefined,
166
- });
167
-
168
- const raw = await apiPost(
169
- "/search",
170
- {
171
- model: "exa",
172
- query: params.query,
173
- search_type: searchType,
174
- max_results: max,
175
- },
176
- signal,
177
- );
178
-
179
- return {
180
- content: [
181
- {
182
- type: "text",
183
- text: formatSearchResults(raw),
184
- },
185
- ],
186
- details: { source: "api" },
187
- };
188
- } catch (apiErr: unknown) {
189
- apiMsg = apiErr instanceof Error ? apiErr.message : String(apiErr);
190
- onUpdate?.({
191
- content: [
192
- {
193
- type: "text",
194
- text: `API failed: ${apiMsg}\nFalling back to curl...`,
195
- },
196
- ],
197
- details: undefined,
198
- });
199
- }
200
-
201
- try {
202
- const body = JSON.stringify({
203
- model: "exa",
204
- query: params.query,
205
- search_type: searchType,
206
- max_results: max,
207
- });
208
- const raw = await curl([
209
- "-X",
210
- "POST",
211
- "-H",
212
- "Content-Type: application/json",
213
- ...(auth() ? ["-H", `Authorization: Bearer ${auth()}`] : []),
214
- "-d",
215
- body,
216
- `${routerBaseUrl()}/search`,
217
- ]);
218
-
219
- return {
220
- content: [
221
- {
222
- type: "text",
223
- text: `[FALLBACK — curl] API called via curl instead of fetch.\n\n${formatSearchResults(raw).slice(0, 19_500)}`,
224
- },
225
- ],
226
- details: { source: "curl-fallback" },
227
- };
228
- } catch (curlErr: unknown) {
229
- const curlMsg = curlErr instanceof Error ? curlErr.message : String(curlErr);
230
- return {
231
- content: [
232
- {
233
- type: "text",
234
- text: `Search failed (both API and curl).\nAPI: ${apiMsg}\nCurl: ${curlMsg}`,
235
- },
236
- ],
237
- details: { source: "failed" },
238
- isError: true,
239
- };
240
- }
345
+ return executeSearch(params as SearchParams, signal, onUpdate);
241
346
  },
242
347
  });
243
348
  }