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
|
|
@ -314,7 +314,8 @@ Prompt registers templates and skills (`plugins/prompt/tui.ts:87`).
|
|||
| Registry | Key → value | Order | Users |
|
||||
| --- | --- | --- | --- |
|
||||
| `TuiToolRenderers` | tool name → pi `ToolRenderers` (`renderCall`, `renderResult`, `renderShell`; `src/tui/tools/types.ts:15-62`) | overrides built-ins | artifacts, ports, send-file, subagents |
|
||||
| `TuiToolRendererResolvers` | `<order>:<plugin>` → `(toolName, next) => renderers` | key order, then the registry, then built-ins (`src/tui/tools/index.ts:20-28`) | none yet |
|
||||
| `TuiToolRendererResolvers` | `<order>:<plugin>` → `(toolName, next) => renderers` | key order, then the registry, then built-ins (`src/tui/tools/index.ts:20-28`) | quiet-tools (`10:`: one line per call, full content when expanded or failed) |
|
||||
| `TuiSettingFields` | `<plugin>.<name>` → `/settings` row (label, values, fallback), stored at `settings.plugins.<plugin>.<name>` of the visitor's prefs; read with `pluginSetting`; resolvers get the settings, a change redraws every card (`src/runtime/tui-preferences.ts`) | after the built-in rows, key order | quiet-tools |
|
||||
| `TuiEntryRenderers` | entry kind → `(entry, ctx) => Component \| undefined` (replaces the display; `undefined` hides) | — | core `p2.shell` (`src/tui/core-commands.ts:290`) |
|
||||
| `TuiMarkdownTransformers` | `<order>:<plugin>` → pi `MarkdownTransformer` | key order | core `00:mermaid` |
|
||||
| `TranscriptEntries` | shared with web (4.3) | — | chat-mode |
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}),
|
||||
);
|
||||
|
|
@ -4,6 +4,7 @@ export * from "../../runtime/tui.ts";
|
|||
export * from "../../runtime/tui-chat.ts";
|
||||
export * from "../../runtime/tui-commands.ts";
|
||||
export * from "../../runtime/tui-context.ts";
|
||||
export * from "../../runtime/tui-preferences.ts";
|
||||
export * from "../../runtime/tui-renderers.ts";
|
||||
export * from "../../runtime/tui-sessions.ts";
|
||||
export * from "../../tui/completion.ts";
|
||||
|
|
|
|||
|
|
@ -2,10 +2,19 @@
|
|||
// privileged-action bindings never come from the server. Browser/worker-safe: types and schemas only.
|
||||
|
||||
import { method, remote } from "@earendil-works/chord";
|
||||
import { arr, bool, int, obj, opt, type Static, str, union } from "@earendil-works/pi-schema";
|
||||
import { serverHost } from "./chord.ts";
|
||||
import { arr, bool, int, map, num, obj, opt, type Schema, type Static, str, union } from "@earendil-works/pi-schema";
|
||||
import { keyedRegistry, serverHost } from "./chord.ts";
|
||||
import { literal, record } from "./schema.ts";
|
||||
|
||||
/** One plugin setting's value. The launcher never runs plugin code: this shape is all it checks. */
|
||||
export type PluginSettingValue = boolean | number | string;
|
||||
const PluginSettingValueSchema = union([bool(), num(), str({ maxLength: 200 })]);
|
||||
/** `plugins.<plugin>.<setting>`, bounded so a plugin cannot grow the visitor's prefs.json without limit. */
|
||||
const PluginSettingsSchema: Schema<Record<string, Record<string, PluginSettingValue>>> = {
|
||||
...map({ ...map(PluginSettingValueSchema), maxProperties: 32 }),
|
||||
maxProperties: 64,
|
||||
};
|
||||
|
||||
const Bool = opt(bool());
|
||||
/** pi `settings.json` keys marked G in section 1.14, spelled (and nested) as pi spells them. */
|
||||
export const TuiSettingsSchema = obj({
|
||||
|
|
@ -37,9 +46,32 @@ export const TuiSettingsSchema = obj({
|
|||
fullscreenScrollbar: opt(union([literal("auto"), literal("always"), literal("hidden")])),
|
||||
fullscreenCopyOnSelect: Bool,
|
||||
fullscreenWheelScrollLines: opt(union([literal("auto"), int({ minimum: 1, maximum: 100 })])),
|
||||
/** p2: plugin settings by plugin name, declared through `TuiSettingFields`. */
|
||||
plugins: opt(PluginSettingsSchema),
|
||||
});
|
||||
export type TuiSettings = Static<typeof TuiSettingsSchema>;
|
||||
|
||||
/**
|
||||
* A plugin's row in `/settings`: the values it cycles and the value it means when unset. It is stored at
|
||||
* `plugins.<plugin>.<name>` of the visitor's settings; read it with `pluginSetting`.
|
||||
*/
|
||||
export type TuiSettingField = {
|
||||
plugin: string;
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
values: readonly PluginSettingValue[];
|
||||
fallback: PluginSettingValue;
|
||||
};
|
||||
/** Plugin rows of `/settings`, listed after the built-in ones in key order; key `<plugin>.<name>`. */
|
||||
export const TuiSettingFields = keyedRegistry<TuiSettingField>("p2.tui-setting-fields");
|
||||
|
||||
/** A plugin setting's value, or the field's fallback when unset or not one of its values. */
|
||||
export function pluginSetting(settings: TuiSettings | undefined, field: TuiSettingField): PluginSettingValue {
|
||||
const value = settings?.plugins?.[field.plugin]?.[field.name];
|
||||
return value !== undefined && field.values.includes(value) ? value : field.fallback;
|
||||
}
|
||||
|
||||
export const TuiPreferencesSchema = obj({
|
||||
keybindings: record(union([str(), arr(str())])),
|
||||
/** pi theme setting: a built-in name, "system" or a "light/dark" pair. Custom theme files are guest-local. */
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// registry as plugin commands so they are rebindable, listed and conflict-checked (p2-tui-port.md section 6.3).
|
||||
// Like the header/footer fallbacks (section 7.1), the worker runtime always loads this facet.
|
||||
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import {
|
||||
type Component,
|
||||
type Focusable,
|
||||
|
|
@ -16,6 +17,7 @@ import { SHELL_ENTRY } from "../runtime/shell.ts";
|
|||
import { TuiSlots } from "../runtime/tui.ts";
|
||||
import { type TuiCommand, TuiCommands, TuiSlashSources } from "../runtime/tui-commands.ts";
|
||||
import type { TuiContext } from "../runtime/tui-context.ts";
|
||||
import { TuiSettingFields } from "../runtime/tui-preferences.ts";
|
||||
import {
|
||||
TuiEntryRenderers,
|
||||
TuiMarkdownTransformers,
|
||||
|
|
@ -245,7 +247,7 @@ export const CORE_COMMANDS: Record<string, TuiCommand> = {
|
|||
category: "General",
|
||||
slash: { name: "settings" },
|
||||
when: "!overlay",
|
||||
run: (ctx) => openSettings(ctx),
|
||||
run: async (ctx) => openSettings(ctx, (await ctx.host.registry(TuiSettingFields, BACKGROUND_CONTEXT)).value),
|
||||
},
|
||||
"p2.keybindings": {
|
||||
title: "Rebind keys",
|
||||
|
|
@ -298,6 +300,7 @@ export const tuiCoreFacet = defineFacet((host, self) => {
|
|||
serveKeyed(host, TuiToolRendererResolvers);
|
||||
serveKeyed(host, TuiSessionSources);
|
||||
serveKeyed(host, TuiSessionPlaces);
|
||||
serveKeyed(host, TuiSettingFields);
|
||||
serveKeyed(host, TuiEntryRenderers);
|
||||
serveKeyed(host, TuiMarkdownTransformers);
|
||||
self.contribute(TuiCommands, CORE_COMMANDS);
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export function transcriptComponent(
|
|||
requestRender: () => {},
|
||||
cwd: options.cwd ?? "",
|
||||
markdownTransformers: [],
|
||||
toolRenderers: (name) => resolveToolRenderers(name, new Map(), new Map()),
|
||||
toolRenderers: (name) => resolveToolRenderers(name, new Map(), new Map(), {}),
|
||||
entryRenderers: new Map(),
|
||||
},
|
||||
{ expanded: expandedTools, showThinking },
|
||||
|
|
|
|||
|
|
@ -49,14 +49,19 @@ export function sanitizeSettings(raw: unknown): { settings: TuiSettings; invalid
|
|||
return { settings: settings as TuiSettings, invalid };
|
||||
}
|
||||
|
||||
/** Later sources override earlier ones; nested `terminal`/`markdown` objects merge per key. */
|
||||
/** Later sources override earlier ones; nested `terminal`/`markdown` objects merge per key, `plugins` per setting. */
|
||||
export function mergeSettings(...layers: TuiSettings[]): TuiSettings {
|
||||
const merged: TuiSettings = {};
|
||||
for (const layer of layers) {
|
||||
const { terminal, markdown, ...rest } = layer;
|
||||
const { terminal, markdown, plugins, ...rest } = layer;
|
||||
Object.assign(merged, rest);
|
||||
if (terminal) merged.terminal = { ...merged.terminal, ...terminal };
|
||||
if (markdown) merged.markdown = { ...merged.markdown, ...markdown };
|
||||
if (plugins) {
|
||||
const all = { ...merged.plugins };
|
||||
for (const [name, values] of Object.entries(plugins)) all[name] = { ...all[name], ...values };
|
||||
merged.plugins = all;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
truncateToWidth,
|
||||
} from "@earendil-works/pi-tui";
|
||||
import type { TuiContext } from "../runtime/tui-context.ts";
|
||||
import type { TuiSettings } from "../runtime/tui-preferences.ts";
|
||||
import type { TuiSettingField, TuiSettings } from "../runtime/tui-preferences.ts";
|
||||
import { ListOverlay, type ListRow } from "./components/list-overlay.ts";
|
||||
import { builtinKeybindings, isKeyId, isPrivileged } from "./keybindings.ts";
|
||||
import { formatKeyText, lineText } from "./render-context.ts";
|
||||
|
|
@ -264,8 +264,22 @@ export class ThemePicker implements Component, Focusable {
|
|||
}
|
||||
|
||||
/** `/settings`: pi's list; changes stay pending until the overlay closes, then go to the launcher as one patch. */
|
||||
export function openSettings(ctx: TuiContext): void {
|
||||
/** `/settings`: the built-in rows, then the plugins' `TuiSettingFields` rows in key order. */
|
||||
export function openSettings(ctx: TuiContext, pluginFields: ReadonlyMap<string, TuiSettingField>): void {
|
||||
const settings = ctx.settings;
|
||||
const fields: Field[] = [
|
||||
...FIELDS,
|
||||
...[...pluginFields]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, field]) => ({
|
||||
id: `plugin:${key}`,
|
||||
label: field.label,
|
||||
description: field.description,
|
||||
path: ["plugins", field.plugin, field.name],
|
||||
fallback: field.fallback,
|
||||
values: [...field.values],
|
||||
})),
|
||||
];
|
||||
const patch: { settings: Record<string, unknown>; theme?: string } = { settings: {} };
|
||||
const set = (path: string[], value: Value) => {
|
||||
let target = patch.settings;
|
||||
|
|
@ -284,7 +298,7 @@ export function openSettings(ctx: TuiContext): void {
|
|||
currentValue: themeSetting,
|
||||
submenu: (current, done) => new ThemePicker(ctx, current, done),
|
||||
},
|
||||
...FIELDS.map((field) => ({
|
||||
...fields.map((field) => ({
|
||||
id: field.id,
|
||||
label: field.label,
|
||||
description: field.description,
|
||||
|
|
@ -319,7 +333,7 @@ export function openSettings(ctx: TuiContext): void {
|
|||
patch.theme = value;
|
||||
return;
|
||||
}
|
||||
const field = FIELDS.find((entry) => entry.id === id);
|
||||
const field = fields.find((entry) => entry.id === id);
|
||||
if (!field) return;
|
||||
const typed = field.values.find((option) => String(option) === value);
|
||||
if (typed !== undefined) set(field.path, typed);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
// Built-in tool renderers by tool name (coding-agent core/tools/renderers/index.ts), plus code mode's.
|
||||
import type { TuiSettings } from "../../runtime/tui-preferences.ts";
|
||||
import { codemodeRenderers } from "./codemode.ts";
|
||||
import { editRenderers, findRenderers, grepRenderers, lsRenderers, readRenderers, writeRenderers } from "./files.ts";
|
||||
import { createShellRenderers } from "./shell.ts";
|
||||
|
|
@ -24,13 +25,14 @@ export function resolveToolRenderers(
|
|||
name: string,
|
||||
resolvers: ReadonlyMap<string, ToolRendererResolver>,
|
||||
registered: ReadonlyMap<string, ToolRenderers>,
|
||||
settings: TuiSettings,
|
||||
): ToolRenderers | undefined {
|
||||
const ordered = [...resolvers].sort(([a], [b]) => a.localeCompare(b, "en", { numeric: true })).map(([, r]) => r);
|
||||
const at = (index: number): ToolRenderers | undefined => {
|
||||
const resolver = ordered[index];
|
||||
if (!resolver) return registered.get(name) ?? BUILTIN_TOOL_RENDERERS[name];
|
||||
try {
|
||||
return resolver(name, () => at(index + 1));
|
||||
return resolver(name, () => at(index + 1), settings);
|
||||
} catch {
|
||||
return at(index + 1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import type { JsonValue } from "@earendil-works/chord/json";
|
||||
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
||||
import type { Component } from "@earendil-works/pi-tui";
|
||||
import type { TuiSettings } from "../../runtime/tui-preferences.ts";
|
||||
import type { NestedCall } from "../../web/transcript.ts";
|
||||
import type { RenderContext } from "../render-context.ts";
|
||||
import type { Theme } from "../theme/theme.ts";
|
||||
|
|
@ -52,10 +53,14 @@ export interface ToolRenderers {
|
|||
) => Component;
|
||||
}
|
||||
|
||||
/** Chooses how calls to a tool are drawn; `next()` returns what the remaining resolvers, then the registry, would use. */
|
||||
/**
|
||||
* Chooses how calls to a tool are drawn; `next()` returns what the remaining resolvers, then the registry, would use.
|
||||
* p2: `settings` are the visitor's; when they change, every card is drawn again with a new resolution.
|
||||
*/
|
||||
export type ToolRendererResolver = (
|
||||
toolName: string,
|
||||
next: () => ToolRenderers | undefined,
|
||||
settings: TuiSettings,
|
||||
) => ToolRenderers | undefined;
|
||||
|
||||
/** A JSON object, or undefined. */
|
||||
|
|
|
|||
125
apps/p2/test/p2/tui-quiet-tools.test.ts
Normal file
125
apps/p2/test/p2/tui-quiet-tools.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
// The quiet-tools plugin: one line per tool call; a failed call and an expanded transcript show the full content; its
|
||||
// `/settings` row (`plugins.quiet-tools.toolCalls`) switches back to the tools' own cards.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
|
||||
import { type EntryId, type EntryRecord, ROOT_CONVERSATION_ID } from "@earendil-works/pi-durable";
|
||||
import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
||||
import { quietTools } from "../../plugins/quiet-tools/tui.ts";
|
||||
import { validatePrefsPatch } from "../../src/hosts/tui-prefs-save.ts";
|
||||
import type { TuiSettings } from "../../src/runtime/tui-preferences.ts";
|
||||
import { TranscriptView } from "../../src/tui/components/transcript.ts";
|
||||
import { mergeSettings, sanitizeSettings } from "../../src/tui/preferences.ts";
|
||||
import { ThemeRef } from "../../src/tui/theme/ref.ts";
|
||||
import { BUILTIN_THEMES, createTheme } from "../../src/tui/theme/theme.ts";
|
||||
import { resolveToolRenderers } from "../../src/tui/tools/index.ts";
|
||||
import { buildTranscript } from "../../src/web/transcript.ts";
|
||||
import { wholeView } from "../fixtures/whole-view.ts";
|
||||
|
||||
const plain = (lines: string[]): string => lines.map((line) => stripTerminalSequences(line).trimEnd()).join("\n");
|
||||
const entry = (id: number, record: Omit<EntryRecord, "id" | "conversationId">): EntryRecord =>
|
||||
({ id: id as EntryId, conversationId: ROOT_CONVERSATION_ID, ...record }) as EntryRecord;
|
||||
const usage = {
|
||||
input: 1,
|
||||
output: 1,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 2,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
const answer: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Checking." },
|
||||
{ type: "toolCall", id: "t1", name: "bash", arguments: { command: "npm run check\necho done" } },
|
||||
{ type: "toolCall", id: "t2", name: "read", arguments: { path: "/work/src/a.ts", offset: 10 } },
|
||||
{ type: "toolCall", id: "t3", name: "edit", arguments: { path: "/work/src/b.ts", edits: [] } },
|
||||
],
|
||||
api: "openai-completions",
|
||||
provider: "faux",
|
||||
model: "faux-1",
|
||||
stopReason: "toolUse",
|
||||
timestamp: 2,
|
||||
usage,
|
||||
};
|
||||
const result = (id: number, toolCallId: string, toolName: string, text: string, isError = false) =>
|
||||
entry(id, {
|
||||
kind: "pi.tool-result",
|
||||
model: [
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId,
|
||||
toolName,
|
||||
content: [{ type: "text", text }],
|
||||
isError,
|
||||
durationMs: toolCallId === "t1" ? 2500 : 5,
|
||||
timestamp: id,
|
||||
} satisfies ToolResultMessage,
|
||||
],
|
||||
});
|
||||
const entries = [
|
||||
entry(1, { kind: "pi.user", model: [{ role: "user", content: "go", timestamp: 1 }] }),
|
||||
entry(2, { kind: "pi.assistant", model: [answer] }),
|
||||
result(3, "t1", "bash", "lots\nof\noutput"),
|
||||
result(4, "t2", "read", "file text"),
|
||||
result(5, "t3", "edit", "edit failed: no match", true),
|
||||
];
|
||||
|
||||
function transcript(expanded: boolean, settings: TuiSettings = {}): TranscriptView {
|
||||
const view = new TranscriptView(
|
||||
{
|
||||
theme: new ThemeRef(createTheme(BUILTIN_THEMES.dark!, "truecolor")),
|
||||
keyText: () => "ctrl+o",
|
||||
requestRender: () => {},
|
||||
cwd: "/work",
|
||||
markdownTransformers: [],
|
||||
toolRenderers: (name) =>
|
||||
resolveToolRenderers(name, new Map([["10:quiet-tools", quietTools]]), new Map(), settings),
|
||||
entryRenderers: new Map(),
|
||||
},
|
||||
{ expanded, showThinking: false },
|
||||
);
|
||||
view.sync(
|
||||
buildTranscript(
|
||||
wholeView({
|
||||
conversation: { id: ROOT_CONVERSATION_ID } as Parameters<typeof wholeView>[0]["conversation"],
|
||||
entries,
|
||||
docs: {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
return view;
|
||||
}
|
||||
|
||||
test("collapsed: one line per call; a failed call keeps its full content", () => {
|
||||
const shown = plain(transcript(false).render(80));
|
||||
assert.match(shown, /Checking\.\n\n › bash npm run check … ✓ 2\.5s\n\n › read src\/a\.ts:10 ✓\n\n/);
|
||||
assert.doesNotMatch(shown, /lots|file text/);
|
||||
assert.match(shown, /edit src\/b\.ts[\s\S]*edit failed: no match/);
|
||||
});
|
||||
|
||||
test("expanded: every call shows its full content", () => {
|
||||
const shown = plain(transcript(true).render(80));
|
||||
assert.doesNotMatch(shown, /›/);
|
||||
assert.match(shown, / \$ npm run check[\s\S]*lots\n of\n output/);
|
||||
assert.match(shown, /read src\/a\.ts[\s\S]*file text/);
|
||||
});
|
||||
|
||||
test("setting full: the tools' own cards, boxed", () => {
|
||||
const shown = plain(transcript(false, { plugins: { "quiet-tools": { toolCalls: "full" } } }).render(80));
|
||||
assert.doesNotMatch(shown, /›/);
|
||||
assert.match(shown, /\$ npm run check/);
|
||||
});
|
||||
|
||||
test("plugin settings: merged per setting, bounded, accepted by the launcher's patch check", () => {
|
||||
assert.deepEqual(
|
||||
mergeSettings({ plugins: { a: { x: 1, y: true } } }, { plugins: { a: { y: false }, b: { z: "s" } } }).plugins,
|
||||
{ a: { x: 1, y: false }, b: { z: "s" } },
|
||||
);
|
||||
assert.deepEqual(sanitizeSettings({ plugins: { a: { x: { nested: 1 } } } }).invalid, ["plugins"]);
|
||||
const many = Object.fromEntries(Array.from({ length: 33 }, (_, index) => [`k${index}`, true]));
|
||||
assert.deepEqual(sanitizeSettings({ plugins: { a: many } }).invalid, ["plugins"]);
|
||||
assert.ok("patch" in validatePrefsPatch({ settings: { plugins: { "quiet-tools": { toolCalls: "full" } } } }));
|
||||
assert.ok("patch" in validatePrefsPatch({ settings: { plugins: { "quiet-tools": { toolCalls: null } } } }));
|
||||
assert.ok("error" in validatePrefsPatch({ settings: { plugins: { "quiet-tools": { toolCalls: [1] } } } }));
|
||||
});
|
||||
|
|
@ -711,6 +711,9 @@ test(
|
|||
"editor",
|
||||
);
|
||||
terminal.sendInput("\r");
|
||||
// quiet-tools draws the call as one line; Ctrl+O expands it into the full card.
|
||||
await poll(async () => /› codemode 2 calls: bash, read ✓/.test(await screen()), 30000, "quiet code mode line");
|
||||
terminal.sendInput("\x0f");
|
||||
// pi's code mode card: the script, its nested bash/read calls and the output.
|
||||
// Live rows can settle before the result commits; the output comes with the result.
|
||||
await poll(async () => /✓ bash[\s\S]*✓ read[\s\S]*bash: written/.test(await screen()), 30000, "code mode card");
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ import { test } from "node:test";
|
|||
import { stripTerminalSequences, TuiAltScreen } from "@earendil-works/pi-tui";
|
||||
import { BACKGROUND_CONTEXT } from "@p2/app/v1";
|
||||
import { VirtualTerminal } from "../../../../packages/tui/test/virtual-terminal.ts";
|
||||
import quietToolsTui from "../../plugins/quiet-tools/tui.ts";
|
||||
import { Sessions } from "../../plugins/sessions/contract.ts";
|
||||
import { launcherActions } from "../../src/hosts/tui-actions.ts";
|
||||
import { startTuiClient } from "../../src/hosts/tui-client.ts";
|
||||
import { loadLocalPreferences, watchLocalPreferences } from "../../src/hosts/tui-local-preferences.ts";
|
||||
import { applyPrefsPatch, prefsDiff, validatePrefsPatch } from "../../src/hosts/tui-prefs-save.ts";
|
||||
import type { Facet } from "../../src/runtime/chord.ts";
|
||||
import { TuiRuntime } from "../../src/runtime/tui.ts";
|
||||
import { tuiCoreFacet } from "../../src/tui/core-commands.ts";
|
||||
import { restoreRuntime, snapshotRuntime } from "../../src/tui/snapshot.ts";
|
||||
|
|
@ -89,8 +91,8 @@ test("savePrefs action: the diff is shown first; the write is atomic and keeps e
|
|||
await assert.rejects(save.run({ patch: { settings: { externalEditor: "x" } } }, undefined as never));
|
||||
});
|
||||
|
||||
async function runtimeWith(t: { after(fn: () => unknown): void }) {
|
||||
const fixture = await testHost({ facets: [tuiCoreFacet] });
|
||||
async function runtimeWith(t: { after(fn: () => unknown): void }, facets: Facet[] = []) {
|
||||
const fixture = await testHost({ facets: [tuiCoreFacet, ...facets] });
|
||||
t.after(() => fixture.close());
|
||||
const terminal = new VirtualTerminal(100, 40);
|
||||
const tui = new TuiAltScreen(terminal);
|
||||
|
|
@ -138,6 +140,22 @@ test("/settings collects changes and proposes one patch; the theme preview never
|
|||
assert.deepEqual(grants[0], ["savePrefs", { patch: { settings: { hideThinkingBlock: false }, theme: "light" } }]);
|
||||
});
|
||||
|
||||
test("/settings lists plugin rows (TuiSettingFields) and saves them under plugins.<plugin>", async (t) => {
|
||||
const { runtime, grants, screen, send } = await runtimeWith(t, [quietToolsTui]);
|
||||
runtime.ctx.commands.runSlash("/settings");
|
||||
await poll(async () => (await screen()).includes("Hide thinking"));
|
||||
// The plugin row comes after the theme and the 15 built-in rows.
|
||||
for (let index = 0; index < 16; index++) send("\x1b[B");
|
||||
await poll(async () => /→ Tool calls +quiet/.test(stripTerminalSequences(await screen())));
|
||||
send("\r");
|
||||
send("\x1b");
|
||||
await poll(() => grants.length === 1);
|
||||
assert.deepEqual(grants[0], [
|
||||
"savePrefs",
|
||||
{ patch: { settings: { plugins: { "quiet-tools": { toolCalls: "full" } } } } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("hideThinkingBlock sets the thinking view; the toggle holds until the setting changes and across a reload", async (t) => {
|
||||
const { runtime } = await runtimeWith(t);
|
||||
const local = (settings: { hideThinkingBlock?: boolean }) => ({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue