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…

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.
Un workbranch è una copia di prova del tuo progetto. Ci lavori, tu o Claude, senza toccare la versione principale:
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:
| Prima | Con i workbranch |
|---|---|
| Claude modifica direttamente il progetto vero | Claude lavora su una copia: la versione principale è al sicuro |
| Per provare due idee devi farle una dopo l'altra | Ogni idea ha il suo workbranch, anche in parallelo |
| Ti perdi tra cartelle, branch e worktree | Il pannello ti dice sempre dove sei e cosa c'è da fare |
| Worktree e branch sono due cose separate da gestire | Un workbranch è una cosa sola: nasce, si salva, si porta nella principale o si butta |
| Comandi git da ricordare | Pulsanti: Salva, Porta nella principale, Butta via |
| Un conflitto di merge è un muro | Chiedi a Claude di sistemarlo: un clic e Claude risolve |
| Puoi perdere lavoro rimuovendo la cartella sbagliata | Ti avvisa prima se perderesti modifiche o salvataggi |
| Pulsante | Cosa fa |
|---|---|
| + Nuovo workbranch | scrivi un nome: crea la copia e ci entra subito |
| Entra / Torna alla principale | sposta la sessione di Claude Code |
| Salva | salva le modifiche con un messaggio tuo |
| Porta nella principale | unisce la prova alla versione principale |
| Butta via | elimina la prova, chiedendo conferma se perderesti qualcosa |
| Riapri | riprende 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.
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.
L'interfaccia è in italiano o inglese e segue la lingua impostata in Claude Code. Se non ne hai impostata una, segue quella del sistema.
Dentro Claude Code:
/plugin marketplace add nayde8824/supermod-claudecode
/plugin install supermod-claudecode@supermod-claudecode
Poi apri una nuova sessione.
| Comando | Cosa fa |
|---|---|
/workbranch | apre il pannello Workbranch |
/usage-bar | mostra o nasconde la riga dell'utilizzo |
git installato.A workbranch is a trial copy of your project. You, or Claude, work in it without touching the main version:
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:
| Before | With workbranches |
|---|---|
| Claude edits the real project | Claude works on a copy: the main version is safe |
| Two ideas have to be tried one after the other | Every idea gets its own workbranch, even in parallel |
| You get lost between folders, branches and worktrees | The pane always tells you where you are and what's left to do |
| Worktree and branch are two things to manage | A workbranch is one thing: it is created, saved, brought into main or thrown away |
| Git commands to remember | Buttons: Save, Bring into main, Throw away |
| A merge conflict is a wall | Ask Claude to fix it: one click and Claude resolves it |
| You can lose work by removing the wrong folder | It warns you first if you would lose changes or saves |
| Button | What it does |
|---|---|
| + New workbranch | type a name: creates the copy and switches into it |
| Enter / Back to main | moves the Claude Code session |
| Save | saves your changes with your own message |
| Bring into main | merges the trial into the main version |
| Throw away | deletes the trial, asking first if you would lose anything |
| Reopen | brings 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.
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.
The interface is in English or Italian and follows the language set in Claude Code. With none set, it follows the system's.
Inside Claude Code:
/plugin marketplace add nayde8824/supermod-claudecode
/plugin install supermod-claudecode@supermod-claudecode
Then start a new session.
| Command | What it does |
|---|---|
/workbranch | opens the Workbranch pane |
/usage-bar | shows or hides the usage bar |
git.MIT © 2026 Valerio Monteforte
hooks/register.tsx 900 lines1import { 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}
900hooks/i18n.ts 244 lines1import 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 }
244types/index.d.ts 73 lines1export 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