Repository navigation
feat(tui): your own Todos and Notes panes and the Pepper reply strip (P1b) - #167
Conversation
…(P1b) Top-level Todos and Notes rows above Pepper (a rule, no dot, the marker while open) open your own panes through P1a's AppPane. Your Todos list each room's open items in a section per room (new PepperClient.list_room_todos) and merge every closed item into one fold; a failed rooms read keeps your own items under one line. /todo and /notes in the Pepper DM open them, and Ctrl+K lists them first. The Pepper composer stays under your own panes and sends to the DM with subject app:todo or app:notes (and client_context.note_id over an open note). A strip at the pane's foot holds the current exchange and the one before it, and the list is read again when the turn ends. Tab goes sidebar, pane, composer; Esc in the composer moves to the pane and never closes it. Refs #166, #161, #157. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QNDeh8M8jKfHhtWGJwkxTm
…ts on Pepper The strip kept saying "checking for the answer" after a dropped turn gave up until the next poll. The sidebar's first draw rested on the first row (now Todos) instead of the open conversation. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QNDeh8M8jKfHhtWGJwkxTm
A failed rooms re-read keeps the sections on screen; a non-object item in /todos/rooms is skipped; a history lookup that finds nothing no longer erases the reply the stream gave; a Ctrl+K jump moves the sidebar cursor to the row it opened. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QNDeh8M8jKfHhtWGJwkxTm
Vycari Review (shadow)Shadow round 3 on f57c9db. This comment is non-gating and needs no response: it is collected to measure agreement with the current reviewer. The round's verdict is the Vycari Review (shadow) check run. Open: P0 0 · P1 2 · P2 5 · confirmed fixed 0 · withdrawn 0 Judge's notes Two P1 findings from the last round are still open at this head: opening your own Todos or Notes from a room still clears the Pepper unread dot, and after a dropped stream the reply strip can show an unrelated Pepper message as the answer. The only new finding is a P2 where the strip jumps to its bottom on every redraw. P1 · VR-27f75f32 · Opening an own pane clears unseen Pepper repliessrc/pepper_cli/tui/app.py lines 1900–1902 · symbol PepperApp._open_own_pane · lens cross-file-contract · status still-open Entering Todos or Notes from a room calls open_conversation(PEPPER), which unconditionally clears dm.dot before the own pane replaces the DM. The updated polling logic preserves unread indicators for newly arriving hidden replies, but cannot restore this cleared indicator because the unread messages already exist in dm.entries. Evidence: The new entry path calls open_conversation at app.py:1900-1902. That existing function clears dm.dot at app.py:1032-1034; _show_pane then hides the thread without retaining its exchanges. The follow-up fix at app.py:814-815 correctly treats an own pane as hiding the DM, but DmTracker.update at state.py:709-717 only raises the dot for newly discovered assistant IDs. Already cached unread replies therefore never restore it. This is an uncovered entry-order variant of the earlier unread-indicator finding. Re-review (fix-incomplete): _on_dm now treats an own pane as hiding the DM and counts only the strip's replies as seen. But entering from a room still clears the dot through open_conversation(PEPPER), and that dot is never restored (C04). Failure scenario While viewing a room, receive a Pepper assistant message and wait for its unread dot. Open the top-level Todos or Notes row directly. The Pepper dot disappears although neither the message nor its exchange appears in the pane. Subsequent unchanged history polls leave the dot cleared, and next-unread navigation skips Pepper. How this was verified (traced-callers) Read open_conversation. _open_own_pane at app.py:1900-1902 calls it when the current conversation is not Pepper, and it clears the dot unconditionally. The own pane then covers the DM. Reachable from: Sidebar own_app row while viewing a room. Proving test P1 · VR-e0f9c2ac · Reply strip can show another assistant message as this turn's answersrc/pepper_cli/tui/state.py lines 936–948 · symbol DmTurn.found_reply · lens cross-file-contract · status still-open When a turn's stream drops before the reply frame, assistant_id is None. reply_landed() then treats ANY assistant row not in the send-time snapshot as this turn's reply, and found_reply() returns the first such row in dict order. app._settle_turns() copies that text into turn.reply and marks the turn done, and app._turn_finished() then draws it in the own-pane reply strip (strip_items) directly under the user's question. The row may belong to a different message, such as a scheduled task or another client's Pepper message posted into the same DM during the checking window. Evidence: Read app._turn_ended (app.py ~2274-2280): the else branch sets phase 'checking' with no assistant_id. app._settle_turns (app.py 2307-2331): checking + reply_landed -> turn.reply = turn.found_reply(entries) or turn.reply, phase 'done', then _turn_finished. state.DmTurn.reply_landed/found_reply (state.py 929-948): no assistant_id -> first assistant row not in snapshot, no text or time match. apps_pane.strip_items (apps_pane.py, new) renders turn.reply as the answer. The DM's own queue already had this ambiguity, but the strip is new and presents it as the answer to the user's own question. I did not find a guard that matches the reply to the sent text. Trigger needs a dropped stream and a second assistant row inside the window, so the harm is conditional, but the wrong answer is shown to the user as Pepper's reply. Failure scenario User opens Todos from the top-level row and types 'add buy stamps'. The stream drops before the reply frame, so the turn enters phase 'checking' with assistant_id None and a snapshot taken at send time. Meanwhile a proactive assistant message (for example a scheduled reminder) lands in the Pepper DM. On the next DM poll, reply_landed() is True, found_reply() returns the reminder's text, and the strip shows 'you: add buy stamps / Pepper: <reminder text>'. The real answer is never shown in the strip, and the turn is retired as answered. How this was verified (traced-callers) Same code as C01: state.py:936-948 and app.py:2323. Reachable from: own pane send with stream drop. Proving test P2 · VR-1d08ff2b · Reopening your own pane drops the in-flight exchange from its reply stripsrc/pepper_cli/tui/app.py lines 1904–1919 · symbol PepperApp._show_pane · lens concurrency-time · status still-open _show_pane builds a fresh AppPaneState for every open (via _open_own_pane, state.py AppPaneState(owner=None, slug=slug)), and its exchanges list defaults to empty. _send_turn records a turn in pane.exchanges (remember_exchange) only on the pane that was on screen at send time. Closing the pane (_hide_pane sets self.pane=None) discards that list. If the turn is still streaming or queued when the user reopens Todos or Notes, the new pane's strip never contains that turn, so the question and its 'waiting/…' state are not shown anywhere under the pane. When the reply lands, _on_dm sees self.pane is not None, so seen is empty and the Pepper dot lights for a reply the strip should have carried. Evidence: app.py:1894-1902 (_open_own_pane builds AppPaneState(owner=None, slug=slug) with no exchanges), app.py:1904-1919 (_show_pane sets self.pane to that new object; no carry-over of exchanges from a previous pane), app.py:2178-2180 (_send_turn appends to pane.exchanges only when self.pane is non-None at send time), state.py:1147-1167 (exchanges default_factory=list; 'Dropped with the pane'), app.py:1928-1939 (_hide_pane sets self.pane=None), app.py:684-690 / _on_dm seen logic (seen computed only from pane.exchanges, so an empty list lights the dot). I checked for a lookup of the in-flight turn from self.turns during _render_strip: _render_strip reads only pane.exchanges, so the in-flight turn is not reached. The reply is still shown in the DM after the pane closes, which is why this is P2 rather than P1. Failure scenario 1) User opens own Todos, types 'add buy stamps' and presses Enter; the turn starts streaming (strip shows 'you: add buy stamps / Pepper: …'). 2) User presses Esc (closes the pane, back to the DM). 3) User reopens Todos from the sidebar while Pepper is still working. The strip is empty: the question is not shown and nothing says a turn is pending. 4) The reply arrives: _turn_finished runs, _render_strip redraws an empty strip, and the Pepper row's dot lights although the reply was meant to be seen behind the pane. How this was verified (traced-callers) Read _open_own_pane/_show_pane/_hide_pane. Reopening after a close creates a fresh pane with default exchanges; _render_strip reads only pane.exchanges. Reachable from: Sidebar own_app row selected -> app.py:976 _open_own_pane -> _show_pane. Proving test P2 · VR-23b3e976 · Unchanged pane refreshes interrupt reading long repliessrc/pepper_cli/tui/apps_pane.py lines 153–155 · symbol AppPane.show_strip · lens concurrency-time · status new Every strip redraw unconditionally scrolls to the bottom, including redraws caused by polling or list navigation when the exchanges have not changed. A user's scroll position is therefore discarded during ordinary pane updates. Evidence: Successful todo reads call _render_pane at src/pepper_cli/tui/app.py:2025-2030, which always calls show_strip at app.py:1986. Selection changes take the same path at app.py:2101-2108. show_strip calls scroll_end regardless of whether reply content changed at apps_pane.py:153-155. The strip explicitly supports wheel scrolling at apps_pane.py:110-115, so its non-focusable status does not prevent this scenario. Failure scenario Receive a reply longer than the strip's eight-line cap, then scroll upward with the mouse wheel to read its beginning. The next 30-second pane poll redraws the unchanged strip and jumps to its bottom, moving the text being read off screen. Moving the todo selection also triggers this jump. How this was verified (traced-callers) The diff shows _render_pane always calling host.show_strip(...), and show_strip calling strip.scroll_end(animate=False) unconditionally. _got_todos reaches _render_pane on every successful poll. Reachable from: own Todos pane: 30s apps poll, or selection move, calls _render_pane. Proving test P2 · VR-5e093875 · An ask may show twice on your own Todos: once in your list and again in the room's sectionsrc/pepper_cli/tui/state.py lines 1235–1252 · symbol own_todo_sections · lens spec-adversary · status still-open own_todo_sections concatenates your own list (GET /todos, all statuses, no mine filter) with each room's items (GET /todos/rooms) and never dedupes. The design notes (planning/cli-core-coverage.md §4.4, line 153) say an assigned room todo is stored as the assignee's own todo plus a display copy on the room's list. So an ask assigned to you would be drawn under your open items and again under its room's section, and its closed copy twice in the Closed fold. The design (planning/tui-room-todos-notes.md §6.3) says the pane should mirror todo.html, and nothing in the PR's text or tests mentions this overlap. Evidence: Checked: own_todo_sections (state.py ~1235-1252) builds `everything` from `entries` plus every `room.items` with no id or dedup step. pane_rows keys open rows `todo:{e.id}` and does not dedupe across sources. fetch_own_todos (poller.py ~264-281) makes two independent reads with no merge. The design doc §6.3 and cli-core-coverage §4.4 line 153 describe the assignee's own todo plus a display copy, so whether the two ids are shared on the server is unverified: the server is not in the checkout. If the server gives the copy the same id, the duplicate is hidden by the existing 'todo:{id}' keys and this candidate falls away. Existing tests (test_tui_apps_state.py, test_tui_poller.py, test_tui_own_apps.py) use distinct ids and never exercise an ask in both places. Protections looked for: dedupe in own_todo_sections, fetch_own_todos, _got_own_todos, and the stored-doc copy in api_client._room_todo_from_dict; none found. Failure scenario You are assigned 'Book the dog sitter' in room 'Trip planning'. The server stores your own todo for it and a display copy on the room. You open Todos. Your own open list shows 'Book the dog sitter', and the 'Trip planning' section shows the same ask again. Marking one done leaves the other open, and both the own and room rows are keyed separately, so the user sees one ask as two items. How this was verified (read-only-reasoning) own_todo_sections does not dedupe. The server is not in the checkout and planning/cli-core-coverage.md:153 only describes the copy. Proving test P2 · VR-c8a64da4 · One malformed room item hides every room's todos behind a single error linesrc/pepper_cli/api_client.py lines 1960–1975 · symbol ApiClient.list_room_todos · lens fail-open · status still-open list_room_todos builds each item with _room_todo_from_dict, which calls _todo_from_dict and indexes row['id'], row['title'], row['status'], row['source'] and row['created_at'] with no default. One item missing any of these raises KeyError, which escapes the comprehension. fetch_own_todos catches KeyError and returns rooms=None, so every room's section disappears and the pane shows 'Couldn't load your rooms' todos.' even though most rooms read fine. Evidence: api_client.py:1960-1975 comprehension has no per-item try; _todo_from_dict (api_client.py:3915-3939) uses row['id'], row['title'], row['status'], row['source'], row['created_at']. poller.py fetch_own_todos catches (httpx.RequestError, ValueError, KeyError, TypeError) and returns OwnTodos(todos, None). The user sees the explicit ROOMS_FAILED line, so the failure is not silent, but the other rooms' valid items are dropped. The per-item skip that exists for non-dict items is not applied to malformed dict items. Failure scenario The server returns a room listing in which one item lacks created_at (or has a null title). The user has five rooms with open todos. Their Todos pane shows only their own items and the line 'Couldn't load your rooms' todos.', with no room sections, and the 30s re-read repeats the same failure every time. How this was verified (read-only-reasoning) fetch_own_todos catches KeyError and returns rooms=None; the existing test asserts this for a malformed item. Reachable from: own Todos pane read. Proving test P2 · VR-f6a91fd6 · Reply strip holds more than the two exchanges the design specifies once sends queue behind a running turnsrc/pepper_cli/tui/state.py lines 1186–1200 · symbol strip_turns · lens spec-adversary · status still-open Design §6.9 says the reply strip 'holds the exchange in flight and the one before it'. strip_turns instead keeps every turn from the one before the first unfinished turn, so while one turn streams and several more are queued, the strip grows beyond two exchanges. The PR body's 'Departures' list does not mention this change. Evidence: planning/tui-room-todos-notes.md:352-354 (§6.9: 'It holds the exchange in flight and the one before it'). state.py strip_turns: 'Otherwise everything from the one before the first unfinished turn, so sends queued behind a running turn never push its answer out'. The pane-level cap is only the 8-line scroll in styles.tcss, not a turn bound. No decision in the pack sanctions the wider strip. Failure scenario Under own Todos, send Q1 (streaming), then Q2 and Q3 while Q1 is still running. The strip keeps Q1, Q2 and Q3 (the first unfinished turn is index 0, so the slice starts at 0). The spec says only Q1 and its predecessor, so the pane shows three exchanges, and the older ones scroll down the 8-line cap. How this was verified (read-only-reasoning) The strip_turns logic returns everything from the turn before the first unfinished one. That matches the candidate's description and is intentional. Reachable from: Send several messages from an own pane while a turn is running. Proving test |
The strip was drawn while a pane-sent turn was queued and again when it ended, so it kept saying "waiting to send…" while Pepper answered, and a dropped stream (phase checking) never reached it at all. The turn's start and every _turn_finished now redraw the strip alone; the list re-read still waits for the turn to be over. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QNDeh8M8jKfHhtWGJwkxTm
The first redraws arrive faster than a refresh, and each queued its own cursor restore. A restore for replaced nodes could run before the tree rebuilt its lines and leave the cursor on the first row (Todos) rather than the open conversation, and a second early redraw could then keep that default as if it were a choice. That made the two older snapshot tests flaky (they were recorded with the cursor on Todos). Now only the newest redraw's restore acts, it builds the lines before moving, and the tree's default cursor is not kept until a restore has placed one. The snapshot tests wait for the cursor before capturing; the two snapshots change only in which row is under it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QNDeh8M8jKfHhtWGJwkxTm
Design §6.6 says unread dots still move while a pane is open, and §6.9 that a turn already running when the pane opens does not appear in the strip. The DM poll treated the DM as open whenever your own pane sat over it, so that turn's reply cleared the dot and was never signalled. Now the DM counts as open only with no pane over it; the replies the strip carries are passed as seen and don't light it; closing the pane back to the DM clears it. DmTurn.reply_id names a turn's assistant row (the frame's, or after a drop the new one) and found_reply uses it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QNDeh8M8jKfHhtWGJwkxTm
…trip The strip kept the last two submitted turns, so two sends queued behind a slow one pushed it out before its answer arrived. Design §6.9 holds the exchange in flight and the one before it: strip_turns keeps everything from the turn before the first unfinished one, and narrows back to two as the queue drains (on each phase change). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QNDeh8M8jKfHhtWGJwkxTm
After a dropped stream the turn may well have run, and its reply can still land in the DM after the reconcile window; "(no answer)" said otherwise. The strip now reads "(no reply yet; it may still show in the DM)" for that case and keeps "(no answer)" for an error frame, which clears any status text the stream had shown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QNDeh8M8jKfHhtWGJwkxTm
The divider under Todos and Notes is a tree line, so the arrow keys could rest the cursor on it, where Enter does nothing. The sidebar now steps past it either way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QNDeh8M8jKfHhtWGJwkxTm
Review round 1: responseResponding to Greptile review 1 (4/5 on 05a2fe0). Head is now 60158dc. Greptile
Other fixes this round (some from the non-gating Vycari Review shadow comment)
Left as is
Pre-flight: ruff format and check are clean, ty is clean, and pytest gave 2646 passed and 2 skipped with coverage at 95.09% (floor 88). |
…dot first A DM read already in flight when a turn starts can come back holding the reply while the turn still streams. Its id isn't known yet, so it lit the Pepper dot, and the reply frame that then put it in the strip left the dot on. DmTracker now keeps the assistant rows behind the dot; when the strip shows a turn's reply, that row is marked seen, and the dot goes out only if nothing else lit it. A reply found after a dropped stream keeps its id for the same purpose. Also restores the definition line of test_dm_tracker_drops_rows_that_leave_the_history_after_midnight, which af1dd95 dropped by accident, folding that test's body into the one above it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QNDeh8M8jKfHhtWGJwkxTm
Review round 2: responseResponding to Greptile review 2 (5/5 on 60158dc). Head is now f57c9db. Greptile
Pre-flight: ruff format and check are clean, ty is clean, and pytest gave 2650 passed and 2 skipped with coverage at 95.10% (floor 88). |
…todos/rooms shape, sidebar cursor (Vycari#169) Claude-Session: https://claude.ai/code/session_01QNDeh8M8jKfHhtWGJwkxTm Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Human overview
P1b of the TUI Todos/Notes design (
planning/tui-room-todos-notes.md): top-level Todos and Notes rows above Pepper open your own read-only panes, your Todos list each room's open items in a section per room, and the Pepper composer stays under them with a reply strip showing the last two exchanges. Refs #166, #161, #157. No writes (P2, P3).AI reviewer
Diff by subsystem
state.py:own_app/rulesidebar rows ahead of Pepper (keyapp:me:<slug>equals the pane key, so the tree marks it current);own_todo_sections,sectionrows inpane_rows,open_todo_countover rooms too;OwnAppwidensJumpTarget.conversation;pane_subject,pane_placeholder,remember_exchange;DmTurn.subject/contextandfound_reply.api_client.py:list_room_todos(RoomTodos,RoomTodosList),chat_send_stream(subject=, client_context=);turn.run_turnpasses them only when set, so the CLI's other callers send the same body as before.poller.py:fetch_own_todos(two reads; a failed or degraded rooms read returnsrooms=None, a 401 still raises).apps_pane.py: room headings,ReplyStrip(non-focusable, capped at 8 lines),strip_items, a Tab "Composer" binding shown only under own panes.app.py:_open_own_pane/_show_pane, own-pane close returns to the DM,/todo/notesin the Pepper composer, Esc in the composer moves to the pane, turn subject/context, strip upkeep and a list re-read when a pane-sent turn ends (_turn_finished).currentis the Pepper DM, so the existing DM composer, draft and turn queue are reused unchanged.What the doc gets wrong about the code
GET /todos/roomsitems are stored documents (item_to_json), notTodoEntryrows: noassignee/requested_by. The ask parties are rebuilt fromsource_ref(assignee_user_id/assignee_label,requester_id/requester_label, only whensource == "request"), asasks._partydoes.RoomTodoscannot be schema-checked; the# MIRROR:comment sits in the method and is allowlisted intests/test_api_contract.py.Departures
Where to look hardest:
open_conversation's same-conversation branch (an own pane sits over the DM, so closing must redraw the DM, not a topic);_turn_finished/_settle_turnsfor the strip's checking/gave-up cases;_got_own_todos.Verification (after the last commit):
uv run ruff format --check,uv run ruff check,uv run ty check src/pepper_cli/clean;uv run pytest --cov: 2641 passed, 2 skipped, coverage 95.06 (floor 88). Snapshots: two existing updated for the new rows, two added (own Todos with strip, own Notes with a note open), stable on re-run./simplify: read inline, no changes (the four parallel agents were not launched).Checklist (repo-specific)
uv run ruff format --checkanduv run ruff checkclean (no new lint ignores)uv run ty check src/pepper_cli/cleanuv run pytestpasses; coverage floor in.claude/maintainerd.jsonnot lowered/api/v1usage stays additive-only,# MIRROR:comments updated, unknown fields and frames still toleratedSKILL.mdN/A (no command or output change)🤖 Generated with Claude Code
https://claude.ai/code/session_01QNDeh8M8jKfHhtWGJwkxTm


Confidence Score: 5/5
The PR appears safe to merge; the unread-dot fix preserves unrelated unread replies.Summary
Adds personal Todos and Notes panes above Pepper, with room sections in Todos and a reply strip above the shared composer.
Diagram
sequenceDiagram participant User participant Pane as Personal pane participant Pepper participant Tracker as DM unread tracker User->>Pane: Send a message Pane->>Pepper: Message with app or note context Pepper-->>Tracker: History may arrive before reply frame Pepper-->>Pane: Reply frame Pane->>Tracker: Mark only the displayed reply as seen Pane->>Pepper: Read the list againReviews (3) · Last reviewed commit: "fix(tui): a reply the strip shows is rea..." · Reviewed by Greptile