แถบเหนือ prompt เตือน repo ที่ session นี้แตะแล้วยังไม่ commit / ยังไม่ push พร้อมปุ่ม commit และ push

One long session of building Claude Code mods on Windows 11, written up honestly: what shipped, what I tried and threw away, and why. Most mod repos only show the finished screenshot. This one also keeps the failures, because they say more about the mod API than the wins do.
Status: experimental, day one. Built on Claude Code 2.1.291 and still loading on 2.1.292. Tested on one machine: Windows 11, Windows Terminal 1.24, Cascadia Code, a cream background (
#F2F0DA), 134 columns, fullscreen mode. The mod API is new and may change under you. Issues welcome.
| Piece | Status | One-line lesson |
|---|---|---|
| fuel-bar | in daily use | The engine gives you context, auto-compact threshold and rate limits for free after every turn. No polling, no tokens. |
| thai-mode | in daily use | You can re-render tool rows, groups, spinner and turn duration. You cannot touch the prompt box. |
| task-band | in daily use | The engine tells a mod when an agent ends right away, but shell/monitor/workflow ends only reach it at the next tool call while Claude is busy. And there is no progress % for anything. |
| paste-peek | new, day three | The mod API can draw images only through the kitty protocol, which Windows Terminal lacks. A tiny native window beside the terminal does the job, and Claude Code already saves each pasted image to disk before you send. |
| git-band | new, day four | Two bands in AbovePrompt hide each other unless each one awaits next(e) and stacks the result. The engine has no git events, so the mod works out which repos the session touched from the tool calls. |
| Matrix intro | in daily use | A sequential intro always leaves a blank gap. Run it in parallel and stop on a file Claude writes before its first frame. |
| Safe updater | in daily use | Auto-update on Windows can leave claude.exe as a 500-byte stub. Stage, verify, then rename-and-swap. |
| Right-side pane + widgets | dropped | The dock frame belongs to the engine, and Thai text breaks in the Windows Terminal grid with every font I tried. |
| project-band above the prompt | dropped | Engine notifications render between your band and the prompt, and you cannot intercept them. |
| matrix-rain as a mod | dropped | session.start fires after the first frame. Too late for a loading screen. |
| Rounded prompt box | not possible | The prompt input is not a render site. |
| Context Router, prismantis | declined | Already built in / clashes with thai-mode and adds tokens per prompt. |

Two lines under the prompt:
T77 │ Opus 5.5 · medium │ ━━━━━━──── 53.7% 519.3k/1M │ 5h 26.0% (1h57m) · 7D 47.0% (3d) turn · model · effort · gauge to the auto-compact point (not to the raw window) · 5-hour and 7-day quota with time to reset.PROJECT · ✦ 24.2° night rain 39% · worked 4:32 hr, plus the latest mod toast on the right for 8 seconds.Why I built it: I was using ccusage statusline, and on a 1M-context model it showed context above 100% (147% on my screen). The engine already knows the right numbers.
How it works:
session.measure fires after every turn with context.tokens, context.window and rateLimits[] (five_hour, seven_day, each with percentUsed and resetsAt). session.usage({ breakdown: 'summary' }) adds autoCompactThreshold. Zero tokens, no process spawned.turn.step carries the current effort.PromptHint render site. If you return two lines, the engine keeps its own mode pill (⏵⏵ auto mode on) on the first line and indents the second by the pill's width. I tried drawing my own pill. The engine's pill stays anyway, so I gave up and kept theirs.ipwho.is for a rough location, then api.open-meteo.com. No keys, cached 30 min. This sends your IP to ipwho.is. Delete loadWeather if you don't want that.ui.toast can be intercepted (return { value: undefined }), so mod toasts land at the end of line 2 instead of popping over the transcript.dependencies: ["thai-mode"] in plugin.json lets fuel-bar read thai-mode's state and translate the hint itself. Two mods never fight over the same render site.Colors are hand-picked for a light cream background. On a dark theme you'll want to change the C palette in register.tsx.

Rewrites, with a built-in dictionary (no model calls):
ToolUse): Read → อ่านไฟล์, Bash → รันคำสั่ง, and MCP tools as service · Thai verbToolResult): "read 120 lines (40–159 of 300)", "added 4 · removed 1 lines", with the diff still drawn by the engine's Code componentToolGroup): กำลังเขียน 1 ไฟล์ · แก้ 1 ไฟล์ · รัน 1 คำสั่ง…requesting / thinking / responding / tool-input / tool-use)ctrl+b hint/thai toggles it, and the choice persists in $.store.
Things I learned:
Edit gets folded into a ToolGroup, so your nice single-row rendering mostly shows up only in ctrl+o.
A framed band above the prompt (AbovePrompt) titled "งานเบื้องหลัง" (background work). Each running subagent, Workflow, background shell and Monitor gets one chip with its own gauge, 3 per page:
What the API gives you (build 2.1.292):
agent.spawn answers with an agentId. Its end is turn.complete carrying that agentId, and it arrives immediately. Workflow agents come through agent.spawn too, with e.workflow.runId.backgroundTaskId. Monitor and Workflow results carry taskId (Workflow also runId and workflowName).prompt.submit with origin.kind === 'task-notification', with <task-id> and <status> in the text. When Claude is idle that is instant. While a turn runs it waits for the next tool-call boundary (I measured 4.3 s late).await next(e).Things that bit me:
Box takes borderStyle but has no border title. Laying a position: "absolute" title over the border line shifted the whole screen sideways and left it garbled until a resize forced a full redraw. The frame is now three plain Text rows sized to bodyColumns (the engine draws [-] at the far right of the band).TaskStop now ends its chip right away (marked ✗) instead of spinning until the stop notice arrives. The band also renders what other mods put in AbovePrompt first, see git-band below.A one-line band above the prompt, shown only while Claude is idle, listing every repo this session touched that still has work to save:
git ค้าง my-app ●3 [ commit ] │ notes ↑1 [ push ]
●3 = three changed files, ↑1 = one commit not on the remote yet.git push from the mod itself, no tokens. It shows ⠹ กำลัง push, then ✓ push แล้ว for 5 seconds. On failure it shows a short reason (needs pull / login / network) and a [ ให้ Claude แก้ ] button that hands the full git error to Claude.logic.ts and register.tsx if you want them in English.How it decides which repos to watch:
Edit / Write / NotebookEdit.cd, Set-Location, pushd or git -C inside a Bash or PowerShell command.git rev-parse --show-toplevel and cached. The band refreshes on turn.complete for the main agent only, so a subagent finishing doesn't make it flicker.Things that bit me:
AbovePrompt handler returns its own element without calling next(e), every band from another mod disappears. Await next(e) and put the result above or below yours. task-band was fixed for the same reason.git push can pick the wrong one and fail with 403. If the repo has no credential.username set, the mod adds -c credential.username=<owner from the remote URL> for that one push.↑N: the mod counts commits not on any remote (rev-list HEAD --not --remotes) and pushes with -u.
Windows only. Experimental, day one. Claude Code shows a pasted image as [Image #1] and nothing else, so you can't tell whether you attached the right screenshot until Claude answers. paste-peek opens a small strip in the bottom-right corner of your Windows Terminal window with a thumbnail of every [Image #N] in your draft:
[Image #2] from the draft and #2 leaves the strip. Send the message and the strip closes.Alt+V pastes and for files dragged onto the terminal.Why a separate window: the mod API has an Image element, but the engine draws it only through the kitty graphics protocol, which Windows Terminal doesn't speak, so you just get the alt text. I also tried a Raster of half-block characters (▀, two pixels per cell). It works everywhere, but a 6-row thumbnail is about 21×12 pixels, which is too blurry to tell screenshots apart.
How it works:
$.prompt.read()) every 250 ms and writes the list of [Image #N] numbers to a small state file in %TEMP%.peek.exe (C#, WinForms, about 475 lines in native/peek.cs). The exe watches the state file with a FileSystemWatcher and closes itself when the file goes empty, which the mod does on submit.%TEMP%\claude\<project-slug>\<session-id>\images\<N>.<ext> as soon as you paste or drop it, before you send. This is undocumented and may change in a future version. Claude Code skips this when transcript saving is off (for example a session started from inside another Claude session, which shows a "Transcript saving is off" notice), and the strip then says "no preview". My first build read the clipboard instead, and a dragged file then showed whatever image was last copied.EVENT_SYSTEM_FOREGROUND and location changes. No polling.Measured on my laptop: about 24 MB private memory and 0% CPU while the strip is open, nothing at all while there are no images, and the strip appears in about 0.2 s.
No binary in this repo. On first use the mod compiles native/peek.cs with the C# compiler that ships with Windows (%WINDIR%\Microsoft.NET\Framework64\v4.0.30319\csc.exe, .NET Framework 4.x) into %LOCALAPPDATA%\paste-peek\. That takes about a second, once.
Options (/config): label language (en / th), theme (dark / light), and the distance from the window's bottom and right edges. The default bottom distance clears a two-line footer. Raise it if the strip covers your prompt.
Tested end to end in a fresh session: first-use compile, dark theme, English labels, a pasted image showing in the strip. Not handled yet: more than one terminal tab in the same window (the strip follows the window, not the tab), and terminals other than Windows Terminal.

Katakana rain in ink-on-cream with the folder name decoding in the middle, shown while Claude Code loads (about 4.7 s on this laptop). It is not a mod. It is a tiny C# exe started by the launcher.
The path there was three failures:
claude. However long the intro runs, you still get a blank or log-only screen between the intro ending and Claude's first frame.session.start fires after Claude has drawn its first screen. The rain lands on top of Claude, and in fullscreen mode Ctrl+L does not clear it.What works:
claude-launcher.cmd does start "" /b matrix-intro.exe and then call claude at the same moment. The rain shows within ~0.7 s.numStartups in <CLAUDE_CONFIG_DIR>\.claude.json every 120 ms. Claude bumps it 0.4–0.7 s before drawing its first frame, which is the "ready" signal. Hard cap: 20 s.cls, but only if the intro actually ran (it drops a marker file).-p, --version, --help and subcommands. CLAUDE_MATRIX_INTRO=0 disables it.Batch trap: claude installed by npm is claude.cmd. If you write claude %* instead of call claude %* in a .cmd, nothing after that line ever runs.
Half-width katakana take exactly one cell in Windows Terminal with Cascadia Code, so the columns line up.
Build it with the C# compiler that ships with Windows:
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe /nologo /optimize+ /codepage:65001 windows\matrix-intro.cs
On this machine, Claude Code's own auto-update repeatedly left node_modules\@anthropic-ai\claude-code\bin\claude.exe as a ~500-byte stub ("This version of claude.exe is not compatible…"), or failed with "claude.exe in use" while another session was open. Its warning also showed up in the middle of the screen:

windows/claude-update-safe.ps1:
npm view @anthropic-ai/claude-code version.--version.claude.exe.old-<timestamp> (Windows allows renaming a running exe, not overwriting it), then moves the new one in. Open sessions keep running the old file. New sessions get the new one..old-* files on the next run, and logs to %LOCALAPPDATA%\claude-update-safe\claude-update.log.Set "env": { "DISABLE_AUTOUPDATER": "1" } in settings.json so the built-in updater stops fighting it. matrix-intro.exe launches the script hidden at every start if it sits in the same folder.
This only applies to the global npm install. The native installer manages its own versioned folders.
Right-side pane with widgets (project name, a 3-tab reminder list, weather, other open sessions). Inspired by paneline and flightdeck. It worked technically (Pane render site with fullscreen: true), but:
เ ปิด). I tried Noto Sans Thai, Leelawadee, Tahoma, Tlwg Mono and Tlwg Typo. None fixed it. This is a WT grid limit, not a font problem.viewport.columns in PromptHint is the transcript width, not the full window./pane.project-band above the prompt (AbovePrompt, one line: project · weather · time worked). It looked good until Claude Code showed its own notice (Auto-update failed …). Engine notices render between AbovePrompt and the prompt, and they are standing warnings, not ui.toast, so a mod can't catch them. I merged the band into fuel-bar's second line instead.
matrix-rain as a mod. See the intro section: session.start is too late.
Rounded prompt box. I mocked it in a browser first. That was a mistake, because the prompt input is not one of the render sites, so a mod can't change it. A custom theme can recolor the border (promptBorder) but not reshape it.
CLAUDE.md from parent folders, and @path imports work.ToolUse, ToolGroup and TurnDuration (the same sites as thai-mode), and its diagramHints adds about 190 tokens to every prompt.The HUD in one popular mod's demo video (LIFE / LEVEL / XP bars across the top) is rendered with Revideo in its demo/ folder. The README says as much: "Nothing in it is captured". It isn't something the mod draws in your terminal. Before you promise someone a feature you saw in a GIF, grep the mod's hooks/ folder.
Mockups in a browser lie about fonts, colors and cell widths. What I used instead:
wt -w new <file.cmd>. Start-Process with arguments quotes the whole command line and fails with 0x80070002.CASCADIA_HOSTING_WINDOW_CLASS. Capture it with PrintWindow(hwnd, dc, 2) after SetProcessDPIAware(). That works even when the window is behind another one. CopyFromScreen only sees what's visible.SendInput + KEYEVENTF_UNICODE. SendKeys follows the active keyboard layout: with Thai active, /exit arrives as /59.claude plugin marketplace add <path-to-clone>\mods
claude plugin install fuel-bar@field-notes-mods --scope user
claude plugin install thai-mode@field-notes-mods --scope user
claude plugin install task-band@field-notes-mods --scope user
claude plugin install paste-peek@field-notes-mods --scope user
claude plugin install git-band@field-notes-mods --scope user
Plugins are read from that folder in place: edit a file, then run /reload-plugins. claude plugin test <folder> runs the tests (fuel-bar 7, thai-mode 5, task-band 14, paste-peek 4, git-band 9).
For the intro: compile windows/matrix-intro.cs, then put matrix-intro.exe, claude-update-safe.ps1 and claude-launcher.cmd in one folder on your PATH.
บันทึกการทำ mod ให้ Claude Code บน Windows ในหนึ่งวัน ทั้งชิ้นที่ใช้จริงและชิ้นที่ลองแล้วทิ้ง
/thai ใช้สลับเปิด/ปิด[Image #N] ที่วางหรือลากมาไว้ในข้อความ ก่อนกดส่ง คลิกเพื่อขยาย ลบ [Image #N] แล้วรูปหายจากกรอบ กดส่งแล้วกรอบปิดเอง ซ่อนเมื่อสลับไปหน้าต่างอื่น ใช้ RAM ประมาณ 24 MB เฉพาะตอนมีรูปแนบ ไม่แนบไฟล์ exe ไว้ใน repo แต่จะคอมไพล์จากโค้ดต้นฉบับตอนใช้ครั้งแรกAbovePrompt หลายตัวต้อง await next(e) แล้ววางซ้อนกัน ไม่งั้นบังกันเองnumStartups เพิ่ม ถ้ารันเรียงกันจะมีจอว่างเสมอclaude.exe เหลือไฟล์ 500 ไบต์ ใช้วิธีติดตั้งแยกไว้ก่อน ตรวจว่าใช้ได้ แล้วค่อยเปลี่ยนชื่อสลับไฟล์session.start มาช้ากว่าจอแรกถ้าใช้ Windows Terminal กับภาษาไทย ให้เตรียมใจไว้ว่าสระบนและวรรณยุกต์จะกินช่องของตัวเอง ฟอนต์แก้ไม่ได้
MIT
hooks/register.tsx 239 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { RepoState } from '../types'
5import { C, cellWidth, commandDirs, commitPrompt, fit, fixPushPrompt, githubAccount, isPending, norm, parentDir, parseStatus, shortReason } from './logic'
6
7const repos = atom({ plugin: 'git-band', key: 'repos' } as const, [] as RepoState[])
8const roots = atom({ plugin: 'git-band', key: 'roots' } as const, [] as string[])
9const edited = atom({ plugin: 'git-band', key: 'edited' } as const, [] as string[])
10const busy = atom({ plugin: 'git-band', key: 'busy' } as const, false)
11
12const LEAD = 'git ค้าง '
13const rootOf = new Map<string, string | null>()
14let refreshing: Promise<void> | null = null
15
16async function git($: EngineInterface, root: string, args: string[], timeoutMs = 15000) {
17 return $.process.run(['git', '-C', root, ...args], { timeoutMs })
18}
19
20async function findRoot($: EngineInterface, dir: string): Promise<string | null> {
21 const key = norm(dir)
22 if (rootOf.has(key)) return rootOf.get(key) ?? null
23 let root: string | null = null
24 try {
25 const r = await $.process.run(['git', '-C', dir, 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 })
26 if (r.exitCode === 0) root = r.stdout.trim() || null
27 } catch {
28 root = null
29 }
30 rootOf.set(key, root)
31 return root
32}
33
34async function addDir($: EngineInterface, dir: string) {
35 const root = await findRoot($, dir)
36 if (!root) return
37 await update($, roots, list => (list.some(r => norm(r) === norm(root)) ? list : [...list, root]))
38}
39
40async function scan($: EngineInterface, root: string, prev: RepoState | undefined, mineAbs: string[]): Promise<RepoState | null> {
41 let st
42 try {
43 const r = await git($, root, ['status', '--porcelain=v1', '-b', '-uall'])
44 if (r.exitCode !== 0) return null
45 st = parseStatus(r.stdout)
46 } catch {
47 return null
48 }
49 let ahead = st.ahead
50 if (!st.upstream) {
51 try {
52 const remotes = await git($, root, ['remote'])
53 if (remotes.stdout.trim()) {
54 const c = await git($, root, ['rev-list', '--count', 'HEAD', '--not', '--remotes'])
55 ahead = c.exitCode === 0 ? Number(c.stdout.trim()) || 0 : 0
56 } else ahead = 0
57 } catch {
58 ahead = 0
59 }
60 }
61 const base = norm(root)
62 const mine = st.files.filter(f => mineAbs.includes(`${base}/${norm(f)}`))
63 const name = root.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? root
64 const keep = prev?.phase === 'fail' || prev?.phase === 'pushing' || prev?.phase === 'done' ? prev : undefined
65 return {
66 root,
67 name,
68 branch: st.branch,
69 dirty: st.files.length,
70 files: st.files,
71 mine,
72 ahead,
73 phase: keep?.phase,
74 reason: keep?.reason,
75 error: keep?.error,
76 }
77}
78
79async function refresh($: EngineInterface) {
80 if (refreshing) return refreshing
81 refreshing = (async () => {
82 const [list, prev, mineAbs] = await Promise.all([read($, roots), read($, repos), read($, edited)])
83 const out: RepoState[] = []
84 for (const root of list ?? []) {
85 const s = await scan($, root, (prev ?? []).find(p => p.root === root), mineAbs ?? [])
86 if (s) out.push(s)
87 }
88 await update($, repos, () => out)
89 })()
90 try {
91 await refreshing
92 } finally {
93 refreshing = null
94 }
95}
96
97async function setPhase($: EngineInterface, root: string, patch: Partial<RepoState>) {
98 await update($, repos, list => list.map(r => (r.root === root ? { ...r, ...patch } : r)))
99}
100
101async function push($: EngineInterface, r: RepoState) {
102 await setPhase($, r.root, { phase: 'pushing', reason: undefined, error: undefined })
103 let args = ['push']
104 try {
105 let remote: string | undefined
106 const up = await git($, r.root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])
107 if (up.exitCode !== 0) {
108 const remotes = (await git($, r.root, ['remote'])).stdout.split(/\r?\n/).filter(Boolean)
109 remote = remotes.includes('origin') ? 'origin' : remotes[0]
110 if (!remote) {
111 await setPhase($, r.root, { phase: 'fail', reason: 'ไม่มี remote', error: 'repo นี้ยังไม่มี remote ให้ push' })
112 return
113 }
114 args = ['push', '-u', remote, r.branch]
115 } else {
116 remote = up.stdout.trim().split('/')[0]
117 }
118 const pinned = await git($, r.root, ['config', 'credential.username'])
119 if (!pinned.stdout.trim() && remote) {
120 const url = await git($, r.root, ['remote', 'get-url', '--push', remote])
121 const account = githubAccount(url.stdout)
122 if (account) args = ['-c', `credential.username=${account}`, ...args]
123 }
124 const res = await git($, r.root, args, 120000)
125 if (res.exitCode === 0) {
126 await setPhase($, r.root, { phase: 'done' })
127 $.clock.after(5000, async () => {
128 await setPhase($, r.root, { phase: undefined })
129 await refresh($)
130 })
131 } else {
132 await setPhase($, r.root, { phase: 'fail', reason: shortReason(res.stderr), error: res.stderr })
133 }
134 } catch (err) {
135 await setPhase($, r.root, { phase: 'fail', reason: 'git ไม่ตอบ', error: String(err) })
136 }
137}
138
139async function commit($: EngineInterface, r: RepoState) {
140 await setPhase($, r.root, { phase: 'queued' })
141 await $.prompt.submit({ text: commitPrompt(r) })
142}
143
144async function askFix($: EngineInterface, r: RepoState) {
145 await setPhase($, r.root, { phase: undefined, reason: undefined })
146 await $.prompt.submit({ text: fixPushPrompt(r) })
147}
148
149export const register: Register = on => {
150 on('session.start', async ($, e, next) => {
151 const started = await next(e)
152 await update($, busy, () => false)
153 await addDir($, await $.session.cwd())
154 await refresh($)
155 return started
156 })
157
158 on('tool.call', async ($, e, next) => {
159 const result = await next(e)
160 if (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit') {
161 const file = 'file_path' in e ? (e.file_path as string) : 'notebook_path' in e ? (e.notebook_path as string) : ''
162 if (file) {
163 await update($, edited, list => (list.includes(norm(file)) ? list : [...list, norm(file)]))
164 await addDir($, parentDir(file))
165 }
166 } else if (e.tool === 'Bash' || e.tool === 'PowerShell') {
167 const command = 'command' in e ? (e.command as string) : ''
168 for (const d of commandDirs(command)) await addDir($, d)
169 }
170 return result
171 }).catch(($, e, next) => next(e))
172
173 on('turn.start', async ($, e, next) => {
174 await update($, busy, () => true)
175 return next(e)
176 })
177
178 on('turn.complete', async ($, e, next) => {
179 const done = await next(e)
180 if (e.agentId === undefined) {
181 await update($, repos, list => list.map(r => (r.phase === 'queued' ? { ...r, phase: undefined } : r)))
182 await refresh($)
183 await update($, busy, () => false)
184 }
185 return done
186 })
187
188 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
189 const [list, isBusy] = await Promise.all([read($, repos), read($, busy)])
190 const pending = (list ?? []).filter(isPending)
191 if (e.props.hasSurvey || isBusy || pending.length === 0) return next(e)
192
193 const below = await next(e)
194 const { Box, Button, Text } = $.ui.resolve(e)
195 const { shown, hidden } = fit(pending, e.props.bodyColumns - 1, cellWidth(LEAD) + 1)
196
197 return (
198 <Box flexDirection="column">
199 <Text color={C.faint}>{'╌'.repeat(Math.max(0, e.props.bodyColumns - 1))}</Text>
200 <Box flexDirection="row">
201 <Text color={C.dim}> {LEAD}</Text>
202 {shown.map((r, i) => (
203 <Box key={r.root} flexDirection="row">
204 {i > 0 ? <Text color={C.faint}> │ </Text> : null}
205 <Text color={C.fg}>{r.name} </Text>
206 {r.phase === 'pushing' ? (
207 <Text color={C.blue}>⠹ กำลัง push</Text>
208 ) : r.phase === 'done' ? (
209 <Text color={C.green}>✓ push แล้ว</Text>
210 ) : r.phase === 'fail' ? (
211 <Box flexDirection="row">
212 <Text color={C.red}>✗ push ไม่ผ่าน {r.reason ?? ''} </Text>
213 <Button key={`fix:${r.root}`} label="ให้ Claude แก้" onPress={() => askFix($, r)} />
214 </Box>
215 ) : (
216 <Box flexDirection="row">
217 {r.dirty > 0 ? <Text color={C.amber}>●{r.dirty} </Text> : null}
218 {r.ahead > 0 ? <Text color={C.amber}>↑{r.ahead} </Text> : null}
219 {r.phase === 'queued' ? (
220 <Text color={C.dim}>◷ รอ Claude commit</Text>
221 ) : (
222 <Box flexDirection="row">
223 {r.dirty > 0 ? <Button key={`commit:${r.root}`} label="commit" onPress={() => commit($, r)} /> : null}
224 {r.dirty > 0 && r.ahead > 0 ? <Text> </Text> : null}
225 {r.ahead > 0 ? <Button key={`push:${r.root}`} label="push" onPress={() => push($, r)} /> : null}
226 </Box>
227 )}
228 </Box>
229 )}
230 </Box>
231 ))}
232 {hidden > 0 ? <Text color={C.dim}> │ +{hidden}</Text> : null}
233 </Box>
234 {below}
235 </Box>
236 )
237 })
238}
239hooks/logic.ts 156 lines1import type { RepoState } from '../types'
2
3export const C = {
4 fg: '#2d2a24',
5 dim: '#6b675e',
6 faint: '#b9b5a3',
7 green: '#3f8a3a',
8 amber: '#a06c00',
9 red: '#b8322b',
10 blue: '#2f5fd0',
11}
12
13const THAI_ZERO = /[ัิ-ฺ็-๎]/u
14
15export function cellWidth(s: string): number {
16 let n = 0
17 for (const ch of s) if (!THAI_ZERO.test(ch)) n++
18 return n
19}
20
21export function norm(p: string): string {
22 let s = p.replace(/\\/g, '/').replace(/\/+$/, '')
23 const msys = s.match(/^\/([a-zA-Z])\/(.*)$/)
24 if (msys) s = `${msys[1]}:/${msys[2]}`
25 return s.toLowerCase()
26}
27
28export function parentDir(p: string): string {
29 const s = p.replace(/\\/g, '/').replace(/\/+$/, '')
30 const i = s.lastIndexOf('/')
31 return i > 0 ? s.slice(0, i) : s
32}
33
34export type Status = {
35 branch: string
36 upstream?: string
37 ahead: number
38 files: string[]
39}
40
41export function parseStatus(out: string): Status {
42 const lines = out.split(/\r?\n/).filter(l => l.length > 0)
43 let branch = ''
44 let upstream: string | undefined
45 let ahead = 0
46 const files: string[] = []
47 for (const l of lines) {
48 if (l.startsWith('## ')) {
49 const head = l.slice(3)
50 const m = head.match(/^(.+?)(?:\.\.\.(\S+))?(?: \[(.*)\])?$/)
51 branch = m?.[1] ?? head
52 if (branch.startsWith('No commits yet on ')) branch = branch.slice(18)
53 upstream = m?.[2]
54 const a = (m?.[3] ?? '').match(/ahead (\d+)/)
55 ahead = a ? Number(a[1]) : 0
56 continue
57 }
58 let f = l.slice(3)
59 const arrow = f.indexOf(' -> ')
60 if (arrow >= 0) f = f.slice(arrow + 4)
61 if (f.startsWith('"') && f.endsWith('"')) f = f.slice(1, -1)
62 files.push(f)
63 }
64 return { branch, upstream, ahead, files }
65}
66
67const CD = /(?:^|[;&|(]\s*)(?:cd|Set-Location|pushd|sl)\s+(?:-LiteralPath\s+|-Path\s+|\/d\s+)?("[^"]+"|'[^']+'|[^\s;&|)]+)/gi
68const GIT_C = /\bgit\s+-C\s+("[^"]+"|'[^']+'|[^\s;&|)]+)/gi
69
70function unquote(s: string): string {
71 return s.replace(/^["']|["']$/g, '')
72}
73
74export function commandDirs(command: string): string[] {
75 const out: string[] = []
76 for (const m of command.matchAll(CD)) out.push(unquote(m[1] ?? ''))
77 for (const m of command.matchAll(GIT_C)) out.push(unquote(m[1] ?? ''))
78 return out.filter(d => /^([a-zA-Z]:[\\/]|\/[a-zA-Z]\/)/.test(d))
79}
80
81export function shortReason(stderr: string): string {
82 const lines = stderr.split(/\r?\n/).map(l => l.trim()).filter(Boolean)
83 const hit =
84 lines.find(l => /rejected|non-fast-forward|fetch first/i.test(l)) ??
85 lines.find(l => /^(fatal|error|remote):/i.test(l)) ??
86 lines[lines.length - 1] ??
87 'ไม่ทราบสาเหตุ'
88 if (/fetch first|non-fast-forward|rejected/i.test(hit)) return 'ต้อง pull ก่อน'
89 if (/could not read|authentication|permission denied|403/i.test(hit)) return 'สิทธิ์/login ไม่ผ่าน'
90 if (/could not resolve host|unable to access/i.test(hit)) return 'ต่อเน็ตไม่ได้'
91 return hit.replace(/^(fatal|error|remote):\s*/i, '').slice(0, 40)
92}
93
94export function isPending(r: RepoState): boolean {
95 return r.dirty > 0 || r.ahead > 0 || r.phase !== undefined
96}
97
98export function chipWidth(r: RepoState): number {
99 let n = cellWidth(r.name) + 1
100 if (r.phase === 'pushing') return n + cellWidth('⠹ กำลัง push')
101 if (r.phase === 'done') return n + cellWidth('✓ push แล้ว')
102 if (r.phase === 'fail') return n + cellWidth(`✗ push ไม่ผ่าน ${r.reason ?? ''} `) + cellWidth('[ ให้ Claude แก้ ]')
103 if (r.dirty > 0) n += cellWidth(`●${r.dirty}`) + 1
104 if (r.ahead > 0) n += cellWidth(`↑${r.ahead}`) + 1
105 if (r.phase === 'queued') return n + cellWidth('◷ รอ Claude commit')
106 if (r.dirty > 0) n += cellWidth('[ commit ]') + 1
107 if (r.ahead > 0) n += cellWidth('[ push ]')
108 return n
109}
110
111export function fit(list: RepoState[], columns: number, lead: number): { shown: RepoState[]; hidden: number } {
112 const shown: RepoState[] = []
113 let used = lead
114 for (let i = 0; i < list.length; i++) {
115 const r = list[i]!
116 const w = chipWidth(r) + (shown.length > 0 ? 3 : 0)
117 const rest = list.length - i - 1
118 const tail = rest > 0 ? cellWidth(` │ +${rest}`) : 0
119 if (used + w + tail > columns && shown.length > 0) return { shown, hidden: list.length - shown.length }
120 shown.push(r)
121 used += w
122 }
123 return { shown, hidden: 0 }
124}
125
126export function commitPrompt(r: RepoState): string {
127 const mine = r.mine
128 const others = r.files.filter(f => !mine.includes(f))
129 const lines = [
130 `[git-band] founder กดปุ่ม commit เอง = ต้องการให้ commit งานที่ค้างทั้งหมดใน repo ${r.root} (branch ${r.branch}) แม้บางไฟล์จะมาจาก session อื่น`,
131 ]
132 if (mine.length > 0) lines.push(`ไฟล์ที่ session นี้แก้: ${mine.join(', ')}`)
133 if (others.length > 0) lines.push(`${mine.length > 0 ? 'ไฟล์อื่นที่ค้าง' : 'ไฟล์ที่ค้าง'} (งานจาก session อื่นหรือแก้มือ): ${others.join(', ')}`)
134 lines.push(
135 'ทำ: อ่าน git diff ก่อน · แยกเป็นหลาย commit ตามเรื่องของงาน (งานของ session นี้เป็น commit แยก) · เขียนข้อความ commit ให้ตรงงาน · ใช้ git identity ที่ตั้งไว้ใน repo',
136 'ข้ามเฉพาะไฟล์ที่มีค่าลับ ไฟล์ชั่วคราว/ขยะ หรือโค้ดที่เห็นชัดว่าเสียครึ่งทาง แล้วบอก founder ว่าข้ามไฟล์ไหนเพราะอะไร · อย่าปฏิเสธเพียงเพราะไม่ใช่งานของ session นี้',
137 'ยังไม่ต้อง push',
138 )
139 return lines.join('\n')
140}
141
142export function githubAccount(url: string): string | null {
143 const m = url.trim().match(/^https:\/\/([^@/]+@)?github\.com\/([^/]+)\//i)
144 if (!m || m[1]) return null
145 return m[2]
146}
147
148export function fixPushPrompt(r: RepoState): string {
149 return [
150 `[git-band] git push ใน repo ${r.root} (branch ${r.branch}) ไม่ผ่าน`,
151 'ข้อความจาก git:',
152 (r.error ?? '').slice(0, 1500),
153 'แก้ให้ push ได้ (เช่น pull --rebase ถ้าปลอดภัย) · ถ้าต้องตัดสินใจอะไรที่เสี่ยงให้ถามก่อน',
154 ].join('\n')
155}
156types/index.d.ts 21 lines1export type RepoPhase = 'queued' | 'pushing' | 'done' | 'fail'
2
3export type RepoState = {
4 root: string
5 name: string
6 branch: string
7 dirty: number
8 files: string[]
9 mine: string[]
10 ahead: number
11 phase?: RepoPhase
12 reason?: string
13 error?: string
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 'git-band': { repos: RepoState[]; roots: string[]; edited: string[]; busy: boolean }
19 }
20}
21