From d3d9c5aeb4601bc46ccb70ec8f990d13ab6da943 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Wed, 4 Nov 2026 14:55:06 +0100 Subject: [PATCH] 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.. of the visitor's prefs. Tool renderer resolvers receive the settings; a change redraws every card. --- apps/p2/docs/plugin-hooks.md | 3 +- apps/p2/plugins/chat/tui.ts | 14 ++- apps/p2/plugins/quiet-tools/package.json | 5 + apps/p2/plugins/quiet-tools/tui.ts | 149 +++++++++++++++++++++++ apps/p2/src/api/v1/tui.ts | 1 + apps/p2/src/runtime/tui-preferences.ts | 36 +++++- apps/p2/src/tui/core-commands.ts | 5 +- apps/p2/src/tui/messages.ts | 2 +- apps/p2/src/tui/preferences.ts | 9 +- apps/p2/src/tui/settings.ts | 22 +++- apps/p2/src/tui/tools/index.ts | 4 +- apps/p2/src/tui/tools/types.ts | 7 +- apps/p2/test/p2/tui-quiet-tools.test.ts | 125 +++++++++++++++++++ apps/p2/test/p2/tui-render.test.ts | 3 + apps/p2/test/p2/tui-settings.test.ts | 22 +++- 15 files changed, 390 insertions(+), 17 deletions(-) create mode 100644 apps/p2/plugins/quiet-tools/package.json create mode 100644 apps/p2/plugins/quiet-tools/tui.ts create mode 100644 apps/p2/test/p2/tui-quiet-tools.test.ts diff --git a/apps/p2/docs/plugin-hooks.md b/apps/p2/docs/plugin-hooks.md index a7c12ba83..1918c08ca 100644 --- a/apps/p2/docs/plugin-hooks.md +++ b/apps/p2/docs/plugin-hooks.md @@ -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` | `:` → `(toolName, next) => renderers` | key order, then the registry, then built-ins (`src/tui/tools/index.ts:20-28`) | none yet | +| `TuiToolRendererResolvers` | `:` → `(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` | `.` → `/settings` row (label, values, fallback), stored at `settings.plugins..` 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` | `:` → pi `MarkdownTransformer` | key order | core `00:mermaid` | | `TranscriptEntries` | shared with web (4.3) | — | chat-mode | diff --git a/apps/p2/plugins/chat/tui.ts b/apps/p2/plugins/chat/tui.ts index 9c67264e3..1de8e53dd 100644 --- a/apps/p2/plugins/chat/tui.ts +++ b/apps/p2/plugins/chat/tui.ts @@ -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). diff --git a/apps/p2/plugins/quiet-tools/package.json b/apps/p2/plugins/quiet-tools/package.json new file mode 100644 index 000000000..a0b4140da --- /dev/null +++ b/apps/p2/plugins/quiet-tools/package.json @@ -0,0 +1,5 @@ +{ + "name": "quiet-tools", + "private": true, + "type": "module" +} diff --git a/apps/p2/plugins/quiet-tools/tui.ts b/apps/p2/plugins/quiet-tools/tui.ts new file mode 100644 index 000000000..d89b7869e --- /dev/null +++ b/apps/p2/plugins/quiet-tools/tui.ts @@ -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 }); + }), +); diff --git a/apps/p2/src/api/v1/tui.ts b/apps/p2/src/api/v1/tui.ts index 4ab7f6ebb..c3811e404 100644 --- a/apps/p2/src/api/v1/tui.ts +++ b/apps/p2/src/api/v1/tui.ts @@ -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"; diff --git a/apps/p2/src/runtime/tui-preferences.ts b/apps/p2/src/runtime/tui-preferences.ts index cc9c2d14c..a90da4534 100644 --- a/apps/p2/src/runtime/tui-preferences.ts +++ b/apps/p2/src/runtime/tui-preferences.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..`, bounded so a plugin cannot grow the visitor's prefs.json without limit. */ +const PluginSettingsSchema: Schema>> = { + ...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; +/** + * A plugin's row in `/settings`: the values it cycles and the value it means when unset. It is stored at + * `plugins..` 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 `.`. */ +export const TuiSettingFields = keyedRegistry("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. */ diff --git a/apps/p2/src/tui/core-commands.ts b/apps/p2/src/tui/core-commands.ts index 7945d4a7e..76e94b8e5 100644 --- a/apps/p2/src/tui/core-commands.ts +++ b/apps/p2/src/tui/core-commands.ts @@ -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 = { 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); diff --git a/apps/p2/src/tui/messages.ts b/apps/p2/src/tui/messages.ts index 5b4feeca6..5574cfb6e 100644 --- a/apps/p2/src/tui/messages.ts +++ b/apps/p2/src/tui/messages.ts @@ -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 }, diff --git a/apps/p2/src/tui/preferences.ts b/apps/p2/src/tui/preferences.ts index 84ee8425e..5db8acb61 100644 --- a/apps/p2/src/tui/preferences.ts +++ b/apps/p2/src/tui/preferences.ts @@ -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; } diff --git a/apps/p2/src/tui/settings.ts b/apps/p2/src/tui/settings.ts index 8c6bad400..92b71145a 100644 --- a/apps/p2/src/tui/settings.ts +++ b/apps/p2/src/tui/settings.ts @@ -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): 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; 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); diff --git a/apps/p2/src/tui/tools/index.ts b/apps/p2/src/tui/tools/index.ts index 7ec29b9eb..19fad51f1 100644 --- a/apps/p2/src/tui/tools/index.ts +++ b/apps/p2/src/tui/tools/index.ts @@ -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, registered: ReadonlyMap, + 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); } diff --git a/apps/p2/src/tui/tools/types.ts b/apps/p2/src/tui/tools/types.ts index 1288641f6..e306c1f46 100644 --- a/apps/p2/src/tui/tools/types.ts +++ b/apps/p2/src/tui/tools/types.ts @@ -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. */ diff --git a/apps/p2/test/p2/tui-quiet-tools.test.ts b/apps/p2/test/p2/tui-quiet-tools.test.ts new file mode 100644 index 000000000..11a251fd9 --- /dev/null +++ b/apps/p2/test/p2/tui-quiet-tools.test.ts @@ -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: 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[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] } } } })); +}); diff --git a/apps/p2/test/p2/tui-render.test.ts b/apps/p2/test/p2/tui-render.test.ts index a3eab9b5d..7baa98dad 100644 --- a/apps/p2/test/p2/tui-render.test.ts +++ b/apps/p2/test/p2/tui-render.test.ts @@ -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"); diff --git a/apps/p2/test/p2/tui-settings.test.ts b/apps/p2/test/p2/tui-settings.test.ts index 1623e0a84..f4e495b09 100644 --- a/apps/p2/test/p2/tui-settings.test.ts +++ b/apps/p2/test/p2/tui-settings.test.ts @@ -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.", 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 }) => ({