diff --git a/MEMORY.md b/MEMORY.md index 4825eb1..b20fe0f 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -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-`, 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-`, 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 diff --git a/ctui/plugins/todo/index.tsx b/ctui/plugins/todo/index.tsx index faf40c3..e9415c1 100644 --- a/ctui/plugins/todo/index.tsx +++ b/ctui/plugins/todo/index.tsx @@ -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, { 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) diff --git a/ctui/tests/roles.test.tsx b/ctui/tests/roles.test.tsx index 23068de..3341aac 100644 --- a/ctui/tests/roles.test.tsx +++ b/ctui/tests/roles.test.tsx @@ -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) => { diff --git a/ctui/tests/sidebar.test.tsx b/ctui/tests/sidebar.test.tsx index 6c3eead..fd14e22 100644 --- a/ctui/tests/sidebar.test.tsx +++ b/ctui/tests/sidebar.test.tsx @@ -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) diff --git a/ctui/tests/todo-sidebar.test.tsx b/ctui/tests/todo-sidebar.test.tsx index 2b0ae74..b6beeb4 100644 --- a/ctui/tests/todo-sidebar.test.tsx +++ b/ctui/tests/todo-sidebar.test.tsx @@ -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>) { - 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', @@ -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) => { @@ -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' }) @@ -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) => { @@ -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() }) @@ -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 diff --git a/docs/configuration.md b/docs/configuration.md index f03795a..6cf5be9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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. | diff --git a/docs/design/sidebar-3-all-expanded.png b/docs/design/sidebar-3-all-expanded.png index 3558704..f77774e 100644 Binary files a/docs/design/sidebar-3-all-expanded.png and b/docs/design/sidebar-3-all-expanded.png differ diff --git a/docs/design/sidebar.html b/docs/design/sidebar.html index 3aca4c8..244f072 100644 --- a/docs/design/sidebar.html +++ b/docs/design/sidebar.html @@ -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')}`), ], }, { diff --git a/docs/testing/live-checks.md b/docs/testing/live-checks.md index 110111b..ef75dc6 100644 --- a/docs/testing/live-checks.md +++ b/docs/testing/live-checks.md @@ -76,7 +76,7 @@ Fullscreen means the `"tui": "fullscreen"` setting or `CLAUDE_CODE_NO_FLICKER=1` | The previous row | Fold MCP | The header's right side reads each state's glyph and count in its color, `●` first: `● 2 ✕ 1 ○ 1`. | | The previous row, a second repo where `/mcp disable` turned off a different server | `/cd` into the second repo | Within a second or two the `off` rows are the second repo's, and the first repo's `off` row is gone, with no `connecting` row for it. | | The previous row | List the processes under `claude` (`pstree -a `) for 10 s | Only the MCP servers and short `git` runs: no `claude -p` and no `claude mcp list`. | -| Sidebar docked, Task tools on (the default), the default model (Opus 5.5), a new session | Look at Todo, then ask Claude to plan three steps with its task tools and work through them | Before the plan, Todo's header is a bare `Todo:` with no count, and its one row reads `no tasks yet` in `inactive`. Rows appear as Claude creates the tasks, in its order: `⠶` in `subtle` with text in `inactive` for pending; a spinner turning through `⠴ ⠦ ⠖ ⠲` in `warning`, about ten frames a second, with the task's spinner text (`Running the tests`) in `text` for the one in progress; `✓` with text, both in `inactive`, once done. The header counts done over total (`1/3`). A task created after every task is done starts a fresh list, as Claude Code's own list (ctrl+t) does. | +| Sidebar docked, Task tools on (the default), the default model (Opus 5.5), a new session | Look at Todo, then ask Claude to plan three steps with its task tools and work through them | Before the plan, Todo's header is a bare `Todo:` with no count, and its one row reads `no tasks yet` in `inactive`. Rows appear as Claude creates the tasks, in its order: `󰄰` in `subtle` with text in `inactive` for pending; a circle filling up through `󰪞 … 󰪥` in `warning`, about ten frames a second, with the task's spinner text (`Running the tests`) in `text` for the one in progress; `󰄳` with text, both in `inactive`, once done. The header counts done over total (`1/3`). A task created after every task is done starts a fresh list, as Claude Code's own list (ctrl+t) does. | | The previous row, a task in progress | Focus the Pane (ctrl+x tab); then, unfocused, switch `/theme` | Focused, the spinner keeps turning. After the switch it turns in the new theme's `warning` within 0.5 s. | | The previous row | Fold Todo | The header's right side reads `1/3 · ` in `inactive`, or `3/3` once nothing is in progress. | | Sidebar docked, Todo expanded | Ask Claude to plan ten steps with its task tools; click `▸ 2 more`, then `▾ show less` | Todo shows its first 8 rows, then `▸ 2 more`. The click shows all 10 and `▾ show less`; the second click goes back to 8. |