Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ spawning: false

## Tools Widget

Every sub-agent session displays a compact tools widget showing available and denied tools. Toggle with `Ctrl+J`:
Every sub-agent session displays a compact tools widget showing available and denied tools. Toggle with `Ctrl+J` by default:

```
[scout] — 12 tools · 4 denied (Ctrl+J) ← collapsed
Expand All @@ -462,6 +462,21 @@ Every sub-agent session displays a compact tools widget showing available and de
denied: subagent, subagents_list, ...
```

Change the shortcut in `config.json` using [Pi's key format](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/keybindings.md):

```json
{
"status": {
"enabled": true
},
"keybindings": {
"toggleToolsWidget": "ctrl+shift+x"
}
}
```

Invalid shortcuts show a warning and fall back to `ctrl+j`.

---

## Requirements
Expand Down
3 changes: 3 additions & 0 deletions config.json.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"status": {
"enabled": true
},
"keybindings": {
"toggleToolsWidget": "ctrl+j"
}
}
83 changes: 77 additions & 6 deletions pi-extension/subagents/subagent-done.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Box, Text } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
import { writeFileSync } from "node:fs";
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { createSubagentActivityRecorder } from "./activity.ts";

export function shouldMarkUserTookOver(agentStarted: boolean): boolean {
Expand Down Expand Up @@ -75,7 +77,67 @@ export function parseDeniedTools(rawValue: string | undefined): string[] {
.filter(Boolean);
}

export default function (pi: ExtensionAPI) {
export interface SubagentDoneKeybindings {
toggleToolsWidget: unknown;
}

const DEFAULT_TOGGLE_TOOLS_WIDGET_SHORTCUT = "ctrl+j";
const SHORTCUT_MODIFIERS = new Set(["ctrl", "shift", "alt"]);
const SHORTCUT_SPECIAL_KEYS = new Set([
"escape", "esc", "enter", "return", "tab", "space", "backspace", "delete", "insert",
"clear", "home", "end", "pageUp", "pageDown", "up", "down", "left", "right",
]);
const SHORTCUT_SYMBOL_KEYS = new Set("`-=[]\\;',./!@#$%^&*()_+|~{}:<>?".split(""));

function isValidShortcut(shortcut: unknown): shortcut is string {
if (typeof shortcut !== "string" || shortcut.length === 0) return false;
const modifiers = new Set<string>();
let key = shortcut;
while (key.includes("+")) {
const separator = key.indexOf("+");
const modifier = key.slice(0, separator);
if (!SHORTCUT_MODIFIERS.has(modifier) || modifiers.has(modifier)) break;
modifiers.add(modifier);
key = key.slice(separator + 1);
}
return /^[a-z0-9]$/.test(key) || /^f(?:[1-9]|1[0-2])$/.test(key) ||
SHORTCUT_SPECIAL_KEYS.has(key) || SHORTCUT_SYMBOL_KEYS.has(key);
}

function formatShortcut(shortcut: string): string {
return shortcut
.split("+")
.map((part) => part.length === 1 ? part.toUpperCase() : part[0].toUpperCase() + part.slice(1))
.join("+");
}

export function loadSubagentDoneKeybindings(
configPath = join(dirname(fileURLToPath(import.meta.url)), "../..", "config.json"),
examplePath = join(dirname(fileURLToPath(import.meta.url)), "../..", "config.json.example"),
): SubagentDoneKeybindings {
let rawConfig: string;
try {
rawConfig = readFileSync(configPath, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
rawConfig = readFileSync(examplePath, "utf8");
}

const config = JSON.parse(rawConfig) as { keybindings?: { toggleToolsWidget?: unknown } };
return {
toggleToolsWidget: config.keybindings?.toggleToolsWidget ?? DEFAULT_TOGGLE_TOOLS_WIDGET_SHORTCUT,
};
}

export function createSubagentDoneExtension(
keybindings: SubagentDoneKeybindings = { toggleToolsWidget: DEFAULT_TOGGLE_TOOLS_WIDGET_SHORTCUT },
) {
return function subagentDoneExtension(pi: ExtensionAPI) {
const configuredShortcut = keybindings.toggleToolsWidget;
const shortcutIsValid = isValidShortcut(configuredShortcut);
const toggleToolsWidgetShortcut = shortcutIsValid
? configuredShortcut
: DEFAULT_TOGGLE_TOOLS_WIDGET_SHORTCUT;
let toolNames: string[] = [];
let denied: string[] = [];
let expanded = false;
Expand All @@ -102,7 +164,7 @@ export default function (pi: ExtensionAPI) {
if (expanded) {
// Expanded: full tool list + denied
const countInfo = theme.fg("dim", ` — ${toolNames.length} available`);
const hint = theme.fg("muted", " (Ctrl+J to collapse)");
const hint = theme.fg("muted", ` (${formatShortcut(toggleToolsWidgetShortcut)} to collapse)`);

const toolList = toolNames
.map((name: string) => theme.fg("dim", name))
Expand All @@ -129,7 +191,7 @@ export default function (pi: ExtensionAPI) {
denied.length > 0
? theme.fg("dim", " · ") + theme.fg("error", `${denied.length} denied`)
: "";
const hint = theme.fg("muted", " (Ctrl+J to expand)");
const hint = theme.fg("muted", ` (${formatShortcut(toggleToolsWidgetShortcut)} to expand)`);

const content = new Text(`${agentTag}${countInfo}${deniedInfo}${hint}`, 0, 0);
box.addChild(content);
Expand All @@ -147,6 +209,12 @@ export default function (pi: ExtensionAPI) {
// Show widget + status bar on session start
pi.on("session_start", (_event, ctx) => {
recorder.sessionStart();
if (!shortcutIsValid) {
ctx.ui.notify(
`Invalid shortcut "${String(configuredShortcut)}" for keybindings.toggleToolsWidget; using Ctrl+J.`,
"warning",
);
}
const tools = pi.getAllTools();
toolNames = tools.map((t) => t.name).sort();
denied = parseDeniedTools(deniedToolsValue);
Expand Down Expand Up @@ -256,8 +324,8 @@ export default function (pi: ExtensionAPI) {
recorder.sessionShutdown((event as any).reason);
});

// Toggle expand/collapse with Ctrl+J
pi.registerShortcut("ctrl+j", {
// Toggle expand/collapse with the configured shortcut.
pi.registerShortcut(toggleToolsWidgetShortcut as any, {
description: "Toggle subagent tools widget",
handler: (ctx) => {
expanded = !expanded;
Expand Down Expand Up @@ -321,4 +389,7 @@ export default function (pi: ExtensionAPI) {
};
},
});
};
}

export default createSubagentDoneExtension(loadSubagentDoneKeybindings());
71 changes: 71 additions & 0 deletions test/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
shouldMarkUserTookOver,
shouldAutoExitOnAgentEnd,
findLatestAssistantError,
createSubagentDoneExtension,
} from "../pi-extension/subagents/subagent-done.ts";
import { __pollForExitTest__ } from "../pi-extension/subagents/cmux.ts";

Expand Down Expand Up @@ -2192,6 +2193,76 @@ describe("cmux.ts", () => {
});
});

describe("subagent widget keybinding", () => {
function pressWidgetShortcut(config?: { toggleToolsWidget: string }) {
const shortcuts = new Map<string, any>();
const extension = createSubagentDoneExtension(config);
extension({
on() {},
getAllTools() { return []; },
registerShortcut(key: string, options: any) { shortcuts.set(key, options); },
registerTool() {},
} as any);

const key = config?.toggleToolsWidget ?? "ctrl+j";
let widgetFactory: any;
shortcuts.get(key).handler({
ui: { setWidget(_name: string, factory: any) { widgetFactory = factory; } },
});
const theme = {
bg: (_name: string, text: string) => text,
bold: (text: string) => text,
fg: (_name: string, text: string) => text,
};
return { output: widgetFactory(null, theme).render(200).join("\n"), shortcuts };
}

it("ctrl+j expands the tool widget when no keybinding option is configured", () => {
const { output } = pressWidgetShortcut();
assert.match(output, /Ctrl\+J to collapse/);
});

it("a configured keybinding replaces ctrl+j and expands the tool widget", () => {
const { output, shortcuts } = pressWidgetShortcut({ toggleToolsWidget: "ctrl+shift+x" });

assert.equal(shortcuts.has("ctrl+j"), false);
assert.match(output, /Ctrl\+Shift\+X to collapse/);
});

it("warns about an invalid shortcut and falls back to ctrl+j", () => {
const shortcuts = new Map<string, any>();
const handlers = new Map<string, any>();
const warnings: string[] = [];
createSubagentDoneExtension({ toggleToolsWidget: "cmd+banana" })({
on(event: string, handler: any) { handlers.set(event, handler); },
getAllTools() { return []; },
registerShortcut(key: string, options: any) { shortcuts.set(key, options); },
registerTool() {},
} as any);

handlers.get("session_start")({}, {
ui: {
notify(message: string, level: string) { if (level === "warning") warnings.push(message); },
setWidget() {},
setStatus() {},
},
});
let widgetFactory: any;
shortcuts.get("ctrl+j").handler({
ui: { setWidget(_name: string, factory: any) { widgetFactory = factory; } },
});
const theme = {
bg: (_name: string, text: string) => text,
bold: (text: string) => text,
fg: (_name: string, text: string) => text,
};
const output = widgetFactory(null, theme).render(200).join("\n");

assert.match(warnings.join("\n"), /invalid shortcut/i);
assert.match(output, /Ctrl\+J to collapse/);
});
});

describe("parseCmuxPaneRefForSurfaceFromJson", () => {
it("returns null for malformed JSON text", () => {
assert.equal(parseCmuxPaneRefForSurfaceFromJson("not json", "surface:7"), null);
Expand Down