feat: add context-aware ask keymaps
This commit is contained in:
+62
-24
@@ -1,7 +1,11 @@
|
||||
import { DEFAULT_ASK_KEYMAPS } from "../constants/keymaps.ts";
|
||||
import {
|
||||
DEFAULT_ASK_KEYMAPS,
|
||||
normalizeConfiguredKeymaps,
|
||||
} from "../constants/keymaps.ts";
|
||||
import type {
|
||||
AskConfig,
|
||||
AskConfigFileV3,
|
||||
AskConfigFileV4,
|
||||
AskConfigKeymaps,
|
||||
AskNotificationChannel,
|
||||
} from "./schema.ts";
|
||||
|
||||
@@ -25,9 +29,7 @@ export const DEFAULT_ASK_CONFIG: AskConfig = {
|
||||
doublePressReviewShortcuts: true,
|
||||
showFooterHints: true,
|
||||
},
|
||||
keymaps: {
|
||||
...DEFAULT_ASK_KEYMAPS,
|
||||
},
|
||||
keymaps: cloneKeymaps(DEFAULT_ASK_KEYMAPS),
|
||||
notifications: {
|
||||
channels: ["bell"],
|
||||
enabled: true,
|
||||
@@ -35,7 +37,7 @@ export const DEFAULT_ASK_CONFIG: AskConfig = {
|
||||
};
|
||||
|
||||
export function normalizeAskConfig(
|
||||
config?: Partial<AskConfigFileV3> | AskConfig
|
||||
config?: Partial<AskConfigFileV4> | AskConfig
|
||||
): AskConfig {
|
||||
return {
|
||||
answer: {
|
||||
@@ -67,10 +69,7 @@ export function normalizeAskConfig(
|
||||
config?.behaviour?.showFooterHints ??
|
||||
DEFAULT_ASK_CONFIG.behaviour.showFooterHints,
|
||||
},
|
||||
keymaps: {
|
||||
...DEFAULT_ASK_CONFIG.keymaps,
|
||||
...(config?.keymaps ?? {}),
|
||||
},
|
||||
keymaps: mergeKeymaps(config?.keymaps),
|
||||
notifications: {
|
||||
channels: normalizeNotificationChannels(config?.notifications?.channels),
|
||||
enabled:
|
||||
@@ -80,10 +79,10 @@ export function normalizeAskConfig(
|
||||
};
|
||||
}
|
||||
|
||||
export function toAskConfigFileV3(config: AskConfig): AskConfigFileV3 {
|
||||
export function toAskConfigFileV4(config: AskConfig): AskConfigFileV4 {
|
||||
const normalized = normalizeAskConfig(config);
|
||||
return {
|
||||
schemaVersion: 3,
|
||||
schemaVersion: 4,
|
||||
answer: {
|
||||
extractionModels: normalized.answer.extractionModels,
|
||||
extractionRetries: normalized.answer.extractionRetries,
|
||||
@@ -97,14 +96,7 @@ export function toAskConfigFileV3(config: AskConfig): AskConfigFileV3 {
|
||||
normalized.behaviour.doublePressReviewShortcuts,
|
||||
showFooterHints: normalized.behaviour.showFooterHints,
|
||||
},
|
||||
keymaps: {
|
||||
cancel: normalized.keymaps.cancel,
|
||||
dismiss: normalized.keymaps.dismiss,
|
||||
toggle: normalized.keymaps.toggle,
|
||||
confirm: normalized.keymaps.confirm,
|
||||
optionNote: normalized.keymaps.optionNote,
|
||||
questionNote: normalized.keymaps.questionNote,
|
||||
},
|
||||
keymaps: cloneKeymaps(normalized.keymaps),
|
||||
notifications: {
|
||||
channels: normalized.notifications.channels,
|
||||
enabled: normalized.notifications.enabled,
|
||||
@@ -112,6 +104,53 @@ export function toAskConfigFileV3(config: AskConfig): AskConfigFileV3 {
|
||||
};
|
||||
}
|
||||
|
||||
function mergeKeymaps(keymaps: unknown): AskConfigKeymaps {
|
||||
const normalized = normalizeConfiguredKeymaps(keymaps);
|
||||
return normalized.ok ? normalized.keymaps : cloneKeymaps(DEFAULT_ASK_KEYMAPS);
|
||||
}
|
||||
|
||||
function cloneKeymaps(keymaps: AskConfigKeymaps): AskConfigKeymaps {
|
||||
return {
|
||||
global: {
|
||||
dismiss: [...keymaps.global.dismiss],
|
||||
settings: [...keymaps.global.settings],
|
||||
},
|
||||
main: {
|
||||
cancel: [...keymaps.main.cancel],
|
||||
confirm: [...keymaps.main.confirm],
|
||||
nextOption: [...keymaps.main.nextOption],
|
||||
nextTab: [...keymaps.main.nextTab],
|
||||
optionNote: [...keymaps.main.optionNote],
|
||||
previousOption: [...keymaps.main.previousOption],
|
||||
previousTab: [...keymaps.main.previousTab],
|
||||
questionNote: [...keymaps.main.questionNote],
|
||||
toggle: [...keymaps.main.toggle],
|
||||
},
|
||||
editor: {
|
||||
close: [...keymaps.editor.close],
|
||||
nextOptionWhenEmpty: [...keymaps.editor.nextOptionWhenEmpty],
|
||||
nextTabWhenEmpty: [...keymaps.editor.nextTabWhenEmpty],
|
||||
previousOptionWhenEmpty: [...keymaps.editor.previousOptionWhenEmpty],
|
||||
previousTabWhenEmpty: [...keymaps.editor.previousTabWhenEmpty],
|
||||
submit: [...keymaps.editor.submit],
|
||||
},
|
||||
noteEditor: {
|
||||
close: [...keymaps.noteEditor.close],
|
||||
nextOptionWhenEmpty: [...keymaps.noteEditor.nextOptionWhenEmpty],
|
||||
nextTabWhenEmpty: [...keymaps.noteEditor.nextTabWhenEmpty],
|
||||
previousOptionWhenEmpty: [...keymaps.noteEditor.previousOptionWhenEmpty],
|
||||
previousTabWhenEmpty: [...keymaps.noteEditor.previousTabWhenEmpty],
|
||||
save: [...keymaps.noteEditor.save],
|
||||
},
|
||||
settingsModal: {
|
||||
close: [...keymaps.settingsModal.close],
|
||||
nextOption: [...keymaps.settingsModal.nextOption],
|
||||
previousOption: [...keymaps.settingsModal.previousOption],
|
||||
toggle: [...keymaps.settingsModal.toggle],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNotificationChannels(
|
||||
channels: unknown
|
||||
): AskNotificationChannel[] {
|
||||
@@ -139,10 +178,9 @@ function isValidNotificationChannel(
|
||||
);
|
||||
}
|
||||
|
||||
function isValidModelPreference(value: unknown): value is {
|
||||
id: string;
|
||||
provider: string;
|
||||
} {
|
||||
function isValidModelPreference(
|
||||
value: unknown
|
||||
): value is { id: string; provider: string } {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === "object" &&
|
||||
|
||||
+6
-13
@@ -1,15 +1,12 @@
|
||||
import {
|
||||
type AskConfigurableKeyAction,
|
||||
normalizeConfiguredKeymaps,
|
||||
} from "../constants/keymaps.ts";
|
||||
import { normalizeConfiguredKeymaps } from "../constants/keymaps.ts";
|
||||
import { normalizeAskConfig } from "./defaults.ts";
|
||||
import {
|
||||
AskConfigVersionMigrationError,
|
||||
type AskConfigVersionMigrationResult,
|
||||
migrateAskConfigFileToCurrent,
|
||||
} from "./migrations/index.ts";
|
||||
import type { AskConfig, AskConfigFileV3 } from "./schema.ts";
|
||||
import { validateAskConfigFileV3 } from "./schema.ts";
|
||||
import type { AskConfig, AskConfigFileV4 } from "./schema.ts";
|
||||
import { validateAskConfigFileV4 } from "./schema.ts";
|
||||
|
||||
export class AskConfigMigrationError extends Error {
|
||||
readonly reason: "invalid_or_unsupported" | "migration_failed";
|
||||
@@ -42,20 +39,16 @@ export function migrateAskConfig(raw: unknown): AskConfigMigrationResult {
|
||||
);
|
||||
}
|
||||
|
||||
if (!validateAskConfigFileV3.Check(migratedFile.config)) {
|
||||
if (!validateAskConfigFileV4.Check(migratedFile.config)) {
|
||||
throw new AskConfigMigrationError(
|
||||
"Config was invalid or unsupported.",
|
||||
"invalid_or_unsupported"
|
||||
);
|
||||
}
|
||||
|
||||
const currentFile = migratedFile.config as AskConfigFileV3;
|
||||
const currentFile = migratedFile.config as AskConfigFileV4;
|
||||
const config = normalizeAskConfig(currentFile);
|
||||
const keymapsResult = normalizeConfiguredKeymaps(
|
||||
currentFile.keymaps as
|
||||
| Partial<Record<AskConfigurableKeyAction, unknown>>
|
||||
| undefined
|
||||
);
|
||||
const keymapsResult = normalizeConfiguredKeymaps(currentFile.keymaps);
|
||||
if (!keymapsResult.ok) {
|
||||
return {
|
||||
config: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { normalizeLegacyFlatKeymaps } from "../../constants/keymaps.ts";
|
||||
import type { AskConfigMigration, VersionedAskConfigFile } from "./types.ts";
|
||||
|
||||
export const CURRENT_ASK_CONFIG_SCHEMA_VERSION = 3;
|
||||
export const CURRENT_ASK_CONFIG_SCHEMA_VERSION = 4;
|
||||
|
||||
const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [
|
||||
{
|
||||
@@ -23,6 +24,15 @@ const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [
|
||||
schemaVersion: 3,
|
||||
}),
|
||||
},
|
||||
{
|
||||
from: 3,
|
||||
to: 4,
|
||||
migrate: (config) => ({
|
||||
...config,
|
||||
keymaps: normalizeLegacyFlatKeymaps(config.keymaps) ?? config.keymaps,
|
||||
schemaVersion: 4,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export class AskConfigVersionMigrationError extends Error {
|
||||
|
||||
+113
-10
@@ -16,6 +16,79 @@ const AskNotificationChannelSchema = Type.Union([
|
||||
}),
|
||||
]);
|
||||
|
||||
const AskKeyBindingSchema = Type.Union([
|
||||
Type.String(),
|
||||
Type.Array(Type.String()),
|
||||
]);
|
||||
|
||||
const AskConfigKeymapsSchema = Type.Object({
|
||||
global: Type.Object({
|
||||
dismiss: Type.Optional(AskKeyBindingSchema),
|
||||
settings: Type.Optional(AskKeyBindingSchema),
|
||||
}),
|
||||
main: Type.Object({
|
||||
cancel: Type.Optional(AskKeyBindingSchema),
|
||||
confirm: Type.Optional(AskKeyBindingSchema),
|
||||
nextOption: Type.Optional(AskKeyBindingSchema),
|
||||
nextTab: Type.Optional(AskKeyBindingSchema),
|
||||
optionNote: Type.Optional(AskKeyBindingSchema),
|
||||
previousOption: Type.Optional(AskKeyBindingSchema),
|
||||
previousTab: Type.Optional(AskKeyBindingSchema),
|
||||
questionNote: Type.Optional(AskKeyBindingSchema),
|
||||
toggle: Type.Optional(AskKeyBindingSchema),
|
||||
}),
|
||||
editor: Type.Object({
|
||||
close: Type.Optional(AskKeyBindingSchema),
|
||||
nextOptionWhenEmpty: Type.Optional(AskKeyBindingSchema),
|
||||
nextTabWhenEmpty: Type.Optional(AskKeyBindingSchema),
|
||||
previousOptionWhenEmpty: Type.Optional(AskKeyBindingSchema),
|
||||
previousTabWhenEmpty: Type.Optional(AskKeyBindingSchema),
|
||||
submit: Type.Optional(AskKeyBindingSchema),
|
||||
}),
|
||||
noteEditor: Type.Object({
|
||||
close: Type.Optional(AskKeyBindingSchema),
|
||||
nextOptionWhenEmpty: Type.Optional(AskKeyBindingSchema),
|
||||
nextTabWhenEmpty: Type.Optional(AskKeyBindingSchema),
|
||||
previousOptionWhenEmpty: Type.Optional(AskKeyBindingSchema),
|
||||
previousTabWhenEmpty: Type.Optional(AskKeyBindingSchema),
|
||||
save: Type.Optional(AskKeyBindingSchema),
|
||||
}),
|
||||
settingsModal: Type.Object({
|
||||
close: Type.Optional(AskKeyBindingSchema),
|
||||
nextOption: Type.Optional(AskKeyBindingSchema),
|
||||
previousOption: Type.Optional(AskKeyBindingSchema),
|
||||
toggle: Type.Optional(AskKeyBindingSchema),
|
||||
}),
|
||||
});
|
||||
|
||||
export const AskConfigFileV4Schema = Type.Object({
|
||||
schemaVersion: Type.Literal(4),
|
||||
answer: Type.Optional(
|
||||
Type.Object({
|
||||
extractionModels: Type.Optional(
|
||||
Type.Array(AskAnswerModelPreferenceSchema)
|
||||
),
|
||||
extractionRetries: Type.Optional(Type.Number()),
|
||||
extractionTimeoutMs: Type.Optional(Type.Number()),
|
||||
})
|
||||
),
|
||||
behaviour: Type.Optional(
|
||||
Type.Object({
|
||||
autoSubmitWhenAnsweredWithoutNotes: Type.Optional(Type.Boolean()),
|
||||
confirmDismissWhenDirty: Type.Optional(Type.Boolean()),
|
||||
doublePressReviewShortcuts: Type.Optional(Type.Boolean()),
|
||||
showFooterHints: Type.Optional(Type.Boolean()),
|
||||
})
|
||||
),
|
||||
keymaps: Type.Optional(AskConfigKeymapsSchema),
|
||||
notifications: Type.Optional(
|
||||
Type.Object({
|
||||
channels: Type.Optional(Type.Array(AskNotificationChannelSchema)),
|
||||
enabled: Type.Optional(Type.Boolean()),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export const AskConfigFileV3Schema = Type.Object({
|
||||
schemaVersion: Type.Literal(3),
|
||||
answer: Type.Optional(
|
||||
@@ -58,6 +131,7 @@ export const AskConfigFileV2Schema = Type.Omit(AskConfigFileV3Schema, [
|
||||
"schemaVersion",
|
||||
]);
|
||||
|
||||
export type AskConfigFileV4 = Static<typeof AskConfigFileV4Schema>;
|
||||
export type AskConfigFileV3 = Static<typeof AskConfigFileV3Schema>;
|
||||
export type AskConfigFileV2 = Static<typeof AskConfigFileV2Schema> & {
|
||||
schemaVersion: 2;
|
||||
@@ -65,9 +139,7 @@ export type AskConfigFileV2 = Static<typeof AskConfigFileV2Schema> & {
|
||||
export type AskConfigFileV1 = Omit<
|
||||
AskConfigFileV2,
|
||||
"answer" | "schemaVersion"
|
||||
> & {
|
||||
schemaVersion: 1;
|
||||
};
|
||||
> & { schemaVersion: 1 };
|
||||
|
||||
export interface AskAnswerModelPreference {
|
||||
id: string;
|
||||
@@ -81,12 +153,43 @@ export type AskNotificationChannel =
|
||||
| { command: string; type: "command" };
|
||||
|
||||
export interface AskConfigKeymaps {
|
||||
cancel: string;
|
||||
confirm: string;
|
||||
dismiss: string;
|
||||
optionNote: string;
|
||||
questionNote: string;
|
||||
toggle: string;
|
||||
editor: {
|
||||
close: string[];
|
||||
nextOptionWhenEmpty: string[];
|
||||
nextTabWhenEmpty: string[];
|
||||
previousOptionWhenEmpty: string[];
|
||||
previousTabWhenEmpty: string[];
|
||||
submit: string[];
|
||||
};
|
||||
global: {
|
||||
dismiss: string[];
|
||||
settings: string[];
|
||||
};
|
||||
main: {
|
||||
cancel: string[];
|
||||
confirm: string[];
|
||||
nextOption: string[];
|
||||
nextTab: string[];
|
||||
optionNote: string[];
|
||||
previousOption: string[];
|
||||
previousTab: string[];
|
||||
questionNote: string[];
|
||||
toggle: string[];
|
||||
};
|
||||
noteEditor: {
|
||||
close: string[];
|
||||
nextOptionWhenEmpty: string[];
|
||||
nextTabWhenEmpty: string[];
|
||||
previousOptionWhenEmpty: string[];
|
||||
previousTabWhenEmpty: string[];
|
||||
save: string[];
|
||||
};
|
||||
settingsModal: {
|
||||
close: string[];
|
||||
nextOption: string[];
|
||||
previousOption: string[];
|
||||
toggle: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface AskConfig {
|
||||
@@ -108,4 +211,4 @@ export interface AskConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export const validateAskConfigFileV3 = Compile(AskConfigFileV3Schema);
|
||||
export const validateAskConfigFileV4 = Compile(AskConfigFileV4Schema);
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
DEFAULT_ASK_CONFIG,
|
||||
normalizeAskConfig,
|
||||
toAskConfigFileV3,
|
||||
toAskConfigFileV4,
|
||||
} from "./defaults.ts";
|
||||
import { AskConfigMigrationError, migrateAskConfig } from "./migrate.ts";
|
||||
import { migrateAskConfigPathIfNeeded } from "./path-migrations.ts";
|
||||
@@ -66,7 +66,7 @@ export class AskConfigStore {
|
||||
async save(config: AskConfig | Partial<AskConfig>): Promise<AskConfig> {
|
||||
const normalized = normalizeAskConfig(config);
|
||||
const content = JSON.stringify(
|
||||
toAskConfigFileV3(normalized),
|
||||
toAskConfigFileV4(normalized),
|
||||
null,
|
||||
2
|
||||
).concat("\n");
|
||||
|
||||
+429
-231
@@ -1,4 +1,4 @@
|
||||
import { Key, matchesKey } from "@mariozechner/pi-tui";
|
||||
import { matchesKey } from "@mariozechner/pi-tui";
|
||||
import type { AskConfig, AskConfigKeymaps } from "../config/schema.ts";
|
||||
|
||||
const DIGIT_SHORTCUT_PATTERN = /^[1-9]$/;
|
||||
@@ -37,14 +37,7 @@ const KNOWN_SPECIAL_KEYS = new Set([
|
||||
"f11",
|
||||
"f12",
|
||||
] as const);
|
||||
const RESERVED_CONFIGURABLE_BINDINGS = new Set([
|
||||
"?",
|
||||
"tab",
|
||||
"shift+tab",
|
||||
"left",
|
||||
"right",
|
||||
"up",
|
||||
"down",
|
||||
const RESERVED_BINDINGS = new Set([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
@@ -55,39 +48,27 @@ const RESERVED_CONFIGURABLE_BINDINGS = new Set([
|
||||
"8",
|
||||
"9",
|
||||
]);
|
||||
const ACTION_LABEL_OVERRIDES: Partial<Record<AskKeyBindingId, string>> = {
|
||||
nextTab: "Tab / →",
|
||||
previousTab: "Shift+Tab / ←",
|
||||
previousOption: "↑ / ↓",
|
||||
nextOption: "↓",
|
||||
numberShortcut: "1..9",
|
||||
|
||||
const CONTEXT_LABELS: Record<AskKeymapContext, string> = {
|
||||
global: "Global",
|
||||
main: "Main flow",
|
||||
editor: "Answer editor",
|
||||
noteEditor: "Note editor",
|
||||
settingsModal: "Settings modal",
|
||||
};
|
||||
|
||||
type KeyId = Parameters<typeof matchesKey>[1];
|
||||
type InputKey = KeyId | (string & {});
|
||||
|
||||
type ConfigurableBindingMetadata = Omit<AskKeyBinding, "keys" | "label"> & {
|
||||
id: AskConfigurableKeyAction;
|
||||
};
|
||||
type FixedBindingMetadata = Omit<AskKeyBinding, "keys" | "label"> & {
|
||||
id: AskFixedKeyBindingId;
|
||||
keys: readonly InputKey[];
|
||||
label?: string;
|
||||
};
|
||||
|
||||
type ModifierName = (typeof MODIFIER_ORDER)[number];
|
||||
|
||||
export type AskKeymapContext = keyof AskConfigKeymaps;
|
||||
export type AskKeymapAction<C extends AskKeymapContext = AskKeymapContext> =
|
||||
keyof AskConfigKeymaps[C] & string;
|
||||
export type AskKeyBindingKind = "command" | "affordance";
|
||||
export type AskConfigurableKeyAction = keyof AskConfigKeymaps;
|
||||
export type AskFixedKeyBindingId =
|
||||
| "settings"
|
||||
| "nextTab"
|
||||
| "previousTab"
|
||||
| "previousOption"
|
||||
| "nextOption"
|
||||
export type AskKeyBindingId =
|
||||
| `${AskKeymapContext}.${string}`
|
||||
| "numberShortcut"
|
||||
| "fileReference";
|
||||
export type AskKeyBindingId = AskConfigurableKeyAction | AskFixedKeyBindingId;
|
||||
|
||||
export interface AskKeyBinding {
|
||||
contexts: readonly string[];
|
||||
@@ -106,105 +87,84 @@ export type FooterKeymapContext =
|
||||
| "note";
|
||||
|
||||
export const DEFAULT_ASK_KEYMAPS: AskConfigKeymaps = {
|
||||
cancel: "esc",
|
||||
dismiss: "ctrl+c",
|
||||
toggle: "space",
|
||||
confirm: "enter",
|
||||
optionNote: "n",
|
||||
questionNote: "shift+n",
|
||||
global: {
|
||||
dismiss: ["ctrl+c"],
|
||||
settings: ["?"],
|
||||
},
|
||||
main: {
|
||||
confirm: ["enter"],
|
||||
cancel: ["esc"],
|
||||
toggle: ["space"],
|
||||
nextTab: ["tab", "right"],
|
||||
previousTab: ["shift+tab", "left"],
|
||||
nextOption: ["down"],
|
||||
previousOption: ["up"],
|
||||
optionNote: ["n"],
|
||||
questionNote: ["shift+n"],
|
||||
},
|
||||
editor: {
|
||||
submit: ["enter"],
|
||||
close: ["esc"],
|
||||
nextTabWhenEmpty: ["tab", "right"],
|
||||
previousTabWhenEmpty: ["shift+tab", "left"],
|
||||
nextOptionWhenEmpty: ["down"],
|
||||
previousOptionWhenEmpty: ["up"],
|
||||
},
|
||||
noteEditor: {
|
||||
save: ["enter"],
|
||||
close: ["esc"],
|
||||
nextTabWhenEmpty: ["tab", "right"],
|
||||
previousTabWhenEmpty: ["shift+tab", "left"],
|
||||
nextOptionWhenEmpty: ["down"],
|
||||
previousOptionWhenEmpty: ["up"],
|
||||
},
|
||||
settingsModal: {
|
||||
close: ["esc", "ctrl+c", "?"],
|
||||
nextOption: ["down"],
|
||||
previousOption: ["up"],
|
||||
toggle: ["enter", "space"],
|
||||
},
|
||||
};
|
||||
|
||||
const CONFIGURABLE_BINDING_METADATA: readonly ConfigurableBindingMetadata[] = [
|
||||
{
|
||||
id: "cancel",
|
||||
kind: "command",
|
||||
description: "Cancel flow or close/save editor draft",
|
||||
contexts: ["Main flow", "Editors"],
|
||||
const DESCRIPTIONS: Record<AskKeymapContext, Record<string, string>> = {
|
||||
global: {
|
||||
dismiss: "Dismiss the active ask surface",
|
||||
settings: "Open ask settings",
|
||||
},
|
||||
{
|
||||
id: "dismiss",
|
||||
kind: "command",
|
||||
description: "Dismiss entire ask flow immediately",
|
||||
contexts: ["Anywhere"],
|
||||
main: {
|
||||
confirm: "Confirm selection, continue, or submit",
|
||||
cancel: "Cancel flow",
|
||||
toggle: "Toggle selected option",
|
||||
nextTab: "Switch to next tab",
|
||||
previousTab: "Switch to previous tab",
|
||||
nextOption: "Move to next option/action",
|
||||
previousOption: "Move to previous option/action",
|
||||
optionNote: "Edit selected option note",
|
||||
questionNote: "Edit question note",
|
||||
},
|
||||
{
|
||||
id: "toggle",
|
||||
kind: "command",
|
||||
description: "Toggle selected option",
|
||||
contexts: ["Questions"],
|
||||
editor: {
|
||||
submit: "Submit custom answer",
|
||||
close: "Close editor and preserve draft",
|
||||
nextTabWhenEmpty: "Switch to next tab when editor is empty",
|
||||
previousTabWhenEmpty: "Switch to previous tab when editor is empty",
|
||||
nextOptionWhenEmpty: "Move to next option when editor is empty",
|
||||
previousOptionWhenEmpty: "Move to previous option when editor is empty",
|
||||
},
|
||||
{
|
||||
id: "confirm",
|
||||
kind: "command",
|
||||
description: "Confirm selection, continue, save, or submit",
|
||||
contexts: ["Main flow", "Editors"],
|
||||
noteEditor: {
|
||||
save: "Save note",
|
||||
close: "Close note editor",
|
||||
nextTabWhenEmpty: "Switch to next tab when editor is empty",
|
||||
previousTabWhenEmpty: "Switch to previous tab when editor is empty",
|
||||
nextOptionWhenEmpty: "Move to next option when editor is empty",
|
||||
previousOptionWhenEmpty: "Move to previous option when editor is empty",
|
||||
},
|
||||
{
|
||||
id: "optionNote",
|
||||
kind: "command",
|
||||
description: "Edit selected option note",
|
||||
contexts: ["Question tabs"],
|
||||
settingsModal: {
|
||||
close: "Close settings",
|
||||
nextOption: "Move to next setting",
|
||||
previousOption: "Move to previous setting",
|
||||
toggle: "Toggle selected setting",
|
||||
},
|
||||
{
|
||||
id: "questionNote",
|
||||
kind: "command",
|
||||
description: "Edit question note",
|
||||
contexts: ["Question tabs"],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const FIXED_BINDING_METADATA: readonly FixedBindingMetadata[] = [
|
||||
{
|
||||
id: "settings",
|
||||
keys: ["?"],
|
||||
kind: "command",
|
||||
description: "Open ask settings",
|
||||
contexts: ["Main flow", "Empty editor"],
|
||||
},
|
||||
{
|
||||
id: "nextTab",
|
||||
keys: [Key.tab, Key.right],
|
||||
kind: "command",
|
||||
description: "Switch to next tab",
|
||||
contexts: ["Main flow", "Empty editor"],
|
||||
},
|
||||
{
|
||||
id: "previousTab",
|
||||
keys: [Key.shift("tab"), Key.left],
|
||||
kind: "command",
|
||||
description: "Switch to previous tab",
|
||||
contexts: ["Main flow", "Empty editor"],
|
||||
},
|
||||
{
|
||||
id: "previousOption",
|
||||
keys: [Key.up],
|
||||
kind: "command",
|
||||
description: "Move between options/actions",
|
||||
contexts: ["Main flow", "Empty editor"],
|
||||
},
|
||||
{
|
||||
id: "nextOption",
|
||||
keys: [Key.down],
|
||||
kind: "command",
|
||||
description: "Move to next option/action",
|
||||
contexts: ["Main flow", "Empty editor"],
|
||||
},
|
||||
{
|
||||
id: "numberShortcut",
|
||||
keys: ["1..9"],
|
||||
kind: "command",
|
||||
label: "1..9",
|
||||
description: "Quick-select option or submit action",
|
||||
contexts: ["Main flow", "Submit tab"],
|
||||
},
|
||||
{
|
||||
id: "fileReference",
|
||||
keys: ["@"],
|
||||
kind: "affordance",
|
||||
description: "Reference files while typing answers or notes",
|
||||
contexts: ["Editors"],
|
||||
},
|
||||
] as const;
|
||||
};
|
||||
|
||||
const footerHint = (
|
||||
binding: AskKeyBinding,
|
||||
@@ -213,6 +173,18 @@ const footerHint = (
|
||||
) => `${label} ${action}`;
|
||||
|
||||
export function formatKeybindingLabel(key: string): string {
|
||||
if (key === "up") {
|
||||
return "↑";
|
||||
}
|
||||
if (key === "down") {
|
||||
return "↓";
|
||||
}
|
||||
if (key === "left") {
|
||||
return "←";
|
||||
}
|
||||
if (key === "right") {
|
||||
return "→";
|
||||
}
|
||||
return key
|
||||
.split("+")
|
||||
.map((part) => {
|
||||
@@ -227,6 +199,10 @@ export function formatKeybindingLabel(key: string): string {
|
||||
.join("+");
|
||||
}
|
||||
|
||||
export function formatBindingLabel(keys: readonly string[]): string {
|
||||
return keys.map(formatKeybindingLabel).join(" / ");
|
||||
}
|
||||
|
||||
export function matchesBinding(data: string, binding: AskKeyBinding): boolean {
|
||||
return binding.keys.some((key) => {
|
||||
if (key.length === 1) {
|
||||
@@ -242,33 +218,71 @@ export function matchesDigitShortcut(data: string): number | null {
|
||||
|
||||
export function getAskKeyBindings(
|
||||
config: AskConfig
|
||||
): Record<AskKeyBindingId, AskKeyBinding> {
|
||||
const customizable: Record<AskConfigurableKeyAction, AskKeyBinding> = {
|
||||
cancel: createCustomizableBinding(config, "cancel"),
|
||||
dismiss: createCustomizableBinding(config, "dismiss"),
|
||||
toggle: createCustomizableBinding(config, "toggle"),
|
||||
confirm: createCustomizableBinding(config, "confirm"),
|
||||
optionNote: createCustomizableBinding(config, "optionNote"),
|
||||
questionNote: createCustomizableBinding(config, "questionNote"),
|
||||
};
|
||||
const fixed = Object.fromEntries(
|
||||
FIXED_BINDING_METADATA.map((binding) => [
|
||||
binding.id,
|
||||
{
|
||||
...binding,
|
||||
label:
|
||||
binding.label ??
|
||||
ACTION_LABEL_OVERRIDES[binding.id] ??
|
||||
binding.keys
|
||||
.map((key) => formatKeybindingLabel(String(key)))
|
||||
.join(" / "),
|
||||
},
|
||||
): Record<string, AskKeyBinding> {
|
||||
const entries: [string, AskKeyBinding][] = [];
|
||||
for (const context of Object.keys(config.keymaps) as AskKeymapContext[]) {
|
||||
const actions = config.keymaps[context] as Record<
|
||||
string,
|
||||
readonly string[]
|
||||
>;
|
||||
for (const [action, keys] of Object.entries(actions)) {
|
||||
const id = `${context}.${action}` as AskKeyBindingId;
|
||||
entries.push([
|
||||
id,
|
||||
{
|
||||
contexts: [CONTEXT_LABELS[context]],
|
||||
description: DESCRIPTIONS[context][action] ?? action,
|
||||
id,
|
||||
keys,
|
||||
kind: "command",
|
||||
label: formatBindingLabel(keys),
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
entries.push([
|
||||
"numberShortcut",
|
||||
{
|
||||
contexts: ["Main flow", "Submit tab"],
|
||||
description: "Quick-select option or submit action",
|
||||
id: "numberShortcut",
|
||||
keys: ["1..9"],
|
||||
kind: "command",
|
||||
label: "1..9",
|
||||
},
|
||||
]);
|
||||
entries.push([
|
||||
"fileReference",
|
||||
{
|
||||
contexts: ["Editors"],
|
||||
description: "Reference files while typing answers or notes",
|
||||
id: "fileReference",
|
||||
keys: ["@"],
|
||||
kind: "affordance",
|
||||
label: "@",
|
||||
},
|
||||
]);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
export function getAskContextBindings<C extends AskKeymapContext>(
|
||||
config: AskConfig,
|
||||
context: C
|
||||
): Record<AskKeymapAction<C>, AskKeyBinding> {
|
||||
const bindings = getAskKeyBindings(config);
|
||||
const actions = config.keymaps[context] as Record<string, readonly string[]>;
|
||||
return Object.fromEntries(
|
||||
Object.keys(actions).map((action) => [
|
||||
action,
|
||||
bindings[`${context}.${action}`],
|
||||
])
|
||||
) as Record<AskFixedKeyBindingId, AskKeyBinding>;
|
||||
return {
|
||||
...customizable,
|
||||
...fixed,
|
||||
};
|
||||
) as Record<AskKeymapAction<C>, AskKeyBinding>;
|
||||
}
|
||||
|
||||
export function getGlobalBindings(
|
||||
config: AskConfig
|
||||
): Record<AskKeymapAction<"global">, AskKeyBinding> {
|
||||
return getAskContextBindings(config, "global");
|
||||
}
|
||||
|
||||
export function getAskKeymaps(config: AskConfig): {
|
||||
@@ -277,8 +291,10 @@ export function getAskKeymaps(config: AskConfig): {
|
||||
} {
|
||||
const bindings = getAskKeyBindings(config);
|
||||
return {
|
||||
customizable: CONFIGURABLE_BINDING_METADATA.map(({ id }) => bindings[id]),
|
||||
fixed: FIXED_BINDING_METADATA.map(({ id }) => bindings[id]),
|
||||
customizable: Object.keys(bindings)
|
||||
.filter((id) => id !== "numberShortcut" && id !== "fileReference")
|
||||
.map((id) => bindings[id]),
|
||||
fixed: [bindings.numberShortcut, bindings.fileReference],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -286,99 +302,260 @@ export function renderFooterKeymaps(
|
||||
config: AskConfig,
|
||||
context: FooterKeymapContext
|
||||
): string {
|
||||
const global = getGlobalBindings(config);
|
||||
const main = getAskContextBindings(config, "main");
|
||||
const editor = getAskContextBindings(config, "editor");
|
||||
const noteEditor = getAskContextBindings(config, "noteEditor");
|
||||
const bindings = getAskKeyBindings(config);
|
||||
const optionNavigationLabel = bindings.previousOption.label.replaceAll(
|
||||
" / ",
|
||||
""
|
||||
);
|
||||
const optionNavigationLabel =
|
||||
`${main.previousOption.label}${main.nextOption.label}`.replaceAll(
|
||||
" / ",
|
||||
""
|
||||
);
|
||||
const tabNavigationLabel = "⇆";
|
||||
const noteNavigationLabel = `${bindings.optionNote.label}/${bindings.questionNote.label}`;
|
||||
const noteNavigationLabel = `${main.optionNote.label}/${main.questionNote.label}`;
|
||||
const hintsByContext: Record<FooterKeymapContext, readonly string[]> = {
|
||||
input: [
|
||||
footerHint(bindings.confirm, "submit"),
|
||||
footerHint(bindings.cancel, "close"),
|
||||
footerHint(bindings.settings, "settings"),
|
||||
footerHint(editor.submit, "submit"),
|
||||
footerHint(editor.close, "close"),
|
||||
footerHint(global.settings, "settings"),
|
||||
],
|
||||
note: [
|
||||
footerHint(bindings.confirm, "save"),
|
||||
footerHint(bindings.cancel, "close"),
|
||||
footerHint(bindings.settings, "settings"),
|
||||
footerHint(noteEditor.save, "save"),
|
||||
footerHint(noteEditor.close, "close"),
|
||||
footerHint(global.settings, "settings"),
|
||||
],
|
||||
submit: [
|
||||
footerHint(bindings.numberShortcut, "hotkeys"),
|
||||
footerHint(bindings.previousOption, "select", optionNavigationLabel),
|
||||
footerHint(bindings.confirm, "confirm"),
|
||||
footerHint(bindings.cancel, "cancel"),
|
||||
footerHint(bindings.settings, "settings"),
|
||||
footerHint(main.previousOption, "select", optionNavigationLabel),
|
||||
footerHint(main.confirm, "confirm"),
|
||||
footerHint(main.cancel, "cancel"),
|
||||
footerHint(global.settings, "settings"),
|
||||
],
|
||||
multi: [
|
||||
footerHint(bindings.nextTab, "tab", tabNavigationLabel),
|
||||
footerHint(bindings.previousOption, "select", optionNavigationLabel),
|
||||
footerHint(bindings.toggle, "toggle"),
|
||||
footerHint(bindings.confirm, "continue"),
|
||||
footerHint(bindings.optionNote, "note", noteNavigationLabel),
|
||||
footerHint(bindings.cancel, "dismiss"),
|
||||
footerHint(bindings.settings, "settings"),
|
||||
footerHint(main.nextTab, "tab", tabNavigationLabel),
|
||||
footerHint(main.previousOption, "select", optionNavigationLabel),
|
||||
footerHint(main.toggle, "toggle"),
|
||||
footerHint(main.confirm, "continue"),
|
||||
footerHint(main.optionNote, "note", noteNavigationLabel),
|
||||
footerHint(main.cancel, "dismiss"),
|
||||
footerHint(global.settings, "settings"),
|
||||
],
|
||||
default: [
|
||||
footerHint(bindings.nextTab, "tab", tabNavigationLabel),
|
||||
footerHint(bindings.previousOption, "select", optionNavigationLabel),
|
||||
footerHint(bindings.confirm, "confirm"),
|
||||
footerHint(bindings.optionNote, "note", noteNavigationLabel),
|
||||
footerHint(bindings.cancel, "dismiss"),
|
||||
footerHint(bindings.settings, "settings"),
|
||||
footerHint(main.nextTab, "tab", tabNavigationLabel),
|
||||
footerHint(main.previousOption, "select", optionNavigationLabel),
|
||||
footerHint(main.confirm, "confirm"),
|
||||
footerHint(main.optionNote, "note", noteNavigationLabel),
|
||||
footerHint(main.cancel, "dismiss"),
|
||||
footerHint(global.settings, "settings"),
|
||||
],
|
||||
};
|
||||
return ` ${hintsByContext[context].join(" · ")}`;
|
||||
}
|
||||
|
||||
export function renderSettingsFooterKeymaps(config: AskConfig): string {
|
||||
const bindings = getAskContextBindings(config, "settingsModal");
|
||||
return `${bindings.toggle.label} to change · ${bindings.close.label} to close`;
|
||||
}
|
||||
|
||||
export function normalizeConfiguredKeymaps(
|
||||
keymaps: Partial<Record<AskConfigurableKeyAction, unknown>> | undefined
|
||||
keymaps: unknown
|
||||
): { ok: true; keymaps: AskConfigKeymaps } | { ok: false; error: string } {
|
||||
if (!keymaps) {
|
||||
return { ok: true, keymaps: { ...DEFAULT_ASK_KEYMAPS } };
|
||||
const candidate = normalizeLegacyFlatKeymaps(keymaps) ?? keymaps;
|
||||
if (!candidate) {
|
||||
return { ok: true, keymaps: cloneKeymaps(DEFAULT_ASK_KEYMAPS) };
|
||||
}
|
||||
if (!(candidate && typeof candidate === "object")) {
|
||||
return { ok: false, error: "Keymaps must be an object." };
|
||||
}
|
||||
|
||||
const normalized = {} as AskConfigKeymaps;
|
||||
for (const action of Object.keys(
|
||||
for (const context of Object.keys(
|
||||
DEFAULT_ASK_KEYMAPS
|
||||
) as AskConfigurableKeyAction[]) {
|
||||
const rawValue = keymaps[action];
|
||||
if (typeof rawValue !== "string") {
|
||||
return { ok: false, error: `Missing keymap for ${action}.` };
|
||||
) as AskKeymapContext[]) {
|
||||
const rawContext = (candidate as Record<string, unknown>)[context];
|
||||
if (!(rawContext && typeof rawContext === "object")) {
|
||||
return { ok: false, error: `Missing keymap context ${context}.` };
|
||||
}
|
||||
const parsed = normalizeKeyId(rawValue);
|
||||
if (!parsed.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Invalid keymap for ${action}: ${parsed.error}`,
|
||||
};
|
||||
const contextResult = normalizeContextKeymaps(
|
||||
context,
|
||||
rawContext as Record<string, unknown>
|
||||
);
|
||||
if (!contextResult.ok) {
|
||||
return contextResult;
|
||||
}
|
||||
if (RESERVED_CONFIGURABLE_BINDINGS.has(parsed.keyId)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Reserved keymap for ${action}: ${formatKeybindingLabel(parsed.keyId)}.`,
|
||||
};
|
||||
}
|
||||
normalized[action] = parsed.keyId;
|
||||
normalized[context] = contextResult.keymaps as never;
|
||||
}
|
||||
|
||||
const seen = new Map<string, AskConfigurableKeyAction>();
|
||||
for (const action of Object.keys(normalized) as AskConfigurableKeyAction[]) {
|
||||
const key = normalized[action];
|
||||
const existing = seen.get(key);
|
||||
if (existing) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Duplicate keymap ${formatKeybindingLabel(key)} for ${existing} and ${action}.`,
|
||||
};
|
||||
}
|
||||
seen.set(key, action);
|
||||
const globalDuplicates = validateCrossContextConflicts(normalized);
|
||||
if (globalDuplicates) {
|
||||
return { ok: false, error: globalDuplicates };
|
||||
}
|
||||
|
||||
return { ok: true, keymaps: normalized };
|
||||
}
|
||||
|
||||
function normalizeContextKeymaps(
|
||||
context: AskKeymapContext,
|
||||
rawContext: Record<string, unknown>
|
||||
):
|
||||
| { ok: true; keymaps: Record<string, string[]> }
|
||||
| { ok: false; error: string } {
|
||||
const defaults = DEFAULT_ASK_KEYMAPS[context] as Record<
|
||||
string,
|
||||
readonly string[]
|
||||
>;
|
||||
const normalized: Record<string, string[]> = {};
|
||||
for (const action of Object.keys(defaults)) {
|
||||
const rawValue = rawContext[action];
|
||||
if (rawValue === undefined) {
|
||||
return { ok: false, error: `Missing keymap for ${context}.${action}.` };
|
||||
}
|
||||
const parsed = normalizeBindingList(rawValue);
|
||||
if (!parsed.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Invalid keymap for ${context}.${action}: ${parsed.error}`,
|
||||
};
|
||||
}
|
||||
normalized[action] = parsed.keys;
|
||||
}
|
||||
|
||||
const duplicateError = validateContextBindings(context, normalized);
|
||||
return duplicateError
|
||||
? { ok: false, error: duplicateError }
|
||||
: { ok: true, keymaps: normalized };
|
||||
}
|
||||
|
||||
function normalizeBindingList(
|
||||
rawValue: unknown
|
||||
): { ok: true; keys: string[] } | { ok: false; error: string } {
|
||||
const rawKeys = typeof rawValue === "string" ? [rawValue] : rawValue;
|
||||
if (!Array.isArray(rawKeys)) {
|
||||
return { ok: false, error: "binding must be a string or array of strings" };
|
||||
}
|
||||
if (rawKeys.length === 0) {
|
||||
return { ok: false, error: "empty binding list" };
|
||||
}
|
||||
const keys: string[] = [];
|
||||
for (const rawKey of rawKeys) {
|
||||
if (typeof rawKey !== "string") {
|
||||
return { ok: false, error: "binding list must contain only strings" };
|
||||
}
|
||||
const parsed = normalizeKeyId(rawKey);
|
||||
if (!parsed.ok) {
|
||||
return parsed;
|
||||
}
|
||||
if (!keys.includes(parsed.keyId)) {
|
||||
keys.push(parsed.keyId);
|
||||
}
|
||||
}
|
||||
return { ok: true, keys };
|
||||
}
|
||||
|
||||
function validateContextBindings(
|
||||
context: AskKeymapContext,
|
||||
bindings: Record<string, readonly string[]>
|
||||
): string | undefined {
|
||||
const seen = new Map<string, string>();
|
||||
for (const { action, key } of flattenBindings(bindings)) {
|
||||
if (RESERVED_BINDINGS.has(key)) {
|
||||
return `Reserved keymap for ${context}.${action}: ${formatKeybindingLabel(key)}.`;
|
||||
}
|
||||
const existing = seen.get(key);
|
||||
if (existing) {
|
||||
return `Duplicate keymap ${formatKeybindingLabel(key)} for ${context}.${existing} and ${context}.${action}.`;
|
||||
}
|
||||
seen.set(key, action);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
function validateCrossContextConflicts(
|
||||
keymaps: AskConfigKeymaps
|
||||
): string | undefined {
|
||||
const globalSeen = new Map(
|
||||
flattenBindings(keymaps.global).map(({ action, key }) => [
|
||||
key,
|
||||
`global.${action}`,
|
||||
])
|
||||
);
|
||||
for (const { context, action, key } of flattenNonGlobalBindings(keymaps)) {
|
||||
const existing = globalSeen.get(key);
|
||||
if (existing) {
|
||||
return `Duplicate global keymap ${formatKeybindingLabel(key)} for ${existing} and ${context}.${action}.`;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
function flattenNonGlobalBindings(keymaps: AskConfigKeymaps): Array<{
|
||||
action: string;
|
||||
context: AskKeymapContext;
|
||||
key: string;
|
||||
}> {
|
||||
return (Object.keys(keymaps) as AskKeymapContext[])
|
||||
.filter((context) => context !== "global" && context !== "settingsModal")
|
||||
.flatMap((context) =>
|
||||
flattenBindings(
|
||||
keymaps[context] as Record<string, readonly string[]>
|
||||
).map((binding) => ({ ...binding, context }))
|
||||
);
|
||||
}
|
||||
|
||||
function flattenBindings(
|
||||
bindings: Record<string, readonly string[]>
|
||||
): Array<{ action: string; key: string }> {
|
||||
return Object.entries(bindings).flatMap(([action, keys]) =>
|
||||
keys.map((key) => ({ action, key }))
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeLegacyFlatKeymaps(
|
||||
keymaps: unknown
|
||||
): AskConfigKeymaps | undefined {
|
||||
if (!(keymaps && typeof keymaps === "object")) {
|
||||
return;
|
||||
}
|
||||
if ("main" in keymaps || "global" in keymaps) {
|
||||
return;
|
||||
}
|
||||
const raw = keymaps as Record<string, unknown>;
|
||||
const defaults = cloneKeymaps(DEFAULT_ASK_KEYMAPS);
|
||||
return {
|
||||
...defaults,
|
||||
global: {
|
||||
...defaults.global,
|
||||
dismiss: coerceLegacyBinding(raw.dismiss, defaults.global.dismiss),
|
||||
},
|
||||
main: {
|
||||
...defaults.main,
|
||||
confirm: coerceLegacyBinding(raw.confirm, defaults.main.confirm),
|
||||
cancel: coerceLegacyBinding(raw.cancel, defaults.main.cancel),
|
||||
toggle: coerceLegacyBinding(raw.toggle, defaults.main.toggle),
|
||||
optionNote: coerceLegacyBinding(raw.optionNote, defaults.main.optionNote),
|
||||
questionNote: coerceLegacyBinding(
|
||||
raw.questionNote,
|
||||
defaults.main.questionNote
|
||||
),
|
||||
},
|
||||
editor: {
|
||||
...defaults.editor,
|
||||
submit: coerceLegacyBinding(raw.confirm, defaults.editor.submit),
|
||||
close: coerceLegacyBinding(raw.cancel, defaults.editor.close),
|
||||
},
|
||||
noteEditor: {
|
||||
...defaults.noteEditor,
|
||||
save: coerceLegacyBinding(raw.confirm, defaults.noteEditor.save),
|
||||
close: coerceLegacyBinding(raw.cancel, defaults.noteEditor.close),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function coerceLegacyBinding(value: unknown, fallback: string[]): string[] {
|
||||
return typeof value === "string" ? [value] : fallback;
|
||||
}
|
||||
|
||||
function normalizeKeyId(
|
||||
rawKey: string
|
||||
): { ok: true; keyId: string } | { ok: false; error: string } {
|
||||
@@ -418,27 +595,48 @@ function normalizeKeyId(
|
||||
const modifierPrefix = MODIFIER_ORDER.filter((modifier) =>
|
||||
modifiers.has(modifier)
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
keyId: [...modifierPrefix, baseKey].join("+"),
|
||||
};
|
||||
return { ok: true, keyId: [...modifierPrefix, baseKey].join("+") };
|
||||
}
|
||||
|
||||
function createCustomizableBinding(
|
||||
config: AskConfig,
|
||||
id: AskConfigurableKeyAction
|
||||
): AskKeyBinding {
|
||||
const binding = CONFIGURABLE_BINDING_METADATA.find(
|
||||
(entry) => entry.id === id
|
||||
);
|
||||
if (!binding) {
|
||||
throw new Error(`Unknown customizable ask key binding: ${id}`);
|
||||
}
|
||||
const key = config.keymaps[id];
|
||||
function cloneKeymaps(keymaps: AskConfigKeymaps): AskConfigKeymaps {
|
||||
return {
|
||||
...binding,
|
||||
keys: [key],
|
||||
label: formatKeybindingLabel(key),
|
||||
global: {
|
||||
dismiss: [...keymaps.global.dismiss],
|
||||
settings: [...keymaps.global.settings],
|
||||
},
|
||||
main: {
|
||||
confirm: [...keymaps.main.confirm],
|
||||
cancel: [...keymaps.main.cancel],
|
||||
toggle: [...keymaps.main.toggle],
|
||||
nextTab: [...keymaps.main.nextTab],
|
||||
previousTab: [...keymaps.main.previousTab],
|
||||
nextOption: [...keymaps.main.nextOption],
|
||||
previousOption: [...keymaps.main.previousOption],
|
||||
optionNote: [...keymaps.main.optionNote],
|
||||
questionNote: [...keymaps.main.questionNote],
|
||||
},
|
||||
editor: {
|
||||
submit: [...keymaps.editor.submit],
|
||||
close: [...keymaps.editor.close],
|
||||
nextTabWhenEmpty: [...keymaps.editor.nextTabWhenEmpty],
|
||||
previousTabWhenEmpty: [...keymaps.editor.previousTabWhenEmpty],
|
||||
nextOptionWhenEmpty: [...keymaps.editor.nextOptionWhenEmpty],
|
||||
previousOptionWhenEmpty: [...keymaps.editor.previousOptionWhenEmpty],
|
||||
},
|
||||
noteEditor: {
|
||||
save: [...keymaps.noteEditor.save],
|
||||
close: [...keymaps.noteEditor.close],
|
||||
nextTabWhenEmpty: [...keymaps.noteEditor.nextTabWhenEmpty],
|
||||
previousTabWhenEmpty: [...keymaps.noteEditor.previousTabWhenEmpty],
|
||||
nextOptionWhenEmpty: [...keymaps.noteEditor.nextOptionWhenEmpty],
|
||||
previousOptionWhenEmpty: [...keymaps.noteEditor.previousOptionWhenEmpty],
|
||||
},
|
||||
settingsModal: {
|
||||
close: [...keymaps.settingsModal.close],
|
||||
nextOption: [...keymaps.settingsModal.nextOption],
|
||||
previousOption: [...keymaps.settingsModal.previousOption],
|
||||
toggle: [...keymaps.settingsModal.toggle],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +172,7 @@ function renderController(
|
||||
}
|
||||
|
||||
function handleControllerInput(controller: AskFlowController, data: string) {
|
||||
controller.editor.disableSubmit = !isNativeEditorSubmitEnabled(controller);
|
||||
const command = getInputCommand(
|
||||
controller.state,
|
||||
controller.config,
|
||||
@@ -185,6 +186,16 @@ function handleControllerInput(controller: AskFlowController, data: string) {
|
||||
handleNavigationCommand(controller, command);
|
||||
}
|
||||
|
||||
function isNativeEditorSubmitEnabled(controller: AskFlowController): boolean {
|
||||
if (controller.state.view.kind === "input") {
|
||||
return controller.config.keymaps.editor.submit.includes("enter");
|
||||
}
|
||||
if (controller.state.view.kind === "note") {
|
||||
return controller.config.keymaps.noteEditor.save.includes("enter");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleEditingCommand(
|
||||
controller: AskFlowController,
|
||||
command: AskInputCommand,
|
||||
@@ -210,6 +221,10 @@ function handleEditingCommand(
|
||||
closeEditor(controller);
|
||||
return;
|
||||
}
|
||||
if (command.kind === "editSubmit") {
|
||||
submitEditor(controller, controller.editor.getText());
|
||||
return;
|
||||
}
|
||||
if (command.kind === "delegateToEditor") {
|
||||
controller.editor.handleInput(data);
|
||||
refresh(controller);
|
||||
@@ -270,6 +285,7 @@ function handleNavigationCommand(
|
||||
case "editMoveTab":
|
||||
case "editMoveOption":
|
||||
case "editClose":
|
||||
case "editSubmit":
|
||||
case "delegateToEditor":
|
||||
return;
|
||||
default:
|
||||
|
||||
+63
-15
@@ -1,6 +1,9 @@
|
||||
import { matchesKey } from "@mariozechner/pi-tui";
|
||||
import type { AskConfig } from "../config/schema.ts";
|
||||
import {
|
||||
getAskKeyBindings,
|
||||
type AskKeyBinding,
|
||||
getAskContextBindings,
|
||||
getGlobalBindings,
|
||||
matchesBinding,
|
||||
matchesDigitShortcut,
|
||||
} from "../constants/keymaps.ts";
|
||||
@@ -20,6 +23,7 @@ export type AskInputCommand =
|
||||
| { kind: "editMoveTab"; delta: 1 | -1 }
|
||||
| { kind: "editMoveOption"; delta: 1 | -1 }
|
||||
| { kind: "editClose" }
|
||||
| { kind: "editSubmit" }
|
||||
| { kind: "delegateToEditor" }
|
||||
| { kind: "ignore" };
|
||||
|
||||
@@ -29,50 +33,94 @@ export function getInputCommand(
|
||||
data: string,
|
||||
editingText = ""
|
||||
): AskInputCommand {
|
||||
const bindings = getAskKeyBindings(config);
|
||||
if (matchesBinding(data, bindings.dismiss)) {
|
||||
const global = getGlobalBindings(config);
|
||||
if (matchesBinding(data, global.dismiss)) {
|
||||
return { kind: "dismiss" };
|
||||
}
|
||||
if (matchesBinding(data, bindings.settings) && editingText.length === 0) {
|
||||
if (matchesBinding(data, global.settings) && editingText.length === 0) {
|
||||
return { kind: "showSettings" };
|
||||
}
|
||||
|
||||
if (state.view.kind === "input" || state.view.kind === "note") {
|
||||
return getEditingInputCommand(bindings, data, editingText);
|
||||
if (state.view.kind === "input") {
|
||||
return getAnswerEditorInputCommand(config, data, editingText);
|
||||
}
|
||||
if (state.view.kind === "note") {
|
||||
return getNoteEditorInputCommand(config, data, editingText);
|
||||
}
|
||||
|
||||
return getNavigationInputCommand(bindings, data);
|
||||
return getNavigationInputCommand(config, data);
|
||||
}
|
||||
|
||||
function getEditingInputCommand(
|
||||
bindings: ReturnType<typeof getAskKeyBindings>,
|
||||
function getAnswerEditorInputCommand(
|
||||
config: AskConfig,
|
||||
data: string,
|
||||
editingText: string
|
||||
): AskInputCommand {
|
||||
if (matchesBinding(data, bindings.cancel)) {
|
||||
const bindings = getAskContextBindings(config, "editor");
|
||||
if (matchesBinding(data, bindings.submit)) {
|
||||
return isNativeEditorSubmit(data)
|
||||
? { kind: "delegateToEditor" }
|
||||
: { kind: "editSubmit" };
|
||||
}
|
||||
if (matchesBinding(data, bindings.close)) {
|
||||
return { kind: "editClose" };
|
||||
}
|
||||
return getEmptyEditorNavigationCommand(bindings, data, editingText);
|
||||
}
|
||||
|
||||
function getNoteEditorInputCommand(
|
||||
config: AskConfig,
|
||||
data: string,
|
||||
editingText: string
|
||||
): AskInputCommand {
|
||||
const bindings = getAskContextBindings(config, "noteEditor");
|
||||
if (matchesBinding(data, bindings.save)) {
|
||||
return isNativeEditorSubmit(data)
|
||||
? { kind: "delegateToEditor" }
|
||||
: { kind: "editSubmit" };
|
||||
}
|
||||
if (matchesBinding(data, bindings.close)) {
|
||||
return { kind: "editClose" };
|
||||
}
|
||||
return getEmptyEditorNavigationCommand(bindings, data, editingText);
|
||||
}
|
||||
|
||||
function getEmptyEditorNavigationCommand(
|
||||
bindings: {
|
||||
nextTabWhenEmpty: AskKeyBinding;
|
||||
previousTabWhenEmpty: AskKeyBinding;
|
||||
previousOptionWhenEmpty: AskKeyBinding;
|
||||
nextOptionWhenEmpty: AskKeyBinding;
|
||||
},
|
||||
data: string,
|
||||
editingText: string
|
||||
): AskInputCommand {
|
||||
if (editingText.length === 0) {
|
||||
if (matchesBinding(data, bindings.nextTab)) {
|
||||
if (matchesBinding(data, bindings.nextTabWhenEmpty)) {
|
||||
return { kind: "editMoveTab", delta: 1 };
|
||||
}
|
||||
if (matchesBinding(data, bindings.previousTab)) {
|
||||
if (matchesBinding(data, bindings.previousTabWhenEmpty)) {
|
||||
return { kind: "editMoveTab", delta: -1 };
|
||||
}
|
||||
if (matchesBinding(data, bindings.previousOption)) {
|
||||
if (matchesBinding(data, bindings.previousOptionWhenEmpty)) {
|
||||
return { kind: "editMoveOption", delta: -1 };
|
||||
}
|
||||
if (matchesBinding(data, bindings.nextOption)) {
|
||||
if (matchesBinding(data, bindings.nextOptionWhenEmpty)) {
|
||||
return { kind: "editMoveOption", delta: 1 };
|
||||
}
|
||||
}
|
||||
return { kind: "delegateToEditor" };
|
||||
}
|
||||
|
||||
function isNativeEditorSubmit(data: string): boolean {
|
||||
return matchesKey(data, "enter");
|
||||
}
|
||||
|
||||
function getNavigationInputCommand(
|
||||
bindings: ReturnType<typeof getAskKeyBindings>,
|
||||
config: AskConfig,
|
||||
data: string
|
||||
): AskInputCommand {
|
||||
const bindings = getAskContextBindings(config, "main");
|
||||
if (matchesBinding(data, bindings.nextTab)) {
|
||||
return { kind: "moveTab", delta: 1 };
|
||||
}
|
||||
|
||||
+15
-15
@@ -1,12 +1,15 @@
|
||||
import {
|
||||
Key,
|
||||
matchesKey,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
wrapTextWithAnsi,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import type { AskConfig } from "../config/schema.ts";
|
||||
import type { AskConfigNotice } from "../config/store.ts";
|
||||
import {
|
||||
getAskContextBindings,
|
||||
matchesBinding,
|
||||
renderSettingsFooterKeymaps,
|
||||
} from "../constants/keymaps.ts";
|
||||
|
||||
interface Theme {
|
||||
bg(color: string, text: string): string;
|
||||
@@ -86,26 +89,23 @@ export class AskSettingsList {
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (
|
||||
data === "?" ||
|
||||
matchesKey(data, Key.ctrl("c")) ||
|
||||
matchesKey(data, Key.escape)
|
||||
) {
|
||||
const bindings = getAskContextBindings(this.config, "settingsModal");
|
||||
if (matchesBinding(data, bindings.close)) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.up)) {
|
||||
if (matchesBinding(data, bindings.previousOption)) {
|
||||
this.focusIndex =
|
||||
this.focusIndex === 0 ? SETTINGS.length - 1 : this.focusIndex - 1;
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.down)) {
|
||||
if (matchesBinding(data, bindings.nextOption)) {
|
||||
this.focusIndex = (this.focusIndex + 1) % SETTINGS.length;
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.enter) || data === " ") {
|
||||
if (matchesBinding(data, bindings.toggle)) {
|
||||
const setting = SETTINGS[this.focusIndex];
|
||||
if (setting) {
|
||||
this.saveSetting(setting.key, !this.getSettingValue(setting.key));
|
||||
@@ -175,15 +175,15 @@ export class AskSettingsList {
|
||||
|
||||
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));
|
||||
const footer = renderSettingsFooterKeymaps(this.config);
|
||||
for (const line of wrapTextWithAnsi(footer, innerWidth - 2)) {
|
||||
lines.push(this.line(this.theme.fg("dim", ` ${line}`), innerWidth));
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.appendWrapped(
|
||||
lines,
|
||||
this.theme.fg("dim", "Enter/Space to change · Esc to close"),
|
||||
this.theme.fg("dim", renderSettingsFooterKeymaps(this.config)),
|
||||
innerWidth
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user