feat(p2): quiet-tools plugin and plugin settings in the TUI

quiet-tools draws every tool call as one line; Ctrl+O, a click or a failure
shows the full content. Its /settings row switches back to the tools' own cards.

TuiSettingFields lets plugins add /settings rows, stored at
settings.plugins.<plugin>.<name> of the visitor's prefs. Tool renderer
resolvers receive the settings; a change redraws every card.
This commit is contained in:
David Brailovsky 2026-11-04 14:55:06 +01:00
parent e885255061
commit d3d9c5aeb4
15 changed files with 390 additions and 17 deletions

View file

@ -167,7 +167,8 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi {
get settings() {
return runtime.ctx.settings;
},
toolRenderers: (name) => resolveToolRenderers(name, surface.resolvers, surface.toolRenderers),
toolRenderers: (name) =>
resolveToolRenderers(name, surface.resolvers, surface.toolRenderers, runtime.ctx.settings),
get entryRenderers() {
return surface.entryRenderers;
},
@ -285,7 +286,16 @@ export class ChatSurface implements TuiSurface, ChatSurfaceApi {
this.rows = rows;
});
});
const changed = (): void => this.update();
// Resolvers read plugin settings (`ToolRendererResolver`): a change there draws every card again.
let pluginSettings = JSON.stringify(runtime.ctx.settings.plugins ?? {});
const changed = (): void => {
const next = JSON.stringify(runtime.ctx.settings.plugins ?? {});
if (next !== pluginSettings) {
pluginSettings = next;
this.renderersChanged();
}
this.update();
};
runtime.listeners.add(changed);
this.stops.push(() => runtime.listeners.delete(changed));
// What the launcher may ask for under a grant (P5: Ctrl+X copy, Ctrl+G external editor).

View file

@ -0,0 +1,5 @@
{
"name": "quiet-tools",
"private": true,
"type": "module"
}

View file

