Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,11 @@ Guidance:
- Add the `userConfig` boolean `todo_tools` (title `Task tools`, default `true`), its description naming Opus 5.x and `CLAUDE_CODE_ENABLE_TODO_TOOLS`. It is independent of `todo_enable`.
- On `session.start`, which fires again on each reload with new `options`: when `todo_tools` is on and the variable is unset, call `$.env.set('CLAUDE_CODE_ENABLE_TODO_TOOLS', '1')` and record that in `$.state`. When it is off, unset the variable only if `$.state` records that ctui set it, so a value the person set stays. `/clear` empties `$.state`, so an off toggle after `/clear` takes effect in the next process.
- Gate the section on `$.tool.list()` from the 1 s tick, stored as `todo.tools` in `$.state`: the list follows `/model` within 1 s, with no event, and render hooks read state only. With `TaskList` or `TodoWrite`, read the list as #10 found; an empty list draws a bare `Todo:` title (no count, expanded or folded) and one `muted` row, `no tasks yet`, and before the first read the view stays empty. Todo sets its own `cap: 8`, so a typical plan of 5 to 8 steps shows whole (#209); the other list sections keep the shared 4. `TaskList` lacks `activeForm`, so after `--resume` the in-progress row shows its subject until the next TaskUpdate names one. A TaskCreate after every task is done starts a fresh list (Claude Code drops the finished tasks); the rows follow it. With neither, draw the header without a count and one dim hint: `no task tools on this model` / `turn on ctui Task tools in /config` when `todo_tools` is off, `no task tools in this session` when it is on (`--tools`, a deny rule). Call `TaskList` only when the tool is listed: it throws otherwise.
- Rows: done `✓` with its text, both `muted`; the task in progress a spinner turning through `⠴ ⠦ ⠖ ⠲`, one frame every 100 ms, in `warning`, with its `activeForm` in `main`; waiting `⠶` in `faint` with its text in `muted`. The spinner is the `spinner.tsx` Client, keyed `spinner-<id>`, animating on its surface's own frame clock (one `surface.every`), so the Sidebar doesn't redraw; it isn't pressable, so it stays a Client while the Pane is focused. The folded summary stays text.
- Rows: done `󰄳` (nf-md-checkbox_marked_circle) with its text, both `muted`; the task in progress a spinner turning through `󰪞 … 󰪥` (nf-md-circle_slice_1 to 8), one frame every 100 ms, in `warning`, with its `activeForm` in `main`; waiting `󰄰` (nf-md-checkbox_blank_circle_outline) in `faint` with its text in `muted`. Nerd Font icons with no fallback, as the git header's. The spinner is the `spinner.tsx` Client, keyed `spinner-<id>`, animating on its surface's own frame clock (one `surface.every`), so the Sidebar doesn't redraw; it isn't pressable, so it stays a Client while the Pane is focused. The folded summary stays text.
- While `todo_tools` is on, `tool.describe` answers `{ ...e, isDeferred: false }` for TaskCreate, TaskUpdate and TodoWrite (TaskList, TaskGet and TaskStop stay deferred), and a pure `prompt.compose` hook appends `{ id: 'ctui:todo', scope: 'session' }` when `e.tools` holds TaskCreate or TodoWrite, naming TodoWrite for both steps when the session has it instead. Pure means the same text every call and no `$.state` writes: it also runs for `/context`'s measuring render. Both are gated on `todo_tools` alone, not `todo_enable`; off, ctui answers `next(e)` unchanged and adds no section, even when the person set the variable. `tool.describe` answers are cached per session, so `session.start` calls `$.ui.invalidate('tool.describe')` on each reload, and ctui's hooks follow a toggle at once. Claude Code 2.1.292 keeps a conversation's first system prompt, and never drops a tool that request sent inline (its `prompt_snapshot`), until `/clear` or a new session: a toggle on adds the pin at once and the section at `/clear`; a toggle off changes nothing Claude gets until `/clear`. No `$.ui.invalidate` event changes that (`prompt.section` tried), so the README, the `userConfig` description and the live check say a change reaches Claude from the next `/clear`. `/context`'s totals don't show the change: check with Claude's own answer or the request.
- `ctui/README` discloses that the default gives Claude the Task tools on Opus 5.x and how to turn it off, names the variable for people who set it in `settings.json` `env`, and says the tools and the section are kept in Claude's prompt, what that costs, and that off stops both.

