Band above the prompt showing the bound flow task and inbox count, with a task switcher pane

A band above the Claude Code prompt that shows which flow task this session is bound to, plus how many flow inbox messages are waiting for you. It also has a switcher that jumps to any in-progress task's session without leaving the one you're in.
The band, collapsed, in a session bound to platform/auth-token-rotation with three messages waiting:
flow ▸ ● platform/auth-token-rotation ✉ 3 Switch Hide ⌃X ⇥
An unbound session shows ▸ not bound instead of the task.
The switcher, opened with ctrl+x tab, Switch or /fs:
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ [-]
│ › Search in-progress tasks… ⏎ open 14 ⇅ priority × │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
PRI TASK STATE NAME TAGS
↵ H platform/auth-token-rotation ◉ Rotate signing keys with a grace window #auth #security
H data-pipeline/backfill-events ◉ ◔ Backfill March events after the schema fix #etl
H billing/invoice-retry ⚠ Retry failed invoice webhooks with backoff #payments
H ops/oncall-runbook ◆ Refresh the on-call runbook before the swap #oncall
M docs/api-reference ⚠ Regenerate API docs from the OpenAPI spec #docs
M mobile-app/offline-sync Offline sync for drafts on Android #android
M platform/rate-limits ◔ Per-tenant rate limits on the public API #api
M ops/weekly-sync--2026-10-07-09-00 ▶ Weekly sync notes
↓ 6 more
↵ open top · ↓↑ move · × close · esc prompt
PRI H high · M medium · L low STATE ▶ run · ◉ live · ◔ waiting · ⚠ stale · ◆ due
In the terminal the glyphs are colored the way flow colors them: ◉ cyan, ◔ yellow, ⚠ red, ◆ red when overdue and yellow when due within three days.
| Do this | To |
|---|---|
ctrl+x tab, Switch, or /fs | open the switcher |
× | close it (Esc only hands the keys back to the prompt) |
| type | filter by slug, name, project or tag |
| Enter | open the top row |
| ↓ ↑, or click a task | pick a row and open it |
⇅ priority | toggle between priority order and most recently updated |
/fs <search> | open the first match straight away, no switcher |
| Hide | hide the band for this session |
The list holds in-progress tasks and playbook runs. Opening a task runs flow do <slug>, which opens or focuses that task's own session. The session you're in doesn't change. When the other session binds, it shows a toast saying it was opened from the switcher. The model there never sees that message.
The band refreshes at session start and after every turn.
ctrl+x tab is Claude Code's default chord for focusing the band. A plugin can't ship key bindings, so for a one-key toggle add ctrl+f to your own ~/.claude/keybindings.json. It opens the switcher from the prompt and closes it from the search box:
{ "context": "Chat", "bindings": { "ctrl+f": "abovePrompt:focus" } },
{ "context": "AbovePromptInput", "bindings": { "ctrl+f": "abovePrompt:previous" } }
flow on PATH, with ~/.flow set up (flow init)sqlite3 on PATHflow-band reads this session's binding from ~/.flow/flow.db with sqlite3 -readonly, because flow show task has no JSON output yet. Everything else goes through the flow CLI. It never writes to the database.
/plugin install flow-band --marketplace pa/cc-modshooks/register.tsx 551 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register } from 'claude-code'
3
4import type { FlowInfo, TaskRow } from '../types'
5import {
6 fitQualified,
7 followOffset,
8 isDueSoon,
9 isLiveSessionGuard,
10 isOverdue,
11 isRun,
12 noticeText,
13 pageOffset,
14 qualifiedWidth,
15 TASK_ARGS,
16 visible,
17} from './list'
18
19// The palette mirrors flow-bar's task switcher: same tabs, search,
20// sort, Enter-opens-first-openable, and `flow do <slug>` to open or focus the task's session.
21
22const COMMANDS = ['flow-switch', 'fs']
23// Cross-session notice: after `flow do`, the palette sends the opened task's session a message
24// that the mod there consumes in `session.receive` and shows as a toast (its model never sees it).
25const NOTICE = 'flow-band notice:'
26const NOTICE_WAIT_MS = 8000
27const NOTICE_STEP_MS = 1000
28const LIMIT = 8
29
30const info = atom({ plugin: 'flow-band', key: 'info' } as const, null)
31const isHidden = atom({ plugin: 'flow-band', key: 'isHidden' } as const, false)
32const tasks = atom({ plugin: 'flow-band', key: 'tasks' } as const, [])
33const filter = atom({ plugin: 'flow-band', key: 'filter' } as const, '')
34const sort = atom({ plugin: 'flow-band', key: 'sort' } as const, 'priority')
35const isLoading = atom({ plugin: 'flow-band', key: 'isLoading' } as const, false)
36const busy = atom({ plugin: 'flow-band', key: 'busy' } as const, null)
37const error = atom({ plugin: 'flow-band', key: 'error' } as const, null)
38const isOpen = atom({ plugin: 'flow-band', key: 'isOpen' } as const, false)
39const offset = atom({ plugin: 'flow-band', key: 'offset' } as const, 0)
40
41// The band's requestId, as last seen: what $.ui.focus needs to move the cursor.
42let bandId: string | null = null
43// Rows the palette window showed last; the focus hook scrolls against it.
44let windowRows = LIMIT
45
46const PRIORITY_COLOR: Record<string, 'error' | 'warning' | 'subtle'> = { high: 'error', medium: 'warning', low: 'subtle' }
47
48// flow's own colors, drawn as glyphs: ● status (green in progress, yellow backlog, dim done),
49// ◉ live (cyan), ◔ waiting (yellow), ⚠ stale (red), ◆ due (red overdue, yellow soon), ▶ playbook run.
50const STATUS_COLOR: Record<string, string | undefined> = { 'in-progress': 'green', backlog: 'yellow', done: undefined }
51const LEGEND = 'PRI H high · M medium · L low STATE ▶ run · ◉ live · ◔ waiting · ⚠ stale · ◆ due'
52const STATE_WIDTH = 9
53const TAG_MAX = 32
54const PRIORITY_LETTER: Record<string, string> = { high: 'H', medium: 'M', low: 'L' }
55
56type Marks = { run?: boolean; live?: boolean; waitingOn: string | null; staleDays?: number | null; due?: { isOverdue: boolean } | null }
57
58function statusDot(ui: Els, status: string | null) {
59 const { Text } = ui
60 const color = STATUS_COLOR[status ?? '']
61 return color ? <Text color={color}>●</Text> : <Text dimColor>●</Text>
62}
63
64// The palette's STATE column: five fixed slots (run, live, waiting, stale, due), blank when absent,
65// so every row's name starts in the same column.
66function markSlots(ui: Els, m: Marks) {
67 const { Box, Text } = ui
68 const slot = (key: string, on: boolean, glyph: string, color: string) => (
69 <Text key={key} color={on ? color : undefined}>
70 {on ? glyph : ' '}
71 </Text>
72 )
73 return (
74 <Box flexDirection="row" gap={1} flexShrink={0} width={STATE_WIDTH}>
75 {slot('run', !!m.run, '▶', 'magenta')}
76 {slot('live', !!m.live, '◉', 'cyan')}
77 {slot('waiting', !!m.waitingOn, '◔', 'yellow')}
78 {slot('stale', !!m.staleDays, '⚠', 'red')}
79 {slot('due', !!m.due, '◆', m.due?.isOverdue ? 'red' : 'yellow')}
80 </Box>
81 )
82}
83
84// The markers flow puts after a task, one glyph each.
85function marks(ui: Els, m: Marks) {
86 const { Text } = ui
87 return [
88 m.live ? <Text key="live" color="cyan">◉</Text> : null,
89 m.waitingOn ? <Text key="waiting" color="yellow">◔</Text> : null,
90 m.staleDays ? <Text key="stale" color="red">⚠</Text> : null,
91 m.due ? <Text key="due" color={m.due.isOverdue ? 'red' : 'yellow'}>◆</Text> : null,
92 ].filter(Boolean)
93}
94
95const fit = (s: string, n: number) => (n <= 1 ? '' : s.length > n ? `${s.slice(0, n - 1)}…` : s.padEnd(n))
96const firstLine = (s: string) => s.trim().split('\n')[0] ?? ''
97
98type Raw = {
99 slug: string
100 name: string
101 status: string
102 priority?: string
103 project?: string | null
104 tags?: string[] | null
105 live?: boolean | null
106 stale?: boolean | null
107 stale_days?: number | null
108 waiting_on?: string | null
109 updated?: string | null
110 due_in_days?: number | null
111 due_label?: string | null
112}
113
114const toRow = (t: Raw): TaskRow => ({
115 slug: t.slug,
116 name: t.name,
117 status: t.status,
118 priority: t.priority ?? 'medium',
119 project: t.project ?? '',
120 tags: t.tags ?? [],
121 live: !!t.live,
122 stale: !!t.stale,
123 staleDays: t.stale_days ?? null,
124 waitingOn: t.waiting_on ?? null,
125 updated: t.updated ?? null,
126 dueInDays: t.due_in_days ?? null,
127 dueLabel: t.due_label ?? null,
128})
129
130// Read-only: a sqlite lookup for this session's binding and `flow inbox` (list, never pop).
131async function refresh($: EngineInterface) {
132 const home = await $.env.get('HOME')
133 const sessionId = (await $.session.id()).replace(/[^A-Za-z0-9-]/g, '')
134
135 let task: string | null = null
136 let name: string | null = null
137 let project: string | null = null
138 let status: string | null = null
139 let waitingOn: string | null = null
140 let isRun = false
141 try {
142 const ran = await $.process.run(
143 [
144 'sqlite3',
145 '-readonly',
146 '-separator',
147 '\t',
148 `${home}/.flow/flow.db`,
149 `select slug, name, coalesce(project_slug, ''), status, coalesce(waiting_on, ''), kind from tasks where session_id = '${sessionId}' limit 1`,
150 ],
151 { timeoutMs: 5000 },
152 )
153 const [slug, title, proj, state, waiting, kind] = ran.exitCode === 0 ? ran.stdout.replace(/\n$/, '').split('\t') : []
154 task = slug || null
155 name = title || null
156 project = proj || null
157 status = state || null
158 waitingOn = waiting || null
159 isRun = kind === 'playbook_run'
160 } catch {
161 task = null
162 }
163
164 let inbox = 0
165 try {
166 const ran = await $.process.run(['flow', 'inbox', '--as', 'user', '--json'], { timeoutMs: 10000 })
167 inbox = ran.exitCode === 0 ? (JSON.parse(ran.stdout || '[]') as unknown[]).length : 0
168 } catch {
169 inbox = 0
170 }
171
172 const next: FlowInfo = { task, name, project, status, waitingOn, isRun, inbox }
173 await update($, info, () => next)
174}
175
176// In-progress tasks and playbook runs (read-only).
177async function loadTasks($: EngineInterface) {
178 await update($, isLoading, () => true)
179 try {
180 const ran = await $.process.run(['flow', ...TASK_ARGS], { timeoutMs: 10000 })
181 if (ran.exitCode !== 0) {
182 await update($, error, () => firstLine(ran.stderr) || 'flow list tasks failed')
183 return
184 }
185 const rows = (JSON.parse(ran.stdout || '[]') as Raw[]).map(toRow)
186 await update($, tasks, () => rows)
187 } catch (err) {
188 await update($, error, () => `could not read tasks: ${String(err)}`)
189 } finally {
190 await update($, isLoading, () => false)
191 }
192}
193
194// flow-bar's prepareForOpen: fresh search, Priority, fresh list, then focus the search box.
195async function openPalette($: EngineInterface) {
196 await update($, filter, () => '')
197 await update($, offset, () => 0)
198 await update($, error, () => null)
199 await update($, sort, () => 'priority')
200 await update($, isHidden, () => false)
201 await update($, isOpen, () => true)
202 await update($, tasks, () => [])
203 await loadTasks($)
204 // Lands only while the band holds the keys (after ctrl+x tab or a click); otherwise a no-op.
205 if (bandId) await $.ui.focus({ requestId: bandId, key: 'q' }).catch(() => undefined)
206}
207
208async function closePalette($: EngineInterface) {
209 await update($, isOpen, () => false)
210 await update($, filter, () => '')
211}
212
213// The session bound to a task, from flow's db (read-only); null until it has bound.
214async function sessionOf($: EngineInterface, slug: string): Promise<string | null> {
215 if (!/^[A-Za-z0-9._-]+$/.test(slug)) return null
216 try {
217 const home = await $.env.get('HOME')
218 const ran = await $.process.run(
219 ['sqlite3', '-readonly', `${home}/.flow/flow.db`, `select session_id from tasks where slug = '${slug}' limit 1`],
220 { timeoutMs: 5000 },
221 )
222 return ran.exitCode === 0 ? ran.stdout.trim() || null : null
223 } catch {
224 return null
225 }
226}
227
228// Runs detached after `flow do`: wait for the task's session to bind (a new tab takes a moment), then push the notice.
229async function notifyOpened($: EngineInterface, slug: string, outcome: 'opened' | 'already-open') {
230 const me = await $.session.id()
231 for (let waited = 0; waited <= NOTICE_WAIT_MS; waited += NOTICE_STEP_MS) {
232 const target = await sessionOf($, slug)
233 if (target && target !== me) {
234 const what = outcome === 'opened' ? `opened ${slug} from the palette` : `switched here from the palette`
235 await $.session.send({ to: { sessionId: target }, text: `${NOTICE} ${what}. No action needed.` }).catch(() => undefined)
236 return
237 }
238 await $.clock.sleep(NOTICE_STEP_MS)
239 }
240}
241
242// flow-bar's switchTo: `flow do <slug>` opens or focuses the task's own session in the
243// terminal; this session is untouched. Run as if from outside Claude, as flow-bar does.
244async function openTask($: EngineInterface, slug: string): Promise<'opened' | 'already-open' | 'failed'> {
245 await update($, busy, () => slug)
246 await update($, error, () => null)
247 try {
248 const ran = await $.process.run(['flow', 'do', slug], {
249 env: { CLAUDE_CODE_SESSION_ID: '', CLAUDECODE: '' },
250 timeoutMs: 30000,
251 })
252 if (ran.exitCode === 0 || isLiveSessionGuard(ran.stderr)) {
253 const outcome = ran.exitCode === 0 ? 'opened' : 'already-open'
254 await closePalette($)
255 // The toast shows in the opened task's session, not here; delivered when it has bound.
256 void notifyOpened($, slug, outcome)
257 return outcome
258 }
259 const why = firstLine(ran.stderr) || firstLine(ran.stdout) || `exit ${ran.exitCode}`
260 await update($, error, () => `open ${slug} failed: ${why}`)
261 return 'failed'
262 } catch (err) {
263 await update($, error, () => String(err))
264 return 'failed'
265 } finally {
266 await update($, busy, () => null)
267 }
268}
269
270type Els = Elements['terminal'] | Elements['desktop']
271
272// Collapsed: one row with this session's task and Switch. Switch is autoFocus, so ctrl+x tab
273// (abovePrompt:focus) lands on it and the ui.focus hook opens the palette. ⌃X ⇥ stays last: see the hook.
274async function band($: EngineInterface, ui: Els, width: number, maxRows: number) {
275 return (await read($, isOpen)) ? palette($, ui, width, maxRows) : flowRow($, ui, width)
276}
277
278// The flow row: this session's task, inbox, Switch / Hide / ⌃X ⇥.
279async function flowRow($: EngineInterface, ui: Els, width: number) {
280 const { Box, Button, Text } = ui
281 const current = await read($, info)
282 return (
283 <Box flexDirection="row" gap={1} width={width}>
284 <Text color="claude" bold>
285 flow
286 </Text>
287 {current?.task ? (
288 <Box flexDirection="row" gap={1} flexShrink={1}>
289 <Text color="claude">▸</Text>
290 {statusDot(ui, current.status)}
291 {current.isRun && <Text color="claude">▶</Text>}
292 <Text>
293 {current.project && <Text dimColor>{current.project}/</Text>}
294 <Text bold>{current.task}</Text>
295 </Text>
296 {marks(ui, { waitingOn: current.waitingOn })}
297 </Box>
298 ) : (
299 <Text color="warning">▸ not bound</Text>
300 )}
301 <Box flexGrow={1} />
302 {current && current.inbox > 0 && <Text color="suggestion">✉ {current.inbox}</Text>}
303 <Button key="switch" hotkey="s" label="Switch" variant="primary" autoFocus onPress={() => openPalette($)} />
304 <Button key="hide" hotkey="h" label="Hide" onPress={() => update($, isHidden, () => true)} />
305 <Button key="reopen" label="⌃X ⇥" plain dimColor onPress={() => openPalette($)} />
306 </Box>
307 )
308}
309
310// One task row, as flow-bar's TaskRow: priority dot, slug, live dot, name, project, tags, due.
311type RowLayout = { index: number; slugWidth: number; tagWidth: number }
312
313// Column titles, laid out with the same widths as the rows beneath.
314function header(ui: Els, { slugWidth, tagWidth }: { slugWidth: number; tagWidth: number }) {
315 const { Box, Text } = ui
316 return (
317 <Box flexDirection="row" gap={1}>
318 <Box flexDirection="row" gap={1} flexShrink={0}>
319 <Text> </Text>
320 <Box width={3}>
321 <Text dimColor>PRI</Text>
322 </Box>
323 <Box width={slugWidth}>
324 <Text dimColor>TASK</Text>
325 </Box>
326 <Box width={STATE_WIDTH}>
327 <Text dimColor>STATE</Text>
328 </Box>
329 </Box>
330 <Box flexGrow={1}>
331 <Text dimColor>NAME</Text>
332 </Box>
333 {tagWidth > 0 && (
334 <Box width={tagWidth} flexShrink={0} justifyContent="flex-end">
335 <Text dimColor>TAGS</Text>
336 </Box>
337 )}
338 </Box>
339 )
340}
341
342// One palette row in fixed columns under the header: ↵ · PRI · TASK · STATE · NAME · TAGS.
343// TASK stays one line; NAME and TAGS wrap rather than cut, so a long one takes a second line.
344function row(ui: Els, t: TaskRow, { index: i, slugWidth, tagWidth }: RowLayout, onPress: () => unknown) {
345 const { Box, Button, Text } = ui
346 const cell = fitQualified(t.project, t.slug, slugWidth)
347 const tags = t.tags.map(x => `#${x}`).join(' ')
348 return (
349 <Box key={`row:${t.slug}`} flexDirection="row" gap={1}>
350 <Box flexDirection="row" gap={1} flexShrink={0}>
351 <Text color={i === 0 ? 'claude' : 'subtle'}>{i === 0 ? '↵' : ' '}</Text>
352 <Box width={3}>
353 <Text color={PRIORITY_COLOR[t.priority] ?? 'warning'} bold>
354 {PRIORITY_LETTER[t.priority] ?? 'M'}
355 </Text>
356 </Box>
357 <Box flexDirection="row" width={slugWidth}>
358 {cell.project && <Text dimColor>{cell.project}</Text>}
359 <Button key={`task:${t.slug}`} plain label={cell.slug} onPress={onPress} />
360 </Box>
361 {markSlots(ui, {
362 run: isRun(t.slug),
363 live: t.live,
364 waitingOn: t.waitingOn,
365 staleDays: t.stale ? t.staleDays : null,
366 due: isDueSoon(t) ? { isOverdue: isOverdue(t) } : null,
367 })}
368 </Box>
369 <Box flexGrow={1} flexShrink={1}>
370 <Text dimColor wrap="wrap">
371 {t.name}
372 </Text>
373 </Box>
374 {tagWidth > 0 && (
375 <Box width={tagWidth} flexShrink={0} justifyContent="flex-end">
376 <Text color="gray" wrap="wrap">
377 {tags}
378 </Text>
379 </Box>
380 )}
381 </Box>
382 )
383}
384
385// The cursor reached ↓ more / ↑ more: page, then put it on the first row that came into view,
386// so moving on never falls out of the panel into the prompt.
387async function pageFromButton($: EngineInterface, direction: 1 | -1) {
388 const hits = visible(await read($, tasks), await read($, filter), await read($, sort))
389 const start = await read($, offset)
390 const next = pageOffset(start, windowRows, hits.length, direction)
391 if (next === start) return
392 await update($, offset, () => next)
393 const target = direction === 1 ? hits[Math.min(hits.length - 1, start + windowRows)] : hits[Math.max(0, start - 1)]
394 if (target && bandId) await $.ui.focus({ requestId: bandId, key: `task:${target.slug}` }).catch(() => undefined)
395}
396
397// ↓ / ↑ onto a row near the window's edge scrolls it, so the next rows are always drawn.
398async function followRow($: EngineInterface, slug: string) {
399 const hits = visible(await read($, tasks), await read($, filter), await read($, sort))
400 const index = hits.findIndex(t => t.slug === slug)
401 if (index < 0) return
402 const current = await read($, offset)
403 const next = followOffset(index, current, windowRows, hits.length)
404 if (next !== current) await update($, offset, () => next)
405}
406
407// Expanded: search box with the sort switch, task rows in a scrolling window, footer.
408async function palette($: EngineInterface, ui: Els, width: number, maxRows: number) {
409 const { Box, Button, Input, Text } = ui
410 const all = await read($, tasks)
411 const query = await read($, filter)
412 const order = await read($, sort)
413 const loading = await read($, isLoading)
414 const pending = await read($, busy)
415 const problem = await read($, error)
416
417 const limit = Math.max(3, Math.min(LIMIT, maxRows - 9))
418 windowRows = limit
419 const hits = visible(all, query, order)
420 const top = hits[0]
421 const count = !query.trim() || hits.length === all.length ? `${all.length}` : `${hits.length} of ${all.length}`
422 const start = Math.min(await read($, offset), Math.max(0, hits.length - limit))
423 const shown = hits.slice(start, start + limit)
424 const above = start
425 const below = Math.max(0, hits.length - start - limit)
426 // Measured over every match, not the window, so columns hold still while scrolling.
427 const slugWidth = qualifiedWidth(hits, Math.min(48, Math.floor(width * 0.4)))
428 // Wide enough for the longest tag list up to TAG_MAX; longer lists wrap onto a second line.
429 const tagWidth = width >= 110 ? Math.min(TAG_MAX, Math.max(0, ...hits.map(t => t.tags.map(x => `#${x}`).join(' ').length))) : 0
430 const page = (direction: 1 | -1) => update($, offset, () => pageOffset(start, limit, hits.length, direction))
431 const open = (t: TaskRow) => (pending ? undefined : openTask($, t.slug))
432
433 return (
434 <Box flexDirection="column" width={width}>
435 <Box borderStyle="round" borderColor="claude" paddingX={1} flexDirection="row" gap={1}>
436 <Button key="toggle" label="›" plain onPress={() => closePalette($)} />
437 <Box flexGrow={1}>
438 <Input
439 key="q"
440 placeholder="Search in-progress tasks…"
441 submitLabel="open"
442 value={query}
443 autoFocus
444 onInput={value => update($, filter, () => value).then(() => update($, offset, () => 0))}
445 onSubmit={() => (top && !pending ? openTask($, top.slug) : undefined)}
446 />
447 </Box>
448 <Text dimColor>{count}</Text>
449 <Button
450 key="sort"
451 plain
452 dimColor
453 label={order === 'priority' ? '⇅ priority' : '⇅ recent'}
454 onPress={() => update($, sort, () => (order === 'priority' ? 'recent' : 'priority'))}
455 />
456 <Button key="close" label="×" plain role="dismiss" onPress={() => closePalette($)} />
457 </Box>
458 {loading && all.length === 0 && <Text dimColor> Loading…</Text>}
459 {!loading && all.length === 0 && !problem && <Text dimColor> No in-progress tasks</Text>}
460 {all.length > 0 && hits.length === 0 && <Text dimColor> No matches for "{query}"</Text>}
461 {shown.length > 0 && header(ui, { slugWidth, tagWidth })}
462 {above > 0 && <Button key="page-up" plain dimColor label={` ↑ ${above} more`} onPress={() => page(-1)} />}
463 {shown.map((t, i) => row(ui, t, { index: start + i, slugWidth, tagWidth }, () => open(t)))}
464 {below > 0 && <Button key="page-down" plain dimColor label={` ↓ ${below} more`} onPress={() => page(1)} />}
465 {pending && <Text color="suggestion"> Opening {pending}…</Text>}
466 {problem && <Text color="error"> {problem}</Text>}
467 <Text dimColor> ↵ open top · ↓↑ move · × close · esc prompt</Text>
468 <Text dimColor> {LEGEND}</Text>
469 </Box>
470 )
471}
472
473export const register: Register = on => {
474 on('session.start', async ($, e, next) => {
475 const started = await next(e)
476 await refresh($)
477 for (const name of COMMANDS) {
478 try {
479 await $.command.register({ name, description: 'Flow task palette: /fs <search> opens the first match' })
480 } catch {
481 // The band's Switch button still opens the palette.
482 }
483 }
484 return started
485 })
486
487 for (const command of COMMANDS) {
488 on('command.run', { command }, async ($, e) => {
489 const query = e.args.trim()
490 if (!query) {
491 await openPalette($)
492 return { text: 'Flow palette open above the prompt. ctrl+x tab to search.' }
493 }
494 await loadTasks($)
495 const top = visible(await read($, tasks), query, 'priority')[0]
496 if (!top) return { text: `No in-progress task matches "${query}".` }
497 const outcome = await openTask($, top.slug)
498 if (outcome === 'opened') return { text: `Opened ${top.slug}.` }
499 if (outcome === 'already-open') return { text: `${top.slug} is already open in another tab.` }
500 return { text: (await read($, error)) ?? `Could not open ${top.slug}.` }
501 })
502 }
503
504 on('turn.complete', async ($, e, next) => {
505 const done = await next(e)
506 await refresh($)
507 return done
508 })
509
510 // A palette in another session opened this task: show its notice as a toast and keep it from the model.
511 on('session.receive', async ($, e, next) => {
512 if (e.agentId !== undefined || !e.text.includes(NOTICE)) return next(e)
513 // The delivery arrives wrapped in Claude Code's envelope (<cross-session-message …>); take only our sentence.
514 const what = noticeText(e.text)
515 $.ui.toast(`flow: ${what}`)
516 return { consumed: 'flow-band notice shown as a toast' }
517 })
518
519 // Focus chords (ctrl+x tab, or ctrl+f where bound) toggle the palette by moving the band's focus ring; where it lands says which way.
520 on('ui.focus', { component: 'AbovePrompt' }, async ($, e, next) => {
521 const moved = await next(e)
522 if (e.plugin !== 'flow-band') return moved
523 bandId = e.requestId
524 const open = await read($, isOpen)
525 if (!open && (e.element === 'reopen' || e.element === 'switch')) {
526 // From the prompt, the focus chord gives the band the keys and autoFocus puts the ring on Switch.
527 // After a close the ring is on nothing, and abovePrompt:previous lands on the last button,
528 // ⌃X ⇥, which is why it sits at the end of the row. Either way: open.
529 await openPalette($)
530 } else if (open && e.element === 'toggle' && e.origin.kind === 'person') {
531 // abovePrompt:previous from the search box moves the ring back onto ›: close.
532 await closePalette($)
533 } else if (open && e.element?.startsWith('task:')) {
534 await followRow($, e.element.slice('task:'.length))
535 } else if (open && e.origin.kind === 'person' && (e.element === 'page-down' || e.element === 'page-up')) {
536 void pageFromButton($, e.element === 'page-down' ? 1 : -1)
537 }
538 return moved
539 })
540
541 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
542 bandId = e.requestId
543 const current = await read($, info)
544 if (e.props.hasSurvey || current === null || (await read($, isHidden))) {
545 return next(e)
546 }
547 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
548 return band($, $.ui.resolve(e), e.props.bodyColumns, e.props.maxRows)
549 })
550}
551hooks/list.ts 98 lines1import type { Sort, TaskRow } from '../types'
2
3// Search and sort ported from flow-bar so the
4// palette and flow-bar agree on what the list shows and which row Enter opens.
5
6const PRIORITY: Record<string, number> = { high: 0, medium: 1, low: 2 }
7
8const prio = (t: TaskRow) => PRIORITY[t.priority] ?? PRIORITY.medium!
9const bySlug = (a: TaskRow, b: TaskRow) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0)
10
11// In-progress only, every kind: `--kind all` brings playbook runs in beside regular tasks.
12export const TASK_ARGS = ['list', 'tasks', '--kind', 'all', '--status', 'in-progress', '--format', 'json']
13
14// Case-insensitive substring on slug, name, project or any tag.
15export function filtered(tasks: readonly TaskRow[], query: string): TaskRow[] {
16 const q = query.trim().toLowerCase()
17 if (!q) return [...tasks]
18 return tasks.filter(
19 t =>
20 t.slug.toLowerCase().includes(q) ||
21 t.name.toLowerCase().includes(q) ||
22 (t.project !== '' && t.project.toLowerCase().includes(q)) ||
23 t.tags.some(tag => tag.toLowerCase().includes(q)),
24 )
25}
26
27function sortRows(rows: TaskRow[], sort: Sort): TaskRow[] {
28 if (sort === 'priority') return rows.sort((a, b) => prio(a) - prio(b) || bySlug(a, b))
29 const time = (t: TaskRow) => (t.updated ? Date.parse(t.updated) : NaN)
30 return rows.sort((a, b) => {
31 const da = time(a)
32 const db = time(b)
33 if (!Number.isNaN(da) && !Number.isNaN(db) && da !== db) return db - da
34 if (a.updated !== b.updated) return (b.updated ?? '') > (a.updated ?? '') ? 1 : -1
35 return bySlug(a, b)
36 })
37}
38
39// What the list shows: filter by query, then sort. Enter opens its first row.
40export function visible(tasks: readonly TaskRow[], query: string, sort: Sort): TaskRow[] {
41 return sortRows(filtered(tasks, query), sort)
42}
43
44// flow refusing because the task is live in another tab is not a failure.
45export function isLiveSessionGuard(stderr: string): boolean {
46 const s = stderr.toLowerCase()
47 return s.includes('--force') || s.includes('already running') || s.includes('running session') || s.includes('already open')
48}
49
50export const isDueSoon = (t: TaskRow) => t.dueInDays !== null && t.dueInDays <= 3
51export const isOverdue = (t: TaskRow) => (t.dueInDays ?? 1) < 0
52
53// A playbook run's slug is its playbook's plus a `--YYYY-MM-DD-HH-MM` stamp; the JSON carries no kind.
54export const isRun = (slug: string) => /--\d{4}-\d{2}-\d{2}-\d{2}-\d{2}$/.test(slug)
55
56/**
57 * Fits `project/slug` into exactly `width` cells. The slug stays whole as long as it fits;
58 * the project gives way first (`platform-opera…/`), and the result is padded to `width`.
59 */
60export function fitQualified(project: string, slug: string, width: number): { project: string; slug: string } {
61 const pad = (p: string, sl: string) => ({ project: p, slug: sl.padEnd(width - p.length) })
62 if (!project) return pad('', slug.length > width ? `${slug.slice(0, width - 1)}…` : slug)
63 if (project.length + 1 + slug.length <= width) return pad(`${project}/`, slug)
64 const room = width - slug.length - 2
65 if (room >= 3) return pad(`${project.slice(0, room)}…/`, slug)
66 return pad('', slug.length > width ? `${slug.slice(0, width - 1)}…` : slug)
67}
68
69/** The width of the project/slug column: the longest one shown, within limits. */
70export function qualifiedWidth(rows: readonly TaskRow[], cap: number): number {
71 const longest = Math.max(0, ...rows.map(t => (t.project ? t.project.length + 1 : 0) + t.slug.length))
72 return Math.max(16, Math.min(cap, longest))
73}
74
75/**
76 * The scroll offset that keeps row `index` inside a window of `limit` rows over `total`, with two
77 * rows of lookahead on each side, so fast ↓/↑ never step past the drawn rows and out of the panel.
78 */
79export function followOffset(index: number, offset: number, limit: number, total: number): number {
80 const maxOffset = Math.max(0, total - limit)
81 const ahead = limit > 4 ? 2 : 1
82 let next = offset
83 if (index >= offset + limit - ahead) next = index - limit + ahead + 1
84 if (index < offset + ahead) next = index - ahead
85 return Math.max(0, Math.min(maxOffset, next))
86}
87
88/** The offset one page up or down, kept inside the list. */
89export function pageOffset(offset: number, limit: number, total: number, direction: 1 | -1): number {
90 return Math.max(0, Math.min(Math.max(0, total - limit), offset + direction * Math.max(1, limit - 1)))
91}
92
93/** The sentence inside a flow-band notice, whatever envelope the delivery came wrapped in. */
94export function noticeText(text: string): string {
95 const match = /flow-band notice:\s*([^<]*?)\.?\s*No action needed\./.exec(text)
96 return (match?.[1] ?? text.slice(text.indexOf('flow-band notice:') + 'flow-band notice:'.length).replace(/<[^>]*>/g, '')).trim()
97}
98types/index.d.ts 45 lines1export type FlowInfo = {
2 task: string | null
3 name: string | null
4 project: string | null
5 status: string | null
6 waitingOn: string | null
7 isRun: boolean
8 inbox: number
9}
10
11export type Sort = 'priority' | 'recent'
12
13export type TaskRow = {
14 slug: string
15 name: string
16 status: string
17 priority: string
18 project: string
19 tags: string[]
20 live: boolean
21 stale: boolean
22 staleDays: number | null
23 waitingOn: string | null
24 updated: string | null
25 dueInDays: number | null
26 dueLabel: string | null
27}
28
29declare module 'claude-code' {
30 interface PluginState {
31 'flow-band': {
32 info: FlowInfo | null
33 isHidden: boolean
34 tasks: TaskRow[]
35 filter: string
36 sort: Sort
37 isLoading: boolean
38 busy: string | null
39 error: string | null
40 isOpen: boolean
41 offset: number
42 }
43 }
44}
45