SLOPSHOPPER

flow-band

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

newbandcommandtoastprocess
v0.2.1no licenseupdated 2026-10-09pa/cc-mods/flow-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · flow-band
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /flow-switch ⎿ flow-band: Flow palette open above the prompt. ctrl+x tab to search. ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ › Search in-progress tasks… ⏎ open 0 ⇅ priority × │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ No in-progress tasks ↵ open top · ↓↑ move · × close · esc prompt PRI H high · M medium · L low STATE ▶ run · ◉ live · ◔ waiting · ⚠ stale · ◆ due ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ › Search in-progress tasks… ⏎ open 0 ⇅ priority × │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ No in-progress tasks ↵ open top · ↓↑ move · × close · esc prompt PRI H high · M medium · L low STATE ▶ run · ◉ live · ◔ waiting · ⚠ stale · ◆ due
README

flow-band

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.

What it looks like

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.

Using it

Do thisTo
ctrl+x tab, Switch, or /fsopen the switcher
×close it (Esc only hands the keys back to the prompt)
typefilter by slug, name, project or tag
Enteropen the top row
↓ ↑, or click a taskpick a row and open it
⇅ prioritytoggle between priority order and most recently updated
/fs <search>open the first match straight away, no switcher
Hidehide 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" } }

Requirements

  • flow on PATH, with ~/.flow set up (flow init)
  • sqlite3 on PATH

flow-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.

Install

/plugin install flow-band --marketplace pa/cc-mods
Source 3 files
hooks/register.tsx 551 lines
1import { 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}
551
hooks/list.ts 98 lines
1import 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}
98
types/index.d.ts 45 lines
1export 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