refactor: simplify ask settings UI
This commit is contained in:
@@ -93,7 +93,7 @@ Ask the agent to elaborate on notes before finalizing choices.
|
|||||||
|
|
||||||
## Default key bindings
|
## Default key bindings
|
||||||
|
|
||||||
Open the ask settings modal with `?` during the ask flow, or with the `/ask-settings` command from pi.
|
Open ask settings with `?` during the ask flow, or with the `/ask-settings` command from pi.
|
||||||
|
|
||||||
Customizable via config:
|
Customizable via config:
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ Fixed bindings:
|
|||||||
|
|
||||||
| Key | Context | Effect |
|
| Key | Context | Effect |
|
||||||
|-----------------------------|-----------------------------------------|---------------------------------------------|
|
|-----------------------------|-----------------------------------------|---------------------------------------------|
|
||||||
| `?` | Ask flow / empty editor | Open ask settings modal |
|
| `?` | Ask flow / empty editor | Open ask settings |
|
||||||
| `Tab` `Shift+Tab` | Main flow | Switch tabs |
|
| `Tab` `Shift+Tab` | Main flow | Switch tabs |
|
||||||
| `←` `→` | Main flow | Switch tabs |
|
| `←` `→` | Main flow | Switch tabs |
|
||||||
| `↑` `↓` | Main flow | Move cursor |
|
| `↑` `↓` | Main flow | Move cursor |
|
||||||
@@ -125,7 +125,7 @@ Review-tab shortcuts can optionally require the same number key twice via `behav
|
|||||||
|
|
||||||
Config file: `~/.pi/.../eko24ive-pi-ask.json`
|
Config file: `~/.pi/.../eko24ive-pi-ask.json`
|
||||||
|
|
||||||
You can edit this file yourself, ask pi to edit it for you, or use the read-only `Keymaps` tab to find the exact path and active bindings.
|
You can edit this file yourself, ask pi to edit it for you, or use `/ask-settings` to find the exact config path and toggle behaviour settings.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -153,7 +153,7 @@ Accepted notation follows pi-tui key ids. Common aliases are normalized, for exa
|
|||||||
|
|
||||||
After installation, the extension registers the `ask_user` tool and `/ask-settings` command.
|
After installation, the extension registers the `ask_user` tool and `/ask-settings` command.
|
||||||
|
|
||||||
Agents can auto-discover and call `ask_user` when they need clarification instead of guessing. In interactive sessions, it opens a terminal UI flow for structured answers, supports native pi-style `@` file references while typing answers or notes, and returns normalized answers back to the agent. The ask settings modal is available both from `?` in the ask flow and from the `/ask-settings` command, with `Keymaps` and `Behaviour` tabs. `Keymaps` is read-only and shows the active resolved bindings plus the config file path. Behaviour settings are saved explicitly with `Ctrl+S`, including whether review-tab number shortcuts require a double press.
|
Agents can auto-discover and call `ask_user` when they need clarification instead of guessing. In interactive sessions, it opens a terminal UI flow for structured answers, supports native pi-style `@` file references while typing answers or notes, and returns normalized answers back to the agent. Ask settings are available both from `?` in the ask flow and from the `/ask-settings` command. Behaviour settings are binary `on`/`off` toggles that save immediately; customizable ask keymaps are changed by editing the shown config file path.
|
||||||
|
|
||||||
You can also talk to pi to configure this extension. When asked to customize pi-ask settings or keymaps, the agent is instructed to read the bundled `docs/configuration.md` guide first and then edit the config file accordingly.
|
You can also talk to pi to configure this extension. When asked to customize pi-ask settings or keymaps, the agent is instructed to read the bundled `docs/configuration.md` guide first and then edit the config file accordingly.
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ The codebase is split so the implementation reads through file boundaries and na
|
|||||||
- `src/ui/input.ts` — raw input to commands using resolved config-backed keymaps
|
- `src/ui/input.ts` — raw input to commands using resolved config-backed keymaps
|
||||||
- `src/ui/dismiss-guard.ts` — pure helpers for dirty-flow exit confirmation behavior
|
- `src/ui/dismiss-guard.ts` — pure helpers for dirty-flow exit confirmation behavior
|
||||||
- `src/ui/render.ts` and `src/ui/render-*.ts` — screen rendering, including config-backed footer/keymap hints
|
- `src/ui/render.ts` and `src/ui/render-*.ts` — screen rendering, including config-backed footer/keymap hints
|
||||||
- `src/ui/settings-state.ts` — pure settings-modal draft/dirty/notice state
|
- `src/ui/settings-list.ts` / `src/ui/show-settings.ts` — lightweight ask settings list rendering and launcher
|
||||||
- `src/ui/settings-modal.ts` / `src/ui/show-settings-modal.ts` — ask settings modal rendering and launcher
|
|
||||||
- `src/ui/constants.ts` and `src/ui/render-types.ts` — rendering constants/contracts
|
- `src/ui/constants.ts` and `src/ui/render-types.ts` — rendering constants/contracts
|
||||||
- `src/ask-component.ts` — thin custom UI export
|
- `src/ask-component.ts` — thin custom UI export
|
||||||
|
|
||||||
@@ -71,7 +70,7 @@ The codebase is split so the implementation reads through file boundaries and na
|
|||||||
- editor lifecycle stays in the controller, not in the reducers
|
- editor lifecycle stays in the controller, not in the reducers
|
||||||
- persisted ask settings are versioned and normalized before use
|
- persisted ask settings are versioned and normalized before use
|
||||||
- invalid persisted keymaps fall back to default keymaps for the current session without discarding valid behaviour settings
|
- invalid persisted keymaps fall back to default keymaps for the current session without discarding valid behaviour settings
|
||||||
- settings modal edits a local draft and only persists modal changes on explicit save
|
- ask settings behaviour changes persist immediately from the settings list
|
||||||
- when the ask config file is missing, the first ask use writes a default persisted config snapshot
|
- when the ask config file is missing, the first ask use writes a default persisted config snapshot
|
||||||
- live config updates can affect an in-progress ask flow immediately
|
- live config updates can affect an in-progress ask flow immediately
|
||||||
|
|
||||||
|
|||||||
+8
-8
@@ -200,11 +200,11 @@ This document defines the stable external behavior. It does not explain internal
|
|||||||
- on the review tab, `Submit` and `Cancel` preview notes only for answered questions
|
- on the review tab, `Submit` and `Cancel` preview notes only for answered questions
|
||||||
- on the review tab, `Elaborate` preview expands to all question notes and all option notes, including notes on unselected options
|
- on the review tab, `Elaborate` preview expands to all question notes and all option notes, including notes on unselected options
|
||||||
- transcript-friendly call and result rendering
|
- transcript-friendly call and result rendering
|
||||||
- ask settings modal with `Keymaps` and `Behaviour` tabs
|
- ask settings list with binary behaviour toggles
|
||||||
- `?` in the ask flow and `/ask-settings` in pi open the same ask settings modal
|
- `?` in the ask flow and `/ask-settings` in pi open the same lightweight ask settings overlay
|
||||||
- `Behaviour` exposes persisted user settings for `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, and `Show footer hints`
|
- behaviour settings persist immediately when changed: `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, and `Show footer hints`
|
||||||
- `Keymaps` is a persisted config section for `cancel`, `dismiss`, `toggle`, `confirm`, `optionNote`, and `questionNote`
|
- `Keymaps` is a persisted config section for `cancel`, `dismiss`, `toggle`, `confirm`, `optionNote`, and `questionNote`
|
||||||
- the `Keymaps` tab is read-only and shows the active bindings plus the config file path
|
- the settings list shows the absolute config file path for changing customizable ask keymaps
|
||||||
- if the flow is already on the review tab, all questions are answered, and no notes exist, enabling auto-submit can complete the current ask flow immediately
|
- if the flow is already on the review tab, all questions are answered, and no notes exist, enabling auto-submit can complete the current ask flow immediately
|
||||||
- elaborate results are phrased as direct follow-up instructions, for example: `User asked to elaborate on question "Which option would you like to select?" option "Option A" with note "why this one?"`
|
- elaborate results are phrased as direct follow-up instructions, for example: `User asked to elaborate on question "Which option would you like to select?" option "Option A" with note "why this one?"`
|
||||||
|
|
||||||
@@ -212,8 +212,8 @@ This document defines the stable external behavior. It does not explain internal
|
|||||||
|
|
||||||
Main flow:
|
Main flow:
|
||||||
|
|
||||||
- `?`: open the ask settings modal on the `Keymaps` tab
|
- `?`: open ask settings
|
||||||
- inside the ask settings modal, `Esc`, `Ctrl+C`, and `?` close it; dirty drafts require a second close action within a short window to discard unsaved changes
|
- inside ask settings, `Esc`, `Ctrl+C`, and `?` close it
|
||||||
- `Tab`, `Shift+Tab`, `Left`, `Right`: move between tabs
|
- `Tab`, `Shift+Tab`, `Left`, `Right`: move between tabs
|
||||||
- `Up`, `Down`: move between options
|
- `Up`, `Down`: move between options
|
||||||
- `1..9`: select or toggle the matching option; on the review tab, `1`, `2`, and `3` trigger `Submit`, `Elaborate`, and `Cancel`
|
- `1..9`: select or toggle the matching option; on the review tab, `1`, `2`, and `3` trigger `Submit`, `Elaborate`, and `Cancel`
|
||||||
@@ -227,8 +227,8 @@ Editing flow:
|
|||||||
- when `Confirm dismiss when dirty` is enabled, cancelling or dismissing a dirty ask flow requires the same action a second time
|
- when `Confirm dismiss when dirty` is enabled, cancelling or dismissing a dirty ask flow requires the same action a second time
|
||||||
- the dirty-dismiss warning stays visible until the user changes tabs in the ask flow
|
- the dirty-dismiss warning stays visible until the user changes tabs in the ask flow
|
||||||
- the configurable `dismiss` binding dismisses the entire flow immediately without saving the current editor draft when no dirty-dismiss confirmation is pending
|
- the configurable `dismiss` binding dismisses the entire flow immediately without saving the current editor draft when no dirty-dismiss confirmation is pending
|
||||||
- `?`: open the ask settings modal on the `Keymaps` tab when the editor is empty; otherwise enter `?` as text
|
- `?`: open ask settings when the editor is empty; otherwise enter `?` as text
|
||||||
- inside the ask settings modal, `Ctrl+S` saves the current draft config without closing the modal
|
- inside ask settings, behaviour changes save immediately without explicit save feedback
|
||||||
- when editor has text, arrow keys and `Tab` stay in the editor so the cursor can move while typing
|
- when editor has text, arrow keys and `Tab` stay in the editor so the cursor can move while typing
|
||||||
- when editor is empty, `Up`/`Down` move options and `Tab`/`Shift+Tab`/`Left`/`Right` move between tabs without requiring the configurable cancel key first
|
- when editor is empty, `Up`/`Down` move options and `Tab`/`Shift+Tab`/`Left`/`Right` move between tabs without requiring the configurable cancel key first
|
||||||
- navigation resumes only after closing the editor with the configurable `cancel` binding, unless the editor is empty and the navigation keys above are used
|
- navigation resumes only after closing the editor with the configurable `cancel` binding, unless the editor is empty and the navigation keys above are used
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import type {
|
|||||||
ExtensionAPI,
|
ExtensionAPI,
|
||||||
ExtensionCommandContext,
|
ExtensionCommandContext,
|
||||||
} from "@mariozechner/pi-coding-agent";
|
} 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) {
|
export function registerAskSettingsCommand(pi: ExtensionAPI) {
|
||||||
pi.registerCommand("ask-settings", {
|
pi.registerCommand("ask-settings", {
|
||||||
description: "Open the ask settings modal",
|
description: "Open ask settings",
|
||||||
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
||||||
await showAskSettingsModal(ctx.ui);
|
await showAskSettings(ctx.ui);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -104,7 +104,7 @@ export class AskConfigStore {
|
|||||||
parsed = JSON.parse(content);
|
parsed = JSON.parse(content);
|
||||||
} catch {
|
} catch {
|
||||||
return this.backupAndReset(
|
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) {
|
if (error instanceof AskConfigMigrationError) {
|
||||||
return this.backupAndReset(
|
return this.backupAndReset(
|
||||||
error.reason === "migration_failed"
|
error.reason === "migration_failed"
|
||||||
? "Config migration failed. Backed up old config 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. 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."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ const FIXED_BINDING_METADATA: readonly FixedBindingMetadata[] = [
|
|||||||
id: "settings",
|
id: "settings",
|
||||||
keys: ["?"],
|
keys: ["?"],
|
||||||
kind: "command",
|
kind: "command",
|
||||||
description: "Open ask settings modal",
|
description: "Open ask settings",
|
||||||
contexts: ["Main flow", "Empty editor"],
|
contexts: ["Main flow", "Empty editor"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import {
|
|||||||
getReviewShortcutHint,
|
getReviewShortcutHint,
|
||||||
resolveReviewShortcutDoublePress,
|
resolveReviewShortcutDoublePress,
|
||||||
} from "./review-shortcuts.ts";
|
} 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 CustomCallback = Parameters<ExtensionContext["ui"]["custom"]>[0];
|
||||||
type CustomCallbackArgs = CustomCallback extends (...args: infer T) => unknown
|
type CustomCallbackArgs = CustomCallback extends (...args: infer T) => unknown
|
||||||
@@ -430,7 +430,7 @@ function showSettingsModal(controller: AskFlowController) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
controller.settingsOpen = true;
|
controller.settingsOpen = true;
|
||||||
showAskSettingsModal(controller.ctx.ui).finally(() => {
|
showAskSettings(controller.ctx.ui).finally(() => {
|
||||||
controller.settingsOpen = false;
|
controller.settingsOpen = false;
|
||||||
refresh(controller);
|
refresh(controller);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)))}`;
|
||||||
|
}
|
||||||
@@ -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]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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 type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||||
import { getAskConfigPath, getAskConfigStore } from "../config/store.ts";
|
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"]
|
ui: Pick<ExtensionContext, "ui">["ui"]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const store = getAskConfigStore();
|
const store = getAskConfigStore();
|
||||||
const { config, notice } = await store.ensureLoaded();
|
const { config, notice } = await store.ensureLoaded();
|
||||||
return ui.custom<void>(
|
return ui.custom<void>(
|
||||||
(tui, theme, _keybindings, done) =>
|
(tui, theme, _keybindings, done) =>
|
||||||
new AskSettingsModal(theme, {
|
new AskSettingsList(theme, {
|
||||||
configPath: getAskConfigPath(),
|
configPath: getAskConfigPath(),
|
||||||
notice,
|
notice,
|
||||||
onClose: () => {
|
onClose: () => {
|
||||||
@@ -16,7 +16,7 @@ function plainTheme() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test("registers /ask-settings and opens the shared modal overlay", async () => {
|
test("registers /ask-settings and opens the shared settings overlay", async () => {
|
||||||
const agentDir = await mkdtemp(join(tmpdir(), "pi-ask-command-"));
|
const agentDir = await mkdtemp(join(tmpdir(), "pi-ask-command-"));
|
||||||
process.env.PI_CODING_AGENT_DIR = agentDir;
|
process.env.PI_CODING_AGENT_DIR = agentDir;
|
||||||
|
|
||||||
@@ -65,7 +65,11 @@ test("registers /ask-settings and opens the shared modal overlay", async () => {
|
|||||||
width: 72,
|
width: 72,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert(customCalls[0]?.lines.join("\n").includes("Keymaps"));
|
const text = customCalls[0]?.lines.join("\n") ?? "";
|
||||||
|
assert(text.includes("Ask Settings"));
|
||||||
|
assert(
|
||||||
|
text.includes("Edit this config file to change customizable ask keymaps:")
|
||||||
|
);
|
||||||
|
|
||||||
delete process.env.PI_CODING_AGENT_DIR;
|
delete process.env.PI_CODING_AGENT_DIR;
|
||||||
await rm(agentDir, { force: true, recursive: true });
|
await rm(agentDir, { force: true, recursive: true });
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ test("config store backs up broken json and loads defaults", async () => {
|
|||||||
assert.deepEqual(result.config, DEFAULT_ASK_CONFIG);
|
assert.deepEqual(result.config, DEFAULT_ASK_CONFIG);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
result.notice?.text,
|
result.notice?.text,
|
||||||
"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."
|
||||||
);
|
);
|
||||||
assert(dirEntries.some((entry) => entry.includes(".bak.json")));
|
assert(dirEntries.some((entry) => entry.includes(".bak.json")));
|
||||||
await assert.rejects(readFile(path, "utf-8"));
|
await assert.rejects(readFile(path, "utf-8"));
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { visibleWidth } from "@mariozechner/pi-tui";
|
||||||
|
import type { AskConfig } from "../src/config/schema.ts";
|
||||||
|
import { AskSettingsList } from "../src/ui/settings-list.ts";
|
||||||
|
|
||||||
|
const savedConfig: AskConfig = {
|
||||||
|
behaviour: {
|
||||||
|
autoSubmitWhenAnsweredWithoutNotes: false,
|
||||||
|
confirmDismissWhenDirty: true,
|
||||||
|
doublePressReviewShortcuts: true,
|
||||||
|
showFooterHints: true,
|
||||||
|
},
|
||||||
|
keymaps: {
|
||||||
|
cancel: "esc",
|
||||||
|
dismiss: "ctrl+c",
|
||||||
|
toggle: "space",
|
||||||
|
confirm: "enter",
|
||||||
|
optionNote: "n",
|
||||||
|
questionNote: "shift+n",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function plainTheme() {
|
||||||
|
return {
|
||||||
|
bg(_color: string, text: string) {
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
fg(_color: string, text: string) {
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createList(
|
||||||
|
options: {
|
||||||
|
onClose?: () => void;
|
||||||
|
onSave?: (config: AskConfig) => Promise<AskConfig>;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const onClose =
|
||||||
|
options.onClose ??
|
||||||
|
(() => {
|
||||||
|
// test callback intentionally unused
|
||||||
|
});
|
||||||
|
return new AskSettingsList(plainTheme(), {
|
||||||
|
configPath: "/tmp/eko24ive-pi-ask.json",
|
||||||
|
notice: undefined,
|
||||||
|
onClose,
|
||||||
|
onSave: options.onSave ?? ((config) => Promise.resolve(config)),
|
||||||
|
savedConfig,
|
||||||
|
tui: {
|
||||||
|
requestRender() {
|
||||||
|
// no-op in tests
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("settings list renders behaviour settings and config path", () => {
|
||||||
|
const list = createList();
|
||||||
|
const text = list.render(72).join("\n");
|
||||||
|
|
||||||
|
assert(text.includes("╭"));
|
||||||
|
assert(text.includes("Ask Settings"));
|
||||||
|
assert(text.includes("Auto-submit when answered without notes"));
|
||||||
|
assert(text.includes("[off]"));
|
||||||
|
assert(text.includes("Confirm dismiss when dirty"));
|
||||||
|
assert(text.includes("on"));
|
||||||
|
assert(
|
||||||
|
text.includes("Edit this config file to change customizable ask keymaps:")
|
||||||
|
);
|
||||||
|
assert(text.includes("/tmp/eko24ive-pi-ask.json"));
|
||||||
|
assert(text.includes("Esc to close"));
|
||||||
|
assert.equal(text.includes("Esc to cancel"), false);
|
||||||
|
assert.equal(text.includes("Keymaps"), false);
|
||||||
|
assert.equal(text.includes("Ctrl+S"), false);
|
||||||
|
assert.equal(text.includes("Saved"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("settings list stays within narrow render width", () => {
|
||||||
|
const list = createList();
|
||||||
|
const lines = list.render(28);
|
||||||
|
|
||||||
|
assert(lines.every((line) => visibleWidth(line) <= 28));
|
||||||
|
const text = lines.join("\n");
|
||||||
|
assert(text.includes("/tmp/eko24ive-pi-ask"));
|
||||||
|
assert(text.includes("n"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("settings list saves behaviour changes immediately without success feedback", async () => {
|
||||||
|
let saved: AskConfig | undefined;
|
||||||
|
const list = createList({
|
||||||
|
onSave: (config) => {
|
||||||
|
saved = config;
|
||||||
|
return Promise.resolve(config);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
list.handleInput(" ");
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
const text = list.render(72).join("\n");
|
||||||
|
assert.equal(saved?.behaviour.autoSubmitWhenAnsweredWithoutNotes, true);
|
||||||
|
assert.equal(saved?.behaviour.confirmDismissWhenDirty, true);
|
||||||
|
assert.equal(saved?.behaviour.doublePressReviewShortcuts, true);
|
||||||
|
assert.equal(saved?.behaviour.showFooterHints, true);
|
||||||
|
assert.equal(text.includes("Saved"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("settings list shows save failures and reverts the toggle", async () => {
|
||||||
|
const list = createList({
|
||||||
|
onSave: () => Promise.reject(new Error("disk nope")),
|
||||||
|
});
|
||||||
|
|
||||||
|
list.handleInput(" ");
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
const text = list.render(72).join("\n");
|
||||||
|
assert(text.includes("disk nope"));
|
||||||
|
assert(text.includes("Auto-submit when answered without notes"));
|
||||||
|
assert(text.includes("[off]"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("settings list closes with escape, ctrl-c, question mark, and dispose idempotently", () => {
|
||||||
|
let closed = 0;
|
||||||
|
const list = createList({
|
||||||
|
onClose: () => {
|
||||||
|
closed += 1;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
list.handleInput("?");
|
||||||
|
list.handleInput("\u0003");
|
||||||
|
list.dispose();
|
||||||
|
assert.equal(closed, 1);
|
||||||
|
});
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import { visibleWidth } from "@mariozechner/pi-tui";
|
|
||||||
import type { AskConfig } from "../src/config/schema.ts";
|
|
||||||
import { AskSettingsModal } from "../src/ui/settings-modal.ts";
|
|
||||||
|
|
||||||
const savedConfig: AskConfig = {
|
|
||||||
behaviour: {
|
|
||||||
autoSubmitWhenAnsweredWithoutNotes: false,
|
|
||||||
confirmDismissWhenDirty: true,
|
|
||||||
doublePressReviewShortcuts: true,
|
|
||||||
showFooterHints: true,
|
|
||||||
},
|
|
||||||
keymaps: {
|
|
||||||
cancel: "esc",
|
|
||||||
dismiss: "ctrl+c",
|
|
||||||
toggle: "space",
|
|
||||||
confirm: "enter",
|
|
||||||
optionNote: "n",
|
|
||||||
questionNote: "shift+n",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function plainTheme() {
|
|
||||||
return {
|
|
||||||
fg(_color: string, text: string) {
|
|
||||||
return text;
|
|
||||||
},
|
|
||||||
bg(_color: string, text: string) {
|
|
||||||
return text;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createModal(
|
|
||||||
options: {
|
|
||||||
onClose?: () => void;
|
|
||||||
onSave?: (config: AskConfig) => Promise<AskConfig>;
|
|
||||||
} = {}
|
|
||||||
) {
|
|
||||||
const onClose =
|
|
||||||
options.onClose ??
|
|
||||||
(() => {
|
|
||||||
// test callback intentionally unused
|
|
||||||
});
|
|
||||||
return new AskSettingsModal(plainTheme(), {
|
|
||||||
configPath: "/tmp/eko24ive-pi-ask.json",
|
|
||||||
notice: undefined,
|
|
||||||
onClose,
|
|
||||||
onSave: options.onSave ?? ((config) => Promise.resolve(config)),
|
|
||||||
savedConfig,
|
|
||||||
tui: {
|
|
||||||
requestRender() {
|
|
||||||
// no-op in tests
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("settings modal renders compactly on phone-width screens", () => {
|
|
||||||
const modal = createModal();
|
|
||||||
const lines = modal.render(28);
|
|
||||||
const text = lines.join("\n");
|
|
||||||
|
|
||||||
assert(lines.every((line) => visibleWidth(line) <= 28));
|
|
||||||
assert(lines.length <= 24);
|
|
||||||
assert(text.includes("@eko24ive/pi-ask"));
|
|
||||||
assert(lines[1]?.includes("Keymaps"));
|
|
||||||
assert(text.includes("Edit keymaps there, then"));
|
|
||||||
assert(text.includes("? settings"));
|
|
||||||
assert(text.includes("Esc cancel"));
|
|
||||||
assert.equal(lines[0]?.includes("├"), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("settings modal renders behaviour tab with checkbox and dirty notice", () => {
|
|
||||||
const modal = createModal();
|
|
||||||
modal.handleInput("\t");
|
|
||||||
modal.handleInput(" ");
|
|
||||||
const lines = modal.render(72);
|
|
||||||
const text = lines.join("\n");
|
|
||||||
|
|
||||||
assert(text.includes("Behaviour"));
|
|
||||||
assert(text.includes("[x] Auto-submit when answered without notes"));
|
|
||||||
assert(text.includes("[x] Confirm dismiss when dirty"));
|
|
||||||
assert(text.includes("[x] Double-press review shortcuts"));
|
|
||||||
assert(text.includes("[x] Show footer hints"));
|
|
||||||
assert(text.includes("Unsaved changes. Press Ctrl+S to save."));
|
|
||||||
assert(text.includes("Ctrl+S save · Esc/Ctrl+C/? twice discard"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("settings modal keeps behaviour tab compact on narrow screens", () => {
|
|
||||||
const modal = createModal();
|
|
||||||
modal.handleInput("\t");
|
|
||||||
const lines = modal.render(36);
|
|
||||||
|
|
||||||
assert(lines.every((line) => visibleWidth(line) <= 36));
|
|
||||||
assert(lines.length <= 21);
|
|
||||||
assert(lines.join("\n").includes("Show footer hints"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("settings modal keymaps tab shows invalid-keymaps session warning", () => {
|
|
||||||
const modal = new AskSettingsModal(plainTheme(), {
|
|
||||||
configPath: "/tmp/eko24ive-pi-ask.json",
|
|
||||||
notice: {
|
|
||||||
kind: "error",
|
|
||||||
text: "Reserved keymap for cancel: ?. Using default ask keymaps for this session. Edit the config and restart pi or run /reload.",
|
|
||||||
},
|
|
||||||
onClose() {
|
|
||||||
// no-op in test
|
|
||||||
},
|
|
||||||
onSave: (config) => Promise.resolve(config),
|
|
||||||
savedConfig,
|
|
||||||
tui: {
|
|
||||||
requestRender() {
|
|
||||||
// no-op in tests
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = modal.render(72).join("\n");
|
|
||||||
assert(
|
|
||||||
text.includes(
|
|
||||||
"Using default keymaps this session because configured keymaps were"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
assert(text.includes("rejected."));
|
|
||||||
assert(
|
|
||||||
text.includes(
|
|
||||||
"Reserved keymap for cancel: ?. Using default ask keymaps for this"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
text.includes("session. Edit the config and restart pi or run /reload.")
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("settings modal saves and clears dirty state", async () => {
|
|
||||||
let saved: AskConfig | undefined;
|
|
||||||
const modal = createModal({
|
|
||||||
onSave: (config) => {
|
|
||||||
saved = config;
|
|
||||||
return Promise.resolve(config);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
modal.handleInput("\t");
|
|
||||||
modal.handleInput(" ");
|
|
||||||
modal.handleInput("\u0013");
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
|
|
||||||
const text = modal.render(72).join("\n");
|
|
||||||
assert.equal(saved?.behaviour.autoSubmitWhenAnsweredWithoutNotes, true);
|
|
||||||
assert.equal(saved?.behaviour.confirmDismissWhenDirty, true);
|
|
||||||
assert.equal(saved?.behaviour.doublePressReviewShortcuts, true);
|
|
||||||
assert.equal(saved?.behaviour.showFooterHints, true);
|
|
||||||
assert(text.includes("Saved"));
|
|
||||||
assert.equal(text.includes("Unsaved changes. Press Ctrl+S to save."), false);
|
|
||||||
assert.equal(
|
|
||||||
text.includes("[x] Auto-submit when answered without notes"),
|
|
||||||
true
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("settings modal requires double close when dirty", () => {
|
|
||||||
let closed = 0;
|
|
||||||
const modal = createModal({
|
|
||||||
onClose: () => {
|
|
||||||
closed += 1;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
modal.handleInput("\t");
|
|
||||||
modal.handleInput(" ");
|
|
||||||
modal.handleInput("\u001b");
|
|
||||||
assert.equal(closed, 0);
|
|
||||||
assert(modal.render(72).join("\n").includes("Press again to discard."));
|
|
||||||
|
|
||||||
modal.handleInput("\u001b");
|
|
||||||
assert.equal(closed, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("settings modal ignores enter and closes idempotently on dispose", () => {
|
|
||||||
let closed = 0;
|
|
||||||
const modal = createModal({
|
|
||||||
onClose: () => {
|
|
||||||
closed += 1;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
modal.handleInput("\r");
|
|
||||||
assert.equal(closed, 0);
|
|
||||||
|
|
||||||
modal.dispose();
|
|
||||||
modal.dispose();
|
|
||||||
assert.equal(closed, 1);
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user