SLOPSHOPPER

fuel-bar

Two-line footer under the prompt: turn, model, effort, context gauge to auto-compact, 5h/7D quota; then project, weather, time worked and the latest mod toast

newspinnercommandnetworktimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · fuel-bar
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /fuel-debug ⎿ fuel-bar: hint: "" ⎿ fuel-bar: snapshot: {"window":200000,"tokens":97400,"turn":1,"model":"Opus 5.5","fiveHour":{"percent":31,"resetsAt":1760 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › T1 │ Opus 5.5 │ ━━━━━━━━━━━━ 48.7% 97.4k/200.0k │ 5h 31.0% ? for shortcuts APP · กำลังโหลดอากาศ… · ทำงานมาแล้ว 8718:59 hr

Draws

Prompt hint
T1 │ Opus 5.5 │ ━━━━━━━━━━━━ 48.7% 97.4k/200.0k │ 5h 31.0% ? for shortcuts APP · กำลังโหลดอากาศ… · ทำงานมาแล้ว 8718:59 hr
README

Claude Code mods: field notes from day one (Windows · Thai)

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.

PieceStatusOne-line lesson
fuel-barin daily useThe engine gives you context, auto-compact threshold and rate limits for free after every turn. No polling, no tokens.
thai-modein daily useYou can re-render tool rows, groups, spinner and turn duration. You cannot touch the prompt box.
task-bandnew, day twoThe 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-peeknew, day threeThe 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.
Matrix introin daily useA sequential intro always leaves a blank gap. Run it in parallel and stop on a file Claude writes before its first frame.
Safe updaterin daily useAuto-update on Windows can leave claude.exe as a 500-byte stub. Stage, verify, then rename-and-swap.
Right-side pane + widgetsdroppedThe dock frame belongs to the engine, and Thai text breaks in the Windows Terminal grid with every font I tried.
project-band above the promptdroppedEngine notifications render between your band and the prompt, and you cannot intercept them.
matrix-rain as a moddroppedsession.start fires after the first frame. Too late for a loading screen.
Rounded prompt boxnot possibleThe prompt input is not a render site.
Context Router, prismantisdeclinedAlready built in / clashes with thai-mode and adds tokens per prompt.

fuel-bar: context + quota footer

fuel-bar

Two lines under the prompt:

  1. 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.
  2. 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.
  • It draws into the 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.
  • On narrow terminals it drops, in order: the hint text, the reset times, the 5h/7D block, then wraps to two lines. The token count always stays.
  • Weather: 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.

thai-mode: Claude Code in Thai

thai-mode

Rewrites, with a built-in dictionary (no model calls):

  • tool rows (ToolUse): Read → อ่านไฟล์, Bash → รันคำสั่ง, and MCP tools as service · Thai verb
  • results (ToolResult): "read 120 lines (40–159 of 300)", "added 4 · removed 1 lines", with the diff still drawn by the engine's Code component
  • collapsed groups (ToolGroup): กำลังเขียน 1 ไฟล์ · แก้ 1 ไฟล์ · รัน 1 คำสั่ง…
  • the spinner word by real phase (requesting / thinking / responding / tool-input / tool-use)
  • turn duration and the ctrl+b hint

/thai toggles it, and the choice persists in $.store.

Things I learned:

  • In this version even a single Edit gets folded into a ToolGroup, so your nice single-row rendering mostly shows up only in ctrl+o.
  • Command output and diffs stay as they are. Translating those would be lying about what ran.

task-band: background work above the prompt

task-band

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:

  • Workflow: agents finished / agents started (1/2) plus elapsed time.
  • Single agent: an estimated ~62% once this agent type has finished at least 3 times (average kept in $.store), otherwise a sweeping line plus elapsed time.
  • Shell / Monitor: sweeping line plus elapsed time. Nothing reports real progress.
  • Running chips come first, finished ones (✓ / ✗) after them, and finished chips stay until the turn ends. Within each group chips keep the order you started them in.
  • When a task starts or ends, the band jumps to its page for 3 seconds. Ctrl+X then Tab focuses the band, ← / → page, Esc goes back to the prompt.

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.
  • Background Bash/PowerShell results carry backgroundTaskId. Monitor and Workflow results carry taskId (Workflow also runId and workflowName).
  • Their end arrives as 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).
  • Results of tool calls sent in parallel come back in any order. To keep chips in the order you started them, take the order key when the hook is entered, before 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).
  • Windows Terminal gives Thai above/below marks zero cells, the same as the engine does. Don't pad widths to "fix" the mis-spaced look. It only misaligns the frame.
  • Engine notices still render between the band and the prompt (the reason project-band was dropped). Here it matters less, because the band is only there while something runs.

