SLOPSHOPPER

supermod-claudecode

Usage bar above the prompt (context, rate limits, cost, tokens) and a Workbranch pane: trial copies of your project you can save, bring into main or throw…

newpanebandcommandprocesstimer
v1.0.0MITupdated 2026-10-04nayde8824/supermod-claudecode
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · supermod-claudecode
│ ┃ Workbranch ✕ › fix the failing auth test and add an audit log call │ ┃ PROJECT │ ┃ app ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ● This project doesn't have workbranches yet ⏺ Update(src/auth.ts) │ ┃ Right now its versions end up with other ⎿ Added 2 lines, removed 1 line │ ┃ projects, in the folder. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ [ Turn on ] │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ? what is a workbranch ✻ Worked for 42s · done 4:20 PM │ │ › /usage-bar │ ⎿ supermod-claudecode: Usage bar hidden. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Workbranch
PROJECT app ● This project doesn't have workbranches yet Right now its versions end up with other projects, in the folder. [ Turn on ] ? what is a workbranch
README

Supermod ClaudeCode

🇮🇹 Italiano · 🇬🇧 English

A mod for Claude Code: a usage bar above the prompt, and workbranches, trial copies of your project that make git worktrees simple for people who have never used git.


Italiano

Supermod ClaudeCode: la riga dell'utilizzo e il pannello Workbranch

Cos'è un workbranch

Un workbranch è una copia di prova del tuo progetto. Ci lavori, tu o Claude, senza toccare la versione principale:

  • se la prova va bene, la porti nella principale con un clic;
  • se va male, la butti via e la principale resta com'era.

Perché è molto più comodo

Senza workbranch, provare un'idea con Claude significa lavorare direttamente sul progetto vero: se il risultato non ti piace, devi annullare tutto a mano. Con git le cose migliorano, ma bisogna conoscere branch, worktree, commit e merge, e capire in quale cartella si sta lavorando.

Con i workbranch:

PrimaCon i workbranch
Claude modifica direttamente il progetto veroClaude lavora su una copia: la versione principale è al sicuro
Per provare due idee devi farle una dopo l'altraOgni idea ha il suo workbranch, anche in parallelo
Ti perdi tra cartelle, branch e worktreeIl pannello ti dice sempre dove sei e cosa c'è da fare
Worktree e branch sono due cose separate da gestireUn workbranch è una cosa sola: nasce, si salva, si porta nella principale o si butta
Comandi git da ricordarePulsanti: Salva, Porta nella principale, Butta via
Un conflitto di merge è un muroChiedi a Claude di sistemarlo: un clic e Claude risolve
Puoi perdere lavoro rimuovendo la cartella sbagliataTi avvisa prima se perderesti modifiche o salvataggi

Il pannello Workbranch

  • Sei in: il workbranch in cui sta lavorando Claude, e cosa c'è da fare ("3 modifiche da salvare");
  • 🏠 Versione principale e 🧪 i tuoi workbranch, ognuno con il suo stato scritto a parole:
  • 3 modifiche da salvare
  • 2 salvataggi da portare nella principale
  • ✓ già nella principale
PulsanteCosa fa
+ Nuovo workbranchscrivi un nome: crea la copia e ci entra subito
Entra / Torna alla principalesposta la sessione di Claude Code
Salvasalva le modifiche con un messaggio tuo
Porta nella principaleunisce la prova alla versione principale
Butta viaelimina la prova, chiedendo conferma se perderesti qualcosa
Riapririprende un workbranch archiviato

Se cambi workbranch con modifiche non salvate, il pannello chiede: Salva e spostati, Spostati e basta o annulla. Se il progetto non usa ancora git, il pulsante Attiva prepara tutto (repository, .gitignore, primo salvataggio) e si ferma se il primo salvataggio pesasse più di 200 MB.

La riga dell'utilizzo

Sopra il prompt: contesto, limiti 5 ore e settimana dell'abbonamento, costo e token della sessione. Gli anelli diventano gialli oltre il 60% e rossi oltre l'85%. La ✕ nasconde la riga, /usage-bar la rimostra.

I limiti arrivano con le risposte del server a questa sessione: se usi Claude anche altrove, la percentuale si aggiorna alla risposta successiva.

Lingua

L'interfaccia è in italiano o inglese e segue la lingua impostata in Claude Code. Se non ne hai impostata una, segue quella del sistema.

Installazione

Dentro Claude Code:

/plugin marketplace add nayde8824/supermod-claudecode
/plugin install supermod-claudecode@supermod-claudecode

Poi apri una nuova sessione.

ComandoCosa fa
/workbranchapre il pannello Workbranch
/usage-barmostra o nasconde la riga dell'utilizzo

Requisiti

  • Claude Code 2.1.286 o successivo: i mod sono una funzione in accesso anticipato e la loro API può cambiare;
  • git installato.

English

Supermod ClaudeCode: the usage bar and the Workbranch pane

What is a workbranch

A workbranch is a trial copy of your project. You, or Claude, work in it without touching the main version:

  • if the trial goes well, you bring it into main with one click;
  • if not, you throw it away and main stays as it was.

Why it is so much easier

Without workbranches, trying an idea with Claude means working on the real project: if you don't like the result, you undo everything by hand. Git helps, but you have to know branches, worktrees, commits and merges, and keep track of which folder you are in.

With workbranches:

BeforeWith workbranches
Claude edits the real projectClaude works on a copy: the main version is safe
Two ideas have to be tried one after the otherEvery idea gets its own workbranch, even in parallel
You get lost between folders, branches and worktreesThe pane always tells you where you are and what's left to do
Worktree and branch are two things to manageA workbranch is one thing: it is created, saved, brought into main or thrown away
Git commands to rememberButtons: Save, Bring into main, Throw away
A merge conflict is a wallAsk Claude to fix it: one click and Claude resolves it
You can lose work by removing the wrong folderIt warns you first if you would lose changes or saves

The Workbranch pane

  • You are in: the workbranch Claude is working in, and what's left to do ("3 changes to save");
  • 🏠 the Main version and 🧪 your workbranches, each with its status in plain words:
  • 3 changes to save
  • 2 saves to bring into main
  • ✓ already in main
ButtonWhat it does
+ New workbranchtype a name: creates the copy and switches into it
Enter / Back to mainmoves the Claude Code session
Savesaves your changes with your own message
Bring into mainmerges the trial into the main version
Throw awaydeletes the trial, asking first if you would lose anything
Reopenbrings back a put-away workbranch

Switching with unsaved changes asks: Save and switch, Just switch or cancel. If the project doesn't use git yet, Turn on sets everything up (repository, .gitignore, first save) and stops if the first save would weigh more than 200 MB.

The usage bar

Above the prompt: context, your subscription's 5 hours and week limits, the session's cost and tokens. Rings turn yellow above 60% and red above 85%. ✕ hides the bar, /usage-bar brings it back.

Rate limits arrive with the server's responses to this session: if you also use Claude elsewhere, the percentage updates on the next response.

Language

The interface is in English or Italian and follows the language set in Claude Code. With none set, it follows the system's.

Install

Inside Claude Code:

/plugin marketplace add nayde8824/supermod-claudecode
/plugin install supermod-claudecode@supermod-claudecode

Then start a new session.

CommandWhat it does
/workbranchopens the Workbranch pane
/usage-barshows or hides the usage bar

Requirements

  • Claude Code 2.1.286 or later: mods are an early-access feature and their API may change;
  • git.

License

MIT © 2026 Valerio Monteforte

