refactor: simplify ask settings UI

This commit is contained in:
Erik Kotsiuba
2026-05-02 16:09:08 +02:00
parent 4153f6b09f
commit 69c1ca3803
15 changed files with 499 additions and 981 deletions
+3 -3
View File
@@ -2,13 +2,13 @@ import type {
ExtensionAPI,
ExtensionCommandContext,
} from "@mariozechner/pi-coding-agent";
import { showAskSettingsModal } from "./ui/show-settings-modal.ts";
import { showAskSettings } from "./ui/show-settings.ts";
export function registerAskSettingsCommand(pi: ExtensionAPI) {
pi.registerCommand("ask-settings", {
description: "Open the ask settings modal",
description: "Open ask settings",
handler: async (_args: string, ctx: ExtensionCommandContext) => {
await showAskSettingsModal(ctx.ui);
await showAskSettings(ctx.ui);
},
});
}
+3 -3
View File
@@ -104,7 +104,7 @@ export class AskConfigStore {
parsed = JSON.parse(content);
} catch {
return this.backupAndReset(
"Config was invalid or unsupported. Backed it up and loaded defaults. Press Ctrl+S to save a fresh config."
"Config was invalid or unsupported. Backed it up and loaded defaults. Change any behaviour setting or edit the config file to save a fresh config."
);
}
@@ -126,8 +126,8 @@ export class AskConfigStore {
if (error instanceof AskConfigMigrationError) {
return this.backupAndReset(
error.reason === "migration_failed"
? "Config migration failed. Backed up old config and loaded defaults. Press Ctrl+S to save a fresh config."
: "Config was invalid or unsupported. Backed it up and loaded defaults. Press Ctrl+S to save a fresh config."
? "Config migration failed. Backed up old config and loaded defaults. Change any behaviour setting or edit the config file to save a fresh config."
: "Config was invalid or unsupported. Backed it up and loaded defaults. Change any behaviour setting or edit the config file to save a fresh config."
);
}
throw error;
+1 -1
View File
@@ -158,7 +158,7 @@ const FIXED_BINDING_METADATA: readonly FixedBindingMetadata[] = [
id: "settings",
keys: ["?"],
kind: "command",
description: "Open ask settings modal",
description: "Open ask settings",
contexts: ["Main flow", "Empty editor"],
},
{
+2 -2
View File
@@ -42,7 +42,7 @@ import {
getReviewShortcutHint,
resolveReviewShortcutDoublePress,
} from "./review-shortcuts.ts";
import { showAskSettingsModal } from "./show-settings-modal.ts";
import { showAskSettings } from "./show-settings.ts";
type CustomCallback = Parameters<ExtensionContext["ui"]["custom"]>[0];
type CustomCallbackArgs = CustomCallback extends (...args: infer T) => unknown
@@ -430,7 +430,7 @@ function showSettingsModal(controller: AskFlowController) {
return;
}
controller.settingsOpen = true;
showAskSettingsModal(controller.ctx.ui).finally(() => {
showAskSettings(controller.ctx.ui).finally(() => {
controller.settingsOpen = false;
refresh(controller);
});
+329
View File
@@ -0,0 +1,329 @@
import {
Key,
matchesKey,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
} from "@mariozechner/pi-tui";
import type { AskConfig } from "../config/schema.ts";
import type { AskConfigNotice } from "../config/store.ts";
interface Theme {
bg(color: string, text: string): string;
fg(color: string, text: string): string;
}
interface TuiLike {
requestRender(): void;
}
interface AskSettingsListOptions {
configPath: string;
notice?: AskConfigNotice;
onClose: () => void;
onSave: (config: AskConfig) => Promise<AskConfig>;
savedConfig: AskConfig;
tui: TuiLike;
}
const DESCRIPTION_LINE_COUNT = 3;
const COMPACT_WIDTH = 40;
const BEHAVIOUR_SETTINGS = [
{
description:
"Auto-submit completed ask flows when no question or option notes were added.",
key: "autoSubmitWhenAnsweredWithoutNotes",
label: "Auto-submit when answered without notes",
},
{
description:
"Require a second cancel or dismiss action before discarding answered or drafted ask content.",
key: "confirmDismissWhenDirty",
label: "Confirm dismiss when dirty",
},
{
description:
"Require pressing 1, 2, or 3 twice on the review tab before triggering Submit, Elaborate, or Cancel.",
key: "doublePressReviewShortcuts",
label: "Double-press review shortcuts",
},
{
description: "Show footer keymap hints at the bottom of the ask flow.",
key: "showFooterHints",
label: "Show footer hints",
},
] as const;
type BehaviourKey = (typeof BEHAVIOUR_SETTINGS)[number]["key"];
export class AskSettingsList {
private closed = false;
private config: AskConfig;
private error?: string;
private focusIndex = 0;
private readonly configPath: string;
private readonly notice?: AskConfigNotice;
private readonly onClose: () => void;
private readonly onSave: (config: AskConfig) => Promise<AskConfig>;
private readonly theme: Theme;
private readonly tui: TuiLike;
constructor(theme: Theme, options: AskSettingsListOptions) {
this.theme = theme;
this.config = options.savedConfig;
this.configPath = options.configPath;
this.notice = options.notice;
this.onClose = options.onClose;
this.onSave = options.onSave;
this.tui = options.tui;
}
handleInput(data: string): void {
if (
data === "?" ||
matchesKey(data, Key.ctrl("c")) ||
matchesKey(data, Key.escape)
) {
this.close();
return;
}
if (matchesKey(data, Key.up)) {
this.focusIndex =
this.focusIndex === 0
? BEHAVIOUR_SETTINGS.length - 1
: this.focusIndex - 1;
this.tui.requestRender();
return;
}
if (matchesKey(data, Key.down)) {
this.focusIndex = (this.focusIndex + 1) % BEHAVIOUR_SETTINGS.length;
this.tui.requestRender();
return;
}
if (matchesKey(data, Key.enter) || data === " ") {
const setting = BEHAVIOUR_SETTINGS[this.focusIndex];
if (setting) {
this.saveBehaviour(setting.key, !this.config.behaviour[setting.key]);
this.tui.requestRender();
}
}
}
render(width: number): string[] {
const innerWidth = Math.max(24, width - 2);
const lines = [
this.topBorder(innerWidth),
this.line(
center(this.theme.fg("accent", "Ask Settings"), innerWidth),
innerWidth
),
this.line("", innerWidth),
];
this.appendWrapped(
lines,
this.theme.fg(
"muted",
"Edit this config file to change customizable ask keymaps:"
),
innerWidth
);
this.appendWrapped(
lines,
this.theme.fg("accent", this.configPath),
innerWidth,
{
center: true,
}
);
lines.push(this.line("", innerWidth));
for (const [index, setting] of BEHAVIOUR_SETTINGS.entries()) {
for (const settingLine of this.renderSetting(
setting,
index,
innerWidth
)) {
lines.push(this.line(settingLine, innerWidth));
}
if (innerWidth < COMPACT_WIDTH && index < BEHAVIOUR_SETTINGS.length - 1) {
lines.push(this.line("", innerWidth));
}
}
this.appendSelectedDescription(lines, innerWidth);
this.appendNotice(lines, innerWidth);
lines.push(this.line("", innerWidth));
this.appendFooter(lines, innerWidth);
lines.push(this.bottomBorder(innerWidth));
return lines.map((line) => truncateToWidth(line, width));
}
invalidate(): void {
// State-driven component; nothing cached across theme changes.
}
dispose(): void {
this.close();
}
private appendFooter(lines: string[], innerWidth: number): void {
if (innerWidth < COMPACT_WIDTH) {
lines.push(
this.line(this.theme.fg("dim", " Enter/Space to change"), innerWidth)
);
lines.push(this.line(this.theme.fg("dim", " Esc to close"), innerWidth));
return;
}
this.appendWrapped(
lines,
this.theme.fg("dim", "Enter/Space to change · Esc to close"),
innerWidth
);
}
private appendSelectedDescription(lines: string[], innerWidth: number): void {
const selectedSetting = BEHAVIOUR_SETTINGS[this.focusIndex];
if (!selectedSetting) {
return;
}
lines.push(this.line("", innerWidth));
const descriptionLines = wrapTextWithAnsi(
this.theme.fg("muted", selectedSetting.description),
innerWidth - 2
).slice(0, DESCRIPTION_LINE_COUNT);
for (const line of descriptionLines) {
lines.push(this.line(` ${line}`, innerWidth));
}
for (
let index = descriptionLines.length;
index < DESCRIPTION_LINE_COUNT;
index++
) {
lines.push(this.line("", innerWidth));
}
}
private appendNotice(lines: string[], innerWidth: number): void {
if (this.notice?.kind === "error") {
lines.push(this.line("", innerWidth));
this.appendWrapped(
lines,
this.theme.fg("warning", this.notice.text),
innerWidth
);
}
if (this.error) {
lines.push(this.line("", innerWidth));
this.appendWrapped(lines, this.theme.fg("error", this.error), innerWidth);
}
}
private appendWrapped(
lines: string[],
content: string,
innerWidth: number,
options: { center?: boolean } = {}
): void {
for (const line of wrapTextWithAnsi(content, innerWidth - 2)) {
const contentLine = options.center
? center(line, innerWidth)
: ` ${line}`;
lines.push(this.line(contentLine, innerWidth));
}
}
private renderSetting(
setting: (typeof BEHAVIOUR_SETTINGS)[number],
index: number,
innerWidth: number
): string[] {
const selected = index === this.focusIndex;
const prefix = selected ? this.theme.fg("accent", "❯ ") : " ";
const continuationPrefix = " ";
const value = this.renderValue(
this.config.behaviour[setting.key],
selected
);
const valueWidth = this.config.behaviour[setting.key] ? 4 : 5;
const labelWidth = Math.max(
1,
innerWidth - visibleWidth(prefix) - valueWidth - 2
);
const labelLines = wrapTextWithAnsi(setting.label, labelWidth);
const firstLabelLine = labelLines[0] ?? "";
const gap = " ".repeat(
Math.max(1, labelWidth - visibleWidth(firstLabelLine) + 1)
);
const firstLine = `${prefix}${firstLabelLine}${gap}${value}`;
const continuationLines = labelLines
.slice(1)
.map((line) => `${continuationPrefix}${line}`);
return [firstLine, ...continuationLines];
}
private renderValue(enabled: boolean, selected: boolean): string {
const value = enabled ? "on" : "off";
const styledValue = selected
? this.theme.bg("selectedBg", this.theme.fg("accent", value))
: this.theme.fg("muted", value);
return `${this.theme.fg("dim", "[")}${styledValue}${this.theme.fg("dim", "]")}`;
}
private saveBehaviour(key: BehaviourKey, enabled: boolean): void {
this.error = undefined;
const previousConfig = this.config;
const nextConfig = {
...this.config,
behaviour: {
...this.config.behaviour,
[key]: enabled,
},
};
this.config = nextConfig;
this.onSave(nextConfig)
.then((savedConfig) => {
this.config = savedConfig;
this.tui.requestRender();
})
.catch((error: unknown) => {
this.config = previousConfig;
this.error = error instanceof Error ? error.message : String(error);
this.tui.requestRender();
});
}
private topBorder(innerWidth: number): string {
return this.theme.fg("muted", `╭${"─".repeat(innerWidth)}╮`);
}
private bottomBorder(innerWidth: number): string {
return this.theme.fg("muted", `╰${"─".repeat(innerWidth)}╯`);
}
private line(content: string, innerWidth: number): string {
const clipped = truncateToWidth(content, innerWidth, "…");
return `${this.theme.fg("muted", "│")}${padToWidth(clipped, innerWidth)}${this.theme.fg("muted", "│")}`;
}
private close(): void {
if (this.closed) {
return;
}
this.closed = true;
this.onClose();
}
}
function center(text: string, width: number): string {
const padding = Math.max(0, width - visibleWidth(text));
const left = Math.floor(padding / 2);
const right = padding - left;
return `${" ".repeat(left)}${text}${" ".repeat(right)}`;
}
function padToWidth(text: string, width: number): string {
return `${text}${" ".repeat(Math.max(0, width - visibleWidth(text)))}`;
}
-589
View File
@@ -1,589 +0,0 @@
import {
Key,
matchesKey,
truncateToWidth,
visibleWidth,
} from "@mariozechner/pi-tui";
import type { AskConfig } from "../config/schema.ts";
import type { AskConfigNotice } from "../config/store.ts";
import { type AskKeyBinding, getAskKeymaps } from "../constants/keymaps.ts";
import { wrapText } from "../text.ts";
import {
type AskSettingsState,
clearTransientNotice,
completeSave,
createAskSettingsState,
failSave,
isDirty,
moveBehaviourFocus,
moveSettingsTab,
requestDiscard,
setSaving,
shouldDiscard,
toggleBehaviourSetting,
} from "./settings-state.ts";
interface Theme {
bg(color: string, text: string): string;
fg(color: string, text: string): string;
}
interface TuiLike {
requestRender(): void;
}
interface AskSettingsModalOptions {
configPath: string;
notice?: AskConfigNotice;
onClose: () => void;
onSave: (config: AskConfig) => Promise<AskConfig>;
savedConfig: AskConfig;
tui: TuiLike;
}
const MIN_WIDTH = 24;
const COMPACT_WIDTH = 46;
const COMPACT_DESCRIPTION_LINES = 2;
const TAB_GAP = " ";
const DISCARD_WINDOW_MS = 1000;
const SETTINGS_MODAL_KEYS = {
close: [Key.escape, Key.ctrl("c"), "?"],
nextTab: [Key.tab, Key.right],
previousTab: [Key.shift("tab"), Key.left],
up: [Key.up],
down: [Key.down],
toggle: [Key.space],
save: [Key.ctrl("s")],
} as const;
const BEHAVIOUR_ROWS = [
{
description:
"Auto-submit completed ask flows when no question or option notes were added.",
key: "autoSubmitWhenAnsweredWithoutNotes",
getValue(config: AskConfig) {
return config.behaviour.autoSubmitWhenAnsweredWithoutNotes;
},
label: "Auto-submit when answered without notes",
},
{
description:
"Require a second cancel or dismiss action before discarding answered or drafted ask content.",
key: "confirmDismissWhenDirty",
getValue(config: AskConfig) {
return config.behaviour.confirmDismissWhenDirty;
},
label: "Confirm dismiss when dirty",
},
{
description:
"Require pressing 1, 2, or 3 twice on the review tab before triggering Submit, Elaborate, or Cancel.",
key: "doublePressReviewShortcuts",
getValue(config: AskConfig) {
return config.behaviour.doublePressReviewShortcuts;
},
label: "Double-press review shortcuts",
},
{
description: "Show footer keymap hints at the bottom of the ask flow.",
key: "showFooterHints",
getValue(config: AskConfig) {
return config.behaviour.showFooterHints;
},
label: "Show footer hints",
},
] as const;
type SettingsTab = "keymaps" | "behaviour";
function padToWidth(text: string, width: number): string {
return text + " ".repeat(Math.max(0, width - visibleWidth(text)));
}
export class AskSettingsModal {
private closed = false;
private readonly configPath: string;
private readonly onClose: () => void;
private readonly onSave: (config: AskConfig) => Promise<AskConfig>;
private state: AskSettingsState;
private readonly theme: Theme;
private readonly tui: TuiLike;
constructor(theme: Theme, options: AskSettingsModalOptions) {
this.theme = theme;
this.configPath = options.configPath;
this.onClose = options.onClose;
this.onSave = options.onSave;
this.state = createAskSettingsState(options.savedConfig, options.notice);
this.tui = options.tui;
}
handleInput(data: string): void {
if (this.handleGlobalInput(data)) {
return;
}
if (this.state.activeTab === "behaviour") {
this.handleBehaviourInput(data);
}
}
render(width: number): string[] {
const layout = createLayout(this.theme, this.state.activeTab, width);
const lines = [...layout.headerLines];
if (this.state.activeTab === "keymaps") {
lines.push(
...renderKeymapsTab(
this.theme,
layout,
this.state.savedConfig,
this.configPath,
this.state.notice
)
);
} else {
lines.push(...renderBehaviourTab(this.theme, this.state, layout));
}
lines.push(...renderBottomPanel(this.theme, this.state, layout));
return lines;
}
invalidate(): void {
// State-driven modal; nothing cached across theme changes.
}
dispose(): void {
this.close();
}
private handleGlobalInput(data: string): boolean {
const closeRequested = matchesAny(data, SETTINGS_MODAL_KEYS.close);
if (!closeRequested) {
this.state = clearTransientNotice(this.state);
}
if (closeRequested) {
this.handleClose();
return true;
}
if (matchesAny(data, SETTINGS_MODAL_KEYS.save)) {
this.save().catch(() => {
// save() handles state updates and rerendering on failure
});
return true;
}
if (matchesAny(data, SETTINGS_MODAL_KEYS.nextTab)) {
this.state = moveSettingsTab(this.state, 1);
this.refresh();
return true;
}
if (matchesAny(data, SETTINGS_MODAL_KEYS.previousTab)) {
this.state = moveSettingsTab(this.state, -1);
this.refresh();
return true;
}
return false;
}
private handleBehaviourInput(data: string): void {
if (matchesAny(data, SETTINGS_MODAL_KEYS.up)) {
this.state = moveBehaviourFocus(this.state, -1, BEHAVIOUR_ROWS.length);
this.refresh();
return;
}
if (matchesAny(data, SETTINGS_MODAL_KEYS.down)) {
this.state = moveBehaviourFocus(this.state, 1, BEHAVIOUR_ROWS.length);
this.refresh();
return;
}
if (matchesAny(data, SETTINGS_MODAL_KEYS.toggle)) {
const row = BEHAVIOUR_ROWS[this.state.behaviourFocusIndex];
if (!row) {
return;
}
this.state = toggleBehaviourSetting(this.state, row.key);
this.refresh();
}
}
private handleClose(): void {
if (!isDirty(this.state)) {
this.close();
return;
}
const now = Date.now();
if (shouldDiscard(this.state, now)) {
this.close();
return;
}
this.state = requestDiscard(this.state, now, DISCARD_WINDOW_MS);
this.refresh();
}
private async save(): Promise<void> {
if (this.state.saving) {
return;
}
this.state = setSaving(this.state);
this.refresh();
try {
const savedConfig = await this.onSave(this.state.draftConfig);
this.state = completeSave(this.state, savedConfig);
} catch (error) {
this.state = failSave(
this.state,
error instanceof Error ? error.message : String(error)
);
}
this.refresh();
}
private refresh(): void {
this.tui.requestRender();
}
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("muted", text);
const title = theme.fg("muted", " @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 renderBottomPanel(
theme: Theme,
state: AskSettingsState,
layout: SettingsLayout
): string[] {
const lines = [`${layout.border(`├${"─".repeat(layout.innerW)}┤`)}`];
const notice = state.notice ?? getDefaultNotice(state);
if (notice) {
for (const noticeLine of wrapText(notice.text, layout.innerW - 2)) {
lines.push(layout.line(colorNotice(theme, notice.kind, noticeLine)));
}
}
for (const hintLine of wrapText(renderCloseHint(state), layout.innerW - 2)) {
lines.push(layout.line(theme.fg("muted", hintLine)));
}
lines.push(`${layout.border(`╰${"─".repeat(layout.innerW)}╯`)}`);
return lines;
}
function renderBehaviourTab(
theme: Theme,
state: AskSettingsState,
layout: SettingsLayout
): string[] {
const compact = layout.innerW < COMPACT_WIDTH;
return BEHAVIOUR_ROWS.flatMap((row, index) =>
renderBehaviourRow(theme, state, layout, row, index, compact)
);
}
function renderBehaviourRow(
theme: Theme,
state: AskSettingsState,
layout: SettingsLayout,
row: (typeof BEHAVIOUR_ROWS)[number],
index: number,
compact: boolean
): string[] {
const focused = index === state.behaviourFocusIndex;
const dirty =
row.getValue(state.draftConfig) !== row.getValue(state.savedConfig);
const checked = row.getValue(state.draftConfig);
const marker = checked ? "[x]" : "[ ]";
let labelColor: "warning" | "accent" | "text" = "text";
if (dirty) {
labelColor = "warning";
} else if (focused) {
labelColor = "accent";
}
let label = `${marker} ${row.label}`;
label = theme.fg(labelColor, label);
if (focused) {
label = theme.bg("selectedBg", label);
}
const descriptionLines = wrapText(row.description, layout.innerW - 4);
const lines = [
layout.line(truncateToWidth(label, layout.innerW - 1, "…")),
...descriptionLines
.slice(0, compact ? COMPACT_DESCRIPTION_LINES : descriptionLines.length)
.map((descLine) => layout.line(` ${theme.fg("dim", descLine)}`)),
];
if (!compact && index < BEHAVIOUR_ROWS.length - 1) {
lines.push(layout.line());
}
if (
compact &&
descriptionLines.length > COMPACT_DESCRIPTION_LINES &&
index < BEHAVIOUR_ROWS.length - 1
) {
lines.push(layout.line(` ${theme.fg("dim", "…")}`));
}
return lines;
}
function renderKeymapsTab(
theme: Theme,
layout: SettingsLayout,
config: AskConfig,
configPath: string,
notice?: AskConfigNotice
): string[] {
const lines: string[] = [];
const compact = layout.innerW < COMPACT_WIDTH;
const { customizable, fixed } = getAskKeymaps(config);
pushKeymapHeaderLines(lines, theme, layout, configPath, compact);
pushInvalidKeymapNotice(lines, theme, layout, notice, compact);
lines.push(layout.line());
if (compact) {
lines.push(
...renderCompactKeymapSection(theme, layout, "Customizable", customizable)
);
lines.push(layout.line());
lines.push(...renderCompactKeymapSection(theme, layout, "Fixed", fixed));
return lines;
}
lines.push(layout.line(theme.fg("muted", "Customizable")));
pushKeymapRows(lines, theme, layout, customizable, compact);
lines.push(
layout.line(theme.fg("muted", "─".repeat(Math.max(1, layout.innerW - 2))))
);
lines.push(layout.line(theme.fg("muted", "Fixed")));
pushKeymapRows(lines, theme, layout, fixed, compact);
return lines;
}
function pushKeymapHeaderLines(
lines: string[],
theme: Theme,
layout: SettingsLayout,
configPath: string,
compact: boolean
) {
const headerLines = compact
? [configPath, "Edit keymaps there, then /reload."]
: [
"Edit this config file to change customizable ask keymaps:",
configPath,
"Restart pi or run /reload after editing.",
];
for (const text of headerLines) {
for (const line of wrapText(text, layout.innerW - 2)) {
lines.push(
layout.line(theme.fg(text === configPath ? "accent" : "dim", line))
);
}
}
}
function pushInvalidKeymapNotice(
lines: string[],
theme: Theme,
layout: SettingsLayout,
notice: AskConfigNotice | undefined,
compact: boolean
) {
if (notice?.kind !== "error") {
return;
}
const warningText = compact
? "Invalid config; using default keymaps this session."
: "Using default keymaps this session because configured keymaps were rejected.";
for (const line of wrapText(warningText, layout.innerW - 2)) {
lines.push(layout.line(theme.fg("warning", line)));
}
}
function renderCompactKeymapSection(
theme: Theme,
layout: SettingsLayout,
title: string,
keymaps: readonly AskKeyBinding[]
): string[] {
const summary = keymaps
.map((hint) => `${hint.label} ${compactKeymapDescription(hint)}`)
.join(" · ");
const lines = [layout.line(theme.fg("muted", title))];
for (const line of wrapText(summary, layout.innerW - 2)) {
lines.push(layout.line(line));
}
return lines;
}
function compactKeymapDescription(hint: AskKeyBinding): string {
switch (hint.id) {
case "cancel":
return "cancel";
case "dismiss":
return "dismiss";
case "toggle":
return "toggle";
case "confirm":
return "confirm";
case "optionNote":
return "option note";
case "questionNote":
return "question note";
case "settings":
return "settings";
case "nextTab":
return "next tab";
case "previousTab":
return "prev tab";
case "previousOption":
return "move";
case "nextOption":
return "next";
case "numberShortcut":
return "quick select";
case "fileReference":
return "file refs";
default:
return hint.description.toLowerCase();
}
}
function pushKeymapRows(
lines: string[],
theme: Theme,
layout: SettingsLayout,
keymaps: readonly AskKeyBinding[],
compact: boolean
) {
for (const hint of 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)}`)
);
}
}
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, "");
}
function colorNotice(
theme: Theme,
kind: AskConfigNotice["kind"],
text: string
): string {
switch (kind) {
case "error":
return theme.fg("error", text);
case "success":
return theme.fg("success", text);
case "warning":
return theme.fg("warning", text);
default:
return text;
}
}
function getDefaultNotice(
state: AskSettingsState
): AskConfigNotice | undefined {
if (state.saving) {
return { kind: "warning", text: "Saving…" };
}
if (isDirty(state)) {
return {
kind: "warning",
text: "Unsaved changes. Press Ctrl+S to save.",
};
}
return;
}
function renderCloseHint(state: AskSettingsState): string {
if (isDirty(state)) {
return "Ctrl+S save · Esc/Ctrl+C/? twice discard";
}
return "Esc, Ctrl+C, or ? close";
}
type MatchableKey = Parameters<typeof matchesKey>[1] | string;
function matchesAny(data: string, keys: readonly MatchableKey[]): boolean {
return keys.some((key) => {
if (key.length === 1) {
return data === key;
}
return matchesKey(data, key as Parameters<typeof matchesKey>[1]);
});
}
-166
View File
@@ -1,166 +0,0 @@
import { normalizeAskConfig } from "../config/defaults.ts";
import type { AskConfig } from "../config/schema.ts";
import type { AskConfigNotice } from "../config/store.ts";
export interface AskSettingsState {
activeTab: "keymaps" | "behaviour";
behaviourFocusIndex: number;
draftConfig: AskConfig;
notice?: AskConfigNotice;
pendingCloseUntil?: number;
savedConfig: AskConfig;
saving: boolean;
}
export function createAskSettingsState(
savedConfig: AskConfig,
notice?: AskConfigNotice
): AskSettingsState {
return {
activeTab: "keymaps",
behaviourFocusIndex: 0,
draftConfig: normalizeAskConfig(savedConfig),
notice,
savedConfig: normalizeAskConfig(savedConfig),
saving: false,
};
}
export function clearTransientNotice(
state: AskSettingsState
): AskSettingsState {
if (!state.notice || state.notice.kind === "error") {
return state;
}
return {
...state,
notice: undefined,
pendingCloseUntil: undefined,
};
}
export function completeSave(
state: AskSettingsState,
savedConfig: AskConfig
): AskSettingsState {
const normalized = normalizeAskConfig(savedConfig);
return {
...state,
draftConfig: normalized,
notice: { kind: "success", text: "Saved" },
pendingCloseUntil: undefined,
savedConfig: normalized,
saving: false,
};
}
export function failSave(
state: AskSettingsState,
message: string
): AskSettingsState {
return {
...state,
notice: { kind: "error", text: message },
saving: false,
};
}
export function isDirty(state: AskSettingsState): boolean {
return !deepEqual(state.draftConfig, state.savedConfig);
}
export function moveBehaviourFocus(
state: AskSettingsState,
delta: 1 | -1,
rowCount: number
): AskSettingsState {
if (rowCount <= 0) {
return state;
}
const next = Math.max(
0,
Math.min(rowCount - 1, state.behaviourFocusIndex + delta)
);
return {
...state,
behaviourFocusIndex: next,
};
}
export function moveSettingsTab(
state: AskSettingsState,
delta: 1 | -1
): AskSettingsState {
if (delta === 1) {
return {
...state,
activeTab: state.activeTab === "keymaps" ? "behaviour" : "keymaps",
};
}
return {
...state,
activeTab: state.activeTab === "behaviour" ? "keymaps" : "behaviour",
};
}
export function requestDiscard(
state: AskSettingsState,
now: number,
windowMs: number
): AskSettingsState {
return {
...state,
notice: {
kind: "warning",
text: "Unsaved changes. Press again to discard.",
},
pendingCloseUntil: now + windowMs,
};
}
export function setSaving(state: AskSettingsState): AskSettingsState {
return {
...state,
notice: undefined,
pendingCloseUntil: undefined,
saving: true,
};
}
export function shouldDiscard(state: AskSettingsState, now: number): boolean {
return !!state.pendingCloseUntil && now <= state.pendingCloseUntil;
}
export function toggleBehaviourSetting(
state: AskSettingsState,
key: keyof AskConfig["behaviour"]
): AskSettingsState {
return {
...state,
draftConfig: {
...state.draftConfig,
behaviour: {
...state.draftConfig.behaviour,
[key]: !state.draftConfig.behaviour[key],
},
},
};
}
function deepEqual(left: AskConfig, right: AskConfig): boolean {
return (
left.behaviour.autoSubmitWhenAnsweredWithoutNotes ===
right.behaviour.autoSubmitWhenAnsweredWithoutNotes &&
left.behaviour.confirmDismissWhenDirty ===
right.behaviour.confirmDismissWhenDirty &&
left.behaviour.doublePressReviewShortcuts ===
right.behaviour.doublePressReviewShortcuts &&
left.behaviour.showFooterHints === right.behaviour.showFooterHints &&
left.keymaps.cancel === right.keymaps.cancel &&
left.keymaps.dismiss === right.keymaps.dismiss &&
left.keymaps.toggle === right.keymaps.toggle &&
left.keymaps.confirm === right.keymaps.confirm &&
left.keymaps.optionNote === right.keymaps.optionNote &&
left.keymaps.questionNote === right.keymaps.questionNote
);
}
@@ -1,15 +1,15 @@
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { getAskConfigPath, getAskConfigStore } from "../config/store.ts";
import { AskSettingsModal } from "./settings-modal.ts";
import { AskSettingsList } from "./settings-list.ts";
export async function showAskSettingsModal(
export async function showAskSettings(
ui: Pick<ExtensionContext, "ui">["ui"]
): Promise<void> {
const store = getAskConfigStore();
const { config, notice } = await store.ensureLoaded();
return ui.custom<void>(
(tui, theme, _keybindings, done) =>
new AskSettingsModal(theme, {
new AskSettingsList(theme, {
configPath: getAskConfigPath(),
notice,
onClose: () => {