SLOPSHOPPER

needs-you

Band above the prompt showing what the session needs from you and where the branch stands

newbandguardpromptmodelprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · needs-you
› 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 feat/auth-refresh · 2 uncommitted [ /implement ] [ /review-brief ] [ /review-changes ] [ /open-pr ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
feat/auth-refresh · 2 uncommitted [ /implement ] [ /review-brief ] [ /review-changes ] [ /open-pr ]
README

Dotfiles

Collection of my personal dotfiles.

Installation

To install the majority of the dotfiles, use the Terminal GUI by starting "./dotfiles.py". There the specific dotfiles can be selected for installation. This will however will not install software and system wide configurations. When launching for the first time, it will download the required dependencies automatically. The dependencies used are listed in ./requirements.txt.

Windows

These dotfiles should also work on Windows, but requires some manual steps.

  • After installing a Font, restart the Service "Windows Font Cache Service" to make the font available. Or just restart the system.
  • At leas install following software with winget:
winget install Python Git.Git zig.zig ajeetdsouza.zoxide junegunn.fzf OpenJS.NodeJS Starship.Starship
  • For a better experience, also install the following software with winget:
winget install Neovide.Neovide SomePythonThings.WingetUIStore 7zip.7zip GitHub.cli

Uninstallation

To uninstall the dotfiles, use the Terminal GUI by starting "./dotfiles.py" and just remove the dotfiles that were installed. This will revert the changes made by the dotfiles and leave the system in the same state as before the installation. Since it does not install software, it will not remove any software as well. ./config.json contains the configuration of theses dotfiles, it is required to correctly uninstall the dotfiles.

gw (Git Worktree Tool)

gw is a local helper script in this repo for git worktree workflows.

Main commands

  • gw new -b <branch>: create a new worktree and new branch (from HEAD by default)
  • gw new -b <branch> --from <ref>: create from another base ref
  • gw move: move the currently checked out branch into a new worktree
  • gw ls / gw ls --json: list worktrees
  • gw cd: pick and switch to a worktree
  • gw rm: remove a worktree
  • gw prune: prune stale worktree metadata
  • gw doctor: validate worktree setup and health

Automation flags

Common non-interactive flags:

  • --yes: auto-confirm destructive/interactive actions
  • --no-prompt: fail instead of prompting
  • --path / --index: explicit target selection for gw rm / gw cd

Shell integration contract

For shell wrappers, gw emits:

GW_CD_TARGET=<path>

Wrappers should only parse this machine-readable line for auto-cd.

gw tests

Run:

./scripts/test-gw.sh

Software

Software I use regularly. The software on this list is available on linux, but some is also compatible with windows.

GUI

  • adw-gtk-theme: GTK3 theme with libadwaita look
  • appimagelauncher: Integrate AppImages into the system
  • mpv: media player
  • moserial: serial monitor
  • neovide: neovim gui with animations
  • kitty: terminal emulator
  • timeshift: btrfs snapshot manager
  • Gnome Tweaks: more gnome settings
  • Rnote: note taking app
  • Flatseal: manage flatpak permissions
  • Extension Manager: manage gnome extensions
  • Black Box: terminal emulator
  • Bottles: wine manager
  • Inkscape: vector graphics editor
  • Prusaslicer: 3d printer slicer
  • Firefox: web browser
  • Brave: web browser (chromium based)
  • Kicad: schematic and pcb design
  • Easyeffects: audio effects
  • Vlc: media player
  • Libreoffice: office suite
  • Pikabackup: backup manager
  • Qalculate (GTK): calculator
  • File Roller: archive manager (GNOME)
  • Ark: archive manager (KDE), more powerful than file roller
  • REW: room acoustics measurement
  • Joplin: text based note taking app
  • Audacity: Basic audio editor
  • Blender: 3d modeling and animation

CLI

  • topgrade: upgrade all software
  • bat: cat clone with syntax highlighting
  • ripgrep: grep clone with better visuals and usability
  • htop: interactive process viewer
  • btop: htop clone with better visuals
  • aria2: download manager
  • exa: ls clone with better visuals
  • fd: find clone with better visuals and usability
  • fzf: fuzzy finder
  • neofetch: system info
  • ncdu: disk usage analyzer
  • nvtop: (nvidia) gpu monitor
  • powertop: power consumption monitor
  • stress: basic stress test
  • tldr: better man pages
  • tmux: terminal multiplexer
  • tokei: code statistics

Development

  • ninja: build system
  • cmake: build system generator
  • nodejs/npm: javascript runtime and package manager
  • docker: containerization
  • docker-compose: container orchestration
  • rustup: up to date rust toolchain
  • openocd: embedded debugging
  • clang: c/c++ compiler
  • stlink: stlink debugger
  • stcubeide: stm microcontroller ide
  • etcher: flash images to sd cards
  • flutter: mobile app development
  • esp-idf: esp32 development
  • avrdude: avr programmer

Windows

  • winget: windows package manager
  • scoop: windows package manager (does not require admin rights, more developer tools, remember to add buckets)
  • WingetUI: GUI for windows package managers
  • 7zip: archive manager
  • teracopy: replace windows copy
  • notepad++: text editor
  • MusicBee: music player with good replaygain calculation
  • mp3tag: tag editor
  • windirstat: disk usage analyzer

VSCode Plugins

  • C/C++: C/C++ language support from Microsoft
  • PlatformIO IDE: IDE for embedded development
  • Material Theme: Material Theme for VSCode
  • Copilot: AI code completion
  • Matieral Icon Theme: Material Design Icons for Visual Studio Code
  • rust extension pack: rust language support
  • git graph: Display a git graph of your repository
  • todo tree: Show TODO, FIXME, etc. comment tags of a project

Device Specific

See device_specific for device specific configurations and notes for my personal devices.

Notes (Linux)

To set environment variables for a user on X11, write them to ~/.profile with the following format:

export VAR=value

This will not work for wayland with gnome. write them to ~/.config/environment.d/envvars.conf with the following format:

VAR=value

When using flatpak apps, just add the environment variables via Flatseal.

Environment variables

For Firefox (and I think Thunderbird), set the following environment variables for wayland support:

MOZ_USE_XINPUT2=1
MOZ_ENABLE_WAYLAND=1

For neovide, set the following environment variables:

NEOVIDE_MULTIGRID=true

For development of GObject Introspection based libraries, set the following environment variables (maybe use ~/.local/lib...?):

GI_TYPELIB_PATH=/usr/local/lib/girepository-1.0

For flutter development, set the following environment variables to not use Chrome (flatpak webbrowsers are as far as I know not supported):

CHROME_EXECUTABLE=brave

Maybe also set if using pyopengl (works on my intel integrated gpu):

PYOPENGL_PLATFORM=osmesa

Awesome WM

I no longer use Awesome WM, but on very old systems it still works better than gnome. The configuration is not exactly finished (especially the app picker), but it works. It requres following software (some is optional): nm-applet, Jonaburg's picom, light-locker (with lightdm), unclutter, copyq, mate-volume-control-status-icon, blueman-applet, touchegg, polkit-gnome-authentication-agent-1, acpi Maybe some more, but I don't remember.

Source 2 files
hooks/register.tsx 481 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AskOption, GitFacts, Item, ItemKind, Reply } from '../types'
5
6const items = atom({ plugin: 'needs-you', key: 'items' } as const, [])
7const reply = atom({ plugin: 'needs-you', key: 'reply' } as const, null)
8const git = atom({ plugin: 'needs-you', key: 'git' } as const, null)
9const now = atom({ plugin: 'needs-you', key: 'now' } as const, 0)
10const tldr = atom({ plugin: 'needs-you', key: 'tldr' } as const, null)
11const shown = atom({ plugin: 'needs-you', key: 'shown' } as const, null)
12const laterOpen = atom({ plugin: 'needs-you', key: 'laterOpen' } as const, false)
13const lastCall = atom({ plugin: 'needs-you', key: 'lastCall' } as const, 0)
14const keepWarm = atom({ plugin: 'needs-you', key: 'keepWarm' } as const, false)
15
16const KINDS: readonly ItemKind[] = ['answer', 'do', 'later']
17const USER_ORIGINS = ['composer', 'bridge', 'sdk']
18const SPAWN_TASK = 'mcp__ccd_session__spawn_task'
19const SHOWN = 3
20const COMMANDS = ['/implement', '/review-brief', '/review-changes', '/open-pr']
21const NO_REPLY: Reply = { question: null, done: null, options: [] }
22const MIN_ANSWER = 20
23const CACHE_TTL_MS = 60 * 60000
24const WARM_MARGIN_MS = 5 * 60000
25const MAX_PINGS = 4
26
27const CLASSIFY = `You keep the list of things the user of an AI coding assistant still has to act on. You get the open list, the user's latest prompts and the assistant's latest reply.
28Answer with JSON only, no prose, in this shape:
29{"resolved": number[], "new": [{"kind": "answer" | "do" | "later", "text": string, "same": number}], "done": string, "question": string, "options": [{"label": string, "fill": string, "send": boolean}], "tldr": string}
30
31resolved: the ids of open items the latest prompts or the reply dealt with: the user answered or decided it, did the task, or the assistant fixed it, opened an issue for it or dropped it. Also resolve an answer or do item that a newer item replaced. A later item stays open while the work moves on. Suggesting an item as a background task does not deal with it. Leave an item the reply asks again out of resolved.
32
33new: everything the reply asks of the user or sets aside, one entry each, new ones first, at most 5. same: the id of the open item the entry repeats, even when worded differently, or -1 when it is new.
34- "answer": a question or decision for the user. Not a routine approval to commit, push, open a PR or post, and not a request to review a draft or changes: those only get question and options below.
35- "do": something the user must do themselves (test a UI, run a command, restart a device, log in).
36- "later": a problem or follow-up the reply leaves out of the current work ("found a bug, but it does not belong in this PR"). Not the assistant's own next steps in the current work.
37text: under 80 characters and clear without reading the reply, so name what it is about. For "later", a noun phrase naming the problem and where it is ("Race in queue reload in the player provider"). Otherwise start with a verb ("Answer: fix needs-you duplicates now or log first?").
38
39done: when the reply asks nothing, what was done, under 60 characters. Otherwise "".
40
41question: the reply's main question in under 50 characters, shown in front of the option buttons ("Push and open the PR form?"), or "" when options is empty.
42
43options: buttons that answer the reply's main question, at most 4. label is one to three words. fill is the answer the user would type, under 100 characters, naming what it answers so it reads clearly on its own ("Yes, add setup-notes to the mod list in settings.json").
44- A go-ahead to open a PR: "Web form", "Draft", "Ready" and "No" entries, fill "Yes, open the PR with the web form", "Yes, open the PR as a draft", "Yes, open the PR ready for review" and "No, don't open the PR yet".
45- A go-ahead to ship PR fixes or drafted review replies: "Ship" and "No" entries, fill "Push, post the drafted replies, resolve those threads, and re-request a Copilot review" and "No, hold off".
46- Any other yes/no question: a "Yes" and a "No" entry.
47send: true only for the "Web form", "Draft" and "Ready" entries of a go-ahead to open a PR and the "Ship" entry of a go-ahead to ship, false otherwise.
48- A choice between named alternatives: one entry per alternative, labeled with it ("Fix now", "Measure first").
49- Numbered options to pick from: one entry per number, labeled with the number, even when the options also have names.
50- Several numbered questions: one entry per number, labeled with the number, fill "<number> - " for the user to finish.
51Otherwise [].
52
53tldr: what the whole session is working on, under 70 characters, as a noun phrase ("Building a band mod that shows what the session needs"). Start from the previous TLDR and change it only when the latest prompts moved the work somewhere new.
54`
55
56// Keep more of the tail, where replies put their questions.
57function clip(text: string, max: number) {
58  if (text.length <= max) return text
59  const head = Math.floor(max / 4)
60
61  return `${text.slice(0, head)}\n[...]\n${text.slice(text.length - (max - head))}`
62}
63
64function parseJson(text: string) {
65  const start = text.indexOf('{')
66  const end = text.lastIndexOf('}')
67  if (start < 0 || end <= start) return null
68  try {
69    return JSON.parse(text.slice(start, end + 1))
70  } catch {
71    return null
72  }
73}
74
75function parseNew(raw: unknown, openIds: number[]): Pick<Item, 'kind' | 'text'>[] {
76  if (!Array.isArray(raw)) return []
77
78  return raw
79    .filter(n => KINDS.includes(n?.kind) && typeof n?.text === 'string' && n.text.trim() !== '')
80    .filter(n => !openIds.includes(n.same))
81    .slice(0, 5)
82    .map(n => ({ kind: n.kind, text: n.text.trim().slice(0, 100) }))
83}
84
85function parseReply(raw: any): Reply {
86  const options: AskOption[] = Array.isArray(raw.options)
87    ? raw.options
88        .filter((o: unknown): o is AskOption =>
89          typeof (o as AskOption)?.label === 'string' &&
90          typeof (o as AskOption)?.fill === 'string',
91        )
92        .slice(0, 4)
93        .map((o: AskOption) => ({ label: o.label, fill: o.fill, send: o.send === true }))
94    : []
95  const done = typeof raw.done === 'string' && raw.done.trim() !== '' ? raw.done.trim().slice(0, 80) : null
96  const question =
97    typeof raw.question === 'string' && raw.question.trim() !== '' ? raw.question.trim().slice(0, 60) : null
98
99  return { question, done, options }
100}
101
102async function changeItems(
103  $: EngineInterface,
104  resolved: number[],
105  added: Pick<Item, 'kind' | 'text' | 'fromTask'>[],
106) {
107  const at = await $.clock.now()
108  await update($, items, (list: Item[]) => {
109    let id = list.reduce((max, i) => Math.max(max, i.id), 0)
110    const kept = list.filter(i => !resolved.includes(i.id))
111
112    return [...kept, ...added.map(a => ({ ...a, id: ++id, at }))]
113  })
114}
115
116async function runGit($: EngineInterface, cwd: string, args: string[]) {
117  const ran = await $.process.run(['git', ...args], { cwd, timeoutMs: 5000 })
118
119  return ran.exitCode === 0 ? ran.stdout.trim() : null
120}
121
122async function refreshGit($: EngineInterface) {
123  const cwd = await $.session.cwd()
124  const branch = await runGit($, cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
125  if (branch === null) {
126    await update($, git, () => null)
127    return
128  }
129  const upstream = await runGit($, cwd, ['rev-list', '--count', '@{u}..HEAD'])
130  const ahead = await runGit($, cwd, ['rev-list', '--count', 'origin/HEAD..HEAD'])
131  const status = (await runGit($, cwd, ['status', '--porcelain'])) ?? ''
132  const facts: GitFacts = {
133    branch,
134    unpushed: Number(upstream ?? ahead ?? 0),
135    ahead: ahead === null ? null : Number(ahead),
136    hasUpstream: upstream !== null,
137    uncommitted: status === '' ? 0 : status.split('\n').length,
138  }
139  await update($, git, () => facts)
140}
141
142async function classify(
143  $: EngineInterface,
144  prompt: string,
145  answer: string,
146  isCurrent: () => boolean,
147) {
148  const current = await read($, items)
149  const open = current.map(i => `[${i.id}] ${i.kind}: ${i.text}`).join('\n')
150  const previous = (await read($, tldr)) ?? '(none yet)'
151  const completed = await $.model.complete({
152    model: 'sonnet',
153    effort: 'low',
154    maxTokens: 700,
155    timeoutMs: 20000,
156    prompt: `${CLASSIFY}\nOpen items:\n${open || '(none)'}\n\nPrevious TLDR:\n${previous}\n\nLatest prompts:\n${clip(prompt, 2000)}\n\nReply:\n${clip(answer, 4000)}`,
157  })
158  const raw = completed.isAnswered ? parseJson(completed.text) : null
159  if (raw === null) {
160    if (isCurrent()) await update($, reply, () => NO_REPLY)
161    return
162  }
163  const resolved = Array.isArray(raw.resolved) ? raw.resolved.filter(Number.isInteger) : []
164  await changeItems($, resolved, parseNew(raw.new, current.map(i => i.id).filter(id => !resolved.includes(id))))
165  if (typeof raw.tldr === 'string' && raw.tldr.trim() !== '') {
166    await update($, tldr, () => raw.tldr.trim().slice(0, 90))
167  }
168  if (isCurrent()) await update($, reply, () => parseReply(raw))
169}
170
171function keyOf(item: Item) {
172  return `${item.id}:${item.at}`
173}
174
175async function expand($: EngineInterface, item: Item) {
176  const change = (patch: Partial<Item>) =>
177    update($, items, (list: Item[]) => list.map(i => (keyOf(i) === keyOf(item) ? { ...i, ...patch } : i)))
178  await change({ expanding: true })
179  const forked = await $.model.fork({
180    prompt: `The user's status band shows this open item from this session (${item.kind}): "${item.text}". Expand it in at most three short sentences of plain text, using what you know from this session: what it is about, what the user needs to know to act on it, and your recommendation if you have one. Don't repeat the item, and don't use lists or headings.`,
181  })
182  await change({
183    expanding: false,
184    detail: forked.isAnswered ? forked.text.trim() : `No detail: ${forked.reason}`,
185    failed: !forked.isAnswered,
186  })
187  if (forked.isAnswered) await update($, lastCall, () => Date.now())
188  await update($, shown, () => keyOf(item))
189}
190
191let pings = 0
192let pinging = false
193
194// Fork the session shortly before the cache expires, since a cache read restarts its lifetime.
195async function tick($: EngineInterface) {
196  const at = Date.now()
197  await update($, now, () => at)
198  const last = await read($, lastCall)
199  if (pinging || !(await read($, keepWarm)) || last === 0) return
200  if (at - last < CACHE_TTL_MS - WARM_MARGIN_MS) return
201  if (pings >= MAX_PINGS) {
202    await update($, keepWarm, () => false)
203    return
204  }
205  pings += 1
206  pinging = true
207  const forked = await $.model.fork({ prompt: 'Reply with only the word ok.' })
208  pinging = false
209  if (forked.isAnswered) await update($, lastCall, () => at)
210}
211
212function minutesSince(at: number, current: number) {
213  const minutes = Math.floor((current - at) / 60000)
214  if (minutes < 1) return ''
215  if (minutes < 60) return ` · ${minutes} min`
216
217  return ` · ${Math.floor(minutes / 60)} h ${minutes % 60} min`
218}
219
220export const register: Register = on => {
221  let prompts: string[] = []
222  let generation = 0
223  let queue: Promise<void> = Promise.resolve()
224
225  on('session.start', async ($, e, next) => {
226    const started = await next(e)
227    await update($, now, () => Date.now())
228    $.clock.every(60000, () => void tick($))
229    await refreshGit($)
230
231    return started
232  })
233
234  on('turn.step', async function* ($, e, next) {
235    if (e.agentId === undefined && !pinging) {
236      pings = 0
237      await update($, lastCall, () => Date.now())
238    }
239
240    return yield* next(e)
241  })
242
243  on('prompt.submit', async ($, e, next) => {
244    if (USER_ORIGINS.includes(e.origin.kind) || (e.origin.kind === 'plugin' && e.origin.name === 'needs-you')) {
245      generation += 1
246      prompts.push(e.text)
247      await update($, reply, () => null)
248    }
249
250    return next(e)
251  })
252
253  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
254    const ran = await next(e)
255    if (typeof e.command === 'string' && /\b(git|gh)\b/.test(e.command)) await refreshGit($)
256
257    return ran
258  })
259
260  on('tool.call', { tool: SPAWN_TASK }, async ($, e, next) => {
261    const ran = await next(e)
262    if (typeof e.title === 'string' && ran.deny === undefined && ran.isError !== true) {
263      await changeItems($, [], [{ kind: 'later', text: e.title.slice(0, 80), fromTask: true }])
264    }
265
266    return ran
267  })
268
269  on('turn.complete', async ($, e, next) => {
270    const done = await next(e)
271    if (e.agentId === undefined && !e.isAborted && e.answer.trim().length >= MIN_ANSWER) {
272      generation += 1
273      const mine = generation
274      const answer = e.answer
275      const prompt = prompts.join('\n\n') || '(none)'
276      prompts = []
277      const run = () => classify($, prompt, answer, () => generation === mine)
278      // Run classifications in order so each one sees the items the previous one added.
279      $.clock.after(0, () => {
280        queue = queue.then(run, run)
281      })
282    } else if (e.agentId === undefined) {
283      await update($, reply, () => NO_REPLY)
284    }
285    void refreshGit($)
286
287    return done
288  })
289
290  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
291    if (e.props.hasSurvey) return next(e)
292
293    const open = await read($, items)
294    const latest = e.props.isWorking ? null : await read($, reply)
295    const asks = latest === null ? [] : open.filter(i => i.kind === 'answer' || i.kind === 'do')
296    const later = open.filter(i => i.kind === 'later')
297    const facts = await read($, git)
298    const current = await read($, now)
299    const topic = await read($, tldr)
300    const shownId = await read($, shown)
301    const isLaterOpen = await read($, laterOpen)
302    const last = await read($, lastCall)
303    const isKeepingWarm = await read($, keepWarm)
304    if (open.length === 0 && latest === null && facts === null && topic === null) return next(e)
305
306    const { Box, Button, Text } = $.ui.resolve(e)
307
308    const fill = async (text: string) => {
309      const box = await $.prompt.read()
310      const isEmpty = box.text.trim() === ''
311      await $.prompt.fill(
312        isEmpty ? { text, mode: 'replace' } : { text: `\n${text}`, mode: 'append' },
313      )
314    }
315
316    const send = (text: string) => $.prompt.submit({ text, asUser: true })
317
318    const start = async (command: string) => {
319      const typed = (await $.prompt.read()).text.trim()
320      const previous = COMMANDS.find(c => typed === c || typed.startsWith(`${c} `))
321      const args = previous === undefined ? typed : typed.slice(previous.length).trim()
322      await $.prompt.fill({ text: '', mode: 'replace' })
323      await $.command.run({ command: command.slice(1), args })
324    }
325
326    const dismiss = (item: Item) => (
327      <Button
328        key={`dismiss-${item.id}`}
329        label="x"
330        plain
331        dimColor
332        onPress={() => void update($, items, (list: Item[]) => list.filter(i => i.id !== item.id))}
333      />
334    )
335
336    const action = (item: Item, label: string, press: () => Promise<unknown>) => (
337      <Button key={`${label}-${item.id}`} label={label} plain dimColor onPress={() => void press()} />
338    )
339
340    const more = (item: Item) => (
341      <Button
342        key={`more-${item.id}`}
343        label={item.expanding ? 'expanding' : shownId === keyOf(item) ? 'less' : 'more'}
344        plain
345        dimColor
346        onPress={() => {
347          if (item.expanding) return
348          if (shownId === keyOf(item)) void update($, shown, () => null)
349          else if (item.detail !== undefined && !item.failed) void update($, shown, () => keyOf(item))
350          else void expand($, item)
351        }}
352      />
353    )
354
355    const detail = (item: Item) =>
356      shownId === keyOf(item) &&
357      item.detail !== undefined && (
358        <Box paddingLeft={2}>
359          <Text dimColor>{item.detail}</Text>
360        </Box>
361      )
362
363    const replyButtons = (latest?.options ?? []).map((o, i) => (
364      <Button
365        key={`opt-${i}`}
366        label={o.label}
367        hotkey={/^[1-9]$/.test(o.label) ? o.label : undefined}
368        onPress={() => void (o.send ? send(o.fill) : fill(o.fill))}
369      />
370    ))
371
372    const statusParts: string[] = []
373    if (facts !== null) {
374      statusParts.push(facts.branch)
375      if (facts.unpushed > 0) {
376        statusParts.push(`${facts.unpushed} unpushed${facts.hasUpstream ? '' : ' (no upstream)'}`)
377      }
378      if (facts.uncommitted > 0) statusParts.push(`${facts.uncommitted} uncommitted`)
379    }
380    if (last > 0) {
381      const left = CACHE_TTL_MS - (Math.max(current, last) - last)
382      statusParts.push(left > 0 ? `cache ~${Math.ceil(left / 60000)} min` : 'cache cold')
383    }
384
385    const hasChanges = facts !== null && (facts.ahead !== 0 || facts.uncommitted > 0)
386    const commands = COMMANDS.filter(c => hasChanges || !['/review-brief', '/open-pr'].includes(c))
387    const showLater = later.length === 1 || isLaterOpen
388
389    return (
390      <Box flexDirection="column">
391        {topic !== null && (
392          <Text dimColor wrap="truncate-end">
393            TLDR: {topic}
394          </Text>
395        )}
396        {asks.slice(-SHOWN).map(item => (
397          <Box key={`ask-${item.id}`} flexDirection="column">
398            <Box flexDirection="row" gap={1}>
399              <Text color="yellow" wrap="truncate-end">
400                ● Needs you: {item.text}
401                {minutesSince(item.at, current)}
402              </Text>
403              {item.kind === 'do' && action(item, 'done', () => fill(`Done: ${item.text}`))}
404              {more(item)}
405              {dismiss(item)}
406            </Box>
407            {detail(item)}
408          </Box>
409        ))}
410        {asks.length > SHOWN && <Text dimColor>+{asks.length - SHOWN} older asks</Text>}
411        {latest?.done != null && (
412          <Text dimColor wrap="truncate-end">
413            Done: {latest.done}
414          </Text>
415        )}
416        {replyButtons.length > 0 && (
417          <Box flexDirection="row" gap={1}>
418            {latest?.question != null && <Text color="yellow">{latest.question}</Text>}
419            {replyButtons}
420          </Box>
421        )}
422        {later.length > 1 && (
423          <Box flexDirection="row" gap={1}>
424            <Text color="magenta">◆ {later.length} later</Text>
425            <Button
426              key="later-toggle"
427              label={isLaterOpen ? 'hide' : 'show'}
428              plain
429              dimColor
430              onPress={() => void update($, laterOpen, (o: boolean) => !o)}
431            />
432          </Box>
433        )}
434        {showLater && later.slice(-SHOWN).map(item => (
435          <Box key={`later-${item.id}`} flexDirection="column">
436            <Box flexDirection="row" gap={1}>
437              <Text color="magenta" wrap="truncate-end">
438                ◆ Later: {item.text}
439              </Text>
440              {action(item, 'now', () => fill(`Work on this now: ${item.text}`))}
441              {!item.fromTask && action(item, 'task', () => send(`Suggest a background task for: ${item.text}`))}
442              {more(item)}
443              {dismiss(item)}
444            </Box>
445            {detail(item)}
446          </Box>
447        ))}
448        {showLater && later.length > SHOWN && <Text dimColor>+{later.length - SHOWN} older later</Text>}
449        {statusParts.length > 0 && (
450          <Box flexDirection="row" gap={1}>
451            <Text dimColor wrap="truncate-end">
452              {statusParts.join(' · ')}
453            </Text>
454            {last > 0 && (
455              <Button
456                key="keep-warm"
457                label={isKeepingWarm ? 'keep warm: on' : 'keep warm: off'}
458                plain
459                dimColor
460                onPress={() => void update($, keepWarm, (isOn: boolean) => !isOn)}
461              />
462            )}
463          </Box>
464        )}
465        {!e.props.isWorking && (
466          <Box flexDirection="row" gap={1}>
467            {commands.map(command => (
468              <Button
469                key={`cmd-${command}`}
470                label={command}
471                dimColor
472                onPress={() => void start(command)}
473              />
474            ))}
475          </Box>
476        )}
477      </Box>
478    )
479  })
480}
481
types/index.d.ts 45 lines
1export type ItemKind = 'answer' | 'do' | 'later'
2
3export type Item = {
4  id: number
5  kind: ItemKind
6  text: string
7  at: number
8  detail?: string
9  expanding?: boolean
10  failed?: boolean
11  fromTask?: boolean
12}
13
14export type AskOption = { label: string; fill: string; send?: boolean }
15
16export type Reply = {
17  question: string | null
18  done: string | null
19  options: AskOption[]
20}
21
22export type GitFacts = {
23  branch: string
24  unpushed: number
25  ahead: number | null
26  hasUpstream: boolean
27  uncommitted: number
28}
29
30declare module 'claude-code' {
31  interface PluginState {
32    'needs-you': {
33      items: Item[]
34      reply: Reply | null
35      git: GitFacts | null
36      now: number
37      tldr: string | null
38      shown: string | null
39      laterOpen: boolean
40      lastCall: number
41      keepWarm: boolean
42    }
43  }
44}
45