Skip to content

feat(tui): CC-parity task checklist HUD, and make the checklist actually appear - #827

Merged
ericleepi314 merged 1 commit into
mainfrom
feat/todo-hud-cc-parity
Aug 11, 2026
Merged

feat(tui): CC-parity task checklist HUD, and make the checklist actually appear#827
ericleepi314 merged 1 commit into
mainfrom
feat/todo-hud-cc-parity

Conversation

@ericleepi314

Copy link
Copy Markdown
Collaborator

What

The task checklist now renders and behaves like real Claude Code's — and, more
importantly, actually shows up. Two user reports drove this: the HUD didn't
match CC's layout, and "I don't see the todo checklist often" (with a guess at
auto-planning as the cause — it isn't; plan mode is at parity on both sides).

The HUD (ui-tui)

While busy, the list hangs off the busy line through the connector,
exactly the original's Spinner→MessageResponse→TaskListV2 mount:

✳ Mapping harbor adapter pattern + pi APIs… (3m 12s · ↓ 11.3k tokens)
  └  ◼ Map harbor adapter pattern + pi headless/extension APIs
     ◻ Write pi harbor agent adapter
     ◻ Build pi vision + Tavily websearch extension
     ◻ Validate on sample TB2.1 tasks
     ◻ Document the full-run command
  • Busy verb = the in-progress todo's activeForm (already true; now the list
    below matches). ✔ green strikethrough-dim / ◼ orange bold / ◻ plain —
    figures.tick / squareSmallFilled / squareSmall, already exact.
  • ctrl+t toggles the checklist (app:toggleTodos, defaultBindings.ts:43).
    The composer's key allowlist had to learn it — shouldPassThroughToGlobalHandler
    swallowed it otherwise. Hidden, the busy line falls back to CC's one-line
    Next: … (the original's expandedView='none' render); the two never
    show together.
  • Footer gains esc to interrupt · ctrl+t to hide tasks (wording flips to
    show when hidden), only while busy with tasks — the original's
    getSpinnerHintParts.
  • Idle keeps the standalone header render (N tasks (X done, …)), still
    click-collapsible.

Why the checklist rarely appeared (three causes, all fixed)

  1. The system-prompt bullet was damped — a deliberate TB2.1 harness-tuning
    change (measured 0.21 vs 0.00 steps/trial of task-tool bookkeeping) whose
    rationale, "a task list you are the only reader of is overhead," is false
    interactively: the checklist is the pinned HUD the user watches. The bullet
    is now per-surface: interactive gets the reference's imperative wording
    verbatim (getUsingYourToolsSection); non-interactive keeps the damped
    wording byte-for-byte. The tool NAME now follows is_todo_v2_enabled too —
    headless sessions were being told about TaskCreate, a tool their toolkit
    doesn't carry (they expose TodoWrite).
  2. CC's periodic reminder was never ported. getTaskReminderAttachments
    the "task tools haven't been used recently" <system-reminder> injected
    every 10 assistant turns, throttled to one per 10 — now exists as
    src/context_system/task_reminder.py: exact turn-counting port
    (before-increment ordering, thinking-only messages skipped, independent
    counters), CC's reminder text verbatim, current task list attached.
    Injected beside the plan-mode attachments in agent_loop_compat, persisted
    via on_attachment so the throttle can find the previous reminder across
    turns.
  3. The HUD archived at every turn end (archiveTodosAtTurnEnd), so the
    pinned panel vanished the moment a turn completed and the checklist
    survived only as a transcript block that scrolls away. CC keeps the list
    until it's done (REPL.tsx:4934 renders TaskListV2 idle). Incomplete
    lists now stay live across turns — startMessage/reset already left
    todos alone, so no other lifecycle change was needed. Finished lists
    archive exactly as before.

Deliberate deltas from the reference

  • Headless gets no reminder and keeps the damped prompt bullet (the
    reference nudges both surfaces; headless had no reminder before either, so
    this preserves the status quo there rather than inventing an asymmetry).
    One headless byte DID change: the damped bullet now names TodoWrite
    instead of TaskCreate — fixing a prompt that named a tool headless
    sessions don't carry. This invalidates headless prompt prefix caches once;
    the TB2.1 step-efficiency measurement was taken with the old (wrong) name,
    and naming a nonexistent tool could only have hurt.
  • The checklist defaults to visible, and ctrl+t is session-scoped. The
    reference ships collapsed (showExpandedTodos defaults false) and
    persists the toggle to global config. Hiding by default would recreate the
    exact complaint this change fixes. Persisting the toggle is deferred: the
    TUI has no settings channel for display prefs in either direction — review
    traced config.get {key:'full'}get_settings, whose reply is a flat
    dict with no display block, meaning applyDisplay's entire
    config.display surface (bell_on_complete, tui_statusbar,
    tui_compact, …) is a pre-existing dead read that no backend populates.
    Reviving that surface is its own change (see follow-ups).
  • The reference's Brief-tool gate on the reminder is not ported. In CC,
    Brief-in-toolkit marks the coworker surface where SendUserMessage owns
    communication. clawcodex registers Brief unconditionally, so the
    transplanted gate simply disabled the reminder everywhere — caught by the
    wiring test, which drives the real run_query_as_agent_loop and asserts
    the reminder reaches the provider.
  • Idle render keeps the collapsible chevron header rather than CC's
    bare-nothing when toggled off — the click affordance to reopen is worth the
    delta.

Also fixed along the way

  • Persisted attachments lost isMetaConversation.add_message
    dropped the flag, so a persisted reminder (or plan-mode attachment)
    counted as a real user turn in the stats odometer and became a /rewind
    boundary (/rewind 1 landed on the reminder, not the user's message).
    Pre-existing for plan mode; the reminder made it common. add_message now
    threads isMeta, both on_attachment call sites pass it, and rewind's
    is_prompt skips meta messages.
  • Removed the now-producerless todoIncomplete transcript path (types, the
    TodoPanel.incomplete prop, the unreachable virtualHeights arm).

Follow-up (out of scope)

  • Resume doesn't rehydrate the checklist HUD. The reminder re-lists tasks
    to the model, but the user sees nothing until the next TaskCreate/TaskUpdate.
    Restoring it needs the backend task store persisted across resume plus a
    control verb to fetch it — its own change.
  • applyDisplay's config.display surface is dead end-to-end (found in
    review): get_settings returns a flat dict, src/config.py has no
    display section, so every display.* key the TUI reads
    (bell_on_complete, tui_statusbar, tui_compact, details_mode, …) is
    currently unreachable by users. Wiring a real settings channel would also
    unlock persisting the ctrl+t toggle.

Testing

  • tests/test_task_reminder.py (16) — turn-counting port fidelity (including
    the before-increment ordering and thinking-skip), every gate, throttling
    both ways, the kill-switch envs.
  • tests/test_task_reminder_wiring.py (2) — end-to-end through the real
    agent loop with a recording provider: the reminder reaches the model wrapped
    in <system-reminder> and persists via on_attachment; short conversations
    get nothing. This test caught the Brief-gate transplant bug.
  • tests/test_system_prompt_full.py — per-surface bullet: damped+TodoWrite
    headless, imperative+TaskCreate interactive, damped+TaskCreate under
    CLAUDE_CODE_ENABLE_TASKS.
  • ui-tui/src/__tests__/todoHud.test.tsx (10) — rendered-output assertions:
    connector + 5-column alignment, no header while attached, Next:-suppression,
    ctrl+t fallback, footer hint wording in all states.
  • Two tests updated that pinned the replaced behavior (busyLine Next:-always,
    turnStore archive-incomplete); passthrough allowlist test extended.

Full suites: Python 9991 passed / 1 failed — test_sigint_during_prefetch_clean_exit,
a SIGINT-timing subprocess race that passes on retry, references nothing in
this diff, and predates it. ui-tui 1725 passed / 8 failed = the pre-existing
baseline (same 5 files, verified against a stashed clean tree), plus one
parallel-run flake (execFileNoThrow) that passes in isolation. Changed files
lint clean; the tree-wide 46 pre-existing lint errors are unchanged.

🤖 Generated with Claude Code

…lly appear

The pinned checklist now renders like the original's: while busy it hangs
off the busy line through the "  └  " connector (Spinner → MessageResponse
→ TaskListV2), ctrl+t toggles it (app:toggleTodos; the composer's key
allowlist had to learn the chord), the footer offers
"esc to interrupt · ctrl+t to hide tasks", and when hidden the busy line
falls back to the original's one-line "Next: …".

Separately, three reasons the checklist almost never appeared, all fixed:

- The system-prompt task bullet was damped during TB2.1 harness tuning.
  Its rationale — "a task list you are the only reader of is overhead" —
  is false interactively, where the checklist is the HUD the user watches.
  The bullet is now per-surface: interactive restores the reference's
  imperative verbatim; non-interactive keeps the damped wording, now
  naming TodoWrite (headless was being told about TaskCreate, a tool its
  toolkit does not carry).
- getTaskReminderAttachments was never ported. task_reminder.py is an
  exact port (turn counting, thresholds, reminder text) injected beside
  the plan-mode attachments, interactive-only. The reference's Brief-tool
  gate is deliberately dropped: clawcodex registers Brief unconditionally,
  so the transplanted gate disabled the reminder everywhere — caught by
  the wiring test that drives the real agent loop.
- The HUD archived the list into the transcript at every turn end.
  Incomplete lists now stay pinned across turns (CC keeps TaskListV2
  rendered while idle); only finished lists archive.

Also fixed along the way: Conversation.add_message dropped isMeta, so a
persisted attachment counted as a real user turn in the stats odometer
and became a /rewind boundary — /rewind 1 landed on the injected reminder
instead of the user's message. Threaded through both on_attachment call
sites, and rewind's is_prompt now skips meta messages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Test Results

     2 files       2 suites   27m 40s ⏱️
10 004 tests  9 983 ✅ 13 💤 8 ❌
20 688 runs  20 633 ✅ 47 💤 8 ❌

For more details on these failures, see this check.

Results for commit 0b1fabc.

@ericleepi314
ericleepi314 merged commit b203e7c into main Aug 11, 2026
4 of 7 checks passed
@ericleepi314
ericleepi314 deleted the feat/todo-hud-cc-parity branch August 11, 2026 17:25
agentforce314 added a commit that referenced this pull request Aug 14, 2026
…#837)

The checklist HUD (#827) landed but the TaskV2 mutations kept printing
their raw results underneath it, so planning a turn cost a screenful of
JSON restating the checklist: one `⏺ TaskCreate(…) ⎿ {"task":{"id":…}}`
row per task up front, then one
`⏺ TaskUpdate ⎿ {"success":true,…,"statusChange":{…}}` row per status
flip. TodoWrite was already suppressed for exactly this reason — the
pinned checklist is these tools' entire UI — and TaskV2 is the same tool
in a per-row shape, so it gets the same treatment: recordTodos() has
already fed the HUD from this very call by the time the trail line would
be appended.

Suppression is by outcome, not just by name. tasks_v2.py answers a
refused mutation with a *successful* tool call carrying
`{"success": false, …, "error": "Task not found"}` (`_task_update_call`),
and the HUD then redraws the unchanged checklist — hiding that row would
leave the miss with no trace anywhere in the UI. So isChecklistHudOnly()
consults the result text, and a refused (or errored) mutation keeps its
row. TaskList/TaskGet/TaskOutput keep theirs unconditionally: they are
reads whose output (task bodies, background-shell logs) the HUD never
shows.

The live spinner row in ToolTrail gets the same filter, so a mutation
that will render no completion line does not blink a row on the way
either.

Tests: 5 new vitest (turnController suppression + the refused/read
paths, isChecklistHudTool/isChecklistHudOnly units). Verified against
deepseek-v4-pro (deepseek provider) in a tmux-driven TUI: same prompt
before and after, four TaskCreate + two TaskUpdate calls. Before, six
JSON rows above the HUD; after, only the checklist. A deliberately
wrong taskId still renders its "Task not found" row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant