@oh-my-pi/pi-coding-agent 15.1.9 → 15.2.1

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 (61) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/dist/types/config/settings-schema.d.ts +10 -0
  3. package/dist/types/eval/py/kernel.d.ts +6 -0
  4. package/dist/types/goals/state.d.ts +1 -1
  5. package/dist/types/goals/tools/goal-tool.d.ts +4 -0
  6. package/dist/types/hashline/parser.d.ts +6 -2
  7. package/dist/types/internal-urls/memory-protocol.d.ts +6 -0
  8. package/dist/types/modes/theme/shimmer.d.ts +27 -0
  9. package/dist/types/slash-commands/helpers/format.d.ts +4 -1
  10. package/dist/types/tools/ast-edit.d.ts +3 -0
  11. package/dist/types/tools/ast-grep.d.ts +3 -0
  12. package/dist/types/tools/find.d.ts +3 -0
  13. package/dist/types/tools/search.d.ts +3 -0
  14. package/dist/types/tui/file-list.d.ts +6 -0
  15. package/dist/types/tui/hyperlink.d.ts +42 -0
  16. package/dist/types/tui/index.d.ts +1 -0
  17. package/dist/types/web/search/providers/utils.d.ts +2 -1
  18. package/package.json +7 -7
  19. package/src/config/settings-schema.ts +12 -0
  20. package/src/config/settings.ts +28 -5
  21. package/src/edit/renderer.ts +5 -3
  22. package/src/eval/py/executor.ts +12 -1
  23. package/src/eval/py/kernel.ts +24 -8
  24. package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
  25. package/src/goals/runtime.ts +9 -3
  26. package/src/goals/state.ts +1 -1
  27. package/src/goals/tools/goal-tool.ts +12 -2
  28. package/src/hashline/diff.ts +1 -1
  29. package/src/hashline/execute.ts +2 -2
  30. package/src/hashline/parser.ts +87 -12
  31. package/src/internal-urls/memory-protocol.ts +1 -1
  32. package/src/modes/interactive-mode.ts +29 -1
  33. package/src/modes/theme/shimmer.ts +79 -0
  34. package/src/prompts/tools/goal.md +7 -2
  35. package/src/session/agent-session.ts +12 -75
  36. package/src/slash-commands/helpers/format.ts +23 -3
  37. package/src/task/executor.ts +115 -19
  38. package/src/tools/ast-edit.ts +39 -6
  39. package/src/tools/ast-grep.ts +38 -6
  40. package/src/tools/find.ts +13 -2
  41. package/src/tools/read.ts +46 -6
  42. package/src/tools/search.ts +447 -265
  43. package/src/tui/file-list.ts +10 -2
  44. package/src/tui/hyperlink.ts +126 -0
  45. package/src/tui/index.ts +1 -0
  46. package/src/web/search/index.ts +13 -9
  47. package/src/web/search/providers/anthropic.ts +3 -1
  48. package/src/web/search/providers/brave.ts +3 -1
  49. package/src/web/search/providers/codex.ts +3 -1
  50. package/src/web/search/providers/exa.ts +3 -1
  51. package/src/web/search/providers/gemini.ts +3 -1
  52. package/src/web/search/providers/jina.ts +3 -1
  53. package/src/web/search/providers/kagi.ts +5 -1
  54. package/src/web/search/providers/kimi.ts +3 -1
  55. package/src/web/search/providers/parallel.ts +5 -1
  56. package/src/web/search/providers/perplexity.ts +5 -1
  57. package/src/web/search/providers/searxng.ts +3 -1
  58. package/src/web/search/providers/synthetic.ts +3 -1
  59. package/src/web/search/providers/tavily.ts +3 -1
  60. package/src/web/search/providers/utils.ts +33 -1
  61. package/src/web/search/providers/zai.ts +3 -1
@@ -15,7 +15,7 @@ import { completionBudgetReport, remainingTokens } from "../runtime";
15
15
  import type { Goal, GoalStatus, GoalToolDetails } from "../state";
16
16
 
17
17
  const goalSchema = z.object({
18
- op: z.enum(["create", "get", "complete"]).describe("goal operation"),
18
+ op: z.enum(["create", "get", "complete", "resume", "drop"]).describe("goal operation"),
19
19
  objective: z.string().describe("goal objective").optional(),
20
20
  token_budget: z.number().int().describe("token budget").optional(),
21
21
  });
