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