code-yeongyu / code-yeongyu/senpi
Todo widget stays on screen after work is finished, and an emptied native todo list never removes it
- Dominant language
- TypeScript
- Stars
- 429
- Forks
- 98
- Avg merge
- 5h 3m
- Merged PRs (30d)
- 526
Description
# Todo widget stays on screen after the work is finished, and an emptied native todo list never removes it
## Environment
| | |
|---|---|
| senpi | `@code-yeongyu/senpi@2026.8.25` (installed via `omo-ai`) |
| Component | `dist/core/extensions/builtin/todotools/` (builtin `todotools` extension) |
| OS | Windows 10 x64 (not platform specific) |
| Mode | interactive TUI |
## Summary
The `todo-sidebar` widget keeps rendering above the editor after the agent has finished
working and there is nothing left to act on. Two separate causes:
1. **Sticky by construction.** Widget visibility is a pure function of the persisted todo
state: it is shown whenever *any* task in *any* phase is still `pending` or
`in_progress`. Nothing clears or collapses it when the agent settles, and every todo
operation force-promotes the first `pending` task to `in_progress`, so one leftover item
pins a permanently "active" looking panel to the screen.
2. **A real removal hole.** When the todo list is driven through the native/cursor-style
`todo` call (`{ todos: [...] }`), emptying the list is skipped instead of applied, so
that path can never remove the widget.
## Reproduction
### Case 1 (sticky)
1. Have the agent create a phased list, e.g. 2 phases with 3 tasks each.
2. Let it complete phase 1 and answer the request, leaving phase 2 untouched.
3. The panel stays on screen indefinitely (through idle, later turns, and session reloads),
showing the first leftover task as `in_progress` even though no agent is running.
### Case 2 (native mirror cannot clear)
1. Trigger a `todo` tool call carrying `todos: [ ... ]` (no `op`) so the cursor mirror path
populates the widget.
2. Trigger a `todo` call with `todos: []`.
3. The widget still shows the previous list.
## Root cause
Paths are relative to `dist/core/extensions/builtin/todotools/`.
### Visibility rule
`todo-widget.js`:
```js
function getActivePhase(phases) {
const activeTask = nextActionableTask(phases);
if (!activeTask) return undefined;
return phases.find((phase) => phase.tasks.includes(activeTask));
}
export function getTodoWidgetModel(phases) {
const activePhase = getActivePhase(phases);
if (!activePhase) return undefined;
...
```
`todo-query.js`:
```js
export function nextActionableTask(phases) {
let firstPending;
for (const phase of phases) {
for (const task of phase.tasks) {
if (task.status === "in_progress") return task;
if (!firstPending && task.status === "pending") firstPending = task;
}
}
return firstPending;
}
```
So the panel disappears only when every task in every phase is `completed` or `abandoned`.
### Auto-promotion makes leftovers look active
`todo-query.js` `normalizeInProgressTask()` runs after every mutation
(`todo-operations.js` `applyParams` / `applyOpsToPhases`):
```js
if (inProgressTasks.length > 0) return;
const firstPendingTask = orderedTasks.find((task) => task.status === "pending");
if (firstPendingTask) firstPendingTask.status = "in_progress";
```
An untouched leftover is therefore rendered with the accent/bold "in progress" styling
(`todo-widget-component.js` `formatTask`) while nothing is actually running.
### The widget is only re-synced at four points
`index.js`:
```js
const syncWidget = (ctx, completedTasks = []) => {
const model = getTodoWidgetModel(currentPhases);
ctx.ui.setWidget("todo-sidebar", model ? (tui, theme) => new TodoWidgetComponent(...) : undefined);
};
```
Callers: `session_start`, `session_tree`, the `message_end` cursor mirror, and the `todo`
tool / `/todo` command (`registerTodoTool` and `registerTodoCommand`, `index.js` 55-56).
There is no `agent_end` / `agent_settled` handler, so an idle session never revisits the
decision. State is also restored from session entries via
`getLatestPhasesFromBranchEntries()` on `session_start` / `session_tree`, so the leftover
survives reloads and branch switches.
Removal itself is fine: `setExtensionWidget(key, undefined)` in
`dist/modes/interactive/interactive-mode.js` disposes and deletes the entry, then
re-renders. The panel persists purely because the model is never `undefined`.
### The mirror path cannot clear
`index.js`, `message_end` handler:
```js
const phases = phasesFromCursorTodos(block.arguments?.todos);
if (phases.length === 0) {
continue; // an emptied list is indistinguishable from "no list"
}
setCurrentPhases(phases);
pi.appendEntry(TODO_STATE_ENTRY_TYPE, { schema: "v2", phases });
syncWidget(ctx);
```
`native-todo-mirror.js` returns `[]` both for "not a todo payload" and for "an explicitly
empty list":
```js
return tasks.length > 0 ? [{ name: DEFAULT_PHASE_NAME, tasks }] : [];
```
so clearing through that path is dropped and the stale widget stays.
## Proposed fix
**A. Distinguish "no payload" from "empty list" in the mirror.**
```diff
-const phases = phasesFromCursorTodos(block.arguments?.todos);
-if (phases.length === 0) {
- continue;
-}
+if (!Array.isArray(block.arguments?.todos)) {
+ continue;
+}
+const phases = phasesFromCursorTodos(block.arguments.todos);
setCurrentPhases(phases);
pi.appendEntry(TODO_STATE_ENTRY_TYPE, { schema: "v2", phases });
syncWidget(ctx);
```
With `phases === []`, `getTodoWidgetModel` returns `undefined` and the widget is removed,
which is the intended behavior for an explicitly emptied list.
**B. Stop pinning the panel while nothing is running.** Lowest-risk shape: hide it when the
agent settles and restore it when the next turn starts, so the todo state is untouched and
only the presentation reacts to idleness.
```js
// index.js
pi.on("agent_settled", async (_event, ctx) => {
ctx.ui.setWidget("todo-sidebar", undefined);
});
pi.on("agent_start", async (_event, ctx) => {
syncWidget(ctx);
});
```
(`agent_start`, `agent_end`, and `agent_settled` are already part of the extension event
surface, see `dist/core/extensions/types.d.ts`.)
If keeping the panel visible while idle is intentional, the alternative is to collapse it to
a one-line summary (`Todo: N open`) on settle, and to not apply
`normalizeInProgressTask()`'s promotion when the operation did not explicitly start a task,
so an untouched leftover is not styled as active work.
## Impact
Cosmetic but persistent: the widget consumes up to 10 rows above the editor
(`InteractiveMode.MAX_WIDGET_LINES`) for the rest of the session and reports work as "in
progress" when nothing is running, which also misleads anyone reading a screenshot or a
recorded session. Case 2 is a functional hole: an explicit clear is silently ignored.
## Workaround for users today
`/todo rm` with no argument clears the whole list (`commands.js`, `commit(ctx, [], "/todo rm (all)", { removed: true })`)
and records the "user intentionally cleared the todo list, do NOT recreate it" note, so the
widget disappears and the agent does not repopulate it.
## Source locations (verified against current `main`)
Source for the quoted dist files: `packages/coding-agent/src/core/extensions/builtin/todotools/`
(`index.ts`, `todo-widget.ts`, `todo-query.ts`, `todo-operations.ts`, `native-todo-mirror.ts`, `commands.ts`).
Contributor guide
Research direction
Start in packages/coding-agent/src/core/extensions/builtin/todotools/index.ts, then read native-todo-mirror.ts and the event definitions in dist/core/extensions/types.d.ts. Trace the message_end mirror and agent lifecycle widget updates. Done means an explicitly empty native todo list removes the widget, and settled sessions do not keep the stale panel visible while later turns restore it.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- cli
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100