@xynogen/pix-9router 0.1.1 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-9router",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Pi extension — 9Router provider + fetch/search tools via router API",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/fetch.ts CHANGED
@@ -11,6 +11,7 @@ import { StringEnum } from "@earendil-works/pi-ai";
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  import { Type } from "typebox";
13
13
  import { routerBaseUrl } from "./data.js";
14
+ import { makeRenderCall, makeRenderResult } from "./render.js";
14
15
 
15
16
  const REQUEST_TIMEOUT_MS = 30_000;
16
17
 
@@ -119,6 +120,8 @@ export default function registerFetch(pi: ExtensionAPI): void {
119
120
  "Set max_characters to cap response size (default 1000, 0 = unlimited). Use 5000-10000 for typical pages.",
120
121
  "Always prefer fetch over raw curl/browser for reading page content.",
121
122
  ],
123
+ renderCall: makeRenderCall("fetch", (args) => String(args.url ?? "")),
124
+ renderResult: makeRenderResult(),
122
125
  parameters: Type.Object({
123
126
  url: Type.String({ description: "URL to fetch" }),
124
127
  format: StringEnum(["markdown", "text", "html"] as const, {
@@ -0,0 +1,78 @@
1
+ import { expect, test } from "bun:test";
2
+ import { makeRenderCall, makeRenderResult } from "./render.js";
3
+
4
+ const theme = {
5
+ fg: (t: string, s: string) => `[${t}]${s}`,
6
+ bold: (s: string) => `*${s}*`,
7
+ } as never;
8
+
9
+ function stubComponent() {
10
+ let captured = "";
11
+ const component = {
12
+ setText: (s: string) => {
13
+ captured = s;
14
+ },
15
+ render: () => [],
16
+ invalidate: () => {},
17
+ };
18
+ return { component, get: () => captured };
19
+ }
20
+
21
+ test("renderCall shows title + accent arg", () => {
22
+ const stub = stubComponent();
23
+ 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
+ });
28
+ expect(stub.get()).toBe("[toolTitle]*fetch* [accent]https://x.com");
29
+ });
30
+
31
+ test("renderResult dims every line", () => {
32
+ const stub = stubComponent();
33
+ const rr = makeRenderResult();
34
+ rr(
35
+ { content: [{ type: "text", text: "a\nb" }] } as never,
36
+ { expanded: true, isPartial: false },
37
+ theme,
38
+ { lastComponent: stub.component as never, isError: false },
39
+ );
40
+ expect(stub.get()).toBe(" [dim]a\n [dim]b");
41
+ });
42
+
43
+ test("renderResult caps preview when not expanded", () => {
44
+ const stub = stubComponent();
45
+ const rr = makeRenderResult();
46
+ const body = Array.from({ length: 45 }, (_, i) => `L${i}`).join("\n");
47
+ rr(
48
+ { content: [{ type: "text", text: body }] } as never,
49
+ { expanded: false, isPartial: false },
50
+ theme,
51
+ { lastComponent: stub.component as never, isError: false },
52
+ );
53
+ expect(stub.get()).toContain("… 5 more lines");
54
+ });
55
+
56
+ test("renderResult renders empty body marker", () => {
57
+ const stub = stubComponent();
58
+ const rr = makeRenderResult();
59
+ rr(
60
+ { content: [{ type: "text", text: " " }] } as never,
61
+ { expanded: false, isPartial: false },
62
+ theme,
63
+ { lastComponent: stub.component as never, isError: false },
64
+ );
65
+ expect(stub.get()).toBe(" [dim](empty)");
66
+ });
67
+
68
+ test("renderResult uses error token on failure", () => {
69
+ const stub = stubComponent();
70
+ const rr = makeRenderResult();
71
+ rr(
72
+ { content: [{ type: "text", text: "boom" }] } as never,
73
+ { expanded: false, isPartial: false },
74
+ theme,
75
+ { lastComponent: stub.component as never, isError: true },
76
+ );
77
+ expect(stub.get()).toBe(" [error]boom");
78
+ });
package/src/render.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * render.ts — shared dim renderers for fetch/search tool output.
3
+ *
4
+ * fetch/search return external web content the model already consumed; the
5
+ * full body is noise in the transcript. We render it dim (theme `dim` token)
6
+ * so it reads like faded reasoning — present for inspection, visually quiet.
7
+ * The tool title line stays normal so calls remain scannable.
8
+ *
9
+ * pi-tui's Text component is loaded via require() so the renderers degrade to
10
+ * the default pi renderer (undefined-safe via cast) when pi-tui is absent.
11
+ */
12
+
13
+ import type {
14
+ AgentToolResult,
15
+ Theme,
16
+ ToolRenderResultOptions,
17
+ } from "@earendil-works/pi-coding-agent";
18
+ import type { Component } from "@earendil-works/pi-tui";
19
+
20
+ interface TextLike extends Component {
21
+ setText(text: string): void;
22
+ }
23
+
24
+ // ToolRenderContext is not re-exported by pi-coding-agent; model the fields the
25
+ // renderers actually read. Structurally compatible with the SDK context.
26
+ interface RenderCtx {
27
+ lastComponent: Component | undefined;
28
+ isError: boolean;
29
+ }
30
+
31
+ type TextCtor = new (text?: string, padX?: number, padY?: number) => TextLike;
32
+
33
+ // Preview cap is intentionally generous — dimming already de-emphasises the
34
+ // body, so we only truncate to keep pathological multi-thousand-line dumps
35
+ // from flooding the viewport. Expanding shows everything.
36
+ const MAX_PREVIEW_LINES = 40;
37
+
38
+ let TextComponent: TextCtor | undefined;
39
+ try {
40
+ TextComponent = (require("@earendil-works/pi-tui") as { Text: TextCtor })
41
+ .Text;
42
+ } catch {
43
+ TextComponent = undefined;
44
+ }
45
+
46
+ function getText(lastComponent: Component | undefined): TextLike {
47
+ if (lastComponent && "setText" in lastComponent)
48
+ return lastComponent as TextLike;
49
+ // TextComponent is always present in the interactive TUI (pi-tui peer); the
50
+ // require() guard only matters for headless/test contexts where renderers
51
+ // are never invoked.
52
+ if (TextComponent) return new TextComponent("", 0, 0);
53
+ throw new Error("pi-tui Text component unavailable");
54
+ }
55
+
56
+ function firstText(result: AgentToolResult<unknown>): string {
57
+ const block = result.content?.find((c) => c.type === "text");
58
+ return (block as { text?: string } | undefined)?.text ?? "";
59
+ }
60
+
61
+ function dimBody(body: string, theme: Theme, expanded: boolean): string {
62
+ const lines = body.split("\n");
63
+ const maxShow = expanded ? lines.length : MAX_PREVIEW_LINES;
64
+ const shown = lines.slice(0, maxShow);
65
+ const out = shown.map((line) => ` ${theme.fg("dim", line)}`);
66
+ const remaining = lines.length - maxShow;
67
+ if (remaining > 0) {
68
+ out.push(` ${theme.fg("dim", `… ${remaining} more lines`)}`);
69
+ }
70
+ return out.join("\n");
71
+ }
72
+
73
+ /** Build a `renderCall` that shows `<title> <accent arg>`. */
74
+ export function makeRenderCall<TArgs>(
75
+ title: string,
76
+ pickArg: (args: TArgs) => string,
77
+ ) {
78
+ return (args: TArgs, theme: Theme, ctx: RenderCtx): Component => {
79
+ const text = getText(ctx.lastComponent);
80
+ const arg = pickArg(args);
81
+ text.setText(
82
+ `${theme.fg("toolTitle", theme.bold(title))} ${theme.fg("accent", arg)}`,
83
+ );
84
+ return text;
85
+ };
86
+ }
87
+
88
+ /** Build a `renderResult` that dims the whole result body. */
89
+ export function makeRenderResult() {
90
+ return (
91
+ result: AgentToolResult<unknown>,
92
+ opts: ToolRenderResultOptions,
93
+ theme: Theme,
94
+ ctx: RenderCtx,
95
+ ): Component => {
96
+ const text = getText(ctx.lastComponent);
97
+ const body = firstText(result);
98
+
99
+ if (ctx.isError) {
100
+ text.setText(` ${theme.fg("error", body || "Error")}`);
101
+ return text;
102
+ }
103
+ if (!body.trim()) {
104
+ text.setText(` ${theme.fg("dim", "(empty)")}`);
105
+ return text;
106
+ }
107
+
108
+ text.setText(dimBody(body, theme, opts.expanded));
109
+ return text;
110
+ };
111
+ }
package/src/search.ts CHANGED
@@ -11,6 +11,7 @@ import { StringEnum } from "@earendil-works/pi-ai";
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  import { Type } from "typebox";
13
13
  import { routerBaseUrl } from "./data.js";
14
+ import { makeRenderCall, makeRenderResult } from "./render.js";
14
15
 
15
16
  const REQUEST_TIMEOUT_MS = 30_000;
16
17
 
@@ -136,6 +137,8 @@ export default function registerSearch(pi: ExtensionAPI): void {
136
137
  "Set search_type='web' for general web results, search_type='news' for recent news articles.",
137
138
  "Always prefer search over raw curl/browser requests for information lookup.",
138
139
  ],
140
+ renderCall: makeRenderCall("search", (args) => String(args.query ?? "")),
141
+ renderResult: makeRenderResult(),
139
142
  parameters: Type.Object({
140
143
  query: Type.String({ description: "Search query" }),
141
144
  search_type: StringEnum(["web", "news"] as const, {