diff --git a/README.md b/README.md index 1cacb28..3dc966a 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Ask the agent to elaborate on notes before finalizing choices. | Key | Context | Effect | |-----------------------------|-----------------------------------------|---------------------------------------------| -| `?` | Ask flow / empty editor | Open key bindings modal | +| `?` | Ask flow / empty editor | Open ask settings modal | | `Tab` `Shift+Tab` | Main flow | Switch tabs | | `←` `→` | Main flow | Switch tabs | | `↑` `↓` | Main flow | Move cursor | @@ -116,9 +116,9 @@ Ask the agent to elaborate on notes before finalizing choices. ## Use -After installation, the extension registers the `ask_user` tool. +After installation, the extension registers the `ask_user` tool and `/ask-settings` command. -Agents can auto-discover and call it when they need clarification instead of guessing. In interactive sessions, it opens a terminal UI flow for structured answers, supports native pi-style `@` file references while typing answers or notes, and returns normalized answers back to the agent. +Agents can auto-discover and call `ask_user` when they need clarification instead of guessing. In interactive sessions, it opens a terminal UI flow for structured answers, supports native pi-style `@` file references while typing answers or notes, and returns normalized answers back to the agent. `/ask-settings` opens the same modal used by `?`, with `Keymaps` and `Behaviour` tabs. This package also bundles the `ask-user` skill profile from `skills/ask-user/SKILL.md`. It reinforces when to use the tool, is enabled by default when installed, and can be disabled via `pi config`. The skill was inspired by https://github.com/edlsh/pi-ask-user. diff --git a/docs/contract.md b/docs/contract.md index 50c24ef..e176c57 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -199,13 +199,15 @@ This document defines the stable external behavior. It does not explain internal - on the review tab, `Submit` and `Cancel` preview notes only for answered questions - on the review tab, `Elaborate` preview expands to all question notes and all option notes, including notes on unselected options - transcript-friendly call and result rendering +- ask settings modal with `Keymaps` and `Behaviour` tabs; `Behaviour` may be empty while unimplemented +- `/ask-settings` opens the same ask settings modal as `?` - elaborate results are phrased as direct follow-up instructions, for example: `User asked to elaborate on question "Which option would you like to select?" option "Option A" with note "why this one?"` ## Keyboard behavior Main flow: -- `?`: open the key bindings modal +- `?`: open the ask settings modal on the `Keymaps` tab - `Tab`, `Shift+Tab`, `Left`, `Right`: move between tabs - `Up`, `Down`: move between options - `Enter`: confirm or submit @@ -221,7 +223,7 @@ Editing flow: - `Enter`: submit the current editor input and close the editor; in note editors this saves the note only and keeps the ask flow open - `Esc`: save draft and close the editor - `Ctrl+C`: dismiss the entire flow immediately without saving the current editor draft -- `?`: open the key bindings modal when the editor is empty; otherwise enter `?` as text +- `?`: open the ask settings modal on the `Keymaps` tab when the editor is empty; otherwise enter `?` as text - when editor has text, arrow keys and `Tab` stay in the editor so the cursor can move while typing - when editor is empty, `Up`/`Down` move options and `Tab`/`Shift+Tab`/`Left`/`Right` move between tabs without requiring `Esc` first - navigation resumes only after closing the editor with `Esc`, unless the editor is empty and the navigation keys above are used diff --git a/src/ask-settings-command.ts b/src/ask-settings-command.ts new file mode 100644 index 0000000..31de2a1 --- /dev/null +++ b/src/ask-settings-command.ts @@ -0,0 +1,14 @@ +import type { + ExtensionAPI, + ExtensionCommandContext, +} from "@mariozechner/pi-coding-agent"; +import { showAskSettingsModal } from "./ui/show-settings-modal.ts"; + +export function registerAskSettingsCommand(pi: ExtensionAPI) { + pi.registerCommand("ask-settings", { + description: "Open the ask settings modal", + handler: async (_args: string, ctx: ExtensionCommandContext) => { + await showAskSettingsModal(ctx.ui); + }, + }); +} diff --git a/src/index.ts b/src/index.ts index 25ac33a..e9aed8d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { registerAskSettingsCommand } from "./ask-settings-command.ts"; import { registerAskTool } from "./ask-tool.ts"; export default function askExtension(pi: ExtensionAPI) { registerAskTool(pi); + registerAskSettingsCommand(pi); } diff --git a/src/ui/controller.ts b/src/ui/controller.ts index 54b0535..10f039c 100644 --- a/src/ui/controller.ts +++ b/src/ui/controller.ts @@ -27,10 +27,10 @@ import { import { isEditingView } from "../state/view.ts"; import type { AskParams, AskResult, AskState } from "../types.ts"; import { createAskAutocompleteProvider } from "./autocomplete.ts"; -import { AskHelpModal } from "./help-modal.ts"; import type { AskInputCommand } from "./input.ts"; import { getInputCommand } from "./input.ts"; import { renderAskScreen } from "./render.ts"; +import { showAskSettingsModal } from "./show-settings-modal.ts"; type CustomCallback = Parameters[0]; type CustomCallbackArgs = CustomCallback extends (...args: infer T) => unknown @@ -288,27 +288,10 @@ function showHelpModal(controller: AskFlowController) { return; } controller.helpOpen = true; - controller.ctx.ui - .custom( - (_tui, _theme, _keybindings, done) => - new AskHelpModal(controller.theme, () => { - done(); - }), - { - overlay: true, - overlayOptions: { - anchor: "center", - margin: 1, - maxHeight: "90%", - minWidth: 26, - width: 72, - }, - } - ) - .finally(() => { - controller.helpOpen = false; - refresh(controller); - }); + showAskSettingsModal(controller.ctx.ui).finally(() => { + controller.helpOpen = false; + refresh(controller); + }); } function refresh(controller: AskFlowController) { diff --git a/src/ui/help-modal.ts b/src/ui/help-modal.ts deleted file mode 100644 index dcb2571..0000000 --- a/src/ui/help-modal.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; -import { - ASK_HELP_CLOSE_BINDINGS, - ASK_HELP_CLOSE_HINT, - ASK_KEYMAPS, - matchesBinding, -} from "../constants/keymaps.ts"; -import { wrapText } from "../text.ts"; - -interface Theme { - bg(color: string, text: string): string; - fg(color: string, text: string): string; -} - -const MIN_WIDTH = 24; -const COMPACT_WIDTH = 46; - -function padToWidth(text: string, width: number): string { - return text + " ".repeat(Math.max(0, width - visibleWidth(text))); -} - -export class AskHelpModal { - private closed = false; - private readonly onClose: () => void; - private readonly theme: Theme; - - constructor(theme: Theme, onClose: () => void) { - this.theme = theme; - this.onClose = onClose; - } - - handleInput(data: string): void { - if ( - ASK_HELP_CLOSE_BINDINGS.some((binding) => matchesBinding(data, binding)) - ) { - this.close(); - } - } - - render(width: number): string[] { - const innerW = Math.max(MIN_WIDTH, width - 2); - const compact = innerW < COMPACT_WIDTH; - const keyW = compact ? 11 : 16; - const descW = Math.max(8, innerW - keyW - 5); - const border = (text: string) => this.theme.fg("border", text); - const title = this.theme.fg("accent", " @eko24ive/pi-ask "); - const titleW = visibleWidth(title); - const titlePad = Math.max(0, innerW - titleW); - const left = Math.floor(titlePad / 2); - const right = titlePad - left; - const line = (content = "") => - `${border("│")}${padToWidth(` ${content}`, innerW)}${border("│")}`; - - const lines = [ - `${border(`╭${"─".repeat(left)}`)}${title}${border(`${"─".repeat(right)}╮`)}`, - `${border(`├${"─".repeat(innerW)}┤`)}`, - ]; - - for (const hint of ASK_KEYMAPS) { - if (compact) { - lines.push(line(this.theme.fg("accent", hint.label))); - for (const descLine of wrapText(hint.description, innerW - 3)) { - lines.push(line(` ${this.theme.fg("text", descLine)}`)); - } - continue; - } - - const keyText = padToWidth(truncateToWidth(hint.label, keyW, "…"), keyW); - const key = this.theme.fg("accent", keyText); - const desc = truncateToWidth(hint.description, descW, "…"); - lines.push(line(`${key} ${desc}`)); - const contexts = hint.contexts.join(", "); - lines.push( - line(`${" ".repeat(keyW)} ${this.theme.fg("dim", contexts)}`) - ); - } - - lines.push(`${border(`├${"─".repeat(innerW)}┤`)}`); - for (const hintLine of wrapText(ASK_HELP_CLOSE_HINT, innerW - 2)) { - lines.push(line(this.theme.fg("muted", hintLine))); - } - lines.push(`${border(`╰${"─".repeat(innerW)}╯`)}`); - return lines; - } - - invalidate(): void { - // Static modal; no async invalidation needed. - } - - dispose(): void { - this.close(); - } - - private close(): void { - if (this.closed) { - return; - } - this.closed = true; - this.onClose(); - } -} diff --git a/src/ui/settings-modal.ts b/src/ui/settings-modal.ts new file mode 100644 index 0000000..ca6afbf --- /dev/null +++ b/src/ui/settings-modal.ts @@ -0,0 +1,181 @@ +import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import { + ASK_HELP_CLOSE_BINDINGS, + ASK_HELP_CLOSE_HINT, + ASK_KEY_BINDINGS, + ASK_KEYMAPS, + matchesBinding, +} from "../constants/keymaps.ts"; +import { wrapText } from "../text.ts"; + +type SettingsTab = "keymaps" | "behaviour"; + +interface Theme { + bg(color: string, text: string): string; + fg(color: string, text: string): string; +} + +const MIN_WIDTH = 24; +const COMPACT_WIDTH = 46; +const TAB_GAP = " "; + +function padToWidth(text: string, width: number): string { + return text + " ".repeat(Math.max(0, width - visibleWidth(text))); +} + +export class AskSettingsModal { + private activeTab: SettingsTab = "keymaps"; + private closed = false; + private readonly onClose: () => void; + private readonly theme: Theme; + + constructor(theme: Theme, onClose: () => void) { + this.theme = theme; + this.onClose = onClose; + } + + handleInput(data: string): void { + if ( + ASK_HELP_CLOSE_BINDINGS.some((binding) => matchesBinding(data, binding)) + ) { + this.close(); + return; + } + if (matchesBinding(data, ASK_KEY_BINDINGS.nextTab)) { + this.activeTab = this.activeTab === "keymaps" ? "behaviour" : "keymaps"; + return; + } + if (matchesBinding(data, ASK_KEY_BINDINGS.previousTab)) { + this.activeTab = this.activeTab === "behaviour" ? "keymaps" : "behaviour"; + } + } + + render(width: number): string[] { + const layout = createLayout(this.theme, this.activeTab, width); + const lines = [...layout.headerLines]; + + if (this.activeTab === "keymaps") { + lines.push(...renderKeymapsTab(this.theme, layout)); + } else { + lines.push(...renderBehaviourTab(this.theme, layout)); + } + + lines.push(`${layout.border(`├${"─".repeat(layout.innerW)}┤`)}`); + for (const hintLine of wrapText(ASK_HELP_CLOSE_HINT, layout.innerW - 2)) { + lines.push(layout.line(this.theme.fg("muted", hintLine))); + } + lines.push(`${layout.border(`╰${"─".repeat(layout.innerW)}╯`)}`); + return lines; + } + + invalidate(): void { + // Static modal; no async invalidation needed. + } + + dispose(): void { + this.close(); + } + + private close(): void { + if (this.closed) { + return; + } + this.closed = true; + this.onClose(); + } +} + +interface SettingsLayout { + border: (text: string) => string; + descW: number; + headerLines: string[]; + innerW: number; + keyW: number; + line: (content?: string) => string; +} + +function createLayout( + theme: Theme, + activeTab: SettingsTab, + width: number +): SettingsLayout { + const innerW = Math.max(MIN_WIDTH, width - 2); + const compact = innerW < COMPACT_WIDTH; + const keyW = compact ? 11 : 16; + const descW = Math.max(8, innerW - keyW - 5); + const border = (text: string) => theme.fg("border", text); + const title = theme.fg("accent", " @eko24ive/pi-ask "); + const titleW = visibleWidth(title); + const titlePad = Math.max(0, innerW - titleW); + const left = Math.floor(titlePad / 2); + const right = titlePad - left; + const line = (content = "") => + `${border("│")}${padToWidth(` ${content}`, innerW)}${border("│")}`; + + return { + border, + descW, + headerLines: [ + `${border(`╭${"─".repeat(left)}`)}${title}${border(`${"─".repeat(right)}╮`)}`, + line(renderTabs(theme, activeTab, innerW - 1)), + line(), + ], + innerW, + keyW, + line, + }; +} + +function renderKeymapsTab(theme: Theme, layout: SettingsLayout): string[] { + const lines: string[] = []; + const compact = layout.innerW < COMPACT_WIDTH; + + for (const hint of ASK_KEYMAPS) { + if (compact) { + lines.push(layout.line(theme.fg("accent", hint.label))); + for (const descLine of wrapText(hint.description, layout.innerW - 3)) { + lines.push(layout.line(` ${theme.fg("text", descLine)}`)); + } + continue; + } + + const keyText = padToWidth( + truncateToWidth(hint.label, layout.keyW, "…"), + layout.keyW + ); + const key = theme.fg("accent", keyText); + const desc = truncateToWidth(hint.description, layout.descW, "…"); + lines.push(layout.line(`${key} ${desc}`)); + const contexts = hint.contexts.join(", "); + lines.push( + layout.line(`${" ".repeat(layout.keyW)} ${theme.fg("dim", contexts)}`) + ); + } + + return lines; +} + +function renderBehaviourTab(theme: Theme, layout: SettingsLayout): string[] { + return wrapText("Behaviour settings coming soon.", layout.innerW - 2).map( + (settingsLine) => layout.line(theme.fg("muted", settingsLine)) + ); +} + +function renderTabs( + theme: Theme, + activeTab: SettingsTab, + width: number +): string { + const renderTab = (tab: SettingsTab, label: string) => { + const text = ` ${label} `; + return activeTab === tab + ? theme.bg("selectedBg", theme.fg("accent", text)) + : theme.fg("muted", text); + }; + + const tabs = [ + renderTab("keymaps", "Keymaps"), + renderTab("behaviour", "Behaviour"), + ].join(TAB_GAP); + return truncateToWidth(tabs, width, ""); +} diff --git a/src/ui/show-settings-modal.ts b/src/ui/show-settings-modal.ts new file mode 100644 index 0000000..8039d4f --- /dev/null +++ b/src/ui/show-settings-modal.ts @@ -0,0 +1,23 @@ +import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { AskSettingsModal } from "./settings-modal.ts"; + +export function showAskSettingsModal( + ui: Pick["ui"] +): Promise { + return ui.custom( + (_tui, theme, _keybindings, done) => + new AskSettingsModal(theme, () => { + done(); + }), + { + overlay: true, + overlayOptions: { + anchor: "center", + margin: 1, + maxHeight: "90%", + minWidth: 26, + width: 72, + }, + } + ); +} diff --git a/tests/ask-settings-command.test.ts b/tests/ask-settings-command.test.ts new file mode 100644 index 0000000..48d58fb --- /dev/null +++ b/tests/ask-settings-command.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { registerAskSettingsCommand } from "../src/ask-settings-command.ts"; + +function plainTheme() { + return { + fg(_color: string, text: string) { + return text; + }, + bg(_color: string, text: string) { + return text; + }, + }; +} + +test("registers /ask-settings and opens the shared modal overlay", async () => { + const commands = new Map< + string, + { handler: (args: string, ctx: any) => Promise } + >(); + registerAskSettingsCommand({ + registerCommand( + name: string, + command: { handler: (args: string, ctx: any) => Promise } + ) { + commands.set(name, command); + }, + } as never); + + assert(commands.has("ask-settings")); + + const customCalls: Array<{ options: unknown; lines: string[] }> = []; + await commands.get("ask-settings")?.handler("", { + ui: { + custom(callback: any, options: unknown) { + const done = () => { + // test callback intentionally unused + }; + const component = callback(undefined, plainTheme(), undefined, done); + customCalls.push({ options, lines: component.render(72) }); + return Promise.resolve(); + }, + }, + }); + + assert.equal(customCalls.length, 1); + assert.deepEqual(customCalls[0]?.options, { + overlay: true, + overlayOptions: { + anchor: "center", + margin: 1, + maxHeight: "90%", + minWidth: 26, + width: 72, + }, + }); + assert(customCalls[0]?.lines.join("\n").includes("Keymaps")); +}); diff --git a/tests/help-modal.test.ts b/tests/help-modal.test.ts index 4207b64..8276c52 100644 --- a/tests/help-modal.test.ts +++ b/tests/help-modal.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { visibleWidth } from "@mariozechner/pi-tui"; import { ASK_HELP_CLOSE_HINT, ASK_KEYMAPS } from "../src/constants/keymaps.ts"; -import { AskHelpModal } from "../src/ui/help-modal.ts"; +import { AskSettingsModal } from "../src/ui/settings-modal.ts"; const noop = () => { // Test callback intentionally does nothing. @@ -19,20 +19,23 @@ function plainTheme() { }; } -test("help modal renders compactly on phone-width screens", () => { - const modal = new AskHelpModal(plainTheme(), noop); +test("settings modal renders compactly on phone-width screens", () => { + const modal = new AskSettingsModal(plainTheme(), noop); const lines = modal.render(28); assert(lines.length > ASK_KEYMAPS.length + 4); assert(lines.every((line) => visibleWidth(line) <= 28)); assert(lines.join("\n").includes("@eko24ive/pi-ask")); assert(lines.slice(-3, -1).join(" ").includes("Esc, Ctrl+C, or ?")); + assert(lines[1]?.includes("Keymaps")); + assert.equal(lines[2], "│ │"); assert(lines.includes("│ ? │")); assert(lines.includes("│ Show this menu │")); + assert.equal(lines[0]?.includes("├"), false); }); -test("help modal keeps context rows on wider screens", () => { - const modal = new AskHelpModal(plainTheme(), noop); +test("settings modal keeps context rows on wider screens", () => { + const modal = new AskSettingsModal(plainTheme(), noop); const lines = modal.render(72); assert(lines.length > ASK_KEYMAPS.length + 4); @@ -40,22 +43,37 @@ test("help modal keeps context rows on wider screens", () => { assert(lines.at(-2)?.includes(ASK_HELP_CLOSE_HINT)); }); -test("help modal closes from configured keyboard shortcuts", () => { +test("settings modal switches tabs and closes from configured shortcuts", () => { + let closed = 0; + const modal = new AskSettingsModal(plainTheme(), () => { + closed += 1; + }); + + modal.handleInput("\t"); + assert( + modal.render(72).join("\n").includes("Behaviour settings coming soon.") + ); + assert(modal.render(72).join("\n").includes("Behaviour")); + + modal.handleInput("\u001b[Z"); + assert.equal( + modal.render(72).join("\n").includes("Behaviour settings coming soon."), + false + ); + for (const input of ["?", "\u001b", "\u0003"]) { - let closed = 0; - const modal = new AskHelpModal(plainTheme(), () => { + const closable = new AskSettingsModal(plainTheme(), () => { closed += 1; }); - - modal.handleInput(input); - - assert.equal(closed, 1); + closable.handleInput(input); } + + assert.equal(closed, 3); }); -test("help modal ignores enter and closes idempotently on dispose", () => { +test("settings modal ignores enter and closes idempotently on dispose", () => { let closed = 0; - const modal = new AskHelpModal(plainTheme(), () => { + const modal = new AskSettingsModal(plainTheme(), () => { closed += 1; });