diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 706618184..c3bfc2e2b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,7 @@ ### Changed - `/settings` rows can now carry a risk note: a warning glyph on the row plus a warning-colored line above the description. `External Thinking` (`externalThinking`, `--external-thinking`) is the first user — providers have flagged the request shape it produces as abuse, up to account-level enforcement, so both the settings entry and `--help` now say so. +- The todo HUD header now draws a summed progress bar — `Todos ━━──────── 44/355` — counting closed/total tasks across every stage, instead of the stage-position counter (`Todos · 4/14`). Bar glyphs follow the symbol preset (`progress.filled`/`progress.empty`: heavy/light lines for unicode and nerd, `=`/`-` for ascii). Stages hidden past the collapsed cap are now announced with a trailing `… n more stages` row, since the header no longer implies them. - Token counting is now scoped to the model being billed rather than to a process-global tokenizer: session maintenance, stats, advisors, `/context`, snapcompact inline imaging, and `compress` each count through the owning agent's `Tokenizer` (`agent.tokenizer`). Message counting is `Tokenizer.countMessage`/`countMessages` (replacing the free `estimateTokens(message, tokenizer)` helper; the legacy shim keeps a compat `estimateTokens` export for legacy pi extensions). `estimateToolSchemaTokens`, `estimateSkillsTokens`, `computeNonMessageTokens`, and `computeNonMessageBreakdown` take an explicit tokenizer; standalone prompt inspection intentionally keeps the default estimate because it has no resolved catalog model. - The advisor runtime's `maintainContext` hook now receives the pending update as a message instead of a pre-computed token count — sizing it needs the advisor model's tokenizer, which the host owns. diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 5c2545181..46fcc7971 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -124,7 +124,7 @@ import type { ConfiguredThinkingLevel } from "../thinking"; import { tinyTitleClient } from "../tiny/title-client"; import type { LspStartupServerInfo } from "../tools"; import { normalizeLocalScheme } from "../tools/path-utils"; -import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../tools/render-utils"; +import { formatMoreItems, replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../tools/render-utils"; import { setAutoQaConsentHandler } from "../tools/report-tool-issue"; import { formatPhaseDisplayName, @@ -2298,7 +2298,7 @@ export class InteractiveMode implements InteractiveModeContext { const multiPhase = phases.length > 1; const activeIdx = phases.indexOf(this.#getActivePhase(phases) ?? phases[0]); // Fixed budgets keep the HUD bounded regardless of plan size / progress. - const subsequentStageCap = 4; // stages shown after the active one (header count implies the rest) + const subsequentStageCap = 4; // stages shown after the active one (a trailing summary row covers the rest) const activeTaskCap = 5; // open tasks previewed for the active stage const activeDescs = this.#getActiveSubagentDescriptions(); @@ -2336,7 +2336,7 @@ export class InteractiveMode implements InteractiveModeContext { // One phase node. The active stage is highlighted with normal-brightness task // progress; other stages render their whole row (name + progress) in the - // brighter muted gray. The root header carries overall stage progression. + // brighter muted gray. The root header carries the summed progress bar. const renderPhase = (phase: TodoPhase, oneBased: number, isActive: boolean): string | string[] => { const label = multiPhase ? formatPhaseDisplayName(phase.name, oneBased) : phase.name; // Closed, not just completed: the collapsed task window hides abandoned @@ -2351,24 +2351,35 @@ export class InteractiveMode implements InteractiveModeContext { return [header, ...renderTasks(phase)]; }; - // Collapsed: active stage + a bounded number of following stages (the - // header's "n/total" count implies any not shown). Expanded: every stage + // Collapsed: active stage + a bounded number of following stages, with a + // "… n more stages" row for anything past the cap. Expanded: every stage // from the top. Roman numerals stay tied to the real phase index. const baseIdx = expanded ? 0 : activeIdx; const phaseSlice = expanded ? phases.slice(baseIdx) : phases.slice(baseIdx, baseIdx + 1 + subsequentStageCap); + const hiddenStages = phases.length - baseIdx - phaseSlice.length; const phaseTreeLines = renderTreeList( { items: phaseSlice, - expanded: true, + expanded, + trailingSummary: hiddenStages > 0 ? formatMoreItems(hiddenStages, "stage") : "", renderItem: (phase, ctx) => renderPhase(phase, baseIdx + ctx.index + 1, baseIdx + ctx.index === activeIdx), }, theme, ); - // Header carries overall stage progression, e.g. "Todos · 1/8". - const root = - theme.bold(theme.fg("accent", "Todos")) + - (multiPhase ? theme.fg("dim", ` · ${activeIdx + 1}/${phases.length}`) : ""); + // Header: overall task progress summed across every stage — a bar plus + // closed/total counts. Per-stage rows carry their own counts. + const totalTasks = phases.reduce((sum, phase) => sum + phase.tasks.length, 0); + const closedTasks = phases.reduce((sum, phase) => sum + phase.tasks.filter(isClosedTodo).length, 0); + const barWidth = 20; + // Clamp so any progress shows a sliver and only 100% fills the bar. + let filledWidth = Math.round((closedTasks / totalTasks) * barWidth); + if (closedTasks > 0) filledWidth = Math.max(filledWidth, 1); + if (closedTasks < totalTasks) filledWidth = Math.min(filledWidth, barWidth - 1); + const bar = + theme.fg("accent", theme.progress.filled.repeat(filledWidth)) + + theme.fg("dim", theme.progress.empty.repeat(barWidth - filledWidth)); + const root = `${theme.bold(theme.fg("accent", "Todos"))} ${bar} ${theme.fg("dim", `${closedTasks}/${totalTasks}`)}`; const lines = ["", root, ...phaseTreeLines.map(line => ` ${line}`)]; this.todoContainer.addChild(new Text(lines.join("\n"), 1, 0)); } diff --git a/packages/coding-agent/src/modes/theme/symbols.ts b/packages/coding-agent/src/modes/theme/symbols.ts index e3104d2b1..8704ca98c 100644 --- a/packages/coding-agent/src/modes/theme/symbols.ts +++ b/packages/coding-agent/src/modes/theme/symbols.ts @@ -32,6 +32,9 @@ export type SymbolKey = | "tree.vertical" | "tree.horizontal" | "tree.hook" + // Progress Bar + | "progress.filled" + | "progress.empty" // Box Drawing - Rounded | "boxRound.topLeft" | "boxRound.topRight" @@ -242,6 +245,9 @@ const UNICODE_SYMBOLS: SymbolMap = { "tree.vertical": "│", "tree.horizontal": "─", "tree.hook": "└", + // Progress bar + "progress.filled": "━", + "progress.empty": "─", // Box (rounded) "boxRound.topLeft": "╭", "boxRound.topRight": "╮", @@ -472,6 +478,11 @@ const NERD_SYMBOLS: SymbolMap = { "tree.horizontal": "─", // pick: └ | alt: ╰ ⎿ ↳ "tree.hook": "└", + // Progress Bar (same as unicode) + // pick: ━ | alt: ▰ ▮ ■ + "progress.filled": "━", + // pick: ─ | alt: ▱ ▯ ╌ + "progress.empty": "─", // Box Drawing - Rounded (same as unicode) // pick: ╭ | alt: ┌ ┏ ╔ "boxRound.topLeft": "╭", @@ -762,6 +773,9 @@ const ASCII_SYMBOLS: SymbolMap = { "tree.vertical": "|", "tree.horizontal": "-", "tree.hook": "`-", + // Progress Bar + "progress.filled": "=", + "progress.empty": "-", // Box Drawing - Rounded (ASCII fallback) "boxRound.topLeft": "+", "boxRound.topRight": "+", diff --git a/packages/coding-agent/src/modes/theme/theme-class.ts b/packages/coding-agent/src/modes/theme/theme-class.ts index 9c7b6ffb3..c3b83efdf 100644 --- a/packages/coding-agent/src/modes/theme/theme-class.ts +++ b/packages/coding-agent/src/modes/theme/theme-class.ts @@ -423,6 +423,13 @@ export class Theme { }; } + get progress() { + return { + filled: this.#symbols["progress.filled"], + empty: this.#symbols["progress.empty"], + }; + } + get boxRound() { return { topLeft: this.#symbols["boxRound.topLeft"], diff --git a/packages/coding-agent/test/interactive-mode-todo-clear.test.ts b/packages/coding-agent/test/interactive-mode-todo-clear.test.ts index 32376170b..194fd48c9 100644 --- a/packages/coding-agent/test/interactive-mode-todo-clear.test.ts +++ b/packages/coding-agent/test/interactive-mode-todo-clear.test.ts @@ -297,9 +297,11 @@ describe("InteractiveMode todo HUD anchor", () => { // Lightened: no boxed top/bottom rules. expect(lines.some(line => line === "─".repeat(80))).toBe(false); - // Root header carries overall stage progression (on stage 1 of 2). + // Root header carries the summed task progress bar (1 of 4 tasks closed). const root = lines.find(line => line.includes("Todos")); - expect(root).toContain("1/2"); + expect(root).toContain("1/4"); + expect(root).toContain(theme.progress.filled); + expect(root).toContain(theme.progress.empty); // Active stage: highlighted header with its own task progress, expanded as a // connector tree; the just-completed task stays as the lead row so progress // is visible while the stage still has open work. @@ -322,7 +324,7 @@ describe("InteractiveMode todo HUD anchor", () => { expect(mode.todoContainer.render(80)).toHaveLength(0); }); - it("omits the stage count and roman numeral for a single-phase list", () => { + it("keeps the summed progress bar but omits the roman numeral for a single-phase list", () => { mode.setTodos([ { name: "Tasks", @@ -336,16 +338,17 @@ describe("InteractiveMode todo HUD anchor", () => { .render(80) .flatMap(line => line.split("\n")) .map(line => Bun.stripANSI(line)); - // One stage → no redundant "1/1" stage count on the root. + // One stage → the root still carries the summed bar and task counts. const root = lines.find(line => line.includes("Todos")); - expect(root).not.toContain("/"); + expect(root).toContain("0/2"); + expect(root).toContain(theme.progress.empty); // The stage keeps its task progress; no roman numeral for a lone stage. expect(lines.some(line => line.includes("Tasks") && line.includes("0/2"))).toBe(true); expect(lines.some(line => line.includes("I. Tasks"))).toBe(false); expect(lines.some(line => line.includes("alpha"))).toBe(true); }); - it("caps the visible stage list and leaves the hidden ones to the header count", () => { + it("caps the visible stage list and summarizes the hidden ones in an overflow row", () => { const stage = (name: string): TodoPhase => ({ name, tasks: [{ content: `${name} task`, status: "pending" }] }); mode.setTodos([ stage("Discovery"), @@ -360,14 +363,15 @@ describe("InteractiveMode todo HUD anchor", () => { .render(80) .flatMap(line => line.split("\n")) .map(line => Bun.stripANSI(line)); - // Active stage + four following stages render; the rest are dropped. + // Active stage + four following stages render; the rest collapse into a + // trailing "… n more stages" row. expect(lines.some(line => line.includes("II. Two"))).toBe(true); expect(lines.some(line => line.includes("V. Five"))).toBe(true); expect(lines.some(line => line.includes("Six"))).toBe(false); - // No overflow row — the header's "1/7" implies the hidden stages. - expect(lines.some(line => line.includes("more"))).toBe(false); + expect(lines.some(line => line.includes("2 more stages"))).toBe(true); + // Root header sums tasks across every stage, hidden ones included. const root = lines.find(line => line.includes("Todos")); - expect(root).toContain("1/7"); + expect(root).toContain("0/7"); }); it("anchors the todo HUD as a native-scrollback live region while populated", () => {