核心:风格、中英文界面、消息编号和 /lemo-mod 面板,以及给其他 lemo mod 用的 $.lemo / Core: styles, zh/en interface, message numbers and the /lemo-mod panel, plus the $.lemo helpers other…

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 1327 lines1// lemo-core:所有 lemo mod 的核心。
2// - 风格:当前用哪个风格包(颜色、点缀、音效、语音、带风格味道的文字),存在 $.store,换个会话还在
3// - 界面语言:跟着用户最近一条消息走;还没记过语言的新用户,先按系统的语言设置选
4// - 消息编号:用户本人发的第几条消息(T01、T02…),以及回复、耗时行属于第几条
5// - 统一面板 /lemo-mod:画头部、分页和「风格」「已装的 mod」两张卡片;别的 mod 把卡片接在后面
6// - 公共方法 $.lemo:放音效、朗读、横条提示、胶囊、报到……(见 types/index.d.ts)
7// - 系统提示词里加一段固定说明:界面上的 T01、T02 是用户的第几条消息(有开关,默认关)
8// - 「安全」页:装上后读一遍用户的设置,列出每个 mod 会做的事和开关;没按「我看完了」之前每次开会话都弹
9
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, Register } from 'claude-code'
12
13import type {
14 Lemo, LemoBadge, LemoCap, LemoCapKind, LemoKept, LemoLang, LemoModInfo, LemoNotice, LemoNumbers, LemoScan, LemoStyle, LemoTab, LemoText, LemoTheme, LemoTurn, LemoTurnRow,
15} from '../types'
16import { DEFAULT_STYLE, STYLES } from '../styles'
17import { STR, TAB_WORDS, detectLang } from './i18n'
18import { HUB, NOT_PERSON, REMIND_MARK, cardCols, cjkKeep, cmdWord, deskDark, exp, fill, hubWrap, inkOf, mix, para, recent, replyKey, sec, steady, visibleTabs, width, word } from './shared/lemo'
19import type { CardSpec } 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// 风格文字里的 {style} 换成名字:用户给这个风格起过名字(「起名」,每个风格各记各的)就用起的,没起过用风格自己的名字。
74// {styleZh}、{styleEn} 是指定语言的(日志抬头中英合写)。别的 mod 从 lemo-core 的 style 状态读文字,读到的就是填好的
75function named(st: LemoStyle, nick: string | undefined): LemoStyle {
76 const zh = nick ?? st.name.zh
77 const en = nick ?? st.name.en
78 const put = (x: string, own: string) =>
79 x.replace(/\{style(Zh|En)?\}/g, (m: string, which: string | undefined, at: number) =>
80 spaced(x[at - 1] ?? '', which === 'Zh' ? zh : which === 'En' ? en : own, x[at + m.length] ?? ''),
81 )
82 const one = (v: LemoText[keyof LemoText], own: string) => (typeof v === 'string' ? put(v, own) : v.map(x => put(x, own)))
83 const words: Record<string, LemoText> = {}
84 for (const [k, w] of Object.entries(st.words)) words[k] = { zh: one(w.zh, zh), en: one(w.en, en) }
85 return { ...st, words }
86}
87
88// 名字的头或尾是字母、数字,挨着的又是汉字,中间加个空格:「以 Lemo 实验室的名义」「Dodo 守则」,不写成「以Lemo」
89const HAN = /\p{Script=Han}/u
90const WORDY = /[A-Za-z0-9]/
91function spaced(before: string, name: string, after: string): string {
92 const head = HAN.test(before) && WORDY.test(name.charAt(0)) ? ' ' : ''
93 const tail = HAN.test(after) && WORDY.test(name.charAt(name.length - 1)) ? ' ' : ''
94 return head + name + tail
95}
96
97// 起的名字最多这么宽(汉字算 2):横条、面板标题、命令卡片的标签里都要放得下
98const NICK_MAX = 16
99// 起名的命令词(/lemo-mod 起名 豆豆)
100const NICK_WORDS = ['起名', '改名', 'name', 'rename']
101
102const FALLBACK: LemoStyle = named(STYLES[DEFAULT_STYLE] ?? Object.values(STYLES)[0]!, undefined)
103
104const style = atom({ plugin: 'lemo-core', key: 'style' } as const, FALLBACK)
105const lang = atom({ plugin: 'lemo-core', key: 'lang' } as const, 'zh' as LemoLang)
106const theme = atom({ plugin: 'lemo-core', key: 'theme' } as const, null as LemoTheme)
107const desk = atom({ plugin: 'lemo-core', key: 'desk' } as const, null as LemoTheme)
108const icon = atom({ plugin: 'lemo-core', key: 'icon' } as const, null as string | null)
109const frame = atom({ plugin: 'lemo-core', key: 'frame' } as const, 0)
110const tab = atom({ plugin: 'lemo-core', key: 'tab' } as const, 'main' as LemoTab)
111const mods = atom({ plugin: 'lemo-core', key: 'mods' } as const, [] as readonly LemoModInfo[])
112const modsOpen = atom({ plugin: 'lemo-core', key: 'modsOpen' } as const, false)
113const notice = atom({ plugin: 'lemo-core', key: 'notice' } as const, null as LemoNotice | null)
114const badges = atom({ plugin: 'lemo-core', key: 'badges' } as const, [] as readonly LemoBadge[])
115// 声音、朗读装上时都是关的(除了外观全部默认关),用户在安全页或各自的卡片里打开,存进 $.store
116const muted = atom({ plugin: 'lemo-core', key: 'muted' } as const, true)
117const speech = atom({ plugin: 'lemo-core', key: 'speech' } as const, false)
118const scan = atom({ plugin: 'lemo-core', key: 'scan' } as const, null as LemoScan | null)
119const safeOk = atom({ plugin: 'lemo-core', key: 'safeOk' } as const, false)
120const capsRev = atom({ plugin: 'lemo-core', key: 'capsRev' } as const, 0)
121const seq = atom({ plugin: 'lemo-core', key: 'seq' } as const, 0)
122const turnNo = atom({ plugin: 'lemo-core', key: 'turnNo' } as const, 0)
123const numbers = atom({ plugin: 'lemo-core', key: 'numbers' } as const, { ids: {}, texts: {} } as LemoNumbers)
124const replies = atom({ plugin: 'lemo-core', key: 'replies' } as const, { ids: {}, texts: {} } as LemoNumbers)
125const turnRows = atom({ plugin: 'lemo-core', key: 'turnRows' } as const, {} as Readonly<Record<string, LemoTurnRow>>)
126const lastTurn = atom({ plugin: 'lemo-core', key: 'lastTurn' } as const, null as LemoTurn | null)
127const tags = atom({ plugin: 'lemo-core', key: 'tags' } as const, {} as Readonly<Record<string, string>>)
128
129// 公共方法被别的 mod 调用时没有自己的 $ 可读,所以几个常用值在模块里留一份镜像
130let curStyle: LemoStyle = FALLBACK
131// 用户给各风格起的名字(风格 id → 名字),存在 $.store 的 nicks 里
132let curNicks: Readonly<Record<string, string>> = {}
133let curLang: LemoLang = 'zh'
134let curMuted = true
135let curSpeech = false
136// 系统提示词里的编号说明(安全页「附加提示」那一行),默认关
137let curNote = false
138// 「全部关闭」按一次加一:朗读一条要试好几个语音,每试一个之前比一下,按了以后不再开始新的一次(已经在念的停不了)
139let speakGen = 0
140// 用户刚发出的消息文字:编号时和对话记录里的文字一起记,桌面上的消息靠它对上号
141let pendingText = ''
142// 用户刚输入的是斜杠命令:下一轮要是开始了(skill),就是这条命令开头的,不带编号。用户每输一次都重新判断。
143// counted:这次输入之后、这一轮开始之前已经编了号(像 /tmp 这样不是命令、当消息发出去的,照常带号)。
144// 每轮开始时用完就清掉,不留给下一轮(定时任务、/loop 跑的 skill 不经过用户输入,不会再清一次)
145let slashNext = false
146let counted = false
147// 有一轮正在跑(turn.start 到 turn.complete)。这时输入的命令(/lemo-mod 这种马上跑的)不动正在跑那一轮的号
148let busy = false
149// 「/名字」开头的是斜杠命令;名字里没有斜杠,所以 /Users/… 这种路径不算
150const SLASH = /^\/[A-Za-z][\w:.-]*(?=\s|$)/
151// /lemo-mod 的回复按 Markdown 画:照抄用户打的字(名字、风格名、不认的词)时,ASCII 标点前加反斜杠,
152// 画出来还是原样的字,不会变成链接、粗体
153const mdEsc = (text: string) => text.replace(/[!-\/:-@\[-`{-~]/g, '\\$&')
154// 这一轮的步数和工具数:每向模型发一次请求算一步
155let curSteps = 0
156let curTools = 0
157let registerTimer: { cancel: () => void } | null = null
158
159// 编号也存进 $.store,每个会话一个键(rows:<会话 id>):会话状态只在这个进程里,恢复旧会话时是空的。
160// 恢复的会话里消息 id 不变(--fork-session 另开的一份也不变),lemo-skin 画的时候按 id 用 $.lemo.recall 找回。
161// 没存过号的旧会话还是按原文对号
162type Kept = { u: Record<string, number>; r: Record<string, number>; t: Record<string, readonly [number, number, number]> }
163const KEEP_PREFIX = 'rows:'
164const KEEP_ORDER = 'rowsOrder'
165// 只留最近这么多个会话、每个会话每种最近这么多行($.store 整个不能超过 4 MiB:这样最多一百多万字节,平常几十 KB)
166const KEEP_SESSIONS = 30
167const KEEP_ROWS = 300
168let mine: Kept = { u: {}, r: {}, t: {} }
169let mineKey = ''
170let keepTimer: { cancel: () => void } | null = null
171// recall 用:各会话存下来的行,第一次 recall 时读一遍
172let keptAll: Promise<Map<string, LemoKept>> | null = null
173
174// 加进系统提示词的固定说明(prompt.compose):界面上的 T01、T02 是什么。有编号就有这段说明,所以放在核心。
175// 不跟语言、风格变,免得提示缓存失效
176const NUMBERING_NOTE =
177 'About the labels T01, T02, T03… in this interface: the lemo-mod plugins number the messages the user types in this session, in order. ' +
178 'T01 is the user\'s first message, T02 the second, and so on; slash commands, reminders and background notifications get no number. ' +
179 'When the user mentions a label such as T03, they mean their own third message in this session.'
180
181async function setLang($: EngineInterface, next: LemoLang | null) {
182 if (next === null || next === curLang) return
183 curLang = next
184 await update($, lang, () => next)
185 await $.store.set('lang', next)
186 scheduleRegister($)
187 await retitle($)
188}
189
190// 面板开着时换了风格或语言:用同一个 id 再 open 一次,面板标题跟着换(已经开着的不会再开一个)。
191// 桌面上不这样做,换成岩彩后面板左上角还是「柠檬实验室」
192async function retitle($: EngineInterface) {
193 try {
194 if ((await $.ui.panes()).some(p => p.id === HUB)) await $.ui.open({ id: HUB, title: await hubTitle() })
195 } catch {
196 // 换不了标题就算了,下次打开面板时是新的
197 }
198}
199
200// 记下一行编号,过一会儿统一存(连着来好几行时只存一次)
201function keepRow($: EngineInterface, patch: (k: Kept) => void) {
202 if (mineKey === '') return
203 patch(mine)
204 keepTimer?.cancel()
205 keepTimer = $.clock.after(1000, () => {
206 keepTimer = null
207 void saveKept($)
208 })
209}
210
211async function saveKept($: EngineInterface) {
212 mine = { u: recent(mine.u, KEEP_ROWS), r: recent(mine.r, KEEP_ROWS), t: recent(mine.t, KEEP_ROWS) }
213 try {
214 await $.store.set(mineKey, mine)
215 // 会话多了删最久没存过的。store 的键按名字排(会话 id 是乱的),所以另记一张先后表 rowsOrder;
216 // 不在表里的(几个 claude 同时存时漏记的)当最旧的
217 const order = ((await $.store.get(KEEP_ORDER)) as string[] | undefined) ?? []
218 const all = (await $.store.keys()).filter(k => k.startsWith(KEEP_PREFIX) && k !== mineKey)
219 const ranked = [...all.filter(k => !order.includes(k)), ...order.filter(k => all.includes(k)), mineKey]
220 const drop = ranked.slice(0, Math.max(0, ranked.length - KEEP_SESSIONS))
221 for (const k of drop) await $.store.delete(k)
222 await $.store.set(KEEP_ORDER, ranked.slice(drop.length))
223 } catch {
224 // 存不上只影响以后恢复这个会话时的编号
225 }
226}
227
228// 这个会话自己的键;接着同一个会话(--resume 不另开)时,接上之前存的
229async function loadMine($: EngineInterface) {
230 const id = await $.session.id().catch(() => '')
231 mineKey = id === '' ? '' : KEEP_PREFIX + id
232 if (mineKey === '') return
233 const v = (await $.store.get(mineKey).catch(() => undefined)) as Partial<Kept> | undefined
234 mine = { u: { ...(v?.u ?? {}) }, r: { ...(v?.r ?? {}) }, t: { ...(v?.t ?? {}) } }
235}
236
237// 「行为」页的静音、朗读开关(没装 lemo-sound、lemo-voice 时才有)。和 $.lemo.set 一样存进 $.store,换个会话还在
238async function setMuted($: EngineInterface, v: boolean) {
239 curMuted = v
240 await update($, muted, () => v)
241 await $.store.set('muted', v)
242}
243
244async function setSpeech($: EngineInterface, v: boolean) {
245 curSpeech = v
246 await update($, speech, () => v)
247 await $.store.set('speech', v)
248}
249
250async function setStyle($: EngineInterface, id: string): Promise<boolean> {
251 const raw = STYLES[id]
252 if (raw === undefined) return false
253 const next = named(raw, curNicks[id])
254 curStyle = next
255 await update($, style, () => next)
256 await $.store.set('style', id)
257 await loadIcon($)
258 // 命令描述里带着风格名,换了要重新登记
259 scheduleRegister($)
260 await retitle($)
261 return true
262}
263
264// 给当前风格起名字;空的就恢复原名。返回存下的名字(null = 恢复了原名),名字太长返回 false
265async function setNick($: EngineInterface, text: string): Promise<string | null | false> {
266 // 换行、制表、花括号(占位符的记号)都不要,连着的空白并成一个
267 const nick = text.replace(/[{}\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim()
268 if (width(nick) > NICK_MAX) return false
269 const id = curStyle.id
270 const { [id]: _old, ...rest } = curNicks
271 curNicks = nick === '' ? rest : { ...rest, [id]: nick }
272 await $.store.set('nicks', curNicks)
273 const raw = STYLES[id]
274 if (raw !== undefined) {
275 curStyle = named(raw, curNicks[id])
276 const next = curStyle
277 await update($, style, () => next)
278 }
279 // 命令描述、面板标题里都有名字
280 scheduleRegister($)
281 await retitle($)
282 return nick === '' ? null : nick
283}
284
285async function loadIcon($: EngineInterface) {
286 let png: string | null = null
287 if (curStyle.icon !== null) {
288 try {
289 png = (await $.fs.read(`${$.plugin.root}/${curStyle.icon}`, { as: 'bytes' })).base64
290 } catch {
291 png = null
292 }
293 }
294 await update($, icon, () => png)
295}
296
297// 系统的语言设置(POSIX 的顺序:LC_ALL、LC_MESSAGES、LANG,取第一个有值的):zh 开头的用中文,别的、没设的用英文。
298// 只在还没记过语言时用,记过就照记下的;用户发了普通消息照样跟着消息走。
299// macOS 上不少中文用户终端的 LANG 是 en_US,会先看到英文,发一条中文消息就变回来
300async function systemLang($: EngineInterface): Promise<LemoLang> {
301 const all = await $.env.get('LC_ALL').catch(() => undefined)
302 const messages = await $.env.get('LC_MESSAGES').catch(() => undefined)
303 const base = await $.env.get('LANG').catch(() => undefined)
304 const v = [all, messages, base].find(x => x !== undefined && x !== '') ?? ''
305 return /^zh/i.test(v) ? 'zh' : 'en'
306}
307
308async function loadPrefs($: EngineInterface) {
309 const savedLang = await $.store.get('lang')
310 curLang = savedLang === 'zh' || savedLang === 'en' ? savedLang : await systemLang($)
311 const savedNicks = await $.store.get('nicks')
312 if (typeof savedNicks === 'object' && savedNicks !== null && !Array.isArray(savedNicks)) {
313 curNicks = Object.fromEntries(Object.entries(savedNicks).filter((x): x is [string, string] => typeof x[1] === 'string' && x[1] !== ''))
314 }
315 const savedStyle = await $.store.get('style')
316 const raw = typeof savedStyle === 'string' ? STYLES[savedStyle] : undefined
317 curStyle = named(raw ?? STYLES[DEFAULT_STYLE] ?? Object.values(STYLES)[0]!, curNicks[raw?.id ?? DEFAULT_STYLE])
318 const savedMuted = await $.store.get('muted')
319 if (typeof savedMuted === 'boolean') curMuted = savedMuted
320 const savedSpeech = await $.store.get('speech')
321 if (typeof savedSpeech === 'boolean') curSpeech = savedSpeech
322 const savedNote = await $.store.get('note')
323 if (typeof savedNote === 'boolean') curNote = savedNote
324 const savedOk = await $.store.get('safeOk')
325 await update($, safeOk, () => savedOk === true)
326 // 先写一遍:别的 mod 读 lemo-core 的状态时,没写过的值读到的是 undefined
327 await update($, lang, () => curLang)
328 await update($, style, () => curStyle)
329 await update($, muted, () => curMuted)
330 await update($, speech, () => curSpeech)
331}
332
333// Claude Code 的主题是浅色还是深色(/config 里的 theme)。终端卡片的底色跟着它选
334async function refreshTheme($: EngineInterface) {
335 try {
336 const row = (await $.config.list()).find(r => r.key === 'theme')
337 const v = typeof row?.value === 'string' ? row.value : ''
338 const t: LemoTheme = /^dark/.test(v) ? 'dark' : /^light/.test(v) ? 'light' : null
339 await update($, theme, () => t)
340 } catch {
341 // 读不到主题就不加底色
342 }
343}
344
345// ---------- 桌面 App 的明暗 ----------
346// 插件接口里没有 App 的明暗(App 切到暗色,$.config 读到的仍是 Claude Code 自己的主题)。
347// App 把用户选的主题记在它自己的设置文件里:只在桌面会话里读,只读、不存、不发;读不到就当亮色,照原来的样子画。
348// 选了「跟随系统」时看 macOS 的外观:系统是深色时,系统设置里有 AppleInterfaceStyle 这一项,浅色时没有。
349// App 里换了明暗,几秒内跟上(每 3 秒看一次,变了才重画)
350const DESK_CONFIG = '/Library/Application Support/Claude/config.json'
351const MAC_PREFS = '/Library/Preferences/.GlobalPreferences.plist'
352// 二进制 plist 里键名「AppleInterfaceStyle」(19 个字母)的写法:0x5F 0x10 0x13 再跟字母。
353// 另一个键 AppleInterfaceStyleSwitchesAutomatically 长度不同,不会对上
354const MAC_DARK_KEY = [0x5f, 0x10, 0x13, ...[...'AppleInterfaceStyle'].map(ch => ch.charCodeAt(0))]
355let curDesk: LemoTheme = null
356
357const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
358function fromBase64(s: string): Uint8Array {
359 const clean = s.replace(/[^A-Za-z0-9+/]/g, '')
360 const out = new Uint8Array(Math.floor((clean.length * 3) / 4))
361 let n = 0
362 for (let i = 0; i + 1 < clean.length; i += 4) {
363 const v = [0, 1, 2, 3].map(k => B64.indexOf(clean[i + k] ?? 'A'))
364 const x = ((v[0] ?? 0) << 18) | ((v[1] ?? 0) << 12) | ((v[2] ?? 0) << 6) | (v[3] ?? 0)
365 for (const [k, shift] of [[1, 16], [2, 8], [3, 0]] as const) if (i + k < clean.length && n < out.length) out[n++] = (x >> shift) & 0xff
366 }
367 return out
368}
369
370/** macOS 现在是不是深色外观:系统设置文件(二进制或 XML 的 plist)里有没有 AppleInterfaceStyle 这一项 */
371function macDark(bytes: Uint8Array): boolean {
372 const head = String.fromCharCode(...bytes.slice(0, 8))
373 if (head.startsWith('<?xml')) return new TextDecoder().decode(bytes).includes('<key>AppleInterfaceStyle</key>')
374 outer: for (let i = 0; i + MAC_DARK_KEY.length <= bytes.length; i++) {
375 for (let k = 0; k < MAC_DARK_KEY.length; k++) if (bytes[i + k] !== MAC_DARK_KEY[k]) continue outer
376 return true
377 }
378 return false
379}
380
381async function refreshDesk($: EngineInterface) {
382 const surfaces = await $.session.surfaces().catch(() => [] as readonly string[])
383 if (!surfaces.includes('desktop')) return
384 const home = await $.env.get('HOME').catch(() => undefined)
385 if (home === undefined || home === '') return
386 let mode: unknown = undefined
387 let found = false
388 try {
389 mode = (JSON.parse(await $.fs.read(`${home}${DESK_CONFIG}`)) as { userThemeMode?: unknown }).userThemeMode
390 found = true
391 } catch {
392 // App 的设置文件读不到:当亮色
393 }
394 let v: LemoTheme = mode === 'dark' ? 'dark' : mode === 'light' ? 'light' : null
395 // 没写明亮或暗(选了跟随系统,或者从没改过主题,App 默认跟随系统):看 macOS 的外观
396 if (v === null && found) {
397 try {
398 v = macDark(fromBase64((await $.fs.read(`${home}${MAC_PREFS}`, { as: 'bytes' })).base64)) ? 'dark' : 'light'
399 } catch {
400 // 系统设置读不到:当亮色
401 }
402 }
403 if (v === curDesk) return
404 curDesk = v
405 await update($, desk, () => v)
406}
407
408// ---------- 安全 ----------
409
410// 安全页顶上的网页手册(GitHub Page)。只是一个链接,点了才打开,不自动开浏览器
411const MANUAL_URL = 'https://lemomo-ai.github.io/lemo-mod/'
412// 没按「我看完了」之前,横条上那个红胶囊的 id
413const SAFE_BADGE = 'lemo-safe'
414// 安全页上分类的先后
415const KIND_ORDER: readonly LemoCapKind[] = ['look', 'sound', 'speech', 'file', 'cost', 'prompt', 'tool', 'act', 'net']
416
417// 读一遍用户自己的设置,只看和 mod 重叠的几项。设置里什么都有(环境变量也在),所以只取这几项、不存、不发出去
418async function scanSettings($: EngineInterface) {
419 let found: LemoScan = { statusLine: false, spinnerVerbs: false, notif: null, outputStyle: null, theme: null }
420 const str = (v: unknown) => (typeof v === 'string' && v !== '' ? v : null)
421 try {
422 const set = await $.settings.read()
423 found = {
424 ...found,
425 statusLine: set.statusLine !== undefined && set.statusLine !== null,
426 spinnerVerbs: set.spinnerVerbs !== undefined && set.spinnerVerbs !== null,
427 notif: str(set.preferredNotifChannel),
428 outputStyle: str(set.outputStyle),
429 }
430 } catch {
431 // 读不到设置就当没有重叠
432 }
433 try {
434 found = { ...found, theme: str((await $.config.list()).find(r => r.key === 'theme')?.value) }
435 } catch {
436 // 读不到主题
437 }
438 await update($, scan, () => found)
439}
440
441// lemo-core 自己在能力清单里的几行:提示音、朗读、编号说明(都默认关)
442function coreCaps(): LemoCap[] {
443 const both = (k: 'sound' | 'speech' | 'note') => ({
444 title: { zh: STR.zh.safe.cap[k].title, en: STR.en.safe.cap[k].title },
445 desc: { zh: STR.zh.safe.cap[k].desc, en: STR.en.safe.cap[k].desc },
446 })
447 return [
448 { mod: 'lemo-core', id: 'sound', kind: 'sound', on: !curMuted, preview: true, ...both('sound') },
449 { mod: 'lemo-core', id: 'speech', kind: 'speech', on: curSpeech, preview: true, ...both('speech') },
450 { mod: 'lemo-core', id: 'note', kind: 'prompt', on: curNote, ...both('note') },
451 ]
452}
453
454// 开会话时:没按过「我看完了」就弹安全页(终端不够宽时面板等着,横条上的红胶囊和提示告诉用户怎么打开);
455// 按过了就在横条上写一行「已开:…」,不悄悄开着
456async function greet($: EngineInterface) {
457 const s = STR[curLang]
458 if (!(await read($, safeOk))) {
459 await $.lemo.badge({ id: SAFE_BADGE, text: { zh: STR.zh.safe.pending, en: STR.en.safe.pending }, tone: 'red' })
460 await $.lemo.notice({ text: s.safe.pending, tone: 'red', ms: 10000 })
461 await openHub($, 'safe')
462 return
463 }
464 const on = (await $.lemo.caps({})).filter(c => c.on && c.kind !== 'look' && c.manual !== true)
465 if (on.length > 0) await $.lemo.notice({ text: fill(s.safe.on, { list: on.map(c => c.title[curLang]).join(s.sep) }), tone: 'ink', ms: 8000 })
466}
467
468async function confirmSafe($: EngineInterface) {
469 await update($, safeOk, () => true)
470 await $.store.set('safeOk', true)
471 await $.lemo.badge({ id: SAFE_BADGE, text: null, tone: 'red' })
472}
473
474// /lemo-mod 的命令提示:拼上各 mod 报到时说的词。mod 陆续报到,等一会儿再统一登记一次。
475// immediate:Claude 干活时输入 /lemo-mod 也马上执行(打开面板、开番茄钟),不用等这一轮结束
476function scheduleRegister($: EngineInterface) {
477 registerTimer?.cancel()
478 registerTimer = $.clock.after(200, () => {
479 registerTimer = null
480 void registerCommand($)
481 })
482}
483
484// /lemo-mod 登记时的说明和提示:说明带风格名,提示拼上已经报到的 mod 说的词(按现在的语言)
485function commandSpec(list: readonly LemoModInfo[]) {
486 const s = STR[curLang]
487 const extra = list.flatMap(m => m.commands?.[curLang] ?? [])
488 const hint = [...extra, curLang === 'zh' ? '风格' : 'style'].join(' | ')
489 const name = word({ st: curStyle, lang: curLang }, 'lemo-core.title', s.title)
490 return { name: 'lemo-mod', description: fill(s.paren, { a: s.describe, b: name }), argumentHint: `[${hint}]`, immediate: true as const }
491}
492
493// 马上登记一次(不等计时器),返回登记的说明和提示,开会话时拿它看读完设置后要不要再登记
494async function registerCommand($: EngineInterface): Promise<string> {
495 const spec = commandSpec(await read($, mods))
496 try {
497 await $.command.register(spec)
498 } catch {
499 // 登记不上就沿用上一次的提示
500 }
501 return JSON.stringify(spec)
502}
503
504async function hubTitle(): Promise<string> {
505 return word({ st: curStyle, lang: curLang }, 'lemo-core.title', STR[curLang].title)
506}
507
508// asked:用户自己输 /lemo-mod 打开的。桌面 App 里光标在输入框时,点面板的第一下只用来把焦点挪到面板(要点两次),
509// 所以这时请 App 把焦点给面板,打开后第一下就能点中(App 只在输入框是空的时候给)。会话开头自动弹的安全页不要焦点,免得抢走用户打字的键盘;
510// 终端照旧:焦点留在输入框,ctrl+x Tab 进面板
511async function openHub($: EngineInterface, at?: LemoTab, asked = false) {
512 if (at !== undefined) await update($, tab, () => at)
513 const ss = asked ? await $.session.surfaces().catch(() => []) : []
514 const focus = ss.includes('desktop') && !ss.includes('terminal')
515 await $.ui.open({ id: HUB, title: await hubTitle(), ...(focus ? { focus: true as const } : {}) })
516}
517
518// 恢复旧会话时,新进程的编号从 0 开始。按对话记录里用户发的文字重新数一遍,接上原来的号。
519// 斜杠命令、系统插入的内容(都以 < 开头)、压缩摘要和 lemo-watch 发的提醒不算
520async function restoreNumbers($: EngineInterface) {
521 if ((await read($, seq)) > 0) return
522 const rows = await $.session.messages()
523 let n = 0
524 const texts: Record<string, number> = {}
525 // 同一句话发过不止一次(两次「你好」):按先后记下每次的号,画的时候按先后对(不然都标成最后那次的号)
526 const order: Record<string, number[]> = {}
527 // Claude 的回复也按文字对上属于第几条:恢复的会话里回复的 id 都不认识了,不补的话回复标签没有编号
528 const replyTexts: Record<string, number> = {}
529 for (const r of rows) {
530 if (r.role === 'assistant') {
531 const k = replyKey(r.text)
532 if (n > 0 && k !== '') replyTexts[k] = n
533 continue
534 }
535 if (r.role !== 'user') continue
536 const t = r.text.trim()
537 if (t === '' || t.startsWith('<') || t.startsWith(REMIND_MARK) || t.startsWith('This session is being continued')) continue
538 n += 1
539 texts[t] = n
540 order[t] = [...(order[t] ?? []), n]
541 }
542 if (n === 0) return
543 const dupes = Object.fromEntries(Object.entries(order).filter(([, ns]) => ns.length > 1))
544 await update($, seq, () => n)
545 await update($, numbers, m => ({
546 ...m,
547 texts: recent({ ...texts, ...m.texts }),
548 ...(Object.keys(dupes).length > 0 ? { dupes: recent(dupes) } : {}),
549 }))
550 await update($, replies, m => ({ ids: m.ids, texts: recent({ ...replyTexts, ...m.texts }) }))
551}
552
553async function tick($: EngineInterface) {
554 await update($, frame, f => f + 1)
555 const now = await $.clock.now()
556 // 对一下再清,在同一次写里完成:中间有 mod 写了新提示,不能把新的也清掉
557 if ((await read($, notice)) !== null) await update($, notice, cur => (cur !== null && now >= cur.until ? null : cur))
558}
559
560export const register: Register = on => {
561 // ---------- 公共方法 $.lemo ----------
562 // 校验规定:engine.create 里拿到的 $(这里叫 $b)不能当参数传给别的函数,只能就地写 $b.noun.event(...)。
563 // 所以这里不用 update() 这类帮手,读改写都直接调 $b.state
564 on('engine.create', async (_$, e, next) => {
565 const $b = await next(e)
566 const LANG = { plugin: 'lemo-core', key: 'lang' } as const
567 const MODS = { plugin: 'lemo-core', key: 'mods' } as const
568 const TAB = { plugin: 'lemo-core', key: 'tab' } as const
569 const NOTICE = { plugin: 'lemo-core', key: 'notice' } as const
570 const BADGES = { plugin: 'lemo-core', key: 'badges' } as const
571 const MUTED = { plugin: 'lemo-core', key: 'muted' } as const
572 const SPEECH = { plugin: 'lemo-core', key: 'speech' } as const
573 const UNFOLD = { plugin: 'lemo-core', key: 'unfold' } as const
574 const TAGS = { plugin: 'lemo-core', key: 'tags' } as const
575 const CAPS_REV = { plugin: 'lemo-core', key: 'capsRev' } as const
576
577 // /lemo-mod 的命令提示要拼上各 mod 的词;mod 陆续报到,等一会儿再统一登记一次,更新提示。
578 // 命令本身在开会话时就登记好了(见 session.start),不靠这里:新会话第一条就发 /lemo-mod 也认得
579 const reRegister = () => {
580 registerTimer?.cancel()
581 registerTimer = $b.clock.after(200, () => {
582 registerTimer = null
583 void (async () => {
584 const list = (await $b.state.get(MODS)).value ?? []
585 await $b.command.register(commandSpec(list)).catch(() => undefined)
586 })()
587 })
588 }
589
590 const SAFEOK = { plugin: 'lemo-core', key: 'safeOk' } as const
591 // 用风格的语音念:按顺序试风格里的语音,没装的会被拒绝,就换下一个;都没有就用系统默认语音
592 // preview:试听(不看朗读开关)。不是试听的,每次试之前也看开关:等上一个语音被拒的时候关掉了,就不再试
593 const speak = (text: string, preview: boolean) => {
594 const gen = speakGen
595 const may = () => gen === speakGen && (preview || curSpeech)
596 void (async () => {
597 for (const voice of curStyle.voices[curLang]) {
598 if (!may()) return
599 try {
600 await $b.audio.speak(text, { voice })
601 return
602 } catch {
603 // 没装这个语音
604 }
605 }
606 if (!may()) return
607 await $b.audio.speak(text).catch(() => undefined)
608 })()
609 }
610
611 const lemo: Lemo = {
612 style: async () => curStyle,
613 lang: async () => curLang,
614 join: async info => {
615 // 版本对不上(别的 mod 同时在写)就重读再写,最多 50 次
616 for (let i = 0; i < 50; i++) {
617 const cur = await $b.state.get(MODS)
618 const list = [...(cur.value ?? []).filter(m => m.mod !== info.mod), info]
619 if ((await $b.state.set(MODS, list, { ifVersion: cur.version })).isSet) break
620 }
621 reRegister()
622 },
623 open: async ({ tab: at }) => {
624 if (at !== undefined) await $b.state.set(TAB, at)
625 await $b.ui.open({ id: HUB, title: word({ st: curStyle, lang: curLang }, 'lemo-core.title', STR[curLang].title) })
626 },
627 play: async ({ sound, gain, preview }) => {
628 if (curMuted && preview !== true) return
629 void $b.audio.play({ asset: curStyle.sounds[sound] }, { gain: gain ?? 1 }).catch(() => undefined)
630 },
631 say: async ({ text, preview }) => {
632 // 朗读有自己的开关(安全页「朗读」),和提示音分开
633 if (preview !== true && !curSpeech) return
634 speak(text, preview === true)
635 },
636 notice: async ({ text, tone, ms }) => {
637 const life = ms ?? 5000
638 const until = (await $b.clock.now()) + life
639 await $b.state.set(NOTICE, { text, tone, until })
640 $b.ui.toast(text, { timeoutMs: life })
641 },
642 badge: async b => {
643 // 版本对不上(别的 mod 同时在写)就重读再写,最多 50 次
644 for (let i = 0; i < 50; i++) {
645 const cur = await $b.state.get(BADGES)
646 const rest = (cur.value ?? []).filter(x => x.id !== b.id)
647 if ((await $b.state.set(BADGES, b.text === null ? rest : [...rest, b], { ifVersion: cur.version })).isSet) break
648 }
649 },
650 set: async o => {
651 if (o.muted !== undefined) {
652 curMuted = o.muted
653 await $b.state.set(MUTED, o.muted)
654 await $b.store.set('muted', o.muted)
655 }
656 if (o.speech !== undefined) {
657 curSpeech = o.speech
658 await $b.state.set(SPEECH, o.speech)
659 await $b.store.set('speech', o.speech)
660 }
661 if (o.unfold !== undefined) await $b.state.set(UNFOLD, o.unfold)
662 // 声音、朗读卡片上改的开关,安全页那两行也要跟着重画
663 if (o.muted !== undefined || o.speech !== undefined) await $b.state.set(CAPS_REV, ((await $b.state.get(CAPS_REV)).value ?? 0) + 1)
664 },
665 tag: async ({ n, kind }) => {
666 // 版本对不上(别的 mod 同时在写)就重读再写,最多 50 次
667 for (let i = 0; i < 50; i++) {
668 const cur = await $b.state.get(TAGS)
669 if ((await $b.state.set(TAGS, recent({ ...(cur.value ?? {}), [String(n)]: kind }), { ifVersion: cur.version })).isSet) break
670 }
671 },
672 // 各 mod 挂 on('lemo.command') 接自己的词;走到这里就是谁都不认,交回 /lemo-mod 的 hook 自己处理
673 command: async () => null,
674 recall: async ({ id }) => {
675 if (keptAll === null) {
676 keptAll = (async () => {
677 const all = new Map<string, LemoKept>()
678 try {
679 for (const k of await $b.store.keys()) {
680 if (!k.startsWith(KEEP_PREFIX)) continue
681 const v = (await $b.store.get(k)) as Partial<Kept> | undefined
682 for (const [uuid, n] of Object.entries(v?.u ?? {})) all.set(uuid, { ...all.get(uuid), u: n })
683 for (const [uuid, n] of Object.entries(v?.r ?? {})) all.set(uuid, { ...all.get(uuid), r: n })
684 for (const [uuid, t] of Object.entries(v?.t ?? {})) all.set(uuid, { ...all.get(uuid), t })
685 }
686 } catch {
687 // 读不到就当没存过
688 }
689 return all
690 })()
691 }
692 return (await keptAll).get(id) ?? null
693 },
694 // 各 mod 挂 on('lemo.caps') 把自己的几行接在后面;最底下是 lemo-core 自己的
695 // 编号说明照 $.store 报:别的会话里改过,这个会话的安全页也是对的
696 caps: async () => {
697 const saved = await $b.store.get('note').catch(() => undefined)
698 if (typeof saved === 'boolean') curNote = saved
699 return coreCaps()
700 },
701 // 各 mod 挂 on('lemo.toggle') 认自己的;走到这里只剩 lemo-core 自己的几行
702 toggle: async ({ mod, id, on: want, preview }) => {
703 if (mod !== 'lemo-core') return false
704 if (preview === true) {
705 if (id === 'sound') void $b.audio.play({ asset: curStyle.sounds.done }, { gain: 1 }).catch(() => undefined)
706 else if (id === 'speech') speak(STR[curLang].safe.sample, true)
707 else return false
708 return true
709 }
710 if (want === undefined) return false
711 if (id === 'sound') {
712 curMuted = !want
713 await $b.state.set(MUTED, curMuted)
714 await $b.store.set('muted', curMuted)
715 } else if (id === 'speech') {
716 curSpeech = want
717 await $b.state.set(SPEECH, want)
718 await $b.store.set('speech', want)
719 } else if (id === 'note') {
720 curNote = want
721 await $b.store.set('note', want)
722 } else return false
723 return true
724 },
725 // 「全部关闭」:各 mod 挂 on('lemo.off') 关自己的再往下传;最底下关 lemo-core 自己的
726 off: async () => {
727 speakGen += 1
728 curMuted = true
729 curSpeech = false
730 curNote = false
731 await $b.state.set(MUTED, true)
732 await $b.state.set(SPEECH, false)
733 await $b.store.set('muted', true)
734 await $b.store.set('speech', false)
735 await $b.store.set('note', false)
736 // 关掉了也算看过安全页
737 await $b.state.set(SAFEOK, true)
738 await $b.store.set('safeOk', true)
739 },
740 }
741 return { ...$b, lemo }
742 })
743
744 // ---------- 生命周期 ----------
745 on('session.start', async ($, e, next) => {
746 // 先登记 /lemo-mod,排在读存档之前:桌面 App 新会话第一条就发 /lemo-mod 时,命令得已经在(不然提示
747 // 「isn't a command here」)。这时还没读用户的语言和风格,说明先用默认的;读完不一样再登记一次。
748 // 提示里各 mod 的词:已经报到的先带上,后报到的由 $.lemo.join 等 200 毫秒统一更新
749 const first = await registerCommand($)
750 await loadPrefs($)
751 if (JSON.stringify(commandSpec(await read($, mods))) !== first) await registerCommand($)
752 await refreshTheme($)
753 await scanSettings($)
754 // 等各 mod 报到、读完自己的开关,再弹安全页或写「已开:…」。排在编号前面:编号出错也不耽误安全页
755 $.clock.after(1500, () => {
756 void greet($)
757 })
758 await loadMine($)
759 await restoreNumbers($)
760 $.clock.every(1000, () => {
761 void tick($)
762 })
763 // 桌面 App 的明暗:先读一次,之后每 3 秒看一次(只在桌面会话里读)
764 await refreshDesk($)
765 $.clock.every(3000, () => {
766 void refreshDesk($)
767 })
768 // 不急的放到后面,免得拖慢会话启动
769 $.clock.after(0, () => {
770 void loadIcon($)
771 })
772 return next(e)
773 })
774
775 // 系统提示词:在最后加一段固定的说明,让 Claude 知道界面上的 T01、T02 指的是用户第几条消息(安全页的开关,默认关)。
776 // 系统提示词在会话第一次请求时就定下来了,之后改了也不再发给模型,所以只放不会变的内容
777 on('prompt.compose', async ($, e, next) => {
778 const r = await next(e)
779 // 默认不加:安全页「附加提示 · 编号说明」打开了才加。
780 // 从 $.store 现读:别的会话里关掉了,这个会话的系统提示词还没定的话也不加
781 if (e.traits.includes('bare')) return r
782 const saved = await $.store.get('note').catch(() => undefined)
783 if (typeof saved === 'boolean') curNote = saved
784 if (!curNote) return r
785 return { ...r, sections: [...r.sections, { id: 'lemo-core:numbering', text: NUMBERING_NOTE, scope: 'session' as const }] }
786 })
787
788 on('config.set', { key: 'theme' }, async ($, e, next) => {
789 const r = await next(e)
790 $.clock.after(0, () => {
791 void refreshTheme($)
792 })
793 return r
794 })
795
796 // 用户发消息:跟着换界面语言,记下原文(编号时用)。
797 // 斜杠命令不记:命令不进 session.append,原文会留到下一条真消息、借走它的号(桌面上会出现两条「T14 · /lemo-mod」)
798 on('prompt.submit', async ($, e, next) => {
799 if (NOT_PERSON.has(e.origin.kind)) return next(e)
800 const text = e.text.trim()
801 slashNext = SLASH.test(text)
802 counted = false
803 pendingText = slashNext ? '' : text
804 // 斜杠命令:加载词在这一轮开始(turn.start)之前就出来了,先把号清掉,头一下不会带着上一条的号。
805 // 像 /tmp 这样当消息发出去的,追加进对话记录时照常编号
806 if (slashNext && !busy) await update($, turnNo, () => 0)
807 await setLang($, detectLang(e.text))
808 return next(e)
809 })
810
811 // ---------- 消息编号 ----------
812 // 用户本人发出的每条消息记一个号。斜杠命令、插件和后台通知发来的不算。
813 // 号存在会话状态里,mod 热重载、上下文压缩都不会重新数;新开会话从 T01 开始
814 on('session.append', { door: 'prompt' }, async ($, e, next) => {
815 const o = e.origin
816 if (e.agentId !== undefined || o.kind === 'model' || o.kind === 'tool') return next(e)
817 // 提醒、助手交回、后台通知这类发进主对话的:接下来的回复不带号
818 if (NOT_PERSON.has(o.kind)) {
819 await update($, turnNo, () => 0)
820 return next(e)
821 }
822 if (e.message.isMeta) return next(e)
823 const n = (await read($, seq)) + 1
824 await update($, seq, () => n)
825 await update($, turnNo, () => n)
826 counted = true
827 // 每一段文字都记下来(桌面会在用户的话前后附上别的内容),再加上 prompt.submit 时记下的原文
828 const keys = e.message.content
829 .map(b => ('text' in b && typeof b.text === 'string' ? b.text.trim() : ''))
830 .filter(t => t !== '' && !t.startsWith('<'))
831 if (pendingText !== '') keys.push(pendingText)
832 pendingText = ''
833 await update($, numbers, m => ({
834 ...m,
835 ids: recent({ ...m.ids, [e.uuid]: n }),
836 texts: recent({ ...m.texts, ...Object.fromEntries(keys.map(k => [k, n])) }),
837 }))
838 keepRow($, k => {
839 k.u[e.uuid] = n
840 })
841 return next(e)
842 })
843
844 // Claude 的回复记下属于第几条消息。不是用户本人发消息开头的一轮记 0(照样记下,按原文对时不会对到以前同样的回复上)
845 on('session.append', { door: 'response' }, async ($, e, next) => {
846 if (e.agentId !== undefined) return next(e)
847 const n = await read($, turnNo)
848 const keys = e.message.content
849 .map(b => ('text' in b && typeof b.text === 'string' ? replyKey(b.text) : ''))
850 .filter(k => k !== '')
851 if ((n > 0 || (await read($, seq)) > 0) && keys.length > 0) {
852 await update($, replies, m => ({
853 ...m,
854 ids: recent({ ...m.ids, [e.uuid]: n }),
855 texts: recent({ ...m.texts, ...Object.fromEntries(keys.map(k => [k, n])) }),
856 }))
857 keepRow($, k => {
858 k.r[e.uuid] = n
859 })
860 }
861 return next(e)
862 })
863
864 // 耗时行在对话记录里是一条 turn_duration 通知,界面上画它时的 requestId 就是这条通知的 id。
865 // 追加时记下它属于第几条消息、这一轮几步几次工具,mod 重载以后也对得上
866 on('session.append', { door: 'notice' }, async ($, e, next) => {
867 if (e.agentId === undefined && e.message.name === 'turn_duration') {
868 const row: LemoTurnRow = { n: await read($, turnNo), steps: curSteps, tools: curTools }
869 await update($, turnRows, m => recent({ ...m, [e.uuid]: row }))
870 keepRow($, k => {
871 k.t[e.uuid] = [row.n, row.steps, row.tools]
872 })
873 }
874 return next(e)
875 })
876
877 // ---------- 每轮统计:每向模型发一次请求算一步 ----------
878 on('turn.start', async ($, e, next) => {
879 curSteps = 0
880 curTools = 0
881 // 斜杠命令(skill)开头的一轮:不借用上一条的号。接着上一轮说的(text 为空)不算
882 const t = e.text.trim()
883 if (!counted && (SLASH.test(t) || (slashNext && t !== ''))) await update($, turnNo, () => 0)
884 slashNext = false
885 counted = false
886 busy = true
887 return next(e)
888 })
889
890 on('turn.step', async function* ($, e, next) {
891 const r = yield* next(e)
892 if (e.agentId === undefined) {
893 curSteps += 1
894 curTools += r.toolUses.length
895 }
896 return r
897 })
898
899 on('turn.complete', async ($, e, next) => {
900 if (e.agentId === undefined) busy = false
901 if (e.agentId === undefined && !e.isAborted) {
902 const done: LemoTurn = { steps: curSteps, tools: curTools, ms: e.durationMs }
903 await update($, lastTurn, () => done)
904 }
905 return next(e)
906 })
907
908 // ---------- 命令 /lemo-mod ----------
909 // 只有这里接 /lemo-mod:Claude Code 在命令的回复前面写上接这个命令的所有插件名,以前 8 个 mod 都接,
910 // 回复前面就是「lemo-pomodoro+lemo-recap+…+lemo-core:」一长串。
911 // 现在先用 $.lemo.command 问各 mod(它们挂 on('lemo.command')),认得的词它们自己处理,都不认识才轮到下面。
912 // 登记成 immediate:Claude 干活时也马上跑,所以这里和各 mod 的命令都不能假定这一轮停着
913 on('command.run', { command: 'lemo-mod' }, async ($, e) => {
914 const answer = await $.lemo.command({ args: e.args })
915 if (answer !== null) return answer.text === undefined ? {} : { text: answer.text }
916 // 命令里的词不改界面语言(「/lemo-mod bg」不该把界面换成英文),语言只跟着用户发的普通消息走
917 const args = e.args.trim()
918 const s = STR[curLang]
919 const { word: w, rest } = cmdWord(args)
920 if (w === '') {
921 // 只打开面板,不回文字:回一句「已打开」的话,那张卡片会一直留在对话记录里
922 await openHub($, undefined, true)
923 return {}
924 }
925 if (w === '风格' || w === 'style') {
926 const names = Object.values(STYLES).map(x => fill(s.paren, { a: x.id, b: x.name[curLang] })).join(s.sep)
927 if (rest === '') return { text: fill(s.cmd.styleList, { list: names }) }
928 // 先拿整段比(风格名可能带空格,比如「Lemo Lab」),对不上再拿第一个词
929 const same = (x: (typeof STYLES)[string], t: string) =>
930 x.id === t || x.name.zh === t || x.name.en.toLowerCase() === t.toLowerCase() || curNicks[x.id]?.toLowerCase() === t.toLowerCase()
931 const first = rest.split(/\s+/)[0] ?? ''
932 const hit = Object.values(STYLES).find(x => same(x, rest)) ?? Object.values(STYLES).find(x => same(x, first))
933 const id = hit === undefined ? rest : hit.id
934 if (hit === undefined || !(await setStyle($, hit.id))) return { text: fill(s.cmd.styleUnknown, { id: mdEsc(id), list: names }) }
935 return { text: fill(s.cmd.styleSet, { name: hit.name[curLang] }) }
936 }
937 // 起名:/lemo-mod 起名 豆豆、/lemo-mod name Dodo;不带名字就恢复原名。名字从原文里命令词后面照原样取
938 // (cmdWord 会把开头的数字拆走、把英文变小写),「起名豆豆」不带空格也认
939 const nickWord = NICK_WORDS.find(x => w === x || (/^[^a-z]/.test(x) && w.startsWith(x)))
940 if (nickWord !== undefined) {
941 const text = args.slice(nickWord.length)
942 const own = STYLES[curStyle.id]?.name[curLang] ?? curStyle.name[curLang]
943 const had = curNicks[curStyle.id]
944 const r = await setNick($, text)
945 if (r === false) return { text: fill(s.cmd.nickLong, { n: NICK_MAX / 2, m: NICK_MAX }) }
946 if (r !== null) return { text: fill(s.cmd.nickSet, { name: own, nick: mdEsc(r) }) }
947 return { text: fill(had === undefined ? s.cmd.nickNone : s.cmd.nickReset, { name: own }) }
948 }
949 const at = TAB_WORDS[w]
950 if (at !== undefined) {
951 await openHub($, at, true)
952 return {}
953 }
954 const list = await read($, mods)
955 // 分页词只列这个会话的界面上有的页(桌面上没有「游戏」页,就不提它)
956 const surfaces = await $.session.surfaces().catch(() => ['terminal'])
957 const tabs = new Set(surfaces.flatMap(x => visibleTabs(list, x)))
958 const known = [
959 ...list.flatMap(m => m.commands?.[curLang] ?? []),
960 curLang === 'zh' ? '风格' : 'style',
961 curLang === 'zh' ? '起名' : 'name',
962 ...(Object.keys(STR[curLang].tabs) as LemoTab[]).filter(k => tabs.has(k)).map(k => STR[curLang].tabs[k]),
963 ]
964 return { text: fill(s.cmd.unknown, { word: mdEsc(w), list: known.join(s.sep) }) }
965 })
966
967 // /lemo-mod 的回复画成一张带风格标签的卡片,正文按 Markdown 排(各 mod 回的文字也走这里)
968 on('ui.render', { component: 'CommandOutput', props: { command: 'lemo-mod' } }, async ($, e, next) => {
969 if (e.props.isErrored || e.props.text.trim() === '') return next(e)
970 const lk = await look($)
971 const tag = word(lk, 'lemo-core.cmdTag', STR[lk.lang].cmdTag)
972 const { Box, Text, Markdown } = $.ui.resolve(e)
973 const chip = (
974 <Box flexDirection="row">
975 <Text backgroundColor={lk.c.accent} color={lk.c.onAccent} bold>{tag}</Text>
976 </Box>
977 )
978 if (e.surface === 'terminal') {
979 return (
980 <Box flexDirection="column" borderStyle="round" borderColor={lk.c.accent} paddingX={1}>
981 {chip}
982 <Markdown text={e.props.text} />
983 </Box>
984 )
985 }
986 return (
987 // 桌面:浅色卡片(暗色 App 换成深色卡片,里面的 Markdown 用 App 的字色,暗色下是浅字)
988 <Box flexDirection="column" gap={1} backgroundColor={deskDark(lk) ? lk.c.cardFillDark : lk.c.cardFillLight} borderStyle="round" borderColor={deskDark(lk) ? mix(lk.c.accent, lk.c.cardFillDark, 0.5) : mix(lk.c.accent, '#FFFFFF', 0.6)} paddingX={2} paddingY={1}>
989 {chip}
990 <Markdown text={e.props.text} />
991 </Box>
992 )
993 })
994
995 // ---------- 统一面板 /lemo-mod:头部、分页、风格、已装的 mod ----------
996 on('ui.render', { component: 'Pane', requestId: HUB }, async ($, e) => {
997 // 面板按钮用 steady:桌面上面板拿到焦点会再画一次,按钮还是同一个,第一下点击不丢(见 shared/lemo.tsx)。
998 // 每次画都先调它,这次不画按钮也调:下一次画才知道哪些按钮上一次画过
999 const Button = steady($.ui.resolve(e).Button, e.surface)
1000 const lk = await look($, e.props)
1001 const s = STR[lk.lang]
1002 const isTerm = e.surface === 'terminal'
1003 const list = await read($, mods)
1004 const shown = visibleTabs(list, e.surface)
1005 // 自己的报到表读自己的 atom(没人报到时是空表);各 mod 用 hubTab 算出来的是同一页
1006 const stored = await read($, tab)
1007 const at = shown.includes(stored) ? stored : shown[0] ?? 'behave'
1008 const n = await read($, seq)
1009 const png = isTerm ? await read($, icon) : null
1010 const el = $.ui.resolve(e)
1011 const { Box, Text } = el
1012 const title = word(lk, 'lemo-core.title', s.title)
1013 const chip = <Text backgroundColor={lk.c.accent} color={lk.c.onAccent} bold>{` ${title} · ${exp(n)} `}</Text>
1014 // 装了几个 mod:报到的那些加上 lemo-core 自己(和 README、/plugin 里数的一样,全装是 16 个)
1015 const modCount = list.length + 1
1016 const count = modCount === 1 ? s.modsOne : fill(s.modsCount, { n: modCount })
1017 const sub = <Text color={lk.c.pencil}>{`${s.style.title} ${lk.st.name[lk.lang]} · ${count}`}</Text>
1018
1019 let head = (
1020 <Box flexDirection="column">
1021 <Box flexDirection="row">{chip}</Box>
1022 {sub}
1023 </Box>
1024 )
1025 if (isTerm && png !== null) {
1026 const { Image } = $.ui.resolve(e)
1027 // 终端一格大约宽高 1:2,所以 6 列 × 3 行是方的
1028 head = (
1029 <Box flexDirection="row" gap={2} alignItems="flex-start">
1030 <Image key="icon" source={{ png }} columns={6} rows={3} alt={lk.st.name[lk.lang]} />
1031 {head}
1032 </Box>
1033 )
1034 }
1035 if (isTerm && lk.st.bubbles.length > 0) {
1036 // 点缀从图标上方往上冒(第一行),右上角再飘几颗
1037 const b = lk.st.bubbles
1038 head = (
1039 <Box flexDirection="column">
1040 <Text color={lk.c.bubble}>{` ${b[4 % b.length]} `}<Text color={lk.c.bubbleAccent}>{b[0]}</Text></Text>
1041 <Box flexDirection="row" alignItems="flex-start">
1042 <Box flexGrow={1}>{head}</Box>
1043 <Box flexDirection="column" flexShrink={0} alignItems="flex-end">
1044 <Text color={lk.c.bubble}>{`${b[0]} ${b[2 % b.length]}`}</Text>
1045 <Text color={lk.c.bubbleAccent}>{`${b[4 % b.length]} `}</Text>
1046 </Box>
1047 </Box>
1048 </Box>
1049 )
1050 }
1051
1052 const tabs = (
1053 <Box key="tabs" flexDirection="row" gap={1} flexWrap="wrap">
1054 {shown.map((id, i) => (
1055 <Button
1056 key={`tab-${id}`}
1057 label={s.tabs[id]}
1058 {...(id === at ? { variant: 'primary' as const } : sec(e.surface))}
1059 {...(isTerm ? { hotkey: 'abcde'[i] ?? 'a' } : {})}
1060 onPress={() => update($, tab, () => id)}
1061 />
1062 ))}
1063 </Box>
1064 )
1065
1066 const cards: CardSpec[] = []
1067 // ---------- 安全页:照能力清单($.lemo.caps)画,每个 mod 会做的事按分类列出,带开关 ----------
1068 if (at === 'safe') {
1069 await read($, capsRev)
1070 const ok = await read($, safeOk)
1071 const sc = await read($, scan)
1072 const caps = await $.lemo.caps({})
1073 const ss = s.safe
1074 const L = lk.lang
1075 const ink = inkOf(lk, e.surface)
1076 const { Link } = $.ui.resolve(e)
1077 const keep = (t: string) => (isTerm ? cjkKeep(t) : t)
1078 // 终端里说明文字先按面板宽度断好行(英文词不拆、标点不放行首);桌面照原样交给 App 排
1079 const cols = cardCols(e.props.bodyColumns)
1080 const dim = (t: string, key: string, w = cols) => (isTerm ? para({ Box, Text }, t, w, key) : <Text key={key} dimColor>{t}</Text>)
1081 const bump = () => update($, capsRev, v => v + 1)
1082 const dk = await read($, desk)
1083 const found: string[] =
1084 sc === null
1085 ? [ss.scanBusy]
1086 : [
1087 ...(sc.statusLine ? [ss.scanStatus] : []),
1088 ...(sc.spinnerVerbs ? [ss.scanSpinner] : []),
1089 ...(sc.notif === null ? [] : [fill(ss.scanNotif, { v: ss.notifNames[sc.notif] ?? sc.notif })]),
1090 ...(sc.outputStyle === null ? [] : [fill(ss.scanStyle, { v: sc.outputStyle })]),
1091 ]
1092 if (sc !== null && found.length === 0) found.push(ss.scanNone)
1093 if (sc !== null) found.push(fill(ss.scanTheme, { v: sc.theme ?? '—' }))
1094 if (e.surface === 'desktop') found.push(fill(ss.scanDesk, { v: dk === 'dark' ? ss.desk.dark : dk === 'light' ? ss.desk.light : ss.desk.unknown }))
1095 cards.push({
1096 id: 'safe-head',
1097 title: ss.title,
1098 desc: ss.promise,
1099 cols,
1100 buttons: [
1101 ...(ok ? [] : [<Button key="safe-ok" label={ss.ok} variant="primary" onPress={() => confirmSafe($)} />]),
1102 <Button
1103 key="safe-off"
1104 label={ss.allOff}
1105 {...sec(e.surface)}
1106 onPress={async () => {
1107 await $.lemo.off({})
1108 await confirmSafe($)
1109 await bump()
1110 }}
1111 />,
1112 ],
1113 extra: (
1114 <Box flexDirection="column">
1115 <Text {...ink}>
1116 <Link href={MANUAL_URL} label={ss.manual} />
1117 </Text>
1118 {dim(ss.allOffDesc, 'safe-off-desc')}
1119 <Box marginTop={1} flexDirection="column">
1120 <Text bold {...ink}>{keep(ss.scanTitle)}</Text>
1121 {found.map((t, i) => dim(`· ${t}`, `safe-scan-${i}`))}
1122 </Box>
1123 {ok ? <Box marginTop={1}>{dim(ss.okDone, 'safe-done')}</Box> : null}
1124 </Box>
1125 ),
1126 })
1127 KIND_ORDER.forEach((k, i) => {
1128 const rows = caps.filter(c => c.kind === k && c.manual !== true)
1129 if (rows.length === 0) return
1130 cards.push({
1131 id: `safe-${k}`,
1132 order: 10 + i * 5,
1133 title: ss.kinds[k],
1134 desc: ss.kindDesc[k],
1135 cols,
1136 extra: (
1137 <Box flexDirection="column" gap={1}>
1138 {rows.map(c => {
1139 const head = (
1140 <Text bold {...ink}>
1141 {c.title[L]}
1142 <Text bold={false} color={c.on ? lk.c.ink : lk.c.pencil}>{` ${c.on ? ss.stateOn : ss.stateOff}`}</Text>
1143 <Text bold={false} color={lk.c.pencil}>{` ${c.mod}`}</Text>
1144 </Text>
1145 )
1146 const btns = (
1147 <Box flexDirection="row" gap={1} flexShrink={0}>
1148 {c.preview === true ? (
1149 <Button key={`cap-try-${c.mod}-${c.id}`} label={ss.listen} {...sec(e.surface)} onPress={() => $.lemo.toggle({ mod: c.mod, id: c.id, preview: true })} />
1150 ) : null}
1151 <Button
1152 key={`cap-sw-${c.mod}-${c.id}`}
1153 label={c.on ? ss.turnOff : ss.turnOn}
1154 {...sec(e.surface)}
1155 onPress={async () => {
1156 await $.lemo.toggle({ mod: c.mod, id: c.id, on: !c.on })
1157 await bump()
1158 }}
1159 />
1160 </Box>
1161 )
1162 // 终端:按钮放在名字那一行右边,说明占满整行(窄栏里中文说明和按钮挤一行断得很怪)。
1163 // 桌面照原来的排法(亮色样子冻结)
1164 return isTerm ? (
1165 <Box key={`cap-${c.mod}-${c.id}`} flexDirection="column">
1166 <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap={2}>
1167 <Box flexShrink={1}>{head}</Box>
1168 {btns}
1169 </Box>
1170 {dim(c.desc[L], `cap-d-${c.mod}-${c.id}`)}
1171 </Box>
1172 ) : (
1173 <Box key={`cap-${c.mod}-${c.id}`} flexDirection="row" justifyContent="space-between" alignItems="center" gap={2}>
1174 <Box flexDirection="column" flexShrink={1}>
1175 {head}
1176 {dim(c.desc[L], `cap-d-${c.mod}-${c.id}`)}
1177 </Box>
1178 {btns}
1179 </Box>
1180 )
1181 })}
1182 </Box>
1183 ),
1184 })
1185 })
1186 const manual = caps.filter(c => c.manual === true)
1187 if (manual.length > 0) {
1188 cards.push({
1189 id: 'safe-manual',
1190 order: 95,
1191 title: ss.manualTitle,
1192 desc: ss.manualDesc,
1193 cols,
1194 // 和上面的开关行一样:名字一行(开关行写「已开」的位置写它会做的事),说明另起一行
1195 extra: (
1196 <Box flexDirection="column" gap={1}>
1197 {manual.map(c => (
1198 <Box key={`cap-m-${c.mod}-${c.id}`} flexDirection="column">
1199 <Text bold {...ink}>
1200 {c.title[L]}styles/index.ts 33 lines1import type { LemoStyle } from '../types'
2import { apothecary } from './apothecary'
3import { aquarium } from './aquarium'
4import { bakery } from './bakery'
5import { blueprint } from './blueprint'
6import { campfire } from './campfire'
7import { dessert } from './dessert'
8import { eightBit } from './eight-bit'
9import { greenhouse } from './greenhouse'
10import { launchpad } from './launchpad'
11import { lemonLab } from './lemon-lab'
12import { lighthouse } from './lighthouse'
13import { metro } from './metro'
14import { mineral } from './mineral'
15import { mixtape } from './mixtape'
16import { newsroom } from './newsroom'
17import { nightMarket } from './night-market'
18import { observatory } from './observatory'
19import { opera } from './opera'
20import { plain } from './plain'
21import { polar } from './polar'
22import { postOffice } from './post-office'
23
24// 20 套主题风格,各有自己的像素画、配色、文字和音效;
25// 排列顺序就是面板里风格按钮和 /lemo-mod 风格 列表的顺序。
26// 最后是素色 plain:没有像素画,音效借用柠檬实验室的,也用来确认换一包风格数据就能整套换
27export const STYLES: Readonly<Record<string, LemoStyle>> = {
28 'lemon-lab': lemonLab, mineral, mixtape, observatory, greenhouse, bakery, metro, opera, 'eight-bit': eightBit, launchpad,
29 campfire, dessert, 'post-office': postOffice, apothecary, newsroom, polar, aquarium, 'night-market': nightMarket, lighthouse, blueprint,
30 plain,
31}
32export const DEFAULT_STYLE = 'lemon-lab'
33hooks/i18n.ts 182 lines1// lemo-core 自己的文字(中性的默认文字;带风格味道的放在风格包 words/lemo-core.ts,按同样的键覆盖)
2
3import type { LemoLang } from '../types'
4
5// 路径、网址、文件名、命令参数、反引号里的代码不算:「/Users/…/TEST.md 读一下这个文件」是中文
6export function detectLang(text: string): LemoLang | null {
7 const plain = text.replace(/`[^`]*`/g, ' ').replace(/\S*[/\\.@:]\S*/g, ' ').replace(/(^|\s)-\S*/g, ' ')
8 const cjk = (plain.match(/[㐀-鿿豈-]/g) ?? []).length
9 const latin = (plain.match(/[A-Za-z]/g) ?? []).length
10 if (cjk === 0 && latin === 0) return null
11 // 写了三个以上汉字就是中文用户,哪怕句子里夹着命令和英文术语
12 return cjk >= 3 || cjk * 3 >= latin ? 'zh' : 'en'
13}
14
15const zh = {
16 title: 'lemo-mod',
17 cmdTag: ' lemo-mod ',
18 modsCount: '{n} 个 mod',
19 modsOne: '1 个 mod',
20 tabs: { main: '常用', behave: '行为', bg: '后台', game: '游戏', safe: '安全' },
21 // {last}:最后一页的键(有几页就到几,比如四页是 d)
22 keys: 'ctrl+x Tab 进入面板 · a–{last} 换页 · 数字键按按钮 · Esc 返回',
23 style: { title: '风格', desc: '一键切换所有 lemo mod 的风格', current: '当前' },
24 mods: { title: '已装的 mod', desc: '各 mod 的作用 · 会做事的开关都在「安全」页', more: '展开说明', less: '收起', none: '暂无其他 lemo mod · 全部安装:/plugin install lemo-mod@lemo-mod', noAlways: '(无自动行为)' },
25 empty: '本页暂无内容',
26 // 没装 lemo-sound、lemo-voice 时「行为」页的开关卡片。{speech} 是下面的 withSpeech(有 mod 会朗读时)或空
27 sound: { title: '声音', desc: '播放 mod 的提示音{speech}', withSpeech: '和到点朗读', mute: '静音', unmute: '取消静音', speechOn: '朗读:开', speechOff: '朗读:关' },
28 // 起名卡片。{name} 是风格自己的名字,{nick} 是起的名字
29 nick: { title: '起名', desc: '给「{name}」起名,用在面板、横条和提示里 · 每套风格分开记', now: ' · 现在叫「{nick}」', placeholder: '输入名字,回车保存', save: '保存', reset: '恢复原名' },
30 // 安全页。kinds 是能力清单的分类;cap 是 lemo-core 自己那几行
31 safe: {
32 title: '安全',
33 promise: '不改你的权限设置:Claude 何时问你、能否执行,都按你自己的设置。除外观外,以下各项安装时全部关闭,按需打开',
34 manual: '完整手册(网页)',
35 scanTitle: '你的相关设置(仅本机读取,不保存,不发给 Claude)',
36 scanStatus: '已有自己的状态栏 → lemo-meter 状态栏默认关闭',
37 scanSpinner: '已自定义转圈文字 → lemo-spinner 默认关闭',
38 scanNotif: '通知方式:{v}',
39 scanStyle: '输出风格:{v}',
40 scanTheme: '主题:{v}',
41 /** 只在桌面上显示:App 自己的明暗(读 App 记在本机的设置),{v} 是下面 desk 里的一个 */
42 scanDesk: '桌面 App:{v}(面板随之切换深浅)· 每 3 秒读一次 App 的设置文件,只取明暗;这个文件里还有 App 的登录缓存',
43 desk: { dark: '暗色', light: '亮色', unknown: '未读到,按亮色显示' },
44 /** 通知方式的内部值换成人话;没列的照原样显示 */
45 notifNames: { auto: '自动', iterm2: 'iTerm2 通知', iterm2_with_bell: 'iTerm2 通知加响铃', terminal_bell: '终端响铃', kitty: 'kitty 通知', ghostty: 'Ghostty 通知', notifications_disabled: '已关闭通知' } as Readonly<Record<string, string>>,
46 scanNone: '没有与 lemo-mod 重叠的设置',
47 scanBusy: '正在读取设置…',
48 ok: '我看完了',
49 okDone: '已确认 · 随时输入 /lemo-mod 安全 修改',
50 allOff: '全部关闭',
51 allOffDesc: '「全部关闭」:关闭外观以外的所有功能,并取消进行中的提醒和番茄钟',
52 pending: '未完成安全确认:输入 /lemo-mod 安全',
53 on: '已开启:{list} · 在 /lemo-mod 安全 中修改',
54 turnOn: '打开',
55 turnOff: '关闭',
56 stateOn: '已开',
57 stateOff: '已关',
58 listen: '试听',
59 manualTitle: '手动触发',
60 manualDesc: '仅在点按钮或输入命令时执行,无需开关',
61 kinds: { look: '外观', sound: '音效', speech: '朗读', file: '写文件', cost: '额外用量', prompt: '附加提示', tool: '新增工具', act: '代你操作', net: '联网' },
62 kindDesc: {
63 look: '只改显示,安装即开启',
64 sound: '播放提示音',
65 speech: '用系统语音朗读',
66 file: '在你的电脑上写文件',
67 cost: '额外调用模型,计入你的用量',
68 prompt: '在发给 Claude 的消息后附加要求(你看不到)',
69 tool: '给 Claude 增加工具',
70 act: '代你给 Claude 发消息,或中止当前这一轮',
71 net: '唯一访问外部网站的一项',
72 },
73 cap: {
74 sound: { title: '提示音', desc: '调用工具、一轮结束、限时中止时播放风格音效' },
75 speech: { title: '朗读', desc: '长任务结束、番茄钟到点时朗读一句' },
76 note: { title: '编号说明', desc: '告诉 Claude 编号 T01、T02 的含义,可直接说「T03」指代 · 下个会话生效' },
77 },
78 sample: '第三条完成,用时 2 分 15 秒',
79 },
80 cmd: {
81 styleSet: '已切换到「{name}」风格。',
82 nickSet: '已将「{name}」改名为「{nick}」。',
83 nickReset: '「{name}」已恢复原名。',
84 nickNone: '「{name}」尚未起名。用法:/lemo-mod 起名 <名字>',
85 nickLong: '名字过长:最多 {n} 个汉字或 {m} 个字母。',
86 styleList: '可选的风格:{list}。用法:/lemo-mod 风格 <名字>',
87 styleUnknown: '没有「{id}」风格。可选:{list}',
88 unknown: '无法识别「{word}」。可用:{list}',
89 },
90 /** 列举时的分隔和括注 */
91 sep: '、',
92 paren: '{a}({b})',
93 describe: 'lemo-mod 面板',
94}
95
96export type CoreStrings = typeof zh
97
98const en: CoreStrings = {
99 title: 'lemo-mod',
100 cmdTag: ' lemo-mod ',
101 modsCount: '{n} mods',
102 modsOne: '1 mod',
103 tabs: { main: 'Main', behave: 'Behavior', bg: 'Background', game: 'Game', safe: 'Safety' },
104 keys: 'ctrl+x Tab to enter · a–{last} to switch pages · digits press buttons · Esc to go back',
105 style: { title: 'Style', desc: 'Switch the style of every lemo mod at once', current: 'Current' },
106 mods: { title: 'Installed mods', desc: 'What each mod does · switches for anything that acts are on the Safety page', more: 'Details', less: 'Hide', none: 'No other lemo mods · install all: /plugin install lemo-mod@lemo-mod', noAlways: '(no automatic behavior)' },
107 empty: 'Nothing on this page yet',
108 sound: { title: 'Sound', desc: 'Plays mod sounds{speech}', withSpeech: ' and reads aloud when time is up', mute: 'Mute', unmute: 'Unmute', speechOn: 'Speech: on', speechOff: 'Speech: off' },
109 nick: { title: 'Name', desc: 'Name "{name}" for the panel, the band and messages · each style keeps its own', now: ' · now called "{nick}"', placeholder: 'Type a name, Enter to save', save: 'Save', reset: 'Reset name' },
110 safe: {
111 title: 'Safety',
112 promise: 'Your permission settings stay as they are: when Claude asks you and what it may run follow your own settings. Apart from looks, everything below is off at install; turn on what you need',
113 manual: 'Full manual (web)',
114 scanTitle: 'Your related settings (read on this machine only, not stored, not sent to Claude)',
115 scanStatus: 'You have your own status line → lemo-meter status line off by default',
116 scanSpinner: 'You set your own spinner words → lemo-spinner off by default',
117 scanNotif: 'Notifications: {v}',
118 scanStyle: 'Output style: {v}',
119 scanTheme: 'Theme: {v}',
120 scanDesk: "Desktop app: {v} (the panel follows it) · reads the app's settings file every 3 seconds, takes only light or dark; that file also holds the app's sign-in cache",
121 desk: { dark: 'dark', light: 'light', unknown: 'not found, shown light' },
122 notifNames: { auto: 'automatic', iterm2: 'iTerm2 notifications', iterm2_with_bell: 'iTerm2 notifications with a bell', terminal_bell: 'terminal bell', kitty: 'kitty notifications', ghostty: 'Ghostty notifications', notifications_disabled: 'notifications off' } as Readonly<Record<string, string>>,
123 scanNone: 'No settings overlap with lemo-mod',
124 scanBusy: 'Reading settings…',
125 ok: 'I have read this',
126 okDone: 'Confirmed · type /lemo-mod safety any time to change',
127 allOff: 'Turn all off',
128 allOffDesc: '"Turn all off": turns off everything except looks and cancels a running reminder or focus timer',
129 pending: 'Safety check not done yet: type /lemo-mod safety',
130 on: 'On: {list} · change in /lemo-mod safety',
131 turnOn: 'Turn on',
132 turnOff: 'Turn off',
133 stateOn: 'on',
134 stateOff: 'off',
135 listen: 'Listen',
136 manualTitle: 'On demand',
137 manualDesc: 'Run only when you press a button or type a command · no switch needed',
138 kinds: { look: 'Looks', sound: 'Sound', speech: 'Speech', file: 'Writes files', cost: 'Extra usage', prompt: 'Extra prompt', tool: 'Extra tool', act: 'Acts for you', net: 'Network' },
139 kindDesc: {
140 look: 'Changes only the display; on at install',
141 sound: 'Plays sounds',
142 speech: 'Reads aloud with the system voice',
143 file: 'Writes files on your computer',
144 cost: 'Extra model calls, counted in your usage',
145 prompt: 'Appends instructions to your messages to Claude (hidden from you)',
146 tool: 'Adds a tool for Claude',
147 act: 'Sends Claude a message for you, or stops the current turn',
148 net: 'The only one that visits an outside website',
149 },
150 cap: {
151 sound: { title: 'Sounds', desc: 'Plays style sounds on tool calls, turn ends and time-limit stops' },
152 speech: { title: 'Speech', desc: 'Reads a line aloud when a long task ends or a focus timer is up' },
153 note: { title: 'Numbering note', desc: 'Tells Claude what T01, T02 mean, so you can just say "T03" · takes effect next session' },
154 },
155 sample: 'Message three done in 2 minutes 15 seconds',
156 },
157 cmd: {
158 styleSet: 'Switched to the "{name}" style.',
159 nickSet: 'Renamed "{name}" to "{nick}".',
160 nickReset: '"{name}" has its original name back.',
161 nickNone: '"{name}" has no name yet. Usage: /lemo-mod name <name>',
162 nickLong: 'Name too long: at most {n} CJK characters or {m} letters.',
163 styleList: 'Styles: {list}. Usage: /lemo-mod style <name>',
164 styleUnknown: 'No "{id}" style. Available: {list}',
165 unknown: 'Unrecognized "{word}". Available: {list}',
166 },
167 sep: ', ',
168 paren: '{a} ({b})',
169 describe: 'lemo-mod panel',
170}
171
172export const STR: Record<LemoLang, CoreStrings> = { zh, en }
173
174// 这几个词打开面板的某一页
175export const TAB_WORDS: Readonly<Record<string, 'safe' | 'main' | 'behave' | 'bg' | 'game'>> = {
176 安全: 'safe', safe: 'safe', safety: 'safe', security: 'safe',
177 常用: 'main', main: 'main',
178 行为: 'behave', behave: 'behave', behavior: 'behave',
179 后台: 'bg', bg: 'bg', background: 'bg',
180 游戏: 'game', game: 'game',
181}
182hooks/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}
597styles/apothecary/index.ts 114 lines1// 葫芦(Gourd):老中药铺——百子柜、戥子、药碾、方子,抓药、煎药。
2// 一切说成抓药:回复是一张方子,工具在跑是抓药,调 skill 是拉开一格药斗,完成是方子抓齐,提问是问诊。
3// 玉绿只当底色用,上面写白字;桌面横条的大数字用红木棕;底色是宣纸色。
4// 风格 = 一包数据:颜色、点缀、像素小画、音效、语音,和各 mod 带风格味道的文字(words/ 下每个 mod 一个文件)。
5import type { LemoStyle, LemoText } from '../../types'
6
7import core from './words/lemo-core'
8import skin from './words/lemo-skin'
9import spinner from './words/lemo-spinner'
10import ask from './words/lemo-ask'
11import meter from './words/lemo-meter'
12import guard from './words/lemo-guard'
13import sound from './words/lemo-sound'
14import voice from './words/lemo-voice'
15import tone from './words/lemo-tone'
16import pomodoro from './words/lemo-pomodoro'
17import watch from './words/lemo-watch'
18import recap from './words/lemo-recap'
19import todo from './words/lemo-todo'
20import journal from './words/lemo-journal'
21import lot from './words/lemo-lot'
22import assistant from './words/lemo-assistant'
23
24const prefix = (mod: string, w: Readonly<Record<string, LemoText>>) => Object.fromEntries(Object.entries(w).map(([k, v]) => [`${mod}.${k}`, v]))
25
26// 一只药葫芦:j 玉色葫芦,d 深一点的玉色(背光的一面),w 高光,s 红木塞子,r 腰上系的红绳和穗子。
27// 三帧:腰上的红穗子往外一荡再落回来,葫芦身上的高光挪一点
28const GOURD: string[][] = [
29 [
30 '.......ss.......',
31 '......jjjj......',
32 '.....jwjjjd.....',
33 '......jjjd......',
34 '.......rrrr.....',
35 '.....jjjjjdr....',
36 '....jwjjjjjdr...',
37 '...jjjjjjjjjdr..',
38 '....jjjjjjjd.rr.',
39 '.....dddddd..rr.',
40 ],
41 [
42 '.......ss.......',
43 '......jjjj......',
44 '.....jwjjjd.....',
45 '......jjjd......',
46 '.......rrrr.....',
47 '.....jjjjjdrr...',
48 '....jwjjjjjd.r..',
49 '...jjjjjjjjjd.r.',
50 '....jjjjjjjd..rr',
51 '.....dddddd...rr',
52 ],
53 [
54 '.......ss.......',
55 '......jjjj......',
56 '.....jjwjjd.....',
57 '......jjjd......',
58 '.......rrrr.....',
59 '.....jjjjjdr....',
60 '....jjwjjjjdr...',
61 '...jjjjjjjjjdr..',
62 '....jjjjjjjd.rr.',
63 '.....dddddd..rr.',
64 ],
65]
66
67export const apothecary: LemoStyle = {
68 id: 'apothecary',
69 name: { zh: '宝葫芦', en: 'Magic Gourd' },
70 colors: {
71 ink: '#B86B3A', // 肉桂:编号、圆点、进行中
72 grid: '#9C8A68', // 竹篾:线条、刻度
73 pencil: '#8F8A80', // 药渣灰
74 accent: '#5FB58F', // 玉绿,只做底色
75 onAccent: '#FFFFFF', // 强调色偏深,上面写白字(标签、竹签)
76 red: '#D64B3C', // 朱砂药签
77 inkDark: '#4A2A1C', // 红木棕,桌面上的深色字
78 chip: '#E8F4EC', // 淡玉
79 bubble: '#8CCFB0', // 浅玉,药罐冒的热气
80 bubbleAccent: '#3FA67C', // 翠:深色主题的卡片竖条
81 cardFillLight: '#FAF6EC', // 宣纸
82 cardFillDark: '#22201B',
83 deskCardFill: '#FBF9F3',
84 deskCardBorder: '#E9E2D2',
85 deskFigure: '#2B1710', // 红木:桌面卡片上的大字(横条、面板的数值和风格名)
86 },
87 // 药罐上冒的热气
88 bubbles: ['˚', '° ˚', '~', '˚ °', '≈'],
89 sprite: { palette: { j: '#5FB58F', d: '#3A8A68', w: '#E6F4EC', s: '#7A4A2E', r: '#D64B3C' }, frames: GOURD },
90 motif: 'sprite',
91 icon: 'assets/apothecary/icon.png',
92 sounds: { tick: 'assets/apothecary/tick.wav', done: 'assets/apothecary/done.wav', deny: 'assets/apothecary/deny.wav' },
93 // 中文用中文语音念:系统默认是英文语音,念中文会念成一串听不懂的音。按顺序试,没装的跳过
94 voices: { zh: ['Tingting', 'Flo (Chinese (China mainland))', 'Meijia'], en: ['Tessa'] },
95 words: {
96 ...prefix('lemo-core', core),
97 ...prefix('lemo-skin', skin),
98 ...prefix('lemo-spinner', spinner),
99 ...prefix('lemo-ask', ask),
100 ...prefix('lemo-meter', meter),
101 ...prefix('lemo-guard', guard),
102 ...prefix('lemo-sound', sound),
103 ...prefix('lemo-voice', voice),
104 ...prefix('lemo-tone', tone),
105 ...prefix('lemo-pomodoro', pomodoro),
106 ...prefix('lemo-watch', watch),
107 ...prefix('lemo-recap', recap),
108 ...prefix('lemo-todo', todo),
109 ...prefix('lemo-journal', journal),
110 ...prefix('lemo-lot', lot),
111 ...prefix('lemo-assistant', assistant),
112 },
113}
114styles/aquarium/index.ts 79 lines1// 泡泡鱼(Bubble Fish,名字取自像素小画里吐泡泡的小鱼):住在水族馆的一排鱼缸里,水草、气泡、喂食、换水、巡游。珊瑚色只当底色用,主色是深水蓝绿。
2// 风格 = 一包数据:颜色、点缀、像素小画、音效、语音,和各 mod 带风格味道的文字(words/ 下每个 mod 一个文件)。
3import type { LemoStyle, LemoText } from '../../types'
4
5import core from './words/lemo-core'
6import skin from './words/lemo-skin'
7import spinner from './words/lemo-spinner'
8import ask from './words/lemo-ask'
9import meter from './words/lemo-meter'
10import guard from './words/lemo-guard'
11import sound from './words/lemo-sound'
12import voice from './words/lemo-voice'
13import tone from './words/lemo-tone'
14import pomodoro from './words/lemo-pomodoro'
15import watch from './words/lemo-watch'
16import recap from './words/lemo-recap'
17import todo from './words/lemo-todo'
18import journal from './words/lemo-journal'
19import lot from './words/lemo-lot'
20import assistant from './words/lemo-assistant'
21
22const prefix = (mod: string, w: Readonly<Record<string, LemoText>>) => Object.fromEntries(Object.entries(w).map(([k, v]) => [`${mod}.${k}`, v]))
23
24// 一条珊瑚色的小鱼对着水草吐泡泡:f 鱼身,t 鱼鳍和尾巴,e 眼睛,b 气泡(空心的圈,不是汽水那种实心小点),g 水草。
25// 三帧:大泡浮到顶、嘴边又冒一颗(第一帧也是面板图标)→ 大泡出了水面,只剩嘴边那颗 → 尾巴一收、泡泡变大往上浮、水草摆
26const FISH: string[][] = [
27 ['.............b..', '............b.b.', '.....ttt.....b..', 't....ffff.......', 'tt..ffffff.b....', '.tt.ffffefff..g.', '.ttfftfffff..g..', 'tt..fttfff...gg.', 't....ffff.....g.', '.............gg.'],
28 ['................', '................', '.....ttt........', 't....ffff.......', 'tt..ffffff.b....', '.tt.ffffefff..g.', '.ttfftfffff..g..', 'tt..fttfff...gg.', 't....ffff.....g.', '.............gg.'],
29 ['................', '............b...', '.....ttt...b.b..', '.....ffff...b...', '.t..ffffff......', '.tt.ffffefff.g..', '.ttfftfffff...g.', '.t..fttfff...gg.', '.....ffff....g..', '.............gg.'],
30]
31
32export const aquarium: LemoStyle = {
33 id: 'aquarium',
34 name: { zh: '泡泡', en: 'Bubbles' },
35 colors: {
36 ink: '#2E8C9A', // 深水蓝绿
37 grid: '#7FAAB3', // 缸壁玻璃的灰蓝
38 pencil: '#948F86', // 缸底细沙
39 accent: '#FF8577', // 珊瑚色,只做底色
40 onAccent: '#142229', // 深海
41 red: '#D63A50', // 警示红,比珊瑚深、偏洋红,和底色分开
42 inkDark: '#164C58', // 深水
43 chip: '#E2F2F4', // 浅水
44 bubble: '#55BBD3', // 水里的气泡
45 bubbleAccent: '#F2705F', // 深一点的珊瑚
46 cardFillLight: '#F0F9FA', // 透过玻璃的浅水色
47 cardFillDark: '#182A30',
48 deskCardFill: '#F5FAFB',
49 deskCardBorder: '#DDEDF0',
50 deskFigure: '#142229', // 深海:桌面卡片上的大字(横条、面板的数值和风格名)
51 },
52 // ><> 是游过去的小鱼,o O 是往上冒的气泡
53 bubbles: ['><>', 'o', '°', 'o O', 'o'],
54 sprite: { palette: { f: '#FF8577', t: '#E0604F', e: '#14303A', b: '#55BBD3', g: '#3FA37A' }, frames: FISH },
55 motif: 'sprite',
56 icon: 'assets/aquarium/icon.png',
57 sounds: { tick: 'assets/aquarium/tick.wav', done: 'assets/aquarium/done.wav', deny: 'assets/aquarium/deny.wav' },
58 // 中文用中文语音念:系统默认是英文语音,念中文会念成一串听不懂的音。按顺序试,没装的跳过。英文挑澳洲口音(大堡礁的海)
59 voices: { zh: ['Tingting', 'Flo (Chinese (China mainland))', 'Meijia'], en: ['Karen'] },
60 words: {
61 ...prefix('lemo-core', core),
62 ...prefix('lemo-skin', skin),
63 ...prefix('lemo-spinner', spinner),
64 ...prefix('lemo-ask', ask),
65 ...prefix('lemo-meter', meter),
66 ...prefix('lemo-guard', guard),
67 ...prefix('lemo-sound', sound),
68 ...prefix('lemo-voice', voice),
69 ...prefix('lemo-tone', tone),
70 ...prefix('lemo-pomodoro', pomodoro),
71 ...prefix('lemo-watch', watch),
72 ...prefix('lemo-recap', recap),
73 ...prefix('lemo-todo', todo),
74 ...prefix('lemo-journal', journal),
75 ...prefix('lemo-lot', lot),
76 ...prefix('lemo-assistant', assistant),
77 },
78}
79styles/bakery/index.ts 78 lines1// 面包(Loaf,名字取自像素小画里刚出炉的那条面包):住在清晨的面包房,揉面、发酵、整形、进炉、出炉、切片。焦糖面包皮色只当底色用,奶油黄做浅底。
2// 风格 = 一包数据:颜色、点缀、像素小画、音效、语音,和各 mod 带风格味道的文字(words/ 下每个 mod 一个文件)。
3import type { LemoStyle, LemoText } from '../../types'
4
5import core from './words/lemo-core'
6import skin from './words/lemo-skin'
7import spinner from './words/lemo-spinner'
8import ask from './words/lemo-ask'
9import meter from './words/lemo-meter'
10import guard from './words/lemo-guard'
11import sound from './words/lemo-sound'
12import voice from './words/lemo-voice'
13import tone from './words/lemo-tone'
14import pomodoro from './words/lemo-pomodoro'
15import watch from './words/lemo-watch'
16import recap from './words/lemo-recap'
17import todo from './words/lemo-todo'
18import journal from './words/lemo-journal'
19import lot from './words/lemo-lot'
20import assistant from './words/lemo-assistant'
21
22const prefix = (mod: string, w: Readonly<Record<string, LemoText>>) => Object.fromEntries(Object.entries(w).map(([k, v]) => [`${mod}.${k}`, v]))
23
24// 刚出炉的一条面包,上面冒热气:h 烤得金黄的顶,w 斜着的割口裂开露出的奶油色,c 面包皮,d 底下烤深的一圈,s 热气(三帧往上飘)
25const LOAF_BODY = ['....hhhhhhhh....', '..hhwhhhwhhhwh..', '.chhhwhhhwhhhwc.', '.cccccccccccccc.', '..cccccccccccc..', '...dddddddddd...', '................']
26const LOAF: string[][] = [
27 ['................', '.......s...s....', '......s...s.....', ...LOAF_BODY],
28 ['.......s...s....', '......s...s.....', '.......s...s....', ...LOAF_BODY],
29 ['......s...s.....', '.......s...s....', '................', ...LOAF_BODY],
30]
31
32export const bakery: LemoStyle = {
33 id: 'bakery',
34 name: { zh: '胖面包', en: 'Chubby Bun' },
35 colors: {
36 ink: '#B86A3D', // 烤深了的面包皮
37 grid: '#B39B78', // 面粉袋的麻色
38 pencil: '#968B80', // 撒了面粉的灰
39 accent: '#D4914A', // 焦糖面包皮,只做底色
40 onAccent: '#FFFFFF', // 强调色偏深,上面写白字(标签、竹签)
41 red: '#DB4152', // 覆盆子果酱
42 inkDark: '#5B3416', // 深烘的可可色
43 chip: '#F5EBDD', // 面粉白
44 bubble: '#CDBFAE', // 热气
45 bubbleAccent: '#C47F35',
46 cardFillLight: '#FFF8E7', // 奶油黄
47 cardFillDark: '#2E2620',
48 deskCardFill: '#FFFBF4',
49 deskCardBorder: '#F2E5CF',
50 deskFigure: '#2B1708', // 桌面卡片上的大字(横条、面板的数值和风格名)
51 },
52 bubbles: ['~', '˚ ~', '≈', '~ ˚', '·'],
53 sprite: { palette: { d: '#8A4E22', c: '#C98242', h: '#E5A552', w: '#FBE6B8', s: '#B8AFA3' }, frames: LOAF },
54 motif: 'sprite',
55 icon: 'assets/bakery/icon.png',
56 sounds: { tick: 'assets/bakery/tick.wav', done: 'assets/bakery/done.wav', deny: 'assets/bakery/deny.wav' },
57 // 中文用中文语音念:系统默认是英文语音,念中文会念成一串听不懂的音。按顺序试,没装的跳过
58 voices: { zh: ['Tingting', 'Flo (Chinese (China mainland))', 'Meijia'], en: ['Moira'] },
59 words: {
60 ...prefix('lemo-core', core),
61 ...prefix('lemo-skin', skin),
62 ...prefix('lemo-spinner', spinner),
63 ...prefix('lemo-ask', ask),
64 ...prefix('lemo-meter', meter),
65 ...prefix('lemo-guard', guard),
66 ...prefix('lemo-sound', sound),
67 ...prefix('lemo-voice', voice),
68 ...prefix('lemo-tone', tone),
69 ...prefix('lemo-pomodoro', pomodoro),
70 ...prefix('lemo-watch', watch),
71 ...prefix('lemo-recap', recap),
72 ...prefix('lemo-todo', todo),
73 ...prefix('lemo-journal', journal),
74 ...prefix('lemo-lot', lot),
75 ...prefix('lemo-assistant', assistant),
76 },
77}
78styles/blueprint/index.ts 81 lines1// 三角尺(Set Square,名字取自像素小画里的三角板):在蓝图上工程制图,图纸、尺寸、比例尺、审图、盖章、描图。
2// 制图亮蓝只当底色用,上面写白字;桌面横条的大数字用深制图蓝;石墨灰做次要色,红铅笔做出错、拦截。
3// 风格 = 一包数据:颜色、点缀、像素小画、音效、语音,和各 mod 带风格味道的文字(words/ 下每个 mod 一个文件)。
4// 世界观:每条消息是一张图纸,Claude 的回复是这张图,工具在跑 = 绘制中,调 skill = 取用模板,一轮完成 = 出图。
5import type { LemoStyle, LemoText } from '../../types'
6
7import core from './words/lemo-core'
8import skin from './words/lemo-skin'
9import spinner from './words/lemo-spinner'
10import ask from './words/lemo-ask'
11import meter from './words/lemo-meter'
12import guard from './words/lemo-guard'
13import sound from './words/lemo-sound'
14import voice from './words/lemo-voice'
15import tone from './words/lemo-tone'
16import pomodoro from './words/lemo-pomodoro'
17import watch from './words/lemo-watch'
18import recap from './words/lemo-recap'
19import todo from './words/lemo-todo'
20import journal from './words/lemo-journal'
21import lot from './words/lemo-lot'
22import assistant from './words/lemo-assistant'
23
24const prefix = (mod: string, w: Readonly<Record<string, LemoText>>) => Object.fromEntries(Object.entries(w).map(([k, v]) => [`${mod}.${k}`, v]))
25
26// 三角板靠着一条线,铅笔贴着三角板往右画:b 三角板,g 石墨(笔杆和画出来的线),n 削开的木头,k 笔尖,e 橡皮
27// 每帧铅笔往右挪一格,线跟着长一格
28const SET_SQUARE: string[][] = [
29 ['................', '.b..............', '.bb.............', '.b.b........e...', '.b..b......gg...', '.b...b....gg....', '.b....b..gg.....', '.bbbbbbb.n......', '.gggggggk.......', '................'],
30 ['................', '.b..............', '.bb.............', '.b.b.........e..', '.b..b.......gg..', '.b...b.....gg...', '.b....b...gg....', '.bbbbbbb..n.....', '.ggggggggk......', '................'],
31 ['................', '.b..............', '.bb.............', '.b.b..........e.', '.b..b........gg.', '.b...b......gg..', '.b....b....gg...', '.bbbbbbb...n....', '.gggggggggk.....', '................'],
32]
33
34export const blueprint: LemoStyle = {
35 id: 'blueprint',
36 name: { zh: '小三角', en: 'Tri-Tri' },
37 colors: {
38 ink: '#3B6FE0', // 制图蓝线
39 grid: '#8AA2E0', // 图纸方格
40 pencil: '#858D99', // 石墨灰
41 accent: '#5580FF', // 制图亮蓝(饱和、偏宝蓝,和岩彩那种沉稳的石青蓝拉开),只做底色
42 onAccent: '#FFFFFF', // 强调色偏深,上面写白字(标签、竹签)
43 red: '#E04848', // 审图红铅笔
44 inkDark: '#102A66',
45 chip: '#E8EEFD',
46 bubble: '#93A3C9', // 淡描线
47 bubbleAccent: '#3F6EF0',
48 cardFillLight: '#F2F6FF',
49 cardFillDark: '#0F2347', // 晒图纸的深蓝
50 deskCardFill: '#F6F8FF',
51 deskCardBorder: '#DCE4FA',
52 deskFigure: '#0A1B38', // 深制图蓝:桌面卡片上的大字(横条、面板的数值和风格名)
53 },
54 // 对位十字、尺寸线、交叉记号
55 bubbles: ['+', '|-|', '·', '+ ·', '×'],
56 sprite: { palette: { b: '#4C78F5', g: '#6E7887', n: '#E2B98A', k: '#56606F', e: '#E8838A' }, frames: SET_SQUARE },
57 motif: 'sprite',
58 icon: 'assets/blueprint/icon.png',
59 sounds: { tick: 'assets/blueprint/tick.wav', done: 'assets/blueprint/done.wav', deny: 'assets/blueprint/deny.wav' },
60 // 中文用中文语音念:系统默认是英文语音,念中文会念成一串听不懂的音。按顺序试,没装的跳过。英文挑一个一丝不苟的英式声音
61 voices: { zh: ['Tingting', 'Flo (Chinese (China mainland))', 'Meijia'], en: ['Daniel'] },
62 words: {
63 ...prefix('lemo-core', core),
64 ...prefix('lemo-skin', skin),
65 ...prefix('lemo-spinner', spinner),
66 ...prefix('lemo-ask', ask),
67 ...prefix('lemo-meter', meter),
68 ...prefix('lemo-guard', guard),
69 ...prefix('lemo-sound', sound),
70 ...prefix('lemo-voice', voice),
71 ...prefix('lemo-tone', tone),
72 ...prefix('lemo-pomodoro', pomodoro),
73 ...prefix('lemo-watch', watch),
74 ...prefix('lemo-recap', recap),
75 ...prefix('lemo-todo', todo),
76 ...prefix('lemo-journal', journal),
77 ...prefix('lemo-lot', lot),
78 ...prefix('lemo-assistant', assistant),
79 },
80}
81styles/campfire/index.ts 79 lines1// 篝火(Campfire):露营的一夜,生火、添柴、扎营、守夜、看星星、收营。炭火橙红只当底色用,主色是松针绿。
2// 风格 = 一包数据:颜色、点缀、像素小画、音效、语音,和各 mod 带风格味道的文字(words/ 下每个 mod 一个文件)。
3import type { LemoStyle, LemoText } from '../../types'
4
5import core from './words/lemo-core'
6import skin from './words/lemo-skin'
7import spinner from './words/lemo-spinner'
8import ask from './words/lemo-ask'
9import meter from './words/lemo-meter'
10import guard from './words/lemo-guard'
11import sound from './words/lemo-sound'
12import voice from './words/lemo-voice'
13import tone from './words/lemo-tone'
14import pomodoro from './words/lemo-pomodoro'
15import watch from './words/lemo-watch'
16import recap from './words/lemo-recap'
17import todo from './words/lemo-todo'
18import journal from './words/lemo-journal'
19import lot from './words/lemo-lot'
20import assistant from './words/lemo-assistant'
21
22const prefix = (mod: string, w: Readonly<Record<string, LemoText>>) => Object.fromEntries(Object.entries(w).map(([k, v]) => [`${mod}.${k}`, v]))
23
24// 两根木柴上的一堆篝火:y 火心,o 火焰,r 火苗外缘,b 树皮,l 木柴的截面。
25// 三帧:木柴不动,火苗尖左右跳、火心忽高忽低,偶尔蹦出一颗火星
26const FIRE: string[][] = [
27 ['................', '.......r........', '......rr...r....', '.....rrorr.r....', '.....roooorr....', '....rrooyoorr...', '....rooyyyoor...', '..lbbbooyyobbbl.', '...lbbbbbbbbbl..', '................'],
28 ['..........y.....', '........r.......', '....r..rr.......', '....r.rrorr.....', '....rroooorr....', '....rrooyoorr...', '....rooyyyoor...', '..lbbbooyyobbbl.', '...lbbbbbbbbbl..', '................'],
29 ['.............o..', '......r.........', '......rr..r.....', '.....rorr.rr....', '.....rooyoor....', '....rrooyyoor...', '....rooyyyoor...', '..lbbbooyyobbbl.', '...lbbbbbbbbbl..', '................'],
30]
31
32export const campfire: LemoStyle = {
33 id: 'campfire',
34 name: { zh: '篝火', en: 'Campfire' },
35 colors: {
36 ink: '#3F9065', // 松针绿
37 grid: '#A08C78', // 木柴的灰褐
38 pencil: '#938A82', // 柴灰
39 accent: '#D66B1F', // 炭火橙红,只做底色。压深一点像烧透的炭,和亮橙、朱红拉开;上面写白字
40 onAccent: '#FFFFFF', // 强调色偏深,上面写白字(标签、竹签)
41 red: '#D9414F', // 野莓红,和炭火的橙红分开
42 inkDark: '#24452F', // 深松林
43 chip: '#F4ECE2', // 帐篷帆布
44 bubble: '#D9A35F', // 往上飘的火星
45 bubbleAccent: '#C45F1A', // 再深一点的炭火
46 cardFillLight: '#FFF6EE', // 火光映着的暖白
47 cardFillDark: '#26201C',
48 deskCardFill: '#FBF8F4',
49 deskCardBorder: '#EEE4D8',
50 deskFigure: '#1F1712', // 炭黑:桌面卡片上的大字(横条、面板的数值和风格名)
51 },
52 // 火堆上方往上飘的火星
53 bubbles: ['*', '˙ ·', '⋆', '· ˙', '˙'],
54 sprite: { palette: { y: '#FFD54F', o: '#F7882F', r: '#E04A26', b: '#8C5A33', l: '#D9A56A' }, frames: FIRE },
55 motif: 'sprite',
56 icon: 'assets/campfire/icon.png',
57 sounds: { tick: 'assets/campfire/tick.wav', done: 'assets/campfire/done.wav', deny: 'assets/campfire/deny.wav' },
58 // 中文用中文语音念:系统默认是英文语音,念中文会念成一串听不懂的音。按顺序试,没装的跳过。英文挑讲故事的英国口音
59 voices: { zh: ['Tingting', 'Flo (Chinese (China mainland))', 'Meijia'], en: ['Daniel'] },
60 words: {
61 ...prefix('lemo-core', core),
62 ...prefix('lemo-skin', skin),
63 ...prefix('lemo-spinner', spinner),
64 ...prefix('lemo-ask', ask),
65 ...prefix('lemo-meter', meter),
66 ...prefix('lemo-guard', guard),
67 ...prefix('lemo-sound', sound),
68 ...prefix('lemo-voice', voice),
69 ...prefix('lemo-tone', tone),
70 ...prefix('lemo-pomodoro', pomodoro),
71 ...prefix('lemo-watch', watch),
72 ...prefix('lemo-recap', recap),
73 ...prefix('lemo-todo', todo),
74 ...prefix('lemo-journal', journal),
75 ...prefix('lemo-lot', lot),
76 ...prefix('lemo-assistant', assistant),
77 },
78}
79styles/dessert/index.ts 78 lines1// 杨枝甘露(Mango Sago):港式糖水铺,杨枝甘露、芋圆、双皮奶、红豆沙,一碗一勺。草莓芋泥粉只当底色用,芒果黄做点缀。
2// 风格 = 一包数据:颜色、点缀、像素小画、音效、语音,和各 mod 带风格味道的文字(words/ 下每个 mod 一个文件)。
3import type { LemoStyle, LemoText } from '../../types'
4
5import core from './words/lemo-core'
6import skin from './words/lemo-skin'
7import spinner from './words/lemo-spinner'
8import ask from './words/lemo-ask'
9import meter from './words/lemo-meter'
10import guard from './words/lemo-guard'
11import sound from './words/lemo-sound'
12import voice from './words/lemo-voice'
13import tone from './words/lemo-tone'
14import pomodoro from './words/lemo-pomodoro'
15import watch from './words/lemo-watch'
16import recap from './words/lemo-recap'
17import todo from './words/lemo-todo'
18import journal from './words/lemo-journal'
19import lot from './words/lemo-lot'
20import assistant from './words/lemo-assistant'
21
22const prefix = (mod: string, w: Readonly<Record<string, LemoText>>) => Object.fromEntries(Object.entries(w).map(([k, v]) => [`${mod}.${k}`, v]))
23
24// 一碗杨枝甘露,瓷勺在碗里搅:p 粉色的碗,q 碗底和碗脚,m 芒果,w 西米,s 瓷勺(三帧勺子挪来挪去,西米跟着转)
25const BOWL_BODY = ['.pppppppppppppp.', '..pppppppppppp..', '...qqqqqqqqqq...', '.....qqqqqq.....']
26const BOWL: string[][] = [
27 ['..............s.', '.............s..', '............s...', '...pppppppsppp..', '.pmmwmmmmmssmmp.', '.pmmmmmwmmmmwmp.', ...BOWL_BODY],
28 ['...........s....', '..........s.....', '.........s......', '...pppppsppppp..', '.pmmmwmmssmmwmp.', '.pmmwmmmmmwmmmp.', ...BOWL_BODY],
29 ['................', '..............s.', '.............s..', '...pppppppppsp..', '.pmwmmmmwmmssmp.', '.pmmmmwmmmmmmwp.', ...BOWL_BODY],
30]
31
32export const dessert: LemoStyle = {
33 id: 'dessert',
34 name: { zh: '芒芒', en: 'Mango Pop' },
35 colors: {
36 ink: '#CF5D8D', // 玫瑰糖浆
37 grid: '#B39BD3', // 芋泥紫
38 pencil: '#9A8A93', // 瓷碗边的灰
39 accent: '#F08CB0', // 草莓芋泥粉,只做底色
40 onAccent: '#3B1426',
41 red: '#E2513C', // 辣椒红,和粉色拉开
42 inkDark: '#6E2440', // 红豆沙
43 chip: '#FBE7EF', // 淡粉
44 bubble: '#EBA43A', // 芒果
45 bubbleAccent: '#E9709C',
46 cardFillLight: '#FFF3F7', // 草莓牛奶
47 cardFillDark: '#2E2329',
48 deskCardFill: '#FFFAFC',
49 deskCardBorder: '#F6DDE7',
50 deskFigure: '#3B1426', // 桌面卡片上的大字(横条、面板的数值和风格名)
51 },
52 bubbles: ['✧', '∘ ∘', '·', '✦ ·', '∘'],
53 sprite: { palette: { p: '#F08CB0', q: '#CF5D8D', m: '#F5B53F', w: '#FFFFFF', s: '#9FB3C9' }, frames: BOWL },
54 motif: 'sprite',
55 icon: 'assets/dessert/icon.png',
56 sounds: { tick: 'assets/dessert/tick.wav', done: 'assets/dessert/done.wav', deny: 'assets/dessert/deny.wav' },
57 // 中文用中文语音念:系统默认是英文语音,念中文会念成一串听不懂的音。按顺序试,没装的跳过
58 voices: { zh: ['Tingting', 'Flo (Chinese (China mainland))', 'Meijia'], en: ['Samantha'] },
59 words: {
60 ...prefix('lemo-core', core),
61 ...prefix('lemo-skin', skin),
62 ...prefix('lemo-spinner', spinner),
63 ...prefix('lemo-ask', ask),
64 ...prefix('lemo-meter', meter),
65 ...prefix('lemo-guard', guard),
66 ...prefix('lemo-sound', sound),
67 ...prefix('lemo-voice', voice),
68 ...prefix('lemo-tone', tone),
69 ...prefix('lemo-pomodoro', pomodoro),
70 ...prefix('lemo-watch', watch),
71 ...prefix('lemo-recap', recap),
72 ...prefix('lemo-todo', todo),
73 ...prefix('lemo-journal', journal),
74 ...prefix('lemo-lot', lot),
75 ...prefix('lemo-assistant', assistant),
76 },
77}
78styles/eight-bit/index.ts 80 lines1// 掌机(Handheld,名字取自像素小画里的那块掌机屏幕):老掌机的绿屏和八位像素游戏。每一轮对话是一关,Claude 干活是闯关、读档,小结是这一局的战绩。
2// 绿屏的黄绿只当底色用(黄绿字在浅色终端上看不清),编号和进行中用屏幕上的像素绿,机身灰做线条,按键的莓红报错。
3// 风格 = 一包数据:颜色、点缀、像素小画、音效、语音,和各 mod 带风格味道的文字(words/ 下每个 mod 一个文件)。
4import type { LemoStyle, LemoText } from '../../types'
5
6import core from './words/lemo-core'
7import skin from './words/lemo-skin'
8import spinner from './words/lemo-spinner'
9import ask from './words/lemo-ask'
10import meter from './words/lemo-meter'
11import guard from './words/lemo-guard'
12import sound from './words/lemo-sound'
13import voice from './words/lemo-voice'
14import tone from './words/lemo-tone'
15import pomodoro from './words/lemo-pomodoro'
16import watch from './words/lemo-watch'
17import recap from './words/lemo-recap'
18import todo from './words/lemo-todo'
19import journal from './words/lemo-journal'
20import lot from './words/lemo-lot'
21import assistant from './words/lemo-assistant'
22
23const prefix = (mod: string, w: Readonly<Record<string, LemoText>>) => Object.fromEntries(Object.entries(w).map(([k, v]) => [`${mod}.${k}`, v]))
24
25// 一块掌机屏幕:g 机身灰边框,s 绿屏,m 屏上的中绿(金币、草坡、地面),d 屏上最深的绿(小人),r 电源灯。
26// 第一帧小人站在地上、头顶悬着一枚金币;第二帧跳起来把金币吃掉,金币的位置闪一下
27const SCREEN: string[][] = [
28 ['.gggggggggggggg.', '.gssssmsssssssg.', '.gssssmsssssssg.', '.gssssssssssssg.', '.gssssdsssssssg.', '.gsssdddssssssg.', '.gssssdssssmssg.', '.gsssdsdssmmmsg.', '.gmmmmmmmmmmmmg.', '.grgggggggggggg.'],
29 ['.gggggggggggggg.', '.gssmsssmsssssg.', '.gssssdsssssssg.', '.gsssdddssssssg.', '.gssssdsssssssg.', '.gsssdsdssssssg.', '.gsssssssssmssg.', '.gssssssssmmmsg.', '.gmmmmmmmmmmmmg.', '.grgggggggggggg.'],
30]
31
32export const eightBit: LemoStyle = {
33 id: 'eight-bit',
34 name: { zh: '掌机仔', en: 'Pixel Pal' },
35 colors: {
36 ink: '#5A9630', // 屏上的像素绿,中间调
37 grid: '#8E9099', // 机身灰
38 pencil: '#8B9182', // 带一点绿的灰
39 accent: '#9BBC0F', // 掌机绿屏的黄绿,只做底色
40 onAccent: '#0F380F', // 屏上最深的绿
41 red: '#E0506A', // 按键莓红
42 inkDark: '#0F380F',
43 chip: '#EDF2DC', // 很浅的屏幕绿
44 bubble: '#A3A7B0', // 浅机身灰
45 bubbleAccent: '#8BAC0F', // 压暗一点的绿屏
46 cardFillLight: '#F6F9E6',
47 cardFillDark: '#1A2A12',
48 deskCardFill: '#F8FAF2',
49 deskCardBorder: '#E2E8D2',
50 deskFigure: '#0F380F', // 屏上最深的绿:桌面卡片上的大字(横条、面板的数值和风格名)
51 },
52 // 加命、加分和像素闪光
53 bubbles: ['1UP', '+1', '·', '* ·', '+'],
54 sprite: { palette: { g: '#9A9DA6', s: '#9BBC0F', m: '#306230', d: '#0F380F', r: '#E0506A' }, frames: SCREEN },
55 motif: 'sprite',
56 icon: 'assets/eight-bit/icon.png',
57 sounds: { tick: 'assets/eight-bit/tick.wav', done: 'assets/eight-bit/done.wav', deny: 'assets/eight-bit/deny.wav' },
58 // 中文用中文语音念:系统默认是英文语音,念中文会念成一串听不懂的音。按顺序试,没装的跳过。
59 // 英文用 Fred:八十年代电脑里那种合成人声
60 voices: { zh: ['Tingting', 'Flo (Chinese (China mainland))', 'Meijia'], en: ['Fred'] },
61 words: {
62 ...prefix('lemo-core', core),
63 ...prefix('lemo-skin', skin),
64 ...prefix('lemo-spinner', spinner),
65 ...prefix('lemo-ask', ask),
66 ...prefix('lemo-meter', meter),
67 ...prefix('lemo-guard', guard),
68 ...prefix('lemo-sound', sound),
69 ...prefix('lemo-voice', voice),
70 ...prefix('lemo-tone', tone),
71 ...prefix('lemo-pomodoro', pomodoro),
72 ...prefix('lemo-watch', watch),
73 ...prefix('lemo-recap', recap),
74 ...prefix('lemo-todo', todo),
75 ...prefix('lemo-journal', journal),
76 ...prefix('lemo-lot', lot),
77 ...prefix('lemo-assistant', assistant),
78 },
79}
80styles/greenhouse/index.ts 79 lines1// 小苗(Sprout):育苗温室的世界,播种、浇水、发芽、换盆、开花、修剪。嫩叶绿只当底色用,陶土红当红笔。
2// 风格 = 一包数据:颜色、点缀、像素小画、音效、语音,和各 mod 带风格味道的文字(words/ 下每个 mod 一个文件)。
3import type { LemoStyle, LemoText } from '../../types'
4
5import core from './words/lemo-core'
6import skin from './words/lemo-skin'
7import spinner from './words/lemo-spinner'
8import ask from './words/lemo-ask'
9import meter from './words/lemo-meter'
10import guard from './words/lemo-guard'
11import sound from './words/lemo-sound'
12import voice from './words/lemo-voice'
13import tone from './words/lemo-tone'
14import pomodoro from './words/lemo-pomodoro'
15import watch from './words/lemo-watch'
16import recap from './words/lemo-recap'
17import todo from './words/lemo-todo'
18import journal from './words/lemo-journal'
19import lot from './words/lemo-lot'
20import assistant from './words/lemo-assistant'
21
22const prefix = (mod: string, w: Readonly<Record<string, LemoText>>) => Object.fromEntries(Object.entries(w).map(([k, v]) => [`${mod}.${k}`, v]))
23
24// 陶土花盆里的一棵小苗:g 嫩叶,d 茎和叶脉,s 盆土,r 盆沿,p 盆身。
25// 三帧:站直 → 被风吹得往右摆 → 往上蹿高一格(再回到站直,像在呼吸着长)
26const SEEDLING: string[][] = [
27 ['................', '..........ggg...', '.........ggggg..', '..ggg...gdggg...', '.ggggg.ddgg.....', '..gggddd........', '....sssdsss.....', '..rrrrrrrrrrr...', '...ppppppppp....', '....ppppppp.....'],
28 ['................', '...........ggg..', '..........ggggg.', '...ggg...gdggg..', '..ggggg.ddgg....', '...gggddd.......', '....sssdsss.....', '..rrrrrrrrrrr...', '...ppppppppp....', '....ppppppp.....'],
29 ['..........ggg...', '.........ggggg..', '..ggg...gdggg...', '.ggggg.ddgg.....', '..gggddd........', '.......d........', '....sssdsss.....', '..rrrrrrrrrrr...', '...ppppppppp....', '....ppppppp.....'],
30]
31
32export const greenhouse: LemoStyle = {
33 id: 'greenhouse',
34 name: { zh: '芽芽', en: 'Sprouty' },
35 colors: {
36 ink: '#4E9A3E', // 茎绿
37 grid: '#8FAF96', // 玻璃窗框的灰绿
38 pencil: '#8C877C', // 盆土灰
39 accent: '#86D96A', // 嫩叶绿,只做底色。比八位机的橄榄屏更亮更绿,比药铺的玉绿更偏黄
40 onAccent: '#1A2614', // 深土色
41 red: '#CF6A48', // 陶土红
42 inkDark: '#2A5524', // 深林绿
43 chip: '#EAF4E1', // 浅叶底
44 bubble: '#8DBFB0', // 喷雾的水汽
45 bubbleAccent: '#6EC454', // 深一点的嫩叶绿
46 cardFillLight: '#F5FAEE', // 晨光里的浅绿
47 cardFillDark: '#20271D',
48 deskCardFill: '#F7FAF3',
49 deskCardBorder: '#E1ECD7',
50 deskFigure: '#1A2614', // 深土色:桌面卡片上的大字(横条、面板的数值和风格名)
51 },
52 // ψ 是一棵小苗,❀ 是开的花,· , 是喷雾落下的水珠
53 bubbles: ['ψ', '· ,', '❀', ', ·', '·'],
54 sprite: { palette: { g: '#86D96A', d: '#4E9A3E', s: '#6B4A35', r: '#E08B5F', p: '#C8643F' }, frames: SEEDLING },
55 motif: 'sprite',
56 icon: 'assets/greenhouse/icon.png',
57 sounds: { tick: 'assets/greenhouse/tick.wav', done: 'assets/greenhouse/done.wav', deny: 'assets/greenhouse/deny.wav' },
58 // 中文用中文语音念:系统默认是英文语音,念中文会念成一串听不懂的音。按顺序试,没装的跳过。英文挑温和的爱尔兰口音
59 voices: { zh: ['Tingting', 'Flo (Chinese (China mainland))', 'Meijia'], en: ['Moira'] },
60 words: {
61 ...prefix('lemo-core', core),
62 ...prefix('lemo-skin', skin),
63 ...prefix('lemo-spinner', spinner),
64 ...prefix('lemo-ask', ask),
65 ...prefix('lemo-meter', meter),
66 ...prefix('lemo-guard', guard),
67 ...prefix('lemo-sound', sound),
68 ...prefix('lemo-voice', voice),
69 ...prefix('lemo-tone', tone),
70 ...prefix('lemo-pomodoro', pomodoro),
71 ...prefix('lemo-watch', watch),
72 ...prefix('lemo-recap', recap),
73 ...prefix('lemo-todo', todo),
74 ...prefix('lemo-journal', journal),
75 ...prefix('lemo-lot', lot),
76 ...prefix('lemo-assistant', assistant),
77 },
78}
79