@oh-my-pi/pi-coding-agent 4.5.0 → 4.7.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 (31) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/package.json +5 -5
  3. package/src/cli/config-cli.ts +344 -0
  4. package/src/core/agent-session.ts +112 -1
  5. package/src/core/extensions/types.ts +2 -0
  6. package/src/core/hooks/loader.ts +6 -3
  7. package/src/core/hooks/tool-wrapper.ts +1 -0
  8. package/src/core/session-manager.ts +4 -1
  9. package/src/core/settings-manager.ts +43 -0
  10. package/src/core/tools/ask.ts +272 -99
  11. package/src/core/tools/index.ts +1 -1
  12. package/src/core/tools/schema-validation.test.ts +30 -0
  13. package/src/core/tools/task/model-resolver.ts +28 -2
  14. package/src/main.ts +12 -0
  15. package/src/modes/interactive/components/hook-editor.ts +1 -1
  16. package/src/modes/interactive/components/hook-message.ts +5 -0
  17. package/src/modes/interactive/components/hook-selector.ts +3 -1
  18. package/src/modes/interactive/components/index.ts +1 -0
  19. package/src/modes/interactive/components/read-tool-group.ts +12 -4
  20. package/src/modes/interactive/components/settings-defs.ts +9 -0
  21. package/src/modes/interactive/components/todo-display.ts +1 -1
  22. package/src/modes/interactive/components/todo-reminder.ts +42 -0
  23. package/src/modes/interactive/controllers/command-controller.ts +12 -0
  24. package/src/modes/interactive/controllers/event-controller.ts +18 -8
  25. package/src/modes/interactive/controllers/extension-ui-controller.ts +9 -2
  26. package/src/modes/interactive/controllers/input-controller.ts +48 -16
  27. package/src/modes/interactive/controllers/selector-controller.ts +38 -8
  28. package/src/modes/interactive/interactive-mode.ts +30 -4
  29. package/src/modes/interactive/types.ts +5 -1
  30. package/src/modes/rpc/rpc-mode.ts +11 -3
  31. package/src/prompts/tools/ask.md +14 -0
