feat: add ask settings modal shell

This commit is contained in:
Erik Kotsiuba
2026-04-28 22:35:43 +02:00
parent b030b90257
commit 5c0d8964fb
10 changed files with 322 additions and 142 deletions
+3 -3
View File
@@ -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.
+4 -2
View File
@@ -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
+14
View File
@@ -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);
},
});
}
+2
View File
@@ -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);
}
+5 -22
View File
@@ -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<ExtensionContext["ui"]["custom"]>[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<void>(
(_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) {
-101
View File
@@ -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();
}
}
+181
View File
@@ -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, "");
}
+23
View File
@@ -0,0 +1,23 @@
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { AskSettingsModal } from "./settings-modal.ts";
export function showAskSettingsModal(
ui: Pick<ExtensionContext, "ui">["ui"]
): Promise<void> {
return ui.custom<void>(
(_tui, theme, _keybindings, done) =>
new AskSettingsModal(theme, () => {
done();
}),
{
overlay: true,
overlayOptions: {
anchor: "center",
margin: 1,
maxHeight: "90%",
minWidth: 26,
width: 72,
},
}
);
}
+58
View File
@@ -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<void> }
>();
registerAskSettingsCommand({
registerCommand(
name: string,
command: { handler: (args: string, ctx: any) => Promise<void> }
) {
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"));
});
+32 -14
View File
@@ -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;
});