Reason: The human chose this in #21 after a live spike (`prototypes/research-21/`, 2.1.288). In #51 the human chose the tick over a render-time check: one tool-list call per second instead of one per redraw, and one source for the hint and the rows. Without the variable, the 5.x models (Opus 5.5, the human's default, and Sonnet 5.5) have no task tools, so the section would always be empty. After `$.env.set`, Opus 5.5 got the tools at once and used them; unsetting removed them. `/plugin install` shows a "Configure ctui" screen listing every `userConfig` field, defaulted ones too, with the focused field's description: that screen is where ctui suggests the tools. The tools are pinned and the section added because the tools alone don't make a list: on Opus 5.5 Claude made no list unprompted in 3 of 3 runs (#206), and in #220 the pin alone made 0 of 6 lists, the pin plus ctui's section 12 of 12 and none for one-step work. Together they cost about 1.2k cached tokens per request. The snapshot shows only in the requests themselves (a logging proxy as `ANTHROPIC_BASE_URL` needs `ENABLE_TOOL_SEARCH=true`, or Claude Code sends every tool in full) (#216); the human chose on 2026-10-09 to document `/clear` instead of a restart-free toggle. Subagents and child processes, a nested `claude -p` included, inherit the variable. The spinner and `⠶` are the human's picks from the Todo spinner spike on 2026-10-09 (`prototype/todo-spinner`, `6fa9e40`): `⠴⠦⠖⠲` at 100 ms over other spinners and speeds, `warning` over `accent`, `main`, `muted` and `success`, and `⠶` over `○` for waiting.
Reason: The human chose this in #21 after a live spike (`prototypes/research-21/`, 2.1.288). In #51 the human chose the tick over a render-time check: one tool-list call per second instead of one per redraw, and one source for the hint and the rows. Without the variable, the 5.x models (Opus 5.5, the human's default, and Sonnet 5.5) have no task tools, so the section would always be empty. After `$.env.set`, Opus 5.5 got the tools at once and used them; unsetting removed them. `/plugin install` shows a "Configure ctui" screen listing every `userConfig` field, defaulted ones too, with the focused field's description: that screen is where ctui suggests the tools. The tools are pinned and the section added because the tools alone don't make a list: on Opus 5.5 Claude made no list unprompted in 3 of 3 runs (#206), and in #220 the pin alone made 0 of 6 lists, the pin plus ctui's section 12 of 12 and none for one-step work. Together they cost about 1.2k cached tokens per request. The snapshot shows only in the requests themselves (a logging proxy as `ANTHROPIC_BASE_URL` needs `ENABLE_TOOL_SEARCH=true`, or Claude Code sends every tool in full) (#216); the human chose on 2026-10-09 to document `/clear` instead of a restart-free toggle. Subagents and child processes, a nested `claude -p` included, inherit the variable. The spinner and `⠶` are the human's picks from the Todo spinner spike on 2026-10-09 (`prototype/todo-spinner`, `6fa9e40`): `⠴⠦⠖⠲` at 100 ms over other spinners and speeds, `warning` over `accent`, `main`, `muted` and `success`, and `⠶` over `○` for waiting. Asked for bigger icons, the human chose Nerd Font icons over fuller one-cell glyphs and double-size text, then N2 Material circles over Font Awesome and Codicons in the Todo icons spike on 2026-10-09 (`prototype/todo-icons`, `1f5a020`, #225): a terminal cell can't grow, and these fill more of it.

## While docked with Todo on, ctui runs Claude's task calls itself to hide Claude Code's list

Expand Down
12 changes: 7 additions & 5 deletions ctui/plugins/todo/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ import type { Todo, TodoItem } from '../../types'
import type { Role } from '../colors'
import type { SidebarPlugin } from '../plugin'