@@ -86,7 +86,13 @@ export class GoalTool implements AgentTool<typeof goalSchema, GoalToolDetails> {
86
86
  response = buildGoalToolResponse(created.goal);
87
87
  } else if (params.op === "get") {
88
88
  const state = this.#session.getGoalModeState?.();
89
- response = buildGoalToolResponse(state?.enabled ? state.goal : null);
89
+ response = buildGoalToolResponse(state?.goal ?? null);
90
+ } else if (params.op === "resume") {
91
+ const resumed = await runtime.resumeGoal();
92
+ response = buildGoalToolResponse(resumed.goal);
93
+ } else if (params.op === "drop") {
94
+ const dropped = await runtime.dropGoal();
95
+ response = buildGoalToolResponse(dropped ?? null);
90
96
  } else {
91
97
  const completed = await runtime.completeGoalFromTool();
92
98
  response = buildGoalToolResponse(completed, { includeCompletionReport: true });
@@ -126,6 +132,10 @@ function describeOp(op: string | undefined): string {
126
132
  return "complete";
127
133
  case "get":
128
134
  return "check";
135
+ case "resume":
136
+ return "resume";
137
+ case "drop":
138
+ return "drop";
129
139
  default:
130
140
  return op ?? "?";
131
141
  }
@@ -30,7 +30,7 @@ export async function computeHashlineSectionDiff(
30
30
  const rawContent = await readHashlineFileText(Bun.file(absolutePath), absolutePath, section.path);
31
31
  const { text: content } = stripBom(rawContent);
32
32
  const normalized = normalizeToLF(content);
33
- const result = applyHashlineEdits(normalized, parseHashline(section.diff), options);
33
+ const result = applyHashlineEdits(normalized, parseHashline(section.diff, { path: section.path }), options);
34
34
  if (normalized === result.lines) return { error: `No changes would be made to ${section.path}.` };
35
35
  return generateDiffString(normalized, result.lines);
36
36
  } catch (err) {
@@ -106,7 +106,7 @@ async function preflightHashlineSection(options: ExecuteHashlineSingleOptions &
106
106
  const { session, path: sectionPath, diff } = options;
107
107
 
108
108
  const absolutePath = resolvePlanPath(session, sectionPath);
109
- const { edits } = parseHashlineWithWarnings(diff);
109
+ const { edits } = parseHashlineWithWarnings(diff, { path: sectionPath });
110
110
  enforcePlanModeWrite(session, sectionPath, { op: "update" });
111
111
 
112
112
  const source = await readHashlineFile(absolutePath, sectionPath);
@@ -139,7 +139,7 @@ async function executeHashlineSection(
139
139
  } = options;
140
140
 
141
141
  const absolutePath = resolvePlanPath(session, sourcePath);
142
- const { edits, warnings: parseWarnings } = parseHashlineWithWarnings(diff);
142
+ const { edits, warnings: parseWarnings } = parseHashlineWithWarnings(diff, { path: sourcePath });
143
143
  enforcePlanModeWrite(session, sourcePath, { op: "update" });
144
144
 
145
145
  const source = await readHashlineFile(absolutePath, sourcePath);
@@ -74,22 +74,86 @@ export function cloneCursor(cursor: HashlineCursor): HashlineCursor {
74
74
  if (cursor.kind === "after_anchor") return { kind: "after_anchor", anchor: { ...cursor.anchor } };
75
75
  return cursor;
76
76
  }
77
- /** Returns true when every non-empty payload line starts with `${sep} ` (sep + one space). */
77
+ /**
78
+ * Returns true when every non-empty payload line looks like the `~ TEXT` readability-padding
79
+ * typo: exactly one leading space followed by a non-space character (or a bare single space).
80
+ *
81
+ * Indented file content (Python 4-space, YAML/JSON/Markdown 2-space, etc.) starts with two or
82
+ * more leading spaces, so this heuristic ignores legitimate indentation while still flagging
83
+ * the common `~ beta` mistake that silently corrupts file content with a stray space.
84
+ */
78
85
  function hasUniformSeparatorPadding(payload: string[]): boolean {
79
86
  let any = false;
80
87
  for (const text of payload) {
81
88
  if (text.length === 0) continue;
82
- if (!text.startsWith(" ")) return false;
89
+ if (text.charCodeAt(0) !== 0x20) return false;
90
+ // Two or more leading spaces is real indentation, not separator padding.
91
+ if (text.length > 1 && text.charCodeAt(1) === 0x20) return false;
83
92
  any = true;
84
93
  }
85
94
  return any;
86
95
  }
87
96
 
97
+ /**
98
+ * File extensions where leading single-space indentation is plausible legitimate file content
99
+ * (off-side-rule languages, structured-indent data formats, prose with continuation indent).
100
+ * For these we suppress the separator-padding warning entirely — the heuristic's false-positive
101
+ * cost on a real edit outweighs the rare chance it catches a `~ TEXT` typo.
102
+ */
103
+ const INDENT_SENSITIVE_EXTS: Record<string, true> = {
104
+ ".py": true,
105
+ ".pyi": true,
106
+ ".pyx": true,
107
+ ".pyw": true,
108
+ ".yml": true,
109
+ ".yaml": true,
110
+ ".md": true,
111
+ ".mdx": true,
112
+ ".markdown": true,
113
+ ".rst": true,
114
+ ".adoc": true,
115
+ ".asciidoc": true,
116
+ ".toml": true,
117
+ ".json": true,
118
+ ".jsonc": true,
119
+ ".json5": true,
120
+ ".ndjson": true,
121
+ ".jsonl": true,
122
+ ".tf": true,
123
+ ".tfvars": true,
124
+ ".hcl": true,
125
+ ".nix": true,
126
+ ".coffee": true,
127
+ ".litcoffee": true,
128
+ ".haml": true,
129
+ ".slim": true,
130
+ ".pug": true,
131
+ ".jade": true,
132
+ ".sass": true,
133
+ ".styl": true,
134
+ ".nim": true,
135
+ ".cr": true,
136
+ ".elm": true,
137
+ ".fs": true,
138
+ ".fsi": true,
139
+ ".fsx": true,
140
+ };
141
+
142
+ function isIndentationSensitivePath(path: string | undefined): boolean {
143
+ if (!path) return false;
144
+ const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
145
+ const dot = path.lastIndexOf(".");
146
+ if (dot <= slash) return false;
147
+ const ext = path.slice(dot).toLowerCase();
148
+ return INDENT_SENSITIVE_EXTS[ext] === true;
149
+ }
150
+
88
151
  function collectPayload(
89
152
  lines: string[],
90
153
  startIndex: number,
91
154
  opLineNum: number,
92
155
  requirePayload: boolean,
156
+ checkPadding: boolean,
93
157
  ): { payload: string[]; nextIndex: number; paddingWarning?: string } {
94
158
  const payload: string[] = [];
95
159
  let index = startIndex;
@@ -125,21 +189,32 @@ function collectPayload(
125
189
  if (payload.length === 0 && requirePayload) {
126
190
  throw new Error(`line ${opLineNum}: + and < operations require at least one ${HL_EDIT_SEP}TEXT payload line.`);
127
191
  }
128
- const paddingWarning = hasUniformSeparatorPadding(payload)
129
- ? `line ${opLineNum}: all payload lines start with "${HL_EDIT_SEP} " (separator + space). ` +
130
- `The space becomes file content. Remove it unless the target file requires leading spaces.`
131
- : undefined;
192
+ const paddingWarning =
193
+ checkPadding && hasUniformSeparatorPadding(payload)
194
+ ? `line ${opLineNum}: every payload line begins with exactly one space before non-space content, ` +
195
+ `which looks like a readability gap after "${HL_EDIT_SEP}". The space becomes file content. ` +
196
+ `Drop it unless the file genuinely uses a one-space indent.`
197
+ : undefined;
132
198
  return { payload, nextIndex: index, paddingWarning };
133
199
  }
134
200
 
135
- export function parseHashline(diff: string): HashlineEdit[] {
136
- return parseHashlineWithWarnings(diff).edits;
201
+ export function parseHashline(diff: string, opts: ParseHashlineOptions = {}): HashlineEdit[] {
202
+ return parseHashlineWithWarnings(diff, opts).edits;
203
+ }
204
+
205
+ export interface ParseHashlineOptions {
206
+ /** File path the diff targets. Used to suppress indent-sensitive false-positive warnings. */
207
+ path?: string;
137
208
  }
138
209
 
139
- export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]; warnings: string[] } {
210
+ export function parseHashlineWithWarnings(
211
+ diff: string,
212
+ opts: ParseHashlineOptions = {},
213
+ ): { edits: HashlineEdit[]; warnings: string[] } {
140
214
  const edits: HashlineEdit[] = [];
141
215
  const warnings: string[] = [];
142
216
  const lines = diff.split(/\r?\n/);
217
+ const checkPadding = !isIndentationSensitivePath(opts.path);
143
218
  let editIndex = 0;
144
219
 
145
220
  const pushInsert = (cursor: HashlineCursor, text: string, lineNum: number) => {
@@ -172,7 +247,7 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]
172
247
  const insertBeforeMatch = INSERT_BEFORE_OP_RE.exec(line);
173
248
  if (insertBeforeMatch) {
174
249
  const cursor = parseInsertTarget(insertBeforeMatch[1], lineNum, "before");
175
- const { payload, nextIndex, paddingWarning } = collectPayload(lines, i + 1, lineNum, true);
250
+ const { payload, nextIndex, paddingWarning } = collectPayload(lines, i + 1, lineNum, true, checkPadding);
176
251
  if (paddingWarning) warnings.push(paddingWarning);
177
252
  for (const text of payload) pushInsert(cursor, text, lineNum);
178
253
  i = nextIndex;
@@ -182,7 +257,7 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]
182
257
  const insertAfterMatch = INSERT_AFTER_OP_RE.exec(line);
183
258
  if (insertAfterMatch) {
184
259
  const cursor = parseInsertTarget(insertAfterMatch[1], lineNum, "after");
185
- const { payload, nextIndex, paddingWarning } = collectPayload(lines, i + 1, lineNum, true);
260
+ const { payload, nextIndex, paddingWarning } = collectPayload(lines, i + 1, lineNum, true, checkPadding);
186
261
  if (paddingWarning) warnings.push(paddingWarning);
187
262
  for (const text of payload) pushInsert(cursor, text, lineNum);
188
263
  i = nextIndex;
@@ -201,7 +276,7 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]
201
276
  const replaceMatch = REPLACE_OP_RE.exec(line);
202
277
  if (replaceMatch) {
203
278
  const range = parseRange(replaceMatch[1], lineNum);
204
- const { payload, nextIndex, paddingWarning } = collectPayload(lines, i + 1, lineNum, false);
279
+ const { payload, nextIndex, paddingWarning } = collectPayload(lines, i + 1, lineNum, false, checkPadding);
205
280
  if (paddingWarning) warnings.push(paddingWarning);
206
281
  // `= A..B` with no payload blanks the range to a single empty line.
207
282
  const replacement = payload.length === 0 ? [""] : payload;
@@ -14,7 +14,7 @@ const MEMORY_NAMESPACE = "root";
14
14
  * Each session has its own cwd (possibly a worktree), so subagents and main
15
15
  * may see different roots.
16
16
  */
17
- function memoryRootsFromRegistry(): string[] {
17
+ export function memoryRootsFromRegistry(): string[] {
18
18
  const agentDir = getAgentDir();
19
19
  const roots: string[] = [];
20
20
  for (const ref of AgentRegistry.global().list()) {
@@ -98,6 +98,7 @@ import {
98
98
  } from "./loop-limit";
99
99
  import { OAuthManualInputManager } from "./oauth-manual-input";
100
100
  import { SessionObserverRegistry } from "./session-observer-registry";
101
+ import { type ShimmerPalette, shimmerSegments, shimmerText } from "./theme/shimmer";
101
102
  import type { Theme } from "./theme/theme";
102
103
  import {
103
104
  getEditorTheme,
@@ -110,6 +111,20 @@ import {
110
111
  import type { CompactionQueuedMessage, InteractiveModeContext, SubmittedUserInput, TodoItem, TodoPhase } from "./types";
111
112
  import { UiHelpers } from "./utils/ui-helpers";
112
113
 
114
+ const WORKING_INTERRUPT_HINT = " (esc to interrupt)";
115
+
116
+ const HINT_SHIMMER_PALETTE: ShimmerPalette = {
117
+ low: "dim",
118
+ mid: "muted",
119
+ high: "borderAccent",
120
+ };
121
+
122
+ function renderWorkingMessage(message: string): string {
123
+ if (!message.endsWith(WORKING_INTERRUPT_HINT)) return shimmerText(message, theme);
124
+ const header = message.slice(0, -WORKING_INTERRUPT_HINT.length);
125
+ return shimmerSegments([{ text: header }, { text: WORKING_INTERRUPT_HINT, palette: HINT_SHIMMER_PALETTE }], theme);
126
+ }
127
+
113
128
  const EDITOR_MAX_HEIGHT_MIN = 6;
114
129
  const EDITOR_MAX_HEIGHT_MAX = 18;
115
130
  const EDITOR_RESERVED_ROWS = 12;
@@ -1063,6 +1078,12 @@ export class InteractiveMode implements InteractiveModeContext {
1063
1078
  return;
1064
1079
  }
1065
1080
  if (event.type === "goal_updated") {
1081
+ // Handle drop before clearing goalModeEnabled so #exitGoalMode can
1082
+ // still restore the previous tool set while the flag is true.
1083
+ if (event.state?.goal?.status === "dropped") {
1084
+ await this.#exitGoalMode({ reason: "dropped", silent: true });
1085
+ return;
1086
+ }
1066
1087
  this.goalModeEnabled = event.state?.enabled === true;
1067
1088
  this.goalModePaused = event.state?.enabled !== true && event.state?.goal?.status === "paused";
1068
1089
  if (!event.state?.enabled) {
@@ -1150,6 +1171,13 @@ export class InteractiveMode implements InteractiveModeContext {
1150
1171
  const restored = await this.session.goalRuntime.onThreadResumed();
1151
1172
  this.goalModeEnabled = restored?.enabled === true;
1152
1173
  this.goalModePaused = restored?.enabled !== true && restored?.goal.status === "paused";
1174
+ // sdk.ts excludes "goal" from the initial active tool set unconditionally.
1175
+ // Re-add it now so the agent can call resume, complete, or drop on this goal.
1176
+ if (restored?.goal) {
1177
+ const previousTools = this.session.getActiveToolNames().filter(name => name !== "goal");
1178
+ this.#goalModePreviousTools = previousTools;
1179
+ await this.session.setActiveToolsByName([...new Set([...previousTools, "goal"])]);
1180
+ }
1153
1181
  this.#updateGoalModeStatus();
1154
1182
  return;
1155
1183
  }
@@ -2167,7 +2195,7 @@ export class InteractiveMode implements InteractiveModeContext {
2167
2195
  this.loadingAnimation = new Loader(
2168
2196
  this.ui,
2169
2197
  spinner => theme.fg("accent", spinner),
2170
- text => theme.fg("muted", text),
2198
+ renderWorkingMessage,
2171
2199
  this.#defaultWorkingMessage,
2172
2200
  getSymbolTheme().spinnerFrames,
2173
2201
  );
@@ -0,0 +1,79 @@
1
+ import type { Theme, ThemeColor } from "./theme";
2
+
3
+ const SHIMMER_PADDING = 10;
4
+ const SHIMMER_SWEEP_MS = 2000;
5
+ const SHIMMER_BAND_HALF_WIDTH = 5;
6
+
7
+ type ShimmerTheme = Pick<Theme, "bold" | "fg">;
8
+
9
+ /** Three-tier color stack a shimmer character cycles through as the band sweeps. */
10
+ export interface ShimmerPalette {
11
+ /** Color for chars outside / at the edge of the band (intensity < 0.2). */
12
+ low: ThemeColor;
13
+ /** Color for chars approaching the crest (0.2 <= intensity < 0.6). */
14
+ mid: ThemeColor;
15
+ /** Color at the band's crest (intensity >= 0.6). */
16
+ high: ThemeColor;
17
+ /** Whether to bold the crest tier. Default `false`. */
18
+ bold?: boolean;
19
+ }
20
+
21
+ /** One run of text that shares a palette inside a larger shimmer sweep. */
22
+ export interface ShimmerSegment {
23
+ text: string;
24
+ palette?: ShimmerPalette;
25
+ }
26
+
27
+ export const DEFAULT_SHIMMER_PALETTE: ShimmerPalette = {
28
+ low: "dim",
29
+ mid: "muted",
30
+ high: "accent",
31
+ bold: true,
32
+ };
33
+
34
+ function shimmerIntensity(index: number, length: number): number {
35
+ const period = length + SHIMMER_PADDING * 2;
36
+ const pos = Math.floor(((Date.now() % SHIMMER_SWEEP_MS) / SHIMMER_SWEEP_MS) * period);
37
+ const dist = Math.abs(index + SHIMMER_PADDING - pos);
38
+ if (dist > SHIMMER_BAND_HALF_WIDTH) return 0;
39
+
40
+ const x = Math.PI * (dist / SHIMMER_BAND_HALF_WIDTH);
41
+ return 0.5 * (1 + Math.cos(x));
42
+ }
43
+
44
+ function styleShimmerChar(ch: string, intensity: number, theme: ShimmerTheme, palette: ShimmerPalette): string {
45
+ if (intensity < 0.2) return theme.fg(palette.low, ch);
46
+ if (intensity < 0.6) return theme.fg(palette.mid, ch);
47
+ const styled = theme.fg(palette.high, ch);
48
+ return palette.bold ? theme.bold(styled) : styled;
49
+ }
50
+
51
+ /**
52
+ * Apply a shimmer sweep across one or more segments, treating them as a single
53
+ * continuous string for band positioning. Each segment can supply its own
54
+ * palette so the gradient stays in lockstep while the colors differ.
55
+ */
56
+ export function shimmerSegments(segments: readonly ShimmerSegment[], theme: ShimmerTheme): string {
57
+ let total = 0;
58
+ const expanded: Array<{ chars: string[]; palette: ShimmerPalette }> = [];
59
+ for (const seg of segments) {
60
+ const chars = [...seg.text];
61
+ total += chars.length;
62
+ expanded.push({ chars, palette: seg.palette ?? DEFAULT_SHIMMER_PALETTE });
63
+ }
64
+ if (total === 0) return "";
65
+
66
+ const out: string[] = [];
67
+ let index = 0;
68
+ for (const { chars, palette } of expanded) {
69
+ for (const ch of chars) {
70
+ out.push(styleShimmerChar(ch, shimmerIntensity(index, total), theme, palette));
71
+ index++;
72
+ }
73
+ }
74
+ return out.join("");
75
+ }
76
+
77
+ export function shimmerText(text: string, theme: ShimmerTheme, palette?: ShimmerPalette): string {
78
+ return shimmerSegments([{ text, palette }], theme);
79
+ }
@@ -1,13 +1,18 @@
1
1
  Manage the active goal-mode objective.
2
2
 
3
3
  Use a single `op` field:
4
- - `create` starts a goal. Requires `objective`; optional `token_budget` must be positive. Use only when no goal exists.
5
- - `get` returns the current goal and remaining token budget.
4
+ - `create` starts a goal. Requires `objective`; optional `token_budget` must be positive. Use only when no goal exists and no goal is paused.
5
+ - `get` returns the current goal (active or paused) and remaining token budget.
6
+ - `resume` re-activates a paused goal so work can continue.
6
7
  - `complete` marks the goal complete after you have verified every deliverable against current evidence.
8
+ - `drop` discards the current goal without completing it.
7
9
 
8
10
  Examples:
9
11
  - `goal({"op":"create","objective":"Implement feature X","token_budget":50000})`
10
12
  - `goal({"op":"get"})`
13
+ - `goal({"op":"resume"})`
11
14
  - `goal({"op":"complete"})`
15
+ - `goal({"op":"drop"})`
12
16
 
13
17
  Do not call `complete` because a budget is low or a turn is ending. Call it only when the goal is actually done and verified.
18
+ If `get` shows a paused goal, call `resume` before continuing work on it.
@@ -440,11 +440,6 @@ function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): strin
440
440
  return `${selector.provider}/${selector.id}`;
441
441
  }
442
442
 
443
- /** Composite key for auto-clear timers, keyed by phase name + task content. */
444
- function todoClearKey(phaseName: string, taskContent: string): string {
445
- return `${phaseName}\u0000${taskContent}`;
446
- }
447
-
448
443
  const IRC_REPLY_MAX_BYTES = 4096;
449
444
 
450
445
  /**
@@ -796,7 +791,6 @@ export class AgentSession {
796
791
  // Todo completion reminder state
797
792
  #todoReminderCount = 0;
798
793
  #todoPhases: TodoPhase[] = [];
799
- #todoClearTimers = new Map<string, Timer>();
800
794
  #toolChoiceQueue = new ToolChoiceQueue();
801
795
 
802
796
  // Bash execution state
@@ -2734,7 +2728,6 @@ export class AgentSession {
2734
2728
  logger.warn("Failed to emit session_shutdown event", { error: String(error) });
2735
2729
  }
2736
2730
  await this.#cancelPostPromptTasks();
2737
- this.#clearTodoClearTimers();
2738
2731
  // Cancel jobs this agent registered so a subagent's teardown doesn't
2739
2732
  // leak its background bash/task work into the parent's manager. Only
2740
2733
  // the session that owns the manager goes on to dispose it (which itself
@@ -4628,13 +4621,12 @@ export class AgentSession {
4628
4621
 
4629
4622
  setTodoPhases(phases: TodoPhase[]): void {
4630
4623
  this.#todoPhases = this.#cloneTodoPhases(phases);
4631
- this.#scheduleTodoAutoClear(phases);
4632
4624
  }
4633
4625
 
4634
4626
  #syncTodoPhasesFromBranch(): void {
4635
4627
  const phases = getLatestTodoPhasesFromEntries(this.sessionManager.getBranch());
4636
4628
  // Strip completed/abandoned tasks — they were done in a previous run,
4637
- // so the auto-clear grace period has already elapsed.
4629
+ // so they have no bearing on progress tracking for the new turn.
4638
4630
  for (const phase of phases) {
4639
4631
  phase.tasks = phase.tasks.filter(t => t.status !== "completed" && t.status !== "abandoned");
4640
4632
  }
@@ -4652,72 +4644,11 @@ export class AgentSession {
4652
4644
  }));
4653
4645
  }
4654
4646
 
4655
- /** Schedule auto-removal of completed/abandoned tasks after a delay. */
4656
- #scheduleTodoAutoClear(phases: TodoPhase[]): void {
4657
- // Default bumped from 60s to 30 min: the prior 60s splice mutated canonical
4658
- // state mid-turn, so the model observed phase totals shrinking ("6 → 5")
4659
- // between tool calls. Surviving the turn matches user expectations; a
4660
- // render-time filter in the UI consumer would be cleaner but lives in a
4661
- // different package and is out of scope for this fix.
4662
- const delaySec = this.settings.get("tasks.todoClearDelay") ?? 1800;
4663
- if (delaySec < 0) return; // "Never" — no auto-clear
4664
- const delayMs = delaySec * 1000;
4665
- const doneKeys = new Set<string>();
4666
- for (const phase of phases) {
4667
- for (const task of phase.tasks) {
4668
- if (task.status === "completed" || task.status === "abandoned") {
4669
- doneKeys.add(todoClearKey(phase.name, task.content));
4670
- }
4671
- }
4672
- }
4673
-
4674
- // Cancel timers for tasks that are no longer done (e.g. status was reverted)
4675
- for (const [key, timer] of this.#todoClearTimers) {
4676
- if (!doneKeys.has(key)) {
4677
- clearTimeout(timer);
4678
- this.#todoClearTimers.delete(key);
4679
- }
4680
- }
4681
-
4682
- // Schedule new timers for newly-done tasks
4683
- for (const key of doneKeys) {
4684
- if (this.#todoClearTimers.has(key)) continue;
4685
- if (delayMs === 0) {
4686
- // Instant — run synchronously on next microtask to batch removals
4687
- const timer = setTimeout(() => this.#runTodoAutoClear(key), 0);
4688
- this.#todoClearTimers.set(key, timer);
4689
- } else {
4690
- const timer = setTimeout(() => this.#runTodoAutoClear(key), delayMs);
4691
- this.#todoClearTimers.set(key, timer);
4692
- }
4693
- }
4694
- }
4695
-
4696
- /** Remove a single completed task and notify the UI. */
4697
- #runTodoAutoClear(key: string): void {
4698
- this.#todoClearTimers.delete(key);
4699
- let removed = false;
4700
- for (const phase of this.#todoPhases) {
4701
- const idx = phase.tasks.findIndex(t => todoClearKey(phase.name, t.content) === key);
4702
- if (idx !== -1 && (phase.tasks[idx].status === "completed" || phase.tasks[idx].status === "abandoned")) {
4703
- phase.tasks.splice(idx, 1);
4704
- removed = true;
4705
- break;
4706
- }
4707
- }
4708
- if (!removed) return;
4709
-
4710
- // Remove empty phases
4711
- this.#todoPhases = this.#todoPhases.filter(p => p.tasks.length > 0);
4712
- this.#emit({ type: "todo_auto_clear" });
4713
- }
4714
-
4715
- #clearTodoClearTimers(): void {
4716
- for (const timer of this.#todoClearTimers.values()) {
4717
- clearTimeout(timer);
4718
- }
4719
- this.#todoClearTimers.clear();
4720
- }
4647
+ // Auto-clear of completed/abandoned tasks was removed: the timer-driven
4648
+ // splice mutated canonical `#todoPhases` between tool calls, so the model
4649
+ // observed phase totals shrinking ("5 4") after marking tasks done. The
4650
+ // `tasks.todoClearDelay` setting is now inert; completed tasks survive
4651
+ // until the next explicit `todo_write` call removes them via `rm`/`drop`.
4721
4652
 
4722
4653
  /**
4723
4654
  * Abort current operation and wait for agent to become idle.
@@ -6240,6 +6171,12 @@ export class AgentSession {
6240
6171
  };
6241
6172
 
6242
6173
  const currentModel = this.model;
6174
+ // Prefer the active session's model: it's what the user is actively using,
6175
+ // and routing compaction to a different provider (e.g. an OpenAI default
6176
+ // model while the chat is on Anthropic) changes provider-specific behavior
6177
+ // like remote compaction endpoints. Role-based candidates only kick in
6178
+ // as auth fallbacks when the current model has no usable credentials.
6179
+ addCandidate(currentModel);
6243
6180
  for (const role of MODEL_ROLE_IDS) {
6244
6181
  addCandidate(this.#resolveRoleModelFull(role, availableModels, currentModel).model);
6245
6182
  }
@@ -1,3 +1,6 @@
1
+ import { shimmerText } from "../../modes/theme/shimmer";
2
+ import { theme as currentTheme, type Theme } from "../../modes/theme/theme";
3
+
1
4
  /** Format a millisecond duration as a coarse-grained human label. */
2
5
  export function formatDuration(ms: number): string {
3
6
  const seconds = Math.max(0, Math.round(ms / 1000));
@@ -10,14 +13,31 @@ export function formatDuration(ms: number): string {
10
13
  return `${days}d`;
11
14
  }
12
15
 
16
+ type ProgressBarTheme = Pick<Theme, "bold" | "fg">;
17
+
18
+ const unstyledProgressBarTheme: ProgressBarTheme = {
19
+ fg(_color, text) {
20
+ return text;
21
+ },
22
+ bold(text) {
23
+ return text;
24
+ },
25
+ };
26
+
27
+ function resolveProgressBarTheme(uiTheme: ProgressBarTheme | undefined): ProgressBarTheme {
28
+ return uiTheme ?? currentTheme ?? unstyledProgressBarTheme;
29
+ }
30
+
13
31
  /**
14
32
  * Render an ASCII progress bar with a trailing percent label.
15
33
  * `fraction` is clamped to `[0, 1]`. `undefined` renders a dotted placeholder.
16
34
  */
17
- export function renderAsciiBar(fraction: number | undefined, width = 24): string {
18
- if (fraction === undefined) return `[${"·".repeat(width)}]`;
35
+ export function renderAsciiBar(fraction: number | undefined, width = 24, uiTheme?: ProgressBarTheme): string {
36
+ const progressBarTheme = resolveProgressBarTheme(uiTheme);
37
+ if (fraction === undefined) return `[${shimmerText("·".repeat(width), progressBarTheme)}]`;
19
38
  const clamped = Math.min(Math.max(fraction, 0), 1);
20
39
  const filled = Math.round(clamped * width);
21
40
  const pct = Math.round(clamped * 100);
22
- return `[${"█".repeat(filled)}${"░".repeat(Math.max(0, width - filled))}] ${pct}%`;
41
+ const bar = `${"█".repeat(filled)}${"░".repeat(Math.max(0, width - filled))}`;
42
+ return `[${shimmerText(bar, progressBarTheme)}] ${pct}%`;
23
43
  }