A side pane for Claude Code with collapsible widgets: files Claude wrote, terminal splits and the state of your git repos, in retro colors.

<img src="docs/flags/gb.svg" height="13" alt=""> <b>English</b> · <img src="docs/flags/de.svg" height="13" alt=""> <a href="README.de.md">Deutsch</a>
A side pane for Claude Code. It sits to the right of the conversation and shows what you otherwise keep asking for:
It comes with 41 retro color schemes, speaks English and German, and plays a small text effect in its title.
♞ Turbo-MOD v0.13.0 · (c) 2026 Michael Blaess · Open Source @ 16 MHz
Theme ◂ lenseflare ▸ · [EN] · GitHub
▾ Files 2 files · 14.3 KB
2 + notes.md
~/projects/shop/docs
2.1 KB · 10-08 15:02:11
[nvim] [open] [path] [content]
1 ~ cart.ts
...
[clear list]
▾ Terminal Windows Terminal · cheat sheet
Split [←] [→] [↓]
[New tab] [Close last pane]
▾ Repos 2 of 9 with changes
● shop ~3 ?1 ↑1 [pull] [>_] feature/ABC-123-fix
● website ↓2 [pull] [>_] main
× ~/projects
[refresh] [fetch all] [add folder] 15:02
▾ Claude · cheat sheet
Current session
██████████░░░░░░░░░░░░ 48 %
Resets 17:20
Current week
██░░░░░░░░░░░░░░░░░░░░ 10 %
Resets 10-15 09:00
Context
████░░░░░░░░░░░░░░░░░░ 21 %
42k of 200k · Cost $1.37
[refresh] 15:02 [/compact] [/clear]
Everything in square brackets is a button, and so are the single arrows and the cross.
turbo-mod is a mod: a Claude Code plugin that draws its own surface. The mod interface of Claude Code is early access and may change between releases. turbo-mod was built and tested with Claude Code 2.1.291.
[>_]: Windows Terminal on Windows, or tmux on Linux and macOS. Without one of them the rest of the pane still works.Git Bash on Windows: start it as a profile inside Windows Terminal. In the standalone Git Bash window (mintty) Claude Code does not start interactively and ends with Input must be provided either through stdin or as a prompt argument. That is a limit of that window, not of the mod.
claude
/plugin install turbo-mod --marketplace michaelblaess/turbo-mod
Add marketplace? - answer y. This only tells Claude Code where to fetch the mod from./turbo.You then see Installed turbo-mod. Plugin is now active. and the pane appears on the right. No restart is needed.
The pane does not show up? It opens by itself only when the terminal is at least 144 columns wide. In a narrower window, type /turbo.
[Understood] once. Until then [pull], [fetch all] and the ticket command stay locked, everything else works. The answer is remembered on this machine.[EN] next to it to switch the pane to German, and again to switch back.[choose folder] to pick the folder your git repos live in. [add folder] adds a second one, for example one for work and one for your own projects.Every file Claude wrote through Write, Edit or NotebookEdit, newest on top:
+ for a new file, ~ for a changed one[nvim] by default) that opens the file in a new split. The button only appears when the editor was found on your machine.[open] opens the file with the default application of your system[path] puts the path of the file on the clipboard, [content] its whole text. Handy for a draft you want to paste somewhere else.When the list is longer than its room in the pane, a row with two arrows and the range (◂ 6-10 of 11 ▸) pages through it. After the last page the first one follows. A new write takes the list back to page one, because the file just written is on top.
One row with the three splits, each an arrow in the direction the new pane opens, and one row with [New tab] and [Close last pane]. Everything starts in the working folder of the session. [Close last pane] asks once: the button turns into [Really close?] and only closes on a second click within five seconds. Under Windows Terminal the heading carries a link to a cheat sheet for its shortcuts.
The state of all git repos directly below one or more folders. Only repos that need attention are listed. Behind the name is what is open:
| Sign | Meaning |
|---|---|
~3 | three changed files git already knows |
?1 | one new file git does not know yet |
↑1 | one commit you have not pushed |
↓2 | two commits you do not have yet (as of your last fetch) |
The widget starts folded and reads when you open it, then every two minutes while it stays open.
[pull] next to a repo runs git pull --ff-only there, which fetches first. It only ever fast-forwards: no merge commit, no rebase. If that is not possible, git's reason is shown and nothing is changed. A pull only clears ↓. A repo with ~ or ? stays in the list afterwards, because your own changes are still there.[>_] opens a terminal split in the folder of that repo.[fetch all] runs git fetch in every repo and reads again. Use it to see which repos are behind, since reading alone never contacts a server.[add folder] adds another folder, the × in front of a folder removes it from the list. With more than one folder, the repos are grouped by folder.ABC-123 in feature/ABC-123-fix) becomes a link to that ticket. Set it once with /turbo ticket https://jira.example.com/browse/{id}.What Claude Code measures for the running session, drawn as bars like /usage does: each rate-limit window with the share used and the time it resets, then the context window with the cost so far. A bar is green, yellow from 80 % and red from 95 %.
The figures arrive after every answer and once a minute while the widget is open, [refresh] reads them right away. The pane never asks a server for them: Claude Code learns the limits from its last answer, so use in another session shows up only after the next answer in this one. The heading carries a link to a Claude Code cheat sheet.
Next to [refresh] are [/compact] and [/clear]. They do what the commands of the same name do: summarize the conversation to free context, or start a fresh one. Both change the conversation for good, so the button turns into [Really?] and only runs on a second click within five seconds.
Off by default. The widget appears once you name a command that prints tickets as JSON:
/turbo feed <command>
The mod runs that command when you open the widget, on [refresh] and every ten minutes while it is open, and lists what comes back: the ticket key as a link, the title behind it, eight tickets at most. The mod never talks to a ticket system itself and knows no credentials, the command does that. /turbo feed off switches the widget off again.
The command prints one JSON object. Only key is required per ticket, other fields are ignored:
{
"since": "2026-10-07",
"tickets": [{ "key": "ABC-123", "summary": "Counter overflows", "url": "https://jira.example.com/browse/ABC-123" }]
}
A message in that object is shown instead of the list, for example when the command could not reach the server. jira-timesheet prints exactly this format for Jira, from version 1.25.0 on:
/turbo feed jira-timesheet --new-tickets-json
If jira-timesheet is not on the PATH, give the full path. A path with blanks goes in double quotes.
| Command | What it does | |
|---|---|---|
/turbo | show or hide the pane | |
/turbo theme <name> | pick a color scheme, without a name you get the list | |
/turbo repos <path> | add a repo folder on this machine, without a path: refresh | |
/turbo repos remove <path> | remove a repo folder from the list | |
/turbo ticket <address> | ticket address with {id}, without an address: show it | |
/turbo feed <command> | switch on the widget New tickets, off switches it off | |
| `/turbo lang <de\ | en>` | language of the pane and of the answers |
/turbo clear | reset the list of files, numbering starts again at 1 | |
/turbo effect | switch the title effect off or on | |
/turbo demo | show or hide made-up sample data, for a screenshot that shows nothing of your work |
Everything you set this way is remembered per machine and survives a restart. The close mark of the pane or ctrl+x x hides it as well.
41 retro schemes, taken from textual-themes, for example brotkasten, boing, synthwave, lenseflare and christophorus. standard follows the theme you set in Claude Code.
Under /config, or in ~/.claude/settings.json under pluginConfigs["turbo-mod"].options. A value set in the pane or with /turbo on a machine takes precedence there.
| Field | Default | Meaning |
|---|---|---|
theme | standard | color scheme |
reposRoot | empty | repo folder, several separated by ; |
language | en | language of the pane, en or de |
ticketUrl | empty | ticket address with {id} |
ticketFeed | empty | command for the widget New tickets |
editor | nvim | program behind the editor button, a name or a full path |
Without a folder from the pane or from reposRoot, the environment variable TURBO_MOD_REPOS is used.
claude plugin update turbo-mod
claude plugin uninstall turbo-mod
After an update, type /reload-plugins in a running session.
file: links, the path is shown as text there.[Close last pane] moves the focus to the pane that was active before and then sends Ctrl+Shift+W to the window in the foreground. If there is no other pane, that closes the tab Claude runs in.zenity on Linux, the clipboard buttons wl-copy or xclip.[content] is meant for text files.[pull] and [fetch all] talk to the server. Both wait at most a minute per repo.Clone the repo and load the mod from the folder. Claude Code watches the folder and reloads the mod when you save a file.
git clone https://github.com/michaelblaess/turbo-mod
claude --plugin-dir turbo-mod
To load it in every session, put the absolute path of the clone into the environment variable CLAUDE_CODE_PLUGIN_DIRS. A terminal that was already open keeps the old value, so restart it.
On load, Claude Code writes the types of the mod interface to .claude-plugin/types/ (ignored by git). With them in place:
npm install # once: Biome and TypeScript, only for development
npm run check # Biome, tsc, claude plugin validate, claude plugin test
npm run format # let Biome fix formatting and what it can fix safely
Biome lints and formats hooks/ and types/, the generated hooks/palettes.ts is left out. package-lock.json is not committed, it depends on the registry of the machine.
Texts live in hooks/texts.ts, one block per language. The palettes in hooks/palettes.ts are a snapshot written by tools/snapshot_palettes.py. What is planned and what was decided is in PLAN.md (German).
Apache License 2.0 - (c) 2026 Michael Blaess
hooks/register.tsx 1340 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4import type { FeedState, FileEntry, HostInfo, RepoEntry, ReposState, UsageState } from '../types'
5import type { EffectName } from './effects'
6import { EFFECTS, effectFrame } from './effects'
7import { demoState } from './demo'
8import { parseFeed } from './feed'
9import {
10 barCells,
11 dotsFor,
12 fileHref,
13 formatClock,
14 formatDay,
15 formatReset,
16 formatSize,
17 formatStamp,
18 formatTokens,
19 formatUsd,
20 shortenDir,
21 splitPath,
22 ticketHref,
23 ticketOf,
24 workingLabel,
25} from './format'
26import { FETCH_ARGV, isClean, PULL_ARGV, parseStatus, repoCounts, STATUS_ARGV, sortRepos } from './git'
27import {
28 CHEAT_SHEET,
29 CLAUDE_SHEET,
30 copyFileArgv,
31 copyTextArgv,
32 detectHost,
33 isPath,
34 joinPath,
35 openDefaultArgv,
36 openEditorArgv,
37 pickFolderArgv,
38 splitCommand,
39 splitRoots,
40 terminalActions,
41 whereArgv,
42} from './platform'
43import type { Lang } from './texts'
44import { findLanguage, LANGUAGES, textsFor } from './texts'
45import { findTheme, lookFor, STANDARD, themeNames } from './theme'
46
47// Must match the version in .claude-plugin/plugin.json
48const VERSION = '0.13.0'
49
50const PANE = 'turbo-mod'
51const PANE_TITLE = 'Turbo-MOD'
52const COMMAND = 'turbo'
53// U+265E is the knight from the chess symbols
54const TITLE = '♞ Turbo-MOD'
55const AUTHOR = '(c) 2026 Michael Blaess'
56const AUTHOR_URL = 'https://www.michaelblaess.de'
57const TAGLINE = ' · Open Source @ 16 MHz'
58const REPO_URL = 'https://github.com/michaelblaess/turbo-mod'
59// Share of the terminal width the pane gets (Claude 80 %, pane 20 %)
60const SHARE = 0.2
61const MIN_COLUMNS = 30
62const MAX_FILES = 200
63const MAX_REPOS = 80
64// Tools that write a file and carry its path in the call
65const WRITERS = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit']
66const TITLE_FRAMES = 14
67const TITLE_FRAME_MS = 70
68// One second of rest between two runs
69const TITLE_PAUSE_TICKS = Math.round(1000 / TITLE_FRAME_MS)
70const REPOS_EVERY_MS = 120000
71const PULSE_EVERY_MS = 350
72const USAGE_EVERY_MS = 60000
73// The feed asks a ticket system, so it runs rarely
74const FEED_EVERY_MS = 600000
75const FEED_TIMEOUT_MS = 30000
76const MAX_FEED_ROWS = 8
77const RUN_TIMEOUT_MS = 10000
78// Fetch and pull talk to a server
79const NET_TIMEOUT_MS = 60000
80const PICK_TIMEOUT_MS = 300000
81const ARMED_MS = 5000
82// The three splits share one row, each as an arrow in the direction the new pane opens
83const SPLIT_SYMBOLS: Record<string, string> = {
84 'split-left': '[←]',
85 'split-right': '[→]',
86 'split-down': '[↓]',
87}
88
89const NO_REPOS: ReposState = {
90 roots: [],
91 list: [],
92 checkedAt: 0,
93 isBusy: false,
94 isFetching: false,
95 pulling: '',
96 note: '',
97}
98const NO_HOST: HostInfo = { os: 'linux', terminal: 'none' }
99const NO_FEED: FeedState = { command: '', list: [], since: '', checkedAt: 0, isBusy: false, note: '' }
100const NO_USAGE: UsageState = { measuredAt: 0, tokens: 0, window: 0, limits: [], usd: -1 }
101
102const files = atom({ plugin: 'turbo-mod', key: 'files' } as const, [] as FileEntry[])
103// The repo overview starts collapsed so that git does not run unasked
104const collapsed = atom({ plugin: 'turbo-mod', key: 'collapsed' } as const, { repos: true } as Record<string, boolean>)
105const repos = atom({ plugin: 'turbo-mod', key: 'repos' } as const, NO_REPOS)
106const host = atom({ plugin: 'turbo-mod', key: 'host' } as const, NO_HOST)
107const theme = atom({ plugin: 'turbo-mod', key: 'theme' } as const, STANDARD)
108const title = atom({ plugin: 'turbo-mod', key: 'title' } as const, TITLE)
109const filePage = atom({ plugin: 'turbo-mod', key: 'filePage' } as const, 0)
110const pulse = atom({ plugin: 'turbo-mod', key: 'pulse' } as const, 0)
111const accepted = atom({ plugin: 'turbo-mod', key: 'accepted' } as const, false)
112const ready = atom({ plugin: 'turbo-mod', key: 'ready' } as const, false)
113const armed = atom({ plugin: 'turbo-mod', key: 'armed' } as const, '')
114const lang = atom({ plugin: 'turbo-mod', key: 'lang' } as const, '')
115const editorPath = atom({ plugin: 'turbo-mod', key: 'editorPath' } as const, '')
116const usage = atom({ plugin: 'turbo-mod', key: 'usage' } as const, NO_USAGE)
117const ticketUrl = atom({ plugin: 'turbo-mod', key: 'ticketUrl' } as const, '')
118const feed = atom({ plugin: 'turbo-mod', key: 'feed' } as const, NO_FEED)
119
120// Applies per module load: after a reload the timers are gone and the start runs again
121let isStarted = false
122let isRestoring = false
123let isTitleLooping = true
124// While sample data is shown, nothing real may be read or written into the pane
125let isDemo = false
126let beforeDemo:
127 | { files: FileEntry[]; repos: ReposState; feed: FeedState; usage: UsageState; collapsed: Record<string, boolean> }
128 | undefined
129// The language from the options, it applies until the start has read the remembered choice
130let optionLanguage: Lang = 'en'
131
132const languageNow = async ($: EngineInterface): Promise<Lang> => findLanguage(await read($, lang)) ?? optionLanguage
133
134const pathOf = (e: unknown): string | undefined => {
135 const input = e as { file_path?: unknown; notebook_path?: unknown }
136 const path = input.file_path ?? input.notebook_path
137
138 return typeof path === 'string' && path.length > 0 ? path : undefined
139}
140
141const wantedColumns = (terminalColumns: number): number => Math.max(MIN_COLUMNS, Math.round(terminalColumns * SHARE))
142
143const textOf = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
144
145const isPaneUp = async ($: EngineInterface): Promise<boolean> =>
146 (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced)
147
148type Reading = {
149 context: { tokens?: number; window: number; percent?: number }
150 rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]
151 cost?: { usd: number }
152}
153
154/** Keeps what the host measured: context window, rate-limit windows and cost. */
155const keepUsage = async ($: EngineInterface, reading: Reading): Promise<void> => {
156 if (isDemo) return
157
158 const measuredAt = await $.clock.now()
159 const window = reading.context.window
160 // Some hosts report only the share, some only the tokens
161 const tokens = reading.context.tokens ?? Math.round(((reading.context.percent ?? 0) / 100) * window)
162 await update($, usage, () => ({
163 measuredAt,
164 tokens,
165 window,
166 limits: reading.rateLimits.map(one => ({
167 kind: one.kind,
168 percentUsed: one.percentUsed,
169 resetsAt: one.resetsAt ?? '',
170 })),
171 usd: reading.cost?.usd ?? -1,
172 }))
173}
174
175/** True once the notice of the first start is confirmed, otherwise says so in a toast. */
176const isAccepted = async ($: EngineInterface): Promise<boolean> => {
177 if ((await read($, accepted)) === true) return true
178 $.ui.toast(textsFor(await languageNow($)).acceptFirst)
179
180 return false
181}
182
183/** Runs the feed command and keeps the tickets it prints. */
184const refreshFeed = async ($: EngineInterface): Promise<void> => {
185 if (isDemo) return
186
187 const before = (await read($, feed)) ?? NO_FEED
188 const argv = splitCommand(before.command)
189 if (argv.length === 0 || before.isBusy) return
190 if (!(await isAccepted($))) return
191
192 await update($, feed, now => ({ ...now, isBusy: true }))
193 const result = await $.process.run(argv, { timeoutMs: FEED_TIMEOUT_MS }).catch(() => undefined)
194 const checkedAt = await $.clock.now()
195 // A feed may end with an error code and still say why in its JSON
196 const parsed = result === undefined ? { list: [], since: '', note: 'no-start' } : parseFeed(result.stdout)
197 await update($, feed, now => ({ ...now, ...parsed, checkedAt, isBusy: false }))
198}
199
200/** Sets the feed command for this machine. An empty command switches the widget off. */
201const setFeed = async ($: EngineInterface, command: string): Promise<void> => {
202 await update($, feed, () => ({ ...NO_FEED, command }))
203 await $.store.set('feedCommand', command)
204 if (command.length > 0) {
205 await update($, collapsed, now => ({ ...now, feed: false }))
206 await refreshFeed($)
207 }
208}
209
210/**
211 * Shows or hides sample data. What the pane showed before is kept and comes back, the
212 * figures of the session are read again right away.
213 */
214const toggleDemo = async ($: EngineInterface): Promise<boolean> => {
215 if (isDemo) {
216 isDemo = false
217 const kept = beforeDemo
218 beforeDemo = undefined
219 if (kept !== undefined) {
220 await update($, files, () => kept.files)
221 await update($, repos, () => kept.repos)
222 await update($, feed, () => kept.feed)
223 await update($, usage, () => kept.usage)
224 await update($, collapsed, () => kept.collapsed)
225 }
226 await readUsage($)
227
228 return false
229 }
230
231 beforeDemo = {
232 files: await read($, files),
233 repos: await read($, repos),
234 feed: (await read($, feed)) ?? NO_FEED,
235 usage: (await read($, usage)) ?? NO_USAGE,
236 collapsed: await read($, collapsed),
237 }
238 const sample = demoState(await $.clock.now())
239 isDemo = true
240 await update($, files, () => sample.files)
241 await update($, repos, () => sample.repos)
242 await update($, feed, () => sample.feed)
243 await update($, usage, () => sample.usage)
244 // Everything open, so one screenshot shows every widget
245 await update($, collapsed, () => ({ files: false, terminal: false, repos: false, feed: false, session: false }))
246
247 return true
248}
249
250/** Asks the host for its current figures. Costs no request, the host has them at hand. */
251const readUsage = async ($: EngineInterface): Promise<void> => {
252 const reading = await $.session.usage().catch(() => undefined)
253 if (reading !== undefined) await keepUsage($, reading).catch(() => undefined)
254}
255
256/**
257 * True on the second press of a button within a few seconds, the first press only arms it.
258 * For everything that cannot be undone.
259 */
260const isSecondPress = async ($: EngineInterface, key: string): Promise<boolean> => {
261 if ((await read($, armed)) === key) {
262 await update($, armed, () => '')
263
264 return true
265 }
266
267 await update($, armed, () => key)
268 $.clock.after(ARMED_MS, () => {
269 void update($, armed, now => (now === key ? '' : now))
270 })
271
272 return false
273}
274
275/** Compacts the conversation as /compact does, then reads the figures again. */
276const compactSession = async ($: EngineInterface): Promise<void> => {
277 const t = textsFor(await languageNow($))
278 const result = await $.session.compact().catch(() => undefined)
279 if (result === undefined) $.ui.toast(t.hostFailed('/compact'))
280 // The host says why it did not compact, for example when there is nothing to compact yet
281 else $.ui.toast(typeof result.skip === 'string' ? `/compact: ${result.skip}` : t.compacted)
282 await readUsage($)
283}
284
285/** Starts a fresh conversation as /clear does. */
286const clearSession = async ($: EngineInterface): Promise<void> => {
287 const result = await $.command.run({ command: 'clear' }).catch(() => undefined)
288 if (result === undefined) $.ui.toast(textsFor(await languageNow($)).hostFailed('/clear'))
289}
290
291/** Starts a program on the machine and shows a toast if that fails. True if it ran without complaint. */
292const run = async ($: EngineInterface, argv: readonly string[], what: string): Promise<boolean> => {
293 const result = await $.process.run(argv, { timeoutMs: RUN_TIMEOUT_MS }).catch(() => undefined)
294 if (result === undefined) {
295 $.ui.toast(textsFor(await languageNow($)).runFailed(what, argv[0] ?? ''))
296
297 return false
298 }
299
300 // explorer.exe returns 1 even on success, so only an error message counts
301 const complaint = result.stderr.trim().split('\n')[0] ?? ''
302 if (result.exitCode !== 0 && complaint.length > 0) {
303 $.ui.toast(`${what}: ${complaint}`)
304
305 return false
306 }
307
308 return true
309}
310
311/**
312 * Looks up the full path of the editor. A new pane inherits the environment of the terminal, where
313 * the editor's folder is sometimes missing from PATH even though Claude Code knows it.
314 */
315const findEditor = async ($: EngineInterface, hostInfo: HostInfo, editor: string): Promise<string> => {
316 if (isPath(editor)) {
317 const stat = await $.fs.stat(editor).catch(() => undefined)
318
319 return stat !== undefined && stat.kind === 'file' ? editor : ''
320 }
321
322 const found = await $.process.run(whereArgv(hostInfo, editor), { timeoutMs: RUN_TIMEOUT_MS }).catch(() => undefined)
323 if (found === undefined || found.exitCode !== 0) return ''
324
325 return (found.stdout.split('\n')[0] ?? '').trim()
326}
327
328/** The root folders of a state. A state kept from before 0.6.0 has one `root` and no `roots`. */
329const rootsOf = (state: ReposState): string[] => {
330 const old = (state as { root?: unknown }).root
331
332 return Array.isArray(state.roots) ? state.roots : typeof old === 'string' && old.length > 0 ? [old] : []
333}
334
335/** Reads the state of one repo from `git status`. */
336const readRepo = async ($: EngineInterface, root: string, dir: string, name: string): Promise<RepoEntry> => {
337 const status = await $.process.run(STATUS_ARGV, { cwd: dir, timeoutMs: RUN_TIMEOUT_MS }).catch(() => undefined)
338
339 return status === undefined || status.exitCode !== 0
340 ? { root, dir, name, branch: '?', ahead: 0, behind: 0, changed: 0, untracked: 0, isRead: false }
341 : parseStatus(name, status.stdout, root, dir)
342}
343
344/**
345 * Reads the state of all git repos under the root folders. Contacts no server, unless `isFetching`
346 * asks for a `git fetch` in every repo first.
347 */
348const refreshRepos = async ($: EngineInterface, isFetching = false): Promise<void> => {
349 if (isDemo) return
350
351 const before = await read($, repos)
352 const roots = rootsOf(before)
353 if (roots.length === 0 || before.isBusy) return
354 // Reading the local state is plain display, only the fetch talks to a server
355 if (isFetching && !(await isAccepted($))) return
356
357 await update($, repos, now => ({ ...now, isBusy: true, isFetching, note: '' }))
358 const list: RepoEntry[] = []
359 let unreadable = 0
360 for (const root of roots) {
361 const entries = await $.fs.list(root).catch(() => undefined)
362 if (entries === undefined) {
363 unreadable += 1
364 continue
365 }
366
367 for (const entry of entries.filter(one => one.kind === 'dir').slice(0, MAX_REPOS)) {
368 const dir = joinPath(root, entry.name)
369 // .git is a file in a worktree, otherwise a folder
370 const marker = await $.fs.stat(joinPath(dir, '.git')).catch(() => undefined)
371 if (marker === undefined || marker.kind === 'other') continue
372
373 // A failed fetch (no remote, no network) leaves the counters as they were
374 if (isFetching) await $.process.run(FETCH_ARGV, { cwd: dir, timeoutMs: NET_TIMEOUT_MS }).catch(() => undefined)
375 list.push(await readRepo($, root, dir, entry.name))
376 }
377 }
378
379 const checkedAt = await $.clock.now()
380 await update($, repos, now => ({
381 ...now,
382 isBusy: false,
383 isFetching: false,
384 list: sortRepos(list),
385 checkedAt,
386 note: unreadable === roots.length ? 'unreadable' : list.length === 0 ? 'empty' : '',
387 }))
388}
389
390/** Fetches one repo and pulls it fast-forward only, then reads its state again. */
391const pullRepo = async ($: EngineInterface, repo: RepoEntry): Promise<void> => {
392 if (isDemo) return
393
394 const before = await read($, repos)
395 if (before.isBusy || (before.pulling ?? '').length > 0) return
396 if (!(await isAccepted($))) return
397
398 await update($, repos, now => ({ ...now, pulling: repo.dir }))
399 const t = textsFor(await languageNow($))
400 const result = await $.process.run(PULL_ARGV, { cwd: repo.dir, timeoutMs: NET_TIMEOUT_MS }).catch(() => undefined)
401 if (result === undefined) {
402 $.ui.toast(t.runFailed(`${repo.name}: pull`, 'git'))
403 } else if (result.exitCode !== 0) {
404 // git says why: diverged branches, local changes in the way, no upstream
405 $.ui.toast(`${repo.name}: ${result.stderr.trim().split('\n')[0] ?? 'git pull'}`)
406 } else {
407 $.ui.toast(t.pulled(repo.name))
408 }
409
410 const after = await readRepo($, repo.root, repo.dir, repo.name)
411 await update($, repos, now => ({
412 ...now,
413 pulling: '',
414 list: sortRepos(now.list.map(one => (one.dir === repo.dir ? after : one))),
415 }))
416}
417
418/** Sets the repo folders for this machine, expands the widget and reads. */
419const setReposRoots = async ($: EngineInterface, roots: string[]): Promise<void> => {
420 await update($, repos, now => ({ ...now, roots, list: [], checkedAt: 0, note: '' }))
421 await $.store.set('reposRoots', roots)
422 await update($, collapsed, now => ({ ...now, repos: false }))
423 await refreshRepos($)
424}
425
426const addReposRoot = async ($: EngineInterface, root: string): Promise<void> => {
427 const roots = rootsOf(await read($, repos))
428 await setReposRoots($, roots.includes(root) ? roots : [...roots, root])
429}
430
431/** Shows the folder dialog and takes the choice as the repo folder. */
432const pickReposRoot = async ($: EngineInterface): Promise<void> => {
433 const t = textsFor(await languageNow($))
434 const argv = pickFolderArgv(await read($, host), t.pickTitle)
435 const result = await $.process.run(argv, { timeoutMs: PICK_TIMEOUT_MS }).catch(() => undefined)
436 if (result === undefined) {
437 $.ui.toast(t.pickFailed(argv[0] ?? ''))
438
439 return
440 }
441
442 // Cancelled means: nothing printed
443 const root = result.stdout.trim()
444 if (root.length > 0) await addReposRoot($, root)
445}
446
447/** Switches to the next or previous color scheme and remembers it for this machine. */
448const stepTheme = async ($: EngineInterface, step: number): Promise<void> => {
449 const names = themeNames()
450 const current = Math.max(0, names.indexOf(await read($, theme)))
451 const next = names[(current + step + names.length) % names.length] ?? STANDARD
452 await update($, theme, () => next)
453 await $.store.set('theme', next)
454}
455
456/** Changes the language and remembers it for this machine. */
457const setLanguage = async ($: EngineInterface, language: Lang): Promise<void> => {
458 await update($, lang, () => language)
459 await $.store.set('lang', language)
460}
461
462/** Switches to the next language. */
463const stepLanguage = async ($: EngineInterface): Promise<void> => {
464 const current = Math.max(0, LANGUAGES.indexOf(await languageNow($)))
465 await setLanguage($, LANGUAGES[(current + 1) % LANGUAGES.length] ?? 'en')
466}
467
468/**
469 * Runs the title through an effect at intervals: effect, one second of rest, next effect.
470 * While the pane is not visible, the loop rests.
471 */
472const startTitleLoop = ($: EngineInterface): void => {
473 let tick = 0
474 let seed = 1
475 let effect: EffectName = 'decrypt'
476 let isVisible = false
477 // Writes one frame after the other so that no late frame overtakes an earlier one
478 let queue: Promise<unknown> = Promise.resolve()
479 const show = (text: string): void => {
480 queue = queue.then(() => update($, title, () => text)).catch(() => undefined)
481 }
482
483 $.clock.every(TITLE_FRAME_MS, () => {
484 const position = tick % (TITLE_FRAMES + TITLE_PAUSE_TICKS)
485 tick += 1
486
487 if (position === 0) {
488 seed += 1
489 // Never the same effect twice in a row
490 const others = EFFECTS.filter(one => one !== effect)
491 effect = others[Math.floor(Math.random() * others.length)] ?? 'decrypt'
492 void isPaneUp($)
493 .then(isUp => {
494 isVisible = isUp
495 })
496 .catch(() => undefined)
497 }
498
499 if (!isTitleLooping || !isVisible || position > TITLE_FRAMES) return
500
501 show(position === TITLE_FRAMES ? TITLE : effectFrame(effect, TITLE, position, TITLE_FRAMES, seed))
502 })
503}
504
505/**
506 * Brings the session state back from the store, the options and the environment. Runs again
507 * whenever the host has dropped the state, which a /clear does without loading the mod anew.
508 */
509const restore = async ($: EngineInterface, options: PluginOptions): Promise<void> => {
510 // Each step may fail on its own, the rest of the start still runs
511 const stored = async (key: string): Promise<string> => textOf(await $.store.get(key).catch(() => undefined))
512 const language = findLanguage(await stored('lang')) ?? optionLanguage
513 await update($, lang, () => language)
514
515 await $.command
516 .register({
517 name: COMMAND,
518 description: textsFor(language).commandDescription,
519 argumentHint:
520 '[theme <name> | repos [remove] <path> | ticket <url> | feed <command> | lang <de|en> | clear | effect | demo]',
521 })
522 .catch(() => undefined)
523
524 const os = await $.env.get('OS').catch(() => undefined)
525 const wtSession = await $.env.get('WT_SESSION').catch(() => undefined)
526 const tmux = await $.env.get('TMUX').catch(() => undefined)
527 const uname =
528 (os ?? '').toLowerCase() === 'windows_nt'
529 ? undefined
530 : await $.process
531 .run(['uname', '-s'])
532 .then(result => result.stdout)
533 .catch(() => undefined)
534 const detected = detectHost({ os, wtSession, tmux }, uname)
535 await update($, host, () => detected)
536
537 const found = await findEditor($, detected, textOf(options.editor) || 'nvim').catch(() => '')
538 await update($, editorPath, () => found)
539
540 // What /turbo has set on this machine takes precedence over the options and the environment
541 const chosen = findTheme(await stored('theme')) ?? findTheme(textOf(options.theme)) ?? STANDARD
542 await update($, theme, () => chosen)
543 const kept = await $.store.get('reposRoots').catch(() => undefined)
544 const roots = Array.isArray(kept)
545 ? kept.filter((one): one is string => typeof one === 'string' && one.length > 0)
546 : splitRoots(
547 // Before 0.6.0 there was one folder under the key reposRoot
548 (await stored('reposRoot')) ||
549 textOf(options.reposRoot) ||
550 textOf(await $.env.get('TURBO_MOD_REPOS').catch(() => undefined)),
551 )
552 await update($, repos, now => ({ ...now, roots, isBusy: false, isFetching: false, pulling: '' }))
553 const tickets = (await stored('ticketUrl')) || textOf(options.ticketUrl)
554 await update($, ticketUrl, () => tickets)
555 const feedCommand = (await stored('feedCommand')) || textOf(options.ticketFeed)
556 await update($, feed, now => ({ ...NO_FEED, ...now, command: feedCommand, isBusy: false }))
557 // Only ever switches on: a click that came before the end of the start must not be undone
558 if ((await stored('accepted')) === 'yes') await update($, accepted, () => true)
559 await readUsage($)
560 isTitleLooping = (await stored('effect')) !== 'aus'
561 await update($, armed, () => '')
562 await update($, title, () => TITLE)
563 await update($, ready, () => true)
564}
565
566/** The timers of the mod. They survive a /clear, so they start once per module load. */
567const startTimers = ($: EngineInterface): void => {
568 startTitleLoop($)
569 // The dots behind a busy label move only while something is being read
570 $.clock.every(PULSE_EVERY_MS, () => {
571 void (async () => {
572 const repoState = await read($, repos)
573 const feedState = (await read($, feed)) ?? NO_FEED
574 const isWorking = repoState.isBusy || (repoState.pulling ?? '').length > 0 || feedState.isBusy
575 if (isWorking) await update($, pulse, now => (now + 1) % 3)
576 })().catch(() => undefined)
577 })
578 $.clock.every(FEED_EVERY_MS, () => {
579 void (async () => {
580 const isFolded = (await read($, collapsed)).feed ?? true
581 // Without the confirmation the timer stays quiet instead of raising a toast every time
582 const isConfirmed = (await read($, accepted)) === true
583 if (isConfirmed && !isFolded && (await isPaneUp($))) await refreshFeed($)
584 })().catch(() => undefined)
585 })
586 // The host reports after every answer. In between, a look once a minute keeps the reset times fresh.
587 $.clock.every(USAGE_EVERY_MS, () => {
588 void (async () => {
589 const isFolded = (await read($, collapsed)).session ?? false
590 if (!isFolded && (await isPaneUp($))) await readUsage($)
591 })().catch(() => undefined)
592 })
593 $.clock.every(REPOS_EVERY_MS, () => {
594 void (async () => {
595 const isFolded = (await read($, collapsed)).repos ?? false
596 if (!isFolded && (await isPaneUp($))) await refreshRepos($)
597 })().catch(() => undefined)
598 })
599}
600
601/**
602 * Runs on session.start and, because that does not fire after reloading the mod, resuming a
603 * session or a /clear, also on every render and before every command. Does nothing while the
604 * state is in place.
605 */
606const start = async ($: EngineInterface, options: PluginOptions): Promise<void> => {
607 if (isRestoring) return
608 if (isStarted && (await read($, ready)) === true) return
609
610 isRestoring = true
611 try {
612 await restore($, options)
613 if (!isStarted) {
614 isStarted = true
615 startTimers($)
616 }
617 } finally {
618 isRestoring = false
619 }
620}
621
622export const register: Register = (on, options) => {
623 optionLanguage = findLanguage(textOf(options.language)) ?? 'en'
624 const editorName = splitPath(textOf(options.editor) || 'nvim').name.replace(/\.exe$/i, '')
625
626 on('session.start', async ($, e, next) => {
627 await start($, options)
628 // Opened unasked, the pane only appears from 144 columns on, below that via /turbo
629 void $.ui.open({ id: PANE, title: PANE_TITLE })
630
631 return next(e)
632 })
633
634 on('command.run', { command: COMMAND }, async ($, e) => {
635 await start($, options)
636 const t = textsFor(await languageNow($))
637 const args = e.args.trim()
638 const verb = (args.split(/\s+/)[0] ?? '').toLowerCase()
639 const argument = args.slice(verb.length).trim()
640
641 if (verb === '') {
642 if (await isPaneUp($)) {
643 await $.ui.close({ id: PANE })
644
645 return { text: t.hidden }
646 }
647
648 await $.ui.open({ id: PANE, title: PANE_TITLE, columns: wantedColumns(e.presentation.columns) })
649
650 return { text: t.shown(VERSION) }
651 }
652
653 if (verb === 'demo') {
654 return { text: (await toggleDemo($)) ? t.demoOn : t.demoOff }
655 }
656
657 if (verb === 'leeren' || verb === 'clear') {
658 await update($, files, () => [])
659
660 return { text: t.cleared }
661 }
662
663 if (verb === 'effekt' || verb === 'effect') {
664 isTitleLooping = !isTitleLooping
665 await $.store.set('effect', isTitleLooping ? 'an' : 'aus')
666 await update($, title, () => TITLE)
667
668 return { text: isTitleLooping ? t.effectOn : t.effectOff }
669 }
670
671 if (verb === 'theme' || verb === 'farben' || verb === 'colors') {
672 if (argument.length === 0) return { text: t.themeList(themeNames().join(', ')) }
673
674 const found = findTheme(argument)
675 if (found === undefined) return { text: t.themeUnknown(argument) }
676
677 await update($, theme, () => found)
678 await $.store.set('theme', found)
679
680 return { text: t.themeSet(found) }
681 }
682
683 if (verb === 'sprache' || verb === 'lang' || verb === 'language') {
684 const found = findLanguage(argument)
685 if (found === undefined) return { text: t.languageUnknown(argument) }
686
687 await setLanguage($, found)
688
689 return { text: textsFor(found).languageSet }
690 }
691
692 if (verb === 'feed') {
693 if (argument.length === 0) {
694 const now = ((await read($, feed)) ?? NO_FEED).command
695
696 return { text: now.length > 0 ? t.feedSet(now) : t.feedNone }
697 }
698
699 if (argument === 'aus' || argument === 'off') {
700 await setFeed($, '')
701
702 return { text: t.feedOff }
703 }
704
705 await setFeed($, argument)
706 const after = (await read($, feed)) ?? NO_FEED
707 const note = t.feedNotes[after.note] ?? after.note
708
709 return { text: note.length > 0 ? note : t.feedSet(argument) }
710 }
711
712 if (verb === 'ticket') {
713 if (argument.length === 0) {
714 const now = await read($, ticketUrl)
715
716 return { text: now.length > 0 ? t.ticketSet(now) : t.ticketNone }
717 }
718
719 await update($, ticketUrl, () => argument)
720 await $.store.set('ticketUrl', argument)
721
722 return { text: t.ticketSet(argument) }
723 }
724
725 if (verb === 'repos') {
726 const roots = rootsOf(await read($, repos))
727 const removal = /^(remove|entfernen)\s+(.+)$/i.exec(argument)
728 if (removal !== null) {
729 const root = (removal[2] ?? '').trim()
730 if (!roots.includes(root)) return { text: t.rootUnknown(root) }
731
732 await setReposRoots(
733 $,
734 roots.filter(one => one !== root),
735 )
736
737 return { text: t.rootRemoved(root) }
738 }
739
740 if (argument.length === 0 && roots.length === 0) return { text: t.rootMissing }
741
742 if (argument.length > 0) await addReposRoot($, argument)
743 else await refreshRepos($)
744 const after = await read($, repos)
745 const dirty = after.list.filter(one => !isClean(one)).length
746
747 return { text: t.notes[after.note] ?? t.reposResult(after.list.length, rootsOf(after).join(', '), dirty) }
748 }
749
750 return { text: t.help }
751 })
752
753 on('session.measure', async ($, e, next) => {
754 await keepUsage($, e).catch(() => undefined)
755
756 return next(e)
757 })
758
759 on('tool.call', async ($, e, next) => {
760 const path = WRITERS.includes(String(e.tool)) ? pathOf(e) : undefined
761 if (path === undefined || isDemo) return next(e)
762
763 // Check before writing whether the file already existed: new or changed
764 const before = await $.fs.stat(path).catch(() => undefined)
765 const ran = await next(e)
766 if (ran.deny !== undefined || ran.isError === true) return ran
767
768 const after = await $.fs.stat(path).catch(() => undefined)
769 if (after === undefined || after.kind !== 'file') return ran
770
771 await update($, files, list => {
772 const known = list.find(one => one.path === path)
773 const entry: FileEntry = {
774 path,
775 size: after.size,
776 mtimeMs: after.mtimeMs,
777 isNew: known !== undefined ? known.isNew : before === undefined || before.kind !== 'file',
778 writes: (known?.writes ?? 0) + 1,
779 no: known?.no ?? list.reduce((highest, one) => Math.max(highest, one.no), 0) + 1,
780 }
781
782 return [entry, ...list.filter(one => one.path !== path)].slice(0, MAX_FILES)
783 })
784 // The written file is on top, so the list goes back to the first page
785 await update($, filePage, () => 0)
786
787 return ran
788 })
789
790 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
791 // Nothing may be written while rendering, so the start runs right after
792 if (!isStarted || (await read($, ready)) !== true) {
793 $.clock.after(1, () => {
794 void start($, options).catch(() => undefined)
795 })
796 }
797
798 const { Box, Text, Link, Button } = $.ui.resolve(e)
799 const list = await read($, files)
800 const folded = await read($, collapsed)
801 const repoState = await read($, repos)
802 const hostInfo = await read($, host)
803 const themeName = await read($, theme)
804 const look = lookFor(themeName)
805 const heading = await read($, title)
806 const armedKey = await read($, armed)
807 const isConfirmed = (await read($, accepted)) === true
808 const language = await languageNow($)
809 const t = textsFor(language)
810 const editor = await read($, editorPath)
811 const usageState = (await read($, usage)) ?? NO_USAGE
812 const feedState = (await read($, feed)) ?? NO_FEED
813 const ticketPattern = (await read($, ticketUrl)) ?? ''
814 const contextPercent = usageState.window > 0 ? Math.round((usageState.tokens / usageState.window) * 100) : 0
815
816 const columns = Math.max(16, e.props.bodyColumns)
817 // The height of the pane body, in tests and on other surfaces the value is missing
818 const bodyRows = e.props.scroll?.bodyRows
819 const rows = bodyRows ?? e.viewport?.rows ?? 30
820 // A prop with the value undefined counts as invalid, so it is left out entirely then
821 const ink = look.text === undefined ? {} : { color: look.text }
822 const ground = look.background === undefined ? {} : { backgroundColor: look.background }
823 // Without a minimum height the area of the color scheme ends below the last line
824 const tall = look.background === undefined || bodyRows === undefined ? {} : { minHeight: bodyRows }
825 const chip = look.chip === undefined ? {} : { backgroundColor: look.chip }
826 const isFolded = (id: string): boolean => folded[id] ?? false
827 const foldedNow = (id: string): boolean => folded[id] ?? id === 'feed'
828 const actions = terminalActions(hostInfo, '')
829 const dirty = repoState.list.filter(one => !isClean(one))
830 const total = list.reduce((sum, one) => sum + one.size, 0)
831 const note = t.notes[repoState.note] ?? ''
832
833 // How many rows the other widgets need, the rest belongs to the files (four rows per file)
834 const terminalRows = isFolded('terminal') ? 1 : 3
835 const repoRoom = Math.max(3, Math.floor(rows / 4))
836 const roots = rootsOf(repoState)
837 const pulling = repoState.pulling ?? ''
838 // One to three dots in a fixed width, so the button does not change its size
839 const step = (await read($, pulse)) ?? 0
840 const dots = dotsFor(step)
841 const working = (label: string): string => workingLabel(label, step)
842 const repoRows = isFolded('repos') ? 1 : 2 + 2 * roots.length + Math.min(dirty.length, repoRoom)
843 const sessionRows = isFolded('session') ? 1 : 5 + 3 * usageState.limits.length
844 // The feed widget exists only with a command and starts collapsed
845 const isFeedOn = feedState.command.length > 0
846 const isFeedFolded = folded.feed ?? true
847 const shownTickets = feedState.list.slice(0, MAX_FEED_ROWS)
848 const feedNote = t.feedNotes[feedState.note] ?? feedState.note
849 const feedRows = !isFeedOn ? 0 : isFeedFolded ? 2 : 4 + shownTickets.length
850 const fileRoom = Math.max(1, Math.floor((rows - 4 - terminalRows - repoRows - sessionRows - feedRows - 7) / 4))
851 // The list is paged instead of scrolled, so the other widgets stay where they are
852 const pageCount = Math.max(1, Math.ceil(list.length / fileRoom))
853 const page = Math.min(Math.max(0, await read($, filePage)), pageCount - 1)
854 const shownFiles = list.slice(page * fileRoom, (page + 1) * fileRoom)
855 const numberWidth = String(list.reduce((highest, one) => Math.max(highest, one.no), 1)).length
856 const shownRepos = dirty.slice(0, repoRoom)
857
858 // A button takes no color. On light schemes it therefore sits on a dark ground.
859 // Square brackets mark what can be clicked. A single glyph (an arrow, the cross) stays bare.
860 const press = (key: string, label: string, onPress: () => unknown, isQuiet = false) => {
861 const shown = label.length > 1 && !label.startsWith('[') ? `[${label}]` : label
862
863 return (
864 <Box {...chip}>
865 {isQuiet ? (
866 <Button key={key} plain dimColor onPress={onPress}>
867 {shown}
868 </Button>
869 ) : (
870 <Button key={key} plain onPress={onPress}>
871 {shown}
872 </Button>
873 )}
874 </Box>
875 )
876 }
877
878 const splits = actions.filter(action => action.key in SPLIT_SYMBOLS)
879 const others = actions.filter(action => !(action.key in SPLIT_SYMBOLS))
880 const termButton = (action: (typeof actions)[number]) =>
881 press(
882 `term:${action.key}`,
883 armedKey === action.key && action.needsConfirm === true
884 ? t.confirmClose
885 : (SPLIT_SYMBOLS[action.key] ?? t.actions[action.key] ?? action.key),
886 async () => {
887 // A command that closes something needs two presses within a few seconds
888 if (action.needsConfirm === true && (await read($, armed)) !== action.key) {
889 await update($, armed, () => action.key)
890 $.clock.after(ARMED_MS, () => {
891 void update($, armed, now => (now === action.key ? '' : now))
892 })
893
894 return
895 }
896
897 await update($, armed, () => '')
898 const cwd = await $.session.cwd()
899 const now = terminalActions(hostInfo, cwd).find(one => one.key === action.key)
900 if (now !== undefined) await run($, now.argv, t.actions[now.key] ?? now.key)
901 },
902 )
903
904 // Green while there is room, then the warning and the error color of the scheme
905 const gauge = (percent: number) => (percent >= 95 ? look.bad : percent >= 80 ? look.warn : look.ok)
906
907 // A ticket key in the branch name becomes a link once a ticket address is set
908 const branchText = (branch: string) => {
909 const ticket = ticketPattern.length > 0 ? ticketOf(branch) : undefined
910 if (ticket === undefined) {
911 return (
912 <Text dimColor wrap="truncate-end" {...ink}>
913 {branch}
914 </Text>
915 )
916 }
917
918 const at = branch.indexOf(ticket)
919
920 return (
921 <Text dimColor wrap="truncate-end" {...ink}>
922 {branch.slice(0, at)}
923 <Link href={ticketHref(ticketPattern, ticket)}>{ticket}</Link>
924 {branch.slice(at + ticket.length)}
925 </Text>
926 )
927 }
928
929 const head = (id: string, label: string, count: string, sheet = '') => (
930 <Box flexDirection="row" gap={1}>
931 {press(`fold:${id}`, foldedNow(id) ? '▸' : '▾', async () => {
932 const wasFolded = (await read($, collapsed))[id] ?? id === 'feed'
933 await update($, collapsed, now => ({ ...now, [id]: !wasFolded }))
934 // Read right away when the repos are expanded for the first time
935 if (id === 'repos' && wasFolded && (await read($, repos)).checkedAt === 0) await refreshRepos($)
936 if (id === 'feed' && wasFolded && ((await read($, feed)) ?? NO_FEED).checkedAt === 0) await refreshFeed($)
937 })}
938 <Text bold color={look.accent}>
939 {label}
940 </Text>
941 <Text dimColor wrap="truncate-end" {...ink}>
942 {count}
943 {sheet.length > 0 ? `${count.length > 0 ? ' ' : ''}· ` : ''}
944 {sheet.length > 0 && <Link href={sheet}>{t.sheet}</Link>}
945 </Text>
946 </Box>
947 )
948
949 // A bar as /usage draws it: the used share in color, the rest dimmed, the figure behind it
950 // Separates two widgets. It takes the row the empty line had, so the pane grows no taller.
951 const rule = (
952 <Text dimColor wrap="truncate-end" {...ink}>
953 {'─'.repeat(Math.max(4, columns - 1))}
954 </Text>
955 )
956
957 const meter = (label: string, percent: number, footer: string) => {
958 // The figure has a fixed width so that all bars end in the same column
959 const suffix = `${String(Math.round(percent)).padStart(4)} %`
960 const width = Math.max(6, columns - 4 - suffix.length)
961 const filled = barCells(percent, width)
962
963 return (
964 <Box flexDirection="column">
965 <Text bold wrap="truncate-end" {...ink}>
966 {label}
967 </Text>
968 <Text wrap="truncate-end" {...ink}>
969 <Text color={gauge(percent)}>{'█'.repeat(filled)}</Text>
970 <Text dimColor>{'█'.repeat(width - filled)}</Text>
971 {suffix}
972 </Text>
973 {footer.length > 0 && (
974 <Text dimColor wrap="truncate-end" {...ink}>
975 {footer}
976 </Text>
977 )}
978 </Box>
979 )
980 }
981
982 return (
983 <Box flexDirection="column" flexGrow={1} {...ground} {...tall}>
984 <Text wrap="truncate-end">
985 <Text bold color={look.title}>
986 {heading}
987 </Text>
988 <Text dimColor {...ink}>
989 {` v${VERSION} · `}
990 <Link href={AUTHOR_URL}>{AUTHOR}</Link>
991 {TAGLINE}
992 </Text>
993 </Text>
994 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
995 <Text bold color={look.accent}>
996 {t.colors}
997 </Text>
998 {press('theme:prev', '◂', () => stepTheme($, -1))}
999 <Text {...ink}>{themeName}</Text>
1000 {press('theme:next', '▸', () => stepTheme($, 1))}
1001 <Text dimColor {...ink}>
1002 ·
1003 </Text>
1004 {press('lang:next', language.toUpperCase(), () => stepLanguage($))}
1005 <Text dimColor {...ink}>
1006 ·
1007 </Text>
1008 <Text {...ink}>
1009 <Link href={REPO_URL}>GitHub</Link>
1010 </Text>
1011 </Box>
1012 {!isConfirmed && (
1013 <Box flexDirection="column">
1014 <Text> </Text>
1015 <Box flexDirection="column" borderStyle="round" borderColor={look.warn} paddingX={1}>
1016 <Text bold color={look.warn}>
1017 {t.noticeTitle}
1018 </Text>
1019 <Text wrap="wrap" {...ink}>
1020 {t.notice}
1021 </Text>
1022 <Box flexDirection="row">
1023 {press('notice:accept', t.accept, async () => {
1024 await update($, accepted, () => true)
1025 await $.store.set('accepted', 'yes')
1026 })}
1027 </Box>
1028 </Box>
1029 </Box>
1030 )}
1031 {rule}
1032
1033 {head('files', t.files, list.length > 0 ? `${t.fileCount(list.length)} · ${formatSize(total, language)}` : '')}
1034 {!isFolded('files') && list.length === 0 && (
1035 <Box marginLeft={2}>
1036 <Text dimColor wrap="wrap" {...ink}>
1037 {t.nothingWritten}
1038 </Text>
1039 </Box>
1040 )}
1041 {!isFolded('files') &&
1042 shownFiles.map(file => {
1043 const { dir, name } = splitPath(file.path)
1044 // The number is right-aligned, the lines below are indented by its width
1045 const number = `${String(file.no).padStart(numberWidth)} `
1046 const indent = ' '.repeat(number.length + 2)
1047
1048 return (
1049 <Box flexDirection="column">
1050 <Text wrap="truncate-middle" {...ink}>
1051 <Text dimColor>{number}</Text>
1052 <Text color={file.isNew ? look.ok : look.warn}>{file.isNew ? '+ ' : '~ '}</Text>
1053 <Link href={fileHref(file.path)}>
1054 <Text bold>{name}</Text>
1055 </Link>
1056 </Text>
1057 <Text dimColor wrap="truncate-start" {...ink}>
1058 {indent}
1059 <Link href={fileHref(dir)}>{shortenDir(dir, columns - indent.length)}</Link>
1060 </Text>
1061 <Text dimColor wrap="truncate-end" {...ink}>
1062 {indent}
1063 {formatSize(file.size, language)} · {formatStamp(file.mtimeMs, language)}
1064 {file.writes > 1 ? ` · ${file.writes}x` : ''}
1065 </Text>
1066 <Box flexDirection="row" flexWrap="wrap" columnGap={1} marginLeft={indent.length}>
1067 {hostInfo.terminal !== 'none' &&
1068 editor.length > 0 &&
1069 press(
1070 `edit:${file.path}`,
1071 editorName,
1072 async () => {
1073 const argv = openEditorArgv(hostInfo, editor, file.path, await $.session.cwd())
1074 if (argv !== undefined) await run($, argv, editorName)
1075 },
1076 true,
1077 )}
1078 {press(`open:${file.path}`, t.open, () => run($, openDefaultArgv(hostInfo, file.path), t.open), true)}
1079 {press(
1080 `copy-path:${file.path}`,
1081 t.copyPath,
1082 async () => {
1083 if (await run($, copyTextArgv(hostInfo, file.path), t.copyPath)) $.ui.toast(t.copied(t.copyPath))
1084 },
1085 true,
1086 )}
1087 {press(
1088 `copy-content:${file.path}`,
1089 t.copyContent,
1090 async () => {
1091 if (await run($, copyFileArgv(hostInfo, file.path), t.copyContent)) $.ui.toast(t.copied(name))
1092 },
1093 true,
1094 )}
1095 </Box>
1096 </Box>
1097 )
1098 })}
1099 {!isFolded('files') && pageCount > 1 && (
1100 <Box flexDirection="row" columnGap={1} marginLeft={2}>
1101 {press('files:prev', '◂', () => update($, filePage, () => (page + pageCount - 1) % pageCount))}
1102 <Text dimColor {...ink}>
1103 {t.range(page * fileRoom + 1, page * fileRoom + shownFiles.length, list.length)}
1104 </Text>
1105 {press('files:next', '▸', () => update($, filePage, () => (page + 1) % pageCount))}
1106 </Box>
1107 )}
1108 {!isFolded('files') && list.length > 0 && (
1109 <Box flexDirection="row" marginLeft={2}>
1110 {press(
1111 'leeren',
1112 t.clearList,
1113 async () => {
1114 await update($, files, () => [])
1115 await update($, filePage, () => 0)
1116 },
1117 true,
1118 )}
1119 </Box>
1120 )}
1121
1122 {rule}
1123 {head(
1124 'terminal',
1125 t.terminal,
1126 hostInfo.terminal === 'wt' ? 'Windows Terminal' : hostInfo.terminal === 'tmux' ? 'tmux' : '',
1127 hostInfo.terminal === 'wt' ? CHEAT_SHEET : '',
1128 )}
1129 {!isFolded('terminal') && actions.length === 0 && (
1130 <Box marginLeft={2}>
1131 <Text dimColor wrap="wrap" {...ink}>
1132 {t.noTerminal}
1133 </Text>
1134 </Box>
1135 )}
1136 {!isFolded('terminal') && actions.length > 0 && (
1137 <Box flexDirection="column" alignItems="flex-start" marginLeft={2}>
1138 <Box flexDirection="row" gap={1}>
1139 <Text {...ink}>Split</Text>
1140 {splits.map(termButton)}
1141 </Box>
1142 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
1143 {others.map(termButton)}
1144 </Box>
1145 </Box>
1146 )}
1147
1148 {rule}
1149 {head('repos', t.repos, repoState.list.length > 0 ? t.reposCount(dirty.length, repoState.list.length) : '')}
1150 {!isFolded('repos') && roots.length === 0 && (
1151 <Box flexDirection="column" alignItems="flex-start" marginLeft={2}>
1152 <Text dimColor wrap="wrap" {...ink}>
1153 {t.noFolder}
1154 </Text>
1155 {press('repos:pick', t.pickFolder, () => pickReposRoot($))}
1156 </Box>
1157 )}
1158 {!isFolded('repos') && note.length > 0 && (
1159 <Text color={look.warn} wrap="wrap">
1160 {' '}
1161 {note}
1162 </Text>
1163 )}
1164 {!isFolded('repos') &&
1165 roots.map(root => {
1166 const here = shownRepos.filter(repo => (repo.root ?? root) === root)
1167 if (here.length === 0) return null
1168
1169 return (
1170 <Box flexDirection="column">
1171 {roots.length > 1 && (
1172 <Text dimColor wrap="truncate-start" {...ink}>
1173 {' '}
1174 {shortenDir(root, columns - 2)}
1175 </Text>
1176 )}
1177 {here.map(repo => (
1178 <Box flexDirection="row" gap={1}>
1179 <Box flexShrink={0}>
1180 <Text {...ink}>
1181 <Text color={!repo.isRead || repo.behind > 0 ? look.bad : look.warn}>{' ● '}</Text>
1182 <Text bold>{repo.name}</Text>
1183 <Text color={look.warn}>{` ${repo.isRead ? repoCounts(repo) : t.unreadable}`}</Text>
1184 </Text>
1185 </Box>
1186 {/* The button stands before the branch: a long branch name is cut, the button never is */}
1187 {repo.isRead && (repo.dir ?? '').length > 0 && (
1188 <Box flexShrink={0}>
1189 {press(`pull:${repo.dir}`, pulling === repo.dir ? dots : t.pull, () => pullRepo($, repo))}
1190 </Box>
1191 )}
1192 {hostInfo.terminal !== 'none' && (repo.dir ?? '').length > 0 && (
1193 <Box flexShrink={0}>
1194 {press(`shell:${repo.dir}`, '>_', async () => {
1195 const split = terminalActions(hostInfo, repo.dir).find(one => one.key === 'split-right')
1196 if (split !== undefined) await run($, split.argv, repo.name)
1197 })}
1198 </Box>
1199 )}
1200 <Box flexShrink={1}>{branchText(repo.branch)}</Box>hooks/effects.ts 96 lines1// Text effects for the title line - without $ and therefore directly testable.
2// Three effects from retro-text-effects.js are rebuilt here as small functions: decrypt,
3// wipe and errorcorrect. The library itself needs the DOM and requestAnimationFrame,
4// neither exists in a mod. Each function returns ONE frame, the host sets the pace.
5
6const GLYPHS = Array.from('#%&$@*+=<>/\\|[]{}0123456789')
7// U+2588, the full block as the cursor
8const CURSOR = '█'
9
10export const EFFECTS = ['decrypt', 'wipe', 'errorcorrect'] as const
11export type EffectName = (typeof EFFECTS)[number]
12
13/** Random numbers in [0, 1) from a seed (mulberry32): same seed, same sequence. */
14export const seeded = (seed: number): (() => number) => {
15 let state = seed >>> 0
16
17 return () => {
18 state = (state + 0x6d2b79f5) >>> 0
19 let value = state
20 value = Math.imul(value ^ (value >>> 15), value | 1)
21 value ^= value + Math.imul(value ^ (value >>> 7), value | 61)
22
23 return ((value ^ (value >>> 14)) >>> 0) / 4294967296
24 }
25}
26
27/**
28 * decrypt: the first characters are already in place, the rest flickers through random characters.
29 *
30 * @param text The finished text.
31 * @param frame Number of the frame, from 0.
32 * @param frames Number of frames, at `frame >= frames` the whole text is shown.
33 * @param random Random number in [0, 1), fixed in tests.
34 */
35export const decryptFrame = (text: string, frame: number, frames: number, random: () => number): string => {
36 const cells = Array.from(text)
37 if (frame >= frames || frames <= 0) return text
38
39 const settled = Math.floor((cells.length * Math.max(0, frame)) / frames)
40
41 return cells
42 .map((ch, i) => {
43 // Spaces stay in place so that the line does not jump
44 if (i < settled || ch === ' ') return ch
45
46 return GLYPHS[Math.floor(random() * GLYPHS.length) % GLYPHS.length]
47 })
48 .join('')
49}
50
51/** wipe: the text is written from the left, with a cursor running ahead of it. */
52export const wipeFrame = (text: string, frame: number, frames: number): string => {
53 const cells = Array.from(text)
54 if (frame >= frames || frames <= 0) return text
55
56 const written = Math.floor((cells.length * Math.max(0, frame)) / frames)
57
58 return cells.map((ch, i) => (i < written ? ch : i === written ? CURSOR : ' ')).join('')
59}
60
61/** errorcorrect: letters are swapped and move pair by pair to their place. */
62export const errorcorrectFrame = (text: string, frame: number, frames: number, seed: number): string => {
63 const cells = Array.from(text)
64 if (frame >= frames || frames <= 0) return text
65
66 // The pairs depend only on the seed, so that every frame of the same run knows the same pairs
67 const random = seeded(seed)
68 const places = cells.map((ch, i) => (ch === ' ' ? -1 : i)).filter(i => i >= 0)
69 for (let i = places.length - 1; i > 0; i -= 1) {
70 const j = Math.floor(random() * (i + 1))
71 const held = places[i] as number
72 places[i] = places[j] as number
73 places[j] = held
74 }
75
76 const pairs = Math.floor(places.length / 2)
77 const corrected = Math.floor((pairs * Math.max(0, frame)) / frames)
78 for (let pair = corrected; pair < pairs; pair += 1) {
79 const a = places[pair * 2] as number
80 const b = places[pair * 2 + 1] as number
81 const held = cells[a] as string
82 cells[a] = cells[b] as string
83 cells[b] = held
84 }
85
86 return cells.join('')
87}
88
89/** One frame of the named effect. The same seed gives the same result for the same frame. */
90export const effectFrame = (name: EffectName, text: string, frame: number, frames: number, seed: number): string => {
91 if (name === 'wipe') return wipeFrame(text, frame, frames)
92 if (name === 'errorcorrect') return errorcorrectFrame(text, frame, frames, seed)
93
94 return decryptFrame(text, frame, frames, seeded(seed * 31 + frame))
95}
96hooks/demo.ts 83 lines1// Invented data for screenshots - without $ and therefore directly testable.
2// Nothing here comes from a real machine: the paths, repos and tickets are made up.
3
4import type { FeedState, FileEntry, ReposState, UsageState } from '../types'
5
6const HOME = '/home/sam/projects'
7const MINUTE = 60000
8const HOUR = 60 * MINUTE
9
10export type DemoState = { files: FileEntry[]; repos: ReposState; feed: FeedState; usage: UsageState }
11
12/** Everything the pane shows, filled with made-up content around the moment `now`. */
13export const demoState = (now: number): DemoState => {
14 const file = (no: number, path: string, size: number, minutesAgo: number, isNew: boolean, writes = 1): FileEntry => ({
15 path: `${HOME}/${path}`,
16 size,
17 mtimeMs: now - minutesAgo * MINUTE,
18 isNew,
19 writes,
20 no,
21 })
22 const repo = (name: string, branch: string, changed: number, untracked: number, ahead: number, behind: number) => ({
23 root: HOME,
24 dir: `${HOME}/${name}`,
25 name,
26 branch,
27 ahead,
28 behind,
29 changed,
30 untracked,
31 isRead: true,
32 })
33 const ticket = (key: string, summary: string) => ({ key, summary, url: `https://tickets.example.com/browse/${key}` })
34
35 return {
36 files: [
37 file(4, 'shop/docs/release-notes.md', 2150, 1, true),
38 file(3, 'shop/src/cart/checkout.ts', 12595, 3, false, 3),
39 file(2, 'shop/src/cart/checkout.test.ts', 5420, 4, true),
40 file(1, 'shop/package.json', 1002, 9, false),
41 ],
42 repos: {
43 roots: [HOME],
44 list: [
45 repo('shop', 'feature/ABC-123-checkout', 3, 1, 1, 0),
46 repo('website', 'main', 0, 0, 0, 2),
47 repo('design-system', 'main', 0, 2, 0, 0),
48 repo('api', 'main', 0, 0, 0, 0),
49 repo('docs', 'main', 0, 0, 0, 0),
50 repo('infra', 'main', 0, 0, 0, 0),
51 ],
52 checkedAt: now,
53 isBusy: false,
54 isFetching: false,
55 pulling: '',
56 note: '',
57 },
58 feed: {
59 command: 'demo',
60 list: [
61 ticket('ABC-131', 'Checkout: total is wrong with two vouchers'),
62 ticket('ABC-130', 'Add dark mode to the order history'),
63 ticket('ABC-129', 'Search returns nothing for umlauts'),
64 ticket('ABC-128', 'Update the payment provider SDK'),
65 ],
66 since: new Date(now - 24 * HOUR).toISOString().slice(0, 10),
67 checkedAt: now,
68 isBusy: false,
69 note: '',
70 },
71 usage: {
72 measuredAt: now,
73 tokens: 42000,
74 window: 200000,
75 limits: [
76 { kind: 'five_hour', percentUsed: 48, resetsAt: new Date(now + 2 * HOUR + 20 * MINUTE).toISOString() },
77 { kind: 'seven_day', percentUsed: 10, resetsAt: new Date(now + 6 * 24 * HOUR).toISOString() },
78 ],
79 usd: 1.37,
80 },
81 }
82}
83hooks/feed.ts 30 lines1// Reads what a ticket feed prints - without $ and therefore directly testable.
2// A feed is any command that prints one JSON object: { tickets: [{ key, summary, url }], since?, message? }.
3
4import type { FeedTicket } from '../types'
5
6export type FeedResult = { list: FeedTicket[]; since: string; note: string }
7
8const text = (value: unknown): string => (typeof value === 'string' ? value : '')
9
10/** Tickets, first day and a note from the output of a feed. Never throws: broken output becomes a note. */
11export const parseFeed = (stdout: string): FeedResult => {
12 let data: unknown
13 try {
14 data = JSON.parse(stdout)
15 } catch {
16 return { list: [], since: '', note: 'no-json' }
17 }
18
19 if (typeof data !== 'object' || data === null) return { list: [], since: '', note: 'no-json' }
20
21 const feed = data as { tickets?: unknown; since?: unknown; message?: unknown; error?: unknown }
22 const rows = Array.isArray(feed.tickets) ? feed.tickets : []
23 const list = rows
24 .filter((row): row is Record<string, unknown> => typeof row === 'object' && row !== null)
25 .map(row => ({ key: text(row.key), summary: text(row.summary), url: text(row.url) }))
26 .filter(row => row.key.length > 0)
27
28 return { list, since: text(feed.since), note: text(feed.message) || text(feed.error) }
29}
30hooks/format.ts 114 lines1// Pure helper functions for the display - without $ and therefore directly testable.
2
3const pad = (n: number): string => (n < 10 ? `0${n}` : `${n}`)
4
5/** Bytes as a size: 512 B, 12,3 KB, 4,0 MB. English with a dot instead of a comma. */
6export const formatSize = (bytes: number, lang = 'de'): string => {
7 if (bytes < 1024) return `${bytes} B`
8 const units = ['KB', 'MB', 'GB']
9 let value = bytes / 1024
10 let unit = 0
11 while (value >= 1024 && unit < units.length - 1) {
12 value /= 1024
13 unit += 1
14 }
15
16 return `${value.toFixed(1).replace('.', lang === 'en' ? '.' : ',')} ${units[unit]}`
17}
18
19/** Timestamp as dd.MM. HH:mm:ss, in English as MM-dd HH:mm:ss. */
20export const formatStamp = (mtimeMs: number, lang = 'de'): string => {
21 const d = new Date(mtimeMs)
22 const day =
23 lang === 'en' ? `${pad(d.getMonth() + 1)}-${pad(d.getDate())}` : `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.`
24
25 return `${day} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
26}
27
28/** Time of day as HH:mm. */
29export const formatClock = (ms: number): string => {
30 const d = new Date(ms)
31
32 return `${pad(d.getHours())}:${pad(d.getMinutes())}`
33}
34
35/** Splits a path into folder and file name, whether \ or /. */
36export const splitPath = (path: string): { dir: string; name: string } => {
37 const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
38
39 return cut < 0 ? { dir: '', name: path } : { dir: path.slice(0, cut), name: path.slice(cut + 1) }
40}
41
42/** file: URL for an absolute path, also for Windows drives. */
43export const fileHref = (path: string): string => {
44 const forward = path.replace(/\\/g, '/')
45 const encoded = forward
46 .split('/')
47 .map((part, i) => (i === 0 && /^[A-Za-z]:$/.test(part) ? part : encodeURIComponent(part)))
48 .join('/')
49
50 return encoded.startsWith('/') ? `file://${encoded}` : `file:///${encoded}`
51}
52
53/** Shortens the folder from the front so that the end (the telling part) stays. */
54export const shortenDir = (dir: string, room: number): string =>
55 dir.length <= room ? dir : `...${dir.slice(dir.length - Math.max(1, room - 3))}`
56
57/** Tokens in short form: 950, 84k, 1.2M. */
58export const formatTokens = (tokens: number): string =>
59 tokens >= 1000000
60 ? `${(tokens / 1000000).toFixed(1)}M`
61 : tokens >= 1000
62 ? `${Math.round(tokens / 1000)}k`
63 : String(Math.round(tokens))
64
65/** Time of a reset: HH:mm on the same day as `nowMs`, otherwise with the date in front. */
66export const formatReset = (iso: string, nowMs: number, lang = 'de'): string => {
67 const ms = Date.parse(iso)
68 if (Number.isNaN(ms)) return ''
69
70 const d = new Date(ms)
71 const now = new Date(nowMs)
72 const isSameDay =
73 d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()
74 if (isSameDay) return formatClock(ms)
75
76 const date =
77 lang === 'en' ? `${pad(d.getMonth() + 1)}-${pad(d.getDate())}` : `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.`
78
79 return `${date} ${formatClock(ms)}`
80}
81
82/** US dollars: 3,21 $ in German, $3.21 in English. */
83export const formatUsd = (usd: number, lang = 'de'): string =>
84 lang === 'en' ? `$${usd.toFixed(2)}` : `${usd.toFixed(2).replace('.', ',')} $`
85
86/** The first ticket key in a branch name, for example ABC-123 in feature/ABC-123-fix. */
87export const ticketOf = (branch: string): string | undefined => /[A-Z][A-Z0-9]+-\d+/.exec(branch)?.[0]
88
89/** Address of a ticket: the key replaces {id}, without a placeholder it is appended. */
90export const ticketHref = (pattern: string, id: string): string =>
91 pattern.includes('{id}') ? pattern.replace('{id}', id) : `${pattern.replace(/\/+$/, '')}/${id}`
92
93/** How many of `width` cells of a bar are filled at `percent`. Anything above zero shows at least one. */
94export const barCells = (percent: number, width: number): number => {
95 const share = Math.min(100, Math.max(0, percent)) / 100
96
97 return share > 0 ? Math.max(1, Math.round(share * width)) : 0
98}
99
100/** An ISO date as dd.MM. or, in English, MM-dd. Empty for anything else. */
101export const formatDay = (iso: string, lang = 'de'): string => {
102 const match = /^(\d{4})-(\d\d)-(\d\d)/.exec(iso)
103 if (match === null) return ''
104
105 return lang === 'en' ? `${match[2]}-${match[3]}` : `${match[3]}.${match[2]}.`
106}
107
108/** One to three dots in a fixed width of three cells, so a label does not change its size. */
109export const dotsFor = (step: number): string => '.'.repeat((Math.max(0, Math.trunc(step)) % 3) + 1).padEnd(3)
110
111/** A busy label with moving dots in place of its own three: "reading ..." becomes "reading . ". */
112export const workingLabel = (label: string, step: number): string =>
113 `${label.replace(/ ?\.{3}$/, '')} ${dotsFor(step)}`.trimStart()
114hooks/git.ts 56 lines1// Reads the output of `git status --porcelain=v2 --branch` - without $ and therefore directly testable.
2
3import type { RepoEntry } from '../types'
4
5export const STATUS_ARGV = ['git', 'status', '--porcelain=v2', '--branch'] as const
6export const FETCH_ARGV = ['git', 'fetch', '--quiet'] as const
7// Fast-forward only: a pull never creates a merge commit and never rewrites local work
8export const PULL_ARGV = ['git', 'pull', '--ff-only', '--quiet'] as const
9
10/** State of a repo from the status output: branch, ahead, behind, number of changes. */
11export const parseStatus = (name: string, stdout: string, root = '', dir = ''): RepoEntry => {
12 const entry: RepoEntry = { root, dir, name, branch: '?', ahead: 0, behind: 0, changed: 0, untracked: 0, isRead: true }
13
14 for (const line of stdout.split('\n')) {
15 if (line.startsWith('# branch.head ')) {
16 entry.branch = line.slice('# branch.head '.length).trim()
17 continue
18 }
19
20 if (line.startsWith('# branch.ab ')) {
21 const match = /\+(\d+) -(\d+)/.exec(line)
22 if (match !== null) {
23 entry.ahead = Number(match[1])
24 entry.behind = Number(match[2])
25 }
26 continue
27 }
28
29 // 1 = changed, 2 = renamed, u = conflict
30 if (/^[12u] /.test(line)) entry.changed += 1
31 // ? = a file git does not know yet
32 if (line.startsWith('? ')) entry.untracked += 1
33 }
34
35 return entry
36}
37
38/** True if there is nothing to do in the repo: nothing changed, nothing ahead, nothing behind. */
39export const isClean = (repo: RepoEntry): boolean =>
40 repo.isRead && repo.changed === 0 && (repo.untracked ?? 0) === 0 && repo.ahead === 0 && repo.behind === 0
41
42/** Repos that need attention first, within the groups by name. */
43export const sortRepos = (list: RepoEntry[]): RepoEntry[] =>
44 [...list].sort((a, b) => Number(isClean(a)) - Number(isClean(b)) || a.name.localeCompare(b.name))
45
46/** Short form of the counters for one line: "~3 ?1 ↑1 ↓2", empty for a clean repo. */
47export const repoCounts = (repo: RepoEntry): string =>
48 [
49 repo.changed > 0 ? `~${repo.changed}` : '',
50 (repo.untracked ?? 0) > 0 ? `?${repo.untracked}` : '',
51 repo.ahead > 0 ? `↑${repo.ahead}` : '',
52 repo.behind > 0 ? `↓${repo.behind}` : '',
53 ]
54 .filter(part => part.length > 0)
55 .join(' ')
56hooks/platform.ts 156 lines1// Which commands the given operating system and terminal need - without $ and therefore directly testable.
2
3import type { HostInfo } from '../types'
4
5export type TerminalAction = {
6 /** Key of the command, also the key of its label in texts.ts. */
7 key: string
8 argv: string[]
9 /** True for commands that close something: the button then asks once. */
10 needsConfirm?: true
11}
12
13export const CHEAT_SHEET = 'https://www.michaelblaess.de/helfer/windows-terminal.html'
14
15/** Detects operating system and terminal from environment variables and the output of `uname -s`. */
16export const detectHost = (env: { os?: string; wtSession?: string; tmux?: string }, uname?: string): HostInfo => {
17 const isWindows = (env.os ?? '').toLowerCase() === 'windows_nt'
18 const os = isWindows ? 'windows' : (uname ?? '').trim().toLowerCase() === 'darwin' ? 'mac' : 'linux'
19 const isSet = (value?: string): boolean => value !== undefined && value.trim().length > 0
20 // tmux first: if it runs inside Windows Terminal (WSL), tmux splits the window
21 const terminal = isSet(env.tmux) ? 'tmux' : isSet(env.wtSession) ? 'wt' : 'none'
22
23 return { os, terminal }
24}
25
26/** Command that opens a file or folder with the default application. */
27export const openDefaultArgv = (host: HostInfo, path: string): string[] => {
28 // explorer.exe only understands backslashes, in return it needs no shell and no quoting
29 if (host.os === 'windows') return ['explorer.exe', path.replace(/\//g, '\\')]
30
31 return host.os === 'mac' ? ['open', path] : ['xdg-open', path]
32}
33
34/** Command that opens the file in the editor in a new split, or undefined without a splittable terminal. */
35export const openEditorArgv = (host: HostInfo, editor: string, path: string, cwd: string): string[] | undefined => {
36 if (host.terminal === 'wt') return ['wt.exe', '-w', '0', 'split-pane', '-V', '-d', cwd, editor, path]
37 if (host.terminal === 'tmux') return ['tmux', 'split-window', '-h', '-c', cwd, editor, path]
38
39 return undefined
40}
41
42// Windows Terminal has no command for closing. So: focus the last active pane,
43// then send the key combination for "close pane" to the foreground window.
44const WT_CLOSE_LAST =
45 "wt.exe -w 0 move-focus previous; Start-Sleep -Milliseconds 400; (New-Object -ComObject WScript.Shell).SendKeys('^+w')"
46
47/** The window commands of the terminal, empty without a splittable terminal. */
48export const terminalActions = (host: HostInfo, cwd: string): TerminalAction[] => {
49 if (host.terminal === 'wt') {
50 const split = ['wt.exe', '-w', '0', 'split-pane']
51
52 return [
53 // A split to the left is missing on the command line: split right and swap the two panes
54 { key: 'split-left', argv: [...split, '-V', '-d', cwd, ';', 'swap-pane', 'left'] },
55 { key: 'split-right', argv: [...split, '-V', '-d', cwd] },
56 { key: 'split-down', argv: [...split, '-H', '-d', cwd] },
57 { key: 'new-tab', argv: ['wt.exe', '-w', '0', 'new-tab', '-d', cwd] },
58 { key: 'close-last', argv: ['powershell.exe', '-NoProfile', '-Command', WT_CLOSE_LAST], needsConfirm: true },
59 ]
60 }
61
62 if (host.terminal === 'tmux') {
63 return [
64 { key: 'split-left', argv: ['tmux', 'split-window', '-h', '-b', '-c', cwd] },
65 { key: 'split-right', argv: ['tmux', 'split-window', '-h', '-c', cwd] },
66 { key: 'split-down', argv: ['tmux', 'split-window', '-v', '-c', cwd] },
67 { key: 'new-tab', argv: ['tmux', 'new-window', '-c', cwd] },
68 // The active pane is Claude's own, so the last used other one
69 { key: 'close-last', argv: ['tmux', 'kill-pane', '-t', '{last}'], needsConfirm: true },
70 ]
71 }
72
73 return []
74}
75
76const windowsPicker = (title: string): string =>
77 [
78 '[Console]::OutputEncoding = [Text.Encoding]::UTF8',
79 'Add-Type -AssemblyName System.Windows.Forms',
80 '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
81 `$dialog.Description = '${title}'`,
82 // Without a window in the foreground the dialog opens behind the terminal
83 '$owner = New-Object System.Windows.Forms.Form -Property @{ TopMost = $true }',
84 "if ($dialog.ShowDialog($owner) -eq 'OK') { [Console]::Out.Write($dialog.SelectedPath) }",
85 ].join('; ')
86
87/**
88 * Command that shows a folder dialog and prints the chosen path.
89 * The title ends up in a command text, so quotes in it are removed.
90 */
91export const pickFolderArgv = (host: HostInfo, title: string): string[] => {
92 const safe = title.replace(/['"`$]/g, '')
93 if (host.os === 'windows') return ['powershell.exe', '-NoProfile', '-STA', '-Command', windowsPicker(safe)]
94 if (host.os === 'mac') return ['osascript', '-e', `POSIX path of (choose folder with prompt "${safe}")`]
95
96 return ['zenity', '--file-selection', '--directory', `--title=${safe}`]
97}
98
99/**
100 * Command that prints the full path of a program. A new pane inherits the environment of the
101 * terminal and not that of Claude Code, so the bare name is not always enough there.
102 */
103export const whereArgv = (host: HostInfo, program: string): string[] =>
104 host.os === 'windows' ? ['where.exe', program] : ['which', program]
105
106/** Splits a list of folders written with ; or line breaks, without blanks and without duplicates. */
107export const splitRoots = (text: string): string[] => [
108 ...new Set(
109 text
110 .split(/[;\n]/)
111 .map(one => one.trim())
112 .filter(one => one.length > 0),
113 ),
114]
115
116/** True if the name is already a path and does not have to be looked up first. */
117export const isPath = (program: string): boolean => /[\\/]/.test(program)
118
119/** Appends a name to a folder, with the separator the folder itself uses. */
120export const joinPath = (dir: string, name: string): string => {
121 const separator = dir.includes('\\') ? '\\' : '/'
122
123 return dir.endsWith('/') || dir.endsWith('\\') ? `${dir}${name}` : `${dir}${separator}${name}`
124}
125
126export const CLAUDE_SHEET = 'https://www.michaelblaess.de/helfer/claude-code.html'
127
128// The text travels as an argument and never as part of the script, so nothing in it is run
129const POSIX_COPY = 'if command -v wl-copy >/dev/null 2>&1; then wl-copy; else xclip -selection clipboard; fi'
130
131const quoted = (text: string): string => `'${text.replace(/'/g, "''")}'`
132
133const powershell = (script: string): string[] => ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', script]
134
135/** Command that puts a text on the clipboard. */
136export const copyTextArgv = (host: HostInfo, text: string): string[] => {
137 if (host.os === 'windows') return powershell(`Set-Clipboard -Value ${quoted(text)}`)
138 if (host.os === 'mac') return ['sh', '-c', 'printf %s "$1" | pbcopy', 'sh', text]
139
140 return ['sh', '-c', `printf %s "$1" | { ${POSIX_COPY}; }`, 'sh', text]
141}
142
143/** Command that puts the content of a text file on the clipboard. */
144export const copyFileArgv = (host: HostInfo, path: string): string[] => {
145 if (host.os === 'windows') {
146 return powershell(`Get-Content -Raw -Encoding UTF8 -LiteralPath ${quoted(path)} | Set-Clipboard`)
147 }
148 if (host.os === 'mac') return ['sh', '-c', 'pbcopy < "$1"', 'sh', path]
149
150 return ['sh', '-c', `{ ${POSIX_COPY}; } < "$1"`, 'sh', path]
151}
152
153/** Splits a command line into program and arguments. Double quotes keep blanks together. */
154export const splitCommand = (line: string): string[] =>
155 (line.match(/"[^"]*"|\S+/g) ?? []).map(part => part.replace(/^"(.*)"$/, '$1'))
156hooks/texts.ts 301 lines1// All texts shown in the pane and in the answers of /turbo, per language - without $ and therefore directly testable.
2
3export const LANGUAGES = ['de', 'en'] as const
4export type Lang = (typeof LANGUAGES)[number]
5
6export type Texts = {
7 files: string
8 fileCount: (count: number) => string
9 nothingWritten: string
10 more: (count: number) => string
11 range: (from: number, to: number, total: number) => string
12 clearList: string
13 open: string
14 terminal: string
15 noTerminal: string
16 sheet: string
17 confirmClose: string
18 confirmRun: string
19 compacted: string
20 hostFailed: (what: string) => string
21 /** Label per terminal command, keys as in platform.ts. */
22 actions: Record<string, string>
23 repos: string
24 reposCount: (dirty: number, total: number) => string
25 noFolder: string
26 pickFolder: string
27 addFolder: string
28 fetchAll: string
29 fetching: string
30 pull: string
31 pulled: (name: string) => string
32 rootRemoved: (root: string) => string
33 rootUnknown: (root: string) => string
34 session: string
35 context: string
36 of: string
37 resets: string
38 cost: string
39 /** Label per rate-limit window, keys as the host reports them. */
40 limits: Record<string, string>
41 noReading: string
42 copyPath: string
43 copyContent: string
44 copied: (what: string) => string
45 ticketSet: (url: string) => string
46 ticketNone: string
47 feed: string
48 feedCount: (count: number, since: string) => string
49 feedEmpty: string
50 feedSet: (command: string) => string
51 feedOff: string
52 demoOn: string
53 demoOff: string
54 feedNone: string
55 /** Notes of the feed, keys as in feed.ts and register.tsx. */
56 feedNotes: Record<string, string>
57 noticeTitle: string
58 notice: string
59 accept: string
60 acceptFirst: string
61 reread: string
62 reading: string
63 allClean: string
64 unreadable: string
65 /** Notes of the repo overview, keys as in ReposState.note. */
66 notes: Record<string, string>
67 /** Title of the folder dialog. No apostrophe, it sits inside a PowerShell string. */
68 pickTitle: string
69 colors: string
70 commandDescription: string
71 help: string
72 hidden: string
73 shown: (version: string) => string
74 cleared: string
75 effectOn: string
76 effectOff: string
77 themeList: (names: string) => string
78 themeUnknown: (name: string) => string
79 themeSet: (name: string) => string
80 rootMissing: string
81 reposResult: (total: number, root: string, dirty: number) => string
82 languageSet: string
83 languageUnknown: (name: string) => string
84 runFailed: (what: string, program: string) => string
85 pickFailed: (program: string) => string
86}
87
88const DE: Texts = {
89 files: 'Dateien',
90 fileCount: count => (count === 1 ? '1 Datei' : `${count} Dateien`),
91 nothingWritten: 'Noch nichts geschrieben. Hier erscheint jede Datei, die Claude anlegt oder ändert.',
92 more: count => `... und ${count} weitere`,
93 range: (from, to, total) => `${from}-${to} von ${total}`,
94 clearList: 'Liste leeren',
95 open: 'öffnen',
96 terminal: 'Terminal',
97 noTerminal: 'Kein teilbares Terminal erkannt. Unterstützt sind Windows Terminal und tmux.',
98 sheet: 'Spickzettel',
99 confirmClose: 'Wirklich schließen?',
100 confirmRun: 'Wirklich?',
101 compacted: 'Gespräch zusammengefasst.',
102 hostFailed: what => `${what} ließ sich von hier aus nicht starten. Tippe den Befehl in die Eingabe.`,
103 actions: {
104 'split-left': 'Split links',
105 'split-right': 'Split rechts',
106 'split-down': 'Split unten',
107 'new-tab': 'Neuer Tab',
108 'close-last': 'Letztes Pane schließen',
109 },
110 repos: 'Repos',
111 reposCount: (dirty, total) => `${dirty} von ${total} mit Änderungen`,
112 noFolder: 'Noch kein Ordner gesetzt.',
113 pickFolder: 'Ordner wählen',
114 addFolder: 'Ordner hinzufügen',
115 fetchAll: 'alle fetchen',
116 fetching: 'fetcht ...',
117 pull: 'pull',
118 pulled: name => `${name}: auf dem Stand des Servers.`,
119 rootRemoved: root => `Repo-Ordner entfernt: ${root}`,
120 rootUnknown: root => `Kein Repo-Ordner "${root}" gesetzt.`,
121 session: 'Claude',
122 context: 'Kontext',
123 of: 'von',
124 resets: 'Reset',
125 cost: 'Kosten',
126 limits: { five_hour: 'Aktuelle Sitzung', seven_day: 'Aktuelle Woche', spend_limit: 'Budget' },
127 noReading: 'Noch keine Werte. Sie kommen nach der ersten Antwort.',
128 copyPath: 'Pfad',
129 copyContent: 'Inhalt',
130 copied: what => `${what} liegt in der Zwischenablage.`,
131 ticketSet: url => `Ticket-Adresse: ${url}`,
132 ticketNone: 'Keine Ticket-Adresse gesetzt. Beispiel: /turbo ticket https://jira.example.com/browse/{id}',
133 feed: 'Neue Tickets',
134 feedCount: (count, since) => (since.length > 0 ? `${count} seit ${since}` : String(count)),
135 feedEmpty: 'Keine neuen Tickets.',
136 feedSet: command => `Neue Tickets kommen von: ${command}`,
137 feedOff: 'Neue Tickets abgeschaltet.',
138 demoOn: 'Beispieldaten eingeblendet, nichts davon ist echt. /turbo demo blendet sie wieder aus.',
139 demoOff: 'Beispieldaten ausgeblendet.',
140 feedNone: 'Kein Befehl gesetzt. /turbo feed <befehl> schaltet das Widget ein, /turbo feed aus schaltet es ab.',
141 feedNotes: {
142 'no-json': 'Der Befehl hat kein JSON ausgegeben.',
143 'no-start': 'Der Befehl ließ sich nicht starten oder hat nicht geantwortet.',
144 },
145 noticeTitle: 'Disclaimer',
146 notice:
147 'Turbo-MOD startet Programme auf Deinem Rechner, führt auf Knopfdruck git fetch und git pull in Deinen Repos aus und kann einen von Dir eingestellten Befehl starten. Du nutzt den Mod auf eigene Verantwortung und ohne Gewähr, siehe Lizenz (Apache 2.0).',
148 accept: 'Verstanden',
149 acceptFirst: 'Bitte zuerst den Hinweis oben im Pane bestätigen.',
150 reread: 'neu lesen',
151 reading: 'liest ...',
152 allClean: 'Alles sauber.',
153 unreadable: 'nicht lesbar',
154 notes: {
155 unreadable: 'Der Ordner ist nicht lesbar.',
156 empty: 'Keine Git-Repos in diesem Ordner.',
157 },
158 pickTitle: 'Ordner mit den Git-Repos',
159 colors: 'Theme',
160 commandDescription: 'Turbo-MOD: Pane ein- oder ausblenden, Farbschema, Sprache und Repo-Ordner setzen',
161 help: [
162 '/turbo Pane ein- oder ausblenden',
163 '/turbo theme <name> Farbschema wählen, ohne Namen: Liste',
164 '/turbo repos <pfad> Repo-Ordner hinzufügen, ohne Pfad: neu lesen',
165 '/turbo repos entfernen <pfad> Repo-Ordner wieder entfernen',
166 '/turbo ticket <adresse> Ticket-Adresse mit {id}, macht die Ticketnummer im Branch zum Link',
167 '/turbo feed <befehl> Widget Neue Tickets: Befehl, der die Tickets als JSON ausgibt, "aus" schaltet ab',
168 '/turbo demo Beispieldaten für ein Bildschirmfoto ein- oder ausblenden',
169 '/turbo sprache <de|en> Sprache des Panes',
170 '/turbo leeren Liste der Dateien zurücksetzen',
171 '/turbo effekt Titeleffekt aus- oder einschalten',
172 ].join('\n'),
173 hidden: 'Turbo-MOD ausgeblendet. /turbo blendet ihn wieder ein.',
174 shown: version => `Turbo-MOD ${version} eingeblendet.`,
175 cleared: 'Liste der Dateien geleert.',
176 effectOn: 'Titeleffekt läuft.',
177 effectOff: 'Titeleffekt aus.',
178 themeList: names => `Farbschemata: ${names}`,
179 themeUnknown: name => `Kein Farbschema "${name}". /turbo theme zeigt die Liste.`,
180 themeSet: name => `Farbschema: ${name}`,
181 rootMissing: 'Kein Repo-Ordner gesetzt. /turbo repos <pfad> fügt einen hinzu.',
182 reposResult: (total, root, dirty) => `${total} Repos unter ${root}, ${dirty} mit Änderungen.`,
183 languageSet: 'Sprache: Deutsch',
184 languageUnknown: name => `Keine Sprache "${name}". Es gibt de und en.`,
185 runFailed: (what, program) => `${what}: ${program} ließ sich nicht starten oder hat nicht geantwortet.`,
186 pickFailed: program =>
187 `Ordnerauswahl: ${program} ließ sich nicht starten. /turbo repos <pfad> setzt den Ordner von Hand.`,
188}
189
190const EN: Texts = {
191 files: 'Files',
192 fileCount: count => (count === 1 ? '1 file' : `${count} files`),
193 nothingWritten: 'Nothing written yet. Every file Claude creates or changes shows up here.',
194 more: count => `... and ${count} more`,
195 range: (from, to, total) => `${from}-${to} of ${total}`,
196 clearList: 'clear list',
197 open: 'open',
198 terminal: 'Terminal',
199 noTerminal: 'No splittable terminal found. Windows Terminal and tmux are supported.',
200 sheet: 'cheat sheet',
201 confirmClose: 'Really close?',
202 confirmRun: 'Really?',
203 compacted: 'Conversation compacted.',
204 hostFailed: what => `${what} could not be started from here. Type the command at the prompt.`,
205 actions: {
206 'split-left': 'Split left',
207 'split-right': 'Split right',
208 'split-down': 'Split down',
209 'new-tab': 'New tab',
210 'close-last': 'Close last pane',
211 },
212 repos: 'Repos',
213 reposCount: (dirty, total) => `${dirty} of ${total} with changes`,
214 noFolder: 'No folder set yet.',
215 pickFolder: 'choose folder',
216 addFolder: 'add folder',
217 fetchAll: 'fetch all',
218 fetching: 'fetching ...',
219 pull: 'pull',
220 pulled: name => `${name}: up to date with the server.`,
221 rootRemoved: root => `Repo folder removed: ${root}`,
222 rootUnknown: root => `No repo folder "${root}" set.`,
223 session: 'Claude',
224 context: 'Context',
225 of: 'of',
226 resets: 'Resets',
227 cost: 'Cost',
228 limits: { five_hour: 'Current session', seven_day: 'Current week', spend_limit: 'Budget' },
229 noReading: 'No figures yet. They arrive after the first answer.',
230 copyPath: 'path',
231 copyContent: 'content',
232 copied: what => `${what} is on the clipboard.`,
233 ticketSet: url => `Ticket address: ${url}`,
234 ticketNone: 'No ticket address set. Example: /turbo ticket https://jira.example.com/browse/{id}',
235 feed: 'New tickets',
236 feedCount: (count, since) => (since.length > 0 ? `${count} since ${since}` : String(count)),
237 feedEmpty: 'No new tickets.',
238 feedSet: command => `New tickets come from: ${command}`,
239 feedOff: 'New tickets switched off.',
240 demoOn: 'Sample data shown, none of it is real. /turbo demo hides it again.',
241 demoOff: 'Sample data hidden.',
242 feedNone: 'No command set. /turbo feed <command> switches the widget on, /turbo feed off switches it off.',
243 feedNotes: {
244 'no-json': 'The command did not print JSON.',
245 'no-start': 'The command could not be started or did not answer.',
246 },
247 noticeTitle: 'Disclaimer',
248 notice:
249 'Turbo-MOD starts programs on your machine, runs git fetch and git pull in your repos when you press the buttons, and can start a command you have set. You use the mod at your own risk and without warranty, see the license (Apache 2.0).',
250 accept: 'Understood',
251 acceptFirst: 'Please confirm the notice at the top of the pane first.',
252 reread: 'refresh',
253 reading: 'reading ...',
254 allClean: 'All clean.',
255 unreadable: 'unreadable',
256 notes: {
257 unreadable: 'The folder cannot be read.',
258 empty: 'No git repos in this folder.',
259 },
260 pickTitle: 'Folder with the git repos',
261 colors: 'Theme',
262 commandDescription: 'Turbo-MOD: show or hide the pane, set color scheme, language and repo folder',
263 help: [
264 '/turbo show or hide the pane',
265 '/turbo theme <name> pick a color scheme, without a name: the list',
266 '/turbo repos <path> add a repo folder, without a path: refresh',
267 '/turbo repos remove <path> remove a repo folder again',
268 '/turbo ticket <address> ticket address with {id}, turns the ticket key in a branch into a link',
269 '/turbo feed <command> widget New tickets: command that prints the tickets as JSON, "off" switches it off',
270 '/turbo demo show or hide sample data for a screenshot',
271 '/turbo lang <de|en> language of the pane',
272 '/turbo clear reset the list of files',
273 '/turbo effect switch the title effect off or on',
274 ].join('\n'),
275 hidden: 'Turbo-MOD hidden. /turbo shows it again.',
276 shown: version => `Turbo-MOD ${version} shown.`,
277 cleared: 'List of files cleared.',
278 effectOn: 'Title effect is running.',
279 effectOff: 'Title effect is off.',
280 themeList: names => `Color schemes: ${names}`,
281 themeUnknown: name => `No color scheme "${name}". /turbo theme shows the list.`,
282 themeSet: name => `Color scheme: ${name}`,
283 rootMissing: 'No repo folder set. /turbo repos <path> adds one.',
284 reposResult: (total, root, dirty) => `${total} repos under ${root}, ${dirty} with changes.`,
285 languageSet: 'Language: English',
286 languageUnknown: name => `No language "${name}". There are de and en.`,
287 runFailed: (what, program) => `${what}: ${program} could not be started or did not answer.`,
288 pickFailed: program => `Folder dialog: ${program} could not be started. /turbo repos <path> sets the folder by hand.`,
289}
290
291const ALL: Record<Lang, Texts> = { de: DE, en: EN }
292
293/** Finds a language by its code, ignoring case. */
294export const findLanguage = (name: string): Lang | undefined => {
295 const wanted = name.trim().toLowerCase()
296
297 return LANGUAGES.find(one => one === wanted)
298}
299
300export const textsFor = (lang: string): Texts => ALL[findLanguage(lang) ?? 'en']
301hooks/theme.ts 63 lines1// Translates a color scheme into the colors the pane needs - without $ and therefore directly testable.
2
3import { PALETTES } from './palettes'
4
5export const STANDARD = 'standard'
6
7export type Look = {
8 /** Title line. */
9 title: string
10 /** Header lines of the widgets. */
11 accent: string
12 /** Body text, undefined means: color of the terminal. */
13 text: string | undefined
14 ok: string
15 warn: string
16 bad: string
17 /** Area behind the pane, undefined means: background of the terminal. */
18 background: string | undefined
19 /**
20 * Area behind a button. A button takes no color, it draws in the text color
21 * of the terminal. On a light scheme that would be light on light, so there it gets
22 * the dark text color of the scheme as its ground.
23 */
24 chip: string | undefined
25}
26
27/** The colors of the host: they follow the theme set in Claude Code. */
28const HOST_LOOK: Look = {
29 title: 'claude',
30 accent: 'suggestion',
31 text: undefined,
32 ok: 'success',
33 warn: 'warning',
34 bad: 'error',
35 background: undefined,
36 chip: undefined,
37}
38
39export const themeNames = (): string[] => [STANDARD, ...PALETTES.map(palette => palette.name)]
40
41/** Finds a scheme by name, ignoring case. */
42export const findTheme = (name: string): string | undefined => {
43 const wanted = name.trim().toLowerCase()
44
45 return themeNames().find(one => one === wanted)
46}
47
48export const lookFor = (name: string): Look => {
49 const palette = PALETTES.find(one => one.name === name)
50 if (palette === undefined) return HOST_LOOK
51
52 return {
53 title: palette.primary,
54 accent: palette.accent,
55 text: palette.foreground,
56 ok: palette.success,
57 warn: palette.warning,
58 bad: palette.error,
59 background: palette.background,
60 chip: palette.dark ? undefined : palette.foreground,
61 }
62}
63hooks/palettes.ts 63 lines1// Snapshot of textual-themes 0.15.2 (Apache-2.0, Michael Blaess).
2// Generated by tools/snapshot_palettes.py - do not edit by hand.
3
4export type Palette = {
5 name: string
6 title: string
7 dark: boolean
8 primary: string
9 secondary: string
10 accent: string
11 foreground: string
12 background: string
13 surface: string
14 panel: string
15 warning: string
16 error: string
17 success: string
18}
19
20export const PALETTES: readonly Palette[] = [
21 { name: 'brotkasten', title: 'Brotkasten', dark: true, primary: '#7C70DA', secondary: '#3A2B8A', accent: '#EDF171', foreground: '#D0CCFF', background: '#3A2B8A', surface: '#5446B8', panel: '#241870', warning: '#EDF171', error: '#C46C71', success: '#A9FF9F' },
22 { name: 'boing', title: 'Boing', dark: true, primary: '#FF8800', secondary: '#0055AA', accent: '#FF8800', foreground: '#FFFFFF', background: '#0055AA', surface: '#0066BB', panel: '#004499', warning: '#FFAA00', error: '#FF4444', success: '#44BB44' },
23 { name: 'gemstone', title: 'Gemstone', dark: false, primary: '#007700', secondary: '#555555', accent: '#009900', foreground: '#111111', background: '#E8E8E8', surface: '#F2F2F2', panel: '#DDDDDD', warning: '#AA8800', error: '#CC0000', success: '#007700' },
24 { name: 'classic-terminal', title: 'Classic Terminal', dark: true, primary: '#33FF33', secondary: '#2A7A2A', accent: '#88FF88', foreground: '#33FF33', background: '#0A0A0A', surface: '#162616', panel: '#0F1B0F', warning: '#FFAA00', error: '#FF4444', success: '#33FF33' },
25 { name: 'next', title: 'Next', dark: true, primary: '#9966CC', secondary: '#555555', accent: '#9966CC', foreground: '#E0E0E0', background: '#2A2A2A', surface: '#3A3A3A', panel: '#222222', warning: '#CC9933', error: '#CC4444', success: '#44AA44' },
26 { name: 'bebox', title: 'BeBox', dark: true, primary: '#FFD800', secondary: '#5F5F5F', accent: '#FFD800', foreground: '#E8E8E8', background: '#3A3A4A', surface: '#4A4A5A', panel: '#333344', warning: '#FF9900', error: '#DD3333', success: '#33BB33' },
27 { name: 'bunty', title: 'Bunty', dark: true, primary: '#DD4814', secondary: '#77216F', accent: '#E18B5C', foreground: '#F2EAEA', background: '#2C001E', surface: '#4A2540', panel: '#1F0014', warning: '#F99B11', error: '#DF382C', success: '#38B44A' },
28 { name: 'cupertino', title: 'Cupertino', dark: false, primary: '#007AFF', secondary: '#5856D6', accent: '#007AFF', foreground: '#1D1D1F', background: '#F5F5F7', surface: '#FFFFFF', panel: '#E8E8ED', warning: '#FF9500', error: '#FF3B30', success: '#34C759' },
29 { name: 'luna', title: 'Luna', dark: true, primary: '#0054E3', secondary: '#21A121', accent: '#0054E3', foreground: '#FFFFFF', background: '#003399', surface: '#0044AA', panel: '#002D8A', warning: '#FFCC00', error: '#E81123', success: '#21A121' },
30 { name: 'commandr', title: 'Commandr', dark: true, primary: '#FFFF55', secondary: '#55FFFF', accent: '#FFFF55', foreground: '#FFFFFF', background: '#0000AA', surface: '#1A1ACC', panel: '#000077', warning: '#FFAA00', error: '#FF5555', success: '#55FF55' },
31 { name: 'plan9', title: 'Plan 9', dark: false, primary: '#228844', secondary: '#4488AA', accent: '#228844', foreground: '#111111', background: '#FFFFEA', surface: '#EAFFFF', panel: '#D5E8D0', warning: '#BB8800', error: '#CC2222', success: '#228844' },
32 { name: 'motif', title: 'Motif', dark: true, primary: '#CC9966', secondary: '#5F7B8A', accent: '#CC9966', foreground: '#D8D0C8', background: '#3A4A5A', surface: '#455565', panel: '#303F4F', warning: '#CCAA44', error: '#CC5544', success: '#55AA66' },
33 { name: 'warp', title: 'Warp', dark: true, primary: '#00BBBB', secondary: '#3333AA', accent: '#00BBBB', foreground: '#D0D0D0', background: '#1A1A4E', surface: '#25255E', panel: '#141442', warning: '#DDAA22', error: '#DD4444', success: '#44BB66' },
34 { name: 'geeko', title: 'Geeko', dark: true, primary: '#73BA25', secondary: '#35B9AB', accent: '#73BA25', foreground: '#EEEEEE', background: '#173F0F', surface: '#1E4D15', panel: '#12330B', warning: '#F0A30A', error: '#DD3333', success: '#73BA25' },
35 { name: 'minty', title: 'Minty', dark: true, primary: '#8BB158', secondary: '#6DAB76', accent: '#8BB158', foreground: '#E8E8E8', background: '#2B2B2B', surface: '#363636', panel: '#232323', warning: '#E5A50A', error: '#CC3333', success: '#8BB158' },
36 { name: 'crimson', title: 'Crimson', dark: true, primary: '#CC0000', secondary: '#A30000', accent: '#EE0000', foreground: '#E0E0E0', background: '#1A0A0A', surface: '#2A1515', panel: '#140808', warning: '#EEA500', error: '#FF4444', success: '#44AA44' },
37 { name: 'razzy', title: 'Razzy', dark: true, primary: '#C51A4A', secondary: '#6CC24A', accent: '#C51A4A', foreground: '#EEEEEE', background: '#1E1E2E', surface: '#2A2A3A', panel: '#181828', warning: '#E5A50A', error: '#DD3333', success: '#6CC24A' },
38 { name: 'beastie', title: 'Beastie', dark: true, primary: '#AB2B28', secondary: '#5E8AAA', accent: '#AB2B28', foreground: '#D4D4D4', background: '#1C2028', surface: '#262A32', panel: '#161A20', warning: '#CC9933', error: '#DD4444', success: '#55AA66' },
39 { name: 'fifty-eight', title: 'Fifty-Eight', dark: true, primary: '#C9A96E', secondary: '#6A6A6A', accent: '#9E1B25', foreground: '#E8C985', background: '#100C08', surface: '#1E1914', panel: '#080605', warning: '#C9A048', error: '#B8252E', success: '#6A9A5A' },
40 { name: 'bluesy', title: 'Bluesy', dark: true, primary: '#D4AF37', secondary: '#1E4FA0', accent: '#F0C85A', foreground: '#F5D76E', background: '#081F54', surface: '#0E2E6E', panel: '#04133A', warning: '#E8A838', error: '#DD3344', success: '#48B870' },
41 { name: 'goldfinder', title: 'Goldfinder', dark: true, primary: '#E6B800', secondary: '#8A6E20', accent: '#FFD740', foreground: '#E8DFC0', background: '#080705', surface: '#18140A', panel: '#040302', warning: '#E8A838', error: '#CC4040', success: '#5AAA5A' },
42 { name: 'goldrunner', title: 'Goldrunner', dark: true, primary: '#C8A93A', secondary: '#C788CE', accent: '#E8C547', foreground: '#EEEECA', background: '#080520', surface: '#1A0F3D', panel: '#120A2E', warning: '#B06400', error: '#D6456A', success: '#5FA82E' },
43 { name: 'hercules', title: 'Hercules', dark: true, primary: '#FF9A2E', secondary: '#A85F12', accent: '#FFC470', foreground: '#F5A742', background: '#0B0A09', surface: '#1D1B19', panel: '#12110F', warning: '#FFC470', error: '#FF4A1F', success: '#F5A742' },
44 { name: 'hulkula', title: 'Hulkula', dark: true, primary: '#2BA841', secondary: '#BCC4CA', accent: '#4DC962', foreground: '#F0F2EE', background: '#083C14', surface: '#104B1B', panel: '#042608', warning: '#D4A040', error: '#CC2222', success: '#4DC962' },
45 { name: 'flughund', title: 'Flughund', dark: true, primary: '#244B85', secondary: '#BCC4CA', accent: '#3D6FB8', foreground: '#F0F2F5', background: '#060810', surface: '#0E1422', panel: '#030509', warning: '#D4A040', error: '#CC3030', success: '#50AA50' },
46 { name: 'classic-navy', title: 'Classic Navy', dark: true, primary: '#C0C5CC', secondary: '#1E4585', accent: '#9E3A42', foreground: '#EEF0F5', background: '#0C2B5C', surface: '#143465', panel: '#061A3A', warning: '#D4A040', error: '#B04048', success: '#50AA50' },
47 { name: 'brick', title: 'Brick', dark: false, primary: '#8E2A5E', secondary: '#5A4858', accent: '#D63B68', foreground: '#241F28', background: '#D4CEBC', surface: '#E2DCCA', panel: '#B2AC9A', warning: '#C85820', error: '#A0203A', success: '#4A7A3A' },
48 { name: 'clipper', title: 'Clipper', dark: false, primary: '#1A4FA0', secondary: '#C61F2C', accent: '#D4222F', foreground: '#1A1A1A', background: '#F6F3E8', surface: '#FCFAF0', panel: '#EBE6D4', warning: '#C88A20', error: '#C61F2C', success: '#2E8B3D' },
49 { name: 'synthwave', title: 'Synthwave', dark: true, primary: '#FF2E93', secondary: '#7B2D8E', accent: '#05D9E8', foreground: '#F5E9FF', background: '#1A0B3D', surface: '#261553', panel: '#0E0524', warning: '#FFD319', error: '#FF3860', success: '#39FF14' },
50 { name: 'miami', title: 'Miami', dark: true, primary: '#FF6FAB', secondary: '#1FB8BC', accent: '#FFA06A', foreground: '#FFE8E0', background: '#0B2F3F', surface: '#124050', panel: '#051825', warning: '#FFD76B', error: '#E63970', success: '#4ECDA8' },
51 { name: 'racing', title: 'Racing', dark: true, primary: '#1A5CC8', secondary: '#C0C6D0', accent: '#E42030', foreground: '#EEF0F5', background: '#14161E', surface: '#1F232E', panel: '#080A10', warning: '#E8A838', error: '#E42030', success: '#3AAA4A' },
52 { name: 'metropolis', title: 'Metropolis', dark: true, primary: '#E02030', secondary: '#1A5CC8', accent: '#FFD53B', foreground: '#F0F2F8', background: '#0A2A5E', surface: '#123876', panel: '#051838', warning: '#FFD53B', error: '#C80A18', success: '#3AAA4A' },
53 { name: 'spiderized', title: 'Spiderized', dark: true, primary: '#D71920', secondary: '#1F75FE', accent: '#1F75FE', foreground: '#FFFFFF', background: '#0E1A3A', surface: '#1A2C5F', panel: '#070D24', warning: '#FFA830', error: '#C80A18', success: '#4AA85A' },
54 { name: 'ascot', title: 'Ascot', dark: true, primary: '#F2C200', secondary: '#C0C5CC', accent: '#F2C200', foreground: '#F5EBD2', background: '#173E2D', surface: '#1F4E3A', panel: '#0E2C1E', warning: '#F2C200', error: '#C81E2A', success: '#2E7D52' },
55 { name: 'joker', title: 'Joker', dark: true, primary: '#7B3FB2', secondary: '#2FCC2F', accent: '#FFC72C', foreground: '#F5F0E5', background: '#0E0824', surface: '#1F0F3F', panel: '#2D1758', warning: '#FFC72C', error: '#DC4040', success: '#2FCC2F' },
56 { name: 'marley', title: 'Marley', dark: true, primary: '#078930', secondary: '#DA121A', accent: '#FCDD09', foreground: '#F5EFD8', background: '#0C0A06', surface: '#181818', panel: '#050505', warning: '#FCDD09', error: '#DA121A', success: '#078930' },
57 { name: 'lenseflare', title: 'Lenseflare', dark: true, primary: '#FF8C42', secondary: '#2BD0E0', accent: '#E5283A', foreground: '#F0E5D2', background: '#0A1226', surface: '#14213D', panel: '#060B18', warning: '#FFB838', error: '#E5283A', success: '#2BD0E0' },
58 { name: 'platoon', title: 'Platoon', dark: true, primary: '#9AA04A', secondary: '#6F8A3C', accent: '#C7B06A', foreground: '#E4E0CF', background: '#12130B', surface: '#22241A', panel: '#3A3D22', warning: '#E0B330', error: '#BF3B2A', success: '#6F9A4A' },
59 { name: 'corleone', title: 'Corleone', dark: true, primary: '#A88B52', secondary: '#4A4E58', accent: '#9A8147', foreground: '#C3C0B4', background: '#0B0C0E', surface: '#16181C', panel: '#1F2228', warning: '#B89043', error: '#8C2F2F', success: '#5E7355' },
60 { name: 'golden-brown', title: 'Golden Brown', dark: true, primary: '#A8843F', secondary: '#6E4E36', accent: '#C29A4E', foreground: '#D5C5A4', background: '#0F0B07', surface: '#1C1611', panel: '#2B2018', warning: '#BE8F39', error: '#9A2B2B', success: '#5F6B42' },
61 { name: 'christophorus', title: 'Christophorus', dark: true, primary: '#D4A15A', secondary: '#50698A', accent: '#E0B574', foreground: '#E6E9EE', background: '#102640', surface: '#132D4B', panel: '#1A3A5E', warning: '#E8B45A', error: '#FF6B8B', success: '#8FD16A' },
62]
63types/index.d.ts 132 lines1export type FileEntry = {
2 /** Absolute path, exactly as the tool received it. */
3 path: string
4 /** Bytes after the last write. */
5 size: number
6 /** Last change, milliseconds since 1970. */
7 mtimeMs: number
8 /** True if the file was newly created in this session. */
9 isNew: boolean
10 /** How often the file was written in this session. */
11 writes: number
12 /** Running number in order of the first write, from 1. Stays the same on later writes. */
13 no: number
14}
15
16export type HostInfo = {
17 /** Operating system, detected via `OS` and `uname -s`. */
18 os: 'windows' | 'mac' | 'linux'
19 /** Terminal that can be split from outside: Windows Terminal, tmux or none. */
20 terminal: 'wt' | 'tmux' | 'none'
21}
22
23export type RepoEntry = {
24 /** Root folder the repo was found under. */
25 root: string
26 /** Full path of the repo. */
27 dir: string
28 /** Folder name under the root folder. */
29 name: string
30 /** Current branch, "(detached)" for a detached HEAD. */
31 branch: string
32 /** Commits ahead of the upstream, as of the last fetch. */
33 ahead: number
34 /** Commits behind the upstream, as of the last fetch. */
35 behind: number
36 /** Changed and staged entries of files git knows. */
37 changed: number
38 /** Files git does not know yet. */
39 untracked: number
40 /** False if `git status` failed for this repo. */
41 isRead: boolean
42}
43
44export type ReposState = {
45 /** Root folders of the repos, empty as long as none is set. */
46 roots: string[]
47 /** True while a run also fetches every repo from its server. */
48 isFetching: boolean
49 /** Full path of the repo that is being pulled right now, otherwise empty. */
50 pulling: string
51 list: RepoEntry[]
52 /** Time of the last run, 0 as long as none has run. */
53 checkedAt: number
54 isBusy: boolean
55 /** Key of a note for the display ("unreadable", "empty"), otherwise empty. */
56 note: string
57}
58
59export type FeedTicket = {
60 key: string
61 summary: string
62 /** Address of the ticket, empty if the feed gives none. */
63 url: string
64}
65
66export type FeedState = {
67 /** Command line that prints the tickets as JSON, empty turns the widget off. */
68 command: string
69 list: FeedTicket[]
70 /** First day the feed covers, ISO date, empty if the feed does not say. */
71 since: string
72 /** Time of the last run, 0 as long as none has run. */
73 checkedAt: number
74 isBusy: boolean
75 /** What the feed or the mod has to say about a failed run, otherwise empty. */
76 note: string
77}
78
79export type UsageLimit = {
80 /** Name of the window as the host reports it, for example "five_hour". */
81 kind: string
82 percentUsed: number
83 /** When the window resets, ISO 8601, empty if the host does not say. */
84 resetsAt: string
85}
86
87export type UsageState = {
88 /** Time of the reading, 0 as long as there is none. */
89 measuredAt: number
90 /** Tokens in the context window right now. */
91 tokens: number
92 /** Size of the context window of the model. */
93 window: number
94 limits: UsageLimit[]
95 /** Cost of the session in US dollars, -1 where the host keeps no ledger. */
96 usd: number
97}
98
99declare module 'claude-code' {
100 interface PluginState {
101 'turbo-mod': {
102 files: FileEntry[]
103 /** Page of the file list that is shown, from 0. A new write goes back to 0. */
104 filePage: number
105 /** Per widget: true means collapsed. */
106 collapsed: Record<string, boolean>
107 repos: ReposState
108 host: HostInfo
109 /** Name of the color scheme, "standard" follows the theme of Claude Code. */
110 theme: string
111 /** The title as it is currently drawn (runs through an effect at intervals). */
112 title: string
113 /** True once the state is restored. False again after the host dropped it, as on /clear. */
114 ready: boolean
115 /** True once the notice of the first start is confirmed on this machine. */
116 accepted: boolean
117 /** Step of the moving dots behind a busy label, 0 to 2. */
118 pulse: number
119 /** Key of the button currently waiting for the second confirmation, otherwise empty. */
120 armed: string
121 /** Language of the pane, "de" or "en". */
122 lang: string
123 /** Full path of the editor, empty if it was not found on this machine. */
124 editorPath: string
125 usage: UsageState
126 feed: FeedState
127 /** Address of a ticket with the placeholder {id}, empty if none is set. */
128 ticketUrl: string
129 }
130 }
131}
132