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

Collection of my personal dotfiles.
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.
These dotfiles should also work on Windows, but requires some manual steps.
winget install Python Git.Git zig.zig ajeetdsouza.zoxide junegunn.fzf OpenJS.NodeJS Starship.Starship
winget install Neovide.Neovide SomePythonThings.WingetUIStore 7zip.7zip GitHub.cli
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 is a local helper script in this repo for git worktree workflows.
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 refgw move: move the currently checked out branch into a new worktreegw ls / gw ls --json: list worktreesgw cd: pick and switch to a worktreegw rm: remove a worktreegw prune: prune stale worktree metadatagw doctor: validate worktree setup and healthCommon non-interactive flags:
--yes: auto-confirm destructive/interactive actions--no-prompt: fail instead of prompting--path / --index: explicit target selection for gw rm / gw cdFor shell wrappers, gw emits:
GW_CD_TARGET=<path>
Wrappers should only parse this machine-readable line for auto-cd.
Run:
./scripts/test-gw.sh
Software I use regularly. The software on this list is available on linux, but some is also compatible with windows.
See device_specific for device specific configurations and notes for my personal devices.
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.
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
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.
hooks/register.tsx 481 lines1import { 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}
481types/index.d.ts 45 lines1export 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