@geoqiao/pi-ask 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/LICENSE +22 -0
  3. package/README.md +282 -0
  4. package/docs/README.md +33 -0
  5. package/docs/configuration.md +406 -0
  6. package/docs/contract.md +309 -0
  7. package/docs/remote-events.md +187 -0
  8. package/package.json +130 -0
  9. package/skills/ask-user/SKILL.md +110 -0
  10. package/src/answer-commands.ts +361 -0
  11. package/src/answer-extraction.ts +354 -0
  12. package/src/ask-payload-store.ts +86 -0
  13. package/src/ask-settings-command.ts +14 -0
  14. package/src/ask-tool-helpers.ts +172 -0
  15. package/src/ask-tool.ts +84 -0
  16. package/src/config/defaults.ts +216 -0
  17. package/src/config/migrate.ts +70 -0
  18. package/src/config/migrations/index.ts +139 -0
  19. package/src/config/migrations/types.ts +10 -0
  20. package/src/config/schema.ts +287 -0
  21. package/src/config/store.ts +227 -0
  22. package/src/constants/keymaps.ts +721 -0
  23. package/src/constants/text.ts +12 -0
  24. package/src/constants/ui.ts +22 -0
  25. package/src/index.ts +30 -0
  26. package/src/math.ts +3 -0
  27. package/src/notifications.ts +119 -0
  28. package/src/remote-ask.ts +563 -0
  29. package/src/result-format.ts +157 -0
  30. package/src/result.ts +23 -0
  31. package/src/schema.ts +74 -0
  32. package/src/state/answers.ts +251 -0
  33. package/src/state/create.ts +18 -0
  34. package/src/state/editor.ts +70 -0
  35. package/src/state/navigation.ts +86 -0
  36. package/src/state/normalize.ts +326 -0
  37. package/src/state/question-type.ts +128 -0
  38. package/src/state/result.ts +263 -0
  39. package/src/state/selectors.ts +135 -0
  40. package/src/state/transitions.ts +330 -0
  41. package/src/state/view.ts +28 -0
  42. package/src/text.ts +98 -0
  43. package/src/types.ts +169 -0
  44. package/src/ui/auto-submit.ts +36 -0
  45. package/src/ui/autocomplete.ts +52 -0
  46. package/src/ui/controller.ts +645 -0
  47. package/src/ui/dismiss-guard.ts +26 -0
  48. package/src/ui/input.ts +160 -0
  49. package/src/ui/render-frame.ts +235 -0
  50. package/src/ui/render-helpers.ts +385 -0
  51. package/src/ui/render-question.ts +288 -0
  52. package/src/ui/render-submit.ts +168 -0
  53. package/src/ui/render-types.ts +33 -0
  54. package/src/ui/render.ts +53 -0
  55. package/src/ui/review-shortcuts.ts +43 -0
  56. package/src/ui/settings-list.ts +461 -0
  57. package/src/ui/show-settings.ts +37 -0
  58. package/src/ui/view-models/question.ts +203 -0
  59. package/src/ui/view-models/review.ts +100 -0