// A done or waiting item's glyph and its role, and the role of its text.
// A done or waiting item's Nerd Font glyph (nf-md-checkbox_marked_circle,
// nf-md-checkbox_blank_circle_outline) and its role, and the role of its text.
const STATUSES: Record<Exclude<TodoItem['status'], 'in_progress'>, { glyph: string; role: Role; textRole: Role }> = {
completed: { glyph: '✓', role: 'muted', textRole: 'muted' },
pending: { glyph: '⠶', role: 'faint', textRole: 'muted' },
completed: { glyph: '\u{f0133}', role: 'muted', textRole: 'muted' },
pending: { glyph: '\u{f0130}', role: 'faint', textRole: 'muted' },
}

// The item in progress's spinner frames, one every `ms` (`spinner.tsx`).
const SPINNER = { frames: ['⠴', '⠦', '⠖', '⠲'], ms: 100 }
// The item in progress's spinner frames, nf-md-circle_slice_1 to 8 (a circle
// filling up), one every `ms` (`spinner.tsx`).
const SPINNER = { frames: Array.from({ length: 8 }, (_, i) => String.fromCodePoint(0xf0a9e + i)), ms: 100 }

const textOf = (item: TodoItem) => (item.status === 'in_progress' ? (item.activeForm ?? item.subject) : item.subject)

Expand Down
6 changes: 3 additions & 3 deletions ctui/tests/roles.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,14 +143,14 @@ test('the git header: glyphs muted, counts in their roles, lines changed muted',
])
})

