mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
e885255061
commit
d3d9c5aeb4
15 changed files with 390 additions and 17 deletions
|
|
@ -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).
|
||||
|
|
|
|||
5
apps/p2/plugins/quiet-tools/package.json
Normal file
5
apps/p2/plugins/quiet-tools/package.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "quiet-tools",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
149
apps/p2/plugins/quiet-tools/tui.ts
Normal file
149
apps/p2/plugins/quiet-tools/tui.ts
Normal 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 });
|
||||
}),
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue