用量横条:输入框上方显示上下文、5 小时额度、花费和 git 分支;状态栏显示消息编号(已有自己的状态栏时默认关闭);点「检查」才联网查新版本 / Usage meter: context, 5-hour quota, cost and git branch above the prompt; a status…

English · 简体中文
Re-skin Claude Code in one click, with a full set of features you turn on when you need them.<br> 21 styles for the terminal and the desktop app.<br> 一键为 Claude Code 换上新风格,并提供一整套按需开启的功能。<br> 21 套风格,终端和桌面 App 都能用。
<img src="assets/readme/cover.png" width="100%" alt="21 styles for Claude Code · lemo-mod 的 21 套风格">
lemo-mod is a set of style mods built on Claude Code's official mod interface. Once installed, Claude Code in the terminal and in the desktop app's Code tab can switch styles in one click. Colors, pixel mascot, spinner words, sounds and names all change together.
Beyond looks, lemo-mod provides a full set of features, including a four-page panel, a usage band, a status line, message numbers and reply tags, sounds, speech, brief mode, reminders, a focus timer, notes, session recaps, per-turn stats, a time limit, a log, auto tags, a lot-drawing tool and a read-only assistant. Your settings are only read, never changed, and every feature beyond looks is yours to turn on.
[!NOTE] 中文读者请看这里。 lemo-mod 是一组基于 Claude Code 官方 mod 接口的风格化 mod。装上之后,终端里的 Claude Code 和桌面 App 的 Code 标签都能一键换风格,配色、像素小画、加载词、音效和名字一起换。
除了外观,lemo-mod 还提供一整套功能,包括四页面板、用量横条、状态栏、消息编号和回复标签、音效、朗读、简短模式、定时提醒、番茄钟、笔记、会话小结、每轮统计、限时、日志、自动分类、抽签工具和一个只读助手。你的设置只读不改,外观以外的功能都由你自己开启。
Looks are on from the start. Your messages get numbers like T01 and T02, and each of Claude's replies shows which one it answers. Tool rows, turn lines and spinner words take on the style. A band above the prompt shows context, the 5-hour quota, cost and git branch, with a status line below it. Claude's question dialog gets a header. Type /lemo-mod to open the panel, with four pages: Main, Behavior, Background and Safety.
The first time you open it, the panel starts on the Safety page. Read what each feature does, turn on the ones you want, and press I have read this. You can switch any feature on or off on the Safety page at any time. Turn all off stops everything but looks in one press. The numbering note and the lot tool are fully gone from the next session.
<table> <tr> <td><b>Sounds</b><br>Style sounds on tool calls and turn ends<br><sub>switch · sound</sub></td> <td><b>Speech</b><br>Reads out the message number and time when a long task ends<br><sub>switch · speech</sub></td> <td><b>Brief mode</b><br>Answers in three sentences or fewer<br><sub>switch · adds to prompt</sub></td> <td><b>Notebook voice</b><br>Opens with what it looked at and ends with a conclusion<br><sub>switch · adds to prompt</sub></td> </tr> <tr> <td><b>Time limit</b><br>Stops a turn that runs over time<br><sub>switch · acts for you</sub></td> <td><b>Log</b><br>One line per turn in <code>~/.claude/lemo-mod/journal.md</code><br><sub>switch · writes a file</sub></td> <td><b>Auto tags</b><br>Tags each message as question, code, debug or chat<br><sub>switch · extra usage · message start goes to a small model</sub></td> <td><b>Auto summary after compaction</b><br>Saves a one-line note after each compaction<br><sub>switch · extra usage · summary goes to a small model</sub></td> </tr> <tr> <td><b>Lot tool</b><br>Say "draw a lot" and Claude draws one<br><sub>switch · adds a tool</sub></td> <td><b>Numbering note</b><br>Tells Claude what <code>T03</code> refers to<br><sub>switch · adds to prompt</sub></td> <td><b>Claude can send the assistant</b><br>Lets Claude send the read-only assistant<br><sub>switch · extra usage · report joins the chat</sub></td> <td><b>Session recap</b><br>Sums up this session in three lines<br><sub>on demand · main model reads the chat</sub></td> </tr> <tr> <td><b>Reminder</b><br>Asks Claude for a progress update after N seconds<br><sub>on demand</sub></td> <td><b>Focus timer</b><br>A countdown on the band and a reminder when time is up<br><sub>on demand</sub></td> <td><b>Write a report</b><br>The read-only assistant writes a three-line report<br><sub>on demand · report joins the chat</sub></td> <td><b>Check for updates</b><br>The only one that visits an outside website<br><sub>on demand</sub></td> </tr> </table>
<sub><b>switch</b> Off at install. Turn it on or off on the Safety page at any time. <b>on demand</b> Runs only when you press its button or type its command.</sub>
A mod runs with your permissions, so it matters whose mods you install. lemo-mod follows these rules. See SAFETY.md for details.
/lemo-mod safety to open the page.To check before you install, clone this repo and run claude plugin validate plugins/<mod>. The hooks: and calls: lines in its output list the events each mod handles and what it asks Claude Code to do.
You need Claude Code v2.1.287 or later. Check with claude --version. In your terminal, run:
claude plugin marketplace add lemomo-ai/lemo-mod
claude plugin install lemo-mod@lemo-mod
Then restart Claude Code, in the terminal or in the desktop app's Code tab. The panel starts on the Safety page. Preview the sounds and the voice if you like, turn on what you want, and press I have read this. In a terminal narrower than 144 columns, type /lemo-mod safety to open it. After that, each new session lists what is on in the band, so nothing runs quietly.
lemo-mod installs all 16 mods, and each one is its own plugin. To drop one look or feature, turn it off on the Safety page. To turn a whole mod off, first turn off the lemo-mod bundle, then the mod. The bundle is only a list, so turning it off leaves every mod on. Do this in /plugin in that order, or run:
claude plugin disable lemo-mod@lemo-mod
claude plugin disable lemo-spinner@lemo-mod
Turning the bundle back on turns all 16 mods on again.
For the best look, match Claude Code's theme to your terminal's light or dark background, for example a dark theme on a dark terminal. Set the theme with /theme. In the desktop app, light mode looks best.
| Type | Does |
|---|---|
/lemo-mod | Open the panel |
/lemo-mod safety | Open the Safety page, and the same for main, behavior and background |
/lemo-mod style <name> | Switch style, or list all styles with no name |
/lemo-mod name <name> | Name the current style, or restore its name with no name |
/lemo-mod mute or sound | Turn sound off or on |
/lemo-mod speak | Hear a speech preview |
/lemo-mod brief | Turn brief mode on or off |
/lemo-mod focus 25 | Start a 25-minute focus timer |
/lemo-mod remind 30 | Ask Claude for a progress update in 30 seconds |
/lemo-mod recap | Get a three-line session recap |
In the terminal, press ctrl+x Tab to move into the panel, a to d to switch pages, number keys to press the buttons on the cards, and Esc to go back to the prompt. The interface follows the language of your latest message, Chinese or English.
There are 21 styles. 20 themed styles each have their own colors, pixel mascot, spinner words, sounds and names. Plain has no mascot and uses the default sounds.
Lemo Lab · Lucky Koi · Cassie · Peeky · Sprouty · Chubby Bun · Choo-Choo · Masky · Pixel Pal · Zoomy · Campfire · Mango Pop · Postie · Magic Gourd · Clacky · Waddles · Bubbles · Lanny · Blinky · Tri-Tri · Plain
See each style in the terminal and the desktop app, light and dark
| Where | What you see |
|---|---|
claude in a terminal | Everything |
| Desktop app, Code tab | Everything the app can draw, the rest is hidden |
VS Code chat panel and claude -p | No interface |
Features you turn on still work in VS Code and claude -p, just without the interface.
In the desktop app's auto mode, the panel's Write a report button is stopped by the auto mode check, which cannot see that you pressed it. Turn on Claude can send the assistant (on the Safety page or the assistant card), then ask Claude to send the assistant to write a three-line report; or switch to another permission mode and press it again.
Your style, switches and safety confirmation are saved once for all projects and sessions, and shared by the terminal and the desktop app. If you add the marketplace from a local folder or load the plugins with --plugin-dir, the desktop app keeps its own copy, so you confirm once on each side.
Updating the lemo-mod bundle does not update the mods inside it, so update each one. Then restart Claude Code or type /reload-plugins in an open session.
claude plugin marketplace update lemo-mod
for m in lemo-mod lemo-core lemo-skin lemo-spinner lemo-ask lemo-meter lemo-guard lemo-sound lemo-voice lemo-tone lemo-pomodoro lemo-watch lemo-recap lemo-todo lemo-journal lemo-lot lemo-assistant; do
claude plugin update "$m@lemo-mod"
done
claude plugin uninstall lemo-mod@lemo-mod
claude plugin prune
claude plugin marketplace remove lemo-mod
Your switches and notes are saved in ~/.claude/plugins/store/lemo-*. If you turned on the log, it is in ~/.claude/lemo-mod/. Delete them too to remove everything.
| Mod | What it does |
|---|---|
lemo-core | Styles, language, message numbers, the /lemo-mod panel and its Safety page |
lemo-skin | Message numbers, reply tags, and tool rows and turn lines in the style |
lemo-spinner | Spinner words from the style, with the message number |
lemo-ask | Message number and question count above the question dialog |
lemo-meter | The usage band, the status line, git branch and update check |
lemo-sound | Sounds on tool calls, turn ends and time-limit stops |
lemo-voice | Reads out the message number and time when a long task ends |
lemo-tone | Brief mode and notebook voice |
lemo-guard | Time limit that stops a turn running over time |
lemo-pomodoro | Focus timer |
lemo-watch | Reminder that asks Claude for a progress update after N seconds |
lemo-recap | Three-line session recap |
lemo-todo | Notes in the panel, with an auto summary after compaction |
lemo-journal | Per-turn stats, log and auto tags |
lemo-lot | A lot-drawing tool for Claude and fortune cards |
lemo-assistant | A read-only assistant that writes a three-line report |
Coming next are combined features, games in the panel and more styles. Ideas for styles or mods are welcome in Issues.
Made by lemomo of Lemo Lab, together with Claude. MIT License.
hooks/register.tsx 612 lines1// lemo-meter:输入框上方的横条和状态栏。
2// - 横条:上下文、五小时额度、花费,右上角是消息编号、各 mod 的胶囊(lemo-core 的 badges)和 git 分支,
3// 提示(lemo-core 的 notice)出在标题右边。终端画风格的像素小画 + 刻度尺,桌面画一张铺满宽度的 SVG 卡片
4// - 状态栏:品牌短名 ┊ T03 ┊ 上下文 12% ┊ 各胶囊,文字没变不重发
5// - 面板卡片:「常用」页的用量(三格数据 + 上下文走势),「后台」页的 git 分支和检查新版本(桌面上没有检查新版本)
6// - 安全页:横条、状态栏两行外观(有开关),查新版本是「点了才做」。
7// 状态栏默认照用户自己的设置:用户自己设了 statusLine 就不开(lemo-core 的 scan),用户按过开关后照用户的
8
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, Register, RenderChildren, RenderElement, RenderInput, Timer } from 'claude-code'
11
12import type { LemoMeterLatest, LemoMeterUsage } from '../types'
13import { BAND_H, PANE_H, bandSvg, paneSvg, ruler, sparkline, spriteCells } from './draw'
14import type { Pill, Tone } from './draw'
15import { STR } from './i18n'
16import { HUB, bareInk, exp, fill, hubWrap, inkOf, mmss, pct, sec, steady, width, word } from './shared/lemo'
17import type { CardSpec, Core, Look } from './shared/lemo'
18
19// ---- lemo-shared BEGIN: 由 scripts/sync-shared.mjs 从 shared/lemo-block.tsx 复制,不要在这里改 ----
20// 下面这几个函数要用 $,所以不能放进 shared/lemo.tsx(校验规定 $ 只能传给同一个文件里定义的函数),
21// 由 scripts/sync-shared.mjs 原样插进每个 mod 的 hooks/register.tsx。只在 shared/lemo-block.tsx 里改。
22// 导入都起了 Lemo 开头的别名,免得和 mod 自己的导入重名。
23import type { EngineInterface as LemoEngine } from 'claude-code'
24import { visibleTabs as lemoVisibleTabs } from './shared/lemo'
25import type { Look as LemoLook, Tab as LemoTabId } from './shared/lemo'
26
27// lemo-core 的状态(校验规定:状态的引用要写在用它的文件里)。读它们会订阅,lemo-core 一改,读过的地方自动重画
28const LemoStyleRef = { plugin: 'lemo-core', key: 'style' } as const
29const LemoLangRef = { plugin: 'lemo-core', key: 'lang' } as const
30const LemoThemeRef = { plugin: 'lemo-core', key: 'theme' } as const
31const LemoDeskRef = { plugin: 'lemo-core', key: 'desk' } as const
32const LemoTabRef = { plugin: 'lemo-core', key: 'tab' } as const
33const LemoModsRef = { plugin: 'lemo-core', key: 'mods' } as const
34const LemoSeqRef = { plugin: 'lemo-core', key: 'seq' } as const
35const LemoTurnNoRef = { plugin: 'lemo-core', key: 'turnNo' } as const
36
37/**
38 * 画东西时要的风格和语言。读 lemo-core 的状态会订阅,lemo-core 一改自动重画;还没写过时用 $.lemo 兜底。
39 * 面板(Pane)的 hook 要把 e.props 传进来:面板停在哪(placement)决定正文用什么颜色(见 shared/lemo.tsx 的 inkOf),
40 * 面板宽度(bodyColumns)决定卡片说明在哪断行(见 shared/lemo.tsx 的 card)
41 */
42async function look($: LemoEngine, pane?: { placement: 'dock' | 'inline'; bodyColumns?: number }): Promise<LemoLook> {
43 const st = (await $.state.get(LemoStyleRef)).value ?? (await $.lemo.style({}))
44 const lang = (await $.state.get(LemoLangRef)).value ?? (await $.lemo.lang({}))
45 const theme = (await $.state.get(LemoThemeRef)).value ?? null
46 const desk = (await $.state.get(LemoDeskRef)).value ?? null
47 return { st, c: st.colors, lang, theme, desk, inline: pane?.placement === 'inline', ...(pane?.bodyColumns === undefined ? {} : { bodyColumns: pane.bodyColumns }) }
48}
49
50/** 用户本人发了几条消息(T01、T02…) */
51async function seqOf($: LemoEngine): Promise<number> {
52 return (await $.state.get(LemoSeqRef)).value ?? 0
53}
54
55/** 这一轮回的是第几条消息:提醒、助手交回、斜杠命令开头的一轮是 0(不写号)。lemo-core 还没写过时当作 seq */
56async function turnNoOf($: LemoEngine): Promise<number> {
57 return (await $.state.get(LemoTurnNoRef)).value ?? (await seqOf($))
58}
59
60/** 统一面板现在显示哪一页:存的那页在这个界面上没有,就显示第一页 */
61async function hubTab($: LemoEngine, surface: string): Promise<LemoTabId> {
62 const stored = (await $.state.get(LemoTabRef)).value ?? 'main'
63 const mods = (await $.state.get(LemoModsRef)).value
64 // 还没有报到表(lemo-core 还没写过,或者测试里用的是假核心):照存的那页
65 if (mods === undefined) return stored
66 const shown = lemoVisibleTabs(mods, surface)
67 return shown.includes(stored) ? stored : shown[0] ?? 'behave'
68}
69// ---- lemo-shared END ----
70
71// lemo-core 的胶囊、提示和每秒加一的 frame(只读)
72const BadgesRef = { plugin: 'lemo-core', key: 'badges' } as const
73const NoticeRef = { plugin: 'lemo-core', key: 'notice' } as const
74const FrameRef = { plugin: 'lemo-core', key: 'frame' } as const
75// lemo-core 扫描到的用户设置(只读):用户自己设了状态栏,这里的状态栏默认就不开
76const LemoScanRef = { plugin: 'lemo-core', key: 'scan' } as const
77
78const EMPTY: LemoMeterUsage = { ctx: null, quota: null, usd: null }
79const IDLE: LemoMeterLatest = { status: 'idle', newest: '', mine: '', isNewer: false, why: '' }
80const CHANGELOG = 'https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md'
81
82const usage = atom({ plugin: 'lemo-meter', key: 'usage' } as const, EMPTY)
83const history = atom({ plugin: 'lemo-meter', key: 'history' } as const, [] as readonly number[])
84const branch = atom({ plugin: 'lemo-meter', key: 'branch' } as const, null as string | null)
85const latest = atom({ plugin: 'lemo-meter', key: 'latest' } as const, IDLE)
86// 横条开关:外观,装上就开。存进 $.store(键 band),开会话时读回来;横条读它,一改就重画
87const bandOn = atom({ plugin: 'lemo-meter', key: 'bandOn' } as const, true)
88// 状态栏开关:用户按过就是 true / false(存进 $.store,键 status),没按过是 null,照 lemo-core 的扫描定
89const statusPref = atom({ plugin: 'lemo-meter', key: 'statusPref' } as const, null as boolean | null)
90
91type Cap = Awaited<ReturnType<EngineInterface['lemo']['caps']>>[number]
92
93// 桌面代码字体一格大约多少像素,只用来估算宽度
94const CELL_PX = 7.8
95// 终端横条第一行右端留给引擎收起按钮「[-]」的格数(按钮 3 格,再空一格)
96const FOLD = 4
97
98type Badge = Core['badges'][number]
99type Shown = Badge & { text: string | { zh: string; en: string } }
100
101// 上一次发给状态栏的文字:没变就不重发(状态栏每次更新,桌面都会重画底部那一块)
102let lastStatus = ''
103// 每秒刷新状态栏的计时器
104let statusTimer: Timer | null = null
105
106/**
107 * 还在的胶囊:text 为 null 的是去掉了;倒计时过了点 3 秒还在的也不画
108 * (放胶囊的 mod 到点没能去掉它,比如重载丢了计时器,不能一直挂着「00:00」、桌面也不能因此一直每秒重画)
109 */
110function liveBadges(list: readonly Badge[] | undefined, now: number): Shown[] {
111 return (list ?? []).filter(
112 (b): b is Shown => b.text !== null && b.text !== '' && (typeof b.endsAt !== 'number' || b.endsAt > now - 3000),
113 )
114}
115
116/** 胶囊上的字(按当前语言挑):有倒计时的在后面加上剩余时间 mm:ss */
117function badgeLabel(b: Shown, now: number, lang: 'zh' | 'en'): string {
118 const text = typeof b.text === 'string' ? b.text : b.text[lang]
119 return typeof b.endsAt === 'number' ? `${text} ${mmss(b.endsAt - now)}` : text
120}
121
122/** 终端里胶囊、提示的颜色。强调色只做底色(浅色终端上黄字看不清) */
123function termTone(lk: Look, tone: Tone): { color: string; backgroundColor?: string } {
124 switch (tone) {
125 case 'red':
126 return { color: lk.c.red }
127 case 'accent':
128 return { color: lk.c.onAccent, backgroundColor: lk.c.accent }
129 case 'ink':
130 return { color: lk.c.ink }
131 default:
132 return { color: lk.c.pencil }
133 }
134}
135
136/** 按显示宽度补空格,让几行的标签对齐(中文占两格) */
137function padTo(text: string, cols: number): string {
138 return text + ' '.repeat(Math.max(0, cols - width(text)))
139}
140
141const money = (usd: number | null) => (usd === null ? '—' : `$${usd.toFixed(2)}`)
142
143async function refreshUsage($: EngineInterface) {
144 try {
145 const u = await $.session.usage()
146 const { tokens, window, percent } = u.context
147 const ctx = percent ?? (tokens !== undefined && window > 0 ? (tokens / window) * 100 : null)
148 const five = u.rateLimits.find(r => r.kind === 'five_hour') ?? u.rateLimits[0]
149 await update($, usage, () => ({ ctx, quota: five?.percentUsed ?? null, usd: u.cost?.usd ?? null }))
150 } catch {
151 // 读不到就沿用上一次的数
152 }
153}
154
155/**
156 * 状态栏开不开:用户按过开关就照用户的;没按过时,用户自己设了状态栏(statusLine)就不开,没设才开。
157 * lemo-core 还没扫完(scan 是 null)先当不开,免得用户有自己的状态栏时先冒出来一下(照用户自己的配置来)
158 */
159async function isStatusOn($: EngineInterface): Promise<boolean> {
160 const pref = await read($, statusPref)
161 if (pref !== null) return pref
162 const sc = (await $.state.get(LemoScanRef)).value ?? null
163 return sc !== null && !sc.statusLine
164}
165
166// 状态栏:品牌短名 ┊ T03 ┊ 上下文 12% ┊ 各胶囊。
167// 由 session.start 里的每秒定时器调:有倒计时胶囊时文字每秒变、每秒发;没有就文字不变、不发。
168// 这样别的 mod 加减胶囊、lemo-core 换语言换风格,一秒内也跟上,不用每处都来通知。
169// 开关关着时不发;之前发过的去掉(lemo-core 的扫描晚一步到、用户刚关掉,都走这里)
170async function showStatus($: EngineInterface) {
171 if (!(await isStatusOn($))) {
172 if (lastStatus !== '') $.ui.status(undefined)
173 lastStatus = ''
174 return
175 }
176 const lk = await look($)
177 const S = STR[lk.lang]
178 const n = await seqOf($)
179 const u = await read($, usage)
180 const now = await $.clock.now()
181 const list = liveBadges((await $.state.get(BadgesRef)).value, now)
182 const parts = [word(lk, 'lemo-meter.statusBrand', S.status.brand), exp(n)]
183 if (u.ctx !== null) parts.push(`${S.status.ctx} ${pct(u.ctx)}`)
184 for (const b of list) parts.push(badgeLabel(b, now, lk.lang))
185 const text = parts.join(' ┊ ')
186 if (text === lastStatus) return
187 lastStatus = text
188 $.ui.status(text)
189}
190
191/**
192 * HEAD 文件的内容 → 分支名。「ref: refs/heads/main」是 main;分离头指针(只有一串提交号)显示前 7 位;
193 * 指向别处的 ref(少见)原样显示;认不出是 null
194 */
195function headBranch(text: string): string | null {
196 const t = text.trim()
197 const ref = /^ref:\s*(\S+)$/.exec(t)?.[1]
198 if (ref !== undefined) return ref.replace(/^refs\/heads\//, '')
199 return /^[0-9a-f]{7,64}$/i.test(t) ? t.slice(0, 7) : null
200}
201
202// 去掉末尾的路径分隔符;往上一层(Windows 的反斜杠也认)
203const trimSep = (p: string) => p.replace(/[\\/]+$/, '')
204const parentOf = (p: string) => p.replace(/[\\/][^\\/]*$/, '')
205const isAbsPath = (p: string) => /^([\\/]|[A-Za-z]:[\\/])/.test(p)
206
207/**
208 * 读当前分支:从会话目录往上找 .git(和 git 自己一样,在仓库的子目录里打开也认得),读里面的 HEAD 文件。
209 * 不跑 git 命令:mod 不在用户的电脑上起程序。
210 * .git 是个文件时(git worktree、子模块),里面写着「gitdir: <真正的目录>」,去那个目录读 HEAD。
211 * 不是 git 仓库、读不到都是 null
212 */
213async function readBranch($: EngineInterface): Promise<string | null> {
214 let dir = trimSep(await $.session.cwd())
215 // 一层一层往上,最多 64 层;到了最上面(/ 或 C:)就停,不去读根目录(「//.git」会被当成网络路径)
216 for (let i = 0; i < 64 && dir !== ''; i++) {
217 const dot = `${dir}/.git`
218 const st = await $.fs.stat(dot).catch(() => null)
219 if (st !== null) {
220 let gitDir = dot
221 if (st.kind === 'file') {
222 const to = /^gitdir:\s*(.+?)\s*$/m.exec(await $.fs.read(dot))?.[1]
223 if (to === undefined) return null
224 gitDir = trimSep(isAbsPath(to) ? to : `${dir}/${to}`)
225 }
226 return headBranch(await $.fs.read(`${gitDir}/HEAD`))
227 }
228 const up = parentOf(dir)
229 if (up === dir) break
230 dir = up
231 }
232 return null
233}
234
235async function refreshBranch($: EngineInterface) {
236 const next = await readBranch($).catch(() => null)
237 if (next !== (await read($, branch))) await update($, branch, () => next)
238}
239
240// 版本号比大小:只比前三段数字
241function isNewerVersion(a: string, b: string): boolean {
242 const pa = a.split(/[.-]/).slice(0, 3).map(Number)
243 const pb = b.split(/[.-]/).slice(0, 3).map(Number)
244 for (let i = 0; i < 3; i++) {
245 const x = pa[i] ?? 0
246 const y = pb[i] ?? 0
247 if (x !== y) return x > y
248 }
249 return false
250}
251
252// 回复里没有版本号时存的记号,画的时候换成当前语言的一句
253const NO_VERSION = 'no-version'
254
255// $.http:联网查 npm 上 Claude Code 的最新版本,和自己的版本比
256async function checkVersion($: EngineInterface) {
257 await update($, latest, (): LemoMeterLatest => ({ ...IDLE, status: 'busy' }))
258 try {
259 const res = await $.http.fetch('https://registry.npmjs.org/@anthropic-ai/claude-code/latest', { headers: { accept: 'application/json' } })
260 if (!res.ok) throw new Error(`HTTP ${res.status}`)
261 const body = JSON.parse(res.text) as { version?: unknown }
262 const newest = typeof body.version === 'string' ? body.version : ''
263 if (newest === '') throw new Error(NO_VERSION)
264 const mine = (await $.session.version()).version
265 await update($, latest, (): LemoMeterLatest => ({ ...IDLE, status: 'done', newest, mine, isNewer: isNewerVersion(newest, mine) }))
266 } catch (err) {
267 const why = err instanceof Error ? err.message : String(err)
268 await update($, latest, (): LemoMeterLatest => ({ ...IDLE, status: 'error', why }))
269 }
270}
271
272/** 横条本身:桌面一张 SVG,终端是像素小画 + 刻度尺 + 底行 */
273async function drawBand($: EngineInterface, e: RenderInput<'AbovePrompt'>): Promise<RenderElement> {
274 const lk = await look($)
275 const S = STR[lk.lang]
276 const u = await read($, usage)
277 const n = await seqOf($)
278 const br = await read($, branch)
279 const now = await $.clock.now()
280 const list = liveBadges((await $.state.get(BadgesRef)).value, now)
281 const shown = (await $.state.get(NoticeRef)).value ?? null
282 // 读了 frame 就每秒重画一次。有倒计时的胶囊时要读,剩余时间跟着走;
283 // 终端有像素小画时也读,小画一直冒泡。
284 // 桌面没有倒计时就不读,免得 SVG 跟着一直重画、闪(平时让 SVG 自己的气泡动画跑)
285 const ticking = list.some(b => typeof b.endsAt === 'number' && b.endsAt > now)
286 const animate = e.surface === 'terminal' && lk.st.sprite !== null && lk.st.sprite.frames.length > 1
287 const tick = ticking || animate ? ((await $.state.get(FrameRef)).value ?? 0) : 0
288 const pills: Pill[] = list.map(b => ({ text: badgeLabel(b, now, lk.lang), tone: b.tone }))
289 const brandName = word(lk, 'lemo-core.title', 'lemo-mod')
290
291 if (e.surface === 'desktop') {
292 const { Box, Svg } = $.ui.resolve(e)
293 const data = {
294 ctx: u.ctx,
295 quota: u.quota,
296 usd: u.usd,
297 label: fill(word(lk, 'lemo-meter.trial', S.trial), { no: exp(n) }),
298 pills: br === null ? pills : [...pills, { text: `⎇ ${br}`, tone: 'grey' as const }],
299 notice: shown === null ? null : { text: shown.text, tone: shown.tone },
300 brand: brandName,
301 brandSub: word(lk, 'lemo-meter.brandSub', S.brandSub),
302 words: { ctx: S.band.ctx, quota: S.band.quota, cost: S.band.cost, pending: word(lk, 'lemo-meter.pending', S.band.pending) },
303 }
304 const alt = `${brandName}: ${S.band.ctx} ${pct(u.ctx)}, ${S.band.quota} ${pct(u.quota)}`
305 const guess = Math.round(e.props.bodyColumns * CELL_PX)
306 // 不给 width,外面包一层竖排 Box,竖排容器会把它横向拉满(桌面上单独放只有 280 像素)。
307 // 不加 isInteractive:那样 SVG 装在一个沙盒框里,每重画一次整个框重新加载、闪一下;画成图片,内容没变就不闪
308 return (
309 <Box flexDirection="column" width="100%">
310 <Svg source={bandSvg(data, { width: '100%', guess }, lk.c, lk.st.motif, lk.st.sprite)} alt={alt} height={BAND_H} />
311 </Box>
312 )
313 }
314
315 // 终端(以及别的界面):两行刻度尺 + 一行底行;提示出现时顶替底行
316 const { Box, Text } = $.ui.resolve(e)
317 const ink = bareInk(lk, e.surface)
318 const ctxLabel = word(lk, 'lemo-meter.tbandCtx', S.tband.ctx)
319 const quotaLabel = word(lk, 'lemo-meter.tbandQuota', S.tband.quota)
320 const cols = Math.max(width(ctxLabel), width(quotaLabel))
321 const scale = (key: string, label: string, v: number | null) => {
322 const r = ruler(v)
323 return (
324 <Box key={key} flexDirection="row">
325 <Text color={lk.c.pencil}>{padTo(label, cols) + ' '}</Text>
326 <Text color={lk.c.grid}>{r.before}</Text>
327 <Text color={lk.c.ink}>●</Text>
328 <Text color={lk.c.grid}>{r.after}</Text>
329 <Text {...ink}>{' ' + (v === null ? word(lk, 'lemo-meter.pending', S.band.pending) : pct(v))}</Text>
330 </Box>
331 )
332 }
333 const sample = word(lk, 'lemo-meter.tbandSample', S.tband.sample)
334 // 底行放不下时截掉,不折行:折出来的行会把刻度尺往上挤,分支名也会断成「⎇」和「main」两行
335 let foot: RenderChildren
336 if (shown !== null) {
337 foot = <Text {...termTone(lk, shown.tone)} bold wrap="truncate">{` ● ${shown.text} `}</Text>
338 } else {
339 foot = (
340 <Text color={lk.c.pencil} wrap="truncate">
341 {`${sample} ${exp(n)}${u.usd === null ? '' : ` · ${money(u.usd)}`}`}
342 {pills.map((p, i) => (
343 <Text key={`badge-${i}`} color={lk.c.pencil}>
344 {' · '}
345 <Text {...termTone(lk, p.tone)}>{p.tone === 'accent' ? ` ${p.text} ` : p.text}</Text>
346 </Text>
347 ))}
348 {br === null ? '' : ` · ⎇ ${br}`}
349 </Text>
350 )
351 }
352 // 地方不够时一级一级往下减:先不画小画,刻度尺也放不下(面板停在旁边、对话区很窄,或者面板在输入框上方、
353 // 只给一两行)时改成一行文字。不然刻度尺会被挤成竖排,引擎再把多出来的行收成「↓ 3 more」。
354 // 引擎在横条第一行的右端画收起按钮「[-]」(3 格):第一行右边留出 FOLD 格,数值不被它盖住
355 const room = e.props.bodyColumns
356 const rows = e.props.maxRows
357 const pending = word(lk, 'lemo-meter.pending', S.band.pending)
358 const rowW = cols + 1 + width(ruler(0).before) + 1 + width(ruler(0).after) + 1 + Math.max(width(pending), 4)
359 if (room < rowW + FOLD || rows < 3) {
360 const val = (v: number | null) => (v === null ? pending : pct(v))
361 const brief = (
362 <Box key="meter-brief" paddingRight={FOLD}>
363 <Text {...ink} wrap="truncate">{`${ctxLabel} ${val(u.ctx)} · ${quotaLabel} ${val(u.quota)}`}</Text>
364 </Box>
365 )
366 // 只给一行时:有提示就只写提示,没有就只写用量
367 if (rows < 2) return shown !== null ? <Box paddingRight={FOLD}>{foot}</Box> : brief
368 return (
369 <Box flexDirection="column">
370 {brief}
371 {foot}
372 </Box>
373 )
374 }
375 const meter = (
376 <Box flexDirection="column">
377 {scale('meter-ctx', ctxLabel, u.ctx)}
378 {scale('meter-quota', quotaLabel, u.quota)}
379 {foot}
380 </Box>
381 )
382 const sprite = lk.st.sprite
383 if (e.surface === 'terminal' && sprite !== null && sprite.frames.length > 0) {
384 // 风格的像素小画:每秒换一帧。放不下(宽度或行数不够)就不画
385 const frame = sprite.frames[tick % sprite.frames.length] ?? sprite.frames[0] ?? []
386 const cells = spriteCells(sprite.palette, frame)
387 if (room >= cells.columns + 2 + rowW + FOLD && rows >= cells.rows) {
388 const { Raster } = $.ui.resolve(e)
389 return (
390 <Box flexDirection="row" gap={2} alignItems="center">
391 <Raster key="meter-sprite" {...cells} />
392 {meter}
393 </Box>
394 )
395 }
396 }
397 return meter
398}
399
400// 读回存下来的开关($.store 全局记住)。没存过就照默认:横条开,状态栏照扫描
401async function loadPrefs($: EngineInterface) {
402 try {
403 const band = await $.store.get('band')
404 if (typeof band === 'boolean') await update($, bandOn, () => band)
405 const status = await $.store.get('status')
406 await update($, statusPref, () => (typeof status === 'boolean' ? status : null))
407 } catch {
408 // 读不到存档就照默认
409 }
410}
411
412// 这个会话画在桌面上吗。桌面 App 自带一份 Claude Code、跟着 App 更新,查 npm 上命令行版的号会误导,桌面上不提查新版本
413async function onDesktop($: EngineInterface): Promise<boolean> {
414 try {
415 return (await $.session.surfaces()).includes('desktop')
416 } catch {
417 return false
418 }
419}
420
421// 能力清单里这个 mod 的几行(安全页照它画):横条、状态栏是外观;查新版本是点了才联网,桌面上不列
422async function meterCaps($: EngineInterface): Promise<Cap[]> {
423 const both = (k: 'band' | 'status' | 'version') => ({
424 title: { zh: STR.zh.cap[k].title, en: STR.en.cap[k].title },
425 desc: { zh: STR.zh.cap[k].desc, en: STR.en.cap[k].desc },
426 })
427 const rows: Cap[] = [
428 { mod: 'lemo-meter', id: 'band', kind: 'look', on: await read($, bandOn), ...both('band') },
429 { mod: 'lemo-meter', id: 'status', kind: 'look', on: await isStatusOn($), ...both('status') },
430 ]
431 if (!(await onDesktop($))) rows.push({ mod: 'lemo-meter', id: 'version', kind: 'net', on: false, manual: true, ...both('version') })
432 return rows
433}
434
435export const register: Register = on => {
436 // ---------- 生命周期 ----------
437
438 on('session.start', async ($, e, next) => {
439 await $.lemo.join({
440 mod: 'lemo-meter',
441 title: { zh: STR.zh.title, en: STR.en.title },
442 tabs: ['main', 'bg'],
443 always: { zh: STR.zh.always, en: STR.en.always },
444 })
445 await loadPrefs($)
446 const r = await next(e)
447 await refreshUsage($)
448 await showStatus($)
449 // 状态栏每秒看一次:文字没变不重发,所以只有倒计时走字、胶囊增减时才真的更新。
450 // session.start 可能再来一次(别的插件热重载时),先停掉旧的,免得两个一起跑
451 statusTimer?.cancel()
452 statusTimer = $.clock.every(1000, () => {
453 void showStatus($)
454 })
455 // 不急的放到后面,免得拖慢会话启动
456 $.clock.after(0, () => {
457 void refreshBranch($)
458 })
459 return r
460 })
461
462 // 每轮结束:重读用量,记一次上下文百分比(走势只留最近 24 个),再看一眼分支(这一轮可能切过)
463 on('turn.complete', async ($, e, next) => {
464 const r = await next(e)
465 if (e.agentId !== undefined) return r
466 await refreshUsage($)
467 if (!e.isAborted) {
468 const { ctx } = await read($, usage)
469 if (ctx !== null) await update($, history, h => [...h, ctx].slice(-24))
470 }
471 await showStatus($)
472 $.clock.after(0, () => {
473 void refreshBranch($)
474 })
475 return r
476 })
477
478 // ---------- 输入框上方的横条 ----------
479 // 别的 mod(比如 lemo-pomodoro)也会挂 AbovePrompt。同一层的插件谁在外层不一定,所以两种顺序都要对:
480 // 它在外层时拿到的是这里的横条,自己往下接;它在里层时,next 拿到的就不是引擎原样,接在横条下面
481
482 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
483 // 问卷占着横条时让给它;安全页上关了横条就原样交给里层
484 if (e.props.hasSurvey || !(await read($, bandOn))) return next(e)
485 const below = await next(e)
486 const band = await drawBand($, e)
487 if (below.type === 'engine') return band
488 // 引擎原样不许放进带 width 的 Box(会拒绝整棵树),所以外面这层不给 width
489 const { Box } = $.ui.resolve(e)
490 return (
491 <Box flexDirection="column" gap={1}>
492 {band}
493 {below}
494 </Box>
495 )
496 })
497
498 // ---------- 统一面板里的卡片 ----------
499
500 on('ui.render', { component: 'Pane', requestId: HUB }, async ($, e, next) => {
501 // 面板按钮用 steady:桌面上面板拿到焦点会再画一次,按钮还是同一个,第一下点击不丢(见 shared/lemo.tsx)。
502 // 每次画都先调它,这次不画按钮也调:下一次画才知道哪些按钮上一次画过
503 const Button = steady($.ui.resolve(e).Button, e.surface)
504 const inner = await next(e)
505 const isDesk = e.surface === 'desktop'
506 const at = await hubTab($, e.surface)
507 if (!isDesk && at !== 'main' && at !== 'bg') return inner
508 const lk = await look($, e.props)
509 const S = STR[lk.lang]
510 const el = $.ui.resolve(e)
511 const { Box, Text, Link } = el
512 const specs: CardSpec[] = []
513 let head: RenderElement | undefined
514
515 if (isDesk) {
516 // 桌面:每一页最上面是一张数据卡(标题、编号、上下文、额度、花费、走势)。
517 // lemo-core 看到装了 lemo-meter,桌面上就不画自己的标题,只画分页
518 const u = await read($, usage)
519 const hist = await read($, history)
520 const n = await seqOf($)
521 const { Svg } = $.ui.resolve(e)
522 const title = word(lk, 'lemo-core.title', 'lemo-mod')
523 const words = { title, sub: word(lk, 'lemo-meter.brandSub', S.brandSub), ctx: S.band.ctx, quota: S.band.quota, cost: S.band.cost, trend: S.usage.trend, noTrend: S.usage.noTrend }
524 const data = { ...u, history: hist, label: fill(word(lk, 'lemo-meter.trial', S.trial), { no: exp(n) }) }
525 // Svg 不给 width、外面包竖排 Box 才会横向拉满;不加 isInteractive(会闪)
526 head = (
527 <Box key="meter-head" flexDirection="column" width="100%">
528 <Svg source={paneSvg(data, Math.round(e.props.bodyColumns * CELL_PX), lk.c, lk.st.motif, words)} alt={`${title}: ${S.band.ctx} ${pct(u.ctx)}, ${S.band.quota} ${pct(u.quota)}, ${S.band.cost} ${money(u.usd)}`} height={PANE_H} />
529 </Box>
530 )
531 }
532
533 if (at === 'main' && !isDesk) {
534 // 常用:用量(终端)。一行数据 + 一行走势;桌面的用量在顶上的数据卡里
535 const u = await read($, usage)
536 const hist = await read($, history)
537 const extra = (
538 <Box flexDirection="column">
539 <Text color={lk.c.pencil}>{`${S.band.ctx} ${pct(u.ctx)} ┊ ${S.band.quota} ${pct(u.quota)} ┊ ${S.band.cost} ${money(u.usd)}`}</Text>
540 <Text color={lk.c.ink}>{hist.length > 0 ? `${S.usage.trend} ${sparkline(hist)}` : S.usage.noTrend}</Text>
541 </Box>
542 )
543 specs.push({ id: 'meter-usage', title: word(lk, 'lemo-meter.usageTitle', S.usage.title), desc: S.usage.desc, extra })
544 } else if (at === 'bg') {
545 // 后台:git 分支、检查新版本
546 const br = await read($, branch)
547 specs.push({
548 id: 'meter-branch',
549 title: S.branch.title,
550 desc: br === null ? S.branch.none : fill(S.branch.value, { b: br }),
551 extra: <Text dimColor>{S.branch.desc}</Text>,
552 })
553 }
554 // 检查新版本只在终端有:桌面 App 自带一份 Claude Code、跟着 App 更新,查的却是 npm 上命令行版的号,会误导
555 // (比如桌面写「2.1.288 is out, you have 2.1.286」,命令行已是最新)
556 if (at === 'bg' && !isDesk) {
557 const v = await read($, latest)
558 const status =
559 v.status === 'idle'
560 ? S.version.desc
561 : v.status === 'busy'
562 ? S.version.busy
563 : v.status === 'error'
564 ? fill(S.version.failed, { why: v.why === NO_VERSION ? S.version.noVersion : v.why })
565 : v.isNewer
566 ? fill(S.version.newer, { latest: v.newest, cur: v.mine })
567 : fill(S.version.same, { v: v.mine })
568 specs.push({
569 id: 'meter-version',
570 title: S.version.title,
571 desc: status,
572 buttons: <Button key="meter-check" label={S.version.btn} {...sec(e.surface)} onPress={() => void checkVersion($)} />,
573 extra: (
574 <Text {...inkOf(lk, e.surface)}>
575 {'→ '}
576 <Link href={CHANGELOG} label={S.version.changelog} />
577 </Text>
578 ),
579 })
580 }
581 return hubWrap(el, lk, e.surface, inner, specs, head)
582 })
583
584 // ---------- 安全页:能力清单、开关、「全部关闭」 ----------
585
586 on('lemo.caps', async ($, e, next) => {
587 const r = await next(e)
588 return { value: [...(r.value ?? []), ...(await meterCaps($))] }
589 })
590
591 // 安全页上按了横条、状态栏的开关:存进 $.store(全局记住),横条、状态栏马上跟着变
592 on('lemo.toggle', async ($, e, next) => {
593 if (e.mod !== 'lemo-meter') return next(e)
594 const want = e.on
595 if (want === undefined) return { value: false }
596 if (e.id === 'band') {
597 await update($, bandOn, () => want)
598 await $.store.set('band', want)
599 } else if (e.id === 'status') {
600 await update($, statusPref, () => want)
601 await $.store.set('status', want)
602 await showStatus($)
603 } else {
604 return { value: false }
605 }
606 return { value: true }
607 })
608
609 // 「全部关闭」只关外观以外的;这个 mod 只有外观和点了才做的查新版本,没有要关的,往下传
610 on('lemo.off', async ($, e, next) => next(e))
611}
612hooks/draw.ts 376 lines1// lemo-meter 的画法:终端的刻度尺、走势、像素小画,桌面的 SVG 卡片。
2// 这里的函数都不收 $(校验规定 $ 只能传给同一个文件里定义的函数)。
3// 颜色一律从风格(lk.c)取:要浅一点、深一点的颜色,用 mix 往卡片底色或深色字那边调,不写死十六进制。
4
5import type { Colors, Style } from './shared/lemo'
6
7/** 胶囊和提示的颜色:红是出错、限时停下,accent 是风格强调色,ink 普通,grey 次要 */
8export type Tone = 'red' | 'accent' | 'ink' | 'grey'
9
10/** 一颗胶囊:文字和颜色 */
11export type Pill = { text: string; tone: Tone }
12
13// ---------- 颜色 ----------
14
15function rgb(hex: string): [number, number, number] | null {
16 const m = /^#([0-9a-f]{6})$/i.exec(hex.trim())
17 if (m === null) return null
18 const n = parseInt(m[1] ?? '0', 16)
19 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
20}
21
22/** 两个颜色按 t 混合(t=0 是 a,t=1 是 b);认不出的颜色原样返回 a */
23export function mix(a: string, b: string, t: number): string {
24 const x = rgb(a)
25 const y = rgb(b)
26 if (x === null || y === null) return a
27 const ch = (i: 0 | 1 | 2) => Math.round(x[i] + (y[i] - x[i]) * t).toString(16).padStart(2, '0')
28 return `#${ch(0)}${ch(1)}${ch(2)}`
29}
30
31// 浅一点:往桌面卡片底色那边调;深一点:往桌面卡片上的大字那边调
32const pale = (c: Colors, x: string, t: number) => mix(x, c.deskCardFill, t)
33const deep = (c: Colors, x: string, t: number) => mix(x, c.deskFigure, t)
34
35// ---------- 终端 ----------
36
37/** 刻度尺:├───●──────┤,返回圆点前后两段 */
38export function ruler(value: number | null, inner = 12): { before: string; after: string } {
39 const v = Math.max(0, Math.min(100, value ?? 0))
40 const pos = Math.round((v / 100) * (inner - 1))
41 return { before: '├' + '─'.repeat(pos), after: '─'.repeat(inner - 1 - pos) + '┤' }
42}
43
44/** 走势:每个值一格,▁ 到 █ */
45export function sparkline(values: readonly number[]): string {
46 const bars = '▁▂▃▄▅▆▇█'
47 return values.map(v => bars[Math.max(0, Math.min(7, Math.round((v / 100) * 7)))] ?? '▁').join('')
48}
49
50// ---------- 像素格(终端 Raster):每格上下两个像素,用 ▀ ▄ 拼 ----------
51
52const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
53
54function base64(bytes: Uint8Array): string {
55 let out = ''
56 let i = 0
57 for (; i + 2 < bytes.length; i += 3) {
58 const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
59 out += (B64[(n >> 18) & 63] ?? '') + (B64[(n >> 12) & 63] ?? '') + (B64[(n >> 6) & 63] ?? '') + (B64[n & 63] ?? '')
60 }
61 const rest = bytes.length - i
62 if (rest > 0) {
63 const n = ((bytes[i] ?? 0) << 16) | (rest === 2 ? (bytes[i + 1] ?? 0) << 8 : 0)
64 out += (B64[(n >> 18) & 63] ?? '') + (B64[(n >> 12) & 63] ?? '') + (rest === 2 ? (B64[(n >> 6) & 63] ?? '') : '=') + '='
65 }
66 return out
67}
68
69// 终端默认色(透明)
70const DEFAULT = 0x01000000
71
72/**
73 * 风格的像素小画画成 Raster 的格子:调色板里没有的字符(比如「.」)是透明的。
74 * 每格上下两个像素:两个都有色用 ▀(前景上、背景下),只有一个用 ▀ 或 ▄
75 */
76export function spriteCells(palette: Readonly<Record<string, string>>, frame: readonly string[]): { columns: number; rows: number; cells: string } {
77 const color = (ch: string | undefined) => {
78 const hex = ch === undefined ? undefined : palette[ch]
79 const v = hex === undefined ? null : rgb(hex)
80 return v === null ? null : (v[0] << 16) | (v[1] << 8) | v[2]
81 }
82 const columns = Math.max(1, ...frame.map(r => r.length))
83 const rows = Math.max(1, Math.ceil(frame.length / 2))
84 const bytes = new Uint8Array(columns * rows * 12)
85 const view = new DataView(bytes.buffer)
86 for (let r = 0; r < rows; r++) {
87 const top = frame[r * 2] ?? ''
88 const bottom = frame[r * 2 + 1] ?? ''
89 for (let x = 0; x < columns; x++) {
90 const t = color(top[x])
91 const b = color(bottom[x])
92 let cell: [number, number, number]
93 if (t === null && b === null) cell = [0x20, DEFAULT, DEFAULT]
94 else if (t !== null && b !== null) cell = [0x2580, t, b]
95 else if (t !== null) cell = [0x2580, t, DEFAULT]
96 else cell = [0x2584, b ?? DEFAULT, DEFAULT]
97 const off = (r * columns + x) * 12
98 view.setUint32(off, cell[0], true)
99 view.setUint32(off + 4, cell[1], true)
100 view.setUint32(off + 8, cell[2], true)
101 }
102 }
103 return { columns, rows, cells: base64(bytes) }
104}
105
106// ---------- SVG(桌面) ----------
107
108const FONT = `-apple-system, 'PingFang SC', 'Helvetica Neue', sans-serif`
109const esc = (t: string) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
110// SVG 里估算文字宽度:中日韩字符按字号算,其他按 0.6 个字号
111const textW = (t: string, size: number) => [...t].reduce((n, ch) => n + ((ch.codePointAt(0) ?? 0) > 0x2e80 ? size : size * 0.6), 0)
112
113/** 放得下就原样;放不下就截到放得下,末尾加「…」;连一个字加「…」都放不下时给空串(不画) */
114function fitText(t: string, max: number, size: number): string {
115 if (textW(t, size) <= max) return t
116 let out = ''
117 for (const ch of t) {
118 if (textW(`${out}${ch}…`, size) > max) break
119 out += ch
120 }
121 return out === '' ? '' : `${out}…`
122}
123
124/** 胶囊、提示条的底色和字色,由风格颜色调出来 */
125function toneInk(c: Colors, tone: Tone): { bg: string; fg: string } {
126 switch (tone) {
127 case 'red':
128 return { bg: pale(c, c.red, 0.85), fg: deep(c, c.red, 0.25) }
129 case 'accent':
130 return { bg: pale(c, c.accent, 0.7), fg: deep(c, c.accent, 0.6) }
131 case 'ink':
132 return { bg: c.chip, fg: c.inkDark }
133 default:
134 return { bg: pale(c, c.pencil, 0.82), fg: deep(c, c.pencil, 0.45) }
135 }
136}
137
138// 两种进度条的渐变:上下文用墨水色,额度用强调色。
139// id 带上前缀(横条 lmb、面板 lmu):万一两张 SVG 画进同一个页面,id 撞了会用错另一张的颜色
140function gradients(c: Colors, p: string): string {
141 return `<linearGradient id="${p}-ink" x1="0" x2="1"><stop offset="0" stop-color="${c.ink}"/><stop offset="1" stop-color="${c.inkDark}"/></linearGradient>
142<linearGradient id="${p}-accent" x1="0" x2="1"><stop offset="0" stop-color="${pale(c, c.accent, 0.25)}"/><stop offset="1" stop-color="${c.bubbleAccent}"/></linearGradient>`
143}
144
145// 胶囊:右端对齐到 xEnd(在贴右边缘的嵌套 svg 里,xEnd 是负数)
146function pill(c: Colors, xEnd: number, y: number, text: string, tone: Tone): { w: number; svg: string } {
147 const { bg, fg } = toneInk(c, tone)
148 const w = 16 + textW(text, 11.5)
149 return {
150 w,
151 svg: `<rect x="${xEnd - w}" y="${y}" width="${w}" height="20" rx="10" fill="${bg}"/>
152<text x="${xEnd - w / 2}" y="${y + 14}" text-anchor="middle" font-size="11.5" font-weight="700" fill="${fg}">${esc(text)}</text>`,
153 }
154}
155
156// 卡片底板:方格纸 + 圆角描边,宽度跟着容器走
157function paper(c: Colors, guess: number, h: number): string {
158 const full = `x="1" y="1" width="${guess - 2}" height="${h - 2}" style="width:calc(100% - 2px)" rx="14"`
159 return `<defs><pattern id="lm-grid" width="10" height="10" patternUnits="userSpaceOnUse"><path d="M10 0H0V10" fill="none" stroke="${c.deskCardBorder}" stroke-width="0.7"/></pattern>
160<pattern id="lm-dash" width="6" height="1" patternUnits="userSpaceOnUse"><rect width="3" height="1" fill="${pale(c, c.grid, 0.65)}"/></pattern></defs>
161<rect ${full} fill="${c.deskCardFill}"/><rect ${full} fill="url(#lm-grid)"/><rect ${full} fill="none" stroke="${mix(c.deskCardBorder, c.grid, 0.12)}"/>`
162}
163
164// 标题:风格的小图标 + 荧光笔划过的品牌名 + 小字副标题,返回标题右端的 x。
165// 烧瓶风格(柠檬实验室)的图标是一片柠檬;不画图案的风格只画一个强调色圆点
166function brand(c: Colors, motif: Style['motif'], title: string, subtitle: string): { svg: string; end: number } {
167 const w = textW(title, 14) + 4
168 const sub = subtitle === '' ? '' : `<text x="${40 + w + 6}" y="25.5" font-size="9" font-weight="600" fill="${c.pencil}" letter-spacing="2">${esc(subtitle)}</text>`
169 const end = 40 + w + (subtitle === '' ? 0 : 6 + subtitle.length * 8)
170 const mark =
171 motif === 'flask'
172 ? `<circle r="8" fill="${c.accent}"/><circle r="5.8" fill="${pale(c, c.accent, 0.55)}"/><path d="M0 -5.8V5.8M-5 -2.9L5 2.9M-5 2.9L5 -2.9" stroke="${c.accent}" stroke-width="1.1"/>`
173 : `<circle r="6" fill="${c.accent}"/>`
174 return {
175 end,
176 svg: `<g transform="translate(24 21)">${mark}</g>
177<rect x="38" y="17" width="${w}" height="10" rx="2" fill="${c.accent}" opacity="0.5"/>
178<text x="40" y="26" font-size="14" font-weight="700" fill="${c.deskFigure}" letter-spacing="0.5">${esc(title)}</text>${sub}`,
179 }
180}
181
182// 烧瓶:柠檬水里冒气泡(SMIL 动画,Svg 不加 isInteractive 时照样动)。放在 (18, 44),56×52
183function flaskSvg(c: Colors): string {
184 const glass = 'M22 2 H34 V18 L50 43 Q54 50 46 50 H10 Q2 50 6 43 L22 18 Z'
185 const bubble = (x: number, r: number, dur: number, begin: number) =>
186 `<circle cx="${x}" cy="47" r="${r}" fill="${c.deskCardFill}" opacity="0.9"><animate attributeName="cy" values="47;31" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.95;0" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/></circle>`
187 return `<defs><clipPath id="lm-glass"><path d="${glass}"/></clipPath></defs>
188<g transform="translate(18 44)">
189<g clip-path="url(#lm-glass)"><rect x="0" y="30" width="56" height="24" fill="${c.accent}"/><path d="M0 30.5 Q28 27.5 56 30.5" stroke="${pale(c, c.accent, 0.5)}" stroke-width="2.5" fill="none"/>${bubble(20, 2.2, 2.6, 0)}${bubble(28, 1.6, 2.1, 0.8)}${bubble(35, 2.6, 3.1, 1.5)}</g>
190<path d="${glass}" fill="none" stroke="${c.grid}" stroke-width="2.2" stroke-linejoin="round"/>
191<path d="M19 2 H37" stroke="${c.grid}" stroke-width="2.6" stroke-linecap="round"/>
192<path d="M25.5 7 V19 L15 37" stroke="${c.deskCardFill}" stroke-width="1.8" stroke-linecap="round" fill="none" opacity="0.85"/>
193<path d="M40 31 h4 M43 37 h4" stroke="${c.grid}" stroke-width="1" opacity="0.7"/>
194</g>`
195}
196
197// 像素小画放大画在烧瓶的位置(56×52 的格子里居中),每格 3.5 像素;几帧时每秒换一帧(SMIL,Svg 不加 isInteractive 时照样动)
198function spriteSvg(sprite: NonNullable<Style['sprite']>): string {
199 const frames = sprite.frames.filter(f => f.length > 0)
200 if (frames.length === 0) return ''
201 const cols = Math.max(...frames.flatMap(f => f.map(r => r.length)))
202 const rows = Math.max(...frames.map(f => f.length))
203 const px = Math.min(56 / cols, 52 / rows, 3.5)
204 const x0 = 18 + (56 - cols * px) / 2
205 const y0 = 44 + (52 - rows * px) / 2
206 const n = frames.length
207 const keyTimes = frames.map((_, i) => (i / n).toFixed(4)).join(';')
208 const groups = frames.map((frame, i) => {
209 let rects = ''
210 frame.forEach((row, y) => [...row].forEach((ch, x) => {
211 const fill = sprite.palette[ch]
212 if (fill !== undefined) rects += `<rect x="${(x * px).toFixed(2)}" y="${(y * px).toFixed(2)}" width="${px.toFixed(2)}" height="${px.toFixed(2)}" fill="${fill}"/>`
213 }))
214 const anim = n > 1
215 ? `<animate attributeName="opacity" values="${frames.map((_, j) => (j === i ? 1 : 0)).join(';')}" keyTimes="${keyTimes}" calcMode="discrete" dur="${n}s" repeatCount="indefinite"/>`
216 : ''
217 return `<g opacity="${i === 0 ? 1 : 0}">${anim}${rects}</g>`
218 })
219 return `<g transform="translate(${x0.toFixed(2)} ${y0.toFixed(2)})" shape-rendering="crispEdges">${groups.join('')}</g>`
220}
221
222// 桌面横条:一张带标题的方格纸卡片,宽度跟着窗口走,右边不留白。以后每个风格的桌面卡片都按这个规则做。
223// 左边的标题和图案位置固定;右边的胶囊、数值和花费放进贴右边缘的嵌套 <svg x="100%">;
224// 进度条和虚线用 CSS 的 calc() 撑满中间。每个撑开的元素同时写一个按估计宽度算好的 width 属性,
225// 万一桌面把 style 过滤掉,也还能按估计宽度画出来。
226export type BandData = {
227 ctx: number | null
228 quota: number | null
229 usd: number | null
230 /** 右上角最右边的胶囊:消息编号 */
231 label: string
232 /** 编号左边的胶囊,从右往左排:各 mod 的胶囊(已经带上剩余时间),最后是 git 分支 */
233 pills: readonly Pill[]
234 /** lemo-core 的提示条:标题右边,带一个闪的小圆点 */
235 notice: Pill | null
236 brand: string
237 brandSub: string
238 words: { ctx: string; quota: string; cost: string; pending: string }
239}
240export type BandFrame = {
241 // 写进 SVG 根节点的宽度:'100%' 表示跟着容器走
242 width: number | '100%'
243 // 估计的像素宽度,给撑开元素的 width 属性兜底
244 guess: number
245}
246
247export const BAND_H = 106
248
249export function bandSvg(o: BandData, f: BandFrame, c: Colors, motif: Style['motif'], sprite: Style['sprite'] = null): string {
250 const H = BAND_H
251 const art = motif === 'flask' ? flaskSvg(c) : motif === 'sprite' && sprite !== null ? spriteSvg(sprite) : ''
252 // 有图案时标签在图案右边;不画图案的风格标签靠左
253 const labelX = art === '' ? 24 : 92
254 // 进度条从标签右边开始:至少留 84,英文标签长就按估计宽度再往右
255 const trackX = labelX + Math.max(84, Math.ceil(Math.max(textW(o.words.ctx, 12), textW(o.words.quota, 12)) + 18))
256 // 进度条右边要留的地方:数值 44 + 间距 24 + 花费栏 70 + 边距 16
257 const reserve = trackX + 154
258 const trackGuess = Math.max(40, f.guess - reserve)
259 const along = (t: number) => `calc(${trackX}px + (100% - ${reserve}px) * ${t})`
260 const tickFill = pale(c, c.grid, 0.55)
261 const ticks = (y: number) =>
262 [0, 0.25, 0.5, 0.75, 1]
263 .map(t => `<rect x="${(trackX + t * trackGuess).toFixed(1)}" style="x:${along(t)}" y="${y + 11}" width="1" height="3" fill="${tickFill}"/>`)
264 .join('')
265 const meter = (y: number, label: string, v: number | null, grad: string) => {
266 const t = v === null ? 0 : Math.max(0, Math.min(1, v / 100))
267 const bar = t > 0
268 ? `<rect x="${trackX}" y="${y}" width="${Math.max(8, t * trackGuess).toFixed(1)}" style="width:max(8px, calc((100% - ${reserve}px) * ${t.toFixed(4)}))" height="8" rx="4" fill="url(#${grad})"/>`
269 : ''
270 return `<text x="${labelX}" y="${y + 8}" font-size="12" fill="${deep(c, c.pencil, 0.25)}">${esc(label)}</text>
271<rect x="${trackX}" y="${y}" width="${trackGuess}" style="width:calc(100% - ${reserve}px)" height="8" rx="4" fill="${c.chip}"/>
272${bar}
273${ticks(y)}`
274 }
275 const value = (y: number, v: number | null) =>
276 `<text x="-142" y="${y + 8}" font-size="12" font-weight="600" fill="${v === null ? c.pencil : c.deskFigure}">${esc(v === null ? o.words.pending : `${Math.round(v)}%`)}</text>`
277 // 右上角的胶囊从右往左排:消息编号、各 mod 的胶囊、git 分支
278 const wanted: Pill[] = [{ text: o.label, tone: 'ink' }, ...o.pills]
279 let xEnd = -16
280 let pills = ''
281 for (const p of wanted) {
282 const one = pill(c, xEnd, 11, p.text, p.tone)
283 pills += one.svg
284 xEnd -= one.w + 8
285 }
286 const head = brand(c, motif, o.brand, o.brandSub)
287 // 提示条:标题右边,带一个小圆点,颜色跟着提示的 tone。
288 // 长了在右上角的胶囊前面收住(末尾加「…」),不从胶囊底下穿过去;完整的话 App 另外弹出来。
289 // 截断按估计的宽度算,实际更窄时再用 clipPath 按真实宽度剪掉
290 let notice = ''
291 if (o.notice !== null) {
292 const { bg, fg } = toneInk(c, o.notice.tone)
293 const x = head.end + 14
294 // 最左边的胶囊离右边框 -xEnd - 8,和提示条之间再空 10
295 const gap = -xEnd - 8 + 10
296 const room = f.guess - gap - x
297 const text = fitText(o.notice.text, room - 30, 12)
298 if (text !== '') {
299 const w = 30 + textW(text, 12)
300 notice = `<clipPath id="lm-notice"><rect x="${x}" y="0" width="${Math.max(0, room)}" style="width:max(0px, calc(100% - ${gap + x}px))" height="38"/></clipPath>
301<g clip-path="url(#lm-notice)"><rect x="${x}" y="10" width="${w}" height="22" rx="11" fill="${bg}"/>
302<circle cx="${x + 13}" cy="21" r="3.5" fill="${fg}"><animate attributeName="opacity" values="1;0.3;1" dur="1.2s" repeatCount="indefinite"/></circle>
303<text x="${x + 22}" y="25.5" font-size="12" font-weight="700" fill="${fg}">${esc(text)}</text></g>`
304 }
305 }
306 return `<svg xmlns="http://www.w3.org/2000/svg" width="${f.width}" height="${H}" font-family="${FONT}">
307${paper(c, f.guess, H)}
308<defs>
309${gradients(c, 'lmb')}
310</defs>
311${head.svg}
312${notice}
313<rect x="16" y="38" width="${f.guess - 32}" style="width:calc(100% - 32px)" height="1" fill="url(#lm-dash)"/>
314${art}
315${meter(52, o.words.ctx, o.ctx, 'lmb-ink')}
316${meter(76, o.words.quota, o.quota, 'lmb-accent')}
317<svg x="100%" y="0" width="1" height="1" overflow="visible">
318${pills}
319${value(52, o.ctx)}
320${value(76, o.quota)}
321<text x="-16" y="60" text-anchor="end" font-size="11" fill="${c.pencil}">${esc(o.words.cost)}</text>
322<text x="-16" y="84" text-anchor="end" font-size="15" font-weight="700" fill="${o.usd === null ? c.pencil : c.deskFigure}">${o.usd === null ? '—' : '$' + o.usd.toFixed(2)}</text>
323</svg>
324</svg>`
325}
326
327// 面板「用量」卡片的桌面画法:三格数据(上下文、五小时额度、花费)+ 上下文走势,同样铺满宽度。
328// 它放在面板的卡片里(卡片自己有底色和边框),所以不画方格纸底板,也不画标题
329export type UsageData = { ctx: number | null; quota: number | null; usd: number | null; history: readonly number[] }
330
331// ---------- 桌面统一面板顶上的数据卡(标题、编号胶囊、三格数据、走势),每一页都有 ----------
332
333export const PANE_H = 150
334
335export type PaneData = UsageData & { label: string }
336export type PaneWords = { title: string; sub: string; ctx: string; quota: string; cost: string; trend: string; noTrend: string }
337
338export function paneSvg(o: PaneData, guess: number, c: Colors, motif: Style['motif'], w: PaneWords): string {
339 const H = PANE_H
340 const head = brand(c, motif, w.title, '')
341 const no = pill(c, -16, 11, o.label, 'ink')
342 const tile = (x: string, label: string, v: string, bar: number | null, grad: string) =>
343 `<svg x="${x}" y="46" width="33.3%" height="56" overflow="visible">
344<text x="16" y="14" font-size="11" fill="${c.pencil}">${esc(label)}</text>
345<text x="16" y="38" font-size="20" font-weight="700" fill="${c.deskFigure}">${esc(v)}</text>
346${bar === null ? '' : `<rect x="16" y="46" width="64" height="4" rx="2" fill="${c.chip}"/><rect x="16" y="46" width="${Math.max(3, Math.min(64, (bar / 100) * 64)).toFixed(1)}" height="4" rx="2" fill="url(#${grad})"/>`}
347</svg>`
348 const vals = o.history.slice(-24)
349 let trend = `<text x="16" y="134" font-size="11" fill="${c.pencil}">${esc(w.noTrend)}</text>`
350 if (vals.length > 0) {
351 const series = vals.length === 1 ? [vals[0] ?? 0, vals[0] ?? 0] : vals
352 // 按这段数据自己的最大值缩放,上下文只用了几个百分点时也看得出起伏
353 const top = Math.max(10, ...series) * 1.25
354 const pts = series.map((v, i) => `${(4 + (i / (series.length - 1)) * 92).toFixed(2)},${(27 - (Math.max(0, v) / top) * 24).toFixed(2)}`).join(' ')
355 trend = `<text x="16" y="120" font-size="11" fill="${c.pencil}">${esc(w.trend)}</text>
356<svg x="0" y="112" width="100%" height="30" viewBox="0 0 100 30" preserveAspectRatio="none">
357<polyline points="4,27 ${pts} 96,27" fill="${c.chip}" stroke="none"/>
358<polyline points="${pts}" fill="none" stroke="${c.inkDark}" stroke-width="2" vector-effect="non-scaling-stroke" stroke-linejoin="round"/>
359</svg>`
360 }
361 return `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="${H}" font-family="${FONT}">
362${paper(c, guess, H)}
363<defs>
364${gradients(c, 'lmp')}
365</defs>
366${head.svg}
367<text x="${head.end + 8}" y="25.5" font-size="11" fill="${c.pencil}">${esc(w.sub)}</text>
368<svg x="100%" y="0" width="1" height="1" overflow="visible">${no.svg}</svg>
369<rect x="16" y="38" width="${guess - 32}" style="width:calc(100% - 32px)" height="1" fill="url(#lm-dash)"/>
370${tile('0', w.ctx, o.ctx === null ? '—' : `${Math.round(o.ctx)}%`, o.ctx, 'lmp-ink')}
371${tile('33.3%', w.quota, o.quota === null ? '—' : `${Math.round(o.quota)}%`, o.quota, 'lmp-accent')}
372${tile('66.6%', w.cost, o.usd === null ? '—' : '$' + o.usd.toFixed(2), null, 'lmp-ink')}
373${trend}
374</svg>`
375}
376hooks/i18n.ts 90 lines1// lemo-meter 的默认文字:中性的(素色风格下显示的就是它们)。
2// 带风格味道的文字(柠檬实验室的「容量」「样本」「待测」「实验 T03」……)放在
3// plugins/lemo-core/styles/lemon-lab/words/lemo-meter.ts,用 word(lk, 'lemo-meter.<键>', 默认) 取。
4// 风格能覆盖的键:tbandCtx、tbandQuota、tbandSample、pending、trial、brandSub、statusBrand、usageTitle
5
6import type { Lang } from './shared/lemo'
7
8const zh = {
9 // 报到:面板「已装的 mod」里的名字和装上就生效的外观(两样都能在安全页关掉)
10 title: '用量横条',
11 always: [
12 '输入框上方横条:上下文、5 小时额度、花费、git 分支和各 mod 的状态标签',
13 '状态栏:消息编号、上下文和状态标签(已有自己的状态栏时默认关闭)',
14 ],
15 // 安全页上的几行(能力清单):会做什么、碰到什么
16 cap: {
17 band: { title: '横条', desc: '输入框上方显示上下文、5 小时额度、花费、git 分支 · 读取 .git/HEAD,不运行 git' },
18 status: { title: '状态栏', desc: '输入框下方显示消息编号和上下文 · 已有自己的状态栏时默认关闭' },
19 version: { title: '查新版本', desc: '点击后联网查询 Claude Code 最新版本' },
20 },
21 // 桌面横条
22 band: { ctx: '上下文', quota: '5 小时额度', cost: '花费', pending: '暂无' },
23 // 终端横条(短一点,刻度尺前面的标签按宽度对齐)
24 tband: { ctx: '上下文', quota: '额度', sample: '消息' },
25 // 右上角的编号胶囊,{no} 是 T03(和 lemo-skin、lemo-ask 一样:{no} 是编号,{n} 是数字)
26 trial: '{no}',
27 // 品牌名后面的小字副标题;空字符串表示不画
28 brandSub: '',
29 // 状态栏:品牌短名 ┊ T03 ┊ 上下文 12% ┊ 各胶囊
30 status: { brand: 'lemo', ctx: '上下文' },
31 usage: {
32 title: '用量',
33 desc: '上下文、5 小时额度和本次花费,每轮更新',
34 trend: '上下文走势',
35 noTrend: '完成一轮后显示走势',
36 },
37 branch: { title: 'git 分支', desc: '在横条显示当前 git 分支(读取 .git/HEAD,不运行 git)', none: '当前目录不是 git 仓库', value: '当前分支:{b}' },
38 version: {
39 title: '检查新版本',
40 desc: '点击「检查」联网查询 Claude Code 最新版本',
41 btn: '检查',
42 busy: '查询中…',
43 same: '已是最新:{v}',
44 newer: '有新版本 {latest}(当前 {cur})',
45 failed: '查询失败({why})',
46 noVersion: '未获取到版本号',
47 changelog: '更新日志',
48 },
49}
50
51export type MeterStrings = typeof zh
52
53const en: MeterStrings = {
54 title: 'Usage meter',
55 always: [
56 'Band above the prompt: context, 5-hour quota, cost, git branch and each mod\'s status badges',
57 'Status line: message number, context and status badges (off by default if you have your own status line)',
58 ],
59 cap: {
60 band: { title: 'Band', desc: 'Shows context, 5-hour quota, cost and git branch above the prompt · reads .git/HEAD, does not run git' },
61 status: { title: 'Status line', desc: 'Shows message number and context below the prompt · off by default if you have your own status line' },
62 version: { title: 'Check for updates', desc: 'Looks up the latest Claude Code version online when pressed' },
63 },
64 band: { ctx: 'Context', quota: '5-hour quota', cost: 'Cost', pending: 'n/a' },
65 tband: { ctx: 'ctx', quota: 'quota', sample: 'msg' },
66 trial: '{no}',
67 brandSub: '',
68 status: { brand: 'lemo', ctx: 'ctx' },
69 usage: {
70 title: 'Usage',
71 desc: 'Context, 5-hour quota and session cost, updated each turn',
72 trend: 'Context trend',
73 noTrend: 'The trend appears after the first turn',
74 },
75 branch: { title: 'git branch', desc: 'Shows the current git branch on the band (reads .git/HEAD, does not run git)', none: 'This directory is not a git repository', value: 'Current branch: {b}' },
76 version: {
77 title: 'Check for updates',
78 desc: 'Press Check to look up the latest Claude Code version online',
79 btn: 'Check',
80 busy: 'Checking…',
81 same: 'Up to date: {v}',
82 newer: 'New version {latest} (current {cur})',
83 failed: 'Check failed ({why})',
84 noVersion: 'no version number found',
85 changelog: 'Changelog',
86 },
87}
88
89export const STR: Record<Lang, MeterStrings> = { zh, en }
90hooks/shared/lemo.tsx 597 lines1// 所有 lemo mod 共用的代码。只在仓库的 shared/ 里改,然后跑 node scripts/sync-shared.mjs,
2// 它会复制到每个 mod 的 hooks/shared/lemo.tsx(插件只能读自己文件夹里的文件,所以每个 mod 带一份副本)。
3//
4// 这里只放三类东西:lemo-core 状态的引用、文字小工具、统一面板 /lemo-mod 里的卡片怎么画。
5// 风格相关的值(颜色、带风格味道的文字)一律从 lemo-core 的 style 读,不写死。
6//
7// 注意:这个文件里的函数一律不收 $。校验规定 $ 只能传给同一个文件里定义的函数,
8// 所以要用 $ 的几个公共函数(look、seqOf、hubTab)放在 shared/lemo-block.tsx,
9// 由同步脚本原样插进每个 mod 的 hooks/register.tsx(两行标记之间)。
10
11import type { BoxProps, ButtonProps, ElementConstructor, PluginState, RenderChildren, RenderElement, RenderNode, TextProps } from 'claude-code'
12
13// ---------- lemo-core 的类型(从合约里取,lemo-core 自己和依赖它的 mod 都能用) ----------
14
15export type Core = PluginState['lemo-core']
16export type Style = Core['style']
17export type Lang = Core['lang']
18export type Tab = Core['tab']
19export type Theme = Core['theme']
20export type ModInfo = Core['mods'][number]
21export type Colors = Style['colors']
22
23// ---------- 统一面板的 id ----------
24
25export const HUB = 'lemo-mod'
26
27/**
28 * 画东西时要的风格和语言。theme 是 Claude Code 的主题(终端用);desk 是桌面 App 自己的明暗
29 * (lemo-core 读 App 记在本机的设置,读不到是 null,当亮色画)。
30 * inline:终端里面板内嵌在输入框上方(Pane 的 placement 是 inline,窄窗口或不是全屏布局时)。
31 * 这时面板没有主题画的底,字直接落在终端自己的底色上;停靠在对话旁边(dock)时底色才是主题画的。
32 * 只有面板的 hook 会是 true(look($, e.props)),别处都是 false
33 */
34/** bodyColumns:面板里画的时候是面板正文的宽度(卡片说明按它先断好行),别处没有 */
35export type Look = { st: Style; c: Colors; lang: Lang; theme: Theme; desk: Theme; inline: boolean; bodyColumns?: number }
36
37// ---------- 文字 ----------
38
39/** 风格里的文字:先找风格的「mod.键」,找不到用 mod 自己的默认文字 */
40export function word(lk: Pick<Look, 'st' | 'lang'>, key: string, fallback: string): string {
41 const v = lk.st.words[key]?.[lk.lang]
42 if (typeof v === 'string') return v
43 return fallback
44}
45
46/** 风格里的一组文字(加载词、签文),找不到用默认 */
47export function words(lk: Pick<Look, 'st' | 'lang'>, key: string, fallback: readonly string[]): readonly string[] {
48 const v = lk.st.words[key]?.[lk.lang]
49 if (Array.isArray(v) && v.length > 0) return v as readonly string[]
50 return fallback
51}
52
53/** 把 {n}、{name} 这类占位符换成值 */
54export function fill(text: string, vars: Readonly<Record<string, string | number>>): string {
55 return text.replace(/\{(\w+)\}/g, (all, k: string) => (vars[k] === undefined ? all : String(vars[k])))
56}
57
58export const pad2 = (n: number) => String(n).padStart(2, '0')
59/** 消息编号:T01、T02… */
60export const exp = (n: number) => `T${pad2(n)}`
61export const pct = (v: number | null) => (v === null ? '—' : `${Math.round(v)}%`)
62
63export function fmtDur(ms: number): string {
64 const s = Math.round(ms / 1000)
65 const m = Math.floor(s / 60)
66 return m > 0 ? `${m}:${pad2(s % 60)}` : `${s}s`
67}
68
69export function hhmm(ms: number): string {
70 const d = new Date(ms)
71 return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
72}
73
74export function mdhm(ms: number): string {
75 const d = new Date(ms)
76 return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${hhmm(ms)}`
77}
78
79export function mmss(ms: number): string {
80 const s = Math.max(0, Math.ceil(ms / 1000))
81 return `${pad2(Math.floor(s / 60))}:${pad2(s % 60)}`
82}
83
84/** 终端里的显示宽度:中日韩字符占两格 */
85export function width(text: string): number {
86 let n = 0
87 for (const ch of text) {
88 const c = ch.codePointAt(0) ?? 0
89 const wide =
90 (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0x303e) || (c >= 0x3041 && c <= 0xa4cf) ||
91 (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) ||
92 (c >= 0xff00 && c <= 0xff60) || (c >= 0xffe0 && c <= 0xffe6)
93 n += wide ? 2 : 1
94 }
95 return n
96}
97
98/** 压成一行,超过 max 格就截断加省略号 */
99export function clip(text: string, max: number): string {
100 const one = text.replace(/\s+/g, ' ').trim()
101 let out = ''
102 for (const ch of one) {
103 if (width(out + ch) > max - 1) return out + '…'
104 out += ch
105 }
106 return out
107}
108
109/**
110 * 终端里中文夹着数字、英文时,挨着中文的空格换成不断行的空格。
111 * 终端换行优先在空格处断,「一轮超过 30 秒,……」会断成「一轮超过 30」和「秒,……」;
112 * 换掉以后整句按字断。英文句子里的空格不动
113 */
114export function cjkKeep(text: string): string {
115 return text.replace(/(?<=[\u2e80-\u9fff\uff00-\uffef]) | (?=[\u2e80-\u9fff\uff00-\uffef])/g, '\u00a0')
116}
117
118// 行首不能出现的标点(句号、逗号、右括号……),行尾不能出现的(左括号、左引号)
119const NO_START = new Set([...'。,、;:!?)」』】》〉…—·%,.;:!?)]}%'])
120const NO_END = new Set([...'(「『【《〈([{'])
121// 不拆开的一段:英文词、数字、路径(~/.claude/x.md)、标识符(draw_lot、.git/HEAD)
122const LATIN = /[A-Za-z0-9_~./@#+=*&'"$<>|^`-]/
123
124/**
125 * 终端里按显示宽度把一段中文(夹着英文)断成几行:英文词、数字、路径不拆开,
126 * 句号、逗号这类标点不放到行首,左括号、左引号不留在行尾。
127 * Ink 只在空格处断行,cjkKeep 换成不断行空格后又只能硬断(「Hai / ku」「Cla / ude」、句号落到行首),
128 * 所以知道宽度的地方(面板的 bodyColumns)先自己断好,每行单独画
129 */
130export function wrapCjk(text: string, cols: number): string[] {
131 const max = Math.max(4, Math.floor(cols))
132 // 切成不能再拆的小段:一个汉字或标点、一串英文数字、一个空格
133 const parts: string[] = []
134 for (const ch of text) {
135 const last = parts.length - 1
136 if (LATIN.test(ch) && last >= 0 && LATIN.test(parts[last]?.slice(-1) ?? '')) parts[last] += ch
137 else parts.push(ch)
138 }
139 const lines: string[][] = []
140 let cur: string[] = []
141 let w = 0
142 const flush = () => {
143 while (cur.length > 0 && cur[cur.length - 1] === ' ') cur.pop()
144 lines.push(cur)
145 cur = []
146 w = 0
147 }
148 for (const p of parts) {
149 if (p === ' ' && cur.length === 0) continue
150 const pw = width(p)
151 if (w + pw <= max) {
152 cur.push(p)
153 w += pw
154 continue
155 }
156 // 放不下。标点不放行首:连着的几个标点(「」,」)和它们前面那个字一起带到下一行
157 const carry: string[] = []
158 if (NO_START.has(p)) {
159 while (cur.length > 1 && NO_START.has(cur[cur.length - 1] ?? '')) carry.unshift(cur.pop() ?? '')
160 if (cur.length > 1 && cur[cur.length - 1] !== ' ') carry.unshift(cur.pop() ?? '')
161 }
162 // 左括号、左引号不留在行尾
163 while (cur.length > 1 && NO_END.has(cur[cur.length - 1] ?? '')) carry.unshift(cur.pop() ?? '')
164 flush()
165 for (const c of carry) {
166 cur.push(c)
167 w += width(c)
168 }
169 if (p === ' ') continue
170 // 一段英文比整行还长:只能硬断
171 let rest = p
172 while (w + width(rest) > max && cur.length === 0) {
173 let cut = ''
174 for (const ch of rest) {
175 if (width(cut + ch) > max) break
176 cut += ch
177 }
178 lines.push([cut])
179 rest = rest.slice(cut.length)
180 }
181 cur.push(rest)
182 w += width(rest)
183 }
184 if (cur.length > 0) flush()
185 return lines.map(l => l.join('')).filter((l, i, all) => l !== '' || all.length === 1)
186}
187
188/** 两种 #RRGGBB 颜色按 t 混合(t=0 是 a,t=1 是 b),调浅、调深风格色用 */
189export function mix(a: string, b: string, t: number): string {
190 const p = (h: string) => [1, 3, 5].map(i => Number.parseInt(h.slice(i, i + 2), 16))
191 const x = p(a)
192 const y = p(b)
193 if (a.length !== 7 || b.length !== 7 || [...x, ...y].some(Number.isNaN)) return a
194 return `#${x.map((v, i) => Math.round(v + ((y[i] ?? v) - v) * t).toString(16).padStart(2, '0')).join('')}`
195}
196
197/** 只留最近的若干条,免得会话状态越积越大 */
198export const recent = <T,>(map: Readonly<Record<string, T>>, keep = 300): Record<string, T> => Object.fromEntries(Object.entries(map).slice(-keep))
199
200// ---------- 消息来源 ----------
201
202/**
203 * 不是用户本人发的消息:后台任务通知、别的会话或 agent、插件发来的。
204 * 桌面发来的消息 origin 不是 composer,所以用排除法
205 */
206export const NOT_PERSON: ReadonlySet<string> = new Set([
207 'task-notification', 'peer', 'peer-send-message', 'coordinator', 'scheduled-trigger', 'plugin',
208 'observer', 'observer-activity', 'auto-continuation', 'channel', 'projects-relay', 'slack-ping',
209])
210
211/**
212 * Claude 回复的对号键:lemo-core 记回复编号、lemo-skin 画回复抬头都按它对。
213 * 按开头的文字对,因为界面上的文字和对话记录里的可能差一点尾巴
214 */
215export const replyKey = (text: string) => text.trim().slice(0, 60)
216
217/** lemo-watch 叫醒会话时发的消息以它开头;恢复旧会话重新编号时跳过 */
218export const REMIND_MARK = '⏰'
219
220// ---------- 命令:/lemo-mod 后面的词 ----------
221
222/**
223 * 把 /lemo-mod 的参数拆成第一个词(小写)、紧跟的数字和剩下的文字。
224 * 词和数字之间可以不空格:「提醒30」「focus25」也认(词本身不含数字;整个是数字时就当词)
225 */
226export function cmdWord(args: string): { word: string; num: number; rest: string } {
227 const a = args.trim()
228 const m = /^([^\s\d]+|\S+)\s*([\d.]*)\s*([\s\S]*)$/.exec(a)
229 return { word: (m?.[1] ?? '').toLowerCase(), num: Number.parseFloat(m?.[2] ?? ''), rest: (m?.[3] ?? '').trim() }
230}
231
232// ---------- 统一面板 /lemo-mod ----------
233// lemo-core 打开面板(id 是 HUB),画头部和分页。每个 mod 用
234// on('ui.render', { component: 'Pane', requestId: HUB }, async ($, e, next) => { ... })
235// 拿到同一个面板:先 const inner = await next(e),再用 hubWrap 把自己的卡片接在后面。
236// 当前分页不是自己的就原样 return inner。
237
238// 分页从左到右:常用在最前,安全在最后。终端的 a、b、c… 换页键照这个顺序给
239const TAB_ORDER: readonly Tab[] = ['main', 'behave', 'bg', 'game', 'safe']
240
241/** 这个界面上有哪些分页:有 mod 在这一页、这个界面上有卡片才显示;「安全」「行为」两页 lemo-core 自己画,总是显示 */
242export function visibleTabs(mods: readonly ModInfo[], surface: string): Tab[] {
243 const used = new Set<Tab>(['safe', 'behave'])
244 for (const m of mods) {
245 if (m.surfaces !== undefined && !m.surfaces.includes(surface)) continue
246 for (const t of m.tabs) used.add(t)
247 }
248 return TAB_ORDER.filter(t => used.has(t))
249}
250
251/** 一张卡片:标题、一句说明、标题行右边的按钮、下面的其他内容 */
252export type CardSpec = {
253 id: string
254 /** 在这一页里的位置,小的在上;不写就查下面的 CARD_ORDER,再没有就排在最后 */
255 order?: number
256 title: string
257 desc?: string
258 buttons?: RenderChildren
259 extra?: RenderChildren
260 /** 终端里说明文字能占几列:给了就用 wrapCjk 先断好行(面板的 bodyColumns 减去卡片的缩进,见 cardCols) */
261 cols?: number
262}
263
264/**
265 * 终端面板里一张卡片正文能占几列:面板正文宽减去左右各 2 列留白、竖条和它后面的空格,再留 1 列余量。
266 * 不设比实际宽的下限:断出来的行比面板宽,每行末尾会被截掉(wrapCjk 自己最少按 4 列断)
267 */
268export function cardCols(bodyColumns: number): number {
269 return Math.max(4, bodyColumns - 7)
270}
271
272/** 终端里先断好行的一段灰字(wrapCjk),每行单独画、不再让 Ink 断 */
273export function para(el: Els, text: string, cols: number, key?: string) {
274 const { Box, Text } = el
275 return (
276 <Box key={key} flexDirection="column">
277 {wrapCjk(text, cols).map((l, i) => (
278 <Text key={`l${i}`} dimColor wrap="truncate">{l}</Text>
279 ))}
280 </Box>
281 )
282}
283
284type Els = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps> }
285
286/**
287 * 模型写的一段字(小结、助手的报告)画成纯文字:去掉控制字符,一行一个 Text,换行照留。
288 * 不按 Markdown 画:内容不可信(可能夹着读到的文件里的话),里面的链接不能点。
289 * 字色同面板正文(inkOf):终端停靠时用主题的正文色,配得上主题画的面板底;内嵌时用终端自己的字色;桌面用卡片的深色字。
290 * Markdown 不设字色,深色终端配浅色主题时停靠面板上的字和底一样浅,看不见
291 */
292export function plainLines(el: Els, lk: Look, surface: string, text: string, key: string) {
293 const { Box, Text } = el
294 const lines = text.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').split('\n')
295 return (
296 <Box key={key} flexDirection="column">
297 {lines.map((l, i) => (
298 <Text key={`${key}-${i}`} {...inkOf(lk, surface)}>{l === '' ? ' ' : l}</Text>
299 ))}
300 </Box>
301 )
302}
303
304/** 桌面 App 是暗色(lemo-core 读 App 自己记的明暗)。读不到时是 null,当亮色 */
305export function deskDark(lk: Pick<Look, 'desk'>): boolean {
306 return lk.desk === 'dark'
307}
308
309/**
310 * 桌面卡片上的深色字:亮色 App 用风格的深色,暗色 App 卡片换成深色底,字用风格的浅色卡片底那个颜色。
311 * 暗色下原生按钮和 dimColor 的字是浅的,卡片底跟着变深才看得清
312 */
313export function deskInk(lk: Pick<Look, 'c' | 'desk'>): string {
314 return deskDark(lk) ? lk.c.cardFillLight : lk.c.inkDark
315}
316
317/** 桌面卡片:亮色是浅色小卡片(样子冻结,不改);暗色 App 用风格的深色卡片底 */
318export function cardBox(lk: Look): BoxProps {
319 if (deskDark(lk)) return { backgroundColor: lk.c.cardFillDark, borderStyle: 'round', borderColor: mix(lk.c.cardFillDark, lk.c.cardFillLight, 0.16), paddingX: 2, paddingY: 1 }
320 return { backgroundColor: lk.c.deskCardFill, borderStyle: 'round', borderColor: lk.c.deskCardBorder, paddingX: 2, paddingY: 1 }
321}
322
323/**
324 * 终端里竖条的颜色(柠檬书脊):强调色;深色主题用暗一点的那个,免得太刺眼。
325 * 用在面板卡片左边(卡片不要框、不要底色,左边一道竖条,卡片之间空一行)
326 * 和对话里你发的消息左边
327 */
328export function spineColor(lk: Look): string {
329 return lk.theme === 'dark' ? lk.c.bubbleAccent : lk.c.accent
330}
331
332/** 卡片标题:加粗,颜色和面板正文一样(见 inkOf) */
333export function cardTitle(el: Els, lk: Look, surface: string, title: string) {
334 const { Text } = el
335 return <Text bold {...inkOf(lk, surface)}>{title}</Text>
336}
337
338/**
339 * 画一张卡片。终端:左边一列带底色的空 Box 当竖条(高度跟着卡片拉满),右边是卡片内容;
340 * 按钮放在标题那一行右边,说明占满整行(中文说明和按钮挤一行会断得很怪)
341 */
342export function card(el: Els, lk: Look, surface: string, spec: CardSpec) {
343 const { Box, Text } = el
344 const isTerm = surface === 'terminal'
345 const head = cardTitle(el, lk, surface, spec.title)
346 // 说明按面板宽度先断好行(标点不落到行首):卡片自己给了 cols 就用它,没给就按面板宽度算
347 const cols = spec.cols ?? (lk.bodyColumns === undefined ? undefined : cardCols(lk.bodyColumns))
348 if (isTerm) {
349 return (
350 <Box key={spec.id} flexDirection="row" gap={1}>
351 <Box width={1} flexShrink={0} backgroundColor={spineColor(lk)} />
352 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
353 <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap={2}>
354 <Box flexShrink={1}>{head}</Box>
355 {spec.buttons === undefined ? null : <Box flexDirection="row" gap={1} flexShrink={0}>{spec.buttons}</Box>}
356 </Box>
357 {spec.desc === undefined ? null : cols === undefined ? <Text dimColor>{cjkKeep(spec.desc)}</Text> : para(el, spec.desc, cols)}
358 {spec.extra === undefined || spec.extra === null ? null : <Box marginTop={1} flexDirection="column">{spec.extra}</Box>}
359 </Box>
360 </Box>
361 )
362 }
363 return (
364 <Box key={spec.id} flexDirection="column" gap={1} {...cardBox(lk)}>
365 <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap={2}>
366 <Box flexDirection="column" flexShrink={1}>
367 {head}
368 {spec.desc === undefined ? null : <Text dimColor>{spec.desc}</Text>}
369 </Box>
370 {spec.buttons === undefined ? null : <Box flexDirection="row" gap={1} flexShrink={0}>{spec.buttons}</Box>}
371 </Box>
372 {spec.extra === undefined ? null : spec.extra}
373 </Box>
374 )
375}
376
377/**
378 * 每页卡片的顺序,按卡片 id。同一层的插件谁在外层不一定,卡片不能按挂 hook 的顺序排,
379 * 所以在这里统一定。新卡片加在这里;没列的排在最后
380 */
381export const CARD_ORDER: Readonly<Record<string, number>> = {
382 // 常用
383 'recap-card': 10, 'sound-main': 20, 'voice-main': 25, 'tone-brief-card': 30, 'watch-remind': 40, 'pomo-main': 50, 'todo-notes': 60, 'meter-usage': 90,
384 // 行为
385 'core-style': 5, 'core-name': 6, 'core-sound': 8, 'tone-voice-card': 10, 'skin-unfold-card': 30, 'journal-stats': 40, 'guard-timeout': 50, 'core-mods': 90,
386 // 后台
387 'journal-log': 10, 'meter-branch': 20, 'meter-version': 30, 'journal-classify': 40, 'assistant-main': 50,
388 // 安全(lemo-core 按能力清单画,分类的顺序见 lemo-core 的 KIND_ORDER)
389 'safe-head': 1,
390}
391
392// 面板里那一列的 key,和每张卡片外层的 key(带着顺序号,外层的 mod 靠它重新排)
393const HUB_KEY = 'lemo-hub'
394const CARD_PREFIX = 'lemo-card:'
395
396/** 终端面板里卡片的数字热键最多到 9;分页按钮用 a–e(lemo-core 给),不在卡片里,不受影响 */
397const MAX_KEYS = 9
398// 按钮前面那个号码外层 Box 的 key:重新编号时靠它认出来、去掉
399const KEY_MARK = 'lemo-hotkey'
400
401const isEl = (x: unknown): x is RenderElement => typeof x === 'object' && x !== null && !Array.isArray(x) && 'type' in x
402
403/** 去掉里层编过的号:按钮上的数字热键、按钮前面画的号码。字母热键是 mod 自己定的,不动 */
404function unnumber(node: RenderNode): RenderNode {
405 if (!isEl(node)) return node
406 if (node.type === 'Button') {
407 const { hotkey, ...rest } = node.props
408 return hotkey !== undefined && /^\d$/.test(hotkey) ? { ...node, props: rest } : node
409 }
410 if ((node.type !== 'Box' && node.type !== 'Text') || node.children === undefined) return node
411 const kids = node.children.filter(k => !(isEl(k) && k.type === 'Box' && String(k.props?.key ?? '').startsWith(KEY_MARK)))
412 return { ...node, children: kids.map(unnumber) }
413}
414
415/** 在一行里找第一个按钮,给它热键 n,前面放上号码 mark。找不到返回 null */
416function numberRow(node: RenderNode, n: number, mark: RenderElement): RenderNode | null {
417 if (!isEl(node) || node.type !== 'Box' || node.children === undefined) return null
418 const kids = node.children
419 const i = kids.findIndex(k => isEl(k) && k.type === 'Button')
420 const btn = kids[i]
421 if (isEl(btn) && btn.type === 'Button') {
422 const numbered: RenderNode = { ...btn, props: { ...btn.props, hotkey: String(n) } }
423 return { ...node, children: [...kids.slice(0, i), mark, numbered, ...kids.slice(i + 1)] }
424 }
425 for (let j = 0; j < kids.length; j++) {
426 const r = numberRow(kids[j] as RenderNode, n, mark)
427 if (r !== null) return { ...node, children: [...kids.slice(0, j), r, ...kids.slice(j + 1)] }
428 }
429 return null
430}
431
432/**
433 * 给一张卡片编号 n:标题行右边的第一个按钮按 n 就能按,按钮前面画一个暗色的 n。
434 * 卡片外层是 hubWrap 包的 Box,里面是 card() 画的卡片:终端卡片是「竖条、内容」两个孩子,内容的第一个孩子是标题行。
435 * 标题行没有按钮返回 null(不占号)。
436 * 按钮的处理函数记在元素的 press 里,跟着元素走,所以外层改了 props,按下去还是原来那个 mod 处理
437 */
438function numberCard(wrapper: RenderNode, n: number, mark: RenderElement): RenderNode | null {
439 if (!isEl(wrapper) || wrapper.type !== 'Box') return null
440 const box = wrapper.children?.[0]
441 if (!isEl(box) || box.type !== 'Box') return null
442 const kids = box.children ?? []
443 const body = kids[kids.length - 1]
444 if (!isEl(body) || body.type !== 'Box') return null
445 const row = body.children?.[0]
446 const r = row === undefined ? null : numberRow(row, n, mark)
447 if (r === null) return null
448 const newBody = { ...body, children: [r, ...(body.children ?? []).slice(1)] }
449 return { ...wrapper, children: [{ ...box, children: [...kids.slice(0, -1), newBody] }, ...(wrapper.children ?? []).slice(1)] }
450}
451
452function orderOf(node: RenderNode): number | null {
453 if (typeof node !== 'object' || node === null) return null
454 const key = (node as { props?: { key?: unknown } }).props?.key
455 if (typeof key !== 'string' || !key.startsWith(CARD_PREFIX)) return null
456 return Number(key.slice(CARD_PREFIX.length, CARD_PREFIX.length + 4))
457}
458
459/**
460 * 把自己的卡片加进面板,和里层已有的卡片一起按顺序排(见 CARD_ORDER)。
461 * 里层是 lemo-core 的头部,或者别的 mod 已经排好的一列(key 是 lemo-hub):拆开,头部留在最上面,卡片重排。
462 * head 是要放在最上面的东西(桌面上 lemo-meter 的数据卡)。
463 * 终端里卡片之间空一行,左右各留 2 格和 lemo-core 的头部对齐,最下面空一行;桌面卡片之间空一点
464 */
465export function hubWrap(el: Els, lk: Look, surface: string, inner: RenderElement, specs: readonly CardSpec[], head?: RenderElement): RenderElement {
466 const { Box, Text } = el
467 const isTerm = surface === 'terminal'
468 if (specs.length === 0 && head === undefined) return inner
469 const isHub = inner.type === 'Box' && (inner as { props?: { key?: unknown } }).props?.key === HUB_KEY
470 const base: RenderNode[] = isHub ? [...((inner as { children?: RenderNode[] }).children ?? [])] : [inner]
471 const fresh = specs.map(s => {
472 const n = Math.max(0, Math.min(9999, Math.round(s.order ?? CARD_ORDER[s.id] ?? 9999)))
473 const key = `${CARD_PREFIX}${String(n).padStart(4, '0')}:${s.id}`
474 return isTerm ? (
475 <Box key={key} flexDirection="column" paddingX={2}>{card(el, lk, surface, s)}</Box>
476 ) : (
477 <Box key={key} flexDirection="column">{card(el, lk, surface, s)}</Box>
478 )
479 })
480 const all = [...base, ...fresh]
481 const tops = all.filter(x => orderOf(x) === null)
482 // sort 是稳定的:顺序号相同的照加进来的先后
483 const sorted = all.filter(x => orderOf(x) !== null).sort((a, b) => (orderOf(a) ?? 0) - (orderOf(b) ?? 0))
484 // 终端里按卡片顺序给卡片编数字热键 1–9(标题行右边的第一个按钮),按钮前面画出号码。
485 // 每一层都先去掉里层编的号再重编,最外层那次就是全部卡片排好后的结果。桌面上直接点,不编
486 let n = 0
487 const cards = !isTerm
488 ? sorted
489 : sorted.map(x => {
490 const clean = unnumber(x)
491 if (n >= MAX_KEYS) return clean
492 const mark = (
493 <Box key={`${KEY_MARK}-${n + 1}`} flexShrink={0}>
494 <Text dimColor>{String(n + 1)}</Text>
495 </Box>
496 )
497 const r = numberCard(clean, n + 1, mark)
498 if (r === null) return clean
499 n += 1
500 return r
501 })
502 return (
503 <Box key={HUB_KEY} flexDirection="column" gap={isTerm ? 1 : 2} {...(isTerm ? { paddingBottom: 1 } : {})}>
504 {head === undefined ? null : head}
505 {tops}
506 {cards}
507 </Box>
508 )
509}
510
511// ---------- 面板里的按钮:重画时还是同一个按钮(桌面 App 第一下点击不丢) ----------
512
513type Kept = { look: string; el: RenderElement; run: { fn: ButtonProps['onPress'] } }
514/** 每个界面上一次画出来的按钮(按 key)。只复用上一次画里有的,见 steady */
515const LAST = new Map<string, Map<string, Kept>>()
516
517/**
518 * 面板(Pane)里画按钮用这个,代替 $.ui.resolve(e) 给的 Button,写法不变:
519 * const Button = steady($.ui.resolve(e).Button, e.surface)
520 * <Button key="x" label="…" onPress={…} />
521 * 每次画都要先调一次 steady(放在面板 hook 的开头、提前 return 之前),它把这一次记成「上一次画」;
522 * 这次没画按钮也要调,不然下一次画会以为按钮一直在。
523 *
524 * 为什么:桌面 App 里光标在输入框时点面板按钮,按下鼠标那一刻按钮拿到焦点,
525 * App 把面板的 isFocused 改成 true,马上再要一次画。引擎每画一次,给新建的每个 Button 一个新 handle,
526 * 新画面里没有的旧 handle 就放掉。App 按住鼠标期间不换按钮,松开时发出去的还是按下前那个旧 handle,
527 * 引擎已经不认(日志里是「no Button … is held under handle」或「no handler is held under handle」),第一下就丢了;
528 * 第二下面板已经有焦点、不再重画,所以能按。
529 *
530 * 引擎的约定是 handle 跟着画面活(the host holds the handle for the lifetime of the drawing):
531 * 新画面里还是同一个按钮元素,handle 就还在。所以这里把上一次画的按钮记住:同一个界面、同一个 key、
532 * 样子(label、variant、hotkey、dimColor……)都没变,就交回那个元素;按下去跑最近一次画时给的 onPress,
533 * 不会拿旧状态做事。样子变了就新建一个。
534 *
535 * 只复用上一次画里有的:切页、按钮消失过一次再出现时,引擎已经放掉了它的 handle,这时新建一个,
536 * 不去碰引擎正在放掉的 handle(放掉和重新登记的先后说不准)。
537 * 这几种不记,照常新建:没写 key(引擎拿 label 当地址,两个同字的按钮分不清)、label 写成了子节点、
538 * 同一次画里这个 key 已经用过(不会把同一个元素放进树里两次)。
539 * Input、Select 不用:引擎按它们的 key 也找得到。
540 * 终端照原样返回 Button:终端的点击和重画在同一个进程里,画面和 handle 一起换,没有这个问题;
541 * 终端的重画还可能被中途取消,那时复用的 handle 可能被引擎一起清掉,所以不在终端上记。
542 */
543export function steady(Button: ElementConstructor<ButtonProps>, surface: string): ElementConstructor<ButtonProps> {
544 if (surface === 'terminal') return Button
545 const before = LAST.get(surface) ?? new Map<string, Kept>()
546 const now = new Map<string, Kept>()
547 LAST.set(surface, now)
548 return props => {
549 const { onPress, children, ...rest } = props
550 const key = rest.key
551 const hasKids = Array.isArray(children) ? children.length > 0 : children !== undefined && children !== null
552 if (key === undefined || hasKids || now.has(key)) {
553 return (
554 <Button {...rest} onPress={onPress}>
555 {children}
556 </Button>
557 )
558 }
559 const look = JSON.stringify(rest)
560 const hit = before.get(key)
561 if (hit !== undefined && hit.look === look) {
562 hit.run.fn = onPress
563 now.set(key, hit)
564 return hit.el
565 }
566 const run = { fn: onPress }
567 const kept: Kept = { look, el: <Button {...rest} onPress={press => run.fn(press)} />, run }
568 now.set(key, kept)
569 return kept.el
570 }
571}
572
573/** 次要按钮:终端里加 dimColor,引擎用主题的灰色画(不加的话用终端默认字色,浅底上看不清) */
574export function sec(surface: string): Pick<ButtonProps, 'variant' | 'dimColor'> {
575 return surface === 'terminal' ? { variant: 'secondary', dimColor: true } : { variant: 'secondary' }
576}
577
578/**
579 * 面板(Pane)里的正文颜色。桌面用风格的深色(暗色 App 用浅色)。
580 * 终端里看面板停在哪:停靠在对话旁边(dock)时底色是主题画的,用主题的正文色(theme key「text」);
581 * 内嵌在输入框上方(lk.inline)时没有主题的底,和 bareInk 一样不给颜色,用终端自己的前景色。
582 * 不然深色终端配浅色主题时标题是黑字落在深底上,浅色终端配深色主题时是白字落在浅底上
583 */
584export function inkOf(lk: Look, surface: string): Pick<TextProps, 'color'> {
585 if (surface !== 'terminal') return { color: deskInk(lk) }
586 return lk.inline ? {} : { color: 'text' }
587}
588
589/**
590 * 直接画在终端底色上的正文(横条、对话里的卡片):终端里不给颜色,用终端自己的前景色;桌面用风格的深色。
591 * 终端的底色不一定和 Claude Code 的主题一致:深色终端配浅色主题时,主题正文色是黑的,画在深底上看不清。
592 * 终端自己的前景色总是配得上它的底色
593 */
594export function bareInk(lk: Look, surface: string): Pick<TextProps, 'color'> {
595 return surface === 'terminal' ? {} : { color: deskInk(lk) }
596}
597types/index.d.ts 34 lines1// lemo-meter 的状态合约:横条、状态栏和面板卡片读这些值,读了会订阅,一改自动重画。
2
3/** 这次会话的用量:上下文占了多少、五小时额度用了多少、花了多少钱。读不到的是 null */
4export type LemoMeterUsage = { ctx: number | null; quota: number | null; usd: number | null }
5
6/** 联网查新版本的结果。文字在画的时候按当前语言拼,所以这里只存数据 */
7export type LemoMeterLatest = {
8 status: 'idle' | 'busy' | 'done' | 'error'
9 /** npm 上的最新版本 */
10 newest: string
11 /** 正在用的版本 */
12 mine: string
13 isNewer: boolean
14 /** 没查到的原因 */
15 why: string
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'lemo-meter': {
21 usage: LemoMeterUsage
22 /** 每轮结束记一次上下文百分比,只留最近 24 个,画走势用 */
23 history: readonly number[]
24 /** 当前 git 分支(读 .git/HEAD,不跑 git),不是 git 仓库时为 null */
25 branch: string | null
26 latest: LemoMeterLatest
27 /** 横条开着吗(安全页的开关,外观,默认开;存进 $.store 全局记住,开会话时读回来) */
28 bandOn: boolean
29 /** 用户按过的状态栏开关;没按过是 null,照 lemo-core 的扫描:用户自己设了状态栏就不开 */
30 statusPref: boolean | null
31 }
32 }
33}
34