SLOPSHOPPER

lemo-journal

记录:面板显示每轮统计 · 日志和自动分类默认关闭,在「安全」页开启 / Journal: per-turn stats in the panel · Log and Auto tags are off by default, switch on in Safety

newpanepromptmodeltimer
★ 14v0.2.0MITupdated 2026-10-04lemomo-ai/lemo-mod/plugins/lemo-journal
A shopper browsing a rack in a slop shop
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 4 files
hooks/register.tsx 506 lines
1// lemo-journal:记录。
2// - 日志:每轮结束(主对话、没被中断)往 ~/.claude/lemo-mod/journal.md 追加一行(带项目名:几个会话共用这个文件)
3// - 自动分类:用户本人每发一条消息,用小模型贴一个标签(提问、改代码、排查、闲聊),交给 lemo-core 记($.lemo.tag),lemo-skin 显示在编号后面
4// - 每轮统计:「行为」页一张卡片,读 lemo-core 的 lastTurn
5// 两个开关存在 $.store,换个会话还在。不放在 /config(userConfig):
6// 桌面 App 改不了插件的设置项,所以做成面板上的按钮。
7// 两个开关装上时都是关的(日志会往用户电脑上写文件,自动分类会多花用量),
8// 都列在「安全」页(lemo.caps),那里和面板卡片上的按钮改的是同一个值。
9// 不改「commit 署名」(attribution.text):开源项目不在别人的 commit 里留自己的名字,
10// 要署名的用户用 Claude Code 自己的署名设置
11
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, Register } from 'claude-code'
14
15import { STR } from './i18n'
16import { HUB, NOT_PERSON, REMIND_MARK, clip, exp, fill, fmtDur, hubWrap, inkOf, mdhm, sec, steady, word } from './shared/lemo'
17import type { CardSpec, Look } from './shared/lemo'
18
19// ---- lemo-shared BEGIN: 由 scripts/sync-shared.mjs 从 shared/lemo-block.tsx 复制,不要在这里改 ----
20// 下面这几个函数要用 $,所以不能放进 shared/lemo.tsx(校验规定 $ 只能传给同一个文件里定义的函数),
21// 由 scripts/sync-shared.mjs 原样插进每个 mod 的 hooks/register.tsx。只在 shared/lemo-block.tsx 里改。
22// 导入都起了 Lemo 开头的别名,免得和 mod 自己的导入重名。
23import type { EngineInterface as LemoEngine } from 'claude-code'
24import { visibleTabs as lemoVisibleTabs } from './shared/lemo'
25import type { Look as LemoLook, Tab as LemoTabId } from './shared/lemo'
26
27// lemo-core 的状态(校验规定:状态的引用要写在用它的文件里)。读它们会订阅,lemo-core 一改,读过的地方自动重画
28const LemoStyleRef = { plugin: 'lemo-core', key: 'style' } as const
29const LemoLangRef = { plugin: 'lemo-core', key: 'lang' } as const
30const LemoThemeRef = { plugin: 'lemo-core', key: 'theme' } as const
31const LemoDeskRef = { plugin: 'lemo-core', key: 'desk' } as const
32const LemoTabRef = { plugin: 'lemo-core', key: 'tab' } as const
33const LemoModsRef = { plugin: 'lemo-core', key: 'mods' } as const
34const LemoSeqRef = { plugin: 'lemo-core', key: 'seq' } as const
35const LemoTurnNoRef = { plugin: 'lemo-core', key: 'turnNo' } as const
36
37/**
38 * 画东西时要的风格和语言。读 lemo-core 的状态会订阅,lemo-core 一改自动重画;还没写过时用 $.lemo 兜底。
39 * 面板(Pane)的 hook 要把 e.props 传进来:面板停在哪(placement)决定正文用什么颜色(见 shared/lemo.tsx 的 inkOf),
40 * 面板宽度(bodyColumns)决定卡片说明在哪断行(见 shared/lemo.tsx 的 card)
41 */
42async function look($: LemoEngine, pane?: { placement: 'dock' | 'inline'; bodyColumns?: number }): Promise<LemoLook> {
43  const st = (await $.state.get(LemoStyleRef)).value ?? (await $.lemo.style({}))
44  const lang = (await $.state.get(LemoLangRef)).value ?? (await $.lemo.lang({}))
45  const theme = (await $.state.get(LemoThemeRef)).value ?? null
46  const desk = (await $.state.get(LemoDeskRef)).value ?? null
47  return { st, c: st.colors, lang, theme, desk, inline: pane?.placement === 'inline', ...(pane?.bodyColumns === undefined ? {} : { bodyColumns: pane.bodyColumns }) }
48}
49
50/** 用户本人发了几条消息(T01、T02…) */
51async function seqOf($: LemoEngine): Promise<number> {
52  return (await $.state.get(LemoSeqRef)).value ?? 0
53}
54
55/** 这一轮回的是第几条消息:提醒、助手交回、斜杠命令开头的一轮是 0(不写号)。lemo-core 还没写过时当作 seq */
56async function turnNoOf($: LemoEngine): Promise<number> {
57  return (await $.state.get(LemoTurnNoRef)).value ?? (await seqOf($))
58}
59
60/** 统一面板现在显示哪一页:存的那页在这个界面上没有,就显示第一页 */
61async function hubTab($: LemoEngine, surface: string): Promise<LemoTabId> {
62  const stored = (await $.state.get(LemoTabRef)).value ?? 'main'
63  const mods = (await $.state.get(LemoModsRef)).value
64  // 还没有报到表(lemo-core 还没写过,或者测试里用的是假核心):照存的那页
65  if (mods === undefined) return stored
66  const shown = lemoVisibleTabs(mods, surface)
67  return shown.includes(stored) ? stored : shown[0] ?? 'behave'
68}
69// ---- lemo-shared END ----
70
71// lemo-core 的状态(只读):上一轮几步几次工具、第 n 条消息的标签。编号 seq 用上面的 seqOf($) 读
72const LAST = { plugin: 'lemo-core', key: 'lastTurn' } as const
73const TAGS = { plugin: 'lemo-core', key: 'tags' } as const
74
75// 自己的状态:日志最近几行,两个开关(值存在 $.store,这里放一份给面板读)。
76// 默认都关:日志会往 ~/.claude 下写文件,自动分类每条消息多调一次小模型
77const tail = atom({ plugin: 'lemo-journal', key: 'tail' } as const, [] as readonly string[])
78const logOn = atom({ plugin: 'lemo-journal', key: 'logOn' } as const, false)
79const classifyOn = atom({ plugin: 'lemo-journal', key: 'classifyOn' } as const, false)
80
81// 能力清单的一行(类型从 lemo-core 的合约里取)
82type Cap = Awaited<ReturnType<EngineInterface['lemo']['caps']>>[number]
83
84// 自动分类的标签:给模型看英文,存进 lemo-core 的是短键,界面上按语言显示(i18n 的 kinds)
85const KIND_LABELS = ['question', 'code-change', 'debugging', 'chat'] as const
86const KIND_KEY: Readonly<Record<string, string>> = { question: 'question', 'code-change': 'code', debugging: 'debug', chat: 'chat' }
87
88// $.fs:日志放在 ~/.claude/lemo-mod/journal.md。$.fs 没有追加,读出来接上一行再整个写回去,只留最近 500 行。
89// 日志和 $.store 都是所有会话共用的:用户别的会话也装了 lemo-journal,也会往这里写
90const LOG_SHOWN = '~/.claude/lemo-mod/journal.md'
91const KEEP = 500
92
93// 日志文件第一行。风格包可以换(键 head),这里是中性的默认
94const LOG_HEAD = '# lemo-mod · 日志 / Journal'
95
96// 日志文件的位置,第一次用时按 HOME 算
97let logPath: string | null = null
98// 这个会话的项目名:工作目录的最后一段(session.start 的 e.cwd)。好几个会话、好几个项目共用一个日志文件,
99// 光有 T 编号分不清是哪个会话的哪条,所以每行带上它;拿不到就不加
100let project = ''
101// 这个会话里写日志排成一队:$.fs 没有追加,每次是读出来、接一行、整个写回去。两轮紧挨着结束时,
102// 两次写会都读到旧的那份、各接各的,后写的盖掉先写的那行。所以上一次写完再读改写下一次。
103// 只管得了这个会话;别的会话同一瞬间写还是可能丢一行,要两个会话几乎同时结束一轮才会碰上,接受
104let logQueue: Promise<void> = Promise.resolve()
105// 用户本人刚发出的消息原文:prompt.submit 里记下,追加进对话记录时对上编号
106let pendingText = ''
107let lastPrompt = ''
108let said: { n: number; text: string } = { n: 0, text: '' }
109// 日志文件的尾巴读过没有。日志关着时开会话不读这个文件(关着就不碰它),
110// 打开日志、或者用户翻到「后台」页看这张卡片时再读一次
111let tailLoaded = false
112// 这一轮是谁引起的:用户本人、lemo-watch 的提醒,还是后台任务通知、助手交回的报告这类。
113// 不是用户本人引起的一轮,日志里编号那一栏写「提醒」「后台」,不冒用用户上一条的编号
114type TurnBy = 'person' | 'remind' | 'bg' | 'cmd'
115let turnBy: TurnBy = 'person'
116// 下一轮是谁引起的:用户输入、追加进对话记录时先记在这里,turn.start 时才定给那一轮。
117// 不在追加时直接定:排队的输入、命令可能在上一轮还没结束时就进来,会改掉正在跑那一轮的类别。
118// skill 那一轮只有用户输入(命令不进对话记录),提醒之后跑的 skill 靠它不再被记成「提醒」
119let nextBy: TurnBy | null = null
120// 这一轮开头时用户最近输入的原文(「命令」那一行写它):turn.start 时抄下,之后排队进来的话改不了它。
121// null:mod 载入以后还没见过 turn.start(一轮跑到一半热重载了),照最近输入的算
122let turnText: string | null = null
123
124/** 英文的「1 steps」「1 tools」改成单数 */
125const one = (t: string) => t.replace(/\b1 (step|tool)s\b/g, '1 $1')
126
127/**
128 * 不跟界面语言变的文字(日志抬头):风格包里中英两份写成一样,这里固定取中文那份。
129 * 抬头只在新建日志文件时写一次,跟着语言变的话,同一个文件里会有两种抬头
130 */
131const fixed = (lk: Look, key: string, fallback: string) => word({ st: lk.st, lang: 'zh' }, key, fallback)
132
133async function logFile($: EngineInterface): Promise<string | null> {
134  if (logPath !== null) return logPath
135  const home = await $.env.get('HOME').catch(() => undefined)
136  logPath = home === undefined || home === '' ? null : `${home}/.claude/lemo-mod/journal.md`
137  return logPath
138}
139
140async function readLog($: EngineInterface): Promise<string> {
141  const path = await logFile($)
142  if (path === null) return ''
143  try {
144    return await $.fs.read(path)
145  } catch {
146    return ''
147  }
148}
149
150async function showLogTail($: EngineInterface, text: string) {
151  const lines = text.split('\n').filter(l => l.startsWith('- '))
152  await update($, tail, () => lines.slice(-6))
153}
154
155/** 工作目录的最后一段:/Users/me/code/Lemo-mod/ → Lemo-mod;空的、只有根目录时是 '' */
156function projectOf(cwd: string): string {
157  return cwd.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
158}
159
160/** 排进这个会话的写日志队列:前面的写完(成没成都算完)再写这一行 */
161function queueLog($: EngineInterface, line: string, head: string): Promise<void> {
162  const job = logQueue.then(() => appendLog($, line, head))
163  logQueue = job.catch(() => undefined)
164  return job
165}
166
167async function appendLog($: EngineInterface, line: string, head: string) {
168  const path = await logFile($)
169  if (path === null) return
170  // 排队等的时候可能关掉了(这个会话、别的会话,或者「全部关闭」):写之前再看一次
171  if (!(await liveFlag($, 'log'))) return
172  let old = ''
173  try {
174    old = await $.fs.read(path)
175  } catch {
176    // 读不出来:文件真不存在才新建。存在却读不了(超过 4 MiB、没权限……)这次不写,免得盖掉旧日志
177    if (await $.fs.exists(path).catch(() => true)) return
178  }
179  const rows = old.split('\n')
180  // 第一行是抬头(# 开头)就去掉,写回去时换成现在的抬头
181  if (rows[0]?.startsWith('#') === true) rows.shift()
182  const lines = [...rows.filter(l => l.trim() !== ''), line].slice(-KEEP)
183  const text = `${head}\n\n${lines.join('\n')}\n`
184  try {
185    await $.fs.write(path, text)
186    await showLogTail($, text)
187  } catch {
188    // 写不进去就算了,不影响对话
189  }
190}
191
192// 一轮一行:时间 · 项目 · T03 · 标签 · 用时 · 几步几次工具 · 用户那句话的开头。
193// 不是用户本人引起的一轮:编号那一栏写「提醒」「后台」,不带标签和用户的话。
194// 斜杠命令(skill)开头的一轮:编号那一栏写「命令」,后面是用户输入的命令。
195// 这一行在一轮刚结束时就拼好(编号、标签是这一轮的),再排队写进文件
196async function logTurn($: EngineInterface, ms: number, by: TurnBy, typed: string) {
197  if (!(await liveFlag($, 'log'))) return
198  const lk = await look($)
199  const S = STR[lk.lang]
200  const last = (await $.state.get(LAST)).value ?? null
201  const stats = last === null ? '' : ` · ${one(fill(S.turnStats, { steps: last.steps, tools: last.tools }))}`
202  const where = project === '' ? '' : ` · ${project}`
203  const now = await $.clock.now()
204  let who = S.by[by === 'person' ? 'bg' : by]
205  let kind = ''
206  let tailText = by === 'cmd' && typed !== '' ? ` · ${clip(typed, 50)}` : ''
207  if (by === 'person') {
208    const n = await seqOf($)
209    const tag = ((await $.state.get(TAGS)).value ?? {})[String(n)]
210    const text = said.n === n && said.text !== '' ? said.text : typed
211    who = exp(n)
212    kind = tag === undefined ? '' : ` · ${S.kinds[tag] ?? tag}`
213    tailText = text === '' ? '' : ` · ${clip(text, 50)}`
214  }
215  await queueLog($, `- ${mdhm(now)}${where} · ${who}${kind} · ${fmtDur(ms)}${stats}${tailText}`, fixed(lk, 'lemo-journal.head', LOG_HEAD))
216}
217
218// $.model.classify:用小模型给第 n 条消息贴标签,交给 lemo-core 存(lemo-skin 显示在编号后面)
219async function classifyPrompt($: EngineInterface, n: number, text: string) {
220  if (n <= 0 || text === '' || !(await liveFlag($, 'classify'))) return
221  try {
222    const k = await $.model.classify(text.slice(0, 2000), KIND_LABELS)
223    const kind = k === undefined ? undefined : KIND_KEY[k]
224    // 等模型的时候关掉了:不贴
225    if (kind !== undefined && (await liveFlag($, 'classify'))) await $.lemo.tag({ n, kind })
226  } catch {
227    // 请求失败就不贴标签
228  }
229}
230
231async function loadPrefs($: EngineInterface) {
232  const log = await $.store.get('log')
233  if (typeof log === 'boolean') await update($, logOn, () => log)
234  const cls = await $.store.get('classify')
235  if (typeof cls === 'boolean') await update($, classifyOn, () => cls)
236}
237
238async function loadTail($: EngineInterface) {
239  await showLogTail($, await readLog($))
240}
241
242/** 不急的放到后面:读日志文件,面板上显示最近几行。读过一次就不再读(之后每写一行,面板跟着更新) */
243function laterTail($: EngineInterface) {
244  if (tailLoaded) return
245  tailLoaded = true
246  $.clock.after(0, () => {
247    void loadTail($)
248  })
249}
250
251// 两个开关。状态库的 read/update 只认写死的 atom,不能当参数传,所以按名字分开写。
252// 面板卡片、安全页(lemo.toggle)、「全部关闭」(lemo.off)都走这里:会话状态和 $.store 一起改
253type Flag = 'log' | 'classify'
254
255async function setFlag($: EngineInterface, f: Flag, v: boolean) {
256  switch (f) {
257    case 'log': {
258      await update($, logOn, () => v)
259      await $.store.set('log', v)
260      // 刚打开:面板上补出日志文件的尾巴
261      if (v) laterTail($)
262      return
263    }
264    case 'classify': {
265      await update($, classifyOn, () => v)
266      await $.store.set('classify', v)
267      return
268    }
269  }
270}
271
272async function toggle($: EngineInterface, f: Flag) {
273  await setFlag($, f, !(await liveFlag($, f)))
274}
275
276/**
277 * 开关现在的值:每次从 $.store 现读(所有会话共用一份),会话状态跟着对齐。
278 * 别的会话里关掉了、按了「全部关闭」,这个会话里的日志、自动分类也跟着停。读不到就当关着
279 */
280async function liveFlag($: EngineInterface, f: Flag): Promise<boolean> {
281  let v: boolean
282  try {
283    v = (await $.store.get(f)) === true
284  } catch {
285    return false
286  }
287  switch (f) {
288    case 'log': {
289      if ((await read($, logOn)) !== v) await update($, logOn, () => v)
290      return v
291    }
292    case 'classify': {
293      if ((await read($, classifyOn)) !== v) await update($, classifyOn, () => v)
294      return v
295    }
296  }
297}
298
299// 安全页上的两行:写什么文件、花谁的用量,说清楚。不带风格味道(安全页要一眼看懂)
300async function journalCaps($: EngineInterface): Promise<Cap[]> {
301  const row = (id: Flag, kind: 'file' | 'cost', isOn: boolean): Cap => ({
302    mod: 'lemo-journal',
303    id,
304    kind,
305    title: { zh: STR.zh.cap[id].title, en: STR.en.cap[id].title },
306    desc: { zh: fill(STR.zh.cap[id].desc, { path: LOG_SHOWN }), en: fill(STR.en.cap[id].desc, { path: LOG_SHOWN }) },
307    on: isOn,
308  })
309  // 照 $.store 报:别的会话里改过,这个会话的安全页也是对的
310  return [row('log', 'file', await liveFlag($, 'log')), row('classify', 'cost', await liveFlag($, 'classify'))]
311}
312
313export const register: Register = on => {
314  // ---------- 生命周期 ----------
315  on('session.start', async ($, e, next) => {
316    await $.lemo.join({
317      mod: 'lemo-journal',
318      title: { zh: '记录', en: 'Journal' },
319      tabs: ['bg', 'behave'],
320      // 没有 always:日志、自动分类都会做事(写文件、多调模型),列在安全页上,有开关
321    })
322    project = projectOf(e.cwd)
323    await loadPrefs($)
324    // 日志开着才读日志文件;关着时等用户打开日志、或者翻到那张卡片再读
325    if (await read($, logOn)) laterTail($)
326    return next(e)
327  })
328
329  // ---------- 安全页:能力清单、开关、「全部关闭」 ----------
330  on('lemo.caps', async ($, e, next) => {
331    const r = await next(e)
332    return { value: [...(r.value ?? []), ...(await journalCaps($))] }
333  })
334
335  on('lemo.toggle', async ($, e, next) => {
336    if (e.mod !== 'lemo-journal') return next(e)
337    if (e.on === undefined || (e.id !== 'log' && e.id !== 'classify')) return { value: false }
338    await setFlag($, e.id, e.on)
339    return { value: true }
340  })
341
342  // 存不进去也要往下传:别的 mod 的开关照样关
343  on('lemo.off', async ($, e, next) => {
344    try {
345      await setFlag($, 'log', false)
346    } catch {
347      // 照样往下传
348    }
349    try {
350      await setFlag($, 'classify', false)
351    } catch {
352      // 照样往下传
353    }
354    return next(e)
355  })
356
357  // 用户发消息:记下原文(只记用户本人的;后台通知、别的 agent、插件发来的不算)
358  on('prompt.submit', async ($, e, next) => {
359    if (!NOT_PERSON.has(e.origin.kind)) {
360      pendingText = e.text.trim()
361      lastPrompt = pendingText
362      nextBy = 'person'
363    }
364    return next(e)
365  })
366
367  // 消息进了对话记录:lemo-core 在里层给它定号,所以先 next 再读编号。
368  // 判断「是不是用户本人发的」和 lemo-core 编号那个 hook 一样。贴标签要另外调一次模型,放到后面做,不拖慢追加
369  on('session.append', { door: 'prompt' }, async ($, e, next) => {
370    const o = e.origin
371    const main = e.agentId === undefined && o.kind !== 'model' && o.kind !== 'tool'
372    // 桌面会在用户的话前后附上别的内容(< 开头),优先用 prompt.submit 时记下的原文
373    const first = e.message.content
374      .map(b => ('text' in b && typeof b.text === 'string' ? b.text.trim() : ''))
375      .find(t => t !== '' && !t.startsWith('<'))
376    // 先记下这一轮是谁引起的(转交之前就记,转交出错也不影响):
377    // 不是用户本人发的,lemo-watch 的提醒以 ⏰ 开头,别的算后台;日志里都不给它编号
378    const isPerson = main && !NOT_PERSON.has(o.kind) && e.message.isMeta !== true
379    if (main && NOT_PERSON.has(o.kind)) nextBy = first?.startsWith(REMIND_MARK) === true ? 'remind' : 'bg'
380    else if (isPerson) nextBy = 'person'
381    const r = await next(e)
382    if (!isPerson) return r
383    const n = await seqOf($)
384    const text = pendingText !== '' ? pendingText : first ?? ''
385    pendingText = ''
386    said = { n, text }
387    $.clock.after(0, () => {
388      void classifyPrompt($, n, text)
389    })
390    return r
391  })
392
393  // 一轮开始:定下这一轮是谁引起的(只有主对话有 turn.start)。接着上一轮说的(前面什么也没进来)照上一轮的算
394  on('turn.start', async ($, e, next) => {
395    if (nextBy !== null) {
396      turnBy = nextBy
397      turnText = lastPrompt
398      nextBy = null
399    }
400    return next(e)
401  })
402
403  // 一轮结束:lemo-core 在里层写 lastTurn(几步几次工具),所以先 next 再读。只记主对话、没被中断的
404  on('turn.complete', async ($, e, next) => {
405    const r = await next(e)
406    if (e.agentId !== undefined || e.isAborted) return r
407    const ms = e.durationMs
408    // 用户本人这边开头、lemo-core 却没给这一轮编号的:是斜杠命令(skill)开头的
409    const by: TurnBy = turnBy === 'person' && (await turnNoOf($)) === 0 ? 'cmd' : turnBy
410    const typed = turnText ?? lastPrompt
411    $.clock.after(0, () => {
412      void logTurn($, ms, by, typed)
413    })
414    return r
415  })
416
417  // ---------- 统一面板:后台页「日志」「自动分类」,行为页「每轮统计」 ----------
418  on('ui.render', { component: 'Pane', requestId: HUB }, async ($, e, next) => {
419    // 面板按钮用 steady:桌面上面板拿到焦点会再画一次,按钮还是同一个,第一下点击不丢(见 shared/lemo.tsx)。
420    // 每次画都先调它,这次不画按钮也调:下一次画才知道哪些按钮上一次画过
421    const Button = steady($.ui.resolve(e).Button, e.surface)
422    const inner = await next(e)
423    const at = await hubTab($, e.surface)
424    if (at !== 'bg' && at !== 'behave') return inner
425    const lk = await look($, e.props)
426    const el = $.ui.resolve(e)
427    const { Box, Text, Code, Markdown } = el
428    const S = STR[lk.lang]
429    const isTerm = e.surface === 'terminal'
430    const onOff = (key: string, isOn: boolean, f: Flag) => (
431      <Button key={key} label={isOn ? S.on : S.off} {...sec(e.surface)} onPress={() => toggle($, f)} />
432    )
433    const specs: CardSpec[] = []
434
435    if (at === 'bg') {
436      // 用户翻到这张卡片了:日志关着时开会话没读过日志文件,这时读一次,显示以前记的
437      laterTail($)
438      const isLog = await read($, logOn)
439      const lines = await read($, tail)
440      // Markdown 里的 file: 链接能点开本机文件;Link 元素只认 https。
441      // 桌面的文件窗格只看工作目录里的文件,打不开 ~/.claude 下的日志,所以只在终端给链接
442      const path = isTerm ? await logFile($) : null
443      const link = path === null ? null : <Markdown key="journal-open" text={`[${S.log.open}](file://${encodeURI(path)})`} />
444      // Code 用主题配好的代码颜色(浅色主题是深灰、深色主题是近白)。终端里面板内嵌时(lk.inline)没有主题的底,
445      // 这些颜色直接落在终端底色上,终端和主题明暗不一样时看不见。内嵌时一行一个 Text,用终端自己的字色;停靠时照旧用 Code
446      const shown =
447        lines.length === 0 ? (
448          <Text dimColor>{S.log.empty}</Text>
449        ) : lk.inline && isTerm ? (
450          <Box flexDirection="column">
451            {lines.map((l, i) => (
452              <Text key={`journal-tail-${i}`} {...inkOf(lk, e.surface)} wrap="truncate-end">{l}</Text>
453            ))}
454          </Box>
455        ) : (
456          <Code source={lines.join('\n')} language="markdown" wrap="truncate-end" />
457        )
458      specs.push({
459        id: 'journal-log',
460        title: word(lk, 'lemo-journal.logTitle', S.log.title),
461        desc: fill(isLog ? S.log.desc : S.log.off, { path: LOG_SHOWN }),
462        buttons: onOff('journal-log-switch', isLog, 'log'),
463        extra: (
464          <Box flexDirection="column" gap={isTerm ? 0 : 1}>
465            {shown}
466            {link}
467          </Box>
468        ),
469      })
470
471      const isClassify = await read($, classifyOn)
472      const tg = (await $.state.get(TAGS)).value ?? {}
473      const recentTags = Object.entries(tg)
474        .sort((a, b) => Number(a[0]) - Number(b[0]))
475        .slice(-5)
476        .map(([n, k]) => `${exp(Number(n))} ${S.kinds[k] ?? k}`)
477        .join(' · ')
478      specs.push({
479        id: 'journal-classify',
480        title: word(lk, 'lemo-journal.classifyTitle', S.classify.title),
481        desc: S.classify.desc,
482        buttons: onOff('journal-classify-switch', isClassify, 'classify'),
483        extra: recentTags === '' ? null : <Text color={lk.c.pencil}>{S.classify.recent + recentTags}</Text>,
484      })
485    }
486
487    if (at === 'behave') {
488      // 读 lemo-core 的 lastTurn 会订阅:每轮结束它一改,这张卡片自动重画
489      const last = (await $.state.get(LAST)).value ?? null
490      specs.push({
491        id: 'journal-stats',
492        title: word(lk, 'lemo-journal.statsTitle', S.stats.title),
493        desc: S.stats.desc,
494        extra:
495          last === null ? (
496            <Text dimColor>{S.stats.none}</Text>
497          ) : (
498            <Text {...inkOf(lk, e.surface)}>{one(fill(S.stats.last, { steps: last.steps, tools: last.tools, t: fmtDur(last.ms) }))}</Text>
499          ),
500      })
501    }
502
503    return hubWrap(el, lk, e.surface, inner, specs)
504  })
505}
506
hooks/i18n.ts 85 lines
1// lemo-journal 自己的文字(中性的默认文字;带风格味道的放在风格包 words/lemo-journal.ts,按同样的键覆盖)。
2// 占位符 {steps}、{t} 这类由 fill() 替换
3
4import type { Lang } from './shared/lemo'
5
6const zh = {
7  // 自动分类的标签名。存进 lemo-core 的 tags 的是键(question、code、debug、chat),显示时按语言换成这里的名字
8  kinds: { question: '提问', code: '改代码', debug: '排查', chat: '闲聊' } as Readonly<Record<string, string>>,
9  // 日志里一行的「几步几次工具」
10  turnStats: '{steps} 步 · {tools} 次工具',
11  // 不是用户本人引起的一轮,日志里编号那一栏写这个:lemo-watch 的提醒、后台任务通知和助手交回的报告
12  by: { remind: '提醒', bg: '后台', cmd: '命令' },
13  on: '已开 · 点击关闭',
14  off: '打开',
15  log: {
16    title: '日志',
17    desc: '每轮结束向 {path} 记录一行:时间、项目、编号、标签、用时、步数和消息开头',
18    off: '未开启 · 开启后每轮结束记录到 {path}',
19    open: '打开日志文件',
20    empty: '暂无记录',
21  },
22  classify: {
23    title: '自动分类',
24    desc: '给每条消息自动打标签(提问 / 改代码 / 排查 / 闲聊),显示在编号后 · 每条额外调用一次小模型',
25    recent: '最近:',
26  },
27  stats: {
28    title: '每轮统计',
29    desc: '上一轮的请求步数、工具次数和用时',
30    last: '上一轮:{steps} 步 · {tools} 次工具 · 用时 {t}',
31    none: '完成一轮后显示',
32  },
33  // 安全页上的两行(lemo.caps)。不带风格味道:安全页要一眼看懂。{path} 是日志文件的位置
34  cap: {
35    log: {
36      title: '日志',
37      desc: '每轮结束向 {path} 记录一行:时间、项目名、编号、用时和你这条消息的前约 50 个字 · 保留最近 500 行,所有会话共用',
38    },
39    classify: {
40      title: '自动分类',
41      desc: '每条消息额外调用一次小模型(通常是 Haiku)打标签 · 发给它的是每条消息的前 2000 个字',
42    },
43  },
44}
45
46export type JournalStrings = typeof zh
47
48const en: JournalStrings = {
49  kinds: { question: 'question', code: 'code', debug: 'debug', chat: 'chat' },
50  turnStats: '{steps} steps · {tools} tools',
51  by: { remind: 'reminder', bg: 'background', cmd: 'command' },
52  on: 'On · click to turn off',
53  off: 'Turn on',
54  log: {
55    title: 'Log',
56    desc: 'Logs one line to {path} after each turn: time, project, number, tag, duration, steps and the start of your message',
57    off: 'Off · when on, logs each turn to {path}',
58    open: 'Open the log file',
59    empty: 'Nothing logged yet',
60  },
61  classify: {
62    title: 'Auto tags',
63    desc: 'Tags each message (question / code / debug / chat), shown after the number · one extra small-model call per message',
64    recent: 'Recent: ',
65  },
66  stats: {
67    title: 'Per-turn stats',
68    desc: 'Request steps, tool calls and time of the last turn',
69    last: 'Last turn: {steps} steps · {tools} tools · {t}',
70    none: 'Shows after the first turn',
71  },
72  cap: {
73    log: {
74      title: 'Log',
75      desc: 'Logs one line to {path} after each turn: time, project name, number, duration and the first ~50 characters of your message · keeps the last 500 lines, shared by all sessions',
76    },
77    classify: {
78      title: 'Auto tags',
79      desc: 'One extra small-model call (usually Haiku) per message to tag it · sends the first 2000 characters of each message',
80    },
81  },
82}
83
84export const STR: Record<Lang, JournalStrings> = { zh, en }
85
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
types/index.d.ts 18 lines
1// lemo-journal 的状态合约。两个开关的值存在 $.store(全局记住,换个会话还在),会话状态里放一份,面板读它,一改自动重画。
2// 装上时两个开关都是关的
3
4/** 日志文件最近几行(面板「日志」卡片显示的) */
5export type LemoJournalTail = readonly string[]
6
7declare module 'claude-code' {
8  interface PluginState {
9    'lemo-journal': {
10      tail: LemoJournalTail
11      /** 每轮结束往 ~/.claude/lemo-mod/journal.md 记一行(默认关) */
12      logOn: boolean
13      /** 给每条消息自动贴标签,每条多调一次小模型(默认关) */
14      classifyOn: boolean
15    }
16  }
17}
18