372 lines
9.5 KiB
TypeScript
372 lines
9.5 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 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;
|
|
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,
|
|
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,
|
|
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") {
|
|
commitState(controller, dismissFlow(controller.state), { finish: true });
|
|
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":
|
|
commitState(controller, cancelFlow(controller.state), { finish: true });
|
|
return;
|
|
case "numberShortcut":
|
|
commitState(
|
|
controller,
|
|
applyNumberShortcut(controller.state, command.digit)
|
|
);
|
|
return;
|
|
case "dismiss":
|
|
commitState(controller, dismissFlow(controller.state), { finish: true });
|
|
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 } = {}
|
|
) {
|
|
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;
|
|
controller.state = maybeAutoSubmitState(
|
|
submitEditorDraft(controller.state, value),
|
|
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 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),
|
|
},
|
|
};
|
|
}
|