paste-peek: see the image you just pasted (Windows)

paste-peek

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:

  • Click a thumbnail to enlarge it, click again to shrink. The strip never takes focus, so you keep typing.
  • Delete [Image #2] from the draft and #2 leaves the strip. Send the message and the strip closes.
  • Works for Alt+V pastes and for files dragged onto the terminal.
  • Stays with its own terminal window: it follows moves, hides when you switch to another app or minimize, and comes back when you return.

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:

  • The mod reads the draft ($.prompt.read()) every 250 ms and writes the list of [Image #N] numbers to a small state file in %TEMP%.
  • On the first image it starts 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.
  • Where the pictures come from: Claude Code writes every attached image to %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.
  • The exe ties itself to the Windows Terminal window that was in front when you pasted, and listens for 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.

Matrix boot intro

matrix-intro

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:

  1. Intro first, then claude. However long the intro runs, you still get a blank or log-only screen between the intro ending and Claude's first frame.
  2. Intro as a mod. 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.
  3. Intro in PowerShell. pwsh takes ~0.9 s to start and ~1.5 s to set up, so the first second is blank anyway.

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.
  • The exe polls 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.
  • Claude's fullscreen mode uses the alternate screen, so the rain on the main screen is hidden behind it. After Claude exits, the launcher runs cls, but only if the intro actually ran (it drops a marker file).
  • The intro is skipped for -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

Safe updater for the npm install

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:

engine notice

windows/claude-update-safe.ps1:

  1. Takes a global mutex, so two launches don't race.
  2. Compares the installed version with npm view @anthropic-ai/claude-code version.
  3. Installs the new version into a staging folder, picks the real binary (≥10 MB), and checks --version.
  4. Renames the live exe to 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.
  5. Cleans up old .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.

Tried and dropped

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:

  • The dock frame and its colors belong to the engine and follow the Claude Code theme, not yours.
  • Thai text in Windows Terminal is mis-spaced: combining vowels and tone marks get their own cell (เ ปิด). 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.
  • While the dock is open, viewport.columns in PromptHint is the transcript width, not the full window.
  • Below 144 columns the pane doesn't open by itself. On a 134-column window you have to open it once with /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.

Considered and declined

  • Context Router (load different context per folder). Claude Code already does this: it loads CLAUDE.md from parent folders, and @path imports work.
  • prismantis. Its default theme is dark. It also re-renders ToolUse, ToolGroup and TurnDuration (the same sites as thai-mode), and its diagramHints adds about 190 tokens to every prompt.

A warning about mod screenshots

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.

Testing on the real terminal (Windows)

Mockups in a browser lie about fonts, colors and cell widths. What I used instead:

  • Launch a fresh window with wt -w new <file.cmd>. Start-Process with arguments quotes the whole command line and fails with 0x80070002.
  • Find the window by class 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.
  • Type with SendInput + KEYEVENTF_UNICODE. SendKeys follows the active keyboard layout: with Thai active, /exit arrives as /59.

Install

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

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 13, paste-peek 4).

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 ในหนึ่งวัน ทั้งชิ้นที่ใช้จริงและชิ้นที่ลองแล้วทิ้ง

  • fuel-bar: แถบ 2 บรรทัดใต้ช่องพิมพ์ แสดง turn, โมเดล, effort, เกจ context (นับถึงจุด auto-compact), โควตา 5 ชม./7 วัน, ชื่อโปรเจกต์, อากาศ และเวลาที่ทำงานมาแล้ว ข้อมูลมาจาก engine หลังจบแต่ละ turn จึงไม่กิน token
  • thai-mode: แปลแถวเครื่องมือ, แถวสรุปที่พับไว้, spinner และเวลาที่ใช้ต่อ turn เป็นภาษาไทยด้วยพจนานุกรมในตัว /thai ใช้สลับเปิด/ปิด
  • task-band: กรอบ "งานเบื้องหลัง" เหนือช่องพิมพ์ แสดง agent, Workflow, คำสั่งเบื้องหลัง และ Monitor ทีละ 3 งาน แต่ละงานมีเกจของตัวเอง งานที่รันอยู่ขึ้นก่อน กด Ctrl+X แล้ว Tab เพื่อเลื่อนหน้าด้วยลูกศร
  • paste-peek (Windows): กรอบรูปเล็กลอยที่มุมขวาล่างของ Windows Terminal แสดงรูปทุก [Image #N] ที่วางหรือลากมาไว้ในข้อความ ก่อนกดส่ง คลิกเพื่อขยาย ลบ [Image #N] แล้วรูปหายจากกรอบ กดส่งแล้วกรอบปิดเอง ซ่อนเมื่อสลับไปหน้าต่างอื่น ใช้ RAM ประมาณ 24 MB เฉพาะตอนมีรูปแนบ ไม่แนบไฟล์ exe ไว้ใน repo แต่จะคอมไพล์จากโค้ดต้นฉบับตอนใช้ครั้งแรก
  • Matrix intro: แสดงฝนตัวอักษรระหว่างรอ Claude โหลด ต้องรันขนานกับ Claude และหยุดเมื่อ numStartups เพิ่ม ถ้ารันเรียงกันจะมีจอว่างเสมอ
  • ตัวอัปเดตปลอดภัย: แก้ปัญหา auto-update ทำให้ claude.exe เหลือไฟล์ 500 ไบต์ ใช้วิธีติดตั้งแยกไว้ก่อน ตรวจว่าใช้ได้ แล้วค่อยเปลี่ยนชื่อสลับไฟล์
  • ที่ลองแล้วไม่เวิร์ค:
  • แผงด้านขวา: กรอบเป็นของ engine และสระ/วรรณยุกต์ไทยเพี้ยนใน Windows Terminal ทุกฟอนต์ที่ลอง
  • แถบเหนือช่องพิมพ์: แจ้งเตือนของ Claude Code ขึ้นมาคั่นกลาง และ mod ดักไม่ได้
  • ฝน Matrix แบบ mod: session.start มาช้ากว่าจอแรก
  • กรอบช่องพิมพ์มุมมน: API ไม่เปิดให้แก้

ถ้าใช้ Windows Terminal กับภาษาไทย ให้เตรียมใจไว้ว่าสระบนและวรรณยุกต์จะกินช่องของตัวเอง ฟอนต์แก้ไม่ได้

License

MIT

Source 3 files
hooks/register.tsx 339 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FuelQuota, FuelSnapshot } from '../types'
5import { baseName, cellWidth, fitCells, shortNote, tempLabel, WEATHER_COLOR, WEATHER_GLYPH, WEATHER_TH, weatherKind, workedLabel } from './project'
6import type { Weather } from './project'
7
8const snapshot = atom({ plugin: 'fuel-bar', key: 'snapshot' } as const, null)
9const thaiOn = atom({ plugin: 'thai-mode', key: 'enabled' } as const, false)
10const weather = atom({ plugin: 'fuel-bar', key: 'weather' } as const, null)
11const toast = atom({ plugin: 'fuel-bar', key: 'toast' } as const, null)
12const frame = atom({ plugin: 'fuel-bar', key: 'frame' } as const, 0)
13const startedAt = atom({ plugin: 'fuel-bar', key: 'startedAt' } as const, 0)
14
15const TOAST_MS = 8000
16const WEATHER_MS = 30 * 60_000
17
18const HINT_TH: Array<[RegExp, string]> = [
19  [/\? for shortcuts/i, '? ดูคีย์ลัด'],
20  [/esc to interrupt/i, 'esc หยุด'],
21  [/for agents/i, 'ดู agents'],
22  [/ctrl\+c again to exit/i, 'กด ctrl+c อีกครั้งเพื่อออก'],
23  [/esc again to clear/i, 'กด esc อีกครั้งเพื่อล้าง'],
24]
25
26export function thaiHint(hint: string): string {
27  return HINT_TH.reduce((h, [re, th]) => h.replace(re, th), hint)
28}
29
30const C = {
31  fg: '#2d2a24',
32  dim: '#6b675e',
33  faint: '#b9b5a3',
34  off: '#d2ceb4',
35  pillBg: '#3a3a3a',
36  pillFg: '#e0e0e0',
37  green: '#3f8a3a',
38  name: '#c25a2c',
39  yellow: '#a06c00',
40  red: '#b8322b',
41}
42
43const MODE_LABEL: Record<string, string> = {
44  auto: 'AUTO',
45  acceptEdits: 'EDITS',
46  plan: 'PLAN',
47  bypassPermissions: 'BYPASS',
48  dontAsk: 'DONT ASK',
49}
50
51const GAUGE_CELLS = 12
52
53type Seg = { text: string; color?: string; bg?: string; bold?: boolean }
54
55export function prettyModel(id: string): string {
56  const m = id.match(/(opus|sonnet|haiku|fable)[-_ ]?(\d+)(?:[-.](\d+))?/i)
57  if (!m || !m[1]) return id.replace(/\[.*\]$/, '')
58  const name = m[1].charAt(0).toUpperCase() + m[1].slice(1).toLowerCase()
59  return m[3] ? `${name} ${m[2]}.${m[3]}` : `${name} ${m[2]}`
60}
61
62export function shortTokens(n: number): string {
63  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
64  return `${(n / 1000).toFixed(1)}k`
65}
66
67export function untilReset(iso: string | undefined, now: number): string | undefined {
68  if (!iso) return undefined
69  const ms = Date.parse(iso) - now
70  if (!Number.isFinite(ms) || ms <= 0) return undefined
71  const mins = Math.floor(ms / 60_000)
72  const d = Math.floor(mins / 1440)
73  const h = Math.floor((mins % 1440) / 60)
74  const m = mins % 60
75  if (d > 0) return h > 0 ? `${d}d${h}h` : `${d}d`
76  if (h > 0) return m > 0 ? `${h}h${m}m` : `${h}h`
77  return `${m}m`
78}
79
80export function modeFromHint(hint: string): string | undefined {
81  const s = hint.toLowerCase()
82  if (s.includes('auto mode')) return 'auto'
83  if (s.includes('accept edits')) return 'acceptEdits'
84  if (s.includes('plan mode')) return 'plan'
85  if (s.includes('bypass permissions')) return 'bypassPermissions'
86  if (s.includes("don't ask") || s.includes('dont ask')) return 'dontAsk'
87  return undefined
88}
89
90export function stripModePill(hint: string): string {
91  return hint
92    .replace(/[⏵⏸▶►]+\s*[^·(]*?(mode|edits|permissions|ask)\s+on\b/gi, '')
93    .replace(/\(shift\+tab to cycle\)/gi, '')
94    .replace(/\s{2,}/g, '  ')
95    .trim()
96    .replace(/^[·\s]+|[·\s]+$/g, '')
97}
98
99const gaugeColor = (pct: number) => (pct > 85 ? C.red : pct >= 60 ? C.yellow : C.green)
100const quotaColor = gaugeColor
101
102export function buildLines(s: FuelSnapshot, mode: string | undefined, hint: string, columns: number, now: number): Seg[][] {
103  const sep: Seg = { text: ' │ ', color: C.faint }
104  const head: Seg[] = []
105  const label = mode ? MODE_LABEL[mode] : undefined
106  if (label) head.push({ text: ` ${label} `, color: C.pillFg, bg: C.pillBg, bold: true }, { text: ' ' })
107  head.push({ text: `T${s.turn}`, color: C.fg, bold: true }, sep, { text: s.model, color: C.fg })
108  if (s.effort) head.push({ text: ` · ${s.effort}`, color: C.dim })
109
110  const gauge: Seg[] = []
111  if (s.tokens === undefined) {
112    gauge.push({ text: '━'.repeat(GAUGE_CELLS), color: C.off }, { text: ' —', color: C.dim })
113  } else {
114    const limit = s.threshold && s.threshold > 0 ? s.threshold : s.window
115    const pct = Math.round((s.tokens / limit) * 1000) / 10
116    const filled = Math.max(0, Math.min(GAUGE_CELLS, Math.round((pct / 100) * GAUGE_CELLS)))
117    const color = gaugeColor(pct)
118    gauge.push(
119      { text: '━'.repeat(filled), color },
120      { text: '━'.repeat(GAUGE_CELLS - filled), color: C.off },
121      { text: ` ${pct.toFixed(1)}%`, color, bold: true },
122      { text: ` ${shortTokens(s.tokens)}/${shortTokens(s.window)}`, color: C.dim },
123    )
124  }
125
126  const quota = (name: string, q: FuelQuota | undefined, withReset: boolean): Seg[] => {
127    if (!q) return []
128    const out: Seg[] = [{ text: `${name} `, color: C.dim }, { text: `${q.percent.toFixed(1)}%`, color: quotaColor(q.percent) }]
129    const left = withReset ? untilReset(q.resetsAt, now) : undefined
130    if (left) out.push({ text: ` (${left})`, color: C.dim })
131    return out
132  }
133  const quotas = (withReset: boolean): Seg[] => {
134    const a = quota('5h', s.fiveHour, withReset)
135    const b = quota('7D', s.sevenDay, withReset)
136    if (!a.length && !b.length) return []
137    return [sep, ...a, ...(a.length && b.length ? [{ text: ' · ', color: C.dim }] : []), ...b]
138  }
139  const hintSegs: Seg[] = hint ? [{ text: `   ${hint}`, color: C.faint }] : []
140
141  const width = (segs: Seg[]) => segs.reduce((n, x) => n + [...x.text].length, 0) + 1
142  const tries: Seg[][] = [
143    [...head, sep, ...gauge, ...quotas(true), ...hintSegs],
144    [...head, sep, ...gauge, ...quotas(true)],
145    [...head, sep, ...gauge, ...quotas(false)],
146    [...head, sep, ...gauge],
147  ]
148  for (const line of tries) if (width(line) <= columns) return [line]
149  return [head, gauge]
150}
151
152async function refresh($: EngineInterface, patch: Partial<FuelSnapshot>) {
153  const [turn, model] = await Promise.all([$.session.turns(), $.session.model()])
154  await update($, snapshot, prev => ({
155    window: 0,
156    ...(prev ?? {}),
157    ...patch,
158    turn,
159    model: prettyModel(model),
160  }))
161}
162
163async function trackMode($: EngineInterface, mode: unknown) {
164  if (typeof mode !== 'string') return
165  const s = await read($, snapshot)
166  if (s && s.permissionMode !== mode) await update($, snapshot, p => (p ? { ...p, permissionMode: mode } : p))
167}
168
169let lastHint = ''
170let cwd = ''
171
172async function loadWeather($: EngineInterface) {
173  const cached = (await $.store.get('weather')) as Weather | undefined
174  if (cached && Date.now() - cached.at < WEATHER_MS) {
175    await update($, weather, () => cached)
176    return
177  }
178  try {
179    const geo = JSON.parse((await $.http.fetch('https://ipwho.is/')).text) as { latitude?: number; longitude?: number; city?: string }
180    if (typeof geo.latitude !== 'number' || typeof geo.longitude !== 'number') return
181    const url =
182      `https://api.open-meteo.com/v1/forecast?latitude=${geo.latitude}&longitude=${geo.longitude}` +
183      '&current=temperature_2m,relative_humidity_2m,weather_code,is_day&hourly=precipitation_probability&forecast_hours=3&timezone=auto'
184    const om = JSON.parse((await $.http.fetch(url)).text) as {
185      current?: { temperature_2m: number; relative_humidity_2m: number; weather_code: number; is_day: number }
186      hourly?: { precipitation_probability?: number[] }
187    }
188    if (!om.current) return
189    const w: Weather = {
190      tempC: om.current.temperature_2m,
191      code: om.current.weather_code,
192      isDay: om.current.is_day === 1,
193      humidity: Math.round(om.current.relative_humidity_2m),
194      rainChance: Math.max(0, ...(om.hourly?.precipitation_probability ?? [0])),
195      city: geo.city ?? '',
196      at: Date.now(),
197    }
198    await $.store.set('weather', w)
199    await update($, weather, () => w)
200  } catch {
201    return
202  }
203}
204
205async function tick($: EngineInterface) {
206  await update($, frame, f => (f + 1) % 2)
207  const t = await read($, toast)
208  if (t && Date.now() - t.at > TOAST_MS) await update($, toast, () => null)
209}
210
211export function projectSegs(name: string, w: Weather | null, f: number, workedMs: number | undefined): Seg[] {
212  const dot: Seg = { text: '  ·  ', color: C.faint }
213  const out: Seg[] = [{ text: name.toUpperCase(), color: C.name, bold: true }, dot]
214  if (w) {
215    const kind = weatherKind(w)
216    out.push(
217      { text: WEATHER_GLYPH[kind][f % 2] ?? '', color: WEATHER_COLOR[kind], bold: true },
218      { text: ` ${tempLabel(w.tempC)} ${WEATHER_TH[kind]}`, color: C.fg, bold: true },
219      { text: ` ${shortNote(w, kind)}`, color: C.dim },
220    )
221  } else {
222    out.push({ text: 'กำลังโหลดอากาศ…', color: C.dim })
223  }
224  out.push(dot, { text: 'ทำงานมาแล้ว ', color: C.fg }, { text: workedMs === undefined ? '—' : workedLabel(workedMs), color: C.fg, bold: true })
225  return out
226}
227
228export const register: Register = on => {
229  on('session.start', async ($, e, next) => {
230    const result = await next(e)
231    cwd = await $.session.cwd()
232    const started = await $.session.usage()
233    await update($, startedAt, () => (typeof started.startedAt === 'number' ? started.startedAt : Date.now()))
234    await loadWeather($)
235    $.clock.every(WEATHER_MS, () => loadWeather($))
236    $.clock.every(2_000, () => tick($))
237    await $.command.register({ name: 'fuel-debug', description: 'fuel-bar: show the raw values it is drawing from' })
238    const settings = (await $.settings.read()) as { effortLevel?: string }
239    const usage = await $.session.usage()
240    await refresh($, {
241      effort: settings.effortLevel,
242      tokens: usage.context.tokens,
243      window: usage.context.window,
244    })
245    return result
246  })
247
248  on('turn.step', async function* ($, e, next) {
249    if (!e.agentId && e.effort !== undefined) {
250      const effort = String(e.effort)
251      const s = await read($, snapshot)
252      if (s && s.effort !== effort) await update($, snapshot, p => (p ? { ...p, effort } : p))
253    }
254    return yield* next(e)
255  })
256
257  on('session.measure', async ($, e, next) => {
258    const result = await next(e)
259    const usage = await $.session.usage({ breakdown: 'summary' })
260    const five = e.rateLimits.find(r => r.kind === 'five_hour')
261    const seven = e.rateLimits.find(r => r.kind === 'seven_day')
262    await refresh($, {
263      tokens: e.context.tokens,
264      window: e.context.window,
265      threshold: usage.context.breakdown?.autoCompactThreshold,
266      fiveHour: five ? { percent: five.percentUsed, resetsAt: five.resetsAt } : undefined,
267      sevenDay: seven ? { percent: seven.percentUsed, resetsAt: seven.resetsAt } : undefined,
268    })
269    return result
270  })
271
272  on('ui.toast', async ($, e) => {
273    await update($, toast, () => ({ text: e.text, at: Date.now() }))
274    return { value: undefined }
275  })
276
277  on('classic.UserPromptSubmit', async ($, e, next) => {
278    await trackMode($, e.permission_mode)
279    return next(e)
280  })
281  on('classic.Stop', async ($, e, next) => {
282    await trackMode($, e.permission_mode)
283    return next(e)
284  })
285
286  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
287    const s = await read($, snapshot)
288    if (!s) return next(e)
289    lastHint = e.props.hint
290    const fromHint = modeFromHint(e.props.hint)
291    const mode = fromHint ?? (s.permissionMode && s.permissionMode !== 'default' ? s.permissionMode : undefined)
292    const stripped = stripModePill(e.props.hint)
293    const hint = (await read($, thaiOn)) ? thaiHint(stripped) : stripped
294    const columns = e.viewport?.columns ?? 120
295    const enginePill = mode ? 20 : 0
296    const room = columns - 2 - enginePill
297    const lines = buildLines(s, undefined, hint, room, Date.now())
298    const [w, t, f, start] = await Promise.all([read($, weather), read($, toast), read($, frame), read($, startedAt)])
299    const proj = projectSegs(baseName(cwd), w, f, start ? Date.now() - start : undefined)
300    const projWidth = proj.reduce((n, x) => n + cellWidth(x.text), 0) + 1
301    const toastRoom = room - projWidth - 4
302    const showToast = t && Date.now() - t.at <= TOAST_MS && toastRoom > 8
303    const { Box, Text } = $.ui.resolve(e)
304    const row = (segs: Seg[], key: string) =>
305      segs.map((seg, j) => (
306        <Text key={`${key}-${j}`} color={seg.color} backgroundColor={seg.bg} bold={seg.bold}>
307          {seg.text}
308        </Text>
309      ))
310    return (
311      <Box flexDirection="column">
312        {lines.map((line, i) => (
313          <Box key={`l${i}`} flexDirection="row">
314            <Text> </Text>
315            {row(line, `s${i}`)}
316          </Box>
317        ))}
318        <Box key="proj" flexDirection="row" justifyContent="space-between" width={room}>
319          <Box flexDirection="row">
320            <Text> </Text>
321            {row(proj, 'p')}
322          </Box>
323          {showToast ? (
324            <Text color={C.fg}>
325              <Text color={C.yellow}>▍ </Text>
326              {fitCells(t.text, toastRoom)}
327            </Text>
328          ) : null}
329        </Box>
330      </Box>
331    )
332  })
333
334  on('command.run', { command: 'fuel-debug' }, async $ => {
335    const s = await read($, snapshot)
336    return { text: `hint: ${JSON.stringify(lastHint)}\nsnapshot: ${JSON.stringify(s)}` }
337  })
338}
339
hooks/project.ts 78 lines
1export type Weather = { tempC: number; code: number; isDay: boolean; humidity: number; rainChance: number; city: string; at: number }
2export type WeatherKind = 'sun' | 'hot' | 'cloud' | 'rain' | 'storm' | 'fog' | 'night'
3
4export const baseName = (p: string) => p.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop() ?? p
5
6export function weatherKind(w: Weather): WeatherKind {
7  const c = w.code
8  if (c >= 95) return 'storm'
9  if ((c >= 51 && c <= 67) || (c >= 80 && c <= 82)) return 'rain'
10  if (c === 45 || c === 48) return 'fog'
11  if (c === 3 || c === 2) return w.isDay ? 'cloud' : 'night'
12  if (!w.isDay) return 'night'
13  return w.tempC >= 36 ? 'hot' : 'sun'
14}
15
16export const WEATHER_TH: Record<WeatherKind, string> = {
17  sun: 'แดดดี',
18  hot: 'ร้อนจัด',
19  cloud: 'เมฆมาก',
20  rain: 'ฝนตก',
21  storm: 'พายุฝนฟ้าคะนอง',
22  fog: 'หมอก',
23  night: 'กลางคืน',
24}
25
26export const WEATHER_COLOR: Record<WeatherKind, string> = {
27  sun: '#a06c00',
28  hot: '#b8322b',
29  cloud: '#6b675e',
30  rain: '#2f6aa8',
31  storm: '#7d4fb0',
32  fog: '#8a8780',
33  night: '#2a8a86',
34}
35
36export const WEATHER_GLYPH: Record<WeatherKind, [string, string]> = {
37  sun: ['☀', '✹'],
38  hot: ['☀', '♨'],
39  cloud: ['☁', '☁'],
40  rain: ['☂', '⁘'],
41  storm: ['↯', '☁'],
42  fog: ['≡', '≈'],
43  night: ['☾', '✦'],
44}
45
46export function shortNote(w: Weather, kind: WeatherKind): string {
47  if (kind === 'storm') return 'ระวังฟ้าผ่า'
48  if (kind === 'hot') return 'ดื่มน้ำ'
49  return `ฝน ${w.rainChance}%`
50}
51
52export function tempLabel(c: number): string {
53  return `${c.toFixed(1)}°`
54}
55
56export function workedLabel(ms: number): string {
57  const m = Math.max(0, Math.floor(ms / 60_000))
58  return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')} hr`
59}
60
61const THAI_ZERO = /[ัิ-ฺ็-๎]/u
62
63export function cellWidth(s: string): number {
64  let n = 0
65  for (const ch of s) if (!THAI_ZERO.test(ch)) n++
66  return n
67}
68
69export function fitCells(s: string, max: number): string {
70  if (cellWidth(s) <= max) return s
71  let out = ''
72  for (const ch of s) {
73    if (cellWidth(out + ch) > max - 1) break
74    out += ch
75  }
76  return `${out}…`
77}
78
types/index.d.ts 29 lines
1export type FuelWeather = { tempC: number; code: number; isDay: boolean; humidity: number; rainChance: number; city: string; at: number }
2export type FuelToast = { text: string; at: number }
3
4export type FuelQuota = { percent: number; resetsAt?: string }
5
6export type FuelSnapshot = {
7  turn: number
8  model: string
9  effort?: string
10  tokens?: number
11  window: number
12  threshold?: number
13  fiveHour?: FuelQuota
14  sevenDay?: FuelQuota
15  permissionMode?: string
16}
17
18declare module 'claude-code' {
19  interface PluginState {
20    'fuel-bar': {
21      snapshot: FuelSnapshot | null
22      weather: FuelWeather | null
23      toast: FuelToast | null
24      frame: number
25      startedAt: number
26    }
27  }
28}
29