test('bars: the empty part faint ─, the percent main; waiting todo ⠶ faint with muted text, the spinner warning', async ($, on) => {
test('bars: the empty part faint ─, the percent main; waiting todo 󰄰 faint with muted text, the spinner warning', async ($, on) => {
const { runs: all } = await draw($, on)
// Shorter than a rule between sections, which spans the body.
const empty = all.filter((run) => /^─+$/.test(run.text) && run.text.length < 38)
expect(empty.map((run) => run.color)).toEqual(['subtle', 'subtle'])
expect(colorOf(all, '⠶')).toEqual(['subtle'])
expect(colorOf(all, '󰄰')).toEqual(['subtle'])
expect(colorOf(all, 'Open the PR')).toEqual(['inactive'])
expect(colorOf(all, '⠴')).toEqual(['warning'])
expect(colorOf(all, '󰪞')).toEqual(['warning'])
})

test('a rule between every two sections draws faint across the body', async ($, on) => {
Expand Down
4 changes: 2 additions & 2 deletions ctui/tests/sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,12 +245,12 @@ const CAPS: SidebarData = {
mcp: Array.from({ length: 5 }, (_, i) => ({ server: `s${i}`, label: `server ${i}`, source: 'user', state: 'ok' as const, tools: 3 })),
}
const capsPlugins = ['git', 'todo', 'mcp', 'versions'].map((id) => plugins.find((plugin) => plugin.id === id)!)
const tasksOf = (sizes: (string | number)[]) => sizes.filter((size) => typeof size === 'string' && size.startsWith('⠶ '))
const tasksOf = (sizes: (string | number)[]) => sizes.filter((size) => typeof size === 'string' && size.startsWith('󰄰 '))

test('Todo caps at its own 8 rows; another list section keeps 4', async ($, on) => {
const sizes = layout((await draw($, on, { plugins: capsPlugins, data: CAPS, bodyRows: 60 })).tree)
const tasks = tasksOf(sizes)
expect(tasks).toEqual(Array.from({ length: 8 }, (_, i) => `⠶ task ${i}`))
expect(tasks).toEqual(Array.from({ length: 8 }, (_, i) => `󰄰 task ${i}`))
expect(sizes[sizes.indexOf(tasks[7]!) + 1]).toBe('▸ 1 more')
const servers = sizes.filter((size) => typeof size === 'string' && size.startsWith('● server'))
expect(servers).toHaveLength(4)
Expand Down
24 changes: 12 additions & 12 deletions ctui/tests/todo-sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,15 @@ async function draw($: Engine, on: On, list: Todo, tools = true) {
// The text of each todo row: a done or waiting row's glyph and text, and the
// text after the spinner, which draws in its own Client.
async function rowsOf(pane: Awaited<ReturnType<typeof draw>>) {
const texts = await pane.findAll({ type: 'Text', text: /^([✓⠶] | \S)/ })
const texts = await pane.findAll({ type: 'Text', text: /^([\u{f0133}\u{f0130}] | \S)/u })
return texts.map((text) => text.text)
}

test('rows in list order: ✓ muted, the spinner with its activeForm, ⠶ faint with muted text', async ($, on) => {
test('rows in list order: 󰄳 muted, the spinner with its activeForm, 󰄰 faint with muted text', async ($, on) => {
const pane = await draw($, on, LIST)
expect(await rowsOf(pane)).toEqual(['✓ Write the parser', ' Running the tests', '⠶ Open the PR'])
expect(await rowsOf(pane)).toEqual(['󰄳 Write the parser', ' Running the tests', '󰄰 Open the PR'])
const glyph = async (text: string) => (await pane.find({ type: 'Text', text: new RegExp(`^${text}$`) }))?.props
expect((await Promise.all(['✓', '⠴', '⠶'].map(glyph))).map((props) => props?.color)).toEqual([
expect((await Promise.all(['󰄳', '󰪞', '󰄰'].map(glyph))).map((props) => props?.color)).toEqual([
'inactive',
'warning',
'subtle',
Expand All @@ -62,7 +62,7 @@ test('rows in list order: ✓ muted, the spinner with its activeForm, ⠶ faint
expect((await glyph(' Running the tests'))?.color).toBe('text')
expect((await glyph('Open the PR'))?.color).toBe('inactive')
// The spinner and its text share one row.
expect((await pane.findAll({ type: 'Box', text: /^⠴ Running the tests$/ })).length).toBeGreaterThan(0)
expect((await pane.findAll({ type: 'Box', text: /^󰪞 Running the tests$/ })).length).toBeGreaterThan(0)
})

test('an in-progress item without an activeForm shows its subject', async ($, on) => {
Expand Down Expand Up @@ -185,18 +185,18 @@ test('TaskCreate and TaskUpdate draw rows, with activeForm from the inputs', asy
await $.tool.call({ tool: 'TaskUpdate', taskId: '1', status: 'in_progress' })
await clock.settle()
await pane.redraw()
expect(await rows()).toEqual([' Doing alpha', '⠶ beta'])
expect(await rows()).toEqual([' Doing alpha', '󰄰 beta'])
// The spinner turns on its own frame clock, keyed by the task's id.
const spinner = await pane.find({ type: 'Client', key: 'spinner-1' })
expect(spinner?.props.module).toBe('plugins/todo/spinner.tsx')
expect(spinner?.props.props).toEqual({ frames: ['⠴', '⠦', '⠖', '⠲'], ms: 100, color: 'warning' })
expect((await pane.find({ type: 'Text', in: 'spinner-1' }))?.text).toBe('⠴')
expect(spinner?.props.props).toEqual({ frames: ['󰪞', '󰪟', '󰪠', '󰪡', '󰪢', '󰪣', '󰪤', '󰪥'], ms: 100, color: 'warning' })
expect((await pane.find({ type: 'Text', in: 'spinner-1' }))?.text).toBe('󰪞')

await $.tool.call({ tool: 'TaskUpdate', taskId: '1', status: 'completed' })
await $.tool.call({ tool: 'TaskUpdate', taskId: '2', status: 'in_progress', activeForm: 'Doing beta' })
await clock.settle()
await pane.redraw()
expect(await rows()).toEqual(['✓ alpha', ' Doing beta'])
expect(await rows()).toEqual(['󰄳 alpha', ' Doing beta'])
expect(await count()).toBe('1/2')

await pane.pointer({ type: 'down', x: 0, y: 0, button: 'left', in: 'fold-todo' })
Expand All @@ -213,7 +213,7 @@ test('a list held before session.start draws at once, as after --resume or a rel
await start($)
await clock.settle()
const { rows } = await sidebarOf($)
expect(await rows()).toEqual(['✓ alpha', '⠶ beta'])
expect(await rows()).toEqual(['󰄳 alpha', '󰄰 beta'])
})

test('/model to a model with the tools loads the list within a second', async ($, on) => {
Expand All @@ -227,7 +227,7 @@ test('/model to a model with the tools loads the list within a second', async ($
world.tools = ['TaskList']
await clock.advance(1000)
await pane.redraw()
expect(await rows()).toEqual(['⠶ alpha'])
expect(await rows()).toEqual(['󰄰 alpha'])
expect(await text('no task tools in this session')).toBeUndefined()
})

Expand Down Expand Up @@ -267,7 +267,7 @@ test('TodoWrite feeds the list when the Task tools are off', async ($, on) => {
})
await clock.settle()
await pane.redraw()
expect(await rows()).toEqual(['✓ alpha', ' Doing beta'])
expect(await rows()).toEqual(['󰄳 alpha', ' Doing beta'])
})

// Hiding Claude Code's own list (MEMORY.md "While docked with Todo on, ctui runs
Expand Down
4 changes: 2 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,11 @@ ctui draws with Claude Code's theme colors, so it follows the theme you pick in
|---|---|
| `text` | The Sidebar's main text: the path, the branch, `↑N ↓N`, section titles, bar percents, the Limits labels, MCP names, skill names, the task in progress, agent names, the versions. |
| `success` | Git `+staged` and `+added` lines, a bar's fill below 50%, MCP `●`, a finished agent or shell `✓`. |
| `warning` | Git `!modified`, a bar's fill from 50% to 85%, MCP `!` and `◐`, the in-progress task's `⠴⠦⠖⠲` spinner, a running agent or shell `◐`, an interrupted tool row. |
| `warning` | Git `!modified`, a bar's fill from 50% to 85%, MCP `!` and `◐`, the in-progress task's `󰪞…󰪥` spinner, a running agent or shell `◐`, an interrupted tool row. |
| `error` | Git `-removed` lines, a bar's fill from 85%, MCP `✕`, a failed agent or shell `✗`, a failed tool row. |
| `suggestion` | Git `≡stashes`. |
| `inactive` | The Git glyphs leading the branch, changeset and lines-changed rows, Git `?untracked` and "lines changed", the fold arrows, section counts and folded summaries, tokens, cost and reset times, MCP sources, `·`, `N tools` and `off`, skill sources, done tasks and pending task text, agent details and elapsed times, `▸ N more` and `↑ more`/`↓ more`, the versions labels. |
| `subtle` | The `─` rules between Sidebar sections and the `═` rule under the git header, the empty `─` part of each bar, MCP `○`, `no skills used yet`, the pending task `⠶`, the `│` in the versions footer. |
| `subtle` | The `─` rules between Sidebar sections and the `═` rule under the git header, the empty `─` part of each bar, MCP `○`, `no skills used yet`, the pending task `󰄰`, the `│` in the versions footer. |
| `promptBorder` | The `┃` bar beside your prompts in the transcript. Claude Code also colors the prompt's `─` rules with it. |
| `userMessageBackground` | The panel behind your prompts in the transcript. |
| `composerSidebarBackground` | The Sidebar's background. Claude Code paints the dock with it, and only a theme picked in `/theme` sets it. |
Expand Down
Binary file modified docs/design/sidebar-3-all-expanded.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 8 additions & 8 deletions docs/design/sidebar.html
Original file line number Diff line number Diff line change
Expand Up @@ -154,14 +154,14 @@
count: span('m', '2/8'),
cap: 8,
list: [
row(span('m', '✓ read sync command')),
row(span('m', '✓ add the flag')),
row(`${span('o', '⠴')} write the test`),
row(`${span('f', '⠶')} ${span('m', 'update README')}`),
row(`${span('f', '⠶')} ${span('m', 'add --dry-run to help text')}`),
row(`${span('f', '⠶')} ${span('m', 'check Windows paths')}`),
row(`${span('f', '⠶')} ${span('m', 'run full test suite')}`),
row(`${span('f', '⠶')} ${span('m', 'write changelog entry')}`),
row(`${span('m', '\u{f0133}')} ${span('m', 'read sync command')}`),
row(`${span('m', '\u{f0133}')} ${span('m', 'add the flag')}`),
row(`${span('o', '\u{f0aa1}')} write the test`),
row(`${span('f', '\u{f0130}')} ${span('m', 'update README')}`),
row(`${span('f', '\u{f0130}')} ${span('m', 'add --dry-run to help text')}`),
row(`${span('f', '\u{f0130}')} ${span('m', 'check Windows paths')}`),
row(`${span('f', '\u{f0130}')} ${span('m', 'run full test suite')}`),
row(`${span('f', '\u{f0130}')} ${span('m', 'write changelog entry')}`),
],
},
{
Expand Down
Loading
Loading