feat(tui): CC-parity task checklist HUD, and make the checklist actually appear - #827
Merged
Conversation
…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>
Test Results 2 files 2 suites 27m 40s ⏱️ For more details on these failures, see this check. Results for commit 0b1fabc. |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
activeForm(already true; now the listbelow matches). ✔ green strikethrough-dim / ◼ orange bold / ◻ plain —
figures.tick/squareSmallFilled/squareSmall, already exact.ctrl+ttoggles the checklist (app:toggleTodos, defaultBindings.ts:43).The composer's key allowlist had to learn it —
shouldPassThroughToGlobalHandlerswallowed it otherwise. Hidden, the busy line falls back to CC's one-line
Next: …(the original'sexpandedView='none'render); the two nevershow together.
esc to interrupt · ctrl+t to hide tasks(wording flips toshowwhen hidden), only while busy with tasks — the original'sgetSpinnerHintParts.N tasks (X done, …)), stillclick-collapsible.
Why the checklist rarely appeared (three causes, all fixed)
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 dampedwording byte-for-byte. The tool NAME now follows
is_todo_v2_enabledtoo —headless sessions were being told about
TaskCreate, a tool their toolkitdoesn't carry (they expose
TodoWrite).getTaskReminderAttachments—the "task tools haven't been used recently"
<system-reminder>injectedevery 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, persistedvia
on_attachmentso the throttle can find the previous reminder acrossturns.
archiveTodosAtTurnEnd), so thepinned 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
TaskListV2idle). Incompletelists now stay live across turns —
startMessage/resetalready lefttodos alone, so no other lifecycle change was needed. Finished lists
archive exactly as before.
Deliberate deltas from the reference
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
TodoWriteinstead of
TaskCreate— fixing a prompt that named a tool headlesssessions 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.
reference ships collapsed (
showExpandedTodosdefaults false) andpersists 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 flatdict with no
displayblock, meaningapplyDisplay's entireconfig.displaysurface (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).
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_loopand assertsthe reminder reaches the provider.
bare-nothing when toggled off — the click affordance to reopen is worth the
delta.
Also fixed along the way
isMeta—Conversation.add_messagedropped the flag, so a persisted reminder (or plan-mode attachment)
counted as a real user turn in the stats odometer and became a
/rewindboundary (
/rewind 1landed on the reminder, not the user's message).Pre-existing for plan mode; the reminder made it common.
add_messagenowthreads
isMeta, bothon_attachmentcall sites pass it, and rewind'sis_promptskips meta messages.todoIncompletetranscript path (types, theTodoPanel.incompleteprop, the unreachable virtualHeights arm).Follow-up (out of scope)
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'sconfig.displaysurface is dead end-to-end (found inreview):
get_settingsreturns a flat dict,src/config.pyhas nodisplaysection, so everydisplay.*key the TUI reads(
bell_on_complete,tui_statusbar,tui_compact,details_mode, …) iscurrently 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 (includingthe 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 realagent loop with a recording provider: the reminder reaches the model wrapped
in
<system-reminder>and persists viaon_attachment; short conversationsget nothing. This test caught the Brief-gate transplant bug.
tests/test_system_prompt_full.py— per-surface bullet: damped+TodoWriteheadless, 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.
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 fileslint clean; the tree-wide 46 pre-existing lint errors are unchanged.
🤖 Generated with Claude Code