@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,287 @@
1
+ import { type Static, Type } from "typebox";
2
+ import { Compile } from "typebox/compile";
3
+
4
+ const AskAnswerModelPreferenceSchema = Type.Object({
5
+ id: Type.String(),
6
+ provider: Type.String(),
7
+ });
8
+
9
+ const AskNotificationChannelSchema = Type.Union([
10
+ Type.Literal("bell"),
11
+ Type.Literal("osc9"),
12
+ Type.Literal("osc777"),
13
+ Type.Object({
14
+ command: Type.String(),
15
+ type: Type.Literal("command"),
16
+ }),
17
+ ]);
18
+
19
+ const AskKeyBindingSchema = Type.Union([
20
+ Type.String(),
21
+ Type.Array(Type.String()),
22
+ ]);
23
+
24
+ const AskConfigKeymapsV4Schema = Type.Object({
25
+ global: Type.Object({
26
+ dismiss: Type.Optional(AskKeyBindingSchema),
27
+ settings: Type.Optional(AskKeyBindingSchema),
28
+ }),
29
+ main: Type.Object({
30
+ cancel: Type.Optional(AskKeyBindingSchema),
31
+ confirm: Type.Optional(AskKeyBindingSchema),
32
+ nextOption: Type.Optional(AskKeyBindingSchema),
33
+ nextTab: Type.Optional(AskKeyBindingSchema),
34
+ optionNote: Type.Optional(AskKeyBindingSchema),
35
+ previousOption: Type.Optional(AskKeyBindingSchema),
36
+ previousTab: Type.Optional(AskKeyBindingSchema),
37
+ questionNote: Type.Optional(AskKeyBindingSchema),
38
+ toggle: Type.Optional(AskKeyBindingSchema),
39
+ }),
40
+ editor: Type.Object({
41
+ close: Type.Optional(AskKeyBindingSchema),
42
+ nextOptionWhenEmpty: Type.Optional(AskKeyBindingSchema),
43
+ nextTabWhenEmpty: Type.Optional(AskKeyBindingSchema),
44
+ previousOptionWhenEmpty: Type.Optional(AskKeyBindingSchema),
45
+ previousTabWhenEmpty: Type.Optional(AskKeyBindingSchema),
46
+ submit: Type.Optional(AskKeyBindingSchema),
47
+ }),
48
+ noteEditor: Type.Object({
49
+ close: Type.Optional(AskKeyBindingSchema),
50
+ nextOptionWhenEmpty: Type.Optional(AskKeyBindingSchema),
51
+ nextTabWhenEmpty: Type.Optional(AskKeyBindingSchema),
52
+ previousOptionWhenEmpty: Type.Optional(AskKeyBindingSchema),
53
+ previousTabWhenEmpty: Type.Optional(AskKeyBindingSchema),
54
+ save: Type.Optional(AskKeyBindingSchema),
55
+ }),
56
+ settingsModal: Type.Object({
57
+ close: Type.Optional(AskKeyBindingSchema),
58
+ nextOption: Type.Optional(AskKeyBindingSchema),
59
+ previousOption: Type.Optional(AskKeyBindingSchema),
60
+ toggle: Type.Optional(AskKeyBindingSchema),
61
+ }),
62
+ });
63
+
64
+ const AskConfigKeymapsSchema = Type.Object({
65
+ global: Type.Object({
66
+ dismiss: Type.Optional(AskKeyBindingSchema),
67
+ settings: Type.Optional(AskKeyBindingSchema),
68
+ }),
69
+ main: Type.Object({
70
+ cancel: Type.Optional(AskKeyBindingSchema),
71
+ changeQuestionType: Type.Optional(AskKeyBindingSchema),
72
+ confirm: Type.Optional(AskKeyBindingSchema),
73
+ nextOption: Type.Optional(AskKeyBindingSchema),
74
+ nextTab: Type.Optional(AskKeyBindingSchema),
75
+ optionNote: Type.Optional(AskKeyBindingSchema),
76
+ previousOption: Type.Optional(AskKeyBindingSchema),
77
+ previousTab: Type.Optional(AskKeyBindingSchema),
78
+ questionNote: Type.Optional(AskKeyBindingSchema),
79
+ toggle: Type.Optional(AskKeyBindingSchema),
80
+ }),
81
+ editor: Type.Object({
82
+ close: Type.Optional(AskKeyBindingSchema),
83
+ nextOptionWhenEmpty: Type.Optional(AskKeyBindingSchema),
84
+ nextTabWhenEmpty: Type.Optional(AskKeyBindingSchema),
85
+ previousOptionWhenEmpty: Type.Optional(AskKeyBindingSchema),
86
+ previousTabWhenEmpty: Type.Optional(AskKeyBindingSchema),
87
+ submit: Type.Optional(AskKeyBindingSchema),
88
+ }),
89
+ noteEditor: Type.Object({
90
+ close: Type.Optional(AskKeyBindingSchema),
91
+ nextOptionWhenEmpty: Type.Optional(AskKeyBindingSchema),
92
+ nextTabWhenEmpty: Type.Optional(AskKeyBindingSchema),
93
+ previousOptionWhenEmpty: Type.Optional(AskKeyBindingSchema),
94
+ previousTabWhenEmpty: Type.Optional(AskKeyBindingSchema),
95
+ save: Type.Optional(AskKeyBindingSchema),
96
+ }),
97
+ settingsModal: Type.Object({
98
+ close: Type.Optional(AskKeyBindingSchema),
99
+ nextOption: Type.Optional(AskKeyBindingSchema),
100
+ previousOption: Type.Optional(AskKeyBindingSchema),
101
+ toggle: Type.Optional(AskKeyBindingSchema),
102
+ }),
103
+ });
104
+
105
+ export const AskConfigFileV5Schema = Type.Object({
106
+ schemaVersion: Type.Literal(5),
107
+ answer: Type.Optional(
108
+ Type.Object({
109
+ extractionModels: Type.Optional(
110
+ Type.Array(AskAnswerModelPreferenceSchema)
111
+ ),
112
+ extractionRetries: Type.Optional(Type.Number()),
113
+ extractionTimeoutMs: Type.Optional(Type.Number()),
114
+ })
115
+ ),
116
+ behaviour: Type.Optional(
117
+ Type.Object({
118
+ autoSubmitWhenAnsweredWithoutNotes: Type.Optional(Type.Boolean()),
119
+ confirmDismissWhenDirty: Type.Optional(Type.Boolean()),
120
+ doublePressReviewShortcuts: Type.Optional(Type.Boolean()),
121
+ presentSingleAsMulti: Type.Optional(Type.Boolean()),
122
+ showFooterHints: Type.Optional(Type.Boolean()),
123
+ })
124
+ ),
125
+ keymaps: Type.Optional(AskConfigKeymapsSchema),
126
+ notifications: Type.Optional(
127
+ Type.Object({
128
+ channels: Type.Optional(Type.Array(AskNotificationChannelSchema)),
129
+ enabled: Type.Optional(Type.Boolean()),
130
+ })
131
+ ),
132
+ });
133
+
134
+ export const AskConfigFileV4Schema = Type.Object({
135
+ schemaVersion: Type.Literal(4),
136
+ answer: Type.Optional(
137
+ Type.Object({
138
+ extractionModels: Type.Optional(
139
+ Type.Array(AskAnswerModelPreferenceSchema)
140
+ ),
141
+ extractionRetries: Type.Optional(Type.Number()),
142
+ extractionTimeoutMs: Type.Optional(Type.Number()),
143
+ })
144
+ ),
145
+ behaviour: Type.Optional(
146
+ Type.Object({
147
+ autoSubmitWhenAnsweredWithoutNotes: Type.Optional(Type.Boolean()),
148
+ confirmDismissWhenDirty: Type.Optional(Type.Boolean()),
149
+ doublePressReviewShortcuts: Type.Optional(Type.Boolean()),
150
+ showFooterHints: Type.Optional(Type.Boolean()),
151
+ })
152
+ ),
153
+ keymaps: Type.Optional(AskConfigKeymapsV4Schema),
154
+ notifications: Type.Optional(
155
+ Type.Object({
156
+ channels: Type.Optional(Type.Array(AskNotificationChannelSchema)),
157
+ enabled: Type.Optional(Type.Boolean()),
158
+ })
159
+ ),
160
+ });
161
+
162
+ export const AskConfigFileV3Schema = Type.Object({
163
+ schemaVersion: Type.Literal(3),
164
+ answer: Type.Optional(
165
+ Type.Object({
166
+ extractionModels: Type.Optional(
167
+ Type.Array(AskAnswerModelPreferenceSchema)
168
+ ),
169
+ extractionRetries: Type.Optional(Type.Number()),
170
+ extractionTimeoutMs: Type.Optional(Type.Number()),
171
+ })
172
+ ),
173
+ behaviour: Type.Optional(
174
+ Type.Object({
175
+ autoSubmitWhenAnsweredWithoutNotes: Type.Optional(Type.Boolean()),
176
+ confirmDismissWhenDirty: Type.Optional(Type.Boolean()),
177
+ doublePressReviewShortcuts: Type.Optional(Type.Boolean()),
178
+ showFooterHints: Type.Optional(Type.Boolean()),
179
+ })
180
+ ),
181
+ keymaps: Type.Optional(
182
+ Type.Object({
183
+ cancel: Type.Optional(Type.String()),
184
+ confirm: Type.Optional(Type.String()),
185
+ dismiss: Type.Optional(Type.String()),
186
+ optionNote: Type.Optional(Type.String()),
187
+ questionNote: Type.Optional(Type.String()),
188
+ toggle: Type.Optional(Type.String()),
189
+ })
190
+ ),
191
+ notifications: Type.Optional(
192
+ Type.Object({
193
+ channels: Type.Optional(Type.Array(AskNotificationChannelSchema)),
194
+ enabled: Type.Optional(Type.Boolean()),
195
+ })
196
+ ),
197
+ });
198
+
199
+ export const AskConfigFileV2Schema = Type.Omit(AskConfigFileV3Schema, [
200
+ "notifications",
201
+ "schemaVersion",
202
+ ]);
203
+
204
+ export type AskConfigFileV5 = Static<typeof AskConfigFileV5Schema>;
205
+ export type AskConfigFileV4 = Static<typeof AskConfigFileV4Schema>;
206
+ export type AskConfigFileV3 = Static<typeof AskConfigFileV3Schema>;
207
+ export type AskConfigFileV2 = Static<typeof AskConfigFileV2Schema> & {
208
+ schemaVersion: 2;
209
+ };
210
+ export type AskConfigFileV1 = Omit<
211
+ AskConfigFileV2,
212
+ "answer" | "schemaVersion"
213
+ > & { schemaVersion: 1 };
214
+
215
+ export interface AskAnswerModelPreference {
216
+ id: string;
217
+ provider: string;
218
+ }
219
+
220
+ export type AskNotificationChannel =
221
+ | "bell"
222
+ | "osc9"
223
+ | "osc777"
224
+ | { command: string; type: "command" };
225
+
226
+ export interface AskConfigKeymaps {
227
+ editor: {
228
+ close: string[];
229
+ nextOptionWhenEmpty: string[];
230
+ nextTabWhenEmpty: string[];
231
+ previousOptionWhenEmpty: string[];
232
+ previousTabWhenEmpty: string[];
233
+ submit: string[];
234
+ };
235
+ global: {
236
+ dismiss: string[];
237
+ settings: string[];
238
+ };
239
+ main: {
240
+ cancel: string[];
241
+ changeQuestionType: string[];
242
+ confirm: string[];
243
+ nextOption: string[];
244
+ nextTab: string[];
245
+ optionNote: string[];
246
+ previousOption: string[];
247
+ previousTab: string[];
248
+ questionNote: string[];
249
+ toggle: string[];
250
+ };
251
+ noteEditor: {
252
+ close: string[];
253
+ nextOptionWhenEmpty: string[];
254
+ nextTabWhenEmpty: string[];
255
+ previousOptionWhenEmpty: string[];
256
+ previousTabWhenEmpty: string[];
257
+ save: string[];
258
+ };
259
+ settingsModal: {
260
+ close: string[];
261
+ nextOption: string[];
262
+ previousOption: string[];
263
+ toggle: string[];
264
+ };
265
+ }
266
+
267
+ export interface AskConfig {
268
+ answer: {
269
+ extractionModels: AskAnswerModelPreference[];
270
+ extractionRetries: number;
271
+ extractionTimeoutMs: number;
272
+ };
273
+ behaviour: {
274
+ autoSubmitWhenAnsweredWithoutNotes: boolean;
275
+ confirmDismissWhenDirty: boolean;
276
+ doublePressReviewShortcuts: boolean;
277
+ presentSingleAsMulti: boolean;
278
+ showFooterHints: boolean;
279
+ };
280
+ keymaps: AskConfigKeymaps;
281
+ notifications: {
282
+ channels: AskNotificationChannel[];
283
+ enabled: boolean;
284
+ };
285
+ }
286
+
287
+ export const validateAskConfigFileV5 = Compile(AskConfigFileV5Schema);
@@ -0,0 +1,227 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ DEFAULT_ASK_CONFIG,
7
+ normalizeAskConfig,
8
+ toAskConfigFileV5,
9
+ } from "./defaults.ts";
10
+ import { AskConfigMigrationError, migrateAskConfig } from "./migrate.ts";
11
+ import type { AskConfig } from "./schema.ts";
12
+
13
+ const INVALID_CONFIG_NOTICE =
14
+ "Config was invalid or unsupported. Loaded defaults for this session and left the config file unchanged. Edit the config file or run /reload after fixing it.";
15
+ const MIGRATION_FAILED_NOTICE =
16
+ "Config migration failed. Loaded defaults for this session and left the config file unchanged. Edit the config file or run /reload after fixing it.";
17
+
18
+ export interface AskConfigNotice {
19
+ kind: "error" | "warning" | "success";
20
+ text: string;
21
+ }
22
+
23
+ interface AskConfigLoadResult {
24
+ config: AskConfig;
25
+ notice?: AskConfigNotice;
26
+ }
27
+
28
+ export class AskConfigStore {
29
+ private config?: AskConfig;
30
+ private loadPromise?: Promise<AskConfigLoadResult>;
31
+ private notice?: AskConfigNotice;
32
+ private readonly listeners = new Set<(config: AskConfig) => void>();
33
+ private readonly configPath: string;
34
+ private readonly legacyConfigPaths: string[];
35
+
36
+ constructor(configPath?: string, legacyConfigPaths?: string[]) {
37
+ this.configPath = configPath ?? getAskConfigPath();
38
+ this.legacyConfigPaths = (
39
+ legacyConfigPaths ?? (configPath ? [] : getLegacyAskConfigPaths())
40
+ ).filter((path) => path !== this.configPath);
41
+ }
42
+
43
+ subscribe(onChange: (config: AskConfig) => void): () => void {
44
+ this.listeners.add(onChange);
45
+ return () => {
46
+ this.listeners.delete(onChange);
47
+ };
48
+ }
49
+
50
+ async ensureLoaded(): Promise<AskConfigLoadResult> {
51
+ if (this.config) {
52
+ return { config: this.config, notice: this.notice };
53
+ }
54
+ if (!this.loadPromise) {
55
+ this.loadPromise = this.loadFromDisk();
56
+ }
57
+ const result = await this.loadPromise;
58
+ this.config = result.config;
59
+ this.notice = result.notice;
60
+ this.loadPromise = undefined;
61
+ return result;
62
+ }
63
+
64
+ async getConfig(): Promise<AskConfig> {
65
+ return (await this.ensureLoaded()).config;
66
+ }
67
+
68
+ async save(config: AskConfig | Partial<AskConfig>): Promise<AskConfig> {
69
+ const normalized = normalizeAskConfig(config);
70
+ const content = JSON.stringify(
71
+ toAskConfigFileV5(normalized),
72
+ null,
73
+ 2
74
+ ).concat("\n");
75
+ try {
76
+ await mkdir(dirname(this.configPath), { recursive: true });
77
+ await writeFile(this.configPath, content, "utf-8");
78
+ } catch (error) {
79
+ throw createConfigSaveError(this.configPath, error);
80
+ }
81
+ this.setConfig(normalized);
82
+ return normalized;
83
+ }
84
+
85
+ setConfig(config: AskConfig): void {
86
+ this.config = normalizeAskConfig(config);
87
+ this.notice = undefined;
88
+ for (const listener of this.listeners) {
89
+ listener(this.config);
90
+ }
91
+ }
92
+
93
+ private async loadFromDisk(): Promise<AskConfigLoadResult> {
94
+ const content = await this.readDiskConfig();
95
+ if (content === undefined) {
96
+ return this.loadMissingConfig();
97
+ }
98
+
99
+ const parsed = parseJson(content);
100
+ if (!parsed.ok) {
101
+ return this.loadDefaultsWithNotice(INVALID_CONFIG_NOTICE);
102
+ }
103
+
104
+ return this.loadParsedConfig(parsed.value);
105
+ }
106
+
107
+ private async readDiskConfig(): Promise<string | undefined> {
108
+ for (const path of [this.configPath, ...this.legacyConfigPaths]) {
109
+ const content = await readConfigFileIfPresent(path);
110
+ if (content !== undefined) {
111
+ return content;
112
+ }
113
+ }
114
+ }
115
+
116
+ private async loadMissingConfig(): Promise<AskConfigLoadResult> {
117
+ const config = normalizeAskConfig(DEFAULT_ASK_CONFIG);
118
+ try {
119
+ await this.save(config);
120
+ return { config };
121
+ } catch (saveError) {
122
+ return {
123
+ config,
124
+ notice: {
125
+ kind: "warning",
126
+ text: getErrorMessage(saveError),
127
+ },
128
+ };
129
+ }
130
+ }
131
+
132
+ private loadParsedConfig(parsed: unknown): AskConfigLoadResult {
133
+ try {
134
+ const migrated = migrateAskConfig(parsed);
135
+ return {
136
+ config: migrated.config,
137
+ notice: migrated.notice
138
+ ? {
139
+ kind: "error",
140
+ text: migrated.notice,
141
+ }
142
+ : undefined,
143
+ };
144
+ } catch (error) {
145
+ if (error instanceof AskConfigMigrationError) {
146
+ return this.loadDefaultsWithNotice(
147
+ error.reason === "migration_failed"
148
+ ? MIGRATION_FAILED_NOTICE
149
+ : INVALID_CONFIG_NOTICE
150
+ );
151
+ }
152
+ throw error;
153
+ }
154
+ }
155
+
156
+ private loadDefaultsWithNotice(text: string): AskConfigLoadResult {
157
+ return {
158
+ config: normalizeAskConfig(DEFAULT_ASK_CONFIG),
159
+ notice: {
160
+ kind: "error",
161
+ text,
162
+ },
163
+ };
164
+ }
165
+ }
166
+
167
+ let askConfigStore: AskConfigStore | undefined;
168
+
169
+ export function getAskConfigStore(): AskConfigStore {
170
+ askConfigStore ??= new AskConfigStore();
171
+ return askConfigStore;
172
+ }
173
+
174
+ export function resetAskConfigStore(): void {
175
+ askConfigStore = undefined;
176
+ }
177
+
178
+ export function getAskConfigPath(): string {
179
+ return join(getAgentDir(), "extensions", "eko24ive-pi-ask.json");
180
+ }
181
+
182
+ export function getLegacyAskConfigPaths(): string[] {
183
+ return [join(getAgentDir(), "eko24ive-pi-ask.json")];
184
+ }
185
+
186
+ function createConfigSaveError(path: string, error: unknown): Error {
187
+ const detail = getErrorMessage(error);
188
+ return new Error(
189
+ `Unable to save ask config at ${path}. The file may be read-only or managed outside pi-ask; edit it manually and run /reload. ${detail}`
190
+ );
191
+ }
192
+
193
+ function parseJson(
194
+ content: string
195
+ ): { ok: true; value: unknown } | { ok: false } {
196
+ try {
197
+ return { ok: true, value: JSON.parse(content) };
198
+ } catch {
199
+ return { ok: false };
200
+ }
201
+ }
202
+
203
+ async function readConfigFileIfPresent(
204
+ path: string
205
+ ): Promise<string | undefined> {
206
+ try {
207
+ return await readFile(path, "utf-8");
208
+ } catch (error) {
209
+ if (isMissingFileError(error)) {
210
+ return;
211
+ }
212
+ throw error;
213
+ }
214
+ }
215
+
216
+ function getErrorMessage(error: unknown): string {
217
+ return error instanceof Error ? error.message : String(error);
218
+ }
219
+
220
+ function isMissingFileError(error: unknown): boolean {
221
+ return (
222
+ !!error &&
223
+ typeof error === "object" &&
224
+ "code" in error &&
225
+ error.code === "ENOENT"
226
+ );
227
+ }