换皮:消息加编号 T01、T02,回复标注「回复 T03」,工具行和耗时行使用风格样式 / Skin: messages numbered T01, T02, replies marked "Reply T03", tool rows and turn lines use the style

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 633 lines1// lemo-skin:把 Claude Code 自己画的那些行换成风格的样子,并显示消息编号。
2// - 你的消息前加编号 T01、T02…(后面带 lemo-journal 贴的标签),Claude 每段回复上面加「回复 T03」抬头
3// - 工具行、折叠的工具组:开始时间、动词、对象、状态
4// - 耗时行、工具进度行、启动提示、输入框下的提示行、模式标签(终端为主,桌面上能换的才换)
5// - 调用 skill 时横条提示,提示音开着时响一声(告诉 Claude「T01 是第几条消息」的那段系统提示词在 lemo-core 里,安全页的开关,默认关)
6// - 面板「行为」页:「展开工具行」开关
7// - 安全页:「消息样式」一行外观,装上就开,有开关;关了以后上面这些行都照 Claude Code 原样画
8// (「展开工具行」是另一个开关,只在这个会话、只改画面,不列进安全页)
9// - 你输入的斜杠命令(/lemo-mod……)不编号:lemo-core 不给它们号,桌面上它们也画成一条「你的消息」,按原文对号会借上别的号
10// 编号、回复号、耗时行号都由 lemo-core 记(numbers、replies、turnRows、seq、tags),这里只读不数。
11
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, Register, RenderChildren } from 'claude-code'
14
15import { STR, plural } from './i18n'
16import {
17 HUB, NOT_PERSON, REMIND_MARK, bareInk, clip, exp, fill, fmtDur, hhmm, hubWrap, replyKey, sec, spineColor, steady, width, word,
18} from './shared/lemo'
19import type { Core } from './shared/lemo'
20
21// ---- lemo-shared BEGIN: 由 scripts/sync-shared.mjs 从 shared/lemo-block.tsx 复制,不要在这里改 ----
22// 下面这几个函数要用 $,所以不能放进 shared/lemo.tsx(校验规定 $ 只能传给同一个文件里定义的函数),
23// 由 scripts/sync-shared.mjs 原样插进每个 mod 的 hooks/register.tsx。只在 shared/lemo-block.tsx 里改。
24// 导入都起了 Lemo 开头的别名,免得和 mod 自己的导入重名。
25import type { EngineInterface as LemoEngine } from 'claude-code'
26import { visibleTabs as lemoVisibleTabs } from './shared/lemo'
27import type { Look as LemoLook, Tab as LemoTabId } from './shared/lemo'
28
29// lemo-core 的状态(校验规定:状态的引用要写在用它的文件里)。读它们会订阅,lemo-core 一改,读过的地方自动重画
30const LemoStyleRef = { plugin: 'lemo-core', key: 'style' } as const
31const LemoLangRef = { plugin: 'lemo-core', key: 'lang' } as const
32const LemoThemeRef = { plugin: 'lemo-core', key: 'theme' } as const
33const LemoDeskRef = { plugin: 'lemo-core', key: 'desk' } as const
34const LemoTabRef = { plugin: 'lemo-core', key: 'tab' } as const
35const LemoModsRef = { plugin: 'lemo-core', key: 'mods' } as const
36const LemoSeqRef = { plugin: 'lemo-core', key: 'seq' } as const
37const LemoTurnNoRef = { plugin: 'lemo-core', key: 'turnNo' } as const
38
39/**
40 * 画东西时要的风格和语言。读 lemo-core 的状态会订阅,lemo-core 一改自动重画;还没写过时用 $.lemo 兜底。
41 * 面板(Pane)的 hook 要把 e.props 传进来:面板停在哪(placement)决定正文用什么颜色(见 shared/lemo.tsx 的 inkOf),
42 * 面板宽度(bodyColumns)决定卡片说明在哪断行(见 shared/lemo.tsx 的 card)
43 */
44async function look($: LemoEngine, pane?: { placement: 'dock' | 'inline'; bodyColumns?: number }): Promise<LemoLook> {
45 const st = (await $.state.get(LemoStyleRef)).value ?? (await $.lemo.style({}))
46 const lang = (await $.state.get(LemoLangRef)).value ?? (await $.lemo.lang({}))
47 const theme = (await $.state.get(LemoThemeRef)).value ?? null
48 const desk = (await $.state.get(LemoDeskRef)).value ?? null
49 return { st, c: st.colors, lang, theme, desk, inline: pane?.placement === 'inline', ...(pane?.bodyColumns === undefined ? {} : { bodyColumns: pane.bodyColumns }) }
50}
51
52/** 用户本人发了几条消息(T01、T02…) */
53async function seqOf($: LemoEngine): Promise<number> {
54 return (await $.state.get(LemoSeqRef)).value ?? 0
55}
56
57/** 这一轮回的是第几条消息:提醒、助手交回、斜杠命令开头的一轮是 0(不写号)。lemo-core 还没写过时当作 seq */
58async function turnNoOf($: LemoEngine): Promise<number> {
59 return (await $.state.get(LemoTurnNoRef)).value ?? (await seqOf($))
60}
61
62/** 统一面板现在显示哪一页:存的那页在这个界面上没有,就显示第一页 */
63async function hubTab($: LemoEngine, surface: string): Promise<LemoTabId> {
64 const stored = (await $.state.get(LemoTabRef)).value ?? 'main'
65 const mods = (await $.state.get(LemoModsRef)).value
66 // 还没有报到表(lemo-core 还没写过,或者测试里用的是假核心):照存的那页
67 if (mods === undefined) return stored
68 const shown = lemoVisibleTabs(mods, surface)
69 return shown.includes(stored) ? stored : shown[0] ?? 'behave'
70}
71// ---- lemo-shared END ----
72
73// lemo-core 的状态(校验规定:引用写在用它的文件里)。读了就订阅,lemo-core 一改,读过的地方自动重画
74const NumbersRef = { plugin: 'lemo-core', key: 'numbers' } as const
75const RepliesRef = { plugin: 'lemo-core', key: 'replies' } as const
76const TurnRowsRef = { plugin: 'lemo-core', key: 'turnRows' } as const
77const TagsRef = { plugin: 'lemo-core', key: 'tags' } as const
78const FrameRef = { plugin: 'lemo-core', key: 'frame' } as const
79
80// 「展开工具行」开关:存在 lemo-core 里(lemo-lot 画抽签卡片时也要看它),只记在这个会话里
81const UnfoldRef = { plugin: 'lemo-core', key: 'unfold' } as const
82
83async function isUnfolded($: EngineInterface): Promise<boolean> {
84 return (await $.state.get(UnfoldRef)).value === true
85}
86
87// 别的 lemo mod 给 Claude 加的工具(mcp__lemo-lot__draw_lot……):由各自的 mod 画,这里不数、不换皮
88const LEMO_TOOL = 'mcp__lemo-'
89
90// 「消息样式」开关:外观,装上就开。存进 $.store(键 look,全局记住),开会话时读回来;每处画法都读它,一改全部重画
91const lookOn = atom({ plugin: 'lemo-skin', key: 'lookOn' } as const, true)
92
93type Cap = Awaited<ReturnType<EngineInterface['lemo']['caps']>>[number]
94type Numbers = Core['numbers']
95type TurnRow = Core['turnRows'][string]
96type Kept = Awaited<ReturnType<EngineInterface['lemo']['recall']>>
97const NO_NUMBERS: Numbers = { ids: {}, texts: {} }
98
99// 对话记录里已经画好的行:编号、标签都齐了就记在这里,下次重画不再读编号表和标签表。
100// 读一次就订阅一次:不再读,别的消息加编号、贴标签时就不会带着它一起重画。mod 重载后清空,第一次画时重新读
101const userDone = new Map<string, { n: number; tag: string | null }>()
102const replyDone = new Map<string, number>()
103const turnDone = new Map<string, TurnRow>()
104// 耗时行没有记录时(恢复的旧会话),只能在第一次画出来时记下当时的编号
105// lemo-core 的 seq 的镜像:画的时候拿来比大小,不订阅。在 session.start、session.append 里更新
106let seqNow = 0
107// 每次工具调用的开始时间(tool.call 时记下),工具行前面显示
108const startedAt = new Map<string, number>()
109// 同一句话发过不止一次的旧消息:按画出来的先后记下各条的 requestId(见 findNo)
110const dupSeen = new Map<string, readonly string[]>()
111// 按 id 问过 lemo-core 存下来的号($.lemo.recall)的行,没有的也记下:正在写的回复每来一段字都重画,不用每次都问
112const recalled = new Map<string, Kept | null>()
113// 判过是不是斜杠命令的消息(按 requestId),判过一次就不再问命令表
114const cmdRows = new Map<string, boolean>()
115// 展开的工具组里的调用(按 tool_use_id):展开组的输出画在 ToolUse 行里,有输出时这一行交回引擎原样,
116// 不然输出就被这一行盖掉了。工具组先画、再画它展开成的各行,所以在工具组的 hook 里记
117const unfoldedCalls = new Set<string>()
118// Bash 命令、WebFetch 网址整条显示(放不下就换行),不截:自动放行的调用,用户靠这一行事后核对 Claude 跑了什么、访问了哪
119const FULL_TARGET: ReadonlySet<string> = new Set(['Bash', 'WebFetch'])
120
121// 斜杠命令的样子:开头是「/名字」,名字里没有别的斜杠(/Users/me/… 这种路径不算),后面是空白或结束
122const SLASH = /^\/([A-Za-z][\w:.-]*)(?=\s|$)/
123
124
125// 先按消息 id 找,再按文字找;桌面上消息的文字和对话记录里的不完全一样,最后按包含关系找。
126// 恢复的旧会话里同一句话发过不止一次(lemo-core 按先后记在 dupes 里):对话记录从上往下画,第几次画到这句话就是第几个号;
127// 多出来的(又发了一次、还没登记的新消息)先不编号,登记以后按 id 对上
128function findNo(m: Numbers, id: string, text: string): number | undefined {
129 const t = text.trim()
130 const byId = m.ids[id]
131 if (byId !== undefined) return byId
132 const same = m.dupes?.[t]
133 if (same !== undefined) {
134 const seen = dupSeen.get(t) ?? []
135 const all = seen.includes(id) ? seen : [...seen, id]
136 dupSeen.set(t, all)
137 return same[all.indexOf(id)]
138 }
139 const hit = m.texts[t]
140 if (hit !== undefined || t.length < 4) return hit
141 const entries = Object.entries(m.texts)
142 for (let i = entries.length - 1; i >= 0; i--) {
143 const [k, n] = entries[i] ?? ['', 0]
144 if (k.length >= 4 && (k.includes(t) || t.includes(k))) return n
145 }
146 return undefined
147}
148
149// 工具行里显示的对象:文件路径、命令、检索词、网址
150function target(tool: string, input: unknown): string {
151 const o = (input ?? {}) as Record<string, unknown>
152 const pick = (k: string) => (typeof o[k] === 'string' ? (o[k] as string) : '')
153 switch (tool) {
154 case 'Read':
155 return pick('file_path').replace(/^.*\/(?=[^/]+\/[^/]+$)/, '…/')
156 case 'Bash':
157 return pick('command')
158 case 'Grep':
159 case 'Glob':
160 return pick('pattern')
161 // 网址照原样写全(http 还是 https 也是要看的)
162 case 'WebFetch':
163 return pick('url')
164 case 'WebSearch':
165 return pick('query')
166 default:
167 return ''
168 }
169}
170
171// 里层画的是不是引擎原样({ type: 'engine' }),而不是别的 mod 画的
172function isEngineDrawing(x: unknown): boolean {
173 return typeof x === 'object' && x !== null && (x as { type?: unknown }).type === 'engine'
174}
175
176// 恢复的旧会话:按对话记录里的 id 找 lemo-core 存下来的号,每行只问一次
177async function recallOnce($: EngineInterface, id: string): Promise<Kept> {
178 const hit = recalled.get(id)
179 if (hit !== undefined) return hit
180 const k = await $.lemo.recall({ id })
181 recalled.set(id, k)
182 if (recalled.size > 2000) {
183 const oldest = recalled.keys().next().value
184 if (oldest !== undefined) recalled.delete(oldest)
185 }
186 return k
187}
188
189/**
190 * 这条「你的消息」是不是你输入的斜杠命令(/lemo-mod、/compact……)。lemo-core 不给斜杠命令编号;
191 * 桌面上命令也画成一条「你的消息」,按原文对号会借上别的消息的号(出现两条「T14 · /lemo-mod」),
192 * 所以认出来就不编号,和终端一样。样子像命令时再拿这个会话能用的命令表核对一遍(「/tmp 下面有什么」不算命令);
193 * 读不到命令表就按样子算
194 */
195async function isSlashCommand($: EngineInterface, id: string, text: string): Promise<boolean> {
196 const name = SLASH.exec(text.trim())?.[1]
197 if (name === undefined) return false
198 const hit = cmdRows.get(id)
199 if (hit !== undefined) return hit
200 let yes = true
201 try {
202 // 插件的命令可能带着插件名(lemo-core:lemo-mod),用户输入的是后半截
203 const names = (await $.command.list()).map(c => c.name)
204 yes = names.some(n => n === name || n.endsWith(`:${name}`))
205 } catch {
206 // 读不到命令表
207 }
208 cmdRows.set(id, yes)
209 if (cmdRows.size > 2000) {
210 const oldest = cmdRows.keys().next().value
211 if (oldest !== undefined) cmdRows.delete(oldest)
212 }
213 return yes
214}
215
216// 安全页上「消息样式」开着吗。读了就订阅:在安全页上一关,画过的行都重画成原样
217async function isStyled($: EngineInterface): Promise<boolean> {
218 return read($, lookOn)
219}
220
221async function toggleUnfold($: EngineInterface) {
222 await $.lemo.set({ unfold: !(await isUnfolded($)) })
223}
224
225export const register: Register = on => {
226 // ---------- 报到 ----------
227 on('session.start', async ($, e, next) => {
228 // 耗时行、工具进度行、启动提示只有终端有:只在终端会话里列出来。
229 // e.surface 是会话开始时画在哪:终端是 terminal,桌面(走 SDK)开始时还没有界面,是 null
230 const hasTerminal = e.surface === 'terminal'
231 await $.lemo.join({
232 mod: 'lemo-skin',
233 // 会放音效:没装 lemo-sound 时,lemo-core 在「行为」页给出开关
234 uses: ['sound'],
235 title: { zh: STR.zh.title, en: STR.en.title },
236 tabs: ['behave'],
237 always: {
238 zh: [...STR.zh.always.common, ...(hasTerminal ? STR.zh.always.terminal : [])],
239 en: [...STR.en.always.common, ...(hasTerminal ? STR.en.always.terminal : [])],
240 },
241 })
242 // 读回存下来的「消息样式」开关;没存过(或读不到存档)照默认:开
243 try {
244 const saved = await $.store.get('look')
245 if (typeof saved === 'boolean') await update($, lookOn, () => saved)
246 } catch {
247 // 读不到存档
248 }
249 const r = await next(e)
250 // lemo-core 恢复旧会话的编号也在 session.start 里做,等它做完再读
251 seqNow = await seqOf($)
252 return r
253 })
254
255 // 用户发出一条消息:lemo-core 在这里定号,定完把镜像更新一下(不管谁在外层,next 回来时号都定好了)
256 on('session.append', { door: 'prompt' }, async ($, e, next) => {
257 const r = await next(e)
258 seqNow = await seqOf($)
259 return r
260 })
261
262 // 记下每次工具调用的开始时间,工具行前面显示。只记时间,不改调用(音效归 lemo-sound)
263 on('tool.call', async ($, e, next) => {
264 startedAt.set(e.tool_use_id, await $.clock.now())
265 // 只留最近的 500 次
266 if (startedAt.size > 500) {
267 const oldest = startedAt.keys().next().value
268 if (oldest !== undefined) startedAt.delete(oldest)
269 }
270 return next(e)
271 })
272
273 // skill 展开(skill.prompt):调用任何 skill 时响一声、横条提示,内容原样不动。
274 // 声音走 $.lemo.play:提示音开关归 lemo-core,装上时是关的,关着就不响
275 on('skill.prompt', async ($, e, next) => {
276 const r = await next(e)
277 // 安全页上关了「消息样式」就不响、不提示
278 if (!(await isStyled($))) return r
279 const lk = await look($)
280 await $.lemo.play({ sound: 'tick', gain: 0.7 })
281 await $.lemo.notice({ text: fill(word(lk, 'lemo-skin.skill', STR[lk.lang].skill), { name: e.skill }), tone: 'accent' })
282 return r
283 })
284
285 // ---------- 统一面板「行为」页:展开工具行 ----------
286 on('ui.render', { component: 'Pane', requestId: HUB }, async ($, e, next) => {
287 // 面板按钮用 steady:桌面上面板拿到焦点会再画一次,按钮还是同一个,第一下点击不丢(见 shared/lemo.tsx)。
288 // 每次画都先调它,这次不画按钮也调:下一次画才知道哪些按钮上一次画过
289 const Button = steady($.ui.resolve(e).Button, e.surface)
290 const inner = await next(e)
291 if ((await hubTab($, e.surface)) !== 'behave') return inner
292 const lk = await look($, e.props)
293 const S = STR[lk.lang]
294 const isOn = await isUnfolded($)
295 const el = $.ui.resolve(e)
296 return hubWrap(el, lk, e.surface, inner, [
297 {
298 // 卡片的 key 和按钮的 key 不能一样,不然按 key 找按钮会先找到卡片
299 id: 'skin-unfold-card',
300 title: S.unfold.title,
301 desc: S.unfold.desc,
302 buttons: <Button key="skin-unfold" label={isOn ? S.unfold.on : S.unfold.off} {...sec(e.surface)} onPress={() => toggleUnfold($)} />,
303 },
304 ])
305 })
306
307 // ---------- 你的消息:前面加编号 ----------
308 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
309 const o = e.props.origin
310 // 插件自己发的消息(比如提醒)挂在「Prompt from … plugin」标签下,这一行不经过这个 hook,换不了皮。
311 // isExpanded 只在终端有意义(ctrl+o 展开);桌面没有 ctrl+o,每条消息都算展开,不能因此跳过
312 // 提醒(lemo-watch 发的,⏰ 开头)也不编号:恢复的旧会话、桌面上它的来源不一定是 plugin,
313 // 按原文又会对上更早一次提醒的号(挂上旧编号)。终端上插件发的消息本来就不编号
314 if ((e.surface === 'terminal' && e.props.isExpanded) || NOT_PERSON.has(o.kind) || e.props.text.trim().startsWith(REMIND_MARK)) return next(e)
315 if (!(await isStyled($))) return next(e)
316 // 你输入的斜杠命令不编号(见 isSlashCommand)。桌面上按原文对号会借上别的号;终端本来就不编号,两边一样
317 if (await isSlashCommand($, e.requestId, e.props.text)) return next(e)
318 let hit = userDone.get(e.requestId)
319 if (hit === undefined) {
320 const m = (await $.state.get(NumbersRef)).value ?? NO_NUMBERS
321 // 恢复的旧会话:会话状态里没有它的 id,按 id 找 lemo-core 存下来的号,找不到再按原文对
322 const kept = m.ids[e.requestId] === undefined ? (await recallOnce($, e.requestId))?.u : undefined
323 const n = kept ?? findNo(m, e.requestId, e.props.text)
324 // 记号之前就有的消息(比如恢复的旧会话)对不上就不编号,免得编错
325 if (n === undefined) return next(e)
326 const tag = ((await $.state.get(TagsRef)).value ?? {})[String(n)] ?? null
327 hit = { n, tag }
328 // 只记精确对上的:桌面先画消息、后登记编号,这时按包含关系找可能先对上一条更早的相似消息
329 // (比如「…只跑这一条」会被标成上一条「…只跑这一条,结果用一句话告诉我」的号),等登记完重画就对了
330 const exact = kept !== undefined || m.ids[e.requestId] !== undefined || m.texts[e.props.text.trim()] !== undefined
331 // 标签到了,或者已经不是最新一条(分类早做完或失败了,或者根本没装 lemo-journal),以后就不再等
332 if (exact && (tag !== null || n < seqNow)) userDone.set(e.requestId, hit)
333 }
334 const { n, tag } = hit
335 const lk = await look($)
336 const S = STR[lk.lang]
337 const kind = tag === null ? null : S.kinds[tag] ?? tag
338 // 桌面保留原生气泡,只在文字前加编号和标签
339 if (e.surface !== 'terminal') {
340 return next({ ...e, props: { ...e.props, text: `${exp(n)}${kind === null ? '' : ` · ${kind}`} · ${e.props.text}` } })
341 }
342 const { Box, Text } = $.ui.resolve(e)
343 // 编号、标签、竖线不许被挤窄:消息一长,它们会被折成两行(「[排查」和「]」分开)
344 // 最左边一列强调色竖条,和面板卡片一样,消息折成几行它就拉多高。
345 // 上面空一行:引擎原来画的消息自己带这一行空行,换掉以后没了,上一轮的耗时行(黄底)和这一行的竖条连成一片
346 return (
347 <Box flexDirection="row" gap={1} marginTop={1}>
348 <Box key="skin-spine" width={1} flexShrink={0} backgroundColor={spineColor(lk)} />
349 <Box flexDirection="row" gap={1} flexShrink={0}>
350 <Text color={lk.c.ink} bold>{exp(n)}</Text>
351 {kind === null ? null : <Text color={lk.c.pencil}>{`[${kind}]`}</Text>}
352 <Text color={lk.c.grid}>┊</Text>
353 </Box>
354 <Box flexShrink={1}>
355 {/* 这一行直接画在终端底色上(换掉原生画法后没有主题的底色),用终端自己的前景色:深色终端配浅色主题时主题正文色是黑的 */}
356 <Text {...bareInk(lk, e.surface)} bold>{e.props.text}</Text>
357 </Box>
358 </Box>
359 )
360 })
361
362 // ---------- Claude 的回复:每段回复上面加「回复 T03」小标签,正文还是引擎自己画(保留 Markdown 排版) ----------
363 // 每段回复都挂(一轮里被工具调用隔开的每一段,各有一个开头)
364 // 桌面上不看 isFirstOfReply:先调了工具的那一轮(比如抽签),工具后面的回复它是 false,标签挂不上。
365 // 桌面不画回复开头的圆点,每段回复都是单独一段字,都挂
366 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
367 if ((!e.props.isFirstOfReply && e.surface === 'terminal') || !(await isStyled($))) return next(e)
368 let n = replyDone.get(e.requestId)
369 if (n === undefined) {
370 const m = (await $.state.get(RepliesRef)).value ?? NO_NUMBERS
371 // 恢复的旧会话先按 id 找 lemo-core 存下来的号,再按原文对
372 n = m.ids[e.requestId] ?? (await recallOnce($, e.requestId))?.r ?? m.texts[replyKey(e.props.text)]
373 if (n !== undefined) replyDone.set(e.requestId, n)
374 }
375 const lk = await look($)
376 const S = STR[lk.lang]
377 // 0:不是用户本人发消息开头的一轮(提醒、助手交回、斜杠命令),不带号
378 const label = n === undefined || n <= 0 ? word(lk, 'lemo-skin.replyBare', S.replyBare) : fill(word(lk, 'lemo-skin.reply', S.reply), { no: exp(n), n })
379 const drawn = await next(e)
380 const { Box, Text } = $.ui.resolve(e)
381 return (
382 <Box flexDirection="column">
383 <Box flexDirection="row">
384 <Text backgroundColor={lk.c.accent} color={lk.c.onAccent} bold>{label}</Text>
385 </Box>
386 {drawn}
387 </Box>
388 )
389 })
390
391 // ---------- 工具进度(只有终端有):命令跑了几秒后出现的「ctrl+b 放到后台」那一行,换成会转的进度 ----------
392 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
393 if (e.surface !== 'terminal' || e.props.kind !== 'background_hint' || !(await isStyled($))) return next(e)
394 const lk = await look($)
395 const S = STR[lk.lang]
396 // 读 lemo-core 的 frame:每秒重画一次,转圈、走秒
397 const f = (await $.state.get(FrameRef)).value ?? 0
398 const at = startedAt.get(e.props.tool_use_id)
399 const now = await $.clock.now()
400 const { Box, Text } = $.ui.resolve(e)
401 return (
402 <Box flexDirection="row" gap={1}>
403 <Text color={lk.c.ink}>{f % 2 === 0 ? '◐' : '◑'}</Text>
404 <Text color={lk.c.ink} bold>{word(lk, 'lemo-skin.running', S.running)}</Text>
405 {at === undefined ? null : <Text color={lk.c.pencil}>{fmtDur(now - at)}</Text>}
406 <Text color={lk.c.grid}>{'· ' + S.bgHint}</Text>
407 </Box>
408 )
409 })
410
411 // ---------- 启动提示(只有终端有):logo 下面那几行灰字,前面加一个小标签 ----------
412 on('ui.render', { component: 'InfoNotice' }, async ($, e, next) => {
413 if (e.surface !== 'terminal' || !(await isStyled($))) return next(e)
414 const lk = await look($)
415 const { Box, Text } = $.ui.resolve(e)
416 return (
417 <Box flexDirection="row" gap={1}>
418 <Text backgroundColor={lk.c.accent} color={lk.c.onAccent}>{` ${word(lk, 'lemo-skin.info', STR[lk.lang].info)} `}</Text>
419 <Text color={lk.c.pencil}>{e.props.text}</Text>
420 {e.props.command === null || e.props.text.includes(e.props.command) ? null : <Text color={lk.c.ink}>{e.props.command}</Text>}
421 </Box>
422 )
423 })
424
425 // ---------- 模式标签:输入框右下角那几个灰字(focus、memory paused),只加自己的开关 ----------
426 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
427 if (!(await isUnfolded($))) return next(e)
428 const lk = await look($)
429 return next({ ...e, props: { ...e.props, modes: [...e.props.modes, STR[lk.lang].unfold.mode] } })
430 })
431
432 // ---------- 输入框下的提示行:末尾加「风格名 /lemo-mod」 ----------
433 // tail 只有终端画(桌面还不画),桌面上原样
434 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
435 if (e.surface !== 'terminal' || e.props.isWorking || e.props.isDraft || !(await isStyled($))) return next(e)
436 const lk = await look($)
437 // 风格没有标题(素色)时只写命令,不写成「lemo-mod /lemo-mod」
438 const title = word(lk, 'lemo-core.title', '')
439 return next({ ...e, props: { ...e.props, tail: title === '' ? '/lemo-mod' : `${title} /lemo-mod` } })
440 })
441
442 // ---------- 耗时行(只有终端有):编号、用时,再加上这一轮几步、几次工具(lemo-core 数的) ----------
443 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
444 if (!(await isStyled($))) return next(e)
445 let rec = turnDone.get(e.requestId)
446 if (rec === undefined) {
447 rec = ((await $.state.get(TurnRowsRef)).value ?? {})[e.requestId]
448 if (rec === undefined) {
449 // 恢复的旧会话:按耗时行的 id 找 lemo-core 存下来的(--fork-session 另开的一份 id 也不变)
450 const t = (await recallOnce($, e.requestId))?.t
451 if (t !== undefined) rec = { n: t[0], steps: t[1], tools: t[2] }
452 }
453 if (rec !== undefined) turnDone.set(e.requestId, rec)
454 }
455 const lk = await look($)
456 const S = STR[lk.lang]
457 // 还是没记录的(没存过号的旧会话恢复时)不写编号:按画出来时的编号算,几行旧的会都写成同一个号或 T00。
458 // 正在进行的会话里,记录晚一步写进来也没关系:读了 turnRows 就订阅了,写进来以后会重画
459 // 号是 0:不是用户本人发消息开头的一轮,也不写号(步数、工具数照写)
460 const label = rec === undefined || rec.n <= 0
461 ? fill(word(lk, 'lemo-skin.turnDone', S.turnDone), { no: '', n: '' }).replace(/ {2,}/g, ' ')
462 : fill(word(lk, 'lemo-skin.turnDone', S.turnDone), { no: exp(rec.n), n: rec.n })
463 const stats = rec === undefined ? null : `${fill(plural(S.steps, rec.steps), { n: rec.steps })} · ${fill(plural(S.tools, rec.tools), { n: rec.tools })}`
464 // 一整段文字:对话区很窄(比如面板停在旁边)时整行一起折,不会三段各折各的、错开
465 const { Text } = $.ui.resolve(e)
466 return (
467 <Text>
468 <Text backgroundColor={lk.c.accent} color={lk.c.onAccent} bold>{label}</Text>
469 <Text color={lk.c.pencil}>{` · ${fmtDur(e.props.durationMs)}`}</Text>
470 {stats === null ? null : <Text color={lk.c.pencil}>{` · ${stats}`}</Text>}
471 </Text>
472 )
473 })
474
475 // ---------- 工具组:连续的读文件、检索、跑命令被折叠成一行(Read 1 file、Ran 1 shell command),这一行也换皮 ----------
476 // 面板里打开「展开工具行」后,不再折叠:每条调用单独画成一行(再由下面 ToolUse 的 hook 换皮)。
477 // 组里有别的 lemo mod 的工具(mcp__lemo-…,比如抽签)时不数它们,它们的卡片由各自的 mod 画:
478 // - 那个 mod 在外层:它 next(e) 拿到这里画的计数行(没有可数的就是引擎原样),再接上自己的卡片
479 // - 那个 mod 在里层:这里先 next(e) 问它,它画了东西(不是引擎原样)就把计数行放在它画的东西上面
480 // 所以那个 mod 拿到引擎原样时,只画自己的卡片,不要把引擎原样也带上(不然在这种顺序下会多一行)
481 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
482 const unfolded = e.props.isExpanded || (await isUnfolded($))
483 if (unfolded) {
484 for (const c of e.props.calls) if (c.tool_use_id !== undefined) unfoldedCalls.add(c.tool_use_id)
485 while (unfoldedCalls.size > 2000) {
486 const oldest = unfoldedCalls.values().next().value
487 if (oldest === undefined) break
488 unfoldedCalls.delete(oldest)
489 }
490 }
491 if (e.props.isExpanded) return next(e)
492 // 「展开工具行」是自己的开关:消息样式关了,打开的展开照样展开
493 if (unfolded) return next({ ...e, props: { ...e.props, isExpanded: true } })
494 if (!(await isStyled($))) return next(e)
495 const calls = e.props.calls
496 const own = calls.filter(c => c.tool.startsWith(LEMO_TOOL))
497 const rest = calls.filter(c => !c.tool.startsWith(LEMO_TOOL))
498 // 「查找工具」只是加载工具的准备动作,有别的调用时不提(桌面会把它和抽签折在一起:「Used 2 tools」)
499 const shown = rest.length > 1 || own.length > 0 ? rest.filter(c => c.tool !== 'ToolSearch') : rest
500 let others: RenderChildren = null
501 if (own.length > 0) {
502 const inner = await next(e)
503 if (shown.length === 0) return inner
504 if (!isEngineDrawing(inner)) others = inner
505 }
506 const first = shown[0]
507 if (first === undefined) return next(e)
508 const lk = await look($)
509 const S = STR[lk.lang]
510 const at = first.tool_use_id === undefined ? undefined : startedAt.get(first.tool_use_id)
511 const tally = new Map<string, number>()
512 for (const c of shown) {
513 const k = c.tool === 'Read' || c.tool === 'Bash' || c.tool === 'ToolSearch' ? c.tool : c.tool === 'Grep' || c.tool === 'Glob' ? 'search' : c.tool
514 tally.set(k, (tally.get(k) ?? 0) + 1)
515 }
516 const known = (k: string) => k === 'Read' || k === 'Bash' || k === 'search' || k === 'ToolSearch'
517 const joined = [...tally]
518 .map(([k, n]) => {
519 const p = k === 'Read' ? S.group.read : k === 'Bash' ? S.group.bash : k === 'search' ? S.group.search : k === 'ToolSearch' ? S.group.tools : S.group.other
520 return fill(plural(p, n), { n, tool: k })
521 })
522 .join(S.group.sep)
523 // 英文开头大写(Ran 1 command),和单个工具行的动词(Run、Read)一样。
524 // 只改我们自己的句子:开头是别的工具名(mcp__…)时照原样写
525 const what = known([...tally.keys()][0] ?? '') ? joined.replace(/^[a-z]/, c => c.toUpperCase()) : joined
526 const [label, tone]: [string, string] = calls.some(c => c.isRunning)
527 ? [S.state.running, lk.c.ink]
528 : calls.some(c => c.isErrored)
529 ? [S.state.error, lk.c.red]
530 : [S.state.done, lk.c.pencil]
531 const dots = e.surface === 'terminal'
532 ? '·'.repeat(Math.max(2, Math.min(30, (e.viewport?.columns ?? 80) - (7 + width(what) + 2 + width(label)) - 8)))
533 : '···'
534 const { Box, Text } = $.ui.resolve(e)
535 // 恢复的旧会话不知道调用时间,就不写时间。
536 // 一整段文字:对话区很窄(面板停在旁边)时整行一起折,不会几段各折各的、错开(和耗时行一样)
537 const row = (
538 <Text key="row">
539 <Text color={lk.c.pencil}>{at === undefined ? '' : hhmm(at) + ' '}</Text>
540 <Text color={lk.c.ink} bold>{what}</Text>
541 <Text color={lk.c.grid}>{' ' + dots}</Text>
542 <Text color={tone}>{' ' + label}</Text>
543 </Text>
544 )
545 if (others === null) return row
546 return (
547 <Box flexDirection="column" gap={1}>
548 {row}
549 {others}
550 </Box>
551 )
552 })
553
554 // ---------- 单独的工具行:开始时间、动词、对象、状态 ----------
555 // 只换这几种工具;别的工具(包括别的 lemo mod 的工具)照引擎原样画,或者交给它自己的 mod
556 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
557 const tool = String(e.props.tool)
558 if (STR.zh.verb[tool] === undefined || !(await isStyled($))) return next(e)
559 const id = e.props.tool_use_id
560 // 展开的工具组里、已经有输出的行:输出画在这一行里,交回引擎原样
561 if (unfoldedCalls.has(id) && e.props.output !== undefined) return next(e)
562 const lk = await look($)
563 const S = STR[lk.lang]
564 const verb = word(lk, `lemo-skin.verb.${tool}`, S.verb[tool] ?? tool)
565 const at = startedAt.get(id)
566 const raw = target(tool, e.props.input).trim()
567 const isFull = FULL_TARGET.has(tool)
568 const [label, tone]: [string, string] = e.props.isRunning
569 ? [S.state.running, lk.c.ink]
570 : e.props.isErrored
571 ? [S.state.error, lk.c.red]
572 : e.props.isInterrupted
573 ? [S.state.interrupted, lk.c.pencil]
574 : [S.state.done, lk.c.pencil]
575 const isTerm = e.surface === 'terminal'
576 // 对象在这一行里能占几列:最多 48;终端对话栏窄(面板停在旁边)时减去时间、动词、点、状态和空格,
577 // 不然对象会在行里折开,状态夹在中间(「…?a=1&b ·· done」下一行「=2」)
578 const fixed = (at === undefined ? 1 : 7) + width(verb) + 1 + 1 + 2 + 1 + width(label) + 2
579 const room = isTerm ? Math.max(8, Math.min(48, (e.viewport?.columns ?? 80) - fixed)) : 48
580 // 整条显示的放不下一行(超过能占的宽或者本身有换行):状态那一行不带它,整条另起一行、自动换行
581 const isLong = isFull && (raw.includes('\n') || width(raw) > room)
582 const what = isFull ? (isLong ? '' : raw) : clip(raw, room)
583 const dots = isTerm ? '·'.repeat(Math.max(2, Math.min(30, (e.viewport?.columns ?? 80) - (7 + width(verb) + 2 + width(what) + 2 + width(label)) - 8))) : '···'
584 const { Box, Text } = $.ui.resolve(e)
585 // 路径和命令:桌面上画成浅底的小标签,终端里用方格线的颜色
586 const chip = (text: string) =>
587 isTerm ? <Text color={lk.c.grid}>{text}</Text> : <Text color={lk.c.inkDark} backgroundColor={lk.c.chip}>{` ${text} `}</Text>
588 const row = (
589 <Box flexDirection="row" gap={1}>
590 <Text color={lk.c.pencil}>{at === undefined ? '' : hhmm(at) + ' '}</Text>
591 <Text color={lk.c.ink} bold>{verb}</Text>
592 {isLong ? null : chip(what)}
593 <Text color={lk.c.grid}>{dots}</Text>
594 <Text color={tone}>{label}</Text>
595 </Box>
596 )
597 if (!isLong) return row
598 return (
599 <Box flexDirection="column">
600 {row}
601 <Box paddingLeft={isTerm ? 2 : 0}>{chip(raw)}</Box>
602 </Box>
603 )
604 })
605
606 // ---------- 安全页:能力清单、开关、「全部关闭」 ----------
607
608 on('lemo.caps', async ($, e, next) => {
609 const r = await next(e)
610 const row: Cap = {
611 mod: 'lemo-skin',
612 id: 'look',
613 kind: 'look',
614 title: { zh: STR.zh.cap.title, en: STR.en.cap.title },
615 desc: { zh: STR.zh.cap.desc, en: STR.en.cap.desc },
616 on: await read($, lookOn),
617 }
618 return { value: [...(r.value ?? []), row] }
619 })
620
621 on('lemo.toggle', async ($, e, next) => {
622 if (e.mod !== 'lemo-skin') return next(e)
623 const want = e.on
624 if (e.id !== 'look' || want === undefined) return { value: false }
625 await update($, lookOn, () => want)
626 await $.store.set('look', want)
627 return { value: true }
628 })
629
630 // 「全部关闭」只关外观以外的;消息样式是外观,调用 skill 时的提示音归 lemo-core 的声音开关,这里没有要关的,往下传
631 on('lemo.off', async ($, e, next) => next(e))
632}
633hooks/i18n.ts 124 lines1// lemo-skin 自己的文字(中性的默认文字,素色风格下显示的就是它们)。
2// 带风格味道的放在风格包 words/lemo-skin.ts,按下面这些键覆盖(mod 里用 word(lk, 'lemo-skin.<键>', 默认) 取):
3// reply、replyBare、turnDone、running、info、skill、verb.<工具名>
4// 占位符:{no} 是消息编号(T03),{n} 是数字,{name} 是 skill 名,{tool} 是工具名。
5// 英文要分单复数的写成 [单数, 复数],用 plural() 挑
6
7import type { Lang } from './shared/lemo'
8
9/** [单数, 复数]:n 为 1 用第一个 */
10export type Plural = readonly [string, string]
11
12const zh = {
13 title: '换皮',
14 // Claude 每段回复上面的小标签
15 reply: ' 回复 {no} ',
16 // 对不上编号的回复(比如恢复的旧会话)
17 replyBare: ' 回复 ',
18 // 耗时行(一轮结束那一行)
19 turnDone: ' ✓ {no} 完成 ',
20 steps: ['{n} 步', '{n} 步'] as Plural,
21 tools: ['{n} 次工具', '{n} 次工具'] as Plural,
22 // 工具进度行(命令跑了几秒后出现的那一行)
23 running: '运行中',
24 bgHint: 'ctrl+b 转到后台运行',
25 // 启动提示前面的小标签
26 info: '提示',
27 // 调用 skill 时横条上的提示
28 skill: '调用 skill:{name}',
29 // 工具行的动词:只有这几种工具换皮,别的工具照引擎原样画
30 verb: { Read: '读取', Bash: '运行', Grep: '检索', Glob: '查找', WebFetch: '抓取', WebSearch: '搜索' } as Readonly<Record<string, string>>,
31 state: { running: '进行中', error: '出错', interrupted: '中断', done: '完成' },
32 // 折叠的工具组那一行
33 group: {
34 read: ['读取 {n} 个文件', '读取 {n} 个文件'] as Plural,
35 bash: ['运行 {n} 条命令', '运行 {n} 条命令'] as Plural,
36 search: ['检索 {n} 次', '检索 {n} 次'] as Plural,
37 tools: ['查找工具 {n} 次', '查找工具 {n} 次'] as Plural,
38 other: ['调用 {tool} {n} 次', '调用 {tool} {n} 次'] as Plural,
39 sep: ',',
40 },
41 // 消息的标签(lemo-journal 自动分类贴的),显示在编号后面;认不出的标签原样显示
42 kinds: { question: '提问', code: '改代码', debug: '排查', chat: '闲聊' } as Readonly<Record<string, string>>,
43 unfold: {
44 title: '展开工具行',
45 desc: '把合并的工具行(如「读取 3 个文件」)逐条展开',
46 on: '已展开 · 点击收起',
47 off: '展开',
48 // 输入框右下角的模式标签
49 mode: '展开工具行',
50 },
51 // 安全页上的那一行(能力清单)
52 cap: {
53 title: '消息样式',
54 desc: '消息加编号 T01、T02,回复标注「回复 T03」,工具行和耗时行使用风格样式',
55 },
56 // 面板「已装的 mod」里列的、装上就生效的外观(在安全页「消息样式」里能关)。terminal 那几条只在有终端的会话里列(桌面没有这几行)
57 always: {
58 common: [
59 '消息加编号 T01、T02,回复标注「回复 T03」',
60 '工具行和合并的工具组使用风格样式,显示开始时间',
61 '调用 skill 时在横条提示「调用 skill:名字」',
62 ],
63 terminal: [
64 '耗时行显示编号、用时、步数和工具次数',
65 '「转到后台」提示、启动提示、输入框下方提示使用风格样式',
66 ],
67 },
68}
69
70export type SkinStrings = typeof zh
71
72const en: SkinStrings = {
73 title: 'Skin',
74 reply: ' Reply {no} ',
75 replyBare: ' Reply ',
76 turnDone: ' ✓ {no} done ',
77 steps: ['{n} step', '{n} steps'],
78 tools: ['{n} tool', '{n} tools'],
79 running: 'Running',
80 bgHint: 'ctrl+b to run in the background',
81 info: 'Notice',
82 skill: 'Skill: {name}',
83 verb: { Read: 'Read', Bash: 'Run', Grep: 'Search', Glob: 'Find', WebFetch: 'Fetch', WebSearch: 'Web search' },
84 state: { running: 'running', error: 'error', interrupted: 'stopped', done: 'done' },
85 group: {
86 read: ['read {n} file', 'read {n} files'],
87 bash: ['ran {n} command', 'ran {n} commands'],
88 search: ['searched {n} time', 'searched {n} times'],
89 tools: ['looked up tools', 'looked up tools ×{n}'],
90 other: ['{tool} × {n}', '{tool} × {n}'],
91 sep: ', ',
92 },
93 kinds: { question: 'question', code: 'code', debug: 'debug', chat: 'chat' },
94 unfold: {
95 title: 'Unfold tool rows',
96 desc: 'Expands grouped tool rows (like "Read 3 files") into one row per call',
97 on: 'Unfolded · click to collapse',
98 off: 'Unfold',
99 mode: 'unfolded',
100 },
101 cap: {
102 title: 'Message style',
103 desc: 'Messages numbered T01, T02, replies marked "Reply T03", tool rows and turn lines use the style',
104 },
105 always: {
106 common: [
107 'Messages numbered T01, T02; replies marked "Reply T03"',
108 'Tool rows and grouped tool rows use the style and show their start time',
109 'Shows "Skill: name" on the band when a skill is called',
110 ],
111 terminal: [
112 'Turn line shows the number, time, steps and tool count',
113 'The "run in background" hint, startup notices and the hint under the prompt use the style',
114 ],
115 },
116}
117
118export const STR: Record<Lang, SkinStrings> = { zh, en }
119
120/** 按数量挑单复数 */
121export function plural(p: Plural, n: number): string {
122 return n === 1 ? p[0] : p[1]
123}
124hooks/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 14 lines1// lemo-skin 的状态合约:每处换皮的画法都读它,读了会订阅,一改自动重画。
2
3/** 安全页上「消息样式」开着吗(外观,装上就开) */
4export type LemoSkinLook = boolean
5
6declare module 'claude-code' {
7 interface PluginState {
8 'lemo-skin': {
9 /** 存进 $.store(键 look,全局记住),开会话时读回来 */
10 lookOn: LemoSkinLook
11 }
12 }
13}
14