Files
pi-ask/src/ui/controller.ts
T

417 lines
11 KiB
TypeScript

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,
saveEditorDraft,
submitEditorDraft,
syncStateToSelection,
} from "../state/editor.ts";
import { toAskResult } from "../state/result.ts";
import {
getCurrentOption,
getCurrentQuestion,
isSubmitTab,
} from "../state/selectors.ts";
import {
applyNumberShortcut,
cancelFlow,
confirmCurrentSelection,
dismissFlow,
enterOptionNoteMode,
enterQuestionNoteMode,
moveOption,
moveTab,
toggleCurrentMultiOption,
} 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 {
DIRTY_DISMISS_NOTICE,
shouldConfirmDirtyDismiss,
shouldDiscardAfterConfirmation,
} from "./dismiss-guard.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
? T
: never;
type Tui = CustomCallbackArgs[0];
type Theme = CustomCallbackArgs[1];
type Keybindings = CustomCallbackArgs[2];
type Done = (result: AskResult) => void;
type AskFlowParams = AskParams &
Pick<ExtensionContext, "cwd"> & { config: AskConfig; ctx: ExtensionContext };
interface AskFlowController {
config: AskConfig;
ctx: ExtensionContext;
dismissNotice?: string;
done: Done;
editor: Editor;
settingsOpen: boolean;
state: AskState;
suppressAutoInputForSelection: boolean;
theme: Theme;
tui: Tui;
unsubscribeConfig: () => void;
}
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, config, cwd: ctx.cwd, ctx })
);
}
function createAskFlowController(
[tui, theme, _keybindings, done]: [
Tui,
Theme,
Keybindings,
(result: AskResult) => void,
],
params: AskFlowParams
) {
const controller: AskFlowController = {
config: params.config,
ctx: params.ctx,
dismissNotice: undefined,
done,
editor: createEditor(tui, theme, params.cwd),
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);
return {
render: (width: number) => renderController(controller, width),
invalidate() {
// The editor manages its own async autocomplete redraws.
},
handleInput(data: string) {
handleControllerInput(controller, data);
},
dispose() {
controller.unsubscribeConfig();
},
};
}
function renderController(
controller: AskFlowController,
width: number
): string[] {
return renderAskScreen({
config: controller.config,
editor: controller.editor,
footerNotice: getDismissNotice(controller),
state: controller.state,
theme: controller.theme,
width,
});
}
function handleControllerInput(controller: AskFlowController, data: string) {
const command = getInputCommand(
controller.state,
controller.config,
data,
isEditingView(controller.state) ? controller.editor.getText() : ""
);
if (isEditingView(controller.state)) {
handleEditingCommand(controller, command, data);
return;
}
handleNavigationCommand(controller, command);
}
function handleEditingCommand(
controller: AskFlowController,
command: AskInputCommand,
data: string
) {
if (command.kind === "dismiss") {
handleExitFlow(controller, dismissFlow(controller.state));
return;
}
if (command.kind === "showSettings") {
showSettingsModal(controller);
return;
}
if (command.kind === "editMoveTab") {
commitSavedEditorNavigation(controller, moveTab, command.delta);
return;
}
if (command.kind === "editMoveOption") {
commitSavedEditorNavigation(controller, moveOption, command.delta);
return;
}
if (command.kind === "editClose") {
closeEditor(controller);
return;
}
if (command.kind === "delegateToEditor") {
controller.editor.handleInput(data);
refresh(controller);
}
}
function handleNavigationCommand(
controller: AskFlowController,
command: AskInputCommand
) {
switch (command.kind) {
case "moveTab":
commitState(controller, moveTab(controller.state, command.delta));
return;
case "moveOption":
commitState(controller, moveOption(controller.state, command.delta));
return;
case "toggleMulti":
handleToggleCurrentOption(controller);
return;
case "openQuestionNote":
openQuestionNote(controller);
return;
case "openOptionNote":
openOptionNote(controller);
return;
case "confirm":
commitState(controller, confirmCurrentSelection(controller.state), {
finish: true,
});
return;
case "cancel":
handleExitFlow(controller, cancelFlow(controller.state));
return;
case "numberShortcut":
commitState(
controller,
applyNumberShortcut(controller.state, command.digit)
);
return;
case "dismiss":
handleExitFlow(controller, dismissFlow(controller.state));
return;
case "showSettings":
showSettingsModal(controller);
return;
case "ignore":
case "editMoveTab":
case "editMoveOption":
case "editClose":
case "delegateToEditor":
return;
default:
return;
}
}
function handleToggleCurrentOption(controller: AskFlowController) {
const question = getCurrentQuestion(controller.state);
if (!question) {
return;
}
commitState(controller, toggleCurrentMultiOption(controller.state));
}
function openQuestionNote(controller: AskFlowController) {
const question = getCurrentQuestion(controller.state);
if (!question || isSubmitTab(controller.state)) {
return;
}
commitState(
controller,
enterQuestionNoteMode(controller.state, question.id),
{
syncSelection: false,
}
);
}
function openOptionNote(controller: AskFlowController) {
const question = getCurrentQuestion(controller.state);
const option = getCurrentOption(controller.state);
if (
!(question && option) ||
option.isCustomOption ||
isSubmitTab(controller.state)
) {
return;
}
commitState(
controller,
enterOptionNoteMode(controller.state, question.id, option.value),
{ syncSelection: false }
);
}
function commitState(
controller: AskFlowController,
nextState: AskState,
options: { finish?: boolean; syncSelection?: boolean } = {}
) {
if (nextState.activeTabIndex !== controller.state.activeTabIndex) {
clearDismissNotice(controller);
}
controller.suppressAutoInputForSelection = false;
controller.state = nextState;
if (options.syncSelection !== false) {
syncSelection(controller);
}
controller.state = maybeAutoSubmitState(controller.state, controller.config);
hydrateEditor(controller);
refresh(controller);
if (options.finish) {
maybeFinish(controller);
}
}
function submitEditor(controller: AskFlowController, value: string) {
controller.suppressAutoInputForSelection = false;
const nextState = submitEditorDraft(controller.state, value);
if (nextState.activeTabIndex !== controller.state.activeTabIndex) {
clearDismissNotice(controller);
}
controller.state = maybeAutoSubmitState(nextState, controller.config);
controller.editor.setText("");
refresh(controller);
maybeFinish(controller);
}
function commitSavedEditorNavigation(
controller: AskFlowController,
navigate: (state: AskState, delta: 1 | -1) => AskState,
delta: 1 | -1
) {
commitState(controller, navigate(saveEditorState(controller), delta));
}
function closeEditor(controller: AskFlowController) {
const nextState = saveEditorState(controller);
controller.suppressAutoInputForSelection = nextState.view.kind !== "input";
controller.state = nextState;
refresh(controller);
}
function handleExitFlow(controller: AskFlowController, nextState: AskState) {
if (!shouldRequestDismissConfirmation(controller)) {
commitState(controller, nextState, { finish: true });
return;
}
if (shouldDiscardAfterConfirmation(!!controller.dismissNotice)) {
commitState(controller, nextState, { finish: true });
return;
}
controller.dismissNotice = DIRTY_DISMISS_NOTICE;
refresh(controller);
}
function shouldRequestDismissConfirmation(
controller: AskFlowController
): boolean {
return shouldConfirmDirtyDismiss({
config: controller.config,
state: controller.state,
editingText: isEditingView(controller.state)
? controller.editor.getText()
: "",
});
}
function clearDismissNotice(controller: AskFlowController) {
controller.dismissNotice = undefined;
}
function getDismissNotice(controller: AskFlowController): string | undefined {
return controller.dismissNotice;
}
function showSettingsModal(controller: AskFlowController) {
if (controller.settingsOpen) {
return;
}
controller.settingsOpen = true;
showAskSettingsModal(controller.ctx.ui).finally(() => {
controller.settingsOpen = false;
refresh(controller);
});
}
function refresh(controller: AskFlowController) {
controller.tui.requestRender();
}
function maybeFinish(controller: AskFlowController) {
if (controller.state.completed) {
controller.done(toAskResult(controller.state));
}
}
function hydrateEditor(controller: AskFlowController) {
controller.editor.setText(getEditorDraft(controller.state));
}
function syncSelection(controller: AskFlowController) {
if (controller.suppressAutoInputForSelection) {
return;
}
controller.state = syncStateToSelection(controller.state);
}
function saveEditorState(controller: AskFlowController): AskState {
const text = controller.editor.getText();
controller.editor.setText("");
return saveEditorDraft(controller.state, text);
}
function createEditor(tui: Tui, theme: Theme, cwd: string) {
const editor = new Editor(tui, createEditorTheme(theme));
editor.setAutocompleteProvider(createAskAutocompleteProvider(cwd));
return editor;
}
function createEditorTheme(theme: Theme): EditorTheme {
return {
borderColor: (text) => theme.fg("accent", text),
selectList: {
description: (text) => theme.fg("muted", text),
noMatch: (text) => theme.fg("warning", text),
scrollInfo: (text) => theme.fg("dim", text),
selectedPrefix: (text) => theme.fg("accent", text),
selectedText: (text) => theme.fg("accent", text),
},
};
}