SLOPSHOPPER

lemo-core

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

newpanerowscommandprompttimer
★ 14v0.2.0MITupdated 2026-10-04lemomo-ai/lemo-mod/plugins/lemo-core
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lemo-core
│ ┃ lemo-mod ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ∘ ° ⏺ Read(src/auth.ts) │ ┃ lemo-mod · T01 ⎿ Read 6 lines │ ┃ Style Lemo Lab · 1 mod ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ [ Behavior ] [ Safety ] ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ctrl+x Tab to enter · a–b to switch │ ┃ pages · digits press buttons · Esc to go ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ back │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Style │ ┃ Switch the style of every lemo mod at o… › /lemo-mod │ ┃ │ ┃ [ Lemo Lab ] [ Lucky Koi ] [ Cassie ] [ P │ ┃ │ ┃ Name │ ┃ Name "Lemo Lab" for the panel, the band… │ ┃ messages · each style keeps its own │ ┃ │ ┃ Type a name, Enter to save ⏎ Save │ ┃ │ ┃ Installed mods │ ┃ No other lemo mods · install all: /plug… │ ┃ lemo-mod@lemo-mod ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · lemo-mod
∘ ° lemo-mod · T01 Style Lemo Lab · 1 mod [ Behavior ] [ Safety ] ctrl+x Tab to enter · a–b to switch pages · digits press buttons · Esc to go back Style Switch the style of every lemo mod at once [ Lemo Lab ] [ Lucky Koi ] [ Cassie ] [ Peeky ] [ Sprouty Name Name "Lemo Lab" for the panel, the band and messages · each style keeps its own Type a name, Enter to save ⏎ Save Installed mods No other lemo mods · install all: /plugin install lemo-mod@lemo-mod
README

lemo-mod

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 套风格">

▶ See every style · 看全部风格

What is lemo-mod · lemo-mod 是什么

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 还提供一整套功能,包括四页面板、用量横条、状态栏、消息编号和回复标签、音效、朗读、简短模式、定时提醒、番茄钟、笔记、会话小结、每轮统计、限时、日志、自动分类、抽签工具和一个只读助手。你的设置只读不改,外观以外的功能都由你自己开启。

→ 阅读中文版 README

What you get

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>

Safety

A mod runs with your permissions, so it matters whose mods you install. lemo-mod follows these rules. See SAFETY.md for details.

  1. No permission decisions for you. No mod allows or blocks a tool call. Your own settings and permission mode decide.
  2. Your settings are read, never changed. Only the few that overlap with the mods are read, on your machine. They are not stored and not sent to Claude.
  3. Only looks are on at install. Everything else starts off.
  4. Every automatic feature has a switch on the Safety page. On-demand features run only when you press a button or type a command. Type /lemo-mod safety to open the page.
  5. Where your setup overlaps, yours wins. If you set your own status line or spinner words, yours stay, and the Safety page says why.

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.

Install

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.

Use

TypeDoes
/lemo-modOpen the panel
/lemo-mod safetyOpen 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 soundTurn sound off or on
/lemo-mod speakHear a speech preview
/lemo-mod briefTurn brief mode on or off
/lemo-mod focus 25Start a 25-minute focus timer
/lemo-mod remind 30Ask Claude for a progress update in 30 seconds
/lemo-mod recapGet 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.

Styles

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 it works

WhereWhat you see
claude in a terminalEverything
Desktop app, Code tabEverything the app can draw, the rest is hidden
VS Code chat panel and claude -pNo 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.

Update

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

Uninstall

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.

The mods

ModWhat it does
lemo-coreStyles, language, message numbers, the /lemo-mod panel and its Safety page
lemo-skinMessage numbers, reply tags, and tool rows and turn lines in the style
lemo-spinnerSpinner words from the style, with the message number
lemo-askMessage number and question count above the question dialog
lemo-meterThe usage band, the status line, git branch and update check
lemo-soundSounds on tool calls, turn ends and time-limit stops
lemo-voiceReads out the message number and time when a long task ends
lemo-toneBrief mode and notebook voice
lemo-guardTime limit that stops a turn running over time
lemo-pomodoroFocus timer
lemo-watchReminder that asks Claude for a progress update after N seconds
lemo-recapThree-line session recap
lemo-todoNotes in the panel, with an auto summary after compaction
lemo-journalPer-turn stats, log and auto tags
lemo-lotA lot-drawing tool for Claude and fortune cards
lemo-assistantA read-only assistant that writes a three-line report

Coming next

Coming next are combined features, games in the panel and more styles. Ideas for styles or mods are welcome in Issues.

About

Made by lemomo of Lemo Lab, together with Claude. MIT License.

Source 91 files
hooks/register.tsx 1327 lines
1// 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 lines
1import 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'
33
hooks/i18n.ts 182 lines
1// 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}
182
hooks/shared/lemo.tsx 597 lines
1// 所有 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}
597
styles/apothecary/index.ts 114 lines
1// 葫芦(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}
114
styles/aquarium/index.ts 79 lines
1// 泡泡鱼(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}
79
styles/bakery/index.ts 78 lines
1// 面包(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}
78
styles/blueprint/index.ts 81 lines
1// 三角尺(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}
81
styles/campfire/index.ts 79 lines
1// 篝火(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}
79
styles/dessert/index.ts 78 lines
1// 杨枝甘露(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}
78
styles/eight-bit/index.ts 80 lines
1// 掌机(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}
80
styles/greenhouse/index.ts 79 lines
1// 小苗(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