feat: add question type presentation controls
This commit is contained in:
@@ -30,6 +30,7 @@ export const ASK_TOOL_PROMPT_GUIDELINES = [
|
||||
|
||||
interface ValidateParamsOptions {
|
||||
allowFreeform?: boolean;
|
||||
presentSingleAsMulti?: boolean;
|
||||
}
|
||||
|
||||
export function validateParams(
|
||||
|
||||
+5
-1
@@ -13,6 +13,7 @@ import {
|
||||
successfulResponse,
|
||||
validateParams,
|
||||
} from "./ask-tool-helpers.ts";
|
||||
import { getAskConfigStore } from "./config/store.ts";
|
||||
import { AskParamsSchema } from "./schema.ts";
|
||||
import type { AskParams } from "./types.ts";
|
||||
import { runAskFlow } from "./ui/controller.ts";
|
||||
@@ -48,7 +49,10 @@ async function executeAskTool(
|
||||
_onUpdate: unknown,
|
||||
ctx: ExtensionContext
|
||||
) {
|
||||
const validation = validateParams(params);
|
||||
const config = await getAskConfigStore().getConfig();
|
||||
const validation = validateParams(params, {
|
||||
presentSingleAsMulti: config.behaviour.presentSingleAsMulti,
|
||||
});
|
||||
if (!validation.ok) {
|
||||
return invalidPayloadResponse(params, validation.issues);
|
||||
}
|
||||
|
||||
+10
-4
@@ -4,7 +4,7 @@ import {
|
||||
} from "../constants/keymaps.ts";
|
||||
import type {
|
||||
AskConfig,
|
||||
AskConfigFileV4,
|
||||
AskConfigFileV5,
|
||||
AskConfigKeymaps,
|
||||
AskNotificationChannel,
|
||||
} from "./schema.ts";
|
||||
@@ -27,6 +27,7 @@ export const DEFAULT_ASK_CONFIG: AskConfig = {
|
||||
autoSubmitWhenAnsweredWithoutNotes: false,
|
||||
confirmDismissWhenDirty: true,
|
||||
doublePressReviewShortcuts: true,
|
||||
presentSingleAsMulti: false,
|
||||
showFooterHints: true,
|
||||
},
|
||||
keymaps: cloneKeymaps(DEFAULT_ASK_KEYMAPS),
|
||||
@@ -37,7 +38,7 @@ export const DEFAULT_ASK_CONFIG: AskConfig = {
|
||||
};
|
||||
|
||||
export function normalizeAskConfig(
|
||||
config?: Partial<AskConfigFileV4> | AskConfig
|
||||
config?: Partial<AskConfigFileV5> | AskConfig
|
||||
): AskConfig {
|
||||
return {
|
||||
answer: {
|
||||
@@ -68,6 +69,9 @@ export function normalizeAskConfig(
|
||||
showFooterHints:
|
||||
config?.behaviour?.showFooterHints ??
|
||||
DEFAULT_ASK_CONFIG.behaviour.showFooterHints,
|
||||
presentSingleAsMulti:
|
||||
config?.behaviour?.presentSingleAsMulti ??
|
||||
DEFAULT_ASK_CONFIG.behaviour.presentSingleAsMulti,
|
||||
},
|
||||
keymaps: mergeKeymaps(config?.keymaps),
|
||||
notifications: {
|
||||
@@ -79,10 +83,10 @@ export function normalizeAskConfig(
|
||||
};
|
||||
}
|
||||
|
||||
export function toAskConfigFileV4(config: AskConfig): AskConfigFileV4 {
|
||||
export function toAskConfigFileV5(config: AskConfig): AskConfigFileV5 {
|
||||
const normalized = normalizeAskConfig(config);
|
||||
return {
|
||||
schemaVersion: 4,
|
||||
schemaVersion: 5,
|
||||
answer: {
|
||||
extractionModels: normalized.answer.extractionModels,
|
||||
extractionRetries: normalized.answer.extractionRetries,
|
||||
@@ -94,6 +98,7 @@ export function toAskConfigFileV4(config: AskConfig): AskConfigFileV4 {
|
||||
confirmDismissWhenDirty: normalized.behaviour.confirmDismissWhenDirty,
|
||||
doublePressReviewShortcuts:
|
||||
normalized.behaviour.doublePressReviewShortcuts,
|
||||
presentSingleAsMulti: normalized.behaviour.presentSingleAsMulti,
|
||||
showFooterHints: normalized.behaviour.showFooterHints,
|
||||
},
|
||||
keymaps: cloneKeymaps(normalized.keymaps),
|
||||
@@ -117,6 +122,7 @@ function cloneKeymaps(keymaps: AskConfigKeymaps): AskConfigKeymaps {
|
||||
},
|
||||
main: {
|
||||
cancel: [...keymaps.main.cancel],
|
||||
changeQuestionType: [...keymaps.main.changeQuestionType],
|
||||
confirm: [...keymaps.main.confirm],
|
||||
nextOption: [...keymaps.main.nextOption],
|
||||
nextTab: [...keymaps.main.nextTab],
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
type AskConfigVersionMigrationResult,
|
||||
migrateAskConfigFileToCurrent,
|
||||
} from "./migrations/index.ts";
|
||||
import type { AskConfig, AskConfigFileV4 } from "./schema.ts";
|
||||
import { validateAskConfigFileV4 } from "./schema.ts";
|
||||
import type { AskConfig, AskConfigFileV5 } from "./schema.ts";
|
||||
import { validateAskConfigFileV5 } from "./schema.ts";
|
||||
|
||||
export class AskConfigMigrationError extends Error {
|
||||
readonly reason: "invalid_or_unsupported" | "migration_failed";
|
||||
@@ -39,14 +39,14 @@ export function migrateAskConfig(raw: unknown): AskConfigMigrationResult {
|
||||
);
|
||||
}
|
||||
|
||||
if (!validateAskConfigFileV4.Check(migratedFile.config)) {
|
||||
if (!validateAskConfigFileV5.Check(migratedFile.config)) {
|
||||
throw new AskConfigMigrationError(
|
||||
"Config was invalid or unsupported.",
|
||||
"invalid_or_unsupported"
|
||||
);
|
||||
}
|
||||
|
||||
const currentFile = migratedFile.config as AskConfigFileV4;
|
||||
const currentFile = migratedFile.config as AskConfigFileV5;
|
||||
const config = normalizeAskConfig(currentFile);
|
||||
const keymapsResult = normalizeConfiguredKeymaps(currentFile.keymaps);
|
||||
if (!keymapsResult.ok) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { normalizeLegacyFlatKeymaps } from "../../constants/keymaps.ts";
|
||||
import type { AskConfigMigration, VersionedAskConfigFile } from "./types.ts";
|
||||
|
||||
export const CURRENT_ASK_CONFIG_SCHEMA_VERSION = 4;
|
||||
export const CURRENT_ASK_CONFIG_SCHEMA_VERSION = 5;
|
||||
|
||||
const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [
|
||||
{
|
||||
@@ -33,8 +33,40 @@ const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [
|
||||
schemaVersion: 4,
|
||||
}),
|
||||
},
|
||||
{
|
||||
from: 4,
|
||||
to: 5,
|
||||
migrate: (config) => ({
|
||||
...config,
|
||||
behaviour: {
|
||||
...((config.behaviour as Record<string, unknown> | undefined) ?? {}),
|
||||
presentSingleAsMulti: false,
|
||||
},
|
||||
keymaps: addV5Keymaps(config.keymaps),
|
||||
schemaVersion: 5,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function addV5Keymaps(keymaps: unknown): unknown {
|
||||
if (!(keymaps && typeof keymaps === "object" && "main" in keymaps)) {
|
||||
return keymaps;
|
||||
}
|
||||
const current = keymaps as Record<string, unknown>;
|
||||
const main = current.main;
|
||||
if (!(main && typeof main === "object")) {
|
||||
return keymaps;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
main: {
|
||||
...(main as Record<string, unknown>),
|
||||
changeQuestionType: (main as Record<string, unknown>)
|
||||
.changeQuestionType ?? ["t"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class AskConfigVersionMigrationError extends Error {
|
||||
readonly reason: "invalid_or_unsupported" | "migration_failed";
|
||||
|
||||
|
||||
+76
-3
@@ -21,7 +21,7 @@ const AskKeyBindingSchema = Type.Union([
|
||||
Type.Array(Type.String()),
|
||||
]);
|
||||
|
||||
const AskConfigKeymapsSchema = Type.Object({
|
||||
const AskConfigKeymapsV4Schema = Type.Object({
|
||||
global: Type.Object({
|
||||
dismiss: Type.Optional(AskKeyBindingSchema),
|
||||
settings: Type.Optional(AskKeyBindingSchema),
|
||||
@@ -61,6 +61,76 @@ const AskConfigKeymapsSchema = Type.Object({
|
||||
}),
|
||||
});
|
||||
|
||||
const AskConfigKeymapsSchema = Type.Object({
|
||||
global: Type.Object({
|
||||
dismiss: Type.Optional(AskKeyBindingSchema),
|
||||
settings: Type.Optional(AskKeyBindingSchema),
|
||||
}),
|
||||
main: Type.Object({
|
||||
cancel: Type.Optional(AskKeyBindingSchema),
|
||||
changeQuestionType: 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 AskConfigFileV5Schema = Type.Object({
|
||||
schemaVersion: Type.Literal(5),
|
||||
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()),
|
||||
presentSingleAsMulti: 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 AskConfigFileV4Schema = Type.Object({
|
||||
schemaVersion: Type.Literal(4),
|
||||
answer: Type.Optional(
|
||||
@@ -80,7 +150,7 @@ export const AskConfigFileV4Schema = Type.Object({
|
||||
showFooterHints: Type.Optional(Type.Boolean()),
|
||||
})
|
||||
),
|
||||
keymaps: Type.Optional(AskConfigKeymapsSchema),
|
||||
keymaps: Type.Optional(AskConfigKeymapsV4Schema),
|
||||
notifications: Type.Optional(
|
||||
Type.Object({
|
||||
channels: Type.Optional(Type.Array(AskNotificationChannelSchema)),
|
||||
@@ -131,6 +201,7 @@ export const AskConfigFileV2Schema = Type.Omit(AskConfigFileV3Schema, [
|
||||
"schemaVersion",
|
||||
]);
|
||||
|
||||
export type AskConfigFileV5 = Static<typeof AskConfigFileV5Schema>;
|
||||
export type AskConfigFileV4 = Static<typeof AskConfigFileV4Schema>;
|
||||
export type AskConfigFileV3 = Static<typeof AskConfigFileV3Schema>;
|
||||
export type AskConfigFileV2 = Static<typeof AskConfigFileV2Schema> & {
|
||||
@@ -167,6 +238,7 @@ export interface AskConfigKeymaps {
|
||||
};
|
||||
main: {
|
||||
cancel: string[];
|
||||
changeQuestionType: string[];
|
||||
confirm: string[];
|
||||
nextOption: string[];
|
||||
nextTab: string[];
|
||||
@@ -202,6 +274,7 @@ export interface AskConfig {
|
||||
autoSubmitWhenAnsweredWithoutNotes: boolean;
|
||||
confirmDismissWhenDirty: boolean;
|
||||
doublePressReviewShortcuts: boolean;
|
||||
presentSingleAsMulti: boolean;
|
||||
showFooterHints: boolean;
|
||||
};
|
||||
keymaps: AskConfigKeymaps;
|
||||
@@ -211,4 +284,4 @@ export interface AskConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export const validateAskConfigFileV4 = Compile(AskConfigFileV4Schema);
|
||||
export const validateAskConfigFileV5 = Compile(AskConfigFileV5Schema);
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
DEFAULT_ASK_CONFIG,
|
||||
normalizeAskConfig,
|
||||
toAskConfigFileV4,
|
||||
toAskConfigFileV5,
|
||||
} 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(
|
||||
toAskConfigFileV4(normalized),
|
||||
toAskConfigFileV5(normalized),
|
||||
null,
|
||||
2
|
||||
).concat("\n");
|
||||
|
||||
@@ -94,6 +94,7 @@ export const DEFAULT_ASK_KEYMAPS: AskConfigKeymaps = {
|
||||
main: {
|
||||
confirm: ["enter"],
|
||||
cancel: ["esc"],
|
||||
changeQuestionType: ["t"],
|
||||
toggle: ["space"],
|
||||
nextTab: ["tab", "right"],
|
||||
previousTab: ["shift+tab", "left"],
|
||||
@@ -134,6 +135,7 @@ const DESCRIPTIONS: Record<AskKeymapContext, Record<string, string>> = {
|
||||
main: {
|
||||
confirm: "Confirm selection, continue, or submit",
|
||||
cancel: "Cancel flow",
|
||||
changeQuestionType: "Change current question type",
|
||||
toggle: "Toggle selected option",
|
||||
nextTab: "Switch to next tab",
|
||||
previousTab: "Switch to previous tab",
|
||||
@@ -172,6 +174,8 @@ const footerHint = (
|
||||
label = binding.label
|
||||
) => `${label} ${action}`;
|
||||
|
||||
const footerKeyIdLabel = (binding: AskKeyBinding) => binding.keys.join(" / ");
|
||||
|
||||
export function formatKeybindingLabel(key: string): string {
|
||||
if (key === "up") {
|
||||
return "↑";
|
||||
@@ -336,6 +340,11 @@ export function renderFooterKeymaps(
|
||||
footerHint(main.nextTab, "tab", tabNavigationLabel),
|
||||
footerHint(main.previousOption, "select", optionNavigationLabel),
|
||||
footerHint(main.toggle, "toggle"),
|
||||
footerHint(
|
||||
main.changeQuestionType,
|
||||
"question type",
|
||||
footerKeyIdLabel(main.changeQuestionType)
|
||||
),
|
||||
footerHint(main.confirm, "continue"),
|
||||
footerHint(main.optionNote, "note", noteNavigationLabel),
|
||||
footerHint(main.cancel, "dismiss"),
|
||||
@@ -344,6 +353,11 @@ export function renderFooterKeymaps(
|
||||
default: [
|
||||
footerHint(main.nextTab, "tab", tabNavigationLabel),
|
||||
footerHint(main.previousOption, "select", optionNavigationLabel),
|
||||
footerHint(
|
||||
main.changeQuestionType,
|
||||
"question type",
|
||||
footerKeyIdLabel(main.changeQuestionType)
|
||||
),
|
||||
footerHint(main.confirm, "confirm"),
|
||||
footerHint(main.optionNote, "note", noteNavigationLabel),
|
||||
footerHint(main.cancel, "dismiss"),
|
||||
@@ -607,6 +621,7 @@ function cloneKeymaps(keymaps: AskConfigKeymaps): AskConfigKeymaps {
|
||||
main: {
|
||||
confirm: [...keymaps.main.confirm],
|
||||
cancel: [...keymaps.main.cancel],
|
||||
changeQuestionType: [...keymaps.main.changeQuestionType],
|
||||
toggle: [...keymaps.main.toggle],
|
||||
nextTab: [...keymaps.main.nextTab],
|
||||
previousTab: [...keymaps.main.previousTab],
|
||||
|
||||
@@ -8,6 +8,8 @@ export function formatResultLines(
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
let hasPresentationOverride = false;
|
||||
|
||||
for (const question of result.questions) {
|
||||
const answer = result.answers[question.id];
|
||||
if (!answer) {
|
||||
@@ -19,6 +21,10 @@ export function formatResultLines(
|
||||
lines.push(answerLine);
|
||||
}
|
||||
|
||||
if (hasPresentedTypeOverride(question.type, question.presentedType)) {
|
||||
hasPresentationOverride = true;
|
||||
}
|
||||
|
||||
const questionNoteLine = formatQuestionNoteLine(
|
||||
question.label,
|
||||
answer.note,
|
||||
@@ -31,6 +37,10 @@ export function formatResultLines(
|
||||
lines.push(...formatOptionNoteLines(question.label, answer, options.mode));
|
||||
}
|
||||
|
||||
if (hasPresentationOverride) {
|
||||
lines.push(formatPresentationNoteLine(options.mode));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
@@ -52,6 +62,19 @@ function formatAnswerLine(
|
||||
return `✓ ${questionLabel}: ${answerText}`;
|
||||
}
|
||||
|
||||
function hasPresentedTypeOverride(
|
||||
type: string,
|
||||
presentedType: string | undefined
|
||||
): boolean {
|
||||
return !!presentedType && presentedType !== type;
|
||||
}
|
||||
|
||||
function formatPresentationNoteLine(mode: "summary" | "render"): string {
|
||||
const text =
|
||||
"Note: Some questions were presented as multi-select by user preference.";
|
||||
return mode === "summary" ? text : ` ${text}`;
|
||||
}
|
||||
|
||||
function formatQuestionNoteLine(
|
||||
questionLabel: string,
|
||||
note: string | undefined,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createInitialState as createBaseState } from "./transitions.ts";
|
||||
|
||||
interface CreateInitialStateOptions {
|
||||
allowFreeform?: boolean;
|
||||
presentSingleAsMulti?: boolean;
|
||||
}
|
||||
|
||||
export function createInitialState(
|
||||
|
||||
+15
-3
@@ -13,6 +13,7 @@ interface IssueCollector {
|
||||
|
||||
interface ValidationOptions {
|
||||
allowFreeform?: boolean;
|
||||
presentSingleAsMulti?: boolean;
|
||||
}
|
||||
|
||||
export function normalizeQuestions(
|
||||
@@ -23,7 +24,9 @@ export function normalizeQuestions(
|
||||
if (issues.length > 0) {
|
||||
throw new Error(issues[0]?.message ?? "Invalid ask_user payload");
|
||||
}
|
||||
return params.questions.map(normalizeQuestion);
|
||||
return params.questions.map((question, index) =>
|
||||
normalizeQuestion(question, index, options)
|
||||
);
|
||||
}
|
||||
|
||||
export function collectValidationIssues(
|
||||
@@ -37,13 +40,22 @@ export function collectValidationIssues(
|
||||
|
||||
function normalizeQuestion(
|
||||
question: AskQuestionInput,
|
||||
index: number
|
||||
index: number,
|
||||
options: ValidationOptions = {}
|
||||
): AskQuestion {
|
||||
const requestedType = normalizeQuestionType(question.type);
|
||||
const presentedType =
|
||||
options.presentSingleAsMulti && requestedType === "single"
|
||||
? "multi"
|
||||
: requestedType;
|
||||
return {
|
||||
id: question.id.trim(),
|
||||
label: question.label?.trim() || `Q${index + 1}`,
|
||||
prompt: question.prompt.trim(),
|
||||
type: normalizeQuestionType(question.type),
|
||||
type: presentedType,
|
||||
...(presentedType === requestedType
|
||||
? {}
|
||||
: { requestedType, presentedType }),
|
||||
required: question.required ?? false,
|
||||
options: question.options.map(normalizeOption),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { AskQuestionType, AskState, AskStateAnswer } from "../types.ts";
|
||||
import { isAnswerEmpty } from "./answers.ts";
|
||||
import { getAnswer, getCurrentQuestion, isSubmitTab } from "./selectors.ts";
|
||||
|
||||
export interface QuestionTypeChangeResult {
|
||||
needsConfirmation: boolean;
|
||||
notice?: string;
|
||||
state: AskState;
|
||||
}
|
||||
|
||||
export function cycleCurrentQuestionType(
|
||||
state: AskState,
|
||||
options: { confirmed?: boolean } = {}
|
||||
): QuestionTypeChangeResult {
|
||||
if (isSubmitTab(state)) {
|
||||
return { needsConfirmation: false, state };
|
||||
}
|
||||
|
||||
const question = getCurrentQuestion(state);
|
||||
if (!question) {
|
||||
return { needsConfirmation: false, state };
|
||||
}
|
||||
|
||||
const nextType = getNextQuestionType(question);
|
||||
if (nextType === question.type) {
|
||||
return { needsConfirmation: false, state };
|
||||
}
|
||||
|
||||
const answer = getAnswer(state, question.id);
|
||||
const requiresConfirmation =
|
||||
nextType === "single" && countCommittedAnswers(answer) > 1;
|
||||
if (requiresConfirmation && !options.confirmed) {
|
||||
return {
|
||||
needsConfirmation: true,
|
||||
notice:
|
||||
"Switching to single-select will clear selected options for this question. Press the type key again to confirm.",
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
needsConfirmation: false,
|
||||
notice: getQuestionTypeChangeNotice(question.type, nextType),
|
||||
state: setQuestionType(state, question.id, nextType, {
|
||||
clearSelectedOptions: requiresConfirmation,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function getNextQuestionType(question: {
|
||||
requestedType?: AskQuestionType;
|
||||
type: AskQuestionType;
|
||||
}): AskQuestionType {
|
||||
const requestedType = question.requestedType ?? question.type;
|
||||
if (requestedType === "preview") {
|
||||
return question.type === "preview" ? "multi" : "preview";
|
||||
}
|
||||
switch (question.type) {
|
||||
case "single":
|
||||
return "multi";
|
||||
case "multi":
|
||||
return "single";
|
||||
case "preview":
|
||||
return "multi";
|
||||
default:
|
||||
return "multi";
|
||||
}
|
||||
}
|
||||
|
||||
function countCommittedAnswers(answer: AskStateAnswer | undefined): number {
|
||||
if (!answer) {
|
||||
return 0;
|
||||
}
|
||||
return (
|
||||
answer.selected.length +
|
||||
(answer.customSelected && answer.customText?.trim() ? 1 : 0)
|
||||
);
|
||||
}
|
||||
|
||||
function setQuestionType(
|
||||
state: AskState,
|
||||
questionId: string,
|
||||
type: AskQuestionType,
|
||||
options: { clearSelectedOptions: boolean }
|
||||
): AskState {
|
||||
const questions = state.questions.map((question) => {
|
||||
if (question.id !== questionId) {
|
||||
return question;
|
||||
}
|
||||
const requestedType = question.requestedType ?? question.type;
|
||||
return {
|
||||
...question,
|
||||
type,
|
||||
requestedType,
|
||||
presentedType: type === requestedType ? undefined : type,
|
||||
};
|
||||
});
|
||||
|
||||
const currentAnswer = state.answers[questionId];
|
||||
if (!(options.clearSelectedOptions && currentAnswer)) {
|
||||
return { ...state, questions };
|
||||
}
|
||||
|
||||
const nextAnswer = {
|
||||
...currentAnswer,
|
||||
customSelected: currentAnswer.customText?.trim() ? true : undefined,
|
||||
selected: [],
|
||||
};
|
||||
const answers = { ...state.answers };
|
||||
if (isAnswerEmpty(nextAnswer)) {
|
||||
delete answers[questionId];
|
||||
} else {
|
||||
answers[questionId] = nextAnswer;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
questions,
|
||||
answers,
|
||||
};
|
||||
}
|
||||
|
||||
function getQuestionTypeChangeNotice(
|
||||
from: AskQuestionType,
|
||||
to: AskQuestionType
|
||||
): string {
|
||||
return `Question type changed from ${from} to ${to}.`;
|
||||
}
|
||||
+10
-2
@@ -54,7 +54,11 @@ export function toAskResult(state: AskState): AskResult {
|
||||
id: question.id,
|
||||
label: question.label,
|
||||
prompt: question.prompt,
|
||||
type: question.type,
|
||||
type: question.requestedType ?? question.type,
|
||||
...(question.presentedType &&
|
||||
question.presentedType !== question.requestedType
|
||||
? { presentedType: question.presentedType }
|
||||
: {}),
|
||||
})),
|
||||
answers,
|
||||
continuation:
|
||||
@@ -186,7 +190,11 @@ function createElaborationQuestionContext(
|
||||
id: question.id,
|
||||
label: question.label,
|
||||
prompt: question.prompt,
|
||||
type: question.type,
|
||||
type: question.requestedType ?? question.type,
|
||||
...(question.presentedType &&
|
||||
question.presentedType !== question.requestedType
|
||||
? { presentedType: question.presentedType }
|
||||
: {}),
|
||||
options: question.options.map(cloneOption),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface AskValidationError {
|
||||
export interface AskQuestion
|
||||
extends Omit<AskQuestionInput, "type" | "required" | "label"> {
|
||||
label: string;
|
||||
presentedType?: AskQuestionType;
|
||||
requestedType?: AskQuestionType;
|
||||
required: boolean;
|
||||
type: AskQuestionType;
|
||||
}
|
||||
@@ -65,6 +67,7 @@ export interface AskResultAnswer {
|
||||
export interface AskQuestionSummary {
|
||||
id: string;
|
||||
label: string;
|
||||
presentedType?: AskQuestionType;
|
||||
prompt: string;
|
||||
type: AskQuestionType;
|
||||
}
|
||||
|
||||
+45
-1
@@ -13,6 +13,7 @@ import {
|
||||
submitEditorDraft,
|
||||
syncStateToSelection,
|
||||
} from "../state/editor.ts";
|
||||
import { cycleCurrentQuestionType } from "../state/question-type.ts";
|
||||
import { toAskResult } from "../state/result.ts";
|
||||
import {
|
||||
getCurrentOption,
|
||||
@@ -58,6 +59,7 @@ type Keybindings = CustomCallbackArgs[2];
|
||||
type Done = (result: AskResult) => void;
|
||||
interface AskFlowOptions {
|
||||
allowFreeform?: boolean;
|
||||
presentSingleAsMulti?: boolean;
|
||||
}
|
||||
|
||||
type AskFlowParams = AskParams &
|
||||
@@ -73,6 +75,7 @@ interface AskFlowController {
|
||||
dismissNotice?: string;
|
||||
done: Done;
|
||||
editor: Editor;
|
||||
pendingQuestionTypeChangeQuestionId?: string;
|
||||
pendingReviewShortcutActionIndex?: number;
|
||||
settingsOpen: boolean;
|
||||
state: AskState;
|
||||
@@ -89,13 +92,18 @@ export async function runAskFlow(
|
||||
): Promise<AskResult> {
|
||||
const store = getAskConfigStore();
|
||||
const config = await store.getConfig();
|
||||
const flowOptions = {
|
||||
...options,
|
||||
presentSingleAsMulti:
|
||||
options.presentSingleAsMulti ?? config.behaviour.presentSingleAsMulti,
|
||||
};
|
||||
return ctx.ui.custom<AskResult>((...args) =>
|
||||
createAskFlowController(args, {
|
||||
...params,
|
||||
config,
|
||||
cwd: ctx.cwd,
|
||||
ctx,
|
||||
flowOptions: options,
|
||||
flowOptions,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -118,6 +126,7 @@ function createAskFlowController(
|
||||
settingsOpen: false,
|
||||
state: createInitialState(params, params.flowOptions),
|
||||
suppressAutoInputForSelection: false,
|
||||
pendingQuestionTypeChangeQuestionId: undefined,
|
||||
pendingReviewShortcutActionIndex: undefined,
|
||||
theme,
|
||||
tui,
|
||||
@@ -238,30 +247,41 @@ function handleNavigationCommand(
|
||||
switch (command.kind) {
|
||||
case "moveTab":
|
||||
clearReviewShortcutPending(controller);
|
||||
clearQuestionTypeChangePending(controller);
|
||||
commitState(controller, moveTab(controller.state, command.delta));
|
||||
return;
|
||||
case "moveOption":
|
||||
clearReviewShortcutPending(controller);
|
||||
clearQuestionTypeChangePending(controller);
|
||||
commitState(controller, moveOption(controller.state, command.delta));
|
||||
return;
|
||||
case "toggleMulti":
|
||||
clearReviewShortcutPending(controller);
|
||||
clearQuestionTypeChangePending(controller);
|
||||
handleToggleCurrentOption(controller);
|
||||
return;
|
||||
case "changeQuestionType":
|
||||
clearReviewShortcutPending(controller);
|
||||
handleChangeQuestionType(controller);
|
||||
return;
|
||||
case "openQuestionNote":
|
||||
clearQuestionTypeChangePending(controller);
|
||||
openQuestionNote(controller);
|
||||
return;
|
||||
case "openOptionNote":
|
||||
clearQuestionTypeChangePending(controller);
|
||||
openOptionNote(controller);
|
||||
return;
|
||||
case "confirm":
|
||||
clearReviewShortcutPending(controller);
|
||||
clearQuestionTypeChangePending(controller);
|
||||
commitState(controller, confirmCurrentSelection(controller.state), {
|
||||
finish: true,
|
||||
});
|
||||
return;
|
||||
case "cancel":
|
||||
clearReviewShortcutPending(controller);
|
||||
clearQuestionTypeChangePending(controller);
|
||||
handleExitFlow(controller, cancelFlow(controller.state));
|
||||
return;
|
||||
case "numberShortcut":
|
||||
@@ -269,6 +289,7 @@ function handleNavigationCommand(
|
||||
return;
|
||||
}
|
||||
clearReviewShortcutPending(controller);
|
||||
clearQuestionTypeChangePending(controller);
|
||||
commitState(
|
||||
controller,
|
||||
applyNumberShortcut(controller.state, command.digit)
|
||||
@@ -276,6 +297,7 @@ function handleNavigationCommand(
|
||||
return;
|
||||
case "dismiss":
|
||||
clearReviewShortcutPending(controller);
|
||||
clearQuestionTypeChangePending(controller);
|
||||
handleExitFlow(controller, dismissFlow(controller.state));
|
||||
return;
|
||||
case "showSettings":
|
||||
@@ -301,6 +323,24 @@ function handleToggleCurrentOption(controller: AskFlowController) {
|
||||
commitState(controller, toggleCurrentMultiOption(controller.state));
|
||||
}
|
||||
|
||||
function handleChangeQuestionType(controller: AskFlowController) {
|
||||
const question = getCurrentQuestion(controller.state);
|
||||
if (!question || isSubmitTab(controller.state)) {
|
||||
return;
|
||||
}
|
||||
const confirmed =
|
||||
controller.pendingQuestionTypeChangeQuestionId === question.id;
|
||||
const result = cycleCurrentQuestionType(controller.state, { confirmed });
|
||||
controller.dismissNotice = result.notice;
|
||||
if (result.needsConfirmation) {
|
||||
controller.pendingQuestionTypeChangeQuestionId = question.id;
|
||||
refresh(controller);
|
||||
return;
|
||||
}
|
||||
clearQuestionTypeChangePending(controller);
|
||||
commitState(controller, result.state, { finish: true });
|
||||
}
|
||||
|
||||
function openQuestionNote(controller: AskFlowController) {
|
||||
const question = getCurrentQuestion(controller.state);
|
||||
if (!question || isSubmitTab(controller.state)) {
|
||||
@@ -415,6 +455,10 @@ function clearReviewShortcutPending(controller: AskFlowController) {
|
||||
controller.pendingReviewShortcutActionIndex = undefined;
|
||||
}
|
||||
|
||||
function clearQuestionTypeChangePending(controller: AskFlowController) {
|
||||
controller.pendingQuestionTypeChangeQuestionId = undefined;
|
||||
}
|
||||
|
||||
function getActiveReviewShortcutHint(
|
||||
controller: AskFlowController
|
||||
): string | undefined {
|
||||
|
||||
@@ -17,6 +17,7 @@ export type AskInputCommand =
|
||||
| { kind: "openOptionNote" }
|
||||
| { kind: "confirm" }
|
||||
| { kind: "cancel" }
|
||||
| { kind: "changeQuestionType" }
|
||||
| { kind: "dismiss" }
|
||||
| { kind: "showSettings" }
|
||||
| { kind: "numberShortcut"; digit: number }
|
||||
@@ -136,6 +137,9 @@ function getNavigationInputCommand(
|
||||
if (matchesBinding(data, bindings.toggle)) {
|
||||
return { kind: "toggleMulti" };
|
||||
}
|
||||
if (matchesBinding(data, bindings.changeQuestionType)) {
|
||||
return { kind: "changeQuestionType" };
|
||||
}
|
||||
if (matchesBinding(data, bindings.confirm)) {
|
||||
return { kind: "confirm" };
|
||||
}
|
||||
|
||||
+46
-6
@@ -34,11 +34,33 @@ const DESCRIPTION_LINE_COUNT = 3;
|
||||
const COMPACT_WIDTH = 40;
|
||||
const RESET_CONFIRMATION_MS = 2000;
|
||||
|
||||
type SettingSection = "Actions" | "Defaults for future asks" | "Live settings";
|
||||
type ToggleSettingKey = keyof AskConfig["behaviour"] | "notifications.enabled";
|
||||
|
||||
interface BaseSetting {
|
||||
description: string;
|
||||
label: string;
|
||||
section: SettingSection;
|
||||
}
|
||||
|
||||
interface ActionSetting extends BaseSetting {
|
||||
key: "resetConfig";
|
||||
type: "action";
|
||||
}
|
||||
|
||||
interface ToggleSetting extends BaseSetting {
|
||||
key: ToggleSettingKey;
|
||||
type: "toggle";
|
||||
}
|
||||
|
||||
type Setting = ActionSetting | ToggleSetting;
|
||||
|
||||
const SETTINGS = [
|
||||
{
|
||||
description:
|
||||
"Auto-submit completed ask flows when no question or option notes were added.",
|
||||
key: "autoSubmitWhenAnsweredWithoutNotes",
|
||||
section: "Live settings",
|
||||
label: "Auto-submit when answered without notes",
|
||||
type: "toggle",
|
||||
},
|
||||
@@ -46,6 +68,7 @@ const SETTINGS = [
|
||||
description:
|
||||
"Require a second cancel or dismiss action before discarding answered or drafted ask content.",
|
||||
key: "confirmDismissWhenDirty",
|
||||
section: "Live settings",
|
||||
label: "Confirm dismiss when dirty",
|
||||
type: "toggle",
|
||||
},
|
||||
@@ -53,6 +76,7 @@ const SETTINGS = [
|
||||
description:
|
||||
"Require pressing 1, 2, or 3 twice on the review tab before triggering Submit, Elaborate, or Cancel.",
|
||||
key: "doublePressReviewShortcuts",
|
||||
section: "Live settings",
|
||||
label: "Double-press review shortcuts",
|
||||
type: "toggle",
|
||||
},
|
||||
@@ -60,26 +84,36 @@ const SETTINGS = [
|
||||
description:
|
||||
"Emit one external notification when the ask flow opens and waits for input.",
|
||||
key: "notifications.enabled",
|
||||
section: "Live settings",
|
||||
label: "Notifications",
|
||||
type: "toggle",
|
||||
},
|
||||
{
|
||||
description: "Show footer keymap hints at the bottom of the ask flow.",
|
||||
key: "showFooterHints",
|
||||
section: "Live settings",
|
||||
label: "Show footer hints",
|
||||
type: "toggle",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Default new and replayed ask flows to render single-select questions as multi-select. This is not hot-applied to the current flow; use the type hotkey there.",
|
||||
key: "presentSingleAsMulti",
|
||||
section: "Defaults for future asks",
|
||||
label: "Present single-select as multi-select",
|
||||
type: "toggle",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Reset behaviour, keymaps, notifications, and extraction settings to defaults. Press twice quickly to confirm.",
|
||||
key: "resetConfig",
|
||||
section: "Actions",
|
||||
label: "Reset config to defaults",
|
||||
type: "action",
|
||||
},
|
||||
] as const;
|
||||
] as const satisfies readonly Setting[];
|
||||
|
||||
type Setting = (typeof SETTINGS)[number];
|
||||
type SettingKey = Extract<Setting, { type: "toggle" }>["key"];
|
||||
type SettingKey = ToggleSettingKey;
|
||||
|
||||
export class AskSettingsList {
|
||||
private closed = false;
|
||||
@@ -152,10 +186,16 @@ export class AskSettingsList {
|
||||
);
|
||||
|
||||
lines.push(this.line("", innerWidth));
|
||||
let previousSection: string | undefined;
|
||||
for (const [index, setting] of SETTINGS.entries()) {
|
||||
if (setting.type === "action") {
|
||||
lines.push(this.line("", innerWidth));
|
||||
lines.push(this.line("", innerWidth));
|
||||
if (setting.section !== previousSection) {
|
||||
if (previousSection) {
|
||||
lines.push(this.line("", innerWidth));
|
||||
}
|
||||
lines.push(
|
||||
this.line(this.theme.fg("accent", ` ${setting.section}`), innerWidth)
|
||||
);
|
||||
previousSection = setting.section;
|
||||
}
|
||||
for (const settingLine of this.renderSetting(
|
||||
setting,
|
||||
|
||||
Reference in New Issue
Block a user