code-yeongyu / code-yeongyu/senpi

Todo widget stays on screen after work is finished, and an emptied native todo list never removes it

Open
#1,146 1 comment 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.