@@ -26,6 +26,7 @@ export interface RetrySettings {
26
26
 
27
27
  export interface SkillsSettings {
28
28
  enabled?: boolean; // default: true
29
+ enableSkillCommands?: boolean; // default: true - register skills as /skill:name commands
29
30
  enableCodexUser?: boolean; // default: true
30
31
  enableClaudeUser?: boolean; // default: true
31
32
  enableClaudeProject?: boolean; // default: true
@@ -123,6 +124,11 @@ export interface TtsrSettings {
123
124
  repeatGap?: number; // default: 10
124
125
  }
125
126
 
127
+ export interface TodoCompletionSettings {
128
+ enabled?: boolean; // default: false - warn agent when it stops with incomplete todos
129
+ maxReminders?: number; // default: 3 - maximum reminders before giving up
130
+ }
131
+
126
132
  export interface VoiceSettings {
127
133
  enabled?: boolean; // default: false
128
134
  transcriptionModel?: string; // default: "whisper-1"
@@ -206,6 +212,7 @@ export interface Settings {
206
212
  lsp?: LspSettings;
207
213
  edit?: EditSettings;
208
214
  ttsr?: TtsrSettings;
215
+ todoCompletion?: TodoCompletionSettings;
209
216
  voice?: VoiceSettings;
210
217
  providers?: ProviderSettings;
211
218
  disabledProviders?: string[]; // Discovery provider IDs that are disabled
@@ -766,6 +773,37 @@ export class SettingsManager {
766
773
  };
767
774
  }
768
775
 
776
+ getTodoCompletionSettings(): { enabled: boolean; maxReminders: number } {
777
+ return {
778
+ enabled: this.settings.todoCompletion?.enabled ?? false,
779
+ maxReminders: this.settings.todoCompletion?.maxReminders ?? 3,
780
+ };
781
+ }
782
+
783
+ getTodoCompletionEnabled(): boolean {
784
+ return this.settings.todoCompletion?.enabled ?? false;
785
+ }
786
+
787
+ async setTodoCompletionEnabled(enabled: boolean): Promise<void> {
788
+ if (!this.globalSettings.todoCompletion) {
789
+ this.globalSettings.todoCompletion = {};
790
+ }
791
+ this.globalSettings.todoCompletion.enabled = enabled;
792
+ await this.save();
793
+ }
794
+
795
+ getTodoCompletionMaxReminders(): number {
796
+ return this.settings.todoCompletion?.maxReminders ?? 3;
797
+ }
798
+
799
+ async setTodoCompletionMaxReminders(maxReminders: number): Promise<void> {
800
+ if (!this.globalSettings.todoCompletion) {
801
+ this.globalSettings.todoCompletion = {};
802
+ }
803
+ this.globalSettings.todoCompletion.maxReminders = maxReminders;
804
+ await this.save();
805
+ }
806
+
769
807
  getThinkingBudgets(): ThinkingBudgetsSettings | undefined {
770
808
  return this.settings.thinkingBudgets;
771
809
  }
@@ -821,6 +859,7 @@ export class SettingsManager {
821
859
  getSkillsSettings(): Required<SkillsSettings> {
822
860
  return {
823
861
  enabled: this.settings.skills?.enabled ?? true,
862
+ enableSkillCommands: this.settings.skills?.enableSkillCommands ?? true,
824
863
  enableCodexUser: this.settings.skills?.enableCodexUser ?? true,
825
864
  enableClaudeUser: this.settings.skills?.enableClaudeUser ?? true,
826
865
  enableClaudeProject: this.settings.skills?.enableClaudeProject ?? true,
@@ -832,6 +871,10 @@ export class SettingsManager {
832
871
  };
833
872
  }
834
873
 
874
+ getEnableSkillCommands(): boolean {
875
+ return this.settings.skills?.enableSkillCommands ?? true;
876
+ }
877
+
835
878
  getCommandsSettings(): Required<CommandsSettings> {
836
879
  return {
837
880
  enableClaudeUser: this.settings.commands?.enableClaudeUser ?? true,
@@ -34,17 +34,31 @@ const OptionItem = Type.Object({
34
34
  label: Type.String({ description: "Display label for this option" }),
35
35
  });
36
36
 
37
+ const QuestionItem = Type.Object({
38
+ id: Type.String({ description: "Short identifier for this question (e.g., 'auth', 'cache')" }),
39
+ question: Type.String({ description: "The question text" }),
40
+ options: Type.Array(OptionItem, { description: "Options for this question" }),
41
+ multi: Type.Optional(Type.Boolean({ description: "Allow multiple selections for this question" })),
42
+ });
43
+
37
44
  const askSchema = Type.Object({
38
- question: Type.String({ description: "The question to ask the user" }),
39
- options: Type.Array(OptionItem, { description: "Available options for the user to choose from." }),
45
+ question: Type.Optional(Type.String({ description: "The question to ask the user" })),
46
+ options: Type.Optional(Type.Array(OptionItem, { description: "Available options for the user to choose from." })),
40
47
  multi: Type.Optional(
41
48
  Type.Boolean({
42
49
  description: "Allow multiple options to be selected (default: false)",
43
50
  }),
44
51
  ),
52
+ questions: Type.Optional(
53
+ Type.Array(QuestionItem, {
54
+ description: "Multiple questions to ask in sequence, each with their own options",
55
+ }),
56
+ ),
45
57
  });
46
58
 
47
- export interface AskToolDetails {
59
+ /** Result for a single question */
60
+ export interface QuestionResult {
61
+ id: string;
48
62
  question: string;
49
63
  options: string[];
50
64
  multi: boolean;
@@ -52,6 +66,17 @@ export interface AskToolDetails {
52
66
  customInput?: string;
53
67
  }
54
68
 
69
+ export interface AskToolDetails {
70
+ /** Single question mode (backwards compatible) */
71
+ question?: string;
72
+ options?: string[];
73
+ multi?: boolean;
74
+ selectedOptions?: string[];
75
+ customInput?: string;
76
+ /** Multi-part question mode */
77
+ results?: QuestionResult[];
78
+ }
79
+
55
80
  // =============================================================================
56
81
  // Constants
57
82
  // =============================================================================
@@ -61,10 +86,123 @@ function getDoneOptionLabel(): string {
61
86
  return `${theme.status.success} Done selecting`;
62
87
  }
63
88
 
89
+ // =============================================================================
90
+ // Question Selection Logic
91
+ // =============================================================================
92
+
93
+ interface SelectionResult {
94
+ selectedOptions: string[];
95
+ customInput?: string;
96
+ }
97
+
98
+ interface UIContext {
99
+ select(prompt: string, options: string[], options_?: { initialIndex?: number }): Promise<string | undefined>;
100
+ input(prompt: string): Promise<string | undefined>;
101
+ }
102
+
103
+ async function askSingleQuestion(
104
+ ui: UIContext,
105
+ question: string,
106
+ optionLabels: string[],
107
+ multi: boolean,
108
+ ): Promise<SelectionResult> {
109
+ const doneLabel = getDoneOptionLabel();
110
+ let selectedOptions: string[] = [];
111
+ let customInput: string | undefined;
112
+
113
+ if (multi) {
114
+ const selected = new Set<string>();
115
+ let cursorIndex = 0;
116
+
117
+ while (true) {
118
+ const opts: string[] = [];
119
+
120
+ for (const opt of optionLabels) {
121
+ const checkbox = selected.has(opt) ? theme.checkbox.checked : theme.checkbox.unchecked;
122
+ opts.push(`${checkbox} ${opt}`);
123
+ }
124
+
125
+ // Done after options, before Other - so cursor stays on options after toggle
126
+ if (selected.size > 0) {
127
+ opts.push(doneLabel);
128
+ }
129
+ opts.push(OTHER_OPTION);
130
+
131
+ const prefix = selected.size > 0 ? `(${selected.size} selected) ` : "";
132
+ const choice = await ui.select(`${prefix}${question}`, opts, { initialIndex: cursorIndex });
133
+
134
+ if (choice === undefined || choice === doneLabel) break;
135
+
136
+ if (choice === OTHER_OPTION) {
137
+ const input = await ui.input("Enter your response:");
138
+ if (input) customInput = input;
139
+ break;
140
+ }
141
+
142
+ // Find which index was selected and update cursor position
143
+ const selectedIdx = opts.indexOf(choice);
144
+ if (selectedIdx >= 0) {
145
+ cursorIndex = selectedIdx;
146
+ }
147
+
148
+ const checkedPrefix = `${theme.checkbox.checked} `;
149
+ const uncheckedPrefix = `${theme.checkbox.unchecked} `;
150
+ let opt: string | undefined;
151
+ if (choice.startsWith(checkedPrefix)) {
152
+ opt = choice.slice(checkedPrefix.length);
153
+ } else if (choice.startsWith(uncheckedPrefix)) {
154
+ opt = choice.slice(uncheckedPrefix.length);
155
+ }
156
+ if (opt) {
157
+ if (selected.has(opt)) {
158
+ selected.delete(opt);
159
+ } else {
160
+ selected.add(opt);
161
+ }
162
+ }
163
+ }
164
+ selectedOptions = Array.from(selected);
165
+ } else {
166
+ const choice = await ui.select(question, [...optionLabels, OTHER_OPTION]);
167
+ if (choice === OTHER_OPTION) {
168
+ const input = await ui.input("Enter your response:");
169
+ if (input) customInput = input;
170
+ } else if (choice) {
171
+ selectedOptions = [choice];
172
+ }
173
+ }
174
+
175
+ return { selectedOptions, customInput };
176
+ }
177
+
178
+ function formatQuestionResult(result: QuestionResult): string {
179
+ if (result.customInput) {
180
+ return `${result.id}: "${result.customInput}"`;
181
+ }
182
+ if (result.selectedOptions.length > 0) {
183
+ return result.multi
184
+ ? `${result.id}: [${result.selectedOptions.join(", ")}]`
185
+ : `${result.id}: ${result.selectedOptions[0]}`;
186
+ }
187
+ return `${result.id}: (cancelled)`;
188
+ }
189
+
64
190
  // =============================================================================
65
191
  // Tool Implementation
66
192
  // =============================================================================
67
193
 
194
+ interface AskParams {
195
+ question?: string;
196
+ options?: Array<{ label: string }>;
197
+ multi?: boolean;
198
+ questions?: Array<{
199
+ id: string;
200
+ question: string;
201
+ options: Array<{ label: string }>;
202
+ multi?: boolean;
203
+ }>;
204
+ }
205
+
68
206
  export function createAskTool(session: ToolSession): null | AgentTool<typeof askSchema, AskToolDetails> {
69
207
  if (!session.hasUI) {
70
208
  return null;
@@ -77,99 +215,66 @@ export function createAskTool(session: ToolSession): null | AgentTool<typeof ask
77
215
 
78
216
  async execute(
79
217
  _toolCallId: string,
80
- params: { question: string; options: Array<{ label: string }>; multi?: boolean },
218
+ params: AskParams,
81
219
  _signal?: AbortSignal,
82
220
  _onUpdate?: AgentToolUpdateCallback<AskToolDetails>,
83
221
  context?: AgentToolContext,
84
222
  ) {
85
- const { question, options, multi = false } = params;
86
- const optionLabels = options.map((o) => o.label);
87
- const doneLabel = getDoneOptionLabel();
88
-
89
- // Headless fallback - return error if no UI available
223
+ // Headless fallback
90
224
  if (!context?.hasUI || !context.ui) {
91
225
  return {
92
- content: [
93
- {
94
- type: "text" as const,
95
- text: "Error: User prompt requires interactive mode",
96
- },
97
- ],
98
- details: {
99
- question,
100
- options: optionLabels,
101
- multi,
102
- selectedOptions: [],
103
- },
226
+ content: [{ type: "text" as const, text: "Error: User prompt requires interactive mode" }],
227
+ details: {},
104
228
  };
105
229
  }
106
230
 
107
231
  const { ui } = context;
108
- let selectedOptions: string[] = [];
109
- let customInput: string | undefined;
110
-
111
- if (multi) {
112
- // Multi-select: show checkboxes in the label to indicate selection state
113
- const selected = new Set<string>();
114
-
115
- while (true) {
116
- // Build options with checkbox indicators
117
- const opts: string[] = [];
118
-
119
- // Add "Done" option if any selected
120
- if (selected.size > 0) {
121
- opts.push(doneLabel);
122
- }
123
-
124
- // Add all options with checkbox prefix
125
- for (const opt of optionLabels) {
126
- const checkbox = selected.has(opt) ? theme.checkbox.checked : theme.checkbox.unchecked;
127
- opts.push(`${checkbox} ${opt}`);
128
- }
129
232
 
130
- // Add "Other" option
131
- opts.push(OTHER_OPTION);
233
+ // Multi-part questions mode
234
+ if (params.questions && params.questions.length > 0) {
235
+ const results: QuestionResult[] = [];
236
+
237
+ for (const q of params.questions) {
238
+ const optionLabels = q.options.map((o) => o.label);
239
+ const { selectedOptions, customInput } = await askSingleQuestion(
240
+ ui,
241
+ q.question,
242
+ optionLabels,
243
+ q.multi ?? false,
244
+ );
245
+
246
+ results.push({
247
+ id: q.id,
248
+ question: q.question,
249
+ options: optionLabels,
250
+ multi: q.multi ?? false,
251
+ selectedOptions,
252
+ customInput,
253
+ });
254
+ }
132
255
 
133
- const prefix = selected.size > 0 ? `(${selected.size} selected) ` : "";
134
- const choice = await ui.select(`${prefix}${question}`, opts);
256
+ const details: AskToolDetails = { results };
257
+ const responseLines = results.map(formatQuestionResult);
258
+ const responseText = `User answers:\n${responseLines.join("\n")}`;
135
259
 
136
- if (choice === undefined || choice === doneLabel) break;
260
+ return { content: [{ type: "text" as const, text: responseText }], details };
261
+ }
137
262
 
138
- if (choice === OTHER_OPTION) {
139
- const input = await ui.input("Enter your response:");
140
- if (input) customInput = input;
141
- break;
142
- }
263
+ // Single question mode (backwards compatible)
264
+ const question = params.question ?? "";
265
+ const options = params.options ?? [];
266
+ const multi = params.multi ?? false;
267
+ const optionLabels = options.map((o) => o.label);
143
268
 
144
- // Toggle selection - extract the actual option name
145
- const checkedPrefix = `${theme.checkbox.checked} `;
146
- const uncheckedPrefix = `${theme.checkbox.unchecked} `;
147
- let opt: string | undefined;
148
- if (choice.startsWith(checkedPrefix)) {
149
- opt = choice.slice(checkedPrefix.length);
150
- } else if (choice.startsWith(uncheckedPrefix)) {
151
- opt = choice.slice(uncheckedPrefix.length);
152
- }
153
- if (opt) {
154
- if (selected.has(opt)) {
155
- selected.delete(opt);
156
- } else {
157
- selected.add(opt);
158
- }
159
- }
160
- }
161
- selectedOptions = Array.from(selected);
162
- } else {
163
- // Single select with "Other" option
164
- const choice = await ui.select(question, [...optionLabels, OTHER_OPTION]);
165
- if (choice === OTHER_OPTION) {
166
- const input = await ui.input("Enter your response:");
167
- if (input) customInput = input;
168
- } else if (choice) {
169
- selectedOptions = [choice];
170
- }
269
+ if (!question || optionLabels.length === 0) {
270
+ return {
271
+ content: [{ type: "text" as const, text: "Error: question and options are required" }],
272
+ details: {},
273
+ };
171
274
  }
172
275
 
276
+ const { selectedOptions, customInput } = await askSingleQuestion(ui, question, optionLabels, multi);
277
+
173
278
  const details: AskToolDetails = {
174
279
  question,
175
280
  options: optionLabels,
@@ -207,21 +312,59 @@ export const askTool = createAskTool({
207
312
  // =============================================================================
208
313
 
209
314
  interface AskRenderArgs {
210
- question: string;
315
+ question?: string;
211
316
  options?: Array<{ label: string }>;
212
317
  multi?: boolean;
318
+ questions?: Array<{
319
+ id: string;
320
+ question: string;
321
+ options: Array<{ label: string }>;
322
+ multi?: boolean;
323
+ }>;
213
324
  }
214
325
 
215
326
  export const askToolRenderer = {
216
327
  renderCall(args: AskRenderArgs, uiTheme: Theme): Component {
217
328
  const ui = createToolUIKit(uiTheme);
329
+ const label = ui.title("Ask");
330
+
331
+ // Multi-part questions
332
+ if (args.questions && args.questions.length > 0) {
333
+ let text = `${label} ${uiTheme.fg("muted", `${args.questions.length} questions`)}`;
334
+
335
+ for (let i = 0; i < args.questions.length; i++) {
336
+ const q = args.questions[i];
337
+ const isLastQ = i === args.questions.length - 1;
338
+ const qBranch = isLastQ ? uiTheme.tree.last : uiTheme.tree.branch;
339
+ const continuation = isLastQ ? " " : uiTheme.tree.vertical;
340
+
341
+ // Question line with metadata
342
+ const meta: string[] = [];
343
+ if (q.multi) meta.push("multi");
344
+ if (q.options?.length) meta.push(`options:${q.options.length}`);
345
+ const metaStr = meta.length > 0 ? uiTheme.fg("dim", ` · ${meta.join(" · ")}`) : "";
346
+
347
+ text += `\n ${uiTheme.fg("dim", qBranch)} ${uiTheme.fg("dim", `[${q.id}]`)} ${uiTheme.fg("accent", q.question)}${metaStr}`;
348
+
349
+ // Options under question
350
+ if (q.options?.length) {
351
+ for (let j = 0; j < q.options.length; j++) {
352
+ const opt = q.options[j];
353
+ const isLastOpt = j === q.options.length - 1;
354
+ const optBranch = isLastOpt ? uiTheme.tree.last : uiTheme.tree.branch;
355
+ text += `\n ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${uiTheme.fg("muted", opt.label)}`;
356
+ }
357
+ }
358
+ }
359
+ return new Text(text, 0, 0);
360
+ }
361
+
362
+ // Single question
218
363
  if (!args.question) {
219
364
  return new Text(ui.errorMessage("No question provided"), 0, 0);
220
365
  }
221
366
 
222
- const label = ui.title("Ask");
223
367
  let text = `${label} ${uiTheme.fg("accent", args.question)}`;
224
-
225
368
  const meta: string[] = [];
226
369
  if (args.multi) meta.push("multi");
227
370
  if (args.options?.length) meta.push(`options:${args.options.length}`);
@@ -250,7 +393,47 @@ export const askToolRenderer = {
250
393
  return new Text(txt?.type === "text" && txt.text ? txt.text : "", 0, 0);
251
394
  }
252
395
 
253
- const hasSelection = details.customInput || details.selectedOptions.length > 0;
396
+ // Multi-part results
397
+ if (details.results && details.results.length > 0) {
398
+ const lines: string[] = [];
399
+
400
+ for (const r of details.results) {
401
+ const hasSelection = r.customInput || r.selectedOptions.length > 0;
402
+ const statusIcon = hasSelection
403
+ ? uiTheme.styledSymbol("status.success", "success")
404
+ : uiTheme.styledSymbol("status.warning", "warning");
405
+
406
+ lines.push(`${statusIcon} ${uiTheme.fg("dim", `[${r.id}]`)} ${uiTheme.fg("accent", r.question)}`);
407
+
408
+ if (r.customInput) {
409
+ lines.push(
410
+ ` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", r.customInput)}`,
411
+ );
412
+ } else if (r.selectedOptions.length > 0) {
413
+ for (let j = 0; j < r.selectedOptions.length; j++) {
414
+ const isLast = j === r.selectedOptions.length - 1;
415
+ const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
416
+ lines.push(
417
+ ` ${uiTheme.fg("dim", branch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${uiTheme.fg("toolOutput", r.selectedOptions[j])}`,
418
+ );
419
+ }
420
+ } else {
421
+ lines.push(
422
+ ` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`,
423
+ );
424
+ }
425
+ }
426
+
427
+ return new Text(lines.join("\n"), 0, 0);
428
+ }
429
+
430
+ // Single question result
431
+ if (!details.question) {
432
+ const txt = result.content[0];
433
+ return new Text(txt?.type === "text" && txt.text ? txt.text : "", 0, 0);
434
+ }
435
+
436
+ const hasSelection = details.customInput || (details.selectedOptions && details.selectedOptions.length > 0);
254
437
  const statusIcon = hasSelection
255
438
  ? uiTheme.styledSymbol("status.success", "success")
256
439
  : uiTheme.styledSymbol("status.warning", "warning");
@@ -258,25 +441,15 @@ export const askToolRenderer = {
258
441
  let text = `${statusIcon} ${uiTheme.fg("accent", details.question)}`;
259
442
 
260
443
  if (details.customInput) {
261
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol(
262
- "status.success",
263
- "success",
264
- )} ${uiTheme.fg("toolOutput", details.customInput)}`;
265
- } else if (details.selectedOptions.length > 0) {
266
- const selected = details.selectedOptions;
267
- for (let i = 0; i < selected.length; i++) {
268
- const isLast = i === selected.length - 1;
444
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", details.customInput)}`;
445
+ } else if (details.selectedOptions && details.selectedOptions.length > 0) {
446
+ for (let i = 0; i < details.selectedOptions.length; i++) {
447
+ const isLast = i === details.selectedOptions.length - 1;
269
448
  const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
270
- text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg(
271
- "success",
272
- uiTheme.checkbox.checked,
273
- )} ${uiTheme.fg("toolOutput", selected[i])}`;
449
+ text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${uiTheme.fg("toolOutput", details.selectedOptions[i])}`;
274
450
  }
275
451
  } else {
276
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol(
277
- "status.warning",
278
- "warning",
279
- )} ${uiTheme.fg("warning", "Cancelled")}`;
452
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
280
453
  }
281
454
 
282
455
  return new Text(text, 0, 0);
@@ -1,5 +1,5 @@
1
1
  export { type AskToolDetails, askTool, createAskTool } from "./ask";
2
- export { type BashOperations, type BashToolDetails, createBashTool } from "./bash";
2
+ export { type BashOperations, type BashToolDetails, type BashToolOptions, createBashTool } from "./bash";
3
3
  export { type CalculatorToolDetails, createCalculatorTool } from "./calculator";
4
4
  export { createCompleteTool } from "./complete";
5
5
  export { createEditTool, type EditToolDetails } from "./edit";
@@ -198,6 +198,36 @@ describe("sanitizeSchemaForGoogle", () => {
198
198
  expect(sanitizeSchemaForGoogle(true)).toBe(true);
199
199
  expect(sanitizeSchemaForGoogle(null)).toBe(null);
200
200
  });
201
+
202
+ it("preserves property names that match schema keywords (e.g., 'pattern')", () => {
203
+ const schema = {
204
+ type: "object",
205
+ properties: {
206
+ pattern: { type: "string", description: "The search pattern" },
207
+ format: { type: "string", description: "Output format" },
208
+ },
209
+ required: ["pattern"],
210
+ };
211
+ const sanitized = sanitizeSchemaForGoogle(schema) as Record<string, unknown>;
212
+ const props = sanitized.properties as Record<string, unknown>;
213
+ expect(props.pattern).toEqual({ type: "string", description: "The search pattern" });
214
+ expect(props.format).toEqual({ type: "string", description: "Output format" });
215
+ expect(sanitized.required).toEqual(["pattern"]);
216
+ });
217
+
218
+ it("still strips schema keywords from non-properties contexts", () => {
219
+ const schema = {
220
+ type: "string",
221
+ pattern: "^[a-z]+$",
222
+ format: "email",
223
+ minLength: 1,
224
+ };
225
+ const sanitized = sanitizeSchemaForGoogle(schema) as Record<string, unknown>;
226
+ expect(sanitized.pattern).toBeUndefined();
227
+ expect(sanitized.format).toBeUndefined();
228
+ expect(sanitized.minLength).toBeUndefined();
229
+ expect(sanitized.type).toBe("string");
230
+ });
201
231
  });
202
232
 
203
233
  describe("tool schema validation (post-sanitization)", () => {
@@ -118,6 +118,15 @@ function getModelId(fullModel: string): string {
118
118
  return slashIdx > 0 ? fullModel.slice(slashIdx + 1) : fullModel;
119
119
  }
120
120
 
121
+ /**
122
+ * Extract provider from "provider/modelId" format.
123
+ * Returns undefined if no provider prefix.
124
+ */
125
+ function getProvider(fullModel: string): string | undefined {
126
+ const slashIdx = fullModel.indexOf("/");
127
+ return slashIdx > 0 ? fullModel.slice(0, slashIdx) : undefined;
128
+ }
129
+
121
130
  /**
122
131
  * Resolve a fuzzy model pattern to "provider/modelId" format.
123
132
  *
@@ -165,8 +174,25 @@ export async function resolveModelPattern(
165
174
  const exactId = models.find((m) => getModelId(m).toLowerCase() === p.toLowerCase());
166
175
  if (exactId) return exactId;
167
176
 
168
- // Try fuzzy match on model ID (substring)
169
- const fuzzyMatch = models.find((m) => getModelId(m).toLowerCase().includes(p.toLowerCase()));
177
+ // Check if pattern has provider prefix (e.g., "zai/glm-4.7")
178
+ const patternProvider = getProvider(p);
179
+ const patternModelId = getModelId(p);
180
+
181
+ // If pattern has provider prefix, fuzzy match must stay within that provider
182
+ // (don't cross provider boundaries when user explicitly specifies provider)
183
+ if (patternProvider) {
184
+ const providerFuzzyMatch = models.find(
185
+ (m) =>
186
+ getProvider(m)?.toLowerCase() === patternProvider.toLowerCase() &&
187
+ getModelId(m).toLowerCase().includes(patternModelId.toLowerCase()),
188
+ );
189
+ if (providerFuzzyMatch) return providerFuzzyMatch;
190
+ // No match in specified provider - don't fall through to other providers
191
+ continue;
192
+ }
193
+
194
+ // No provider prefix - fall back to general fuzzy match on model ID (substring)
195
+ const fuzzyMatch = models.find((m) => getModelId(m).toLowerCase().includes(patternModelId.toLowerCase()));
170
196
  if (fuzzyMatch) return fuzzyMatch;
171
197
  }
172
198
 
package/src/main.ts CHANGED
@@ -10,6 +10,7 @@ import { join, resolve } from "node:path";
10
10
  import { type ImageContent, supportsXhigh } from "@oh-my-pi/pi-ai";
11
11
  import chalk from "chalk";
12
12
  import { type Args, parseArgs, printHelp } from "./cli/args";
13
+ import { parseConfigArgs, printConfigHelp, runConfigCommand } from "./cli/config-cli";
13
14
  import { processFileArguments } from "./cli/file-processor";
14
15
  import { listModels } from "./cli/list-models";
15
16
  import { parsePluginArgs, printPluginHelp, runPluginCommand } from "./cli/plugin-cli";
@@ -418,6 +419,17 @@ export async function main(args: string[]) {
418
419
  return;
419
420
  }
420
421
 
422
+ // Handle config subcommand
423
+ const configCmd = parseConfigArgs(args);
424
+ if (configCmd) {
425
+ if (args.includes("--help") || args.includes("-h")) {
426
+ printConfigHelp();
427
+ return;
428
+ }
429
+ await runConfigCommand(configCmd);
430
+ return;
431
+ }
432
+
421
433
  const parsed = parseArgs(args);
422
434
  time("parseArgs");
423
435
  await maybeAutoChdir(parsed);
@@ -113,7 +113,7 @@ export class HookEditorComponent extends Container {
113
113
  // Ignore cleanup errors
114
114
  }
115
115
  this.tui.start();
116
- this.tui.requestRender();
116
+ this.tui.requestRender(true);
117
117
  }
118
118
  }
119
119
  }
@@ -36,6 +36,11 @@ export class HookMessageComponent extends Container {
36
36
  }
37
37
  }
38
38
 
39
+ override invalidate(): void {
40
+ super.invalidate();
41
+ this.rebuild();
42
+ }
43
+
39
44
  private rebuild(): void {
40
45
  // Remove previous content component
41
46
  if (this.customComponent) {