@oh-my-pi/pi-coding-agent 4.6.0 → 4.8.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.
@@ -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);
@@ -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);
@@ -21,11 +21,12 @@ import { DynamicBorder } from "./dynamic-border";
21
21
  export interface HookSelectorOptions {
22
22
  tui?: TUI;
23
23
  timeout?: number;
24
+ initialIndex?: number;
24
25
  }
25
26
 
26
27
  export class HookSelectorComponent extends Container {
27
28
  private options: string[];
28
- private selectedIndex = 0;
29
+ private selectedIndex: number;
29
30
  private listContainer: Container;
30
31
  private onSelectCallback: (option: string) => void;
31
32
  private onCancelCallback: () => void;
@@ -43,6 +44,7 @@ export class HookSelectorComponent extends Container {
43
44
  super();
44
45
 
45
46
  this.options = options;
47
+ this.selectedIndex = Math.min(opts?.initialIndex ?? 0, options.length - 1);
46
48
  this.onSelectCallback = onSelect;
47
49
  this.onCancelCallback = onCancel;
48
50
  this.baseTitle = title;
@@ -31,6 +31,7 @@ export { ShowImagesSelectorComponent } from "./show-images-selector";
31
31
  export { StatusLineComponent } from "./status-line";
32
32
  export { ThemeSelectorComponent } from "./theme-selector";
33
33
  export { ThinkingSelectorComponent } from "./thinking-selector";
34
+ export { TodoReminderComponent } from "./todo-reminder";
34
35
  export { ToolExecutionComponent, type ToolExecutionHandle, type ToolExecutionOptions } from "./tool-execution";
35
36
  export { TreeSelectorComponent } from "./tree-selector";
36
37
  export { TtsrNotificationComponent } from "./ttsr-notification";
@@ -74,15 +74,23 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
74
74
 
75
75
  private updateDisplay(): void {
76
76
  const entries = [...this.entries.values()];
77
- const header = `${theme.fg("toolTitle", theme.bold("Read"))}${
78
- entries.length > 1 ? theme.fg("dim", ` (${entries.length})`) : ""
79
- }`;
80
77
 
81
78
  if (entries.length === 0) {
82
- this.text.setText(` ${theme.format.bullet} ${header}`);
79
+ this.text.setText(` ${theme.format.bullet} ${theme.fg("toolTitle", theme.bold("Read"))}`);
83
80
  return;
84
81
  }
85
82
 
83
+ if (entries.length === 1) {
84
+ const entry = entries[0];
85
+ const statusSymbol = this.formatStatus(entry.status);
86
+ const pathDisplay = this.formatPath(entry);
87
+ this.text.setText(
88
+ ` ${theme.format.bullet} ${theme.fg("toolTitle", theme.bold("Read"))} ${pathDisplay} ${statusSymbol}`.trimEnd(),
89
+ );
90
+ return;
91
+ }
92
+
93
+ const header = `${theme.fg("toolTitle", theme.bold("Read"))}${theme.fg("dim", ` (${entries.length})`)}`;
86
94
  const lines = [` ${theme.format.bullet} ${header}`];
87
95
  const total = entries.length;
88
96
  for (const [index, entry] of entries.entries()) {
@@ -97,6 +97,15 @@ export const SETTINGS_DEFS: SettingDef[] = [
97
97
  get: (sm) => sm.getBranchSummaryEnabled(),
98
98
  set: (sm, v) => sm.setBranchSummaryEnabled(v),
99
99
  },
100
+ {
101
+ id: "todoCompletion",
102
+ tab: "config",
103
+ type: "boolean",
104
+ label: "Todo completion",
105
+ description: "Remind agent to complete todos before stopping (up to 3 reminders)",
106
+ get: (sm) => sm.getTodoCompletionEnabled(),
107
+ set: (sm, v) => sm.setTodoCompletionEnabled(v),
108
+ },
100
109
  {
101
110
  id: "showImages",
102
111
  tab: "config",
@@ -98,7 +98,7 @@ export class TodoDisplayComponent {
98
98
  }
99
99
 
100
100
  if (hasMore) {
101
- lines.push(theme.fg("dim", ` ${theme.tree.hook} +${this.todos.length - 5} more (Ctrl+T to expand)`));
101
+ lines.push(theme.fg("dim", ` ${theme.tree.hook} +${this.todos.length - 5} more (Ctrl+T to expand)`));
102
102
  }
103
103
 
104
104
  return lines;
@@ -0,0 +1,42 @@
1
+ import { Box, Container, Spacer, Text } from "@oh-my-pi/pi-tui";
2
+ import type { TodoItem } from "../../../core/tools/todo-write";
3
+ import { theme } from "../theme/theme";
4
+
5
+ /**
6
+ * Component that renders a todo completion reminder notification.
7
+ * Shows when the agent stops with incomplete todos.
8
+ */
9
+ export class TodoReminderComponent extends Container {
10
+ private todos: TodoItem[];
11
+ private attempt: number;
12
+ private maxAttempts: number;
13
+ private box: Box;
14
+
15
+ constructor(todos: TodoItem[], attempt: number, maxAttempts: number) {
16
+ super();
17
+ this.todos = todos;
18
+ this.attempt = attempt;
19
+ this.maxAttempts = maxAttempts;
20
+
21
+ this.addChild(new Spacer(1));
22
+
23
+ this.box = new Box(1, 1, (t) => theme.inverse(theme.fg("warning", t)));
24
+ this.addChild(this.box);
25
+
26
+ this.rebuild();
27
+ }
28
+
29
+ private rebuild(): void {
30
+ this.box.clear();
31
+
32
+ const count = this.todos.length;
33
+ const label = count === 1 ? "todo" : "todos";
34
+ const header = `${theme.icon.warning} ${count} incomplete ${label} - reminder ${this.attempt}/${this.maxAttempts}`;
35
+
36
+ this.box.addChild(new Text(header, 0, 0));
37
+ this.box.addChild(new Spacer(1));
38
+
39
+ const todoList = this.todos.map((t) => ` ${theme.checkbox.unchecked} ${t.content}`).join("\n");
40
+ this.box.addChild(new Text(theme.italic(todoList), 0, 0));
41
+ }
42
+ }
@@ -376,6 +376,7 @@ export class CommandController {
376
376
  this.ctx.chatContainer.addChild(
377
377
  new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1),
378
378
  );
379
+ await this.ctx.reloadTodos();
379
380
  this.ctx.ui.requestRender();
380
381
  }
381
382