feat: add config-backed ask keymaps
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import type { AskConfig } from "../config/schema.ts";
|
||||
import { hasAnswerNotes } from "../state/answers.ts";
|
||||
import { isQuestionAnswered, isSubmitTab } from "../state/selectors.ts";
|
||||
import type { AskState } from "../types.ts";
|
||||
|
||||
export function maybeAutoSubmitState(
|
||||
state: AskState,
|
||||
config: AskConfig
|
||||
): AskState {
|
||||
if (!shouldAutoSubmit(state, config)) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
completed: true,
|
||||
mode: "submit",
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldAutoSubmit(state: AskState, config: AskConfig): boolean {
|
||||
if (!config.behaviour.autoSubmitWhenAnsweredWithoutNotes) {
|
||||
return false;
|
||||
}
|
||||
if (state.completed || state.cancelled || !isSubmitTab(state)) {
|
||||
return false;
|
||||
}
|
||||
for (const question of state.questions) {
|
||||
if (!isQuestionAnswered(state, question.id)) {
|
||||
return false;
|
||||
}
|
||||
if (hasAnswerNotes(state.answers[question.id])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
+44
-14
@@ -1,5 +1,7 @@
|
||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { Editor, type EditorTheme } from "@mariozechner/pi-tui";
|
||||
import type { AskConfig } from "../config/schema.ts";
|
||||
import { getAskConfigStore } from "../config/store.ts";
|
||||
import { createInitialState } from "../state/create.ts";
|
||||
import {
|
||||
getEditorDraft,
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
} from "../state/transitions.ts";
|
||||
import { isEditingView } from "../state/view.ts";
|
||||
import type { AskParams, AskResult, AskState } from "../types.ts";
|
||||
import { maybeAutoSubmitState } from "./auto-submit.ts";
|
||||
import { createAskAutocompleteProvider } from "./autocomplete.ts";
|
||||
import type { AskInputCommand } from "./input.ts";
|
||||
import { getInputCommand } from "./input.ts";
|
||||
@@ -41,25 +44,29 @@ type Theme = CustomCallbackArgs[1];
|
||||
type Keybindings = CustomCallbackArgs[2];
|
||||
type Done = (result: AskResult) => void;
|
||||
type AskFlowParams = AskParams &
|
||||
Pick<ExtensionContext, "cwd"> & { ctx: ExtensionContext };
|
||||
Pick<ExtensionContext, "cwd"> & { config: AskConfig; ctx: ExtensionContext };
|
||||
|
||||
interface AskFlowController {
|
||||
config: AskConfig;
|
||||
ctx: ExtensionContext;
|
||||
done: Done;
|
||||
editor: Editor;
|
||||
helpOpen: boolean;
|
||||
settingsOpen: boolean;
|
||||
state: AskState;
|
||||
suppressAutoInputForSelection: boolean;
|
||||
theme: Theme;
|
||||
tui: Tui;
|
||||
unsubscribeConfig: () => void;
|
||||
}
|
||||
|
||||
export function runAskFlow(
|
||||
export async function runAskFlow(
|
||||
ctx: ExtensionContext,
|
||||
params: AskParams
|
||||
): Promise<AskResult> {
|
||||
const store = getAskConfigStore();
|
||||
const config = await store.getConfig();
|
||||
return ctx.ui.custom<AskResult>((...args) =>
|
||||
createAskFlowController(args, { ...params, cwd: ctx.cwd, ctx })
|
||||
createAskFlowController(args, { ...params, config, cwd: ctx.cwd, ctx })
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,16 +80,30 @@ function createAskFlowController(
|
||||
params: AskFlowParams
|
||||
) {
|
||||
const controller: AskFlowController = {
|
||||
config: params.config,
|
||||
ctx: params.ctx,
|
||||
done,
|
||||
editor: createEditor(tui, theme, params.cwd),
|
||||
helpOpen: false,
|
||||
settingsOpen: false,
|
||||
state: createInitialState(params),
|
||||
suppressAutoInputForSelection: false,
|
||||
theme,
|
||||
tui,
|
||||
unsubscribeConfig: () => {
|
||||
// replaced immediately after controller creation
|
||||
},
|
||||
};
|
||||
|
||||
controller.unsubscribeConfig = getAskConfigStore().subscribe((config) => {
|
||||
controller.config = config;
|
||||
controller.state = maybeAutoSubmitState(
|
||||
controller.state,
|
||||
controller.config
|
||||
);
|
||||
refresh(controller);
|
||||
maybeFinish(controller);
|
||||
});
|
||||
|
||||
controller.editor.onSubmit = (value) => submitEditor(controller, value);
|
||||
syncSelection(controller);
|
||||
|
||||
@@ -94,6 +115,9 @@ function createAskFlowController(
|
||||
handleInput(data: string) {
|
||||
handleControllerInput(controller, data);
|
||||
},
|
||||
dispose() {
|
||||
controller.unsubscribeConfig();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -102,6 +126,7 @@ function renderController(
|
||||
width: number
|
||||
): string[] {
|
||||
return renderAskScreen({
|
||||
config: controller.config,
|
||||
editor: controller.editor,
|
||||
state: controller.state,
|
||||
theme: controller.theme,
|
||||
@@ -112,6 +137,7 @@ function renderController(
|
||||
function handleControllerInput(controller: AskFlowController, data: string) {
|
||||
const command = getInputCommand(
|
||||
controller.state,
|
||||
controller.config,
|
||||
data,
|
||||
isEditingView(controller.state) ? controller.editor.getText() : ""
|
||||
);
|
||||
@@ -131,8 +157,8 @@ function handleEditingCommand(
|
||||
commitState(controller, dismissFlow(controller.state), { finish: true });
|
||||
return;
|
||||
}
|
||||
if (command.kind === "showHelp") {
|
||||
showHelpModal(controller);
|
||||
if (command.kind === "showSettings") {
|
||||
showSettingsModal(controller);
|
||||
return;
|
||||
}
|
||||
if (command.kind === "editMoveTab") {
|
||||
@@ -190,8 +216,8 @@ function handleNavigationCommand(
|
||||
case "dismiss":
|
||||
commitState(controller, dismissFlow(controller.state), { finish: true });
|
||||
return;
|
||||
case "showHelp":
|
||||
showHelpModal(controller);
|
||||
case "showSettings":
|
||||
showSettingsModal(controller);
|
||||
return;
|
||||
case "ignore":
|
||||
case "editMoveTab":
|
||||
@@ -253,6 +279,7 @@ function commitState(
|
||||
if (options.syncSelection !== false) {
|
||||
syncSelection(controller);
|
||||
}
|
||||
controller.state = maybeAutoSubmitState(controller.state, controller.config);
|
||||
hydrateEditor(controller);
|
||||
refresh(controller);
|
||||
if (options.finish) {
|
||||
@@ -262,7 +289,10 @@ function commitState(
|
||||
|
||||
function submitEditor(controller: AskFlowController, value: string) {
|
||||
controller.suppressAutoInputForSelection = false;
|
||||
controller.state = submitEditorDraft(controller.state, value);
|
||||
controller.state = maybeAutoSubmitState(
|
||||
submitEditorDraft(controller.state, value),
|
||||
controller.config
|
||||
);
|
||||
controller.editor.setText("");
|
||||
refresh(controller);
|
||||
maybeFinish(controller);
|
||||
@@ -283,13 +313,13 @@ function closeEditor(controller: AskFlowController) {
|
||||
refresh(controller);
|
||||
}
|
||||
|
||||
function showHelpModal(controller: AskFlowController) {
|
||||
if (controller.helpOpen) {
|
||||
function showSettingsModal(controller: AskFlowController) {
|
||||
if (controller.settingsOpen) {
|
||||
return;
|
||||
}
|
||||
controller.helpOpen = true;
|
||||
controller.settingsOpen = true;
|
||||
showAskSettingsModal(controller.ctx.ui).finally(() => {
|
||||
controller.helpOpen = false;
|
||||
controller.settingsOpen = false;
|
||||
refresh(controller);
|
||||
});
|
||||
}
|
||||
|
||||
+29
-34
@@ -1,5 +1,6 @@
|
||||
import type { AskConfig } from "../config/schema.ts";
|
||||
import {
|
||||
ASK_KEY_BINDINGS,
|
||||
getAskKeyBindings,
|
||||
matchesBinding,
|
||||
matchesDigitShortcut,
|
||||
} from "../constants/keymaps.ts";
|
||||
@@ -14,7 +15,7 @@ export type AskInputCommand =
|
||||
| { kind: "confirm" }
|
||||
| { kind: "cancel" }
|
||||
| { kind: "dismiss" }
|
||||
| { kind: "showHelp" }
|
||||
| { kind: "showSettings" }
|
||||
| { kind: "numberShortcut"; digit: number }
|
||||
| { kind: "editMoveTab"; delta: 1 | -1 }
|
||||
| { kind: "editMoveOption"; delta: 1 | -1 }
|
||||
@@ -24,85 +25,79 @@ export type AskInputCommand =
|
||||
|
||||
export function getInputCommand(
|
||||
state: AskState,
|
||||
config: AskConfig,
|
||||
data: string,
|
||||
editingText = ""
|
||||
): AskInputCommand {
|
||||
if (matchesBinding(data, ASK_KEY_BINDINGS.dismiss)) {
|
||||
const bindings = getAskKeyBindings(config);
|
||||
if (matchesBinding(data, bindings.dismiss)) {
|
||||
return { kind: "dismiss" };
|
||||
}
|
||||
if (matchesBinding(data, ASK_KEY_BINDINGS.help) && editingText.length === 0) {
|
||||
return { kind: "showHelp" };
|
||||
if (matchesBinding(data, bindings.settings) && editingText.length === 0) {
|
||||
return { kind: "showSettings" };
|
||||
}
|
||||
|
||||
if (state.view.kind === "input" || state.view.kind === "note") {
|
||||
return getEditingInputCommand(data, editingText);
|
||||
return getEditingInputCommand(bindings, data, editingText);
|
||||
}
|
||||
|
||||
return getNavigationInputCommand(data);
|
||||
}
|
||||
|
||||
export function formatKeybindingLabel(key: string): string {
|
||||
return key
|
||||
.split("+")
|
||||
.map((part) => {
|
||||
if (part.length <= 1) {
|
||||
return part.toUpperCase();
|
||||
}
|
||||
return part.charAt(0).toUpperCase() + part.slice(1);
|
||||
})
|
||||
.join("+");
|
||||
return getNavigationInputCommand(bindings, data);
|
||||
}
|
||||
|
||||
function getEditingInputCommand(
|
||||
bindings: ReturnType<typeof getAskKeyBindings>,
|
||||
data: string,
|
||||
editingText: string
|
||||
): AskInputCommand {
|
||||
if (matchesBinding(data, ASK_KEY_BINDINGS.cancel)) {
|
||||
if (matchesBinding(data, bindings.cancel)) {
|
||||
return { kind: "editClose" };
|
||||
}
|
||||
if (editingText.length === 0) {
|
||||
if (matchesBinding(data, ASK_KEY_BINDINGS.nextTab)) {
|
||||
if (matchesBinding(data, bindings.nextTab)) {
|
||||
return { kind: "editMoveTab", delta: 1 };
|
||||
}
|
||||
if (matchesBinding(data, ASK_KEY_BINDINGS.previousTab)) {
|
||||
if (matchesBinding(data, bindings.previousTab)) {
|
||||
return { kind: "editMoveTab", delta: -1 };
|
||||
}
|
||||
if (matchesBinding(data, ASK_KEY_BINDINGS.previousOption)) {
|
||||
if (matchesBinding(data, bindings.previousOption)) {
|
||||
return { kind: "editMoveOption", delta: -1 };
|
||||
}
|
||||
if (matchesBinding(data, ASK_KEY_BINDINGS.nextOption)) {
|
||||
if (matchesBinding(data, bindings.nextOption)) {
|
||||
return { kind: "editMoveOption", delta: 1 };
|
||||
}
|
||||
}
|
||||
return { kind: "delegateToEditor" };
|
||||
}
|
||||
|
||||
function getNavigationInputCommand(data: string): AskInputCommand {
|
||||
if (matchesBinding(data, ASK_KEY_BINDINGS.nextTab)) {
|
||||
function getNavigationInputCommand(
|
||||
bindings: ReturnType<typeof getAskKeyBindings>,
|
||||
data: string
|
||||
): AskInputCommand {
|
||||
if (matchesBinding(data, bindings.nextTab)) {
|
||||
return { kind: "moveTab", delta: 1 };
|
||||
}
|
||||
if (matchesBinding(data, ASK_KEY_BINDINGS.previousTab)) {
|
||||
if (matchesBinding(data, bindings.previousTab)) {
|
||||
return { kind: "moveTab", delta: -1 };
|
||||
}
|
||||
if (matchesBinding(data, ASK_KEY_BINDINGS.previousOption)) {
|
||||
if (matchesBinding(data, bindings.previousOption)) {
|
||||
return { kind: "moveOption", delta: -1 };
|
||||
}
|
||||
if (matchesBinding(data, ASK_KEY_BINDINGS.nextOption)) {
|
||||
if (matchesBinding(data, bindings.nextOption)) {
|
||||
return { kind: "moveOption", delta: 1 };
|
||||
}
|
||||
if (matchesBinding(data, ASK_KEY_BINDINGS.toggle)) {
|
||||
if (matchesBinding(data, bindings.toggle)) {
|
||||
return { kind: "toggleMulti" };
|
||||
}
|
||||
if (matchesBinding(data, ASK_KEY_BINDINGS.confirm)) {
|
||||
if (matchesBinding(data, bindings.confirm)) {
|
||||
return { kind: "confirm" };
|
||||
}
|
||||
if (matchesBinding(data, ASK_KEY_BINDINGS.cancel)) {
|
||||
if (matchesBinding(data, bindings.cancel)) {
|
||||
return { kind: "cancel" };
|
||||
}
|
||||
if (matchesBinding(data, ASK_KEY_BINDINGS.questionNote)) {
|
||||
if (matchesBinding(data, bindings.questionNote)) {
|
||||
return { kind: "openQuestionNote" };
|
||||
}
|
||||
if (matchesBinding(data, ASK_KEY_BINDINGS.optionNote)) {
|
||||
if (matchesBinding(data, bindings.optionNote)) {
|
||||
return { kind: "openOptionNote" };
|
||||
}
|
||||
|
||||
|
||||
+16
-7
@@ -1,4 +1,5 @@
|
||||
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
||||
import type { AskConfig } from "../config/schema.ts";
|
||||
import {
|
||||
getCurrentQuestion,
|
||||
isQuestionAnswered,
|
||||
@@ -28,14 +29,15 @@ export function renderFrameHeader(args: {
|
||||
}
|
||||
|
||||
export function renderFrameFooter(args: {
|
||||
config: AskConfig;
|
||||
lines: string[];
|
||||
state: AskState;
|
||||
theme: Theme;
|
||||
width: number;
|
||||
}) {
|
||||
const { lines, state, theme, width } = args;
|
||||
const { config, lines, state, theme, width } = args;
|
||||
const add = (text = "") => lines.push(truncateToWidth(text, width));
|
||||
const footer = renderFooter(state, width);
|
||||
const footer = renderFooter(config, state, width);
|
||||
if (footer.length > 0) {
|
||||
add();
|
||||
for (const line of footer) {
|
||||
@@ -178,17 +180,24 @@ function applyTabGrowth(
|
||||
range.end = nextIndex;
|
||||
}
|
||||
|
||||
function renderFooter(state: AskState, width: number): string[] {
|
||||
function renderFooter(
|
||||
config: AskConfig,
|
||||
state: AskState,
|
||||
width: number
|
||||
): string[] {
|
||||
let footer: string;
|
||||
if (state.view.kind === "input") {
|
||||
footer = renderFooterText("input");
|
||||
footer = renderFooterText(config, "input");
|
||||
} else if (state.view.kind === "note") {
|
||||
footer = renderFooterText("note");
|
||||
footer = renderFooterText(config, "note");
|
||||
} else if (isSubmitTab(state)) {
|
||||
footer = renderFooterText("submit");
|
||||
footer = renderFooterText(config, "submit");
|
||||
} else {
|
||||
const question = getCurrentQuestion(state);
|
||||
footer = renderFooterText(question?.type === "multi" ? "multi" : "default");
|
||||
footer = renderFooterText(
|
||||
config,
|
||||
question?.type === "multi" ? "multi" : "default"
|
||||
);
|
||||
}
|
||||
return wrapDelimitedFooterHints(footer, width);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
||||
import type { AskConfig } from "../config/schema.ts";
|
||||
import {
|
||||
type FooterKeymapContext,
|
||||
renderFooterKeymaps,
|
||||
@@ -372,8 +373,11 @@ export function pushSavedNote(args: {
|
||||
);
|
||||
}
|
||||
|
||||
export function renderFooterText(mode: FooterKeymapContext): string {
|
||||
return renderFooterKeymaps(mode);
|
||||
export function renderFooterText(
|
||||
config: AskConfig,
|
||||
mode: FooterKeymapContext
|
||||
): string {
|
||||
return renderFooterKeymaps(config, mode);
|
||||
}
|
||||
|
||||
function padToVisibleWidth(text: string, width: number): string {
|
||||
|
||||
+4
-2
@@ -1,3 +1,4 @@
|
||||
import type { AskConfig } from "../config/schema.ts";
|
||||
import {
|
||||
getCurrentQuestion,
|
||||
getRenderableOptions,
|
||||
@@ -10,12 +11,13 @@ import { renderSubmitScreen } from "./render-submit.ts";
|
||||
import type { QuestionRenderContext, Theme } from "./render-types.ts";
|
||||
|
||||
export function renderAskScreen(args: {
|
||||
config: AskConfig;
|
||||
state: AskState;
|
||||
theme: Theme;
|
||||
width: number;
|
||||
editor: QuestionRenderContext["editor"];
|
||||
}): string[] {
|
||||
const { state, theme, width, editor } = args;
|
||||
const { config, state, theme, width, editor } = args;
|
||||
const lines: string[] = [];
|
||||
const question = getCurrentQuestion(state);
|
||||
const options = getRenderableOptions(question);
|
||||
@@ -36,6 +38,6 @@ export function renderAskScreen(args: {
|
||||
});
|
||||
}
|
||||
|
||||
renderFrameFooter({ lines, state, theme, width });
|
||||
renderFrameFooter({ config, lines, state, theme, width });
|
||||
return lines;
|
||||
}
|
||||
|
||||
+330
-45
@@ -1,81 +1,214 @@
|
||||
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";
|
||||
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";
|
||||
|
||||
type SettingsTab = "keymaps" | "behaviour";
|
||||
import {
|
||||
type AskSettingsState,
|
||||
clearTransientNotice,
|
||||
completeSave,
|
||||
createAskSettingsState,
|
||||
failSave,
|
||||
isDirty,
|
||||
moveBehaviourFocus,
|
||||
moveSettingsTab,
|
||||
requestDiscard,
|
||||
setSaving,
|
||||
shouldDiscard,
|
||||
toggleAutoSubmit,
|
||||
} 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 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.",
|
||||
getValue(config: AskConfig) {
|
||||
return config.behaviour.autoSubmitWhenAnsweredWithoutNotes;
|
||||
},
|
||||
label: "Auto-submit when answered without notes",
|
||||
},
|
||||
] 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 activeTab: SettingsTab = "keymaps";
|
||||
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, onClose: () => void) {
|
||||
constructor(theme: Theme, options: AskSettingsModalOptions) {
|
||||
this.theme = theme;
|
||||
this.onClose = onClose;
|
||||
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 (
|
||||
ASK_HELP_CLOSE_BINDINGS.some((binding) => matchesBinding(data, binding))
|
||||
) {
|
||||
this.close();
|
||||
if (this.handleGlobalInput(data)) {
|
||||
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";
|
||||
if (this.state.activeTab === "behaviour") {
|
||||
this.handleBehaviourInput(data);
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const layout = createLayout(this.theme, this.activeTab, width);
|
||||
const layout = createLayout(this.theme, this.state.activeTab, width);
|
||||
const lines = [...layout.headerLines];
|
||||
|
||||
if (this.activeTab === "keymaps") {
|
||||
lines.push(...renderKeymapsTab(this.theme, layout));
|
||||
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, layout));
|
||||
lines.push(...renderBehaviourTab(this.theme, this.state, 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)}╯`)}`);
|
||||
lines.push(...renderBottomPanel(this.theme, this.state, layout));
|
||||
return lines;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
// Static modal; no async invalidation needed.
|
||||
// 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)) {
|
||||
this.state = toggleAutoSubmit(this.state);
|
||||
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;
|
||||
@@ -103,8 +236,8 @@ function createLayout(
|
||||
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 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);
|
||||
@@ -126,11 +259,122 @@ function createLayout(
|
||||
};
|
||||
}
|
||||
|
||||
function renderKeymapsTab(theme: Theme, layout: SettingsLayout): string[] {
|
||||
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[] {
|
||||
return BEHAVIOUR_ROWS.flatMap((row, index) =>
|
||||
renderBehaviourRow(theme, state, layout, row, index)
|
||||
);
|
||||
}
|
||||
|
||||
function renderBehaviourRow(
|
||||
theme: Theme,
|
||||
state: AskSettingsState,
|
||||
layout: SettingsLayout,
|
||||
row: (typeof BEHAVIOUR_ROWS)[number],
|
||||
index: number
|
||||
): 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 lines = [
|
||||
layout.line(truncateToWidth(label, layout.innerW - 1, "…")),
|
||||
...wrapText(row.description, layout.innerW - 4).map((descLine) =>
|
||||
layout.line(` ${theme.fg("dim", descLine)}`)
|
||||
),
|
||||
];
|
||||
if (index < BEHAVIOUR_ROWS.length - 1) {
|
||||
lines.push(layout.line());
|
||||
}
|
||||
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);
|
||||
const headerLines = [
|
||||
"Edit this config file to change customizable ask keymaps:",
|
||||
configPath,
|
||||
"Restart pi or run /reload after editing.",
|
||||
];
|
||||
|
||||
for (const hint of ASK_KEYMAPS) {
|
||||
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))
|
||||
);
|
||||
}
|
||||
}
|
||||
if (notice?.kind === "error") {
|
||||
lines.push(layout.line());
|
||||
for (const line of wrapText(
|
||||
"Using default keymaps this session because configured keymaps were rejected.",
|
||||
layout.innerW - 2
|
||||
)) {
|
||||
lines.push(layout.line(theme.fg("warning", line)));
|
||||
}
|
||||
}
|
||||
lines.push(layout.line());
|
||||
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 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)) {
|
||||
@@ -138,7 +382,6 @@ function renderKeymapsTab(theme: Theme, layout: SettingsLayout): string[] {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const keyText = padToWidth(
|
||||
truncateToWidth(hint.label, layout.keyW, "…"),
|
||||
layout.keyW
|
||||
@@ -151,14 +394,6 @@ function renderKeymapsTab(theme: Theme, layout: SettingsLayout): string[] {
|
||||
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(
|
||||
@@ -179,3 +414,53 @@ function renderTabs(
|
||||
].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]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
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 toggleAutoSubmit(state: AskSettingsState): AskSettingsState {
|
||||
return {
|
||||
...state,
|
||||
draftConfig: {
|
||||
...state.draftConfig,
|
||||
behaviour: {
|
||||
...state.draftConfig.behaviour,
|
||||
autoSubmitWhenAnsweredWithoutNotes:
|
||||
!state.draftConfig.behaviour.autoSubmitWhenAnsweredWithoutNotes,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function deepEqual(left: AskConfig, right: AskConfig): boolean {
|
||||
return (
|
||||
left.behaviour.autoSubmitWhenAnsweredWithoutNotes ===
|
||||
right.behaviour.autoSubmitWhenAnsweredWithoutNotes &&
|
||||
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,13 +1,23 @@
|
||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { getAskConfigPath, getAskConfigStore } from "../config/store.ts";
|
||||
import { AskSettingsModal } from "./settings-modal.ts";
|
||||
|
||||
export function showAskSettingsModal(
|
||||
export async function showAskSettingsModal(
|
||||
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, () => {
|
||||
done();
|
||||
(tui, theme, _keybindings, done) =>
|
||||
new AskSettingsModal(theme, {
|
||||
configPath: getAskConfigPath(),
|
||||
notice,
|
||||
onClose: () => {
|
||||
done();
|
||||
},
|
||||
onSave: async (nextConfig) => store.save(nextConfig),
|
||||
savedConfig: config,
|
||||
tui,
|
||||
}),
|
||||
{
|
||||
overlay: true,
|
||||
|
||||
@@ -127,27 +127,26 @@ function buildOptionDetailModel(
|
||||
const note = option.isCustomOption
|
||||
? undefined
|
||||
: getOptionNote(state, question.id, option.value);
|
||||
const withGap = !option.isCustomOption;
|
||||
|
||||
if (noteOpen) {
|
||||
return {
|
||||
kind: "editor",
|
||||
placeholder: UI_TEXT.editorPlaceholderNote,
|
||||
withGap,
|
||||
withGap: false,
|
||||
};
|
||||
}
|
||||
if (inputOpen) {
|
||||
return {
|
||||
kind: "editor",
|
||||
placeholder: UI_TEXT.editorPlaceholderInput,
|
||||
withGap,
|
||||
withGap: false,
|
||||
};
|
||||
}
|
||||
if (customText) {
|
||||
return { kind: "custom-text", text: customText, withGap };
|
||||
return { kind: "custom-text", text: customText, withGap: false };
|
||||
}
|
||||
if (note) {
|
||||
return { kind: "saved-note", text: note, withGap };
|
||||
return { kind: "saved-note", text: note, withGap: false };
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user