@@ -0,0 +1,216 @@
1
+ import {
2
+ DEFAULT_ASK_KEYMAPS,
3
+ normalizeConfiguredKeymaps,
4
+ } from "../constants/keymaps.ts";
5
+ import type {
6
+ AskConfig,
7
+ AskConfigFileV5,
8
+ AskConfigKeymaps,
9
+ AskNotificationChannel,
10
+ } from "./schema.ts";
11
+
12
+ const DEFAULT_EXTRACTION_RETRIES = 1;
13
+ const MAX_EXTRACTION_RETRIES = 3;
14
+ const DEFAULT_EXTRACTION_TIMEOUT_MS = 30_000;
15
+
16
+ export const DEFAULT_ASK_CONFIG: AskConfig = {
17
+ answer: {
18
+ extractionModels: [
19
+ { provider: "openai-codex", id: "gpt-5.4-mini" },
20
+ { provider: "github-copilot", id: "gpt-5.4-mini" },
21
+ { provider: "anthropic", id: "claude-haiku-4-5" },
22
+ ],
23
+ extractionRetries: DEFAULT_EXTRACTION_RETRIES,
24
+ extractionTimeoutMs: DEFAULT_EXTRACTION_TIMEOUT_MS,
25
+ },
26
+ behaviour: {
27
+ autoSubmitWhenAnsweredWithoutNotes: false,
28
+ confirmDismissWhenDirty: true,
29
+ doublePressReviewShortcuts: true,
30
+ presentSingleAsMulti: false,
31
+ showFooterHints: true,
32
+ },
33
+ keymaps: cloneKeymaps(DEFAULT_ASK_KEYMAPS),
34
+ notifications: {
35
+ channels: ["bell"],
36
+ enabled: true,
37
+ },
38
+ };
39
+
40
+ export function normalizeAskConfig(
41
+ config?: Partial<AskConfigFileV5> | AskConfig
42
+ ): AskConfig {
43
+ return {
44
+ answer: {
45
+ extractionModels:
46
+ config?.answer?.extractionModels?.filter(isValidModelPreference) ??
47
+ DEFAULT_ASK_CONFIG.answer.extractionModels,
48
+ extractionRetries: clampInteger(
49
+ config?.answer?.extractionRetries,
50
+ 0,
51
+ MAX_EXTRACTION_RETRIES,
52
+ DEFAULT_EXTRACTION_RETRIES
53
+ ),
54
+ extractionTimeoutMs: positiveNumberOrDefault(
55
+ config?.answer?.extractionTimeoutMs,
56
+ DEFAULT_EXTRACTION_TIMEOUT_MS
57
+ ),
58
+ },
59
+ behaviour: {
60
+ autoSubmitWhenAnsweredWithoutNotes:
61
+ config?.behaviour?.autoSubmitWhenAnsweredWithoutNotes ??
62
+ DEFAULT_ASK_CONFIG.behaviour.autoSubmitWhenAnsweredWithoutNotes,
63
+ confirmDismissWhenDirty:
64
+ config?.behaviour?.confirmDismissWhenDirty ??
65
+ DEFAULT_ASK_CONFIG.behaviour.confirmDismissWhenDirty,
66
+ doublePressReviewShortcuts:
67
+ config?.behaviour?.doublePressReviewShortcuts ??
68
+ DEFAULT_ASK_CONFIG.behaviour.doublePressReviewShortcuts,
69
+ showFooterHints:
70
+ config?.behaviour?.showFooterHints ??
71
+ DEFAULT_ASK_CONFIG.behaviour.showFooterHints,
72
+ presentSingleAsMulti:
73
+ config?.behaviour?.presentSingleAsMulti ??
74
+ DEFAULT_ASK_CONFIG.behaviour.presentSingleAsMulti,
75
+ },
76
+ keymaps: mergeKeymaps(config?.keymaps),
77
+ notifications: {
78
+ channels: normalizeNotificationChannels(config?.notifications?.channels),
79
+ enabled:
80
+ config?.notifications?.enabled ??
81
+ DEFAULT_ASK_CONFIG.notifications.enabled,
82
+ },
83
+ };
84
+ }
85
+
86
+ export function toAskConfigFileV5(config: AskConfig): AskConfigFileV5 {
87
+ const normalized = normalizeAskConfig(config);
88
+ return {
89
+ schemaVersion: 5,
90
+ answer: {
91
+ extractionModels: normalized.answer.extractionModels,
92
+ extractionRetries: normalized.answer.extractionRetries,
93
+ extractionTimeoutMs: normalized.answer.extractionTimeoutMs,
94
+ },
95
+ behaviour: {
96
+ autoSubmitWhenAnsweredWithoutNotes:
97
+ normalized.behaviour.autoSubmitWhenAnsweredWithoutNotes,
98
+ confirmDismissWhenDirty: normalized.behaviour.confirmDismissWhenDirty,
99
+ doublePressReviewShortcuts:
100
+ normalized.behaviour.doublePressReviewShortcuts,
101
+ presentSingleAsMulti: normalized.behaviour.presentSingleAsMulti,
102
+ showFooterHints: normalized.behaviour.showFooterHints,
103
+ },
104
+ keymaps: cloneKeymaps(normalized.keymaps),
105
+ notifications: {
106
+ channels: normalized.notifications.channels,
107
+ enabled: normalized.notifications.enabled,
108
+ },
109
+ };
110
+ }
111
+
112
+ function mergeKeymaps(keymaps: unknown): AskConfigKeymaps {
113
+ const normalized = normalizeConfiguredKeymaps(keymaps);
114
+ return normalized.ok ? normalized.keymaps : cloneKeymaps(DEFAULT_ASK_KEYMAPS);
115
+ }
116
+
117
+ function cloneKeymaps(keymaps: AskConfigKeymaps): AskConfigKeymaps {
118
+ return {
119
+ global: {
120
+ dismiss: [...keymaps.global.dismiss],
121
+ settings: [...keymaps.global.settings],
122
+ },
123
+ main: {
124
+ cancel: [...keymaps.main.cancel],
125
+ changeQuestionType: [...keymaps.main.changeQuestionType],
126
+ confirm: [...keymaps.main.confirm],
127
+ nextOption: [...keymaps.main.nextOption],
128
+ nextTab: [...keymaps.main.nextTab],
129
+ optionNote: [...keymaps.main.optionNote],
130
+ previousOption: [...keymaps.main.previousOption],
131
+ previousTab: [...keymaps.main.previousTab],
132
+ questionNote: [...keymaps.main.questionNote],
133
+ toggle: [...keymaps.main.toggle],
134
+ },
135
+ editor: {
136
+ close: [...keymaps.editor.close],
137
+ nextOptionWhenEmpty: [...keymaps.editor.nextOptionWhenEmpty],
138
+ nextTabWhenEmpty: [...keymaps.editor.nextTabWhenEmpty],
139
+ previousOptionWhenEmpty: [...keymaps.editor.previousOptionWhenEmpty],
140
+ previousTabWhenEmpty: [...keymaps.editor.previousTabWhenEmpty],
141
+ submit: [...keymaps.editor.submit],
142
+ },
143
+ noteEditor: {
144
+ close: [...keymaps.noteEditor.close],
145
+ nextOptionWhenEmpty: [...keymaps.noteEditor.nextOptionWhenEmpty],
146
+ nextTabWhenEmpty: [...keymaps.noteEditor.nextTabWhenEmpty],
147
+ previousOptionWhenEmpty: [...keymaps.noteEditor.previousOptionWhenEmpty],
148
+ previousTabWhenEmpty: [...keymaps.noteEditor.previousTabWhenEmpty],
149
+ save: [...keymaps.noteEditor.save],
150
+ },
151
+ settingsModal: {
152
+ close: [...keymaps.settingsModal.close],
153
+ nextOption: [...keymaps.settingsModal.nextOption],
154
+ previousOption: [...keymaps.settingsModal.previousOption],
155
+ toggle: [...keymaps.settingsModal.toggle],
156
+ },
157
+ };
158
+ }
159
+
160
+ function normalizeNotificationChannels(
161
+ channels: unknown
162
+ ): AskNotificationChannel[] {
163
+ if (!Array.isArray(channels)) {
164
+ return DEFAULT_ASK_CONFIG.notifications.channels;
165
+ }
166
+ const normalized = channels.filter(isValidNotificationChannel);
167
+ return normalized.length > 0
168
+ ? normalized
169
+ : DEFAULT_ASK_CONFIG.notifications.channels;
170
+ }
171
+
172
+ function isValidNotificationChannel(
173
+ value: unknown
174
+ ): value is AskNotificationChannel {
175
+ if (value === "bell" || value === "osc9" || value === "osc777") {
176
+ return true;
177
+ }
178
+ return (
179
+ !!value &&
180
+ typeof value === "object" &&
181
+ (value as { type?: unknown }).type === "command" &&
182
+ typeof (value as { command?: unknown }).command === "string" &&
183
+ (value as { command: string }).command.trim().length > 0
184
+ );
185
+ }
186
+
187
+ function isValidModelPreference(
188
+ value: unknown
189
+ ): value is { id: string; provider: string } {
190
+ return (
191
+ !!value &&
192
+ typeof value === "object" &&
193
+ typeof (value as { id?: unknown }).id === "string" &&
194
+ (value as { id: string }).id.trim().length > 0 &&
195
+ typeof (value as { provider?: unknown }).provider === "string" &&
196
+ (value as { provider: string }).provider.trim().length > 0
197
+ );
198
+ }
199
+
200
+ function clampInteger(
201
+ value: unknown,
202
+ min: number,
203
+ max: number,
204
+ fallback: number
205
+ ): number {
206
+ if (typeof value !== "number" || !Number.isFinite(value)) {
207
+ return fallback;
208
+ }
209
+ return Math.max(min, Math.min(max, Math.trunc(value)));
210
+ }
211
+
212
+ function positiveNumberOrDefault(value: unknown, fallback: number): number {
213
+ return typeof value === "number" && Number.isFinite(value) && value > 0
214
+ ? value
215
+ : fallback;
216
+ }
@@ -0,0 +1,70 @@
1
+ import { normalizeConfiguredKeymaps } from "../constants/keymaps.ts";
2
+ import { normalizeAskConfig } from "./defaults.ts";
3
+ import {
4
+ AskConfigVersionMigrationError,
5
+ type AskConfigVersionMigrationResult,
6
+ migrateAskConfigFileToCurrent,
7
+ } from "./migrations/index.ts";
8
+ import type { AskConfig, AskConfigFileV5 } from "./schema.ts";
9
+ import { validateAskConfigFileV5 } from "./schema.ts";
10
+
11
+ export class AskConfigMigrationError extends Error {
12
+ readonly reason: "invalid_or_unsupported" | "migration_failed";
13
+
14
+ constructor(
15
+ message: string,
16
+ reason: "invalid_or_unsupported" | "migration_failed"
17
+ ) {
18
+ super(message);
19
+ this.reason = reason;
20
+ }
21
+ }
22
+
23
+ export interface AskConfigMigrationResult {
24
+ config: AskConfig;
25
+ migrated: boolean;
26
+ notice?: string;
27
+ }
28
+
29
+ export function migrateAskConfig(raw: unknown): AskConfigMigrationResult {
30
+ let migratedFile: AskConfigVersionMigrationResult;
31
+ try {
32
+ migratedFile = migrateAskConfigFileToCurrent(raw);
33
+ } catch (error) {
34
+ throw new AskConfigMigrationError(
35
+ "Config was invalid or unsupported.",
36
+ error instanceof AskConfigVersionMigrationError
37
+ ? error.reason
38
+ : "migration_failed"
39
+ );
40
+ }
41
+
42
+ if (!validateAskConfigFileV5.Check(migratedFile.config)) {
43
+ throw new AskConfigMigrationError(
44
+ "Config was invalid or unsupported.",
45
+ "invalid_or_unsupported"
46
+ );
47
+ }
48
+
49
+ const currentFile = migratedFile.config as AskConfigFileV5;
50
+ const config = normalizeAskConfig(currentFile);
51
+ const keymapsResult = normalizeConfiguredKeymaps(currentFile.keymaps);
52
+ if (!keymapsResult.ok) {
53
+ return {
54
+ config: {
55
+ ...config,
56
+ keymaps: normalizeAskConfig().keymaps,
57
+ },
58
+ migrated: migratedFile.migrated,
59
+ notice: `${keymapsResult.error} Using default ask keymaps for this session. Edit the config and restart pi or run /reload.`,
60
+ };
61
+ }
62
+
63
+ return {
64
+ config: {
65
+ ...config,
66
+ keymaps: keymapsResult.keymaps,
67
+ },
68
+ migrated: migratedFile.migrated,
69
+ };
70
+ }
@@ -0,0 +1,139 @@
1
+ import { normalizeLegacyFlatKeymaps } from "../../constants/keymaps.ts";
2
+ import type { AskConfigMigration, VersionedAskConfigFile } from "./types.ts";
3
+
4
+ export const CURRENT_ASK_CONFIG_SCHEMA_VERSION = 5;
5
+
6
+ const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [
7
+ {
8
+ from: 1,
9
+ to: 2,
10
+ migrate: (config) => ({
11
+ ...config,
12
+ schemaVersion: 2,
13
+ }),
14
+ },
15
+ {
16
+ from: 2,
17
+ to: 3,
18
+ migrate: (config) => ({
19
+ ...config,
20
+ notifications: {
21
+ channels: ["bell"],
22
+ enabled: true,
23
+ },
24
+ schemaVersion: 3,
25
+ }),
26
+ },
27
+ {
28
+ from: 3,
29
+ to: 4,
30
+ migrate: (config) => ({
31
+ ...config,
32
+ keymaps: normalizeLegacyFlatKeymaps(config.keymaps) ?? config.keymaps,
33
+ schemaVersion: 4,
34
+ }),
35
+ },
36
+ {
37
+ from: 4,
38
+ to: 5,
39
+ migrate: (config) => ({
40
+ ...config,
41
+ behaviour: {
42
+ ...((config.behaviour as Record<string, unknown> | undefined) ?? {}),
43
+ presentSingleAsMulti: false,
44
+ },
45
+ keymaps: addV5Keymaps(config.keymaps),
46
+ schemaVersion: 5,
47
+ }),
48
+ },
49
+ ];
50
+
51
+ function addV5Keymaps(keymaps: unknown): unknown {
52
+ if (!(keymaps && typeof keymaps === "object" && "main" in keymaps)) {
53
+ return keymaps;
54
+ }
55
+ const current = keymaps as Record<string, unknown>;
56
+ const main = current.main;
57
+ if (!(main && typeof main === "object")) {
58
+ return keymaps;
59
+ }
60
+ return {
61
+ ...current,
62
+ main: {
63
+ ...(main as Record<string, unknown>),
64
+ changeQuestionType: (main as Record<string, unknown>)
65
+ .changeQuestionType ?? ["t"],
66
+ },
67
+ };
68
+ }
69
+
70
+ export class AskConfigVersionMigrationError extends Error {
71
+ readonly reason: "invalid_or_unsupported" | "migration_failed";
72
+
73
+ constructor(
74
+ message: string,
75
+ reason: "invalid_or_unsupported" | "migration_failed"
76
+ ) {
77
+ super(message);
78
+ this.reason = reason;
79
+ }
80
+ }
81
+
82
+ export interface AskConfigVersionMigrationResult {
83
+ config: VersionedAskConfigFile;
84
+ migrated: boolean;
85
+ }
86
+
87
+ export function migrateAskConfigFileToCurrent(
88
+ raw: unknown
89
+ ): AskConfigVersionMigrationResult {
90
+ const start = getVersionedConfigFile(raw);
91
+ let config = start;
92
+ let migrated = false;
93
+
94
+ while (config.schemaVersion < CURRENT_ASK_CONFIG_SCHEMA_VERSION) {
95
+ const migration = ASK_CONFIG_MIGRATIONS.find(
96
+ (candidate) => candidate.from === config.schemaVersion
97
+ );
98
+ if (!migration) {
99
+ throw new AskConfigVersionMigrationError(
100
+ `No ask config migration from schemaVersion ${config.schemaVersion}`,
101
+ "migration_failed"
102
+ );
103
+ }
104
+ config = migration.migrate(config);
105
+ if (config.schemaVersion !== migration.to) {
106
+ throw new AskConfigVersionMigrationError(
107
+ `Ask config migration from schemaVersion ${migration.from} did not produce schemaVersion ${migration.to}`,
108
+ "migration_failed"
109
+ );
110
+ }
111
+ migrated = true;
112
+ }
113
+
114
+ if (config.schemaVersion !== CURRENT_ASK_CONFIG_SCHEMA_VERSION) {
115
+ throw new AskConfigVersionMigrationError(
116
+ `Unsupported ask config schemaVersion ${config.schemaVersion}`,
117
+ "invalid_or_unsupported"
118
+ );
119
+ }
120
+
121
+ return { config, migrated };
122
+ }
123
+
124
+ function getVersionedConfigFile(raw: unknown): VersionedAskConfigFile {
125
+ if (!(raw && typeof raw === "object")) {
126
+ throw new AskConfigVersionMigrationError(
127
+ "Ask config must be an object",
128
+ "invalid_or_unsupported"
129
+ );
130
+ }
131
+ const schemaVersion = (raw as { schemaVersion?: unknown }).schemaVersion;
132
+ if (!Number.isInteger(schemaVersion) || typeof schemaVersion !== "number") {
133
+ throw new AskConfigVersionMigrationError(
134
+ "Ask config must include an integer schemaVersion",
135
+ "invalid_or_unsupported"
136
+ );
137
+ }
138
+ return raw as VersionedAskConfigFile;
139
+ }
@@ -0,0 +1,10 @@
1
+ export interface VersionedAskConfigFile {
2
+ schemaVersion: number;
3
+ [key: string]: unknown;
4
+ }
5
+
6
+ export interface AskConfigMigration {
7
+ from: number;
8
+ migrate: (config: VersionedAskConfigFile) => VersionedAskConfigFile;
9
+ to: number;
10
+ }