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

@ -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 |

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 });
}),
);

View file

@ -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";

View file

@ -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. */

View file

@ -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);

View file

@ -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 },

View file

@ -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;
}

View file

@ -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);

View file

@ -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);
}

View file

@ -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. */

View 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] } } } }));
});

View file

@ -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");

View file

@ -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 }) => ({