Source 3 files
hooks/register.tsx 900 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Archived, Lang, Tokens, Worktree, WorktreeView, WtUi } from '../types'
5import { words } from './i18n'
6
7const ZERO: Tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
8const IDLE: WtUi = { mode: 'list', target: null, then: null, busy: false, message: null, isError: false, conflicts: [] }
9
10const tokens = atom({ plugin: 'supermod-claudecode', key: 'tokens' } as const, ZERO)
11// Redraws the bar every second, so context/cost/limits stay fresh.
12const tick = atom({ plugin: 'supermod-claudecode', key: 'tick' } as const, 0)
13const bandHidden = atom({ plugin: 'supermod-claudecode', key: 'bandHidden' } as const, false)
14const worktrees = atom({ plugin: 'supermod-claudecode', key: 'worktrees' } as const, null)
15const wtUi = atom({ plugin: 'supermod-claudecode', key: 'wtUi' } as const, IDLE)
16const prefs = atom({ plugin: 'supermod-claudecode', key: 'prefs' } as const, { lang: 'en' })
17
18const WT_PANE = 'worktrees'
19const WT_TITLE = 'Workbranch'
20const WT_COLUMNS = 50
21const WT_REFRESH_MS = 8000
22
23/* ---------- formatting ---------- */
24
25const fmt = (n: number) =>
26  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(2)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
27
28const bar = (percent: number, width: number) => {
29  const full = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
30  return '▰'.repeat(full) + '▱'.repeat(width - full)
31}
32
33const tone = (percent: number) => (percent >= 85 ? 'red' : percent >= 60 ? 'yellow' : 'green')
34
35const ORDER: Record<string, number> = { five_hour: 0, seven_day: 1 }
36
37/* ---------- SVG icons (desktop): colors readable on light and dark themes ---------- */
38
39const ICON = 16
40const HEX = { green: '#3fb950', yellow: '#e3b341', red: '#f85149' }
41const GREY = '#8c8c8c'
42const svg = (body: string) =>
43  `<svg xmlns="http://www.w3.org/2000/svg" width="${ICON}" height="${ICON}" viewBox="0 0 16 16">${body}</svg>`
44
45const ring = (pct: number) => {
46  const r = 6
47  const len = 2 * Math.PI * r
48  const done = (Math.min(100, Math.max(0, pct)) / 100) * len
49  return svg(
50    `<circle cx="8" cy="8" r="${r}" fill="none" stroke="${GREY}" stroke-opacity=".3" stroke-width="2.4"/>` +
51      `<circle cx="8" cy="8" r="${r}" fill="none" stroke="${HEX[tone(pct)]}" stroke-width="2.4" stroke-linecap="round" ` +
52      `stroke-dasharray="${done.toFixed(2)} ${len.toFixed(2)}" transform="rotate(-90 8 8)"/>`,
53  )
54}
55const TOKEN_ICON = svg(
56  `<path d="M3 5.5h10M3 8h7M3 10.5h8.5" fill="none" stroke="${GREY}" stroke-width="1.5" stroke-linecap="round"/>`,
57)
58const COST_ICON = svg(
59  `<circle cx="8" cy="8" r="6.2" fill="none" stroke="#39c5cf" stroke-width="1.5"/>` +
60    `<path d="M9.9 5.9c-.4-.6-1.1-.9-1.9-.9-1.1 0-1.9.6-1.9 1.4 0 1.9 3.9 1 3.9 3 0 .8-.8 1.5-2 1.5-.9 0-1.6-.4-2-1M8 4v8" fill="none" stroke="#39c5cf" stroke-width="1.3" stroke-linecap="round"/>`,
61)
62
63/* ---------- workbranches: reading git ---------- */
64
65type Run = (argv: readonly string[], cwd?: string) => Promise<{ exitCode: number; stdout: string; stderr: string }>
66
67// Above this size the first commit stops: better to exclude videos, builds, models first.
68const FIRST_COMMIT_LIMIT = 200 * 1024 * 1024
69
70const GITIGNORE = `# Dependencies and builds
71node_modules/
72.next/
73dist/
74build/
75out/
76.dart_tool/
77Pods/
78
79# Secrets
80.env
81.env.*
82!.env.example
83
84# System
85.DS_Store
86*.log
87`
88
89// Branch names come from the repository and end up as git arguments: only plain names pass
90// (letters, digits, . _ / -, never a leading - or ..), so none can be read as an option.
91const safeRef = (name: string | null | undefined) =>
92  name && /^[A-Za-z0-9_][A-Za-z0-9._/-]{0,199}$/.test(name) && !name.includes('..') ? name : null
93
94const parsePorcelain = (out: string) =>
95  out
96    .split('\n\n')
97    .map(block => block.trim())
98    .filter(Boolean)
99    .map(block => {
100      const wt = { path: '', head: '', branch: null as string | null, isLocked: false, isPrunable: false }
101      for (const line of block.split('\n')) {
102        if (line.startsWith('worktree ')) wt.path = line.slice(9)
103        else if (line.startsWith('HEAD ')) wt.head = line.slice(5, 12)
104        else if (line.startsWith('branch ')) wt.branch = safeRef(line.slice(7).replace(/^refs\/heads\//, ''))
105        else if (line.startsWith('locked')) wt.isLocked = true
106        else if (line.startsWith('prunable')) wt.isPrunable = true
107      }
108      return wt
109    })
110
111const countAhead = async (run: Run, repo: string, base: string | null, branch: string | null) => {
112  if (!base || !branch || base === branch) return 0
113  const r = await run(['git', '-C', repo, 'rev-list', '--count', `${base}..${branch}`])
114  return r.exitCode === 0 ? Number(r.stdout.trim()) || 0 : 0
115}
116
117// How much the first commit would weigh, in total and per top-level folder. No shell: git lists the
118// files, `stat` sizes them in chunks, each name passed as its own argument.
119const measureNew = async (run: Run, project: string) => {
120  const listed = await run(['git', '-C', project, 'ls-files', '-o', '--exclude-standard', '-z'])
121  const files = listed.stdout.split('\0').filter(Boolean)
122  const byFolder = new Map<string, number>()
123  let total = 0
124  for (let i = 0; i < files.length; i += 400) {
125    const chunk = files.slice(i, i + 400)
126    // BSD stat (macOS) first, GNU stat (Linux) otherwise; `--` so no name is read as an option.
127    let r = await run(['stat', '-f', '%z', '--', ...chunk], project)
128    if (r.exitCode !== 0) r = await run(['stat', '-c', '%s', '--', ...chunk], project)
129    r.stdout
130      .split('\n')
131      .filter(Boolean)
132      .forEach((size, j) => {
133        const name = chunk[j] ?? ''
134        const slash = name.indexOf('/')
135        const folder = slash >= 0 ? name.slice(0, slash + 1) : name
136        byFolder.set(folder, (byFolder.get(folder) ?? 0) + Number(size))
137        total += Number(size)
138      })
139  }
140  return { total, byFolder }
141}
142
143// Settings a repository can carry that make git run a program. The person's own global settings are
144// theirs and trusted; the repository's are not: with any of these, the pane leaves the repository alone.
145const RUNS_PROGRAMS = new RegExp(
146  '^(' +
147    [
148      'core\\.(fsmonitor|hookspath|sshcommand|gitproxy|pager|editor|askpass|alternaterefscommand)',
149      'filter\\..*',
150      'diff\\.external',
151      'diff\\..*\\.(textconv|command)',
152      'merge\\..*\\.driver',
153      'credential\\..*',
154      'gpg\\.(.*\\.)?program',
155      'sequence\\.editor',
156      'uploadpack\\.packobjectshook',
157      'protocol\\.ext\\.allow',
158      'include\\.path',
159      'includeif\\..*',
160    ].join('|') +
161    ')$',
162  'i',
163)
164
165// Whether a repository's own config would make the pane's automatic git calls run its programs.
166// Listing config never runs anything.
167const runsPrograms = async (run: Run, path: string) => {
168  const r = await run(['git', '-C', path, 'config', '--list', '--name-only', '--show-scope'])
169  return r.stdout
170    .split('\n')
171    .map(line => line.split('\t'))
172    .some(([scope, key]) => (scope === 'local' || scope === 'worktree') && RUNS_PROGRAMS.test(key ?? ''))
173}
174
175const loadWorktrees = async (run: Run, project: string): Promise<WorktreeView> => {
176  const empty = { project, parentRepo: null, base: null, list: [], archived: [], error: null }
177  const top = await run(['git', '-C', project, 'rev-parse', '--show-toplevel'])
178  if (top.exitCode !== 0) return { ...empty, repo: 'none' }
179  if (await runsPrograms(run, project)) return { ...empty, repo: 'unsafe' }
180
181  const root = top.stdout.trim()
182  if (root !== project) {
183    // A parent folder has a repository: the project is "its own" only if that repository tracks its files.
184    const tracked = await run(['git', '-C', project, 'ls-files', '--', '.'])
185    if (!tracked.stdout.trim()) return { ...empty, repo: 'inside', parentRepo: root }
186  }
187
188  const hasHead = (await run(['git', '-C', project, 'rev-parse', '--verify', '-q', 'HEAD'])).exitCode === 0
189  if (!hasHead) return { ...empty, repo: 'empty' }
190
191  const listed = await run(['git', '-C', project, 'worktree', 'list', '--porcelain'])
192  const raw = parsePorcelain(listed.stdout)
193  const main = raw[0]
194  const base = main?.branch ?? null
195  const current = raw
196    .filter(w => project === w.path || project.startsWith(w.path + '/'))
197    .sort((a, b) => b.path.length - a.path.length)[0]?.path
198
199  // Each worktree can carry settings of its own (config.worktree): check them before reading any.
200  const live = raw.filter(w => !w.isPrunable)
201  if ((await Promise.all(live.map(w => runsPrograms(run, w.path)))).some(Boolean)) return { ...empty, repo: 'unsafe' }
202
203  const list = await Promise.all(
204    raw.map(async (w, i): Promise<Worktree> => {
205      const status = w.isPrunable ? null : await run(['git', '-C', w.path, 'status', '--porcelain=v1'])
206      return {
207        ...w,
208        isMain: i === 0,
209        isCurrent: w.path === current,
210        changes: status?.exitCode === 0 ? status.stdout.split('\n').filter(Boolean).length : 0,
211        ahead: i === 0 ? 0 : await countAhead(run, project, base, w.branch),
212      }
213    }),
214  )
215
216  // Branches with no folder: workbranches put away.
217  const inUse = new Set(list.map(w => w.branch).filter(Boolean))
218  const heads = await run(['git', '-C', project, 'for-each-ref', '--format=%(refname:short)', 'refs/heads'])
219  const archived = await Promise.all(
220    heads.stdout
221      .split('\n')
222      .filter(b => safeRef(b) && !inUse.has(b))
223      .map(async (branch): Promise<Archived> => ({ branch, ahead: await countAhead(run, project, base, branch) })),
224  )
225
226  return { ...empty, repo: 'own', base, list, archived }
227}
228
229const baseName = (path: string) => path.split('/').filter(Boolean).pop() ?? path
230
231const slug = (text: string) =>
232  text
233    .trim()
234    .toLowerCase()
235    .normalize('NFD')
236    .replace(/[̀-ͯ]/g, '')
237    .replace(/[^a-z0-9._/-]+/g, '-')
238    .replace(/^-+|-+$/g, '')
239
240const mb = (bytes: number) => (bytes >= 1024 ** 3 ? `${(bytes / 1024 ** 3).toFixed(1)} GB` : `${Math.round(bytes / 1024 ** 2)} MB`)
241
242// Where a workbranch's folder lives, next to the others of the same project.
243const folderFor = (home: string, mainPath: string, branch: string) =>
244  `${home}/.worktrees/${slug(baseName(mainPath))}/${branch.replace(/\//g, '-')}`
245
246/* ---------- language ---------- */
247
248// The interface language follows Claude Code's own language setting; with none set, the system's.
249async function resolveLang($: EngineInterface): Promise<Lang> {
250  const setting = (await $.settings.read())['language']
251  if (typeof setting === 'string' && setting.trim()) return /^(it|ital)/i.test(setting.trim()) ? 'it' : 'en'
252  const env = (await $.env.get('LANG')) ?? ''
253  if (env) return env.toLowerCase().startsWith('it') ? 'it' : 'en'
254  const mac = await $.process.run(['defaults', 'read', '-g', 'AppleLocale']).catch(() => null)
255  return mac?.stdout.trim().toLowerCase().startsWith('it') ? 'it' : 'en'
256}
257
258async function currentWords($: EngineInterface) {
259  const p = await read($, prefs)
260  return words(p.lang)
261}
262
263/* ---------- workbranches: actions ---------- */
264
265// The pane runs git on its own (every few seconds) in whatever folder the session is in. A repository's
266// config can name a program for git to run (core.fsmonitor): turn that off, so opening a downloaded
267// project never runs its code behind the person's back.
268function runner($: EngineInterface): Run {
269  return (argv, cwd) => {
270    const safe = argv[0] === 'git' ? ['git', '-c', 'core.fsmonitor=false', ...argv.slice(1)] : argv
271    return $.process.run(safe, { timeoutMs: 120_000, ...(cwd ? { cwd } : {}) })
272  }
273}
274
275
276async function refreshWorktrees($: EngineInterface) {
277  const project = await $.session.root()
278  const view = await loadWorktrees(runner($), project)
279  await update($, worktrees, () => view)
280}
281
282async function busy($: EngineInterface, message: string) {
283  await update($, wtUi, ui => ({ ...(ui ?? IDLE), busy: true, message, isError: false }))
284}
285
286async function finish($: EngineInterface, message: string, isError = false) {
287  await update($, wtUi, () => ({ ...IDLE, message, isError }))
288  await refreshWorktrees($)
289}
290
291async function initRepo($: EngineInterface) {
292  const t = await currentWords($)
293  const run = runner($)
294  const project = await $.session.root()
295  await busy($, t.working.init)
296
297  if (!(await $.fs.exists(`${project}/.git`))) {
298    const init = await run(['git', 'init', '-b', 'main'], project)
299    if (init.exitCode !== 0) return finish($, t.done.failed(init.stderr.trim()), true)
300  }
301  if (!(await $.fs.exists(`${project}/.gitignore`))) await $.fs.write(`${project}/.gitignore`, GITIGNORE)
302
303  const { total, byFolder } = await measureNew(run, project)
304  if (total > FIRST_COMMIT_LIMIT) {
305    const biggest = [...byFolder]
306      .map(([name, size]) => ({ size, name }))
307      .sort((a, b) => b.size - a.size)
308      .slice(0, 3)
309      .map(r => `${r.name} (${mb(r.size)})`)
310      .join(', ')
311    return finish($, t.done.tooBig(mb(total), biggest), true)
312  }
313
314  const add = await run(['git', 'add', '-A'], project)
315  if (add.exitCode !== 0) return finish($, t.done.failed(add.stderr.trim()), true)
316  const commit = await run(['git', 'commit', '-q', '-m', 'First commit'], project)
317  if (commit.exitCode !== 0) return finish($, t.done.failed((commit.stderr || commit.stdout).trim()), true)
318
319  return finish($, t.done.init(mb(total)))
320}
321
322// Moves this Claude Code session. EnterWorktree only takes secondary worktrees, and only from the
323// starting folder; ExitWorktree brings the session back to where it started.
324async function moveTo($: EngineInterface, path: string, toMain: boolean) {
325  const t = await currentWords($)
326  await busy($, t.working.move)
327  const consent = `The user pressed "${t.enter}" on ${path} in the Workbranch pane`
328  const view = await read($, worktrees)
329  const inSide = view?.list.some(w => w.isCurrent && !w.isMain) ?? false
330
331  if (toMain || inSide) {
332    const out = await $.tool.call({ tool: 'ExitWorktree', action: 'keep', consent })
333    if (out.deny !== undefined) return finish($, t.done.notMoved(out.deny), true)
334    if (out.isError) return finish($, t.done.notMoved(out.text ?? ""), true)
335    if (toMain) return finish($, t.done.moved(t.mainName))
336  }
337  const r = await $.tool.call({ tool: 'EnterWorktree', path, consent })
338  if (r.deny !== undefined) return finish($, t.done.notMoved(r.deny), true)
339  if (r.isError) return finish($, t.done.notMoved(r.text ?? ""), true)
340
341  return finish($, t.done.moved(baseName(path)))
342}
343
344async function goTo($: EngineInterface, path: string, toMain: boolean) {
345  const view = await read($, worktrees)
346  const here = view?.list.find(w => w.isCurrent)
347  // Leaving a workbranch with unsaved changes asks first.
348  if (here && here.changes > 0) {
349    return update($, wtUi, (ui): WtUi => ({ ...(ui ?? IDLE), mode: 'confirm-leave', target: path, message: null, isError: false }))
350  }
351  return moveTo($, path, toMain)
352}
353
354// Commits everything in the workbranch. Returns true when it went through.
355async function commitChanges($: EngineInterface, path: string, typed: string): Promise<boolean> {
356  const t = await currentWords($)
357  const run = runner($)
358  await busy($, t.working.save)
359  const add = await run(['git', '-C', path, 'add', '-A'])
360  const count = (await run(['git', '-C', path, 'diff', '--cached', '--name-only'])).stdout.split('\n').filter(Boolean).length
361  if (add.exitCode !== 0 || count === 0) {
362    await finish($, add.exitCode !== 0 ? t.done.failed(add.stderr.trim()) : t.done.nothing, add.exitCode !== 0)
363    return false
364  }
365  const commit = await run(['git', '-C', path, 'commit', '-q', '-m', typed.trim() || t.defaultMessage(count)])
366  if (commit.exitCode !== 0) {
367    await finish($, t.done.failed((commit.stderr || commit.stdout).trim()), true)
368    return false
369  }
370  await finish($, t.done.saved(count, baseName(path)))
371  return true
372}
373
374// Saves with the typed message; with `then` ("save and switch") moves the session afterwards.
375async function saveChanges($: EngineInterface, path: string, typed: string, then: string | null) {
376  const saved = await commitChanges($, path, typed)
377  if (!saved || !then) return
378  const view = await read($, worktrees)
379  const dest = view?.list.find(w => w.path === then)
380  if (dest) await moveTo($, dest.path, dest.isMain)
381}
382
383async function addWorktree($: EngineInterface, rawName: string) {
384  const t = await currentWords($)
385  const name = slug(rawName)
386  if (!name) return update($, wtUi, ui => ({ ...(ui ?? IDLE), message: t.done.needName, isError: true }))
387
388  const run = runner($)
389  const view = await read($, worktrees)
390  const main = view?.list.find(w => w.isMain)
391  if (!main) return finish($, t.noRepoTitle, true)
392
393  await busy($, t.working.create(name))
394  const path = folderFor((await $.env.get('HOME')) ?? '', main.path, name)
395  const exists = (await run(['git', '-C', main.path, 'show-ref', '--verify', '-q', `refs/heads/${name}`])).exitCode === 0
396  const r = await run(
397    exists
398      ? ['git', '-C', main.path, 'worktree', 'add', path, name]
399      : ['git', '-C', main.path, 'worktree', 'add', '-b', name, path],
400  )
401  if (r.exitCode !== 0) return finish($, t.done.failed(r.stderr.trim()), true)
402
403  await refreshWorktrees($)
404  // A new workbranch is where you want to be: go straight in.
405  return moveTo($, path, false)
406}
407
408// Brings a workbranch into the main branch. On a conflict it backs out and offers to ask Claude.
409async function mergeIntoMain($: EngineInterface, path: string) {
410  const t = await currentWords($)
411  const run = runner($)
412  const view = await read($, worktrees)
413  const main = view?.list.find(w => w.isMain)
414  const wb = view?.list.find(w => w.path === path)
415  if (!main || !wb?.branch) return finish($, t.done.failed('?'), true)
416  if (wb.changes > 0) return finish($, t.mergeSaveFirst, true)
417  if (main.changes > 0) return finish($, t.mainDirty, true)
418
419  await busy($, t.working.merge)
420  const r = await run(['git', '-C', main.path, 'merge', '--no-edit', wb.branch])
421  if (r.exitCode === 0) return finish($, t.done.merged(wb.branch))
422
423  const conflicted = await run(['git', '-C', main.path, 'diff', '--name-only', '--diff-filter=U'])
424  const files = conflicted.stdout.split('\n').filter(Boolean)
425  await run(['git', '-C', main.path, 'merge', '--abort'])
426  if (!files.length) return finish($, t.done.failed((r.stderr || r.stdout).trim()), true)
427
428  await update($, wtUi, (): WtUi => ({ ...IDLE, mode: 'conflict', target: path, conflicts: files }))
429  return refreshWorktrees($)
430}
431
432async function askClaudeToMerge($: EngineInterface, path: string) {
433  const t = await currentWords($)
434  const view = await read($, worktrees)
435  const main = view?.list.find(w => w.isMain)
436  const wb = view?.list.find(w => w.path === path)
437  if (!main || !wb?.branch) return
438  await update($, wtUi, () => IDLE)
439  // Framed as this plugin's message, not as the person's words. Only the two branch names go in, and
440  // safeRef has already reduced them to plain names; file names and paths stay out: Claude reads them from git.
441  await $.prompt.submit({ text: t.askPrompt(wb.branch, main.branch ?? 'main') })
442}
443
444// Throws a workbranch away: its folder and its branch together.
445async function discardWorktree($: EngineInterface, path: string) {
446  const t = await currentWords($)
447  const run = runner($)
448  const view = await read($, worktrees)
449  const main = view?.list.find(w => w.isMain)
450  const wb = view?.list.find(w => w.path === path)
451  if (!main || !wb) return finish($, t.done.failed('?'), true)
452  if (wb.isCurrent) return finish($, t.discardHere, true)
453
454  await busy($, t.working.discard)
455  const rm = await run(['git', '-C', main.path, 'worktree', 'remove', '--force', path])
456  if (rm.exitCode !== 0) return finish($, t.done.failed(rm.stderr.trim()), true)
457  if (wb.branch) await run(['git', '-C', main.path, 'branch', '-D', wb.branch])
458
459  return finish($, t.done.discarded(wb.branch ?? baseName(path)))
460}
461
462async function discardArchived($: EngineInterface, branch: string) {
463  const t = await currentWords($)
464  const view = await read($, worktrees)
465  const main = view?.list.find(w => w.isMain)
466  if (!main) return
467  await busy($, t.working.discard)
468  const r = await runner($)(['git', '-C', main.path, 'branch', '-D', branch])
469  return finish($, r.exitCode === 0 ? t.done.discarded(branch) : t.done.failed(r.stderr.trim()), r.exitCode !== 0)
470}
471
472async function reopenArchived($: EngineInterface, branch: string) {
473  const t = await currentWords($)
474  const view = await read($, worktrees)
475  const main = view?.list.find(w => w.isMain)
476  if (!main) return
477  await busy($, t.working.reopen)
478  const path = folderFor((await $.env.get('HOME')) ?? '', main.path, branch)
479  const r = await runner($)(['git', '-C', main.path, 'worktree', 'add', path, branch])
480  return finish($, r.exitCode === 0 ? t.done.reopened(branch) : t.done.failed(r.stderr.trim()), r.exitCode !== 0)
481}
482
483/* ---------- hooks ---------- */
484
485export const register: Register = on => {
486  on('session.start', async ($, e, next) => {
487    const lang = await resolveLang($)
488    await update($, prefs, () => ({ lang }))
489    const t = words(lang)
490
491    await $.command.register({ name: 'usage-bar', description: t.barCmd })
492    await $.command.register({ name: 'workbranch', description: t.paneCmd })
493
494    $.clock.every(1000, () => {
495      void update($, tick, n => (n ?? 0) + 1)
496    })
497    $.clock.every(WT_REFRESH_MS, () => {
498      void (async () => {
499        const panes = await $.ui.panes()
500        const ui = await read($, wtUi)
501        if (!ui.busy && panes.some(p => p.id === WT_PANE && p.isShown)) await refreshWorktrees($)
502      })()
503    })
504
505    await update($, wtUi, () => IDLE)
506    void refreshWorktrees($)
507    void $.ui.open({ id: WT_PANE, title: WT_TITLE, columns: WT_COLUMNS })
508
509    return next(e)
510  })
511
512  on('command.run', { command: 'usage-bar' }, async $ => {
513    const t = await currentWords($)
514    const hidden = await update($, bandHidden, v => !v)
515
516    return { text: hidden ? t.barHidden : t.barShown }
517  })
518
519  on('command.run', { command: 'workbranch' }, async $ => {
520    const t = await currentWords($)
521    void refreshWorktrees($)
522    await $.ui.open({ id: WT_PANE, title: WT_TITLE, columns: WT_COLUMNS })
523
524    return { text: t.paneOpened }
525  })
526
527  on('turn.complete', async ($, e, next) => {
528    const result = await next(e)
529    const u = result.usage ?? e.usage
530    if (u) {
531      await update($, tokens, t => ({
532        input: (t ?? ZERO).input + u.input_tokens,
533        output: (t ?? ZERO).output + u.output_tokens,
534        cacheRead: (t ?? ZERO).cacheRead + u.cache_read_input_tokens,
535        cacheWrite: (t ?? ZERO).cacheWrite + u.cache_creation_input_tokens,
536      }))
537    }
538    // After each turn Claude may have changed files or worktrees.
539    if (!e.agentId) void refreshWorktrees($)
540
541    return result
542  })
543
544  /* ----- bar above the prompt: context, 5 hours, week, cost, tokens ----- */
545
546  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
547    if (e.props.hasSurvey || (await read($, bandHidden))) return next(e)
548
549    await read($, tick)
550    const t = words((await read($, prefs)).lang)
551    const tk = await read($, tokens)
552    const usage = await $.session.usage()
553    const ctx = usage.context
554    const ctxPct = ctx.percent ?? (ctx.tokens && ctx.window ? Math.round((ctx.tokens / ctx.window) * 100) : 0)
555    const total = tk.input + tk.output + tk.cacheRead + tk.cacheWrite
556    const names: Record<string, string> = { five_hour: t.fiveHours, seven_day: t.week, spend_limit: t.spend }
557    const limits = [...usage.rateLimits]
558      .sort((a, b) => (ORDER[a.kind] ?? 9) - (ORDER[b.kind] ?? 9))
559      .map(l => ({ name: names[l.kind] ?? l.kind, pct: l.percentUsed }))
560    const hide = () => update($, bandHidden, () => true)
561
562    if (e.surface === 'terminal') {
563      const { Box, Text, Button } = $.ui.resolve(e)
564      const dot = <Text dimColor>  │  </Text>
565
566      return (
567        <Box flexDirection="row" flexWrap="wrap" paddingX={1}>
568          <Text dimColor>{t.context} </Text>
569          <Text color={tone(ctxPct)}>{bar(ctxPct, 8)} {ctxPct}%</Text>
570          {limits.map(l => (
571            <Text>
572              <Text dimColor>  │  {l.name} </Text>
573              <Text color={tone(l.pct)}>{bar(l.pct, 5)} {l.pct}%</Text>
574            </Text>
575          ))}
576          {usage.cost && dot}
577          {usage.cost && <Text color="cyan">${usage.cost.usd.toFixed(2)}</Text>}
578          {dot}
579          <Text>{fmt(total)}</Text>
580          <Text dimColor> {t.tokens}</Text>
581          <Text>   </Text>
582          <Button key="hide" plain dimColor label="✕" onPress={hide} />
583        </Box>
584      )
585    }
586
587    const { Box, Text, Button, Svg } = $.ui.resolve(e)
588    const chip = (key: string, icon: string, iconAlt: string, value: string, label: string, color?: string) => (
589      <Box key={key} flexDirection="row" alignItems="center" gap={1} paddingX={1} borderStyle="round" borderDimColor>
590        <Svg source={icon} alt={iconAlt} width={ICON} height={ICON} />
591        <Text bold color={color}>{value}</Text>
592        <Text dimColor>{label}</Text>
593      </Box>
594    )
595
596    return (
597      <Box flexDirection="row" flexWrap="wrap" alignItems="center" gap={1}>
598        {chip('ctx', ring(ctxPct), `${ctxPct}%`, `${ctxPct}%`, t.context)}
599        {limits.map(l => chip(`lim-${l.name}`, ring(l.pct), `${l.pct}%`, `${l.pct}%`, l.name))}
600        {usage.cost && chip('cost', COST_ICON, t.cost, `$${usage.cost.usd.toFixed(2)}`, t.cost, 'cyan')}
601        {chip('tok', TOKEN_ICON, t.tokens, fmt(total), t.tokens)}
602        <Button key="hide" role="dismiss" plain label="✕" onPress={hide} />
603      </Box>
604    )
605  })
606
607  /* ----- side pane: the project's workbranches ----- */
608
609  on('ui.render', { component: 'Pane', requestId: WT_PANE }, async ($, e) => {
610    const els = $.ui.resolve(e)
611    const { Box, Text, Button } = els
612    // The text field exists everywhere but on mobile.
613    const Input = 'Input' in els ? els.Input : undefined
614    const view = await read($, worktrees)
615    const ui = await read($, wtUi)
616    const p = await read($, prefs)
617    const t = words(p.lang)
618    const setUi = (patch: Partial<WtUi>) =>
619      update($, wtUi, cur => ({ ...(cur ?? IDLE), message: null, isError: false, ...patch }))
620
621    if (!view) return <Text dimColor>{t.reading}</Text>
622
623    const message = ui.message && <Text color={ui.isError ? 'red' : 'green'}>{ui.message}</Text>
624    const footer = (
625      <Box flexDirection="column">
626        <Text> </Text>
627        {ui.mode === 'help' ? (
628          <Box flexDirection="column">
629            {t.helpText.map(line => (
630              <Text dimColor>{line}</Text>
631            ))}
632            <Button key="help-close" plain dimColor label={t.close} onPress={() => setUi({ mode: 'list' })} />
633          </Box>
634        ) : (
635          <Button key="help" plain dimColor label={t.help} onPress={() => setUi({ mode: 'help' })} />
636        )}
637      </Box>
638    )
639    const header = (
640      <Box flexDirection="column">
641        <Text dimColor>{t.project}</Text>
642        <Text bold>{baseName(view.project)}</Text>
643        <Text> </Text>
644      </Box>
645    )
646
647    if (ui.busy) {
648      return (
649        <Box flexDirection="column">
650          {header}
651          <Text color="yellow">{ui.message ?? t.wait}</Text>
652        </Box>
653      )
654    }
655
656    // A repository whose settings run programs: the pane won't run git there on its own.
657    if (view.repo === 'unsafe') {
658      return (
659        <Box flexDirection="column">
660          {header}
661          <Text color="yellow">⚠ {t.unsafeTitle}</Text>
662          <Text dimColor>{t.unsafeText}</Text>
663        </Box>
664      )
665    }
666
667    // No repository of its own: one button to turn history on.
668    if (view.repo !== 'own') {
669      const isEmpty = view.repo === 'empty'
670      return (
671        <Box flexDirection="column">
672          {header}
673          <Text color="yellow">● {isEmpty ? t.emptyTitle : t.noRepoTitle}</Text>
674          <Text dimColor>
675            {isEmpty ? t.emptyText : view.repo === 'inside' ? t.insideText(baseName(view.parentRepo ?? '')) : t.noRepoText}
676          </Text>
677          <Text> </Text>
678          {ui.mode === 'confirm-init' ? (
679            <Box flexDirection="column">
680              <Text>{t.confirmInit(baseName(view.project))}</Text>
681              <Box flexDirection="row" gap={2}>
682                <Button key="init-yes" variant="primary" label={t.yesActivate} onPress={() => initRepo($)} />
683                <Button key="init-no" label={t.cancel} onPress={() => setUi({ mode: 'list' })} />
684              </Box>
685            </Box>
686          ) : (
687            <Button
688              key="init"
689              variant="primary"
690              label={isEmpty ? t.firstSave : t.activate}
691              onPress={() => (isEmpty ? initRepo($) : setUi({ mode: 'confirm-init' }))}
692            />
693          )}
694          <Text> </Text>
695          {message}
696          {footer}
697        </Box>
698      )
699    }
700
701    const name = (w: Worktree) => (w.isMain ? t.mainName : (w.branch ?? w.head))
702    const icon = (w: Worktree) => (w.isMain ? '🏠' : '🧪')
703    const status = (w: Worktree) =>
704      w.isPrunable
705        ? { color: 'red', text: t.missing }
706        : w.changes > 0
707          ? { color: 'yellow', text: t.changes(w.changes) }
708          : w.ahead > 0
709            ? { color: 'cyan', text: t.ahead(w.ahead) }
710            : { color: 'green', text: w.isMain ? t.clean : t.merged }
711    const here = view.list.find(w => w.isCurrent)
712    const main = view.list.find(w => w.isMain)
713
714    const commitForm = (w: Worktree) => (
715      <Box flexDirection="column" paddingLeft={3}>
716        {Input ? (
717          <Input
718            key={`commit-msg-${w.path}`}
719            label={t.messageLabel}
720            placeholder={t.messagePlaceholder(w.changes)}
721            submitLabel={ui.then ? t.saveAndSwitch : t.save.toLowerCase()}
722            autoFocus
723            onSubmit={(value: string) => saveChanges($, w.path, value, ui.then)}
724          />
725        ) : (
726          <Button key={`commit-plain-${w.path}`} variant="primary" label={t.save} onPress={() => saveChanges($, w.path, '', ui.then)} />
727        )}
728        <Text dimColor>{t.saveHint(Boolean(ui.then))}</Text>
729        <Button key={`commit-no-${w.path}`} plain dimColor label={t.cancel} onPress={() => setUi({ mode: 'list', target: null, then: null })} />
730      </Box>
731    )
732
733    const actions = (w: Worktree) => {
734      if (ui.mode === 'commit' && ui.target === w.path) return commitForm(w)
735
736      if (ui.mode === 'confirm-leave' && ui.target === w.path && here) {
737        return (
738          <Box flexDirection="column" paddingLeft={3}>
739            <Text color="yellow">{t.leaveWarn(name(here), here.changes)}</Text>
740            <Box flexDirection="row" gap={2}>
741              <Button
742                key={`leave-save-${w.path}`}
743                variant="primary"
744                label={t.saveThenGo}
745                onPress={() => setUi({ mode: 'commit', target: here.path, then: w.path })}
746              />
747              <Button key={`leave-go-${w.path}`} label={t.justGo} onPress={() => moveTo($, w.path, w.isMain)} />
748              <Button key={`leave-no-${w.path}`} plain dimColor label={t.cancel} onPress={() => setUi({ mode: 'list', target: null })} />
749            </Box>
750          </Box>
751        )
752      }
753
754      if (ui.mode === 'confirm-discard' && ui.target === w.path) {
755        return (
756          <Box flexDirection="column" paddingLeft={3}>
757            <Text color="yellow">{t.discardWarn(w.changes, w.ahead)}</Text>
758            <Box flexDirection="row" gap={2}>
759              <Button key={`discard-yes-${w.path}`} variant="primary" label={t.discardYes} onPress={() => discardWorktree($, w.path)} />
760              <Button key={`discard-no-${w.path}`} label={t.cancel} onPress={() => setUi({ mode: 'list', target: null })} />
761            </Box>
762          </Box>
763        )
764      }
765
766      if (ui.mode === 'conflict' && ui.target === w.path) {
767        return (
768          <Box flexDirection="column" paddingLeft={3}>
769            <Text color="yellow">{t.conflictTitle(ui.conflicts.length)}</Text>
770            {ui.conflicts.slice(0, 5).map(f => (
771              <Text dimColor wrap="truncate-middle">  {f}</Text>
772            ))}
773            <Box flexDirection="row" gap={2}>
774              <Button key={`ask-${w.path}`} variant="primary" label={t.askClaude} onPress={() => askClaudeToMerge($, w.path)} />
775              <Button key={`ask-no-${w.path}`} label={t.cancel} onPress={() => setUi({ mode: 'list', target: null, conflicts: [] })} />
776            </Box>
777          </Box>
778        )
779      }
780
781      return (
782        <Box flexDirection="row" flexWrap="wrap" columnGap={2} paddingLeft={3}>
783          {!w.isCurrent && !w.isPrunable && (
784            <Button
785              key={`enter-${w.path}`}
786              plain
787              label={w.isMain ? t.backToMain : t.enter}
788              onPress={() => goTo($, w.path, w.isMain)}
789            />
790          )}
791          {w.changes > 0 && !w.isPrunable && (
792            <Button key={`save-${w.path}`} plain label={t.save} onPress={() => setUi({ mode: 'commit', target: w.path, then: null })} />
793          )}
794          {!w.isMain && w.ahead > 0 && (
795            <Button key={`merge-${w.path}`} plain label={t.merge} onPress={() => mergeIntoMain($, w.path)} />
796          )}
797          {!w.isMain && !w.isCurrent && (
798            <Button key={`discard-${w.path}`} plain dimColor label={t.discard} onPress={() => setUi({ mode: 'confirm-discard', target: w.path })} />
799          )}
800        </Box>
801      )
802    }
803
804    return (
805      <Box flexDirection="column">
806        {header}
807
808        <Text dimColor>{t.youAreIn}</Text>
809        {here ? (
810          <Box flexDirection="column">
811            <Text bold color="green">
812              {icon(here)} {name(here)}
813            </Text>
814            <Text color={status(here).color}>{status(here).text}</Text>
815          </Box>
816        ) : (
817          <Text dimColor>—</Text>
818        )}
819
820        <Text> </Text>
821        <Box flexDirection="row" justifyContent="space-between">
822          <Text dimColor>{t.listTitle}</Text>
823          <Button key="refresh" plain dimColor label="↻" onPress={() => refreshWorktrees($)} />
824        </Box>
825
826        {view.list.map(w => (
827          <Box key={w.path} flexDirection="column">
828            <Box flexDirection="row" justifyContent="space-between">
829              <Text wrap="truncate-end">
830                {icon(w)} <Text bold={w.isCurrent} color={w.isCurrent ? 'green' : undefined}>{name(w)}</Text>
831                {w.isCurrent && <Text color="green"> {t.here}</Text>}
832              </Text>
833            </Box>
834            <Text color={status(w).color}>   {status(w).text}</Text>
835            {actions(w)}
836          </Box>
837        ))}
838
839        <Text> </Text>
840        {ui.mode === 'new' ? (
841          <Box flexDirection="column">
842            {Input ? (
843              <Input
844                key="new-name"
845                label={t.nameLabel}
846                placeholder={t.namePlaceholder}
847                submitLabel={t.create}
848                autoFocus
849                onSubmit={(value: string) => addWorktree($, value)}
850              />
851            ) : null}
852            <Text dimColor>{t.createHint(main ? name(main) : 'main')}</Text>
853            <Button key="new-no" plain dimColor label={t.cancel} onPress={() => setUi({ mode: 'list' })} />
854          </Box>
855        ) : (
856          Input && <Button key="new" variant="primary" label={t.newBtn} onPress={() => setUi({ mode: 'new' })} />
857        )}
858
859        {view.archived.length > 0 && (
860          <Box flexDirection="column">
861            <Text> </Text>
862            <Text dimColor>{t.archivedTitle}</Text>
863            {view.archived.map(a => (
864              <Box key={`arch-${a.branch}`} flexDirection="column">
865                <Text dimColor>
866                  📦 {a.branch} <Text color={a.ahead > 0 ? 'cyan' : 'green'}>· {a.ahead > 0 ? t.ahead(a.ahead) : t.merged}</Text>
867                </Text>
868                {ui.mode === 'confirm-discard' && ui.target === `arch:${a.branch}` ? (
869                  <Box flexDirection="column" paddingLeft={3}>
870                    <Text color="yellow">{t.discardWarn(0, a.ahead)}</Text>
871                    <Box flexDirection="row" gap={2}>
872                      <Button key={`drop-yes-${a.branch}`} variant="primary" label={t.discardYes} onPress={() => discardArchived($, a.branch)} />
873                      <Button key={`drop-no-${a.branch}`} label={t.cancel} onPress={() => setUi({ mode: 'list', target: null })} />
874                    </Box>
875                  </Box>
876                ) : (
877                  <Box flexDirection="row" columnGap={2} paddingLeft={3}>
878                    <Button key={`reopen-${a.branch}`} plain label={t.reopen} onPress={() => reopenArchived($, a.branch)} />
879                    <Button
880                      key={`drop-${a.branch}`}
881                      plain
882                      dimColor
883                      label={t.discard}
884                      onPress={() => setUi({ mode: 'confirm-discard', target: `arch:${a.branch}` })}
885                    />
886                  </Box>
887                )}
888              </Box>
889            ))}
890          </Box>
891        )}
892
893        <Text> </Text>
894        {message}
895        {footer}
896      </Box>
897    )
898  })
899}
900
hooks/i18n.ts 244 lines
1import type { Lang } from '../types'
2
3// Every word the mod shows, per language, in plain words for people who don't know git.
4
5const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
6
7const it = {
8  // usage bar
9  context: 'contesto',
10  fiveHours: '5 ore',
11  week: 'settimana',
12  spend: 'spesa',
13  cost: 'costo',
14  tokens: 'token',
15  barHidden: 'Riga Usage nascosta.',
16  barShown: 'Riga Usage visibile.',
17  barCmd: 'Mostra/nasconde la riga Usage sopra il prompt',
18  paneCmd: 'Apre il pannello Workbranch',
19  paneOpened: 'Pannello Workbranch aperto.',
20
21  // pane
22  project: 'PROGETTO',
23  youAreIn: 'SEI IN',
24  reading: 'Leggo il progetto…',
25  wait: 'Un attimo…',
26  mainName: 'Versione principale',
27  listTitle: 'I TUOI WORKBRANCH',
28  archivedTitle: 'WORKBRANCH ARCHIVIATI',
29  here: '◀ qui',
30  newBtn: '+ Nuovo workbranch',
31  namePlaceholder: 'es. nuova-grafica',
32  nameLabel: 'Nome ',
33  create: 'crea',
34  createHint: (from: string) => `Invio per creare ed entrare · parte da ${from}`,
35  cancel: 'annulla',
36  save: 'Salva',
37  enter: 'Entra',
38  backToMain: 'Torna alla principale',
39  merge: 'Porta nella principale',
40  discard: 'Butta via',
41  reopen: 'Riapri',
42  help: '? cosa sono i workbranch',
43  helpText: [
44    'Un workbranch è una copia di prova del progetto.',
45    'Ci lavori senza toccare la versione principale: se la prova va bene la porti nella principale, se va male la butti via.',
46    'Salva spesso: ogni salvataggio è un punto a cui puoi tornare.',
47  ],
48  close: 'chiudi',
49  changes: (n: number) => `${plural(n, 'modifica', 'modifiche')} da salvare`,
50  ahead: (n: number) => `${plural(n, 'salvataggio', 'salvataggi')} da portare nella principale`,
51  merged: '✓ già nella principale',
52  clean: '✓ tutto salvato',
53  missing: 'cartella mancante',
54
55  // no repository
56  unsafeTitle: 'Workbranch disattivati in questo progetto',
57  unsafeText:
58    'Le impostazioni git di questo progetto fanno eseguire dei programmi a git. Per sicurezza il pannello non lo legge da solo: se ti fidi del progetto, usa git normalmente.',
59  noRepoTitle: 'Questo progetto non ha ancora i workbranch',
60  noRepoText: 'Per usarli bisogna attivare la cronologia delle versioni (git).',
61  insideText: (parent: string) => `Ora le sue versioni finiscono insieme ad altri progetti, nella cartella ${parent}.`,
62  emptyTitle: 'Cronologia attivata, manca il primo salvataggio',
63  emptyText: 'Fai il primo salvataggio per iniziare.',
64  activate: 'Attiva',
65  firstSave: 'Primo salvataggio',
66  confirmInit: (p: string) => `Attivo la cronologia in ${p} e faccio il primo salvataggio?`,
67  yesActivate: 'Sì, attiva',
68
69  // commit
70  messageLabel: 'Cosa hai cambiato? ',
71  messagePlaceholder: (n: number) => `(vuoto: ${plural(n, 'file', 'file')})`,
72  saveAndSwitch: 'salva e spostati',
73  saveHint: (then: boolean): string => (then ? 'Invio per salvare, poi ti sposto' : 'Invio per salvare'),
74  defaultMessage: (n: number) => `Salvataggio dal pannello (${plural(n, 'file', 'file')})`,
75
76  // leave
77  leaveWarn: (name: string, n: number) =>
78    `⚠ In ${name} hai ${plural(n, 'modifica non salvata', 'modifiche non salvate')}. Restano lì anche se ti sposti.`,
79  saveThenGo: 'Salva e spostati',
80  justGo: 'Spostati e basta',
81
82  // discard
83  discardWarn: (changes: number, ahead: number) =>
84    changes || ahead
85      ? `⚠ Perderai ${[changes && plural(changes, 'modifica non salvata', 'modifiche non salvate'), ahead && plural(ahead, 'salvataggio non portato nella principale', 'salvataggi non portati nella principale')].filter(Boolean).join(' e ')}. Sicuro?`
86      : 'Butto via questo workbranch? È già tutto nella principale.',
87  discardYes: 'Sì, butta via',
88  discardHere: 'Sei dentro questo workbranch: torna alla principale per buttarlo via.',
89
90  // merge / conflicts
91  mergeSaveFirst: 'Prima salva le modifiche di questo workbranch.',
92  mainDirty: 'La versione principale ha modifiche non salvate: salvale prima.',
93  conflictTitle: (files: number) => `⚠ ${plural(files, 'file è stato cambiato', 'file sono stati cambiati')} in modo diverso nei due punti.`,
94  askClaude: 'Chiedi a Claude di sistemarlo',
95  askPrompt: (branch: string, base: string) =>
96    `Unisci il branch ${branch} nel branch ${base} di questo progetto, nella cartella della versione principale ` +
97    `(trovala con git worktree list). Il merge darà dei conflitti: dopo averlo avviato guardali con git status, risolvili tenendo entrambe ` +
98    `le modifiche quando ha senso, spiegami le scelte e fai il commit del merge.`,
99
100  // results
101  working: {
102    init: 'Attivo la cronologia…',
103    save: 'Salvo…',
104    create: (n: string) => `Creo ${n}…`,
105    move: 'Sposto la sessione…',
106    merge: 'Porto nella principale…',
107    discard: 'Butto via…',
108    reopen: 'Riapro…',
109  },
110  done: {
111    init: (size: string) => `Cronologia attivata, primo salvataggio fatto (${size}).`,
112    tooBig: (size: string, biggest: string) =>
113      `Il primo salvataggio peserebbe ${size}. I più grandi: ${biggest}. Escludili in .gitignore e premi "Primo salvataggio".`,
114    saved: (n: number, where: string) => `Salvate ${plural(n, 'modifica', 'modifiche')} in ${where}.`,
115    nothing: 'Niente da salvare.',
116    created: (n: string) => `Workbranch ${n} creato.`,
117    moved: (where: string) => `Ora lavori in ${where}.`,
118    merged: (n: string) => `${n} è nella versione principale ✓`,
119    discarded: (n: string) => `${n} buttato via.`,
120    reopened: (n: string) => `${n} riaperto.`,
121    needName: 'Scrivi un nome, per esempio nuova-grafica.',
122    failed: (what: string) => `Non è riuscito: ${what}`,
123    notMoved: (why: string) => `Non mi sono spostato: ${why}`,
124  },
125}
126
127type Words = typeof it
128
129const en: Words = {
130  context: 'context',
131  fiveHours: '5 hours',
132  week: 'week',
133  spend: 'spend',
134  cost: 'cost',
135  tokens: 'tokens',
136  barHidden: 'Usage bar hidden.',
137  barShown: 'Usage bar shown.',
138  barCmd: 'Shows/hides the usage bar above the prompt',
139  paneCmd: 'Opens the Workbranch pane',
140  paneOpened: 'Workbranch pane opened.',
141
142  project: 'PROJECT',
143  youAreIn: 'YOU ARE IN',
144  reading: 'Reading the project…',
145  wait: 'One moment…',
146  mainName: 'Main version',
147  listTitle: 'YOUR WORKBRANCHES',
148  archivedTitle: 'PUT AWAY',
149  here: '◀ here',
150  newBtn: '+ New workbranch',
151  namePlaceholder: 'e.g. new-design',
152  nameLabel: 'Name ',
153  create: 'create',
154  createHint: (from: string) => `Enter to create and switch · starts from ${from}`,
155  cancel: 'cancel',
156  save: 'Save',
157  enter: 'Enter',
158  backToMain: 'Back to main',
159  merge: 'Bring into main',
160  discard: 'Throw away',
161  reopen: 'Reopen',
162  help: '? what is a workbranch',
163  helpText: [
164    'A workbranch is a trial copy of your project.',
165    "You work in it without touching the main version: if it goes well you bring it into main, if not you throw it away.",
166    'Save often: every save is a point you can go back to.',
167  ],
168  close: 'close',
169  changes: (n: number) => `${plural(n, 'change', 'changes')} to save`,
170  ahead: (n: number) => `${plural(n, 'save', 'saves')} to bring into main`,
171  merged: '✓ already in main',
172  clean: '✓ all saved',
173  missing: 'folder missing',
174
175  unsafeTitle: 'Workbranches are off in this project',
176  unsafeText:
177    "This project's git settings make git run programs. For safety the pane won't read it on its own: if you trust the project, use git as usual.",
178  noRepoTitle: "This project doesn't have workbranches yet",
179  noRepoText: 'To use them, turn on version history (git).',
180  insideText: (parent: string) => `Right now its versions end up with other projects, in the ${parent} folder.`,
181  emptyTitle: 'History is on, the first save is missing',
182  emptyText: 'Make the first save to start.',
183  activate: 'Turn on',
184  firstSave: 'First save',
185  confirmInit: (p: string) => `Turn on history in ${p} and make the first save?`,
186  yesActivate: 'Yes, turn on',
187
188  messageLabel: 'What did you change? ',
189  messagePlaceholder: (n: number) => `(empty: ${plural(n, 'file', 'files')})`,
190  saveAndSwitch: 'save and switch',
191  saveHint: (then: boolean): string => (then ? 'Enter to save, then I switch' : 'Enter to save'),
192  defaultMessage: (n: number) => `Saved from the pane (${plural(n, 'file', 'files')})`,
193
194  leaveWarn: (name: string, n: number) =>
195    `⚠ ${name} has ${plural(n, 'unsaved change', 'unsaved changes')}. They stay there if you switch.`,
196  saveThenGo: 'Save and switch',
197  justGo: 'Just switch',
198
199  discardWarn: (changes: number, ahead: number) =>
200    changes || ahead
201      ? `⚠ You will lose ${[changes && plural(changes, 'unsaved change', 'unsaved changes'), ahead && plural(ahead, 'save not in main', 'saves not in main')].filter(Boolean).join(' and ')}. Sure?`
202      : 'Throw this workbranch away? Everything is already in main.',
203  discardYes: 'Yes, throw away',
204  discardHere: "You're inside this workbranch: go back to main to throw it away.",
205
206  mergeSaveFirst: "Save this workbranch's changes first.",
207  mainDirty: 'The main version has unsaved changes: save them first.',
208  conflictTitle: (files: number) => `⚠ ${plural(files, 'file was', 'files were')} changed differently in the two places.`,
209  askClaude: 'Ask Claude to fix it',
210  askPrompt: (branch: string, base: string) =>
211    `Merge the branch ${branch} into ${base} in this project, in the main version's folder ` +
212    `(find it with git worktree list). The merge will conflict: once started, look at the conflicts with git status, resolve them keeping ` +
213    `both changes where it makes sense, explain your choices and commit the merge.`,
214
215  working: {
216    init: 'Turning on history…',
217    save: 'Saving…',
218    create: (n: string) => `Creating ${n}…`,
219    move: 'Moving the session…',
220    merge: 'Bringing into main…',
221    discard: 'Throwing away…',
222    reopen: 'Reopening…',
223  },
224  done: {
225    init: (size: string) => `History on, first save done (${size}).`,
226    tooBig: (size: string, biggest: string) =>
227      `The first save would weigh ${size}. Biggest: ${biggest}. Exclude them in .gitignore and press "First save".`,
228    saved: (n: number, where: string) => `Saved ${plural(n, 'change', 'changes')} in ${where}.`,
229    nothing: 'Nothing to save.',
230    created: (n: string) => `Workbranch ${n} created.`,
231    moved: (where: string) => `Now working in ${where}.`,
232    merged: (n: string) => `${n} is in the main version ✓`,
233    discarded: (n: string) => `${n} thrown away.`,
234    reopened: (n: string) => `${n} reopened.`,
235    needName: 'Type a name, for example new-design.',
236    failed: (what: string) => `It didn't work: ${what}`,
237    notMoved: (why: string) => `Didn't switch: ${why}`,
238  },
239}
240
241export const words = (lang: Lang): Words => (lang === 'en' ? en : it)
242
243export type { Words }
244
types/index.d.ts 73 lines
1export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
2
3export type Lang = 'it' | 'en'
4
5/** A workbranch: a worktree and its branch, born and removed together. */
6export type Worktree = {
7  path: string
8  branch: string | null
9  head: string
10  isMain: boolean
11  isCurrent: boolean
12  isLocked: boolean
13  isPrunable: boolean
14  /** Files changed and not yet committed. */
15  changes: number
16  /** Commits on this branch that the main branch does not have yet. */
17  ahead: number
18}
19
20/** A branch with no folder: a workbranch put away. */
21export type Archived = { branch: string; ahead: number }
22
23/** The session's project and its workbranches. */
24export type WorktreeView = {
25  project: string
26  /**
27   * `own`: its own repository; `inside`: falls into a parent folder's; `none`: no git; `empty`: no commit yet;
28   * `unsafe`: the repository's own settings make git run programs, so the pane leaves it alone.
29   */
30  repo: 'own' | 'inside' | 'none' | 'empty' | 'unsafe'
31  parentRepo: string | null
32  base: string | null
33  list: Worktree[]
34  archived: Archived[]
35  error: string | null
36}
37
38/** What the pane shows: the list, a confirmation, a text field, the help. */
39export type WtUi = {
40  mode:
41    | 'list'
42    | 'help'
43    | 'confirm-init'
44    | 'new'
45    | 'commit'
46    | 'confirm-leave'
47    | 'confirm-discard'
48    | 'conflict'
49  target: string | null
50  /** After the commit, move the session into this worktree ("save and switch"). */
51  then: string | null
52  busy: boolean
53  message: string | null
54  isError: boolean
55  /** Files in conflict after a failed merge, for "ask Claude". */
56  conflicts: string[]
57}
58
59export type Prefs = { lang: Lang }
60
61declare module 'claude-code' {
62  interface PluginState {
63    'supermod-claudecode': {
64      tokens: Tokens
65      tick: number
66      bandHidden: boolean
67      worktrees: WorktreeView | null
68      wtUi: WtUi
69      prefs: Prefs
70    }
71  }
72}
73