SLOPSHOPPER

turbo-mod

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

newpaneguardcommandtoastprocess
v0.13.0Apache-2.0updated 2026-10-09michaelblaess/turbo-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · turbo-mod
│ ┃ turbo-mod ✕ › fix the failing auth test and add an audit log call │ ┃ ♞ Turbo-MOD v0.13.0 · https://www.michaelbl… │ ┃ Theme ◂ standard ▸ · [EN] · https://github.c ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ╭──────────────────────────────────────────╮ ⏺ Update(src/auth.ts) │ ┃ │ Disclaimer │ ⎿ Added 2 lines, removed 1 line │ ┃ │ Turbo-MOD starts programs on your │ ⏺ Bash(bun test) │ ┃ │ machine, runs git fetch and git pull in │ ⎿ 3 pass, 1 fail │ ┃ │ your repos when you press the buttons, │ │ ┃ │ and can start a command you have set. │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ You use the mod at your own risk and │ │ ┃ │ without warranty, see the license │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ (Apache 2.0). │ │ ┃ │ [Understood] │ › /turbo │ ┃ ╰──────────────────────────────────────────╯ ⎿ turbo-mod: Turbo-MOD hidden. /turbo shows it again. │ ┃ ───────────────────────────────────────────… │ ┃ ▾ Files 1 file · 193 B │ ┃ 1 ~ file:///work/app/src/auth.ts │ ┃ file:///work/app/src │ ┃ 193 B · 10-09 08:52:20 │ ┃ [open] [path] [content] │ ┃ [clear list] │ ┃ ───────────────────────────────────────────… │ ┃ ▾ Terminal │ ┃ No splittable terminal found. Windows │ ┃ Terminal and tmux are supported. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · turbo-mod
♞ Turbo-MOD v0.13.0 · https://www.michaelblaess.de · Open S… Theme ◂ standard ▸ · [EN] · https://github.com/michaelblaess ╭──────────────────────────────────────────────────────────╮ │ Disclaimer │ │ 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). │ │ [Understood] │ ╰──────────────────────────────────────────────────────────╯ ─────────────────────────────────────────────────────── ▾ Files 1 file · 193 B 1 ~ file:///work/app/src/auth.ts file:///work/app/src 193 B · 10-09 08:52:20 [open] [path] [content] [clear list] ─────────────────────────────────────────────────────── ▾ Terminal No splittable terminal found. Windows Terminal and tmux are supported. ─────────────────────────────────────────────────────── ▸ Repos ─────────────────────────────────────────────────────── ▾ Claude · https://www.michaelblaess.de/helfer/claude-code.h Current session ██████████████████████████████████████████████ 31 % Context ██████████████████████████████████████████████ 49 % 97k of 200k · Cost $0.42 [refresh] 08:53 [/compact] [/clear]
README

turbo-mod

<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:

  • Files - every file Claude wrote in this session, with buttons to open or copy it
  • Terminal - split the window or open a new tab, in the folder you are working in
  • Repos - which of your git repos have uncommitted changes or unpushed commits, with a pull button next to each
  • Claude - how much of your limits is used, when they reset and how full the context is
  • New tickets - optional, off by default: new tickets from your ticket system

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.

What you need

  • Claude Code in a terminal. The install command below does not work in the desktop app.
  • git, for the Repos widget.
  • For the Terminal widget, the editor button and [>_]: 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.

Install

  1. Start Claude Code in a terminal:
   claude
  1. Type this line at the prompt and press Enter:
   /plugin install turbo-mod --marketplace michaelblaess/turbo-mod
  1. Claude Code asks three things:
  2. Add marketplace? - answer y. This only tells Claude Code where to fetch the mod from.
  3. The scope - press Enter for user. The mod then loads in every session you start.
  4. The options (theme, repo folder, language and so on) - press Enter to keep the defaults. You can change all of them later inside the pane or with /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.

First steps

  • On the very first start the pane shows a short disclaimer in a frame. Click [Understood] once. Until then [pull], [fetch all] and the ticket command stay locked, everything else works. The answer is remembered on this machine.
  • Ask Claude to write a file. It shows up under Files, newest on top.
  • Click the arrow in front of a heading to fold a widget away.
  • Click the arrows next to Theme below the title to try the color schemes.
  • Click [EN] next to it to switch the pane to German, and again to switch back.
  • Open Repos and click [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.

The widgets

Files

Every file Claude wrote through Write, Edit or NotebookEdit, newest on top:

  • a running number, in the order the files were first written
  • + for a new file, ~ for a changed one
  • the file name and its folder as links
  • size, time of the last write and how often it was written
  • a button with the name of your editor ([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.

Terminal

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.

Repos

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:

SignMeaning
~3three changed files git already knows
?1one new file git does not know yet
↑1one commit you have not pushed
↓2two 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.
  • With a ticket address set, a ticket key in a branch name (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}.

Claude

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.

New tickets (optional)

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.

Commands

CommandWhat it does
/turboshow 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 clearreset the list of files, numbering starts again at 1
/turbo effectswitch the title effect off or on
/turbo demoshow 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.

Color schemes

41 retro schemes, taken from textual-themes, for example brotkasten, boing, synthwave, lenseflare and christophorus. standard follows the theme you set in Claude Code.

Settings

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.

FieldDefaultMeaning
themestandardcolor scheme
reposRootemptyrepo folder, several separated by ;
languageenlanguage of the pane, en or de
ticketUrlemptyticket address with {id}
ticketFeedemptycommand for the widget New tickets
editornvimprogram 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.

Update and uninstall

claude plugin update turbo-mod
claude plugin uninstall turbo-mod

After an update, type /reload-plugins in a running session.

Limits

  • Files created by a shell command (a redirect, a copy, a script) are not seen.
  • The desktop app gets no file: links, the path is shown as text there.
  • Windows Terminal has no command to close a pane and none to split to the left. The split to the left therefore splits to the right and swaps the two panes. [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.
  • Other terminals (kitty, WezTerm, GNOME Terminal) are not supported for splitting.
  • The folder dialog needs zenity on Linux, the clipboard buttons wl-copy or xclip.
  • [content] is meant for text files.
  • Windows Terminal opens links only with Ctrl held down. The buttons react to a plain click.
  • A button cannot take a color, it is drawn in the text color of your terminal. On light schemes the buttons therefore sit on a dark patch. With a light terminal and a dark scheme a button can be hard to read.
  • Reading the repos never contacts a server. "Ahead" and "behind" reflect your last fetch, only [pull] and [fetch all] talk to the server. Both wait at most a minute per repo.
  • At most 80 folders below each repo folder are read.

For developers

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

License

Apache License 2.0 - (c) 2026 Michael Blaess

Source 11 files
hooks/register.tsx 1340 lines
1import 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 lines
1// 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}
96
hooks/demo.ts 83 lines
1// 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}
83
hooks/feed.ts 30 lines
1// 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}
30
hooks/format.ts 114 lines
1// 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()
114
hooks/git.ts 56 lines
1// 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(' ')
56
hooks/platform.ts 156 lines
1// 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'))
156
hooks/texts.ts 301 lines
1// 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']
301
hooks/theme.ts 63 lines
1// 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}
63
hooks/palettes.ts 63 lines
1// 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]
63
types/index.d.ts 132 lines
1export 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