Toggleable agenda pane: a focus view of the current arc story and Claude's tasks, and an overview of every open todo

A pane with two modes:
claude-coloured header bar and heavy rule. Headed by the arc story for the current branch, or the current git repo when there is no story. Lists Claude's own tasks (mirrored live from TaskCreate/TaskUpdate/TodoWrite, in-progress first), then todos added in this session, the story note's todos, and the rest of this repo's todos.project: prefix for todos outside any repo.It opens in focus when the branch resolves to a story, and in overview otherwise.
/agenda | toggle the pane |
/agenda focus / /agenda overview | open in that mode |
/agenda add <text> [@YYYY-MM-DD] | add a manual todo |
/agenda refresh | re-read arc |
In the pane, titles wrap in full and the pane scrolls; click a title to expand its details, and click its ▢/○ to complete it. Press f/o to switch modes and r to refresh.
dockColumns (in /config, default 40): the width the pane asks for when docked beside the transcript. Dragging the dock to another width wins.
arc on PATH with repo-scoped todos (arc todo list printing { repo, todos }). /agenda add and Claude's add_todo calls through arc's MCP server are tagged with this session and scoped to the session's repo by its origin remote (a repo without one gets global todos). The pane refreshes every minute while open, and after Bash and mcp__arc__* tool calls.
hooks/register.tsx 173 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ArcSnapshot, ClaudeTask, Mode } from '../types'
5import { applyTaskCall, localDate, parseAddArgs, toggled, type TaskCall } from './agenda'
6import { CONTEXT_ARGV, INSTALL_HINT, LIST_ARGV, addArgv, doneArgv, failure, snapshotFrom } from './arc'
7import { FocusView, OverviewView, RIGHT_PADDING, type Actions, type RowEnv } from './views'
8
9const PANE = 'agenda'
10const REFRESH_MS = 60_000
11const DEFAULT_DOCK_COLUMNS = 40
12const ARC_TIMEOUT_MS = 15_000
13const USAGE = 'Usage: /agenda [focus | overview | refresh | add <text> [@YYYY-MM-DD]]'
14
15const mode = atom({ plugin: 'agenda', key: 'mode' } as const, null as Mode | null)
16const tasks = atom({ plugin: 'agenda', key: 'tasks' } as const, [] as ClaudeTask[])
17const arc = atom({ plugin: 'agenda', key: 'arc' } as const, { kind: 'loading' } as ArcSnapshot)
18const expanded = atom({ plugin: 'agenda', key: 'expanded' } as const, [] as string[])
19
20async function isOpen($: EngineInterface): Promise<boolean> {
21 return (await $.ui.panes()).some(pane => pane.id === PANE)
22}
23
24async function runArc($: EngineInterface, argv: readonly string[]) {
25 return $.process.run(argv, { timeoutMs: ARC_TIMEOUT_MS }).catch(() => {
26 throw new Error(INSTALL_HINT)
27 })
28}
29
30async function writeArc($: EngineInterface, argv: readonly string[]): Promise<void> {
31 const failed = failure(argv, await runArc($, argv))
32 if (failed !== null) throw new Error(failed)
33}
34
35async function loadArc($: EngineInterface): Promise<ArcSnapshot> {
36 try {
37 const [context, todos] = await Promise.all([runArc($, CONTEXT_ARGV), runArc($, LIST_ARGV)])
38 return snapshotFrom(context, todos)
39 } catch (error) {
40 return { kind: 'failed', reason: error instanceof Error ? error.message : String(error) }
41 }
42}
43
44async function refresh($: EngineInterface): Promise<void> {
45 const snapshot = await loadArc($)
46 await update($, arc, () => snapshot)
47}
48
49async function refreshIfOpen($: EngineInterface): Promise<void> {
50 if (await isOpen($)) await refresh($)
51}
52
53async function show($: EngineInterface, next: Mode | null, columns: number): Promise<void> {
54 if (next !== null) await update($, mode, () => next)
55 await $.ui.open({ id: PANE, title: 'Agenda', columns })
56 await refresh($)
57}
58
59async function trackTasks($: EngineInterface, call: TaskCall): Promise<void> {
60 await update($, tasks, list => applyTaskCall(list, call))
61}
62
63export const register: Register = (on, options) => {
64 const dockColumns = typeof options.dockColumns === 'number' ? options.dockColumns : DEFAULT_DOCK_COLUMNS
65
66 on('session.start', async ($, e, next) => {
67 await $.command.register({
68 name: 'agenda',
69 description: 'Toggle the agenda pane, or switch it to focus / overview',
70 argumentHint: '[focus | overview | refresh | add <text> [@YYYY-MM-DD]]',
71 immediate: true,
72 })
73 $.clock.every(REFRESH_MS, () => void refreshIfOpen($))
74 void refreshIfOpen($)
75 return next(e)
76 })
77
78 on('command.run', { command: 'agenda' }, async ($, e) => {
79 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
80 switch (verb) {
81 case '':
82 if (await isOpen($)) {
83 await $.ui.close({ id: PANE })
84 return { text: 'Agenda closed.' }
85 }
86 await show($, null, dockColumns)
87 return { text: 'Agenda opened.' }
88 case 'focus':
89 case 'overview':
90 await show($, verb, dockColumns)
91 return { text: `Agenda: ${verb}.` }
92 case 'refresh':
93 await refresh($)
94 return { text: 'Agenda refreshed.' }
95 case 'add': {
96 const parsed = parseAddArgs(rest.join(' '))
97 if (parsed === null) return { text: USAGE }
98 await writeArc($, addArgv(parsed.text, parsed.date, await $.session.id()))
99 await refreshIfOpen($)
100 return { text: `Added: ${parsed.text}${parsed.date === null ? '' : ` (due ${parsed.date})`}` }
101 }
102 default:
103 return { text: USAGE }
104 }
105 })
106
107 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
108 const ran = await next(e)
109 if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true) {
110 await trackTasks($, { tool: 'TodoWrite', todos: ran.result.newTodos })
111 }
112 return ran
113 }).catch(($, e, next) => next(e))
114
115 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
116 const ran = await next(e)
117 if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true) {
118 await trackTasks($, { tool: 'TaskCreate', id: ran.result.task.id, subject: ran.result.task.subject, detail: e.description })
119 }
120 return ran
121 }).catch(($, e, next) => next(e))
122
123 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
124 const ran = await next(e)
125 if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true && ran.result.success) {
126 await trackTasks($, { tool: 'TaskUpdate', id: e.taskId, subject: e.subject, detail: e.description, status: e.status })
127 }
128 return ran
129 }).catch(($, e, next) => next(e))
130
131 on('tool.call', { tool: 'mcp__arc__add_todo' }, async ($, e, next) =>
132 next({
133 ...e,
134 session_id: e.session_id ?? (await $.session.id()),
135 cwd: e.cwd ?? (await $.session.cwd()),
136 }),
137 ).catch(($, e, next) => next(e))
138
139 on('tool.call', async ($, e, next) => {
140 const ran = await next(e)
141 if (e.tool === 'Bash' || String(e.tool).startsWith('mcp__arc__')) void refreshIfOpen($)
142 return ran
143 }).catch(($, e, next) => next(e))
144
145 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
146 const { Box, Text, Button } = $.ui.resolve(e)
147 const ui = { Box, Text, Button }
148 const snapshot = await read($, arc)
149 const chosen = await read($, mode)
150 const isSomewhere = snapshot.kind === 'loaded' && (snapshot.context !== null || snapshot.repo !== null)
151 const shown: Mode = chosen ?? (isSomewhere ? 'focus' : 'overview')
152 const actions: Actions = {
153 complete: async id => {
154 await writeArc($, doneArgv(id)).catch(error => $.ui.toast(`agenda: ${error instanceof Error ? error.message : error}`))
155 await refresh($)
156 },
157 toggle: key => update($, expanded, keys => toggled(keys, key)).then(() => undefined),
158 switchTo: next => update($, mode, () => next).then(() => undefined),
159 refresh: () => refresh($),
160 }
161 const row: RowEnv = {
162 ui,
163 actions,
164 open: await read($, expanded),
165 columns: e.props.bodyColumns - RIGHT_PADDING,
166 today: localDate(await $.clock.now()),
167 session: await $.session.id(),
168 }
169 if (shown === 'focus') return <FocusView row={row} arc={snapshot} tasks={await read($, tasks)} />
170 return <OverviewView row={row} arc={snapshot} />
171 })
172}
173hooks/agenda.ts 170 lines1import type { ArcTodo, ClaudeTask, TaskStatus } from '../types'
2
3export type DueGroup = 'overdue' | 'today' | 'this week' | 'later' | 'someday'
4
5export const DUE_GROUPS: readonly DueGroup[] = ['overdue', 'today', 'this week', 'later', 'someday']
6
7const DAY_MS = 86_400_000
8
9export function localDate(epochMs: number): string {
10 const d = new Date(epochMs)
11 const pad = (n: number) => String(n).padStart(2, '0')
12 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
13}
14
15function daysBetween(from: string, to: string): number {
16 return Math.round((Date.parse(to) - Date.parse(from)) / DAY_MS)
17}
18
19export function dueGroup(date: string | null, today: string): DueGroup {
20 if (date === null) return 'someday'
21 const days = daysBetween(today, date)
22 if (days < 0) return 'overdue'
23 if (days === 0) return 'today'
24 if (days < 7) return 'this week'
25 return 'later'
26}
27
28export function groupByDue(todos: readonly ArcTodo[], today: string): [DueGroup, ArcTodo[]][] {
29 const sorted = [...todos].sort((a, b) => (a.date ?? '9999').localeCompare(b.date ?? '9999'))
30 return DUE_GROUPS.map(group => [group, sorted.filter(t => dueGroup(t.date, today) === group)] as [DueGroup, ArcTodo[]])
31 .filter(([, members]) => members.length > 0)
32}
33
34const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
35const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
36
37export function shortDate(date: string, today: string): string {
38 const days = daysBetween(today, date)
39 const d = new Date(Date.parse(date))
40 if (days === 1) return 'tomorrow'
41 if (days > 1 && days < 7) return WEEKDAYS[d.getUTCDay()]!
42 return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`
43}
44
45const STATUS_ORDER: Record<TaskStatus, number> = { in_progress: 0, pending: 1, completed: 2 }
46
47export function byStatus(tasks: readonly ClaudeTask[]): ClaudeTask[] {
48 return [...tasks].sort((a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status])
49}
50
51export type TaskCall =
52 | { tool: 'TodoWrite'; todos: readonly { content: string; status: TaskStatus }[] }
53 | { tool: 'TaskCreate'; id: string; subject: string; detail: string }
54 | { tool: 'TaskUpdate'; id: string; subject?: string; detail?: string; status?: TaskStatus | 'deleted' }
55
56export function applyTaskCall(tasks: readonly ClaudeTask[], call: TaskCall): ClaudeTask[] {
57 switch (call.tool) {
58 case 'TodoWrite':
59 return call.todos.map((todo, i) => ({ id: `todo-${i}`, text: todo.content, detail: null, status: todo.status }))
60 case 'TaskCreate':
61 return [...tasks, { id: call.id, text: call.subject, detail: call.detail.trim() || null, status: 'pending' }]
62 case 'TaskUpdate': {
63 const { id, subject, detail, status } = call
64 if (status === 'deleted') return tasks.filter(task => task.id !== id)
65 return tasks.map(task =>
66 task.id === id
67 ? { ...task, text: subject ?? task.text, detail: detail?.trim() || task.detail, status: status ?? task.status }
68 : task,
69 )
70 }
71 }
72}
73
74const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
75
76export function parseAddArgs(args: string): { text: string; date: string | null } | null {
77 const words = args.trim().split(/\s+/).filter(Boolean)
78 const dated = words.findIndex(word => word.startsWith('@') && ISO_DATE.test(word.slice(1)))
79 const date = dated === -1 ? null : words[dated]!.slice(1)
80 const text = words.filter((_, i) => i !== dated).join(' ')
81 return text === '' ? null : { text, date }
82}
83
84export function latestNext(sessions: readonly string[]): string | null {
85 for (const entry of [...sessions].reverse()) {
86 const line = entry.split('\n').find(l => l.startsWith('**Next:**'))
87 if (line !== undefined) return line.slice('**Next:**'.length).trim()
88 }
89 return null
90}
91
92export function wrapWords(text: string, width: number): string[] {
93 const room = Math.max(1, width)
94 const lines: string[] = []
95 let line = ''
96 for (const word of text.split(/\s+/).filter(Boolean)) {
97 const candidate = line === '' ? word : `${line} ${word}`
98 if ([...candidate].length <= room) {
99 line = candidate
100 continue
101 }
102 if (line !== '') lines.push(line)
103 const chars = [...word]
104 while (chars.length > room) lines.push(chars.splice(0, room).join(''))
105 line = chars.join('')
106 }
107 return line === '' && lines.length > 0 ? lines : [...lines, line]
108}
109
110export function toggled(keys: readonly string[], key: string): string[] {
111 return keys.includes(key) ? keys.filter(k => k !== key) : [...keys, key]
112}
113
114const PROJECT_PREFIX = /^([A-Za-z0-9][\w.-]{0,31}):\s+(\S.*)$/s
115
116export type ProjectRow = { todo: ArcTodo; title: string }
117export type ProjectGroup = { project: string | null; rows: ProjectRow[] }
118
119function prefixOf(text: string): { project: string; rest: string } | null {
120 const match = PROJECT_PREFIX.exec(text)
121 return match === null ? null : { project: match[1] ?? '', rest: match[2] ?? '' }
122}
123
124export function repoName(repo: string): string {
125 return repo.split('/').filter(Boolean).at(-1) ?? repo
126}
127
128export function byProject(todos: readonly ArcTodo[]): ProjectGroup[] {
129 const inRepo = todos.filter(todo => todo.repo !== null)
130 const repos = [...new Set(inRepo.map(todo => repoName(todo.repo ?? '')))].sort()
131 const repoGroups = repos.map(project => ({
132 project,
133 rows: inRepo.filter(todo => repoName(todo.repo ?? '') === project).map(todo => ({ todo, title: todo.text })),
134 }))
135 const prefixGroups = byPrefix(todos.filter(todo => todo.repo === null))
136 const loose = prefixGroups.filter(group => group.project === null)
137 const named = [...prefixGroups.filter(group => group.project !== null), ...repoGroups].sort((a, b) =>
138 (a.project ?? '').localeCompare(b.project ?? ''),
139 )
140 return [...loose, ...named]
141}
142
143function byPrefix(todos: readonly ArcTodo[]): ProjectGroup[] {
144 const split = todos.map(todo => ({ todo, prefix: prefixOf(todo.text) }))
145 const named = split.flatMap(({ prefix }) => (prefix === null ? [] : [prefix.project]))
146 const shared = new Set(named.filter((project, i) => named.indexOf(project) !== i))
147 const rows = split.map(({ todo, prefix }) =>
148 prefix !== null && shared.has(prefix.project)
149 ? { project: prefix.project, todo, title: prefix.rest }
150 : { project: null, todo, title: todo.text },
151 )
152 const group = (project: string | null): ProjectGroup => ({
153 project,
154 rows: rows.filter(row => row.project === project).map(({ todo, title }) => ({ todo, title })),
155 })
156 return [group(null), ...[...shared].sort().map(group)].filter(g => g.rows.length > 0)
157}
158
159export type FocusPlace = { session: string; storyNote: string | null; repo: string | null }
160export type FocusSections = { session: ArcTodo[]; story: ArcTodo[]; repo: ArcTodo[] }
161
162export function focusSections(todos: readonly ArcTodo[], place: FocusPlace): FocusSections {
163 const session = todos.filter(todo => todo.session === place.session)
164 const rest = todos.filter(todo => todo.session !== place.session)
165 const story = place.storyNote === null ? [] : rest.filter(todo => todo.note === place.storyNote)
166 const repo =
167 place.repo === null ? [] : rest.filter(todo => todo.note !== place.storyNote && todo.repo === place.repo)
168 return { session, story, repo }
169}
170hooks/arc.ts 40 lines1import type { ProcessRunResult } from 'claude-code'
2
3import type { ArcContext, ArcListing, ArcSnapshot } from '../types'
4
5export const INSTALL_HINT = 'arc is not on PATH: cargo install --path ~/code/arc'
6export const OUTDATED_HINT = 'arc predates repo-scoped todos: reinstall it with cargo install --path ~/code/arc'
7
8export const CONTEXT_ARGV = ['arc', 'context', '--format', 'json'] as const
9export const LIST_ARGV = ['arc', 'todo', 'list', '--format', 'json'] as const
10
11export function doneArgv(id: string): string[] {
12 return ['arc', 'todo', 'done', id]
13}
14
15export function addArgv(text: string, date: string | null, session: string): string[] {
16 return ['arc', 'todo', 'add', text, '--session', session, ...(date === null ? [] : ['--date', date])]
17}
18
19export function failure(argv: readonly string[], ran: ProcessRunResult): string | null {
20 if (ran.exitCode === 0) return null
21 return ran.stderr.trim() || `${argv.join(' ')} exited ${ran.exitCode}`
22}
23
24export function snapshotFrom(context: ProcessRunResult, todos: ProcessRunResult): ArcSnapshot {
25 const failed = failure(CONTEXT_ARGV, context) ?? failure(LIST_ARGV, todos)
26 if (failed !== null) return { kind: 'failed', reason: failed }
27 try {
28 const listing = JSON.parse(todos.stdout) as ArcListing | unknown[]
29 if (Array.isArray(listing)) return { kind: 'failed', reason: OUTDATED_HINT }
30 return {
31 kind: 'loaded',
32 context: context.stdout.trim() === '' ? null : (JSON.parse(context.stdout) as ArcContext),
33 repo: listing.repo,
34 todos: listing.todos,
35 }
36 } catch (error) {
37 return { kind: 'failed', reason: `arc printed unreadable JSON: ${error instanceof Error ? error.message : error}` }
38 }
39}
40hooks/views.tsx 387 lines1import type { BoxProps, ButtonProps, ElementConstructor, RenderChildren, TextProps } from 'claude-code'
2
3import type { ArcContext, ArcSnapshot, ArcTodo, ClaudeTask, Mode, TaskStatus } from '../types'
4import {
5 byProject,
6 byStatus,
7 dueGroup,
8 focusSections,
9 groupByDue,
10 latestNext,
11 repoName,
12 shortDate,
13 wrapWords,
14 type DueGroup,
15} from './agenda'
16
17export type Kit = {
18 Box: ElementConstructor<BoxProps>
19 Text: ElementConstructor<TextProps>
20 Button: ElementConstructor<ButtonProps>
21}
22
23export type Actions = {
24 complete: (id: string) => Promise<void>
25 toggle: (key: string) => Promise<void>
26 switchTo: (mode: Mode) => Promise<void>
27 refresh: () => Promise<void>
28}
29
30export type RowEnv = {
31 ui: Kit
32 actions: Actions
33 open: readonly string[]
34 columns: number
35 today: string
36 session: string
37}
38
39export const RIGHT_PADDING = 1
40const MARKER_COLUMNS = 2
41const PROJECT_INDENT = 1
42const RULE = '┈'
43
44const TASK_GLYPH: Record<TaskStatus, string> = { in_progress: '◐', pending: '▢', completed: '✔' }
45const TASK_WORD: Record<TaskStatus, string> = { in_progress: 'in progress', pending: 'pending', completed: 'completed' }
46
47const GROUP_COLOR: Record<DueGroup, TextProps['color']> = {
48 overdue: 'error',
49 today: 'warning',
50 'this week': 'subtle',
51 later: 'subtle',
52 someday: 'inactive',
53}
54
55type RowProps = {
56 row: RowEnv
57 rowKey: string
58 marker: RenderChildren
59 title: string
60 indent: number
61 isDim?: boolean
62 trailing?: { text: string; color: TextProps['color'] }
63 details: readonly string[]
64 body: string | null
65}
66
67function ExpandableRow({ row, rowKey, marker, title, indent, isDim, trailing, details, body }: RowProps) {
68 const { Box, Text, Button } = row.ui
69 const isOpen = row.open.includes(rowKey)
70 const room = row.columns - indent - MARKER_COLUMNS - (trailing === undefined ? 0 : trailing.text.length + 1)
71 const lines = wrapWords(title, room)
72 return (
73 <Box key={`row:${rowKey}`} flexDirection="column">
74 <Box gap={1}>
75 {marker}
76 <Box flexGrow={1} flexDirection="column">
77 {lines.map((line, i) => (
78 <Button
79 key={i === 0 ? `open:${rowKey}` : `open:${rowKey}:${i}`}
80 plain
81 dimColor={isDim}
82 onPress={() => row.actions.toggle(rowKey)}
83 >
84 {line}
85 </Button>
86 ))}
87 </Box>
88 {trailing !== undefined && <Text color={trailing.color}>{trailing.text}</Text>}
89 </Box>
90 {isOpen && (
91 <Box key={`detail:${rowKey}`} flexDirection="column" paddingLeft={MARKER_COLUMNS} marginBottom={1}>
92 {body !== null && <Text wrap="wrap">{body}</Text>}
93 <Text dimColor wrap="wrap">
94 {details.join(' · ')}
95 </Text>
96 </Box>
97 )}
98 </Box>
99 )
100}
101
102const GLOBAL_TODOS = 'todos.md'
103
104function todoOrigin(todo: ArcTodo): string {
105 if (todo.repo !== null) return `repo ${repoName(todo.repo)}`
106 return todo.note === GLOBAL_TODOS ? 'global todo' : todo.note
107}
108
109function todoDetails(todo: ArcTodo, row: RowEnv): string[] {
110 const due = todo.date === null ? 'no due date' : `due ${shortDate(todo.date, row.today)} (${todo.date})`
111 const added = todo.session === null ? [] : [todo.session === row.session ? 'added this session' : 'added in another session']
112 return [due, todoOrigin(todo), ...added]
113}
114
115type TodoRowProps = { row: RowEnv; todo: ArcTodo; title: string; indent: number; glyph: string; withDate: boolean }
116
117function TodoRow({ row, todo, title, indent, glyph, withDate }: TodoRowProps) {
118 const { Button } = row.ui
119 const group = dueGroup(todo.date, row.today)
120 return (
121 <ExpandableRow
122 row={row}
123 rowKey={`todo:${todo.id}`}
124 marker={
125 <Button key={`done:${todo.id}`} plain onPress={() => row.actions.complete(todo.id)}>
126 {glyph}
127 </Button>
128 }
129 title={title}
130 indent={indent}
131 isDim={group === 'someday' && withDate}
132 trailing={withDate && todo.date !== null ? { text: shortDate(todo.date, row.today), color: GROUP_COLOR[group] } : undefined}
133 details={todoDetails(todo, row)}
134 body={null}
135 />
136 )
137}
138
139function TaskRow({ row, task }: { row: RowEnv; task: ClaudeTask }) {
140 const { Text } = row.ui
141 return (
142 <ExpandableRow
143 row={row}
144 rowKey={`task:${task.id}`}
145 marker={
146 <Text color={task.status === 'in_progress' ? 'claude' : undefined} dimColor={task.status === 'completed'}>
147 {TASK_GLYPH[task.status]}
148 </Text>
149 }
150 title={task.text}
151 indent={0}
152 isDim={task.status === 'completed'}
153 details={[TASK_WORD[task.status], 'Claude task']}
154 body={task.detail}
155 />
156 )
157}
158
159function Ruled({ row, ruleKey, indent, children }: { row: RowEnv; ruleKey: string; indent: number; children: JSX.Element[] }) {
160 const { Box, Text } = row.ui
161 const rule = RULE.repeat(Math.max(1, row.columns - indent))
162 return (
163 <Box flexDirection="column" paddingLeft={indent}>
164 {children.flatMap((child, i) =>
165 i === 0
166 ? [child]
167 : [
168 <Text key={`rule:${ruleKey}:${i}`} dimColor wrap="truncate-end">
169 {rule}
170 </Text>,
171 child,
172 ],
173 )}
174 </Box>
175 )
176}
177
178function ProjectGroups({ row, todos, groupKey, glyph, withDate }: { row: RowEnv; todos: readonly ArcTodo[]; groupKey: string; glyph: string; withDate: boolean }) {
179 const { Box, Text } = row.ui
180 return (
181 <Box flexDirection="column">
182 {byProject(todos).map(({ project, rows }) => {
183 const indent = project === null ? 0 : PROJECT_INDENT
184 return (
185 <Box key={`project:${groupKey}:${project ?? ''}`} flexDirection="column">
186 {project !== null && (
187 <Box justifyContent="space-between">
188 <Text color="suggestion" bold>
189 {project}
190 </Text>
191 <Text dimColor>{String(rows.length)}</Text>
192 </Box>
193 )}
194 <Ruled row={row} ruleKey={`${groupKey}:${project ?? ''}`} indent={indent}>
195 {rows.map(({ todo, title }) => (
196 <TodoRow row={row} todo={todo} title={title} indent={indent} glyph={glyph} withDate={withDate} />
197 ))}
198 </Ruled>
199 </Box>
200 )
201 })}
202 </Box>
203 )
204}
205
206function Controls({ row, mode }: { row: RowEnv; mode: Mode }) {
207 const { Box, Button } = row.ui
208 const other: Mode = mode === 'focus' ? 'overview' : 'focus'
209 return (
210 <Box gap={2}>
211 <Button key="switch" plain dimColor hotkey={other[0]} onPress={() => row.actions.switchTo(other)}>
212 {other}
213 </Button>
214 <Button key="refresh" plain dimColor hotkey="r" onPress={row.actions.refresh}>
215 refresh
216 </Button>
217 </Box>
218 )
219}
220
221function HeaderRule({ row, glyph, color }: { row: RowEnv; glyph: string; color: TextProps['color'] }) {
222 const { Text } = row.ui
223 return (
224 <Text color={color} wrap="truncate-end">
225 {glyph.repeat(Math.max(1, row.columns))}
226 </Text>
227 )
228}
229
230function Section({ ui, label, color, children }: { ui: Kit; label: string; color: TextProps['color']; children: RenderChildren }) {
231 const { Box, Text } = ui
232 return (
233 <Box flexDirection="column" marginTop={1}>
234 <Text color={color} bold>
235 {label}
236 </Text>
237 {children}
238 </Box>
239 )
240}
241
242type FocusTodosProps = { row: RowEnv; label: string; color: TextProps['color']; listKey: string; todos: readonly ArcTodo[] }
243
244function FocusTodos({ row, label, color, listKey, todos }: FocusTodosProps) {
245 if (todos.length === 0) return null
246 return (
247 <Section ui={row.ui} label={label} color={color}>
248 <Ruled row={row} ruleKey={listKey} indent={0}>
249 {todos.map(todo => (
250 <TodoRow row={row} todo={todo} title={todo.text} indent={0} glyph="▢" withDate />
251 ))}
252 </Ruled>
253 </Section>
254 )
255}
256
257function focusTitle(context: ArcContext | null, repo: string | null): string {
258 if (context !== null) return `◉ FOCUS sc-${context.story.id}`
259 return repo === null ? '◉ FOCUS' : `◉ FOCUS ${repoName(repo)}`
260}
261
262function FocusHeader({ row, context, repo }: { row: RowEnv; context: ArcContext | null; repo: string | null }) {
263 const { Box, Text } = row.ui
264 const next = context === null ? null : latestNext(context.sessions)
265 return (
266 <Box flexDirection="column">
267 <Box backgroundColor="claude" paddingX={1} justifyContent="space-between">
268 <Text color="inverseText" bold>
269 {focusTitle(context, repo)}
270 </Text>
271 <Text color="inverseText">{context?.story.state ?? ''}</Text>
272 </Box>
273 <Controls row={row} mode="focus" />
274 <HeaderRule row={row} glyph="━" color="claude" />
275 {context === null ? (
276 <Text dimColor wrap="truncate-middle">
277 {repo ?? 'no repo scope: todos here are global'}
278 </Text>
279 ) : (
280 <Box flexDirection="column">
281 <Text bold wrap="wrap">
282 {context.story.name ?? `story ${context.story.id}`}
283 </Text>
284 <Text dimColor wrap="truncate-middle">
285 {context.branch}
286 </Text>
287 </Box>
288 )}
289 {next !== null && (
290 <Box marginTop={1} gap={1}>
291 <Text color="claude" bold>
292 ➜ NEXT
293 </Text>
294 <Text wrap="wrap">{next}</Text>
295 </Box>
296 )}
297 </Box>
298 )
299}
300
301export function FocusView({ row, arc, tasks }: { row: RowEnv; arc: ArcSnapshot; tasks: readonly ClaudeTask[] }) {
302 const { ui } = row
303 const { Box, Text } = ui
304 const context = arc.kind === 'loaded' ? arc.context : null
305 const repo = arc.kind === 'loaded' ? arc.repo : null
306 const place = { session: row.session, storyNote: context?.note ?? null, repo }
307 const sections = focusSections(arc.kind === 'loaded' ? arc.todos : [], place)
308 const isEmpty = tasks.length + sections.session.length + sections.story.length + sections.repo.length === 0
309 return (
310 <Box key="focus" flexDirection="column" paddingRight={RIGHT_PADDING}>
311 <FocusHeader row={row} context={context} repo={repo} />
312 {tasks.length > 0 && (
313 <Section ui={ui} label="CLAUDE · THIS SESSION" color="claude">
314 <Ruled row={row} ruleKey="tasks" indent={0}>
315 {byStatus(tasks).map(task => (
316 <TaskRow row={row} task={task} />
317 ))}
318 </Ruled>
319 </Section>
320 )}
321 <FocusTodos row={row} label="ADDED THIS SESSION" color="claude" listKey="session" todos={sections.session} />
322 <FocusTodos row={row} label="STORY TODOS" color="text" listKey="story" todos={sections.story} />
323 <FocusTodos
324 row={row}
325 label={`REPO · ${repoName(repo ?? '')}`}
326 color="text"
327 listKey="repo"
328 todos={sections.repo}
329 />
330 {isEmpty && (
331 <Box marginTop={1}>
332 <Text dimColor>nothing in focus yet</Text>
333 </Box>
334 )}
335 <ArcStatus ui={ui} arc={arc} />
336 </Box>
337 )
338}
339
340export function OverviewView({ row, arc }: { row: RowEnv; arc: ArcSnapshot }) {
341 const { ui } = row
342 const { Box, Text } = ui
343 const todos = arc.kind === 'loaded' ? arc.todos : []
344 const context = arc.kind === 'loaded' ? arc.context : null
345 return (
346 <Box key="overview" flexDirection="column" paddingRight={RIGHT_PADDING}>
347 <Box justifyContent="space-between">
348 <Text color="subtle" bold>
349 ◇ overview
350 </Text>
351 <Text dimColor>{todos.length} open</Text>
352 </Box>
353 <Controls row={row} mode="overview" />
354 <HeaderRule row={row} glyph="─" color="subtle" />
355 {context !== null && (
356 <Text dimColor wrap="truncate-end">
357 ↳ in focus: sc-{String(context.story.id)} {context.story.name ?? ''}
358 </Text>
359 )}
360 {groupByDue(todos, row.today).map(([group, members]) => (
361 <Box key={`group:${group}`} flexDirection="column" marginTop={1}>
362 <Text color={GROUP_COLOR[group]} italic>
363 {group}
364 </Text>
365 <ProjectGroups row={row} todos={members} groupKey={group} glyph="○" withDate />
366 </Box>
367 ))}
368 {arc.kind === 'loaded' && todos.length === 0 && (
369 <Box marginTop={1}>
370 <Text dimColor>no open todos</Text>
371 </Box>
372 )}
373 <ArcStatus ui={ui} arc={arc} />
374 </Box>
375 )
376}
377
378function ArcStatus({ ui, arc }: { ui: Kit; arc: ArcSnapshot }) {
379 const { Box, Text } = ui
380 if (arc.kind === 'loaded') return null
381 return (
382 <Box marginTop={1}>
383 {arc.kind === 'loading' ? <Text dimColor>loading arc…</Text> : <Text color="error">{arc.reason}</Text>}
384 </Box>
385 )
386}
387types/index.d.ts 47 lines1export type Mode = 'focus' | 'overview'
2
3export type TaskStatus = 'pending' | 'in_progress' | 'completed'
4
5export type ClaudeTask = { id: string; text: string; detail: string | null; status: TaskStatus }
6
7export type ArcTodo = {
8 id: string
9 text: string
10 date: string | null
11 note: string
12 repo: string | null
13 session: string | null
14}
15
16export type ArcListing = { repo: string | null; todos: ArcTodo[] }
17
18export type ArcStory = {
19 id: number
20 name?: string
21 state?: string | null
22 url?: string
23}
24
25export type ArcContext = {
26 story: ArcStory
27 branch: string
28 note: string | null
29 sessions: string[]
30}
31
32export type ArcSnapshot =
33 | { kind: 'loading' }
34 | { kind: 'loaded'; context: ArcContext | null; repo: string | null; todos: ArcTodo[] }
35 | { kind: 'failed'; reason: string }
36
37declare module 'claude-code' {
38 interface PluginState {
39 agenda: {
40 mode: Mode | null
41 tasks: ClaudeTask[]
42 arc: ArcSnapshot
43 expanded: string[]
44 }
45 }
46}
47