@ -0,0 +1,149 @@
// Quiet tool cards in the TUI: every tool call is one dim line (`› read src/foo.ts ✓`) without its output. A failed
// call, an expanded transcript (Ctrl+O) or a clicked line shows the tool's full content, drawn by the renderers this
// resolver wraps, without the card's box.
import { Box, type Component, Container, Text, truncateToWidth } from "@earendil-works/pi-tui";
import { contribute, defineFacet } from "@p2/app/v1";
import {
formatToolCallWithArgs,
getTextOutput,
lineText,
moreLinesHint,
pluginSetting,
shortenPath,
type ToolArgs,
type ToolRenderContext,
type ToolRendererResolver,
type ToolRenderers,
type TuiSettingField,
TuiSettingFields,
TuiToolRendererResolvers,
} from "@p2/app/v1/tui";
/** The `/settings` row: quiet (one line per call) or the tools' own cards. */
export const TOOL_CALLS: TuiSettingField = {
plugin: "quiet-tools",
name: "toolCalls",
label: "Tool calls",
description: "quiet: one line per call (Ctrl+O or a click shows it); full: the tools' own cards",
values: ["quiet", "full"],
fallback: "quiet",
};
/** Wraps every tool's renderers: one line while collapsed, the full content (without the box) when expanded or failed. */
export const quietTools: ToolRendererResolver = (name, next, settings) => {
const full = next();
if (pluginSetting(settings, TOOL_CALLS) === "full") return full;
const isFull = (context: ToolRenderContext) => context.expanded || context.isError;
return {
// The card fixes its frame when created: no box, so a one-line call takes a blank line and the line.
renderShell: "self",
renderCall(args, theme, context) {
if (isFull(context))
return indent(
(inner) =>
full?.renderCall?.(args, theme, inner) ??
new Text(formatToolCallWithArgs(name, args, theme, true), 0, 0),
context,
);
const status = context.isPartial ? " …" : ` ✓${duration(context.durationMs)}`;
const text = `${theme.fg("muted", "›")} ${theme.fg("muted", theme.bold(lineText(name)))} ${theme.fg("dim", `${summary(name, args, context)}${status}`)}`;
return new OneLine(text, context.outputPad);
},
renderResult(result, options, theme, context) {
if (!isFull(context)) return new Container();
return indent((inner) => {
if (full?.renderResult) return full.renderResult(result, options, theme, inner);
// The card's own fallback for tools without a result renderer: the output, cut unless expanded.
const lines = getTextOutput(result).split("\n");
const shown = options.expanded ? lines : lines.slice(0, PREVIEW_LINES);
const more = lines.length - shown.length;
const text = shown.map((line) => theme.fg("toolOutput", line)).join("\n");
return new Text(more > 0 ? `${text}\n${moreLinesHint(context.ui, theme, more)}` : text, 0, 0);
}, context);
},
} satisfies ToolRenderers;
/** The wrapped content, indented as the card's box would: wrapped renderers see their own last component. */
function indent(render: (context: ToolRenderContext) => Component, context: ToolRenderContext): Component {
const last = context.lastComponent instanceof Indent ? context.lastComponent.children[0] : context.lastComponent;
const content = render({ ...context, lastComponent: last });
// A renderer that draws its own frame is already laid out.
return full?.renderShell === "self" ? content : new Indent(content, context.outputPad);
}
};
/** Pads the full content left and right, like the card's box without its background. */
class Indent extends Box {
constructor(content: Component, pad: number) {
super(pad, 0);
this.addChild(content);
}
}
const PREVIEW_LINES = 10;
/** One line cut to the width, indented like the answer's text. */
class OneLine implements Component {
private text: string;
private pad: number;
constructor(text: string, pad: number) {
this.text = text;
this.pad = pad;
}
render(width: number): string[] {
return [" ".repeat(this.pad) + truncateToWidth(this.text, Math.max(1, width - this.pad), "…")];
}
invalidate(): void {}
}
/** The argument that tells calls of a tool apart: a path, a command, a pattern, or code mode's calls. */
function summary(name: string, args: ToolArgs, context: ToolRenderContext): string {
const path = (value: unknown) => lineText(shortenPath(value, context.cwd));
switch (name) {
case "read": {
const offset = typeof args.offset === "number" ? `:${args.offset}` : "";
return `${path(args.path ?? args.file_path)}${offset}`;
}
case "write":
case "edit":
return path(args.path ?? args.file_path);
case "ls":
return path(args.path) || ".";
case "grep":
case "find":
return `${typeof args.pattern === "string" ? lineText(args.pattern) : ""}${args.path ? ` in ${path(args.path)}` : ""}`;
case "bash":
case "powershell":
return firstLine(args.command);
case "codemode": {
const calls = context.calls ?? [];
if (calls.length === 0) return firstLine(args.code);
const names = [...new Set(calls.map((call) => lineText(call.name)))];
return `${calls.length} call${calls.length === 1 ? "" : "s"}: ${names.join(", ")}`;
}
default: {
const value = Object.values(args).find((arg) => typeof arg === "string" && arg.trim());
return typeof value === "string" ? firstLine(value) : "";
}
}
}
/** The first non-empty line of a string argument, marked when more follow. */
function firstLine(value: unknown): string {
if (typeof value !== "string") return "";
const lines = value.split("\n").filter((line) => line.trim());
return `${lineText(lines[0] ?? "").trim()}${lines.length > 1 ? " …" : ""}`;
}
/** A settled call's duration, when it took long enough to matter. */
function duration(ms: number | undefined): string {
return ms !== undefined && ms >= 1000 ? ` ${(ms / 1000).toFixed(1)}s` : "";
}
export default defineFacet((host) =>
contribute(host, "p2.tui-quiet-tools", (self) => {
// Key 10 runs before other resolvers, so it wraps whatever they and the registry would draw.
self.contribute(TuiToolRendererResolvers, { "10:quiet-tools": quietTools });
self.contribute(TuiSettingFields, { "quiet-tools.toolCalls": TOOL_CALLS });
}),
);