Outline Sidebar:把 Claude Code 会话变成 Logseq 式可折叠大纲侧边栏,一轮一块,按「结论 / 需要我做的 / 我让它做的 / 它已做的 / 技术信息」过滤;「做了」挂工具调用证据,ToReview 先看证据再验收,侧栏改的状态回传给 Claude

English | 简体中文
In the age of AI, the most precious thing a person has is whole, undivided attention.
Knowledge work has left the farming age for the age of automation: we now drive giant multi-row tractors. Most of the work goes to AI, and what is left for people is review: judging whether it got things right and where to go next. Human attention becomes the bottleneck, so guarding it becomes the key. Attention alone is not enough, though. You also need a good harness to help you review.
More precisely, what you need is attention loaded with the problem: holding the state of the whole problem in your head at once, knowing what counts as right, what has been tried and where things are most likely to break. That state takes twenty or thirty minutes to build and three seconds to destroy, and the cost of rebuilding it is not linear. What really gets shredded is this loaded state, not time.
A long Claude Code session is the easiest place to shred it. The conversation is a stream that only grows downward, with conclusions, decisions waiting on me, work I handed off, finished work and technical details all mixed together. To recover what we just decided or what is still waiting on me, I can only scroll up, and every scroll eats into that loaded state. Without good harness engineering, cognitive debt keeps piling up.
A Claude Code mod that turns a session into a collapsible, Logseq-style outline sidebar. Each turn is a block; going deeper into the same topic makes a child block, and a new topic starts a new top-level block. The AI drafts the hierarchy and you correct it by hand in the sidebar.
Use it in the terminal (the Claude Code CLI). Only the terminal is polished so far. The desktop app and VS Code can open it, but it falls back to plain buttons, and keyboard control, icons and CJK layout have not been tuned there. Desktop support comes later.
It is my take on harness engineering. Most harnesses work on the agent's side: what context it gets, which tools, how its output is verified. This mod works on the human side: how a person sees clearly what the agent did, and how that person's judgment gets back to the agent reliably.

Left: view 3, What I asked it to do. Each request carries my original words, how many items got done, how many have evidence, and the conclusion. Right: view 5, What it has done. Every "done" item has its evidence underneath; items without evidence are marked yellow. The image was generated by the mod's own outline code from a sample session. The UI text is in Chinese for now.
These are drawn from the real outline of the session in which I built this mod, laid out the way the terminal shows them (the turn that was still running is left out, and each image shows only the first screen).
View 2, What I need to do, the recommended main screen: blocks waiting for your review, questions for you and tasks handed to you form one queue, and each item gets its own status. A subtree in which everything is settled folds into one line (the ones with ▸).

View 1, Conclusions: the conclusion reached in each turn, in outline order. Blocks without a conclusion are dimmed and kept only as context.

View 6, Technical info: totals and review metrics for the whole session at the top (once for this session, once across all sessions), then each turn's usage, cache hit rate, duration, model and skills used, plus the tokens the outline spent summarizing. Evidence and blocked counts show "—" in the screenshot because those blocks predate the evidence feature.

In the harness it plays two roles:
Around these two roles there are a few more pieces, so that review looks at evidence rather than an AI-written summary, and so that a review verdict drives the next step:
| Key | Status | How Claude continues |
|---|---|---|
| ⇧R | Rework | Right direction, details need changes: it first restates what to change, starts once you confirm, then hands it back for review |
| ⇧U | Unclear? | It does not touch code yet; it explains, from this turn's edits and command results, what it did, why and how it checked |
| ⇧F | Failed | It looks for the cause in the edits and command results: if the cause is clear it reworks and hands it back for review; if not, it asks you first |
| ⇧S | Suspended | It leaves it alone and notes how far it got and what is missing, so it can be picked up later |
| ⇧C | Cancelled | It stops; if it already edited files it lists them and asks whether to revert |
| ⏎ menu | Doing (sent back) | It says what is still missing and finishes the job |
Change it back to Done within 4 seconds and nothing is sent. Questions Claude asks you ("shall I go on and add it?") work the same way: press Y or N in What I need to do, and 4 seconds after you stop it goes out as your answer and Claude acts on it. For tasks handed to you ("restart the terminal to see the effect"), Y / N is not sent on its own but rides along with your next message. Errored and Hit Limit! are not in this list: the agent side reports them to you, so you never have to tell it. When a turn ends on an API error the mod marks it Errored; if the reply at that point mentions a usage limit (rate limit, usage limit, quota and similar words), it is marked Hit Limit!. This step reads the words, because the engine does not report an error type here.
When you send back several items at once, the message also asks Claude to hand independent ones that do not touch the same files to subagents in parallel, and to do the ones sharing files in order. Subagents show up in the outline as child blocks of that turn.
After an interruption, the fastest way to load your attention back is view 3, What I asked it to do: what I said back then, how many items it finished, how many have evidence, how many are still waiting on me and what the conclusion was, all on one screen.
What it does not do: it does not constrain the agent. Guardrails, permissions, tests and the rest of the control layer are not its job. It mostly looks at a single session; across sessions there is only /agenda, which collects what is still open.
It is tuned for the terminal version of Claude Code: keyboard control, icon fonts and CJK layout are all adjusted for the terminal. The desktop app and VS Code work too, but fall back to plain buttons and are fairly bare; client versions come later.
In the terminal, keyboard shortcuts and Chinese input methods get in each other's way: letter keys like J/K, T and S are grabbed by the IME to compose characters, never reach the sidebar, and just pop up a candidate window. Almost every Chinese user has Pinyin or Squirrel (Rime) on, so a few things are handled specifically:
Keep the sidebar on **view 2, *What I need to do*** (press 2, or open it there directly with /outline me).
In this collaboration the human bottleneck is attention during review, and view 2 is everything waiting on you, lined up as one queue:
You do not need to read the whole conversation stream. Watch this queue and clear it item by item. The Replying and reviewing keys below are designed around this view.
A typical round trip:
D or Y to approve; if not, press ⇧R for rework, ⇧U if it is unclear, ⇧F if it failed (or press B to step from mild to severe). 4 seconds after you stop, the mod sends a message for you, and Claude reworks, explains or asks.Y or N; 4 seconds after you stop it goes out as your answer and Claude acts on it.D (or Y) when done. Nothing is sent on its own; Claude hears about it with your next message.O to reach the "hide Done" step. Coming back after an interruption, look at view 3, What I asked it to do, first to load what you were doing back into your head./outline [view]: open the outline pane, optionally at a given view. Views: conclusions, me, asks, all, done, tech/outline export [view] [path]: export a view to a Markdown file/agenda: everything across all sessions that is waiting on me and not yet done| Key | View | What you see |
|---|---|---|
1 | Conclusions | The conclusion reached in each turn |
2 | What I need to do | Recommended main screen. Blocks waiting for your review, questions for you, tasks handed to you, things stuck and waiting for you; each item can change status on its own |
3 | What I asked it to do | Everything I asked for: my words, how many items got done, how many have evidence, how many still wait on me, the conclusion. Look here first after an interruption |
4 | All | The whole outline |
5 | What it has done | Every "done" item with its evidence underneath |
6 | Technical info | Usage, cache hit rate, duration, model, skills, MCP, subagents; review metrics at the bottom |
The order of the number keys can be changed in the views field of /config.
Click the outline tree once and the keyboard goes to the sidebar. These keys are used most in view 2.
| Key | Action |
|---|---|
| Space | Expand / collapse details to see the evidence under "done". Expanding a block waiting for review counts as having looked at its evidence |
D | Done: finished / review approved. A block waiting for review must have its evidence looked at first; if not, the first press only expands it for you |
Y | Yes: answer "yes". On a block waiting for review it approves, same as D |
N | No: answer "no" |
- | Skip: skip it, neither you nor Claude does it. No message is sent; it counts as settled (in the desktop app use the Enter menu) |
⇧R | Rework: right direction, details need changes. Claude restates what to change first and starts once you confirm |
⇧U | Unclear?: you did not follow what it did. Claude does not touch code yet and explains with the evidence |
⇧F | Failed: it did not work. Claude looks for the cause in the evidence and reworks, or asks you if unsure |
⇧S | Suspended: put it aside. Claude leaves it alone and notes how far it got |
⇧C | Cancelled: drop it. Edited files are listed and Claude asks whether to revert |
B | Cycle through the five above from mild to severe: Rework → Unclear? → Failed → Suspended → Cancelled |
S | Normal flow: Todo → Doing → Done → Todo. On a block waiting for review it approves; to send one back to keep working, pick Doing in the ⏎ menu |
What goes to Claude right away:
⇧ plus a letter only works in the terminal tree (desktop buttons do not tell case apart); in the desktop app use B or the ⏎ menu.
Auto-fold: when everything in a subtree is settled (Done, Cancelled, or answered Yes / No), it folds into one line showing only the top block's title, so what is still open stays in front.
→ or E are not auto-folded again; when O is on "expand all" or ⇧1/2/3 sets a depth, your instruction wins.| Key | Action |
|---|---|
J / K | Move the cursor down / up (↓ / ↑ also work) |
| Scroll wheel | When the outline is taller than the pane, one notch scrolls 3 lines. After scrolling the window stays where you scrolled; moving the cursor pulls it back to the cursor's block. The window is cut by lines; "↑ n lines" and "↓ n lines" tell how many lines lie above and below |
T | On a child block: fold its parent and jump the cursor to it; press again to fold one more level up. On a top-level block: open or close its own children |
E | The reverse of T: expand the children of the cursor's block and jump to the first child; press again to go one level deeper |
← / → | Fold / expand the children of the cursor's block (Ctrl+↑ / Ctrl+↓ also work) |
⇧1 ⇧2 ⇧3 | Expand the whole outline to depth 1 / 2 / 3 and fold anything deeper (⌥1 / ⌥2 / ⌥3 also work if your terminal does not take them) |
O | Cycle levels: top level only → all titles → expand all → hide Done. "Hide Done" hides everything settled (Done, Cancelled, Skip, and anything answered Yes / No), for both blocks and What I need to do items; a hidden block that still has open children is dimmed and kept as context |
Z | Focus: show only the cursor's block and its children; press again to leave |
L / H | Indent / outdent: hang this block under the previous one, or lift it back a level (⌥→ / ⌥← also work). The AI never touches a hierarchy you changed by hand |
| Key | Action |
|---|---|
F | Fold in the transcript: turn this turn's messages in the conversation into a one-line summary; unrelated to folding in the sidebar |
W | Fold the whole transcript: every turn except the latest keeps only a one-line summary; press again to expand all |
P | Refresh progress: for the turn that is running, have the model write one line on how far it got |
R | Recap: generate a recap of this session from the outline |
G | Jump: scroll the conversation to the message this block belongs to |
X | Export: copy the current view as Markdown |
I | Legend: show what every status and field icon means |
⏎ | Open the menu with every action (change status, focus, export and more), usable with just the arrow keys and Enter |
⌘/ or Ctrl+/ | Change status, same as S |
There are 20, following the keywords, colors and icons of the Obsidian plugin todoseq, plus a few that only humans use. Press I to see every icon in the pane.
They are grouped by "whose hands this is in right now". Every status can be picked directly from the ⏎ menu; once you change a block's status by hand, Claude's later summaries do not overwrite it.
On Claude's side, in progress or queued
| Status | Meaning | Who sets it | What happens next |
|---|---|---|---|
| Todo | Decided, not started yet | The summarizing model; you, by cycling with S | S turns it into Doing |
| Todo4AI | Left for the AI to do later | The summarizing model | — |
| Doing | Not finished, continues next turn. The turn that is running is also Doing, with a spinning icon | The summarizing model; the mod at the start of a turn | S turns it into Done. Picking Doing in the ⏎ menu on a block waiting for review sends it back to Claude to keep working |
| Queued | In the queue, will resume on schedule | The summarizing model | — |
| Delegated | Running in a subagent or another agent | The summarizing model | — |
| Paused | Stopped halfway, interrupted, to be resumed later | The summarizing model; the mod as a fallback when you interrupt a turn | — |
**Waiting on you (shows up in view 2, What I need to do)**
| Status | Meaning | Who sets it | What happens next |
|---|---|---|---|
| ToReview | Finished, waiting for your review | The summarizing model; What I need to do items Claude says it did for you also become this automatically, marked "[自动]" | D, Y or S approves, after you have looked at the evidence; if not approved, use the statuses under Your judgment below |
| NeedOK? | Stopped, waiting for your approval, confirmation or choice | The summarizing model | Answer with Y / N, sent to Claude right away |
| Waiting | Waiting on you or on something external | The summarizing model | Once every What I need to do item inside is settled (you mark the last one by hand, or later conversation settles it as you said), the block turns Done on its own |
| Errored | Stopped on an error, needs you | The mod: set as soon as a turn ends on an API error, without waiting for the model | After you deal with it, change it with S or D |
Stuck on the agent's side
| Status | Meaning | Who sets it | What happens next |
|---|---|---|---|
| Hit Limit! | Hit a quota or usage limit, waiting for the reset | The mod: set when a turn ends on an API error and the reply mentions a usage limit (it reads the words; the engine gives no error type) | Continue once the quota is back |
Your judgment (review, answers, skip)
| Status | Meaning | How to set it | What happens next |
|---|---|---|---|
| Rework | Right direction, details need changes | ⇧R, or cycle with B | Human-only. 4 seconds after you stop it goes to Claude, which first restates what to change and starts once you confirm |
| Unclear? | You did not follow what it did | ⇧U, or cycle with B | Human-only. Goes to Claude: do not touch code yet, explain with the evidence |
| Yes | Answer "yes", agree | Y | Human-only. A question to you is sent to Claude right away and it acts on it; a task handed to you rides along
hooks/register.tsx 1860 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register } from 'claude-code'
3
4import type { AgendaGroup, Block, Body, Evidence, Kind, Level, Metrics, Recap, Resolution, Status, StatusChange, Tech, View } from '../types'
5import type { Field, Line, MenuItem, TreeProps } from './outline'
6import { PH, PH_SVG } from './icons'
7import type { PhosphorIcon } from './icons'
8import { FIRE_MS, statusSvg } from './status'
9import {
10 DEFAULT_KEYS,
11 KIND_BADGE,
12 STATUSES,
13 STATUS_INFO,
14 glyph,
15 hasOpenForMe,
16 itemStatus,
17 lineText,
18 applyResolutions,
19 isForMe,
20 isMark,
21 nextBadStatus,
22 nextStatus,
23 parseKey,
24 FIELD,
25 ITEM_MARK,
26 normalizeStatus,
27 statusText,
28 VIEW_INFO,
29 ancestorsOf,
30 blockLines,
31 childrenOf,
32 clip,
33 estimateWrappedRows,
34 compactSentText,
35 isOpenStatus,
36 matches,
37 parseKeys,
38 sumTech,
39 techLines,
40 type AutoFold,
41 addMetrics,
42 measure,
43 metricsLines,
44 textRows,
45 toMarkdown,
46 toTreeRows,
47 viewFromArg,
48 visibleRows,
49 waitingCount,
50} from './outline'
51
52const PANE = 'outline'
53const TITLE = '大纲'
54const AGENDA = 'agenda'
55const STORE_PREFIX = 'session:'
56const STORE_KEEP = 100
57const TREE = 'tree'
58
59const blocks = atom({ plugin: 'outline-sidebar', key: 'blocks' } as const, [])
60const folded = atom({ plugin: 'outline-sidebar', key: 'folded' } as const, [])
61const unfolded = atom({ plugin: 'outline-sidebar', key: 'unfolded' } as const, [])
62const lingering = atom({ plugin: 'outline-sidebar', key: 'lingering' } as const, {})
63const wheel = atom({ plugin: 'outline-sidebar', key: 'wheel' } as const, 0)
64const open = atom({ plugin: 'outline-sidebar', key: 'open' } as const, [])
65const view = atom({ plugin: 'outline-sidebar', key: 'view' } as const, 'all')
66const level = atom({ plugin: 'outline-sidebar', key: 'level' } as const, 'contents')
67const cursor = atom({ plugin: 'outline-sidebar', key: 'cursor' } as const, null)
68const zoom = atom({ plugin: 'outline-sidebar', key: 'zoom' } as const, null)
69const corrections = atom({ plugin: 'outline-sidebar', key: 'corrections' } as const, 0)
70const bodyFolded = atom({ plugin: 'outline-sidebar', key: 'bodyFolded' } as const, [])
71const bodyFoldAll = atom({ plugin: 'outline-sidebar', key: 'bodyFoldAll' } as const, false)
72const body = atom({ plugin: 'outline-sidebar', key: 'body' } as const, { labels: {}, hide: [] })
73const agenda = atom({ plugin: 'outline-sidebar', key: 'agenda' } as const, [])
74const recap = atom({ plugin: 'outline-sidebar', key: 'recap' } as const, null)
75const legend = atom({ plugin: 'outline-sidebar', key: 'legend' } as const, false)
76const changes = atom({ plugin: 'outline-sidebar', key: 'changes' } as const, [])
77const scope = atom({ plugin: 'outline-sidebar', key: 'scope' } as const, 'tabs')
78const metricsAll = atom({ plugin: 'outline-sidebar', key: 'metricsAll' } as const, null)
79const sessionTitle = atom({ plugin: 'outline-sidebar', key: 'sessionTitle' } as const, null)
80
81const LEVELS: Level[] = ['overview', 'contents', 'all', 'undone']
82const LEVEL_LABEL: Record<Level, string> = { overview: '只看顶层', contents: '全部标题', all: '全部展开', undone: '去掉 Done' }
83
84/** 层级「去掉 Done」:Done 的块不显示 */
85async function isHidingDone($: EngineInterface) {
86 return (await read($, level)) === 'undone'
87}
88
89/** 工具栏:文字按钮(中文输入法下用不了字母快捷键时,点按钮或在树里按回车用菜单) */
90const ACTIONS: { key: string; hotkey: string; label: string }[] = [
91 { key: 'down', hotkey: 'j', label: '下' },
92 { key: 'up', hotkey: 'k', label: '上' },
93 { key: 'fold-up', hotkey: 't', label: '折叠到母块' },
94 { key: 'unfold-down', hotkey: 'e', label: '展开子块' },
95 { key: 'done', hotkey: 'd', label: 'Done' },
96 { key: 'yes', hotkey: 'y', label: 'Yes' },
97 { key: 'no', hotkey: 'n', label: 'No' },
98 { key: 'indent', hotkey: 'l', label: '缩进' },
99 { key: 'outdent', hotkey: 'h', label: '反缩进' },
100 { key: 'status', hotkey: 's', label: '切状态' },
101 { key: 'status-bad', hotkey: 'b', label: '负面状态' },
102 { key: 'level', hotkey: 'o', label: '层级' },
103 { key: 'zoom', hotkey: 'z', label: '聚焦' },
104 { key: 'body', hotkey: 'f', label: '正文折叠' },
105 { key: 'body-all', hotkey: 'w', label: '正文全折' },
106 { key: 'progress', hotkey: 'p', label: '刷新进度' },
107 { key: 'recap', hotkey: 'r', label: '回顾' },
108 { key: 'jump', hotkey: 'g', label: '跳转' },
109 { key: 'export', hotkey: 'x', label: '导出' },
110 { key: 'legend', hotkey: 'i', label: '图例' },
111]
112
113/** 视图名,「需要我做的」后面带还剩几件 */
114const viewLabel = (one: View, waiting: number) => `${VIEW_INFO[one].label}${one === 'me' && waiting > 0 ? ` ${waiting}` : ''}`
115
116/** 聚焦时「聚焦」变「退出聚焦」,正文全折时「正文全折」变「正文全开」 */
117const actionLabel = (a: (typeof ACTIONS)[number], isZoomed: boolean, isBodyAll: boolean) =>
118 a.key === 'zoom' && isZoomed ? '退出聚焦' : a.key === 'body-all' && isBodyAll ? '正文全开' : a.label
119
120/** 图例里列出的字段 */
121const LEGEND_FIELDS = ['needsMe', 'did', 'proof', 'files', 'conclusion', 'question', 'resolved', 'ask', 'live', 'last', 'note', 'stats', 'tokens', 'cache', 'time', 'model', 'skills', 'mcp', 'slash', 'agents', 'outline', 'coverage', 'review', 'reopen', 'fix'] as const
122
123/** 图例占几行(终端),用来算树视图还剩多少高度 */
124const legendRows = (width: number) =>
125 estimateWrappedRows(STATUSES.map(st => statusText(st)), width) + estimateWrappedRows(LEGEND_FIELDS.map(f => `${glyph(f)} ${FIELD[f].label}`), width)
126
127// ---------- 桌面版的图标:SVG ----------
128
129/** Phosphor 图标的 SVG,颜色跟文字 */
130const phosphorIconSvg = (icon: PhosphorIcon, color: string) => {
131 const { path, size } = PH_SVG[icon]
132 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}"><path fill="${color}" d="${path}"/></svg>`
133}
134
135const phosphorSvg = (field: Field, color: string) => {
136 const icon = FIELD[field].icon
137 return isMark(icon) ? undefined : phosphorIconSvg(icon, color)
138}
139
140const ICON_PX = 14
141
142type DesktopEls = Elements['desktop']
143
144/** 桌面版一行:每段「图标 字」 */
145function desktopLine(els: DesktopEls, line: Line) {
146 const { Box, Text, Svg } = els
147 const props = { line }
148 const color = line.color === undefined ? '#8a8f98' : line.color === 'claude' ? '#c8635f' : line.color === 'warning' ? '#b39a3e' : line.color
149 return (
150 <Box flexDirection="row" columnGap={1} flexWrap="wrap">
151 {props.line.parts.map(part => {
152 const svg = phosphorSvg(part.field, color)
153 return (
154 <Box flexDirection="row" columnGap={1}>
155 {svg === undefined ? <Text>{glyph(part.field)}</Text> : <Svg source={svg} alt={FIELD[part.field].label} width={ICON_PX} height={ICON_PX} />}
156 {part.text !== '' && <Text color={props.line.color} dimColor={props.line.color === undefined}>{part.text}</Text>}
157 </Box>
158 )
159 })}
160 </Box>
161 )
162}
163
164/** 桌面版的状态:todoseq 的 SVG(Doing 一直转,刚换上的状态播一次出场动效)+ 关键词 */
165function desktopStatus(els: DesktopEls, status: Status, isFire: boolean, isContext = false) {
166 const { Box, Text, Svg } = els
167 const props = { status, isFire, isContext }
168 const info = STATUS_INFO[props.status]
169 const isAnimated = props.status === 'DOING' || props.isFire
170 return (
171 <Box flexDirection="row" columnGap={1}>
172 <Svg source={statusSvg(props.status, props.isFire)} alt={info.label} width={ICON_PX} height={ICON_PX} {...(isAnimated ? { isInteractive: true } : {})} />
173 <Text color={info.color} bold={isOpenStatus(props.status)} dimColor={props.isContext === true}>{info.label}</Text>
174 </Box>
175 )
176}
177
178
179// ---------- 一轮里采集到的东西 ----------
180
181/** 子任务、fork、/btw:挂在它所在那一轮下面 */
182type Branch = { kind: Kind; title: string; ask: string; conclusion: string; status: Status; tokens: number }
183
184type Turn = {
185 turnId: string
186 text: string
187 messageId?: string
188 rowIds: string[]
189 files: Set<string>
190 commands: string[]
191 slash: string[]
192 skills: string[]
193 mcp: string[]
194 branches: Branch[]
195 agentTokens: number
196 /** 一开始就建好的「进行中」块 */
197 blockId: string
198 startedAt: number
199 /** 每一步做了什么,手动刷新时交给 haiku */
200 steps: string[]
201 /** Claude Code 到目前为止写出的文字 */
202 texts: string[]
203 /** 改了的文件、跑了的命令和结果,给「做了」挂证据 */
204 evidence: Evidence[]
205}
206
207const usageTotal = (u: { input_tokens: number; output_tokens: number; cache_read_input_tokens?: number | null; cache_creation_input_tokens?: number | null } | undefined) =>
208 u === undefined ? 0 : u.input_tokens + u.output_tokens + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
209
210const emptyBlock = (id: string, kind: Kind, turn: number, parent: string | null): Block => ({
211 id,
212 kind,
213 turn,
214 ask: '',
215 title: '',
216 status: 'DONE',
217 did: [],
218 conclusion: '',
219 questions: [],
220 needsMe: [],
221 parent,
222 isManual: false,
223 isStatusManual: false,
224 isPending: false,
225 files: [],
226 commands: 0,
227})
228
229const branchBlock = (br: Branch, id: string, turn: number, parent: string | null): Block => ({
230 ...emptyBlock(id, br.kind, turn, parent),
231 ask: br.ask,
232 title: br.title,
233 status: br.status,
234 conclusion: br.conclusion,
235})
236
237// ---------- 跨事件的采集状态(重载后重新开始) ----------
238
239let cwd = ''
240let current: Turn | undefined
241let lastPromptId: string | undefined
242/** 子代理是在哪一轮里切出去的 */
243const agentOwner = new Map<string, string>()
244/** 一轮结束后对应的块 id */
245const turnBlock = new Map<string, string>()
246let lastTurnId: string | undefined
247/** 敲了还没归到哪一轮的斜杠命令 */
248let pendingSlash: string[] = []
249
250// ---------- 保存,--resume 后恢复 ----------
251
252type Saved = { blocks?: Block[]; cwd?: string; savedAt?: number }
253
254async function persist($: EngineInterface) {
255 const sid = await $.session.id()
256 await $.store.set(`${STORE_PREFIX}${sid}`, { blocks: await read($, blocks), cwd, savedAt: await $.clock.now() })
257 const all = await $.store.keys()
258 const keys = all.filter(k => k.startsWith(STORE_PREFIX))
259 if (keys.length <= STORE_KEEP) return
260 // 超了才删,按最后保存的时间删最久没动过的:键的顺序是第一次写入的顺序,一直在用的老会话也排在前面
261 const dated = await Promise.all(
262 keys.map(async key => {
263 const stored = await $.store.get(key)
264 return { key, at: (stored as Saved | undefined)?.savedAt ?? 0 }
265 }),
266 )
267 dated.sort((a, b) => a.at - b.at)
268 for (const old of dated.slice(0, dated.length - STORE_KEEP)) await $.store.delete(old.key)
269}
270
271async function setBlocks($: EngineInterface, change: (list: Block[]) => Block[]) {
272 await update($, blocks, list => change(list ?? []))
273 await persist($)
274 await refreshBody($)
275}
276
277async function restore($: EngineInterface) {
278 const sid = await $.session.id()
279 const stored = await $.store.get(`${STORE_PREFIX}${sid}`)
280 const list = (stored as Saved | undefined)?.blocks
281 if ((await read($, blocks)).length === 0 && Array.isArray(list) && list.length > 0) {
282 // 旧版本存下的块没有 kind,状态也是旧写法
283 await update($, blocks, () => list.map(b => ({ ...b, kind: b.kind ?? 'turn', status: normalizeStatus(String(b.status)) })))
284 }
285 // 当前会话里已有的旧状态(CANCELED、DOING 以外的旧词)也换成新写法
286 const existing = await read($, blocks)
287 if (existing.some(b => !(STATUSES as string[]).includes(b.status))) {
288 await update($, blocks, all => (all ?? []).map(b => ({ ...b, status: normalizeStatus(String(b.status)) })))
289 }
290}
291
292// ---------- 正文折叠 ----------
293
294/** 按折叠设置算出:哪些消息改画成一行摘要,哪些行藏起来 */
295async function refreshBody($: EngineInterface) {
296 const list = await read($, blocks)
297 const isAll = await read($, bodyFoldAll)
298 const toggled = await read($, bodyFolded)
299 const turns = list.filter(b => b.kind === 'turn')
300 const last = turns.at(-1)?.id
301 const computed: Body = { labels: {}, hide: [] }
302 for (const b of turns) {
303 if (b.messageId === undefined || b.isPending) continue
304 // 全折模式下,最新一轮默认不折,toggled 里的是例外
305 const isFolded = isAll ? b.id !== last !== toggled.includes(b.id) : toggled.includes(b.id)
306 if (!isFolded) continue
307 const open = b.needsMe.filter((_, i) => isOpenStatus(itemStatus(b, i)))
308 const waiting = open.length > 0 ? ` 需要我做:${open.join(';')}` : ''
309 computed.labels[b.messageId] = `▸ ${STATUS_INFO[b.status].label} ${b.title}${waiting}`
310 computed.hide.push(...(b.rowIds ?? []))
311 }
312 await update($, body, () => computed)
313}
314
315async function toggleBodyFold($: EngineInterface) {
316 const id = await cursorBlockId($)
317 const b = (await read($, blocks)).find(one => one.id === id)
318 // 子任务和 btw 跟着它所在那一轮
319 const turnId = b?.kind === 'turn' ? b.id : b?.parent ?? undefined
320 if (turnId === undefined) return
321 await update($, bodyFolded, ids => toggle(ids, turnId))
322 await refreshBody($)
323}
324
325async function toggleBodyFoldAll($: EngineInterface) {
326 await update($, bodyFoldAll, isOn => !isOn)
327 await update($, bodyFolded, () => [])
328 await refreshBody($)
329}
330
331async function hideRow($: EngineInterface, requestId: string, isExpanded: boolean | undefined) {
332 if (isExpanded === true) return false
333 return (await read($, body)).hide.includes(requestId)
334}
335
336// ---------- 总结 ----------
337
338/** 交给模型的状态说明:关键词 + 什么时候用 */
339const STATUS_LINES = STATUSES.filter(st => STATUS_INFO[st].isVerdict !== true).map(st => `\n ${st}:${STATUS_INFO[st].meaning}`).join('')
340
341const SYSTEM = `你在给一段 Claude Code 会话做大纲。每次给你「现有大纲」和「刚结束的一轮」,你把这一轮变成大纲里的一个块。
342只输出一个 JSON 对象,不要任何别的文字,不要代码块标记。字段:
343- title:用户要 Claude Code 做什么,一句话,中文,不超过 30 字,动词开头
344- status:从下面选一个最贴切的关键词(照用户在 Obsidian todoseq 里的那一套):${STATUS_LINES}
345- did:Claude Code 这一轮实际做了的事,数组,每条是 { "text": 一短句, "evidence": [证据编号] };evidence 只能填下面「证据」里列出的编号,哪条证据能证明这件事做了就填哪条,没有能证明它的证据就填空数组,不许编;没做事就空数组
346- conclusion:这一轮定下来的结论,一句话,没有就空字符串
347- questions:还没解决的问题,没有就空数组
348- needsMe:需要用户做的事(确认、选择、手动操作),没有就空数组
349- asks:needsMe 里哪几条是在问用户、要用户回答是或否或做选择才能往下走的(比如「要我接着加吗」「仓库保持公开吗」),填它们的下标数组(从 0 开始);交给用户自己去做的事(比如「重开终端看效果」)不算,没有就空数组
350- parent:如果这一轮是顺着现有大纲里某个块继续深入的同一个功能或话题,填那个块的 id;换了新话题或新功能,填 null
351- resolved:上面「待用户处理」里被这一轮了结的,数组,每一项是 { "key": 列表里方括号中的编号(比如 b4#0 或 b5), "by": "user" 或 "claude" }。用户在这一轮的原话里说已经做完、给了答复或做了决定,by 填 user;Claude Code 说它替用户做了、或者判断不需要用户做了,by 填 claude。按条判断,没了结的别填,拿不准就不填;没有就空数组
352用户在一轮进行中的插话只是引导和补充,不单独当成任务;以这一轮从提问到做完的整体为准。`
353
354const describeOutline = (list: readonly Block[]) => {
355 const turns = list.filter(b => b.kind === 'turn')
356 const lines = turns.slice(-40).map(b => `${' '.repeat(ancestorsOf(list, b).length)}- [${b.id}] ${b.status} ${b.title}`)
357 // 按条列:每一条还没了结的「需要我做」单独一行;没有条目、只是在等我的块(待验收的除外)列块本身
358 const pending = turns.flatMap(b => {
359 const open = b.needsMe.flatMap((text, i) => (isOpenStatus(itemStatus(b, i)) ? [`- [${b.id}#${i}] ${text}(块 [${b.id}] ${b.title})`] : []))
360 if (b.needsMe.length > 0) return open
361 return isForMe(b.status) && b.status !== 'TOREVIEW' ? [`- [${b.id}] ${b.title}(等用户确认)`] : []
362 })
363 return `现有大纲:\n${lines.join('\n') || '(空)'}\n\n待用户处理的块:\n${pending.join('\n') || '(无)'}`
364}
365
366const parseJson = (text: string) => {
367 const start = text.indexOf('{')
368 const end = text.lastIndexOf('}')
369 if (start < 0 || end <= start) return undefined
370 try {
371 return JSON.parse(text.slice(start, end + 1)) as Record<string, unknown>
372 } catch {
373 return undefined
374 }
375}
376
377/** 模型给的 resolved:{ key, by } 的数组;只给了编号字符串(旧写法)的,当成 Claude 说的,宁可让我再看一眼 */
378const parseResolutions = (value: unknown): Resolution[] =>
379 (Array.isArray(value) ? value : []).flatMap(one => {
380 if (typeof one === 'string' && one !== '') return [{ key: one, by: 'claude' as const }]
381 const item = one as { key?: unknown; by?: unknown }
382 return typeof item?.key === 'string' && item.key !== '' ? [{ key: item.key, by: item.by === 'user' ? ('user' as const) : ('claude' as const) }] : []
383 })
384
385const strings = (value: unknown) =>
386 Array.isArray(value) ? value.filter((one): one is string => typeof one === 'string' && one !== '') : []
387
388/** 证据最多记这么多条,交给模型的也是这些 */
389const MAX_EVIDENCE = 30
390
391/** 工具跑完记一条证据:改文件按文件去重,跑命令记成没成、输出的最后一行 */
392function addEvidence(turn: Turn, tool: string, input: Record<string, unknown>, isFailed: boolean, output: string | undefined) {
393 if (turn.evidence.length >= MAX_EVIDENCE) return
394 if ((tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') && typeof input.file_path === 'string') {
395 const text = base(input.file_path)
396 const seen = turn.evidence.find(one => one.kind === 'edit' && one.text === text)
397 if (seen === undefined) turn.evidence.push({ kind: 'edit', text, isOk: !isFailed })
398 else if (!isFailed) seen.isOk = true
399 }
400 if (tool === 'Bash' && typeof input.command === 'string') {
401 const tail = (output ?? '').split('\n').map(line => line.trim()).filter(line => line !== '').at(-1)
402 turn.evidence.push({ kind: 'run', text: clip(input.command.split('\n')[0] ?? '', 80), isOk: !isFailed, ...(tail === undefined ? {} : { tail: clip(tail, 60) }) })
403 }
404}
405
406const evidenceText = (e: Evidence) => (e.kind === 'edit' ? `改了 ${e.text}${e.isOk ? '' : '(失败)'}` : `跑了 ${e.text}(${e.isOk ? '成功' : '报错'}${e.tail === undefined ? '' : `:${e.tail}`})`)
407
408/** 模型给的 did:新格式是 { text, evidence: [编号] },旧格式是字符串;编号从 1 开始,对不上的丢掉 */
409const parseDid = (value: unknown, count: number) => {
410 const did: string[] = []
411 const proofs: number[][] = []
412 for (const one of Array.isArray(value) ? value : []) {
413 const item = typeof one === 'string' ? { text: one, evidence: [] } : (one as { text?: unknown; evidence?: unknown })
414 if (typeof item.text !== 'string' || item.text === '') continue
415 did.push(item.text)
416 const nums = Array.isArray(item.evidence) ? item.evidence.filter((n): n is number => Number.isInteger(n) && n >= 1 && n <= count) : []
417 proofs.push([...new Set(nums)].map(n => n - 1))
418 }
419 return { did, proofs }
420}
421
422/** 一轮因 API 出错结束时,回答里出现这些字就当是撞上了用量上限(引擎在这里不给错误种类,只能看字) */
423const LIMIT_WORDS = /rate.?limit|usage limit|quota|429|额度|上限|限额/i
424
425/**
426 * Errored、Hit Limit! 是 Agent 那边的事,不用我告诉它:这一轮因 API 出错结束,mod 直接标,不等模型判断
427 */
428const errorStatus = (answer: string): Status => (LIMIT_WORDS.test(answer) ? 'HITLIMIT' : 'ERRORED')
429
430async function summarize($: EngineInterface, id: string, turn: Turn, answer: string, isAborted: boolean, isErrored = false) {
431 const before = (await read($, blocks)).filter(b => b.id !== id && !b.isPending && b.live === undefined)
432 const prompt = [
433 describeOutline(before),
434 '',
435 '刚结束的一轮:',
436 `用户原话:${clip(turn.text, 2000)}`,
437 `Claude Code 的回答:${clip(answer, 4000) || '(无文字回答)'}`,
438 `证据(编号从 1 开始):\n${turn.evidence.map((one, i) => `[${i + 1}] ${evidenceText(one)}`).join('\n') || '(无)'}`,
439 `切出去的子任务:${turn.branches.map(br => `${br.title}(${clip(br.conclusion, 200)})`).join(';') || '(无)'}`,
440 isAborted ? '这一轮被用户中断了。' : '',
441 ].join('\n')
442
443 const reply = await $.model.complete({ model: 'haiku', system: SYSTEM, prompt, maxTokens: 1024, timeoutMs: 60000 })
444 const data = reply.isAnswered ? parseJson(reply.text) : undefined
445 const spent = usageTotal(reply.usage)
446 const now = await $.clock.now()
447 const didParsed = parseDid(data?.did, turn.evidence.length)
448
449 await setBlocks($, all => {
450 const turnIds = new Set(all.filter(b => b.kind === 'turn').map(b => b.id))
451 const status = String(data?.status ?? '')
452 const parent = typeof data?.parent === 'string' && turnIds.has(data.parent) && data.parent !== id ? data.parent : null
453 const resolved = parseResolutions(data?.resolved)
454 const self = all.find(b => b.id === id)
455 return all.map(b => {
456 if (b.id === id) {
457 return {
458 ...b,
459 isPending: false,
460 // 模型没给标题、我又只贴了内容没打字时,拿回答的第一行顶上
461 title: typeof data?.title === 'string' && data.title !== '' ? data.title : b.title !== '' ? b.title : clip(answer.trim().split('\n')[0] ?? '', 30),
462 // 验收用的状态(Rework、Unclear?)只归我用,模型给了也不认
463 status: isErrored
464 ? errorStatus(answer)
465 : (STATUSES as string[]).includes(status) && STATUS_INFO[status as Status].isVerdict !== true ? (status as Status) : isAborted ? 'PAUSED' : 'DONE',
466 statusAt: now,
467 needsMeStatus: undefined,
468 did: didParsed.did,
469 evidence: turn.evidence,
470 didEvidence: didParsed.proofs,
471 conclusion: typeof data?.conclusion === 'string' ? data.conclusion : '',
472 questions: strings(data?.questions),
473 needsMe: strings(data?.needsMe),
474 // 模型没给 asks 就留空,按字面猜
475 needsMeAsks: Array.isArray(data?.asks) ? data.asks.filter((n): n is number => Number.isInteger(n) && n >= 0) : undefined,
476 parent: b.isManual ? b.parent : parent,
477 tech: b.tech === undefined ? undefined : { ...b.tech, outlineTokens: b.tech.outlineTokens + spent },
478 }
479 }
480 // 被这一轮了结的等我的事:按条改,我说的 Done,Claude 说替我办了的 ToReview
481 return applyResolutions(b, resolved, self?.turn, now)
482 })
483 })
484 if (!reply.isAnswered) $.ui.log(`outline-sidebar:这一轮没总结成(${reply.reason}),先用原话当标题`, { to: 'debug' })
485}
486
487/** 把子任务或 /btw 挂到它所在那一轮下面;那一轮还在跑就先攒着 */
488async function addBranch($: EngineInterface, br: Branch, ownerTurn: string | undefined) {
489 if (current !== undefined && (ownerTurn === undefined || ownerTurn === current.turnId)) {
490 current.branches.push(br)
491 return
492 }
493 const all = await read($, blocks)
494 const parentId = (ownerTurn === undefined ? undefined : turnBlock.get(ownerTurn)) ?? all.filter(b => b.kind === 'turn').at(-1)?.id ?? null
495 const parent = all.find(b => b.id === parentId)
496 const id = `${parentId ?? 'x'}.${all.filter(b => b.parent === parentId).length + 1}`
497 await setBlocks($, list => [...list, branchBlock(br, id, parent?.turn ?? 0, parentId)])
498}
499
500// ---------- 面板里的操作 ----------
501
502const toggle = (list: readonly string[] | undefined, id: string) =>
503 (list ?? []).includes(id) ? (list ?? []).filter(one => one !== id) : [...(list ?? []), id]
504
505async function currentRows($: EngineInterface) {
506 return visibleRows(await read($, blocks), await read($, view), await read($, folded), await read($, zoom), await read($, open), await isHidingDone($), await autoFold($))
507}
508
509// ---------- 自动折叠 ----------
510
511/** 光标离开一棵已了结的子树后,等这么久再自动折,给个缓冲 */
512const FOLD_DELAY = 3000
513
514/** 自动折叠用的:我手动打开过的块,和先别折的块(光标所在的块和它的祖先,加上光标刚离开、还在缓冲期的) */
515async function autoFold($: EngineInterface): Promise<AutoFold> {
516 const all = await read($, blocks)
517 const key = await read($, cursor)
518 const now = await $.clock.now()
519 const hold = Object.entries(await read($, lingering)).filter(([, until]) => until > now).map(([id]) => id)
520 const at = key === null ? undefined : all.find(b => b.id === parseKey(key).blockId)
521 if (at !== undefined) hold.push(at.id, ...ancestorsOf(all, at).map(b => b.id))
522 return { unfolded: await read($, unfolded), hold }
523}
524
525/** 挪光标:刚离开的那一块和它的祖先先缓冲一会儿,到点再重画,该折的才折起来 */
526async function setCursor($: EngineInterface, key: string | null) {
527 const before = await read($, cursor)
528 await update($, cursor, () => key)
529 if (before === null || before === key) return
530 const all = await read($, blocks)
531 const left = all.find(b => b.id === parseKey(before).blockId)
532 if (left === undefined) return
533 const now = await $.clock.now()
534 const until = now + FOLD_DELAY
535 await update($, lingering, map => ({ ...map, ...Object.fromEntries([left, ...ancestorsOf(all, left)].map(b => [b.id, until])) }))
536 $.clock.after(FOLD_DELAY, () => void expireLingering($))
537}
538
539async function expireLingering($: EngineInterface) {
540 const now = await $.clock.now()
541 await update($, lingering, map => Object.fromEntries(Object.entries(map ?? {}).filter(([, until]) => until > now)))
542 await keepCursorVisible($)
543}
544
545/** 折起或打开一块。打开的记进 unfolded:整棵都了结的块被我打开过,就不再自动折 */
546async function setFold($: EngineInterface, id: string, isFold: boolean) {
547 await update($, folded, ids => (isFold ? ((ids ?? []).includes(id) ? (ids ?? []) : [...(ids ?? []), id]) : (ids ?? []).filter(one => one !== id)))
548 await update($, unfolded, ids => (isFold ? (ids ?? []).filter(one => one !== id) : (ids ?? []).includes(id) ? (ids ?? []) : [...(ids ?? []), id]))
549}
550
551/** 光标可能停在「需要我做」的某一条上;块一级的操作(折叠、缩进、聚焦…)作用在它所在的块 */
552async function cursorBlockId($: EngineInterface) {
553 const key = await read($, cursor)
554 return key === null ? null : parseKey(key).blockId
555}
556
557async function moveCursor($: EngineInterface, step: number) {
558 const rows = await currentRows($)
559 if (rows.length === 0) return
560 const id = await read($, cursor)
561 const at = rows.findIndex(r => r.key === id)
562 const target = at < 0 ? (step > 0 ? 0 : rows.length - 1) : Math.min(rows.length - 1, Math.max(0, at + step))
563 await setCursor($, rows[target]!.key)
564}
565
566/** 换了视图,光标要是不在这个视图的行里,就放到第一行 */
567async function keepCursorInView($: EngineInterface) {
568 const rows = await currentRows($)
569 const key = await read($, cursor)
570 if (rows.some(r => r.key === key)) return
571 const first = rows.find(r => !r.isContext) ?? rows[0]
572 await setCursor($, first?.key ?? null)
573}
574
575async function switchView($: EngineInterface, v: View) {
576 await update($, view, () => v)
577 await keepCursorInView($)
578 if (v === 'tech') await measureAll($)
579}
580
581/** 所有会话的验收指标:当前会话加上各个会话存下的大纲 */
582async function measureAll($: EngineInterface) {
583 const sid = await $.session.id()
584 let total: Metrics = measure(await read($, blocks))
585 for (const key of await $.store.keys()) {
586 if (!key.startsWith(STORE_PREFIX) || key === `${STORE_PREFIX}${sid}`) continue
587 const stored = await $.store.get(key)
588 const saved = stored as Saved | undefined
589 if (Array.isArray(saved?.blocks)) total = addMetrics(total, measure(saved.blocks))
590 }
591 await update($, metricsAll, () => total)
592}
593
594/** 视图栏上 ←→:在数字键的顺序里挪一格 */
595async function stepView($: EngineInterface, keys: readonly View[], step: number) {
596 const at = keys.indexOf(await read($, view))
597 await switchView($, keys[(at + step + keys.length) % keys.length]!)
598}
599
600async function enterView($: EngineInterface) {
601 await update($, scope, () => 'tree')
602 await keepCursorInView($)
603}
604
605/** 点树之前用的输入法;回对话框敲第一个键时切回去 */
606let restoreInput: string | undefined
607
608/** 这次加载里有没有试过编译 bin/switch-input */
609let isBuildTried = false
610
611/** 面板上一次画的时候有没有键盘,用来只在刚拿到键盘那一下切输入法 */
612let isPaneFocused = false
613
614/** 终端里大纲树(Client)要的数据:画面板的钩子算好放这里,外层的终端钩子拿去画树 */
615let paneTree: TreeProps | undefined
616
617/**
618 * 点进大纲树时切到英文键盘:中文输入法开着,J/K、T 这些字母会被拿去组字。
619 * 用 tools/ascii-input.swift(第一次用时编译到 bin/),只在 macOS 上,失败了也不打扰。
620 * 命令都写成固定文本、在插件目录里跑,官方目录才认得出这个 mod 只跑这几个程序
621 */
622async function asciiInput($: EngineInterface) {
623 if ((await switchToAscii($)) || isBuildTried) return
624 // 编译最多试一次:编不出来、或者编出来也跑不了,就别每点一下都再编
625 isBuildTried = true
626 try {
627 await $.process.run(['/bin/mkdir', '-p', 'bin'], { cwd: $.plugin.root })
628 const built = await $.process.run(['/usr/bin/swiftc', '-O', 'tools/ascii-input.swift', '-o', 'bin/switch-input'], { cwd: $.plugin.root, timeoutMs: 120000 })
629 if (built.exitCode === 0) await switchToAscii($)
630 } catch {
631 // 不是 macOS,或者没装 Xcode 命令行工具:就不切了
632 }
633}
634
635/** 切到英文键盘,记下原来的输入法;还没编译过(或者切不了)就返回 false */
636async function switchToAscii($: EngineInterface) {
637 try {
638 const r = await $.process.run(['./bin/switch-input'], { cwd: $.plugin.root, timeoutMs: 3000 })
639 const before = r.stdout.trim()
640 // 原来就是英文就不用切回去;连着点几下,记的还是最早那个
641 if (r.exitCode === 0 && before !== '' && before !== 'com.apple.keylayout.US' && restoreInput === undefined) restoreInput = before
642 return r.exitCode === 0
643 } catch {
644 return false
645 }
646}
647
648async function pick($: EngineInterface, key: string) {
649 await update($, scope, () => 'tree')
650 const { blockId, index } = parseKey(key)
651 // 点块:第二下展开详情。点只挪光标,不改状态,改状态用 S
652 if ((await read($, cursor)) === key) {
653 if (index === undefined) {
654 await update($, open, ids => toggle(ids, blockId))
655 if ((await read($, open)).includes(blockId)) await markReviewed($, blockId)
656 }
657 } else await setCursor($, key)
658}
659
660async function toggleFold($: EngineInterface, force?: boolean) {
661 const id = await cursorBlockId($)
662 if (id === null) return
663 // 看眼下是不是折着(手动折的、自动折的都算),不是只看折叠列表
664 const isFolded = (await currentRows($)).find(r => r.key === id)?.isFolded ?? false
665 const want = force ?? !isFolded
666 if (want !== isFolded) await setFold($, id, want)
667 await keepCursorVisible($)
668}
669
670/**
671 * T:在子块里按,把它所在的母块折起来,光标跳到母块上;再按一次就再往上折一层。
672 * 顶层块没有母块,就折它自己的子块。「需要我做」的一条,母块就是它所在的块
673 */
674async function foldUp($: EngineInterface) {
675 const key = await read($, cursor)
676 if (key === null) return
677 const { blockId, index } = parseKey(key)
678 const all = await read($, blocks)
679 const b = all.find(one => one.id === blockId)
680 if (b === undefined) return
681 // 聚焦时,聚焦的那块就是顶,不再往上
682 const target = index !== undefined ? b.id : b.parent !== null && b.id !== (await read($, zoom)) ? b.parent : null
683 if (target === null) return toggleFold($)
684 await setFold($, target, true)
685 await setCursor($, target)
686}
687
688/** E:和 T 反过来,展开光标所在的块,光标跳到它的第一个子块上;再按就再往下一层 */
689async function unfoldDown($: EngineInterface) {
690 const id = await cursorBlockId($)
691 if (id === null) return
692 await setFold($, id, false)
693 const rows = await currentRows($)
694 const at = rows.findIndex(r => r.key === id)
695 const below = rows[at + 1]
696 if (at >= 0 && below !== undefined && parseKey(below.key).blockId !== id && below.block.parent === id) await setCursor($, below.key)
697}
698
699/** ⌥1 / ⌥2 / ⌥3:整棵大纲展开到第几层,更深的折起来 */
700async function showDepth($: EngineInterface, depth: number) {
701 const all = await read($, blocks)
702 const atDepth = all.filter(b => ancestorsOf(all, b).length === depth - 1 && (childrenOf(all, b.id).length > 0 || b.needsMe.length > 0))
703 await update($, folded, () => atDepth.map(b => b.id))
704 // 指定了层数就照办:这一层以上都打开,自动折叠不再插手
705 await update($, unfolded, () => all.filter(b => ancestorsOf(all, b).length < depth - 1).map(b => b.id))
706 await keepCursorVisible($)
707}
708
709async function toggleDetail($: EngineInterface) {
710 const id = await cursorBlockId($)
711 if (id === null) return
712 await update($, open, ids => toggle(ids, id))
713 if ((await read($, open)).includes(id)) await markReviewed($, id)
714}
715
716/** 展开详情就算看过证据了 */
717async function markReviewed($: EngineInterface, id: string) {
718 const now = await $.clock.now()
719 await setBlocks($, all => all.map(b => (b.id === id && b.status === 'TOREVIEW' ? { ...b, reviewedAt: now } : b)))
720}
721
722/**
723 * 验收关口:ToReview 改成 Done 之前,要先展开详情看过证据。
724 * 没看过就替你展开,这一下不改状态;看完再按一次才算通过
725 */
726async function passesReview($: EngineInterface, b: Block, to: Status) {
727 if (b.status !== 'TOREVIEW' || to !== 'DONE') return true
728 if (b.reviewedAt !== undefined && b.reviewedAt >= (b.statusAt ?? 0)) return true
729 await update($, open, ids => (ids.includes(b.id) ? ids : [...ids, b.id]))
730 await markReviewed($, b.id)
731 return false
732}
733
734async function reparent($: EngineInterface, direction: 'in' | 'out') {
735 const id = await cursorBlockId($)
736 if (id === null) return
737 let isChanged = false
738 await setBlocks($, all => {
739 const b = all.find(one => one.id === id)
740 if (b === undefined) return all
741 let parent = b.parent
742 if (direction === 'in') {
743 // 缩进:挂到上一个兄弟块下面
744 const siblings = childrenOf(all, b.parent)
745 const above = siblings[siblings.findIndex(s => s.id === id) - 1]
746 if (above === undefined) return all
747 parent = above.id
748 } else {
749 // 反缩进:提到父块同级
750 if (b.parent === null) return all
751 parent = all.find(one => one.id === b.parent)?.parent ?? null
752 }
753 isChanged = true
754 return all.map(one => (one.id === id ? { ...one, parent, isManual: true } : one))
755 })
756 if (isChanged) await update($, corrections, n => (n ?? 0) + 1)
757}
758
759/** 改光标所在那一行的状态:块,或者「需要我做」的某一条 */
760async function changeStatus($: EngineInterface, choose: (current: Status) => Status) {
761 const key = await read($, cursor)
762 if (key === null) return
763 const { blockId, index } = parseKey(key)
764 const now = await $.clock.now()
765 const target = (await read($, blocks)).find(b => b.id === blockId)
766 if (index === undefined && target !== undefined && !(await passesReview($, target, choose(target.status)))) return
767 let change: StatusChange | undefined
768 let verdict: Status | undefined
769 await setBlocks($, all =>
770 all.map(b => {
771 if (b.id !== blockId) return b
772 if (index === undefined) {
773 const to = choose(b.status)
774 // 验收通过:告诉 Claude 是看过证据才通过的,没证据的那几条也说出来
775 const bare = b.did.filter((_, i) => b.didEvidence !== undefined && (b.didEvidence[i] ?? []).length === 0)
776 const checked = b.status === 'TOREVIEW' && to === 'DONE' ? `(用户看过证据后验收${bare.length > 0 ? `;其中这几条没有工具调用能证明:${bare.join(';')}` : ''})` : ''
777 change = { key, what: `块 [${b.id}]「${b.title}」${checked}`, from: b.status, to }
778 // 指标:验收放行 / 拦下,Done 之后重新打开(改成 Cancelled 不算返工)
779 const marks: Partial<Block> = {}
780 if (b.status === 'TOREVIEW' && to !== 'TOREVIEW') {
781 if (to === 'DONE') marks.reviewPassed = true
782 else marks.reviewBlocked = true
783 }
784 if (b.status === 'DONE' && to !== 'DONE' && to !== 'CANCELLED') marks.isReopened = true
785 if (b.status === 'TOREVIEW' || verdicts.has(key) || isAnswer(to)) verdict = to
786 return { ...b, ...marks, status: to, isStatusManual: true, statusAt: now, autoResolved: undefined }
787 }
788 const marks = b.needsMe.map((_, i) => b.needsMeStatus?.[i] ?? null)
789 const from = itemStatus(b, index)
790 marks[index] = choose(from)
791 change = { key, what: `需要用户做的「${b.needsMe[index] ?? ''}」(块 [${b.id}]「${b.title}」)`, from, to: marks[index]! }
792 if (verdicts.has(key) || (isAnswer(marks[index]!) && isQuestion(b, index))) verdict = marks[index]!
793 // 我手动改过的,「自动」记号清掉
794 const auto = b.needsMe.map((_, i) => (i === index ? null : (b.needsMeAuto?.[i] ?? null)))
795 const updated = { ...b, needsMeStatus: marks, needsMeAuto: auto }
796 // 最后一条也了结了,在等我的块跟着 Done;待验收的块还得我验收,不跟着变
797 const isAllSettled = b.needsMe.every((_, i) => !isOpenStatus(itemStatus(updated, i)))
798 return isAllSettled && isForMe(b.status) && b.status !== 'TOREVIEW' ? { ...updated, status: 'DONE' as const, statusAt: now } : updated
799 }),
800 )
801 if (change !== undefined) await noteChange($, change)
802 if (verdict !== undefined) noteVerdict($, key, verdict)
803}
804
805// ---------- 验收没通过、回答了是或否:交给 Claude 回应 ----------
806
807/** Yes / No 是我对它问题的回答 */
808const isAnswer = (st: Status) => st === 'YES' || st === 'NO'
809
810/** 旧的条目没有模型标过是不是问题,按字面猜:问号结尾,或者带「是否」「要不要」「要我」 */
811const LOOKS_ASKED = /[??]\s*$|是否|要不要|要我/
812
813/** 「需要我做」第 index 条是不是问我的问题:是的话答 Yes / No 要马上告诉 Claude,交给我做的事就等下一条消息 */
814const isQuestion = (b: Block, index: number) => (b.needsMeAsks === undefined ? LOOKS_ASKED.test(b.needsMe[index] ?? '') : b.needsMeAsks.includes(index))
815
816/** 我给了这个状态,Claude 该怎么接 */
817const VERDICT: Partial<Record<Status, string>> = {
818 YES: '我的回答是「是」,同意。照这个接着做。',
819 NO: '我的回答是「否」,不同意。别照原来的打算做;不清楚我想要什么就先问我。',
820 REWORK: '方向对,细节要改。先复述你理解的要改哪几处,我确认了再动手,改完让我再验收。',
821 UNCLEAR: '我没看懂你做了什么、为什么这么做。先别改代码,拿这一轮的改动和命令结果解释清楚:做了什么、为什么这样做、怎么验证的。',
822 FAILED: '没做成。先对照这一轮的改动和命令结果找原因:原因清楚就直接返工,改完让我再验收;拿不准哪里不对,先追问我,别瞎改。',
823 SUSPENDED: '先搁置。别再动它,用两三句话记下现在做到哪、还差什么,方便以后接上。',
824 CANCELLED: '不做了。停下;这件事要是已经改了文件,列出改动,问我要不要撤回,我没同意之前别动。',
825 DOING: '还没做完。说清还差什么,接着做完,做完让我再验收。',
826}
827
828/** 一次打回好几件时附上:能并行的交给子代理,碰同一批文件的按顺序来 */
829const PARALLEL =
830 '这几件里互不相干、不改同一批文件的,开子代理(Agent 工具)并行处理,一个子代理只管一件,做完把结果汇总给我;会改同一批文件的按顺序做,免得互相覆盖。Rework 要先复述等我确认、Unclear? 只做解释,这两种不用开子代理。'
831
832/** 等我停手再发:B 要连按几下才换到想要的状态 */
833const VERDICT_DELAY = 4000
834
835/** 被我改了状态、还没交给 Claude 的行:块的 id,或者「需要我做」那一条的「块 id#第几条」 */
836const verdicts = new Set<string>()
837let verdictRound = 0
838
839function noteVerdict($: EngineInterface, id: string, to: Status) {
840 verdicts.add(id)
841 const round = ++verdictRound
842 $.clock.after(VERDICT_DELAY, () => {
843 if (round === verdictRound) void sendVerdicts($)
844 })
845}
846
847/** 停手以后看最终状态:没通过的、回答了是或否的,发一条消息给 Claude,按状态说清怎么接;改回 Done 的就算了 */
848async function sendVerdicts($: EngineInterface) {
849 const keys = [...verdicts]
850 verdicts.clear()
851 const all = await read($, blocks)
852 const found = keys.flatMap(key => {
853 const { blockId, index } = parseKey(key)
854 const b = all.find(one => one.id === blockId)
855 if (b === undefined) return []
856 const st = index === undefined ? b.status : itemStatus(b, index)
857 const how = VERDICT[st]
858 const what = index === undefined ? `块 [${b.id}]「${b.title}」` : `「${b.needsMe[index] ?? ''}」(块 [${b.id}]「${b.title}」)`
859 return how === undefined ? [] : [{ st, line: `- ${what}:${STATUS_INFO[st].label}。${how}` }]
860 })
861 if (found.length === 0) return
862 const lines = found.map(one => one.line)
863 const answers = found.filter(one => isAnswer(one.st)).length
864 const head = answers === found.length ? '我在大纲侧栏里回答了你问的这些事,请照着接着做:' : answers === 0 ? '我在大纲侧栏验收时,没让下面这些事通过。请逐条回应:' : '我在大纲侧栏里对下面这些事做了回复,请逐条接着处理:'
865 // 简单的回答别展开:答完不需要再做事的,回一句就够了
866 const brief = answers > 0 ? ['', '这些回答要是不需要你再做什么,回一句「收到」就行,别展开。'] : []
867 // mod 自己发的消息不经过自己的 prompt.submit 钩子:侧栏里改过的状态直接接在正文后面
868 const told = await takeChanges($)
869 const parallel = lines.length > 1 ? ['', PARALLEL] : []
870 await $.prompt.submit({ text: [head, ...lines, ...brief, ...parallel, ...(told === undefined ? [] : ['', told])].join('\n') })
871}
872
873/** 同一处改了几次只记最后一次;改回原样就不用说了 */
874async function noteChange($: EngineInterface, change: StatusChange) {
875 await update($, changes, list => {
876 const before = (list ?? []).find(one => one.key === change.key)
877 const rest = (list ?? []).filter(one => one.key !== change.key)
878 const from = before?.from ?? change.from
879 return from === change.to ? rest : [...rest, { ...change, from }]
880 })
881}
882
883/** 发消息时附给 Claude 的一段话:侧栏里改了哪些状态 */
884async function takeChanges($: EngineInterface) {
885 const list = await read($, changes)
886 if (list.length === 0) return undefined
887 await update($, changes, () => [])
888 const lines = list.map(c => `- ${c.what}:${c.from} → ${c.to}`)
889 return [
890 '用户在会话大纲侧栏里手动改了下面这些状态。这是用户在告诉你进展:「需要用户做」的事改成 DONE,表示用户已经做完了;块的状态改动表示用户对那件事的判断:DONE 是认可做完;YES、NO 是用户对你的问题答了「是」或「否」;SKIP 是用户跳过了这件事,不用做也不用再提;REWORK 是方向对、细节要改;UNCLEAR 是用户没看懂,先解释别改;FAILED 是用户认为没做成,要返工,先弄清哪里不对;SUSPENDED 是先搁置、暂时别碰;CANCELLED 是不做了。据此接着做,不用复述这段说明。',
891 ...lines,
892 ].join('\n')
893}
894
895/** ⌘/:照 todoseq 的流转在 Todo、Doing、Done 之间切 */
896async function cycleStatus($: EngineInterface) {
897 await changeStatus($, nextStatus)
898}
899
900async function setStatus($: EngineInterface, status: Status) {
901 await changeStatus($, () => status)
902}
903
904async function toggleLegend($: EngineInterface) {
905 await update($, legend, isOn => !isOn)
906}
907
908/** 回车弹出的菜单:只用方向键和回车就能做完所有操作 */
909const statusMenu = (): MenuItem[] => STATUSES.map(st => ({ id: `status:${st}`, label: statusText(st), color: STATUS_INFO[st].color }))
910
911const menuFor = (lvl: Level, isZoomed: boolean, isBodyAll: boolean): MenuItem[] => [
912 { id: 'detail', label: '详情' },
913 ...statusMenu(),
914 { id: 'fold', label: '折叠子块' },
915 { id: 'zoom', label: isZoomed ? '退出聚焦' : '聚焦这一块' },
916 { id: 'body', label: '正文里折叠这一轮' },
917 { id: 'body-all', label: isBodyAll ? '正文全部展开' : '正文全折' },
918 { id: 'jump', label: '跳到对话' },
919 { id: 'progress', label: '刷新进度' },
920 { id: 'recap', label: '回顾' },
921 { id: 'level', label: `全局层级:${LEVEL_LABEL[LEVELS[(LEVELS.indexOf(lvl) + 1) % LEVELS.length]!]}` },
922 { id: 'export', label: '导出' },
923 { id: 'legend', label: '图例' },
924]
925
926/** 光标在「需要我做」的某一条上时,菜单只有改状态 */
927const itemMenu = (): MenuItem[] => statusMenu()
928
929/** 层级按钮(O):只看顶层 → 全部标题 → 全部展开(连详情)→ 去掉 Done(不折、不展开详情,Done 的块藏起来) */
930async function cycleLevel($: EngineInterface) {
931 const all = await read($, blocks)
932 const nextLevel = LEVELS[(LEVELS.indexOf(await read($, level)) + 1) % LEVELS.length]!
933 await update($, level, () => nextLevel)
934 await update($, folded, () => (nextLevel === 'overview' ? all.filter(b => childrenOf(all, b.id).length > 0).map(b => b.id) : []))
935 await update($, open, () => (nextLevel === 'all' ? all.map(b => b.id) : []))
936 // 「全部展开」照办,别的档位交还给自动折叠
937 await update($, unfolded, () => (nextLevel === 'all' ? all.map(b => b.id) : []))
938 await keepCursorVisible($)
939}
940
941/** 折起来以后光标要是被藏住了,挪到看得见的那个祖先块上 */
942async function keepCursorVisible($: EngineInterface) {
943 const rows = await currentRows($)
944 const key = await read($, cursor)
945 if (key === null || rows.some(r => r.key === key)) return
946 const all = await read($, blocks)
947 const b = all.find(one => one.id === parseKey(key).blockId)
948 const seen = b === undefined ? [] : [b, ...ancestorsOf(all, b).reverse()]
949 const visible = seen.find(one => rows.some(r => r.key === one.id))
950 if (visible !== undefined) await setCursor($, visible.id)
951 else await keepCursorInView($)
952}
953
954async function toggleZoom($: EngineInterface) {
955 const id = await read($, cursor)
956 const current = await read($, zoom)
957 await update($, zoom, () => (current !== null ? null : id === null ? null : parseKey(id).blockId))
958}
959
960async function exportView($: EngineInterface, v: View, path?: string) {
961 const list = await read($, blocks)
962 const rows = visibleRows(list, v, await read($, folded), await read($, zoom), [], await isHidingDone($))
963 const now = new Date(await $.clock.now())
964 const md = toMarkdown(list, rows, v, await read($, open), now.toISOString().slice(0, 16).replace('T', ' '))
965 const count = rows.filter(r => !r.isContext).length
966 if (path !== undefined) {
967 await $.fs.write(path, md)
968 return `已导出「${VIEW_INFO[v].label}」视图(${count} 块)到 ${path}`
969 }
970 const copied = await $.ui.copy({ text: md })
971 return copied.isCopied
972 ? `已复制「${VIEW_INFO[v].label}」视图(${count} 块)的 Markdown`
973 : `复制失败(${copied.reason}),可以用 /outline export ${v} <文件路径> 存成文件`
974}
975
976async function jump($: EngineInterface) {
977 const id = await cursorBlockId($)
978 const b = (await read($, blocks)).find(one => one.id === id)
979 const messageId = b?.kind === 'turn' ? b.messageId : (await read($, blocks)).find(one => one.id === b?.parent)?.messageId
980 if (messageId === undefined) return
981 await $.ui.scroll({ to: { requestId: messageId }, block: 'start' })
982}
983
984async function runAction($: EngineInterface, action: string) {
985 if (action === 'down') return moveCursor($, 1)
986 if (action === 'up') return moveCursor($, -1)
987 if (action === 'fold') return toggleFold($)
988 if (action === 'fold-up') return foldUp($)
989 if (action === 'unfold-down') return unfoldDown($)
990 if (action === 'detail') return toggleDetail($)
991 if (action === 'indent') return reparent($, 'in')
992 if (action === 'outdent') return reparent($, 'out')
993 if (action === 'status') return cycleStatus($)
994 if (action === 'status-bad') return changeStatus($, nextBadStatus)
995 if (action === 'done') return setStatus($, 'DONE')
996 // 待验收的块上按 Y 就是验收通过:走 Done 的路子(先看证据、算放行、不额外发消息)
997 if (action === 'yes') return changeStatus($, st => (st === 'TOREVIEW' ? 'DONE' : 'YES'))
998 if (action === 'no') return setStatus($, 'NO')
999 if (action === 'level') return cycleLevel($)
1000 if (action === 'zoom') return toggleZoom($)
1001 if (action === 'body') return toggleBodyFold($)
1002 if (action === 'body-all') return toggleBodyFoldAll($)
1003 if (action === 'jump') return jump($)
1004 if (action === 'export') {
1005 await exportView($, await read($, view))
1006 return
1007 }
1008 if (action === 'recap') return generateRecap($)
1009 if (action === 'progress') return refreshProgress($)
1010 if (action === 'legend') return toggleLegend($)
1011 if (action.startsWith('status:')) return setStatus($, normalizeStatus(action.slice('status:'.length)))
1012 if (action === 'cycle') return cycleStatus($)
1013}
1014
1015/**
1016 * 展开到第几层:⇧1 / ⇧2 / ⇧3 打出来的 ! @ #(中文输入法下是全角的),
1017 * 以及 ⌥1 / ⌥2 / ⌥3 在美式键盘上打出来的 ¡ ™ £。
1018 * 主推 ⇧:Tern、iTerm 这类终端默认拿 ⌥ 加数字切标签页,按键到不了 Claude Code
1019 */
1020const DEPTH_KEYS: Record<string, number> = { '!': 1, '@': 2, '#': 3, '!': 1, '@': 2, '#': 3, '¡': 1, '™': 2, '£': 3 }
1021
1022type KeyMessage = { type: 'key'; key: string; shift: boolean; ctrl: boolean; meta: boolean }
1023
1024/** ⇧ 加这些字母:直接标成对应的负面状态,不用按 N 一个个转 */
1025const VERDICT_KEYS: Record<string, Status> = { r: 'REWORK', u: 'UNCLEAR', f: 'FAILED', s: 'SUSPENDED', c: 'CANCELLED' }
1026
1027/** 树视图里收到的按键,按 Logseq 的习惯来 */
1028async function handleKey($: EngineInterface, k: KeyMessage, keys: readonly View[]) {
1029 const isUp = k.key === 'up'
1030 const isDown = k.key === 'down'
1031 // ⇧ 或 ⌥ 加 1~3:终端可能报成 ! @ #、¡ ™ £,也可能报成数字带 shift / meta
1032 const depth = DEPTH_KEYS[k.key] ?? ((k.meta || k.shift) && /^[1-3]$/.test(k.key) ? Number(k.key) : undefined)
1033 if (depth !== undefined) return showDepth($, depth)
1034 // 缩进照 Org 的 ⌥→ / ⌥←
1035 if ((k.key === 'left' || k.key === 'right') && k.meta) return reparent($, k.key === 'right' ? 'in' : 'out')
1036 if ((isUp || isDown) && k.ctrl) return toggleFold($, isUp)
1037 if (isUp || k.key === 'k') return moveCursor($, -1)
1038 if (isDown || k.key === 'j') return moveCursor($, 1)
1039 if (k.key === 'left') return toggleFold($, true)
1040 if (k.key === 'right') return toggleFold($, false)
1041 if (k.key === 'return' || k.key === 'space') return toggleDetail($)
1042 if (/^[1-9]$/.test(k.key)) {
1043 const v = keys[Number(k.key) - 1]
1044 if (v !== undefined) await switchView($, v)
1045 return
1046 }
1047 // -:跳过这件事(中文输入法下是全角的)。按钮只认字母,桌面版走回车菜单
1048 if (k.key === '-' || k.key === '-') return setStatus($, 'SKIP')
1049 // ⇧ 加首字母直接标成对应的负面状态(终端可能报成大写字母,也可能是小写带 shift)
1050 const verdict = /^[a-zA-Z]$/.test(k.key) && (k.shift || k.key !== k.key.toLowerCase()) ? VERDICT_KEYS[k.key.toLowerCase()] : undefined
1051 if (verdict !== undefined) return setStatus($, verdict)
1052 const action = ACTIONS.find(a => a.hotkey === k.key.toLowerCase())
1053 if (action !== undefined) return runAction($, action.key)
1054}
1055
1056/** 敲完几秒还没开新一轮,说明这条命令不走模型,记到最近一轮上 */
1057async function settleSlash($: EngineInterface, name: string) {
1058 if (!pendingSlash.includes(name) || current !== undefined) return
1059 pendingSlash = pendingSlash.filter(one => one !== name)
1060 await setBlocks($, all => {
1061 const last = all.filter(b => b.kind === 'turn' && b.tech !== undefined).at(-1)
1062 if (last?.tech === undefined) return all
1063 const slash = last.tech.slash ?? []
1064 if (slash.includes(name)) return all
1065 return all.map(b => (b.id === last.id && b.tech !== undefined ? { ...b, tech: { ...b.tech, slash: [...slash, name] } } : b))
1066 })
1067}
1068
1069// ---------- 进行中的一轮:实时进度和手动刷新 ----------
1070
1071const base = (path: unknown) => (typeof path === 'string' ? (path.split('/').pop() ?? path) : '')
1072
1073/** 一步工具调用用一句话说清楚,给进度用(技术信息视图不记内置工具,这里只是说做到哪了) */
1074const describeStep = (tool: string, input: Record<string, unknown>) => {
1075 if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') return `改 ${base(input.file_path)}`
1076 if (tool === 'Read') return `读 ${base(input.file_path)}`
1077 if (tool === 'Bash') return `跑 ${clip(String(input.command ?? '').split('\n')[0] ?? '', 60)}`
1078 if (tool === 'Grep' || tool === 'Glob') return `搜「${clip(String(input.pattern ?? ''), 40)}」`
1079 if (tool === 'Skill') return `技能 ${String(input.skill ?? '')}`
1080 if (tool === 'Agent' || tool === 'Task') return `子任务 ${clip(String(input.description ?? ''), 40)}`
1081 if (tool === 'WebSearch' || tool === 'WebFetch') return `查 ${clip(String(input.query ?? input.url ?? ''), 50)}`
1082 if (tool.startsWith('mcp__')) {
1083 const [, server = '', ...rest] = tool.split('__')
1084 return `MCP ${server.replace(/^claude_ai_/, '')}/${rest.join('__')}`
1085 }
1086 return tool
1087}
1088
1089let isLiveQueued = false
1090
1091/** 工具调用很密,攒一下再写,最多一秒一次 */
1092function queueLive($: EngineInterface) {
1093 if (isLiveQueued) return
1094 isLiveQueued = true
1095 $.clock.after(1000, () => {
1096 isLiveQueued = false
1097 void writeLive($)
1098 })
1099}
1100
1101async function writeLive($: EngineInterface) {
1102 const turn = current
1103 if (turn === undefined) return
1104 await setBlocks($, all =>
1105 all.map(b =>
1106 b.id === turn.blockId && b.live !== undefined
1107 ? { ...b, files: [...turn.files], commands: turn.commands.length, live: { ...b.live, steps: turn.steps.length, last: turn.steps.at(-1) ?? '' } }
1108 : b,
1109 ),
1110 )
1111}
1112
1113const PROGRESS_SYSTEM = `Claude Code 还在处理用户的一个请求,用户想知道它做到哪一步了。根据给你的原话、已经做过的步骤和它目前写出的文字,只输出一个 JSON 对象,不要别的文字:
1114- title:用户要它做什么,一句话,中文,不超过 30 字
1115- did:到目前为止已经做完的事,每条一短句
1116- note:一句话说现在做到哪了、接下来要做什么
1117- needsMe:现在就需要用户做的事,没有就空数组`
1118
1119/** 手动刷新:这一轮还没结束也先给个小结 */
1120async function refreshProgress($: EngineInterface) {
1121 const turn = current
1122 if (turn === undefined) return
1123 await writeLive($)
1124 const mark = (patch: Partial<Block>, live: Partial<Block['live'] & object>) =>
1125 setBlocks($, all => all.map(b => (b.id === turn.blockId && b.live !== undefined ? { ...b, ...patch, live: { ...b.live, ...live } } : b)))
1126 await mark({}, { isRefreshing: true })
1127 const prompt = [
1128 `用户原话:${clip(turn.text, 1500)}`,
1129 `已经做过的步骤(共 ${turn.steps.length} 步):\n${turn.steps.slice(-40).map(step => `- ${step}`).join('\n') || '(还没有)'}`,
1130 `它目前写出的文字:${clip(turn.texts.join('\n'), 3000) || '(还没有)'}`,
1131 ].join('\n\n')
1132 const reply = await $.model.complete({ model: 'haiku', system: PROGRESS_SYSTEM, prompt, maxTokens: 512, timeoutMs: 30000 })
1133 const data = reply.isAnswered ? parseJson(reply.text) : undefined
1134 if (current?.turnId !== turn.turnId) return // 刷新期间这一轮已经结束,交给正式总结
1135 if (data === undefined) {
1136 await mark({}, { isRefreshing: false })
1137 return
1138 }
1139 const now = await $.clock.now()
1140 await mark(
1141 {
1142 ...(typeof data.title === 'string' && data.title !== '' ? { title: data.title } : {}),
1143 did: strings(data.did),
1144 needsMe: strings(data.needsMe),
1145 },
1146 { isRefreshing: false, note: typeof data.note === 'string' ? data.note : '', refreshedAt: now },
1147 )
1148}
1149
1150/** 重载或崩溃后 current 没了,留下的「进行中」块收尾成普通块 */
1151async function settleStaleLive($: EngineInterface) {
1152 const list = await read($, blocks)
1153 if (!list.some(b => b.live !== undefined)) return
1154 await setBlocks($, all => all.map(b => (b.live === undefined ? b : { ...b, live: undefined, isPending: false })))
1155}
1156
1157// ---------- 回顾 ----------
1158
1159const RECAP_SUFFIX = /\s*\(disable recaps in \/config\)\s*$/
1160
1161/** 记下一次回顾:面板顶部显示最新的一条,大纲里挂在最近一轮下面 */
1162async function addRecap($: EngineInterface, text: string, source: Recap['source']) {
1163 const clean = text.replace(RECAP_SUFFIX, '').trim()
1164 if (clean === '') return
1165 const at = await $.clock.now()
1166 await update($, recap, () => ({ text: clean, source, at }))
1167 await setBlocks($, all => {
1168 const parent = all.filter(b => b.kind === 'turn').at(-1)
1169 const n = all.filter(b => b.kind === 'recap').length + 1
1170 return [
1171 ...all,
1172 { ...emptyBlock(`r${n}`, 'recap', parent?.turn ?? 0, parent?.id ?? null), title: clip(clean, 40), conclusion: clean, ask: source === 'claude' ? 'Claude Code 自带的 recap' : '大纲回顾' },
1173 ]
1174 })
1175}
1176
1177const RECAP_SYSTEM = `你在给一段 Claude Code 会话写回顾,读的人刚回到电脑前。根据给你的大纲,用 2 到 4 句中文写:做到哪了、定下了什么、还有什么在等用户做。只写这段话,不要标题、不要列表。`
1178
1179/** 大纲自己的回顾:只读大纲里的块,不读主对话,不占主对话上下文 */
1180async function generateRecap($: EngineInterface) {
1181 const list = (await read($, blocks)).filter(b => b.kind === 'turn' && !b.isPending)
1182 if (list.length === 0) return
1183 const previous = await read($, recap)
1184 await update($, recap, () => ({ text: previous?.text ?? '', source: 'outline' as const, at: previous?.at ?? 0, isPending: true }))
1185 const lines = list.slice(-30).map(b =>
1186 [
1187 `- [${b.status}] ${b.title}`,
1188 b.did.length > 0 ? ` 做了:${b.did.join(';')}` : '',
1189 b.conclusion !== '' ? ` 结论:${b.conclusion}` : '',
1190 b.needsMe.length > 0 && isOpenStatus(b.status) ? ` 等用户:${b.needsMe.join(';')}` : '',
1191 ]
1192 .filter(Boolean)
1193 .join('\n'),
1194 )
1195 const reply = await $.model.complete({ model: 'haiku', system: RECAP_SYSTEM, prompt: lines.join('\n'), maxTokens: 512, timeoutMs: 60000 })
1196 if (!reply.isAnswered) {
1197 await update($, recap, () => previous)
1198 return
1199 }
1200 await addRecap($, reply.text, 'outline')hooks/outline.ts 652 lines1import type { Block, Evidence, Kind, Metrics, Resolution, Status, Tech, View } from '../types'
2import { PH } from './icons'
3import type { PhosphorIcon } from './icons'
4import { STATUS_INFO, isForMe, isOpenStatus, statusText } from './status'
5
6export { STATUSES, STATUS_GAP, STATUS_INFO, isForMe, isOpenStatus, nextBadStatus, nextStatus, normalizeStatus, statusText } from './status'
7
8export const VIEW_INFO: Record<View, { label: string; aliases: string[] }> = {
9 conclusions: { label: '结论', aliases: ['concl', 'conclusion'] },
10 me: { label: '需要我做的', aliases: ['todo'] },
11 asks: { label: '我让它做的', aliases: ['ask'] },
12 all: { label: '全部', aliases: [] },
13 done: { label: '它已做的', aliases: ['did'] },
14 tech: { label: '技术信息', aliases: ['stats'] },
15}
16
17export const DEFAULT_KEYS = 'conclusions,me,asks,all,done,tech'
18
19/** 把配置里的 `conclusions,me,asks,…` 读成数字键的顺序;写错的跳过,漏掉的视图补在后面 */
20export const parseKeys = (text: string): View[] => {
21 const known = Object.keys(VIEW_INFO) as View[]
22 const picked: View[] = []
23 for (const word of text.split(/[,,\s]+/)) {
24 const v = viewFromArg(word)
25 if (v !== undefined && !picked.includes(v)) picked.push(v)
26 }
27 for (const v of known) if (!picked.includes(v)) picked.push(v)
28 return picked.slice(0, 9)
29}
30
31export const viewFromArg = (word: string): View | undefined => {
32 const w = word.trim().toLowerCase()
33 for (const [v, info] of Object.entries(VIEW_INFO) as [View, { aliases: string[] }][]) {
34 if (v === w || info.aliases.includes(w)) return v
35 }
36 return undefined
37}
38
39/** 子块类型:用文字标 */
40export const KIND_BADGE: Record<Kind, { text: string; color: string } | undefined> = {
41 turn: undefined,
42 agent: { text: '子任务', color: 'merged' },
43 fork: { text: 'fork', color: 'merged' },
44 btw: { text: 'btw', color: 'ide' },
45 recap: { text: '回顾', color: 'remember' },
46}
47
48// ---------- 字段图标(Phosphor;「需要我做」用 ASCII 的 -,证据用 ↳) ----------
49
50export type Field =
51 | 'needsMe' | 'did' | 'proof' | 'files' | 'conclusion' | 'question' | 'resolved' | 'ask'
52 | 'coverage' | 'review' | 'reopen' | 'fix'
53 | 'stats' | 'tokens' | 'cache' | 'time' | 'model' | 'skills' | 'mcp' | 'slash' | 'agents' | 'outline' | 'live' | 'last' | 'note'
54
55export const FIELD: Record<Field, { icon: PhosphorIcon | Mark; label: string }> = {
56 needsMe: { icon: 'point', label: '需要我做' },
57 did: { icon: 'did', label: '做了' },
58 proof: { icon: 'proof', label: '证据' },
59 files: { icon: 'files', label: '改了的文件' },
60 conclusion: { icon: 'conclusion', label: '结论' },
61 question: { icon: 'question', label: '还没解决的问题' },
62 resolved: { icon: 'resolved', label: '在第几轮解决' },
63 ask: { icon: 'ask', label: '我的原话' },
64 coverage: { icon: 'proof', label: '证据覆盖率:「做了」里有证据的比例' },
65 review: { icon: 'resolved', label: '验收拦下率:验收时没让通过的比例' },
66 reopen: { icon: 'past', label: '返工率:标了 Done 又被重新打开的比例' },
67 fix: { icon: 'pending', label: '手动纠正过层级的块' },
68 stats: { icon: 'stats', label: '技术信息' },
69 tokens: { icon: 'tokens', label: 'token(↑ 输入 ↓ 输出)' },
70 cache: { icon: 'cache', label: '缓存命中率' },
71 time: { icon: 'time', label: '耗时' },
72 model: { icon: 'model', label: '模型' },
73 skills: { icon: 'skills', label: '技能' },
74 mcp: { icon: 'mcp', label: 'MCP' },
75 slash: { icon: 'slash', label: '斜杠命令' },
76 agents: { icon: 'agents', label: '子代理' },
77 outline: { icon: 'outline', label: '大纲自己的开销' },
78 live: { icon: 'live', label: '进行中' },
79 last: { icon: 'last', label: '刚才那一步' },
80 note: { icon: 'note', label: '进度小结' },
81}
82
83/** 终端里画图标用的字:Phosphor 字体里的字,「需要我做」是 - */
84/** 后来的对话自动了结的条目和块,标题前面带上它;我一改状态就没了 */
85export const AUTO_TAG = '自动'
86
87/** 没总结出标题、也没有原话可用的块 */
88export const UNTITLED = '(没有标题)'
89
90/** 「需要我做」那一条行首的记号 */
91export const ITEM_MARK = '-'
92
93/** 不用图标字体、直接画字的记号 */
94const MARKS = { point: ITEM_MARK, proof: '↳' } as const
95type Mark = keyof typeof MARKS
96export const isMark = (icon: PhosphorIcon | Mark): icon is Mark => icon in MARKS
97
98export const glyph = (field: Field) => {
99 const icon = FIELD[field].icon
100 return isMark(icon) ? MARKS[icon] : PH[icon]
101}
102
103/** 终端里的显示宽度:中日韩文字、全角符号和 emoji 占两格;图标字体(补充私用区)占一格 */
104export const cellWidth = (text: string) => {
105 let n = 0
106 for (const ch of text) {
107 const cp = ch.codePointAt(0) ?? 0
108 if (cp === 0xfe0f || cp === 0x200d) continue
109 if (cp >= 0xf0000) {
110 n += 1
111 continue
112 }
113 const isWide =
114 (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff) ||
115 (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) || (cp >= 0xffe0 && cp <= 0xffe6) || cp >= 0x1f300
116 n += isWide ? 2 : 1
117 }
118 return n
119}
120
121// ---------- 树 ----------
122
123export const childrenOf = (list: readonly Block[], id: string | null) => list.filter(b => b.parent === id)
124
125export const ancestorsOf = (list: readonly Block[], b: Block) => {
126 const byId = new Map(list.map(one => [one.id, one]))
127 const chain: Block[] = []
128 let at = b.parent === null ? undefined : byId.get(b.parent)
129 while (at !== undefined && chain.length < 64) {
130 chain.unshift(at)
131 at = at.parent === null ? undefined : byId.get(at.parent)
132 }
133 return chain
134}
135
136/**
137 * 后来的对话了结了这一块里等我的事,按条改:我自己说的(user)直接 Done;Claude 说替我办了的(claude)改成 ToReview 等我看。
138 * 都打上「自动」的记号;我手动改过的条目和块不动。条目全了结、而且都是我说的,块也跟着 Done
139 */
140export const applyResolutions = (b: Block, found: readonly Resolution[], turn: number | undefined, now: number): Block => {
141 const mine = found.filter(r => parseKey(r.key).blockId === b.id)
142 if (mine.length === 0) return b
143 const settle = (by: Resolution['by']): Status => (by === 'user' ? 'DONE' : 'TOREVIEW')
144 if (b.needsMe.length === 0) {
145 const by = mine[0]!.by
146 return b.isStatusManual || !isForMe(b.status) || b.status === 'TOREVIEW' ? b : { ...b, status: settle(by), autoResolved: by, resolvedIn: turn, statusAt: now }
147 }
148 const marks = b.needsMe.map((_, i) => b.needsMeStatus?.[i] ?? null)
149 const auto = b.needsMe.map((_, i) => b.needsMeAuto?.[i] ?? null)
150 let isChanged = false
151 for (const r of mine) {
152 const { index } = parseKey(r.key)
153 // 只给了块 id:块里每一条还没了结的都算
154 const which = index === undefined ? b.needsMe.map((_, i) => i) : [index]
155 for (const i of which) {
156 if (i >= b.needsMe.length || !isOpenStatus(itemStatus(b, i))) continue
157 // 我手动改过的不动;之前自动改的可以再改
158 if (marks[i] !== null && auto[i] === null) continue
159 marks[i] = settle(r.by)
160 auto[i] = r.by
161 isChanged = true
162 }
163 }
164 if (!isChanged) return b
165 const updated: Block = { ...b, needsMeStatus: marks, needsMeAuto: auto }
166 const isAllSettled = b.needsMe.every((_, i) => !isOpenStatus(itemStatus(updated, i)))
167 return isAllSettled && !b.isStatusManual && isForMe(b.status) && b.status !== 'TOREVIEW' ? { ...updated, status: 'DONE', resolvedIn: turn, statusAt: now } : updated
168}
169
170/** 「需要我做」第 i 条的状态:单独改过就用改过的,没改过就跟着块走 */
171export const itemStatus = (b: Block, i: number): Status => b.needsMeStatus?.[i] ?? (isOpenStatus(b.status) ? 'TODO' : 'DONE')
172
173/** 这一块还有没办完的、要我做的事 */
174export const hasOpenForMe = (b: Block) => b.needsMe.some((_, i) => isOpenStatus(itemStatus(b, i))) || (b.needsMe.length === 0 && isForMe(b.status))
175
176export const matches = (b: Block, v: View) => {
177 if (b.live !== undefined) return true
178 if (v === 'me') return b.needsMe.length > 0 || isForMe(b.status)
179 if (v === 'asks') return b.kind === 'turn' || b.kind === 'btw'
180 if (v === 'done') return b.did.length > 0 || b.files.length > 0 || (b.kind !== 'turn' && b.kind !== 'recap' && b.conclusion !== '')
181 if (v === 'conclusions') return b.conclusion !== ''
182 if (v === 'tech') return b.tech !== undefined
183 return true
184}
185
186/** 还没办完的「需要我做的」条数,面板上的角标 */
187export const waitingCount = (list: readonly Block[]) =>
188 list.reduce((n, b) => {
189 if (b.live !== undefined) return n
190 const open = b.needsMe.filter((_, i) => isOpenStatus(itemStatus(b, i))).length
191 return n + (b.needsMe.length > 0 ? open : isForMe(b.status) ? 1 : 0)
192 }, 0)
193
194/** 「需要我做」的一条,在大纲里是块下面一行,能单独选中、改状态 */
195export type Item = { index: number; text: string; status: Status }
196
197export type Row = {
198 block: Block
199 item?: Item
200 /** 光标认的 id:块就是块的 id,条目是「块 id#第几条」 */
201 key: string
202 depth: number
203 isContext: boolean
204 hasChildren: boolean
205 isFolded: boolean
206 /** 标题行前的引导线:「│ ├─ 」 */
207 prefix: string
208 /** 标题下面字段行前的引导线,竖线一直连到子块和下一个兄弟块 */
209 cont: string
210 /** 顶层话题(第一个除外)前空一行,让一个话题和它的子块聚成一团 */
211 isGap: boolean
212}
213
214export const itemKey = (blockId: string, index: number) => `${blockId}#${index}`
215
216export const parseKey = (key: string) => {
217 const at = key.lastIndexOf('#')
218 return at < 0 ? { blockId: key } : { blockId: key.slice(0, at), index: Number(key.slice(at + 1)) }
219}
220
221/** 已了结的状态:做完的、不做了的、我已经答过是或否的、我跳过的。层级「去掉 Done」藏起来,整棵都是这些的子树自动折叠 */
222const SETTLED: readonly Status[] = ['DONE', 'CANCELLED', 'YES', 'NO', 'SKIP']
223
224/** 这一块连同它的「需要我做」条目、所有子孙都了结了 */
225const isSettledTree = (list: readonly Block[], b: Block): boolean =>
226 SETTLED.includes(b.status) &&
227 b.live === undefined &&
228 !b.isPending &&
229 b.needsMe.every((_, i) => SETTLED.includes(itemStatus(b, i))) &&
230 childrenOf(list, b.id).every(kid => isSettledTree(list, kid))
231
232/** 自动折叠只在看当前进度的视图里生效 */
233const AUTO_FOLD_VIEWS: readonly View[] = ['me', 'asks', 'all']
234
235/** 自动折叠:整棵都了结的块折起来,除了我手动打开过的(unfolded)和先别折的(hold:光标所在的块、它的祖先、光标刚离开还在缓冲的) */
236export type AutoFold = { unfolded: readonly string[]; hold: readonly string[] }
237
238/**
239 * 按视图把树摊成可见的行。除「全部」外都是 sparse tree:命中的块和它们的祖先,其余不显示。
240 * hideDone(层级「去掉 Done」):已了结的块和「需要我做」条目也不算命中,块下面还有没了结的子块时留着当上下文
241 */
242export const visibleRows = (list: readonly Block[], v: View, folded: readonly string[], zoom: string | null, open: readonly string[] = [], hideDone = false, auto?: AutoFold): Row[] => {
243 // 「结论」「它已做的」「技术信息」本来就是看做完的事,不自动折
244 const isAutoFolded = (b: Block) =>
245 auto !== undefined && AUTO_FOLD_VIEWS.includes(v) && !auto.unfolded.includes(b.id) && !auto.hold.includes(b.id) && isSettledTree(list, b)
246 const isFiltered = v !== 'all' || hideDone
247 const hit = (b: Block) => matches(b, v) && !(hideDone && SETTLED.includes(b.status))
248 const keep = new Set<string>()
249 if (isFiltered) {
250 for (const b of list) {
251 if (!hit(b)) continue
252 keep.add(b.id)
253 for (const a of ancestorsOf(list, b)) keep.add(a.id)
254 }
255 }
256 const rows: Row[] = []
257 const shown = (bs: Block[]) => (isFiltered ? bs.filter(b => keep.has(b.id)) : bs)
258 type Node = { block: Block; item?: Item }
259 const walk = (node: Node, depth: number, parentCont: string, isLast: boolean) => {
260 const b = node.block
261 const own = depth === 0 ? '' : isLast ? ' ' : '│ '
262 const branch = depth === 0 ? '' : isLast ? '└─ ' : '├─ '
263 const below = parentCont + own
264 if (node.item !== undefined) {
265 rows.push({ block: b, item: node.item, key: itemKey(b.id, node.item.index), depth, isContext: false, hasChildren: false, isFolded: false, prefix: parentCont + branch, cont: below + ' ', isGap: false })
266 return
267 }
268 // 「需要我做」的条目排在子块前面,各占一行
269 const showItems = (v === 'me' || open.includes(b.id)) && !(isFiltered && !hit(b))
270 const items: Node[] = showItems
271 ? b.needsMe.map((text, index) => ({ block: b, item: { index, text, status: itemStatus(b, index) } })).filter(one => !(hideDone && SETTLED.includes(one.item.status)))
272 : []
273 const kids: Node[] = [...items, ...shown(childrenOf(list, b.id)).map(block => ({ block }))]
274 const isFolded = kids.length > 0 && (folded.includes(b.id) || isAutoFolded(b))
275 rows.push({
276 block: b,
277 key: b.id,
278 depth,
279 isContext: isFiltered && !hit(b),
280 hasChildren: kids.length > 0,
281 isFolded,
282 prefix: parentCont + branch,
283 cont: below + (kids.length > 0 && !isFolded ? '│ ' : ' '),
284 isGap: depth === 0 && rows.length > 0,
285 })
286 if (!isFolded) kids.forEach((kid, i) => walk(kid, depth + 1, below, i === kids.length - 1))
287 }
288 const top = shown(zoom === null ? childrenOf(list, null) : list.filter(b => b.id === zoom))
289 top.forEach((b, i) => walk({ block: b }, 0, '', i === top.length - 1))
290 return rows
291}
292
293// ---------- 每块下面显示的行 ----------
294
295/** 一行里的一段:一个图标加一段字。一行可以有好几段(🗄️ 3.2k 🎯 90% 🕐 12s) */
296export type Part = { field: Field; text: string }
297
298/** level 1 缩进一层;bullet 是某个字段下的一条,画 • 不画图标 */
299export type Line = { parts: Part[]; color?: string; level?: 1; bullet?: true }
300
301const one = (field: Field, text: string, extra: Partial<Line> = {}): Line => ({ parts: [{ field, text }], ...extra })
302
303/** 一个字段的几条:一条就跟图标同一行,多条就图标单独一行、条目缩进 */
304const group = (field: Field, items: readonly string[]): Line[] =>
305 items.length <= 1 ? items.map(text => one(field, text)) : [one(field, ''), ...items.map(text => one(field, text, { level: 1, bullet: true }))]
306
307/** 一条证据:改了哪个文件;跑了哪条命令,✓ 成功 / ✗ 报错,→ 后面是输出的最后一行 */
308const evidenceLine = (e: Evidence): Line => {
309 const text = e.kind === 'edit' ? `${e.isOk ? '' : '✗ '}改 ${e.text}` : `${e.isOk ? '✓' : '✗'} ${e.text}${e.tail === undefined ? '' : ` → ${e.tail}`}`
310 const color = !e.isOk ? STATUS_INFO.FAILED.color : e.kind === 'run' ? STATUS_INFO.DONE.color : undefined
311 return one('proof', text, { level: 1, ...(color === undefined ? {} : { color }) })
312}
313
314/** 每条「做了」下面挂它的证据;一条证据都挂不上的标「无证据」,验收时一眼看得到 */
315const didLines = (b: Block): Line[] => {
316 // 加证据之前存下的块没有这一项,照旧画
317 if (b.didEvidence === undefined) return group('did', b.did)
318 const all = b.evidence ?? []
319 const isMany = b.did.length > 1
320 const out: Line[] = isMany ? [one('did', '')] : []
321 b.did.forEach((text, i) => {
322 const proofs = (b.didEvidence?.[i] ?? []).map(n => all[n]).filter((e): e is Evidence => e !== undefined)
323 const extra: Partial<Line> = { ...(isMany ? { level: 1 as const, bullet: true as const } : {}), ...(proofs.length === 0 ? { color: STATUS_INFO.TOREVIEW.color } : {}) }
324 out.push(one('did', proofs.length === 0 ? `${text}(无证据)` : text, extra))
325 out.push(...proofs.slice(0, 3).map(evidenceLine))
326 if (proofs.length > 3) out.push(one('proof', `另有 ${proofs.length - 3} 条`, { level: 1 }))
327 })
328 return out
329}
330
331/**
332 * 「我让它做的」:每件事带上我的原话、做成了几件、证据够不够、还有几件等我,以及结论。
333 * 被打断以后扫一遍这个视图,就能把「当时要干什么、做到哪了」重新装回脑子里
334 */
335const askLines = (b: Block): Line[] => {
336 const lines: Line[] = []
337 const said = b.ask.replace(/\s+/g, ' ').trim()
338 if (said !== '' && said !== b.title) lines.push(one('ask', clip(said, 80)))
339 const parts: Part[] = []
340 if (b.did.length > 0) {
341 const proven = b.didEvidence?.filter(nums => nums.length > 0).length
342 parts.push({ field: 'did', text: proven === undefined ? `做了 ${b.did.length} 件` : `做了 ${b.did.length} 件 · 有证据 ${proven}` })
343 }
344 const waiting = b.needsMe.filter((_, i) => isOpenStatus(itemStatus(b, i))).length
345 if (waiting > 0) parts.push({ field: 'needsMe', text: `等我 ${waiting} 件` })
346 if (parts.length > 0) lines.push({ parts })
347 if (b.conclusion !== '') lines.push(one('conclusion', b.conclusion))
348 return lines
349}
350
351const elapsed = (ms: number) => {
352 const s = Math.max(0, Math.round(ms / 1000))
353 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
354}
355
356const tokens = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 100000 ? 0 : 1)}k` : String(n))
357
358export const cacheHit = (t: Tech) => {
359 const all = t.input + t.cacheRead + t.cacheWrite
360 return all === 0 ? undefined : Math.round((t.cacheRead / all) * 100)
361}
362
363export const techLines = (t: Tech): Line[] => {
364 const hit = cacheHit(t)
365 const usage: Part[] = [{ field: 'tokens', text: `↑${tokens(t.input + t.cacheRead + t.cacheWrite)} ↓${tokens(t.output)}` }]
366 if (hit !== undefined) usage.push({ field: 'cache', text: `${hit}%` })
367 usage.push({ field: 'time', text: elapsed(t.durationMs) })
368 if (t.model !== '') usage.push({ field: 'model', text: t.model.replace(/^claude-/, '') })
369 const lines: Line[] = [{ parts: usage }]
370 lines.push(...group('skills', t.skills))
371 lines.push(...group('mcp', t.mcp))
372 if ((t.slash ?? []).length > 0) lines.push(one('slash', (t.slash ?? []).map(c => `/${c}`).join(' ')))
373 if (t.agents > 0) lines.push({ parts: [{ field: 'agents', text: `×${t.agents}` }, { field: 'tokens', text: tokens(t.agentTokens) }] })
374 if (t.outlineTokens > 0) lines.push({ parts: [{ field: 'outline', text: '' }, { field: 'tokens', text: tokens(t.outlineTokens) }] })
375 return lines
376}
377
378/** 进行中那一轮的实时进度,在任何视图里都显示 */
379export const liveLines = (b: Block, now: number): Line[] => {
380 const live = b.live
381 if (live === undefined) return []
382 const parts: Part[] = [{ field: 'last', text: String(live.steps) }, { field: 'time', text: elapsed(now - live.startedAt) }]
383 if (live.isRefreshing === true) parts.push({ field: 'note', text: '…' })
384 const lines: Line[] = [{ parts, color: 'claude' }]
385 if (live.last !== '') lines.push(one('last', live.last))
386 if (live.note !== undefined && live.note !== '') lines.push(one('note', live.note, { color: 'claude' }))
387 return lines
388}
389
390/** 某块在某个视图下要显示的字段;`isOpen` 是我展开了详情。「需要我做」的条目不在这里,是单独的行 */
391export const blockLines = (b: Block, v: View, isOpen: boolean, now = 0): Line[] => {
392 const lines: Line[] = b.live === undefined ? [] : liveLines(b, now)
393 // 进行中的块:手动刷新出来的进展在任何视图里都直接露出来
394 if (v === 'asks' && !isOpen && b.live === undefined) return [...lines, ...askLines(b)]
395 if (v === 'done' || isOpen || b.live !== undefined) lines.push(...didLines(b))
396 if ((v === 'done' || isOpen) && b.files.length > 0) lines.push(one('files', b.files.join('、')))
397 if ((v === 'conclusions' || v === 'done' || isOpen) && b.conclusion !== '') lines.push(one('conclusion', b.conclusion))
398 if (v === 'tech' && b.tech !== undefined) lines.push(...techLines(b.tech))
399 if (isOpen) {
400 lines.push(...group('question', b.questions))
401 if (b.resolvedIn !== undefined) lines.push(one('resolved', `#${b.resolvedIn}`))
402 if (b.ask !== '' && b.ask !== b.title) lines.push(one('ask', b.ask.replace(/\s+/g, ' ')))
403 // 展开详情时,技术信息收进一个小节,缩进一层
404 if (v !== 'tech' && b.tech !== undefined) lines.push(one('stats', ''), ...techLines(b.tech).map(l => ({ ...l, level: 1 as const })))
405 }
406 return lines
407}
408
409/** 终端里一行的字:每段「图标 字」,段与段之间空两格 */
410export const lineText = (line: Line) => line.parts.map(p => `${glyph(p.field)}${p.text === '' ? '' : ` ${p.text}`}`).join(' ')
411
412// ---------- 导出 ----------
413
414const partsText = (parts: readonly Part[]) => parts.map(p => (p.text === '' ? FIELD[p.field].label : `${FIELD[p.field].label} ${p.text}`)).join(' · ')
415
416/** 把当前视图导出成 Markdown 大纲(不用图标字体,换个地方打开也看得懂) */
417export const toMarkdown = (list: readonly Block[], rows: readonly Row[], v: View, open: readonly string[], date: string) => {
418 const out = [`# 会话大纲:${VIEW_INFO[v].label}`, '', `导出于 ${date},共 ${rows.filter(r => !r.isContext && r.item === undefined).length} 块`, '']
419 for (const row of rows) {
420 const b = row.block
421 const pad = ' '.repeat(row.depth)
422 if (row.item !== undefined) {
423 out.push(`${pad}- ${STATUS_INFO[row.item.status].label} ${row.item.text}`)
424 continue
425 }
426 const badge = KIND_BADGE[b.kind]
427 out.push(`${pad}- ${STATUS_INFO[b.status].label} ${badge === undefined ? '' : `[${badge.text}] `}${b.title}${row.isContext ? '(上下文)' : ''}`)
428 if (row.isContext) continue
429 for (const line of blockLines(b, v, open.includes(b.id))) {
430 const indent = line.level === 1 ? ' ' : ' '
431 out.push(`${pad}${indent}- ${line.bullet === true ? (line.parts[0]?.text ?? '') : partsText(line.parts)}`)
432 }
433 }
434 if (v === 'tech') {
435 const total = sumTech(list)
436 if (total !== undefined) out.push('', '## 本次会话合计', '', ...techLines(total).map(l => `- ${partsText(l.parts)}`))
437 }
438 return `${out.join('\n')}\n`
439}
440
441// ---------- 验收指标 ----------
442
443export const NO_METRICS: Metrics = { did: 0, proven: 0, reviewed: 0, blocked: 0, finished: 0, reopened: 0, fixed: 0 }
444
445/** 从块上的标记数出验收指标:证据覆盖率、验收拦下率、返工率、纠正层级 */
446export const measure = (list: readonly Block[]): Metrics => {
447 const m = { ...NO_METRICS }
448 for (const b of list) {
449 if (b.kind !== 'turn') continue
450 if (b.didEvidence !== undefined) {
451 m.did += b.did.length
452 m.proven += b.didEvidence.filter(nums => nums.length > 0).length
453 }
454 if (b.reviewPassed === true || b.reviewBlocked === true) m.reviewed += 1
455 if (b.reviewBlocked === true) m.blocked += 1
456 if (b.status === 'DONE' || b.isReopened === true) m.finished += 1
457 if (b.isReopened === true) m.reopened += 1
458 if (b.isManual) m.fixed += 1
459 }
460 return m
461}
462
463export const addMetrics = (a: Metrics, b: Metrics): Metrics => ({
464 did: a.did + b.did, proven: a.proven + b.proven, reviewed: a.reviewed + b.reviewed, blocked: a.blocked + b.blocked,
465 finished: a.finished + b.finished, reopened: a.reopened + b.reopened, fixed: a.fixed + b.fixed,
466})
467
468const ratio = (word: string, n: number, total: number) => (total === 0 ? `${word} —` : `${word} ${n}/${total} · ${Math.round((n * 100) / total)}%`)
469
470export const metricsLines = (m: Metrics): Line[] => [
471 { parts: [{ field: 'coverage', text: ratio('证据', m.proven, m.did) }, { field: 'review', text: ratio('拦下', m.blocked, m.reviewed) }] },
472 { parts: [{ field: 'reopen', text: ratio('重开', m.reopened, m.finished) }, { field: 'fix', text: `纠正层级 ${m.fixed} 块` }] },
473]
474
475export const sumTech = (list: readonly Block[]): Tech | undefined => {
476 const all = list.map(b => b.tech).filter((t): t is Tech => t !== undefined)
477 if (all.length === 0) return undefined
478 const unique = (take: (t: Tech) => string[]) => [...new Set(all.flatMap(take))]
479 return {
480 durationMs: all.reduce((s, t) => s + t.durationMs, 0),
481 model: '',
482 input: all.reduce((s, t) => s + t.input, 0),
483 output: all.reduce((s, t) => s + t.output, 0),
484 cacheRead: all.reduce((s, t) => s + t.cacheRead, 0),
485 cacheWrite: all.reduce((s, t) => s + t.cacheWrite, 0),
486 slash: unique(t => t.slash ?? []),
487 skills: unique(t => t.skills),
488 mcp: unique(t => t.mcp),
489 agents: all.reduce((s, t) => s + t.agents, 0),
490 agentTokens: all.reduce((s, t) => s + t.agentTokens, 0),
491 outlineTokens: all.reduce((s, t) => s + t.outlineTokens, 0),
492 }
493}
494
495export const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max)}…` : text)
496
497// ---------- 交给树视图(Client)画的数据 ----------
498
499export type TreeLine = { text: string; color?: string; level?: 1; isBullet?: boolean }
500
501export type TreeRow = {
502 key: string
503 prefix: string
504 cont: string
505 isGap: boolean
506 arrow: string
507 status: string
508 statusColor: string
509 /** 还在办的,状态和标题加粗 */
510 isOpen: boolean
511 /** Doing 的图标要转 */
512 isSpinning: boolean
513 badge?: { text: string; color: string }
514 title: string
515 isAt: boolean
516 isContext: boolean
517 /** 已了结的,标题变暗 */
518 isClosed: boolean
519 isCanceled: boolean
520 /** 「需要我做」的一条,回车菜单只有改状态 */
521 isItem?: boolean
522 /** 标题前面的灰色小记号,比如后来的对话自动了结的「自动」 */
523 tag?: string
524 lines: TreeLine[]
525}
526
527export type MenuItem = { id: string; label: string; color?: string }
528
529export type TreeProps = {
530 rows: TreeRow[]
531 height: number
532 empty?: string
533 menu: MenuItem[]
534 itemMenu: MenuItem[]
535 /** tabs:键盘在视图栏上,←→ 和数字选视图、⏎ 进入;tree:光标只在这个视图的行里走 */
536 scope?: 'tabs' | 'tree'
537 /** 滚轮在面板上滚过的格数,一直累加;树看它和上次差多少,就往下(正)或往上(负)翻几行 */
538 wheel?: number
539}
540
541export const toTreeRows = (rows: readonly Row[], v: View, open: readonly string[], cursor: string | null, now = 0): TreeRow[] =>
542 rows.map(row => {
543 const b = row.block
544 const st = row.item?.status ?? b.status
545 const info = STATUS_INFO[st]
546 const isOpen = isOpenStatus(st)
547 const common = {
548 key: row.key,
549 prefix: row.prefix,
550 cont: row.cont,
551 isGap: row.isGap,
552 statusColor: info.color,
553 isOpen,
554 isSpinning: st === 'DOING',
555 isAt: row.key === cursor,
556 isClosed: !isOpen && b.live === undefined,
557 isCanceled: st === 'CANCELLED',
558 }
559 if (row.item !== undefined) {
560 const isAuto = b.needsMeAuto?.[row.item.index] != null
561 return { ...common, isItem: true, arrow: ITEM_MARK, status: statusText(st), ...(isAuto ? { tag: AUTO_TAG } : {}), title: row.item.text, isContext: false, lines: [] }
562 }
563 const badge = KIND_BADGE[b.kind]
564 const lines = row.isContext ? [] : blockLines(b, v, open.includes(b.id), now)
565 return {
566 ...common,
567 arrow: row.hasChildren ? (row.isFolded ? '▸' : '▾') : '•',
568 status: b.isPending ? `${glyph('note')} …` : statusText(st),
569 ...(badge === undefined ? {} : { badge }),
570 ...(b.autoResolved === undefined ? {} : { tag: AUTO_TAG }),
571 title: b.title === '' ? UNTITLED : b.title,
572 isContext: row.isContext,
573 lines: lines.map(line => ({
574 text: line.bullet === true ? (line.parts[0]?.text ?? '') : lineText(line),
575 ...(line.color === undefined ? {} : { color: line.color }),
576 ...(line.level === 1 ? { level: 1 as const } : {}),
577 ...(line.bullet === true ? { isBullet: true } : {}),
578 })),
579 }
580 })
581
582/**
583 * 按终端格子宽度折行。中文可以在任意字之间断开;英文单词尽量不拆,单词比一行还长才硬断。
584 * 终端自己的折行只认空格,一长串中文会被当成一个词整段挪到下一行,留下一大片空白
585 */
586export const wrapCells = (text: string, width: number): string[] => {
587 const room = Math.max(4, width)
588 const out: string[] = []
589 let line = ''
590 let used = 0
591 // 当前行末尾那个还没写完的英文单词从哪开始(-1:末尾不是英文单词)
592 let wordAt = -1
593 for (const ch of text) {
594 const w = cellWidth(ch)
595 const isWord = /[A-Za-z0-9_./:@#%+-]/.test(ch)
596 if (used + w > room && line !== '') {
597 if (isWord && wordAt > 0) {
598 // 把没写完的单词整个挪到下一行
599 const carry = line.slice(wordAt)
600 out.push(line.slice(0, wordAt).trimEnd())
601 line = carry
602 } else {
603 out.push(line.trimEnd())
604 line = ''
605 }
606 if (ch === ' ' && line === '') {
607 used = 0
608 wordAt = -1
609 continue
610 }
611 used = cellWidth(line)
612 wordAt = isWord && line !== '' ? 0 : -1
613 }
614 if (isWord) {
615 if (wordAt < 0) wordAt = line.length
616 } else wordAt = -1
617 line += ch
618 used += w
619 }
620 out.push(line)
621 return out
622}
623
624/** 估算一串按钮排成几行,用来算树视图还剩多少高度 */
625export const estimateWrappedRows = (labels: readonly string[], columns: number) => {
626 let rows = 1
627 let used = 0
628 for (const label of labels) {
629 const w = cellWidth(label)
630 if (used > 0 && used + 1 + w > columns) {
631 rows++
632 used = w
633 } else used += (used > 0 ? 1 : 0) + w
634 }
635 return rows
636}
637
638/** 一段文字在给定宽度下大约占几行(中日韩文字占两格) */
639export const textRows = (text: string, columns: number) => Math.max(1, Math.ceil(cellWidth(text) / Math.max(4, columns)))
640
641/**
642 * 侧栏替我发给 Claude 的消息,在对话里只画要点:开头一句,加上每一条的「哪件事:怎么答 / 从什么改成什么」。
643 * 块名括注、每条后面给 Claude 的说明、「回一句收到」「开子代理」这些整段话都不画;ctrl+o 照样看全文
644 */
645export const compactSentText = (text: string) => {
646 const [head = '', ...rest] = text.split('\n')
647 const items = rest
648 .filter(line => line.startsWith('- '))
649 .map(line => line.replace(/([^()]*)(?=:)/, '').replace(/^(- .*?:[^。]*)。.*$/, '$1'))
650 return [head, ...items].join('\n')
651}
652hooks/icons.ts 564 lines1// 由 build_icons.py 生成:Phosphor / Lucide 里挑出来的图标,搬到补充私用区 B(U+100000 起)
2// 终端靠 ~/Library/Fonts 里的 OutlineSidebarPhosphor / OutlineSidebarLucide 两个字体显示;桌面版用下面的 SVG 路径
3// 别手改,换图标就改 build_icons.py 重新生成
4
5export type PhosphorIcon = 'did' | 'files' | 'conclusion' | 'question' | 'resolved' | 'ask' | 'stats' | 'tokens' | 'cache' | 'time' | 'model' | 'skills' | 'mcp' | 'slash' | 'agents' | 'outline' | 'live' | 'last' | 'note' | 'recap' | 'total' | 'empty' | 'folder' | 'celebrate' | 'pending' | 'past' | 'dot' | 'spinner'
6
7export type LucideIcon = 'todo' | 'bot' | 'doing' | 'done' | 'paused' | 'waiting' | 'suspended' | 'cancelled' | 'failed' | 'review' | 'needok' | 'errored' | 'hitlimit' | 'delegated' | 'queued' | 'rework' | 'unclear' | 'yes' | 'no' | 'skip'
8
9/** Phosphor 图标在字体里的字(终端用) */
10export const PH: Record<PhosphorIcon, string> = {
11 did: String.fromCodePoint(0x100000), // wrench
12 files: String.fromCodePoint(0x100001), // file-text
13 conclusion: String.fromCodePoint(0x100002), // lightbulb
14 question: String.fromCodePoint(0x100003), // question
15 resolved: String.fromCodePoint(0x100004), // seal-check
16 ask: String.fromCodePoint(0x100005), // chat-text
17 stats: String.fromCodePoint(0x100006), // chart-bar
18 tokens: String.fromCodePoint(0x100007), // database
19 cache: String.fromCodePoint(0x100008), // target
20 time: String.fromCodePoint(0x100009), // clock
21 model: String.fromCodePoint(0x10000a), // brain
22 skills: String.fromCodePoint(0x10000b), // puzzle-piece
23 mcp: String.fromCodePoint(0x10000c), // plug
24 slash: String.fromCodePoint(0x10000d), // terminal-window
25 agents: String.fromCodePoint(0x10000e), // robot
26 outline: String.fromCodePoint(0x10000f), // receipt
27 live: String.fromCodePoint(0x100010), // hourglass-medium
28 last: String.fromCodePoint(0x100011), // footprints
29 note: String.fromCodePoint(0x100012), // note-pencil
30 recap: String.fromCodePoint(0x100013), // compass
31 total: String.fromCodePoint(0x100014), // sigma
32 empty: String.fromCodePoint(0x100015), // plant
33 folder: String.fromCodePoint(0x100016), // folder
34 celebrate: String.fromCodePoint(0x100017), // confetti
35 pending: String.fromCodePoint(0x100018), // pencil-simple-line
36 past: String.fromCodePoint(0x100019), // clock-counter-clockwise
37 dot: String.fromCodePoint(0x10001a), // circle
38 spinner: String.fromCodePoint(0x10001b), // spinner
39}
40
41/** Lucide 状态图标在字体里的字(终端用) */
42export const LU: Record<LucideIcon, string> = {
43 todo: String.fromCodePoint(0x100100), // circle-dashed
44 bot: String.fromCodePoint(0x100101), // bot
45 doing: String.fromCodePoint(0x100102), // rotate-cw
46 done: String.fromCodePoint(0x100103), // circle-check
47 paused: String.fromCodePoint(0x100104), // pause
48 waiting: String.fromCodePoint(0x100105), // hourglass
49 suspended: String.fromCodePoint(0x100106), // snowflake
50 cancelled: String.fromCodePoint(0x100107), // ban
51 failed: String.fromCodePoint(0x100108), // x
52 review: String.fromCodePoint(0x100109), // eye
53 needok: String.fromCodePoint(0x10010a), // hand
54 errored: String.fromCodePoint(0x10010b), // badge-alert
55 hitlimit: String.fromCodePoint(0x10010c), // gauge
56 delegated: String.fromCodePoint(0x10010d), // send
57 queued: String.fromCodePoint(0x10010e), // alarm-clock
58 rework: String.fromCodePoint(0x10010f), // pencil-line
59 unclear: String.fromCodePoint(0x100110), // message-circle-question
60 yes: String.fromCodePoint(0x100111), // thumbs-up
61 no: String.fromCodePoint(0x100112), // thumbs-down
62 skip: String.fromCodePoint(0x100113), // skip-forward
63}
64
65/** Doing 的 rotate-cw 转 8 个角度,终端里按节拍轮流显示 */
66export const SPIN = [
67 String.fromCodePoint(0x100120),
68 String.fromCodePoint(0x100121),
69 String.fromCodePoint(0x100122),
70 String.fromCodePoint(0x100123),
71 String.fromCodePoint(0x100124),
72 String.fromCodePoint(0x100125),
73 String.fromCodePoint(0x100126),
74 String.fromCodePoint(0x100127),
75]
76
77/** Phosphor 图标的 SVG 路径(桌面版用),方框是 0..size */
78export const PH_SVG: Record<PhosphorIcon, { path: string; size: number }> = {
79 did: {
80 size: 1024,
81 path: [
82 'M907 276Q903 267 895.0 261.5Q887 256 877 256Q871 256 865.5 258.5Q860 261 856 264L694 413L626 398',
83 'L611 330L760 168Q763 164 765.5 158.5Q768 153 768 147Q768 137 762.5 129.0Q757 121 748 117Q724 107',
84 ' 696.5 101.5Q669 96 640 96Q580 96 528 119Q475 141 436.0 180.0Q397 219 375 272Q352 324 352 384Q35',
85 '2 384 352.0 384.0Q352 384 352 384Q352 384 352.0 384.0Q352 384 352 384Q352 416 358.5 445.5Q365 47',
86 '5 377 502L376 500L135 708Q135 708 134.5 708.5Q134 709 133 710Q116 727 106.0 750.5Q96 774 96 800Q',
87 '96 853 133.5 890.5Q171 928 224 928Q250 928 273.5 918.0Q297 908 314 891Q315 890 315.5 889.5Q316 8',
88 '89 316 889L524 648Q550 660 579.5 666.0Q609 672 640 672Q700 672 752 650Q805 627 844.0 588.0Q883 5',
89 '49 905 496Q928 444 928 384Q928 384 928.0 384.0Q928 384 928 384Q928 384 928.0 384.0Q928 384 928 3',
90 '84Q928 354 922.5 326.5Q917 299 906 274L907 276ZM640 608Q610 608 582.5 600.5Q555 593 531 579L532 ',
91 '580Q528 578 524.5 577.0Q521 576 516 576Q509 576 502.5 579.0Q496 582 492 587L268 846Q260 854 248.',
92 '5 859.0Q237 864 224 864Q198 864 179.0 845.0Q160 826 160 800Q160 787 165.0 775.5Q170 764 178 756L',
93 '437 532Q442 528 445.0 521.5Q448 515 448 508Q448 504 447.0 500.0Q446 496 444 492Q431 469 423.5 44',
94 '1.5Q416 414 416 384Q416 338 433 297Q451 256 481.5 225.5Q512 195 553 177Q593 160 640 160Q650 160 ',
95 '659.5 161.0Q669 162 679 163H677L552 298Q549 303 546.5 308.5Q544 314 544 320Q544 322 544.0 323.5Q',
96 '544 325 545 327L567 432Q569 441 576.0 448.0Q583 455 592 457L697 479Q699 480 700.5 480.0Q702 480 ',
97 '704 480Q710 480 715.5 477.5Q721 475 726 471L861 347Q862 355 863.0 364.5Q864 374 864 384Q864 430 ',
98 '846 471Q829 512 798.5 542.5Q768 573 727 590Q686 608 640 608Z',
99 ].join(''),
100 },
101 files: {
102 size: 1024,
103 path: [
104 'M855 329 631 105Q626 101 620.5 98.5Q615 96 608 96Q608 96 608.0 96.0Q608 96 608 96H224Q197 96 178',
105 '.5 114.5Q160 133 160 160V864Q160 891 178.5 909.5Q197 928 224 928H800Q827 928 845.5 909.5Q864 891',
106 ' 864 864V352Q864 352 864.0 352.0Q864 352 864 352Q864 345 861.5 339.5Q859 334 855 329ZM640 205 75',
107 '5 320H640ZM800 864H224V160H576V352Q576 365 585.5 374.5Q595 384 608 384H800ZM672 544Q672 557 662.',
108 '5 566.5Q653 576 640 576H384Q371 576 361.5 566.5Q352 557 352 544Q352 531 361.5 521.5Q371 512 384 ',
109 '512H640Q653 512 662.5 521.5Q672 531 672 544ZM672 672Q672 685 662.5 694.5Q653 704 640 704H384Q371',
110 ' 704 361.5 694.5Q352 685 352 672Q352 659 361.5 649.5Q371 640 384 640H640Q653 640 662.5 649.5Q672',
111 ' 659 672 672Z',
112 ].join(''),
113 },
114 conclusion: {
115 size: 1024,
116 path: [
117 'M704 928Q704 941 694.5 950.5Q685 960 672 960H352Q339 960 329.5 950.5Q320 941 320 928Q320 915 329',
118 '.5 905.5Q339 896 352 896H672Q685 896 694.5 905.5Q704 915 704 928ZM864 416Q864 416 864.0 416.5Q86',
119 '4 417 864 417Q864 459 855 498Q845 537 827.5 572.5Q810 608 785 638Q761 668 730 692L729 693Q718 70',
120 '2 711.0 715.0Q704 728 704 744V768Q704 795 685.5 813.5Q667 832 640 832H384Q357 832 338.5 813.5Q32',
121 '0 795 320 768V744Q320 729 313.5 715.5Q307 702 295 693Q264 669 239 639Q214 608 196.5 573.0Q179 53',
122 '8 170 499Q160 460 160 418Q160 346 186 283Q213 219 260.0 171.0Q307 123 369 94Q432 66 504 64Q505 6',
123 '4 507.5 64.0Q510 64 512 64Q585 64 649 92Q713 119 761.0 167.0Q809 215 836 279Q864 343 864 416Q864',
124 ' 416 864.0 416.0Q864 416 864 416ZM800 416Q800 416 800.0 416.0Q800 416 800 416Q800 356 777 304Q75',
125 '5 251 716.0 212.0Q677 173 624 151Q572 128 512 128Q510 128 508.5 128.0Q507 128 505 128Q447 129 39',
126 '5 153Q344 176 306.0 215.5Q268 255 246 307Q224 359 224 418Q224 486 254.0 544.5Q284 603 334 642L33',
127 '5 643Q357 660 370.5 686.5Q384 713 384 744Q384 744 384.0 744.0Q384 744 384 744V768H640V744Q640 71',
128 '3 653.5 686.5Q667 660 690 643Q740 603 770.0 544.0Q800 485 800 417Q800 417 800.0 416.5Q800 416 80',
129 '0 416ZM736 379Q722 307 671.5 256.5Q621 206 551 193L549 192Q548 192 546.5 192.0Q545 192 544 192Q5',
130 '31 192 521.5 201.5Q512 211 512 224Q512 236 519.5 245.0Q527 254 538 256H539Q588 264 626.0 302.0Q6',
131 '64 340 672 389Q674 401 683.0 408.5Q692 416 704 416Q704 416 704.0 416.0Q704 416 704 416Q705 416 7',
132 '06.5 416.0Q708 416 710 416H709Q721 414 728.5 405.0Q736 396 736 384Q736 383 736.0 381.5Q736 380 7',
133 '36 378Z',
134 ].join(''),
135 },
136 question: {
137 size: 1024,
138 path: [
139 'M560 720Q560 740 546.0 754.0Q532 768 512 768Q492 768 478.0 754.0Q464 740 464 720Q464 700 478.0 6',
140 '86.0Q492 672 512 672Q532 672 546.0 686.0Q560 700 560 720ZM512 288Q446 288 399.0 330.0Q352 372 35',
141 '2 432V448Q352 461 361.5 470.5Q371 480 384 480Q397 480 406.5 470.5Q416 461 416 448V432Q416 399 44',
142 '4.0 375.5Q472 352 512 352Q552 352 580.0 375.5Q608 399 608 432Q608 465 580.0 488.5Q552 512 512 51',
143 '2Q499 512 489.5 521.5Q480 531 480 544V576Q480 589 489.5 598.5Q499 608 512 608Q525 608 534.5 598.',
144 '5Q544 589 544 576V573Q599 563 635.5 523.5Q672 484 672 432Q672 372 625.0 330.0Q578 288 512 288ZM9',
145 '28 512Q928 598 895 674Q863 750 806.5 806.5Q750 863 674 895Q598 928 512 928Q426 928 350 895Q274 8',
146 '63 217.5 806.5Q161 750 129 674Q96 598 96 512Q96 426 129 350Q161 274 217.5 217.5Q274 161 350 129Q',
147 '426 96 512 96Q598 96 674 129Q750 162 806.0 218.0Q862 274 895 350Q928 426 928 512ZM864 512Q864 43',
148 '9 836 375Q809 311 761.0 263.0Q713 215 649 188Q585 160 512 160Q439 160 375 188Q311 215 263.0 263.',
149 '0Q215 311 188 375Q160 439 160 512Q160 585 188 649Q215 713 263.0 761.0Q311 809 375 836Q439 864 51',
150 '2 864Q585 864 649 836Q713 809 761.0 761.0Q809 713 836 649Q864 585 864 512Z',
151 ].join(''),
152 },
153 resolved: {
154 size: 1024,
155 path: [
156 'M903 411Q892 399 881.5 387.5Q871 376 867 365Q863 355 862.0 340.0Q861 325 861 309Q860 280 856.0 2',
157 '49.0Q852 218 829 195Q806 172 775.0 168.0Q744 164 715 163Q699 163 684.0 162.0Q669 161 659 157Q648',
158 ' 153 636.5 142.5Q625 132 613 121Q592 101 567.5 82.5Q543 64 512 64Q481 64 456.5 82.5Q432 101 411 ',
159 '121Q399 132 387.5 142.5Q376 153 365 157Q355 161 340.0 162.0Q325 163 309 163Q280 164 249.0 168.0Q',
160 '218 172 195 195Q172 218 168.0 249.0Q164 280 163 309Q163 325 162.0 340.0Q161 355 157 365Q153 376 ',
161 '142.5 387.5Q132 399 121 411Q101 432 82.5 456.5Q64 481 64 512Q64 543 82.5 567.5Q101 592 121 613Q1',
162 '32 625 142.5 636.5Q153 648 157 659Q161 669 162.0 684.0Q163 699 163 715Q164 744 168.0 775.0Q172 8',
163 '06 195 829Q218 852 249.0 856.0Q280 860 309 861Q325 861 340.0 862.0Q355 863 365 867Q376 871 387.5',
164 ' 881.5Q399 892 411 903Q432 923 456.5 941.5Q481 960 512 960Q543 960 567.5 941.5Q592 923 613 903Q6',
165 '25 892 636.5 881.5Q648 871 659 867Q669 863 684.0 862.0Q699 861 715 861Q744 860 775.0 856.0Q806 8',
166 '52 829 829Q852 806 856.0 775.0Q860 744 861 715Q861 699 862.0 684.0Q863 669 867 659Q871 648 881.5',
167 ' 636.5Q892 625 903 613Q923 592 941.5 567.5Q960 543 960 512Q960 481 941.5 456.5Q923 432 903 411ZM',
168 '857 568Q843 583 829.5 599.0Q816 615 808 635Q800 653 798.5 673.5Q797 694 797 714Q797 735 795.0 75',
169 '4.5Q793 774 784 784Q774 793 754.5 795.0Q735 797 714 797Q694 797 673.5 798.5Q653 800 635 808Q615 ',
170 '816 599.0 829.5Q583 843 568 857Q553 872 538.5 884.0Q524 896 512 896Q500 896 485.0 884.0Q470 872 ',
171 '456 857Q441 843 425.0 829.5Q409 816 389 808Q371 800 350.5 798.5Q330 797 310 797Q289 797 269.5 79',
172 '5.0Q250 793 240 784Q231 774 229.5 754.5Q228 735 227 714Q227 694 225.5 673.5Q224 653 216 635Q208 ',
173 '615 194.5 599.0Q181 583 167 568Q152 553 140.0 538.5Q128 524 128 512Q128 500 140.0 485.0Q152 470 ',
174 '167 456Q181 441 194.5 425.0Q208 409 216 389Q224 371 225.5 350.5Q227 330 227 310Q228 289 229.5 26',
175 '9.5Q231 250 240 240Q250 231 269.5 229.5Q289 228 310 227Q330 227 350.5 225.5Q371 224 389 216Q409 ',
176 '208 425.0 194.5Q441 181 456 167Q471 152 485.5 140.0Q500 128 512 128Q524 128 539.0 140.0Q554 152 ',
177 '568 167Q583 181 599.0 194.5Q615 208 635 216Q653 224 673.5 225.5Q694 227 714 227Q735 227 754.5 22',
178 '9.0Q774 231 784 240Q793 250 795.0 269.5Q797 289 797 310Q797 330 798.5 350.5Q800 371 808 389Q816 ',
179 '409 829.5 425.0Q843 441 857 456Q872 471 884.0 485.5Q896 500 896 512Q896 524 884.0 539.0Q872 554 ',
180 '857 568ZM695 393Q699 398 701.5 403.5Q704 409 704 416Q704 423 701.5 428.5Q699 434 695 439L471 663',
181 'Q466 667 460.5 669.5Q455 672 448 672Q441 672 435.5 669.5Q430 667 425 663L329 567Q325 562 322.5 5',
182 '56.5Q320 551 320 544Q320 531 329.5 521.5Q339 512 352 512Q359 512 364.5 514.5Q370 517 375 521L448',
183 ' 595L649 393Q654 389 659.5 386.5Q665 384 672 384Q679 384 684.5 386.5Q690 389 695 393Z',
184 ].join(''),
185 },
186 ask: {
187 size: 1024,
188 path: [
189 'M864 192H160Q133 192 114.5 210.5Q96 229 96 256V896Q96 896 96.0 896.0Q96 896 96 896Q96 915 106.0 ',
190 '930.5Q116 946 133 954Q139 957 146.0 958.5Q153 960 160 960Q172 960 182.5 956.0Q193 952 201 945L33',
191 '2 832H864Q891 832 909.5 813.5Q928 795 928 768V256Q928 229 909.5 210.5Q891 192 864 192ZM864 768H3',
192 '20Q314 768 308.5 770.0Q303 772 299 776L160 896V256H864ZM352 448Q352 435 361.5 425.5Q371 416 384 ',
193 '416H640Q653 416 662.5 425.5Q672 435 672 448Q672 461 662.5 470.5Q653 480 640 480H384Q371 480 361.',
194 '5 470.5Q352 461 352 448ZM352 576Q352 563 361.5 553.5Q371 544 384 544H640Q653 544 662.5 553.5Q672',
195 ' 563 672 576Q672 589 662.5 598.5Q653 608 640 608H384Q371 608 361.5 598.5Q352 589 352 576Z',
196 ].join(''),
197 },
198 stats: {
199 size: 1024,
200 path: [
201 'M896 800H864V160Q864 147 854.5 137.5Q845 128 832 128H608Q595 128 585.5 137.5Q576 147 576 160V320',
202 'H384Q371 320 361.5 329.5Q352 339 352 352V512H192Q179 512 169.5 521.5Q160 531 160 544V800H128Q115',
203 ' 800 105.5 809.5Q96 819 96 832Q96 845 105.5 854.5Q115 864 128 864H896Q909 864 918.5 854.5Q928 84',
204 '5 928 832Q928 819 918.5 809.5Q909 800 896 800ZM640 192H800V800H640ZM416 384H576V800H416ZM224 576',
205 'H352V800H224Z',
206 ].join(''),
207 },
208 tokens: {
209 size: 1024,
210 path: [
211 'M512 96Q431 96 361 113Q291 131 239.0 161.0Q187 191 158 232Q128 273 128 320V704Q128 751 158 792Q1',
212 '87 833 239.0 863.0Q291 893 361 911Q431 928 512 928Q593 928 663 911Q733 893 785.0 863.0Q837 833 8',
213 '66 792Q896 751 896 704V320Q896 273 866 232Q837 191 785.0 161.0Q733 131 663 113Q593 96 512 96ZM83',
214 '2 512Q832 541 809.5 569.0Q787 597 746 620Q699 645 639.0 658.5Q579 672 512 672Q445 672 385.0 658.',
215 '5Q325 645 278 620Q237 597 214.5 569.0Q192 541 192 512V445Q218 468 252 486Q286 504 327.0 517.0Q36',
216 '8 530 415 537Q461 544 512 544Q563 544 609 537Q656 530 697.0 517.0Q738 504 772 486Q806 468 832 44',
217 '5ZM278 212Q325 187 385.0 173.5Q445 160 512 160Q579 160 639.0 173.5Q699 187 746 212Q787 235 809.5',
218 ' 263.0Q832 291 832 320Q832 349 809.5 377.0Q787 405 746 428Q699 453 639.0 466.5Q579 480 512 480Q4',
219 '45 480 385.0 466.5Q325 453 278 428Q237 405 214.5 377.0Q192 349 192 320Q192 291 214.5 263.0Q237 2',
220 '35 278 212ZM746 812Q699 837 639.0 850.5Q579 864 512 864Q445 864 385.0 850.5Q325 837 278 812Q237 ',
221 '789 214.5 761.0Q192 733 192 704V637Q218 660 252 678Q286 696 327.0 709.0Q368 722 415 729Q461 736 ',
222 '512 736Q563 736 609 729Q656 722 697.0 709.0Q738 696 772 678Q806 660 832 637V704Q832 733 809.5 76',
223 '1.0Q787 789 746 812Z',
224 ].join(''),
225 },
226 cache: {
227 size: 1024,
228 path: [
229 'M887 333Q907 372 917.5 417.5Q928 463 928 512Q928 598 895 674Q863 750 806.5 806.5Q750 863 674 896',
230 'Q598 929 512 929Q426 929 350 896Q274 863 217.5 806.5Q161 750 128 674Q95 598 95 512Q95 426 128 35',
231 '0Q161 274 217.5 217.5Q274 161 350 129Q426 96 512 96Q590 96 659.5 122.5Q729 149 783 196L873 105Q8',
232 '78 101 883.5 98.5Q889 96 896 96Q909 96 918.5 105.5Q928 115 928 128Q928 135 925.5 140.5Q923 146 9',
233 '19 151L535 535Q530 539 524.5 541.5Q519 544 512 544Q499 544 489.5 534.5Q480 525 480 512Q480 505 4',
234 '82.5 499.5Q485 494 489 489L600 378Q582 366 559.5 359.0Q537 352 512 352Q446 352 399.0 399.0Q352 4',
235 '46 352 512Q352 578 399.0 625.0Q446 672 512 672Q578 672 625.0 625.0Q672 578 672 512Q672 509 672.0',
236 ' 507.0Q672 505 672 502V503Q672 502 672.0 501.5Q672 501 672 501Q672 488 681.0 478.5Q690 469 704 4',
237 '69Q717 469 726.0 477.5Q735 486 736 499Q736 502 736.0 505.5Q736 509 736 512Q736 558 718 599Q701 6',
238 '40 670.5 670.5Q640 701 599 718Q558 736 512 736Q466 736 425 718Q384 701 353.5 670.5Q323 640 306 5',
239 '99Q288 558 288 512Q288 466 306 425Q323 384 353.5 353.5Q384 323 425 306Q466 288 512 288Q550 288 5',
240 '84.5 300.0Q619 312 647 333H646L737 242Q692 204 634.5 182.0Q577 160 513 160Q440 160 376 188Q312 2',
241 '16 264.0 263.5Q216 311 189 375Q161 439 161 512Q161 585 189 649Q216 713 264.0 760.5Q312 808 376 8',
242 '36Q440 864 513 864Q585 864 649 836Q714 808 761.5 760.5Q809 713 837 649Q864 585 864 512Q864 471 8',
243 '55.0 432.0Q846 393 829 358L830 360Q828 357 827.5 353.5Q827 350 827 346Q827 333 836.0 323.5Q845 3',
244 '14 859 314Q868 314 876.0 319.0Q884 324 887 332V333Z',
245 ].join(''),
246 },
247 time: {
248 size: 1024,
249 path: [
250 'M512 96Q426 96 350 129Q274 161 217.5 217.5Q161 274 129 350Q96 426 96 512Q96 598 129 674Q161 750 ',
251 '217.5 806.5Q274 863 350 895Q426 928 512 928Q598 928 674 895Q750 863 806.5 806.5Q863 750 895 674Q',
252 '928 598 928 512Q928 426 895 350Q862 274 806.0 218.0Q750 162 674 129Q598 96 512 96ZM512 864Q439 8',
253 '64 375 836Q311 809 263.0 761.0Q215 713 188 649Q160 585 160 512Q160 439 188 375Q215 311 263.0 263',
254 '.0Q311 215 375 188Q439 160 512 160Q585 160 649 188Q713 215 761.0 263.0Q809 311 836 375Q864 439 8',
255 '64 512Q864 585 836 649Q809 713 761.0 761.0Q713 809 649 836Q585 864 512 864ZM768 512Q768 525 758.',
256 '5 534.5Q749 544 736 544H512Q499 544 489.5 534.5Q480 525 480 512V288Q480 275 489.5 265.5Q499 256 ',
257 '512 256Q525 256 534.5 265.5Q544 275 544 288V480H736Q749 480 758.5 489.5Q768 499 768 512Z',
258 ].join(''),
259 },
260 model: {
261 size: 1024,
262 path: [
263 'M992 496Q992 429 957.0 375.0Q922 321 865 294H864V288Q864 209 807.5 152.5Q751 96 672 96Q622 96 58',
264 '0.0 119.5Q538 143 512 181V182Q486 143 444.0 119.5Q402 96 352 96Q273 96 216.5 152.5Q160 209 160 2',
265 '88V294Q103 321 67.5 375.0Q32 429 32 496Q32 563 67.0 617.0Q102 671 159 698H160V704Q160 783 216.5 ',
266 '839.5Q273 896 352 896Q402 896 444.0 872.5Q486 849 512 811V810Q538 849 580.0 872.5Q622 896 672 89',
267 '6Q751 896 807.5 839.5Q864 783 864 704V698Q921 671 956.5 617.0Q992 563 992 496ZM352 832Q303 832 2',
268 '66.5 799.0Q230 766 225 718Q232 719 240.0 719.5Q248 720 256 720H288Q301 720 310.5 710.5Q320 701 3',
269 '20 688Q320 675 310.5 665.5Q301 656 288 656H256Q256 656 256.0 656.0Q256 656 256 656Q190 656 143.0',
270 ' 609.0Q96 562 96 496Q96 444 125.5 403.0Q155 362 202 345H203Q212 342 218.0 333.5Q224 325 224 315V',
271 '288Q224 235 261.5 197.5Q299 160 352 160Q405 160 442.5 197.5Q480 235 480 288V561Q455 538 422.0 52',
272 '5.0Q389 512 352 512Q352 512 352.0 512.0Q352 512 352 512Q339 512 329.5 521.5Q320 531 320 544Q320 ',
273 '557 329.5 566.5Q339 576 352 576Q405 576 442.5 613.5Q480 651 480 704Q480 757 442.5 794.5Q405 832 ',
274 '352 832ZM768 656H736Q723 656 713.5 665.5Q704 675 704 688Q704 701 713.5 710.5Q723 720 736 720H768',
275 'Q776 720 784.5 719.5Q793 719 800 718H799Q794 766 757.5 799.0Q721 832 672 832Q619 832 581.5 794.5',
276 'Q544 757 544 704Q544 651 581.5 613.5Q619 576 672 576Q672 576 672.0 576.0Q672 576 672 576Q685 576',
277 ' 694.5 566.5Q704 557 704 544Q704 531 694.5 521.5Q685 512 672 512Q672 512 672.0 512.0Q672 512 672',
278 ' 512Q635 512 602.0 525.0Q569 538 544 561V288Q544 235 581.5 197.5Q619 160 672 160Q725 160 762.5 1',
279 '97.5Q800 235 800 288V315Q800 325 806.0 333.5Q812 342 821 345Q868 362 898.0 403.0Q928 444 928 496',
280 'Q928 562 881.0 609.0Q834 656 768 656Q768 656 768.0 656.0Q768 656 768 656ZM832 448Q832 461 822.5 ',
281 '470.5Q813 480 800 480H784Q724 480 682.0 438.0Q640 396 640 336V320Q640 307 649.5 297.5Q659 288 67',
282 '2 288Q685 288 694.5 297.5Q704 307 704 320V336Q704 369 727.5 392.5Q751 416 784 416H800Q813 416 82',
283 '2.5 425.5Q832 435 832 448ZM240 480H224Q211 480 201.5 470.5Q192 461 192 448Q192 435 201.5 425.5Q2',
284 '11 416 224 416H240Q273 416 296.5 392.5Q320 369 320 336V320Q320 307 329.5 297.5Q339 288 352 288Q3',
285 '65 288 374.5 297.5Q384 307 384 320V336Q384 396 342.0 438.0Q300 480 240 480Z',
286 ].join(''),
287 },
288 skills: {
289 size: 1024,
290 path: [
291 'M881 634Q877 632 873.0 630.5Q869 629 864 629Q860 629 856.5 630.0Q853 631 850 632Q843 636 834.0 6',
292 '38.0Q825 640 816 640Q783 640 759.5 616.5Q736 593 736 560Q736 527 759.5 503.5Q783 480 816 480Q825',
293 ' 480 834.0 482.0Q843 484 851 488H850Q853 489 856.5 490.0Q860 491 864 491Q877 491 886.5 481.5Q896',
294 ' 472 896 459Q896 459 896.0 459.0Q896 459 896 459V288Q896 261 877.5 242.5Q859 224 832 224H687Q688',
295 ' 220 688.0 216.0Q688 212 688 208Q688 208 688.0 208.0Q688 208 688 208Q688 177 676.0 150.0Q664 123',
296 ' 643 103Q623 85 598.0 74.5Q573 64 544 64Q487 64 446.0 102.5Q405 141 400 197V198Q400 200 400.0 20',
297 '2.5Q400 205 400 208Q400 212 400.0 216.0Q400 220 401 225V224H256Q229 224 210.5 242.5Q192 261 192 ',
298 '288V417Q188 416 184.0 416.0Q180 416 176 416Q176 416 176.0 416.0Q176 416 176 416Q145 416 118.0 42',
299 '8.0Q91 440 71 461Q53 481 42.5 506.0Q32 531 32 560Q32 562 32.0 564.5Q32 567 32 570V569Q36 623 74.',
300 '0 661.5Q112 700 165 704H166Q168 704 170.5 704.0Q173 704 176 704Q180 704 184.5 704.0Q189 704 193 ',
301 '703H192V832Q192 859 210.5 877.5Q229 896 256 896H832Q859 896 877.5 877.5Q896 859 896 832V661Q896 ',
302 '661 896.0 661.0Q896 661 896 661Q896 653 892.0 646.0Q888 639 881 634ZM832 832H256V661Q256 661 256',
303 '.0 661.0Q256 661 256 661Q256 648 246.5 638.5Q237 629 224 629Q220 629 216.5 630.0Q213 631 210 632',
304 'Q203 636 194.0 638.0Q185 640 176 640Q143 640 119.5 616.5Q96 593 96 560Q96 527 119.5 503.5Q143 48',
305 '0 176 480Q185 480 194.0 482.0Q203 484 211 488H210Q213 489 216.5 490.0Q220 491 224 491Q237 491 24',
306 '6.5 481.5Q256 472 256 459Q256 459 256.0 459.0Q256 459 256 459V288H443Q443 288 443.0 288.0Q443 28',
307 '8 443 288Q456 288 465.5 278.5Q475 269 475 256Q475 252 474.0 248.5Q473 245 472 242Q468 235 466.0 ',
308 '226.0Q464 217 464 208Q464 175 487.5 151.5Q511 128 544 128Q577 128 600.5 151.5Q624 175 624 208Q62',
309 '4 217 622.0 226.0Q620 235 616 243V242Q615 245 614.0 248.5Q613 252 613 256Q613 269 622.5 278.5Q63',
310 '2 288 645 288Q645 288 645.0 288.0Q645 288 645 288H832V417Q828 416 824.0 416.0Q820 416 816 416Q81',
311 '3 416 810.5 416.0Q808 416 805 416H806Q749 420 710.5 461.5Q672 503 672 560Q672 620 714.0 662.0Q75',
312 '6 704 816 704Q816 704 816.0 704.0Q816 704 816 704Q816 704 816.0 704.0Q816 704 816 704Q820 704 82',
313 '4.5 704.0Q829 704 833 703H832Z',
314 ].join(''),
315 },
316 mcp: {
317 size: 1024,
318 path: [
319 'M951 265Q946 261 940.5 258.5Q935 256 928 256Q921 256 915.5 258.5Q910 261 905 265L768 403L621 256',
320 'L759 119Q763 114 765.5 108.5Q768 103 768 96Q768 83 758.5 73.5Q749 64 736 64Q729 64 723.5 66.5Q71',
321 '8 69 713 73L576 211L471 105Q466 101 460.5 98.5Q455 96 448 96Q435 96 425.5 105.5Q416 115 416 128Q',
322 '416 135 418.5 140.5Q421 146 425 151L451 176L239 388Q217 410 204.5 439.0Q192 468 192 501Q192 534 ',
323 '204.5 563.5Q217 593 239 614L302 677L105 873Q101 878 98.5 883.5Q96 889 96 896Q96 909 105.5 918.5Q',
324 '115 928 128 928Q135 928 140.5 925.5Q146 923 151 919L347 722L410 785Q432 807 461.0 819.5Q490 832 ',
325 '523 832Q556 832 585.0 819.5Q614 807 636 785L848 573L873 599Q878 603 883.5 605.5Q889 608 896 608Q',
326 '909 608 918.5 598.5Q928 589 928 576Q928 569 925.5 563.5Q923 558 919 553L813 448L951 311Q955 306 ',
327 '957.5 300.5Q960 295 960 288Q960 281 957.5 275.5Q955 270 951 265ZM591 740Q578 753 560.5 760.5Q543',
328 ' 768 523 768Q503 768 485.5 760.5Q468 753 455 740L284 569Q271 556 263.5 538.5Q256 521 256 501Q256',
329 ' 481 263.5 463.5Q271 446 284 433L496 221L803 528Z',
330 ].join(''),
331 },
332 slash: {
333 size: 1024,
334 path: [
335 'M512 512Q512 512 512.0 512.0Q512 512 512 512Q512 520 509.0 526.5Q506 533 500 537L340 665Q336 668',
336 ' 331.0 670.0Q326 672 320 672Q307 672 297.5 662.5Q288 653 288 640Q288 632 291.0 625.5Q294 619 300',
337 ' 615L429 512L300 409Q294 405 291.0 398.5Q288 392 288 384Q288 371 297.5 361.5Q307 352 320 352Q326',
338 ' 352 331.0 354.0Q336 356 340 359L500 487Q506 491 509.0 497.5Q512 504 512 512Q512 512 512.0 512.0',
339 'Q512 512 512 512ZM704 608H544Q531 608 521.5 617.5Q512 627 512 640Q512 653 521.5 662.5Q531 672 54',
340 '4 672H704Q717 672 726.5 662.5Q736 653 736 640Q736 627 726.5 617.5Q717 608 704 608ZM928 224V800Q9',
341 '28 827 909.5 845.5Q891 864 864 864H160Q133 864 114.5 845.5Q96 827 96 800V224Q96 197 114.5 178.5Q',
342 '133 160 160 160H864Q891 160 909.5 178.5Q928 197 928 224ZM864 800V224H160V800Z',
343 ].join(''),
344 },
345 agents: {
346 size: 1024,
347 path: [
348 'M800 192H544V64Q544 51 534.5 41.5Q525 32 512 32Q499 32 489.5 41.5Q480 51 480 64V192H224Q171 192 ',
349 '133.5 229.5Q96 267 96 320V768Q96 821 133.5 858.5Q171 896 224 896H800Q853 896 890.5 858.5Q928 821',
350 ' 928 768V320Q928 267 890.5 229.5Q853 192 800 192ZM864 768Q864 795 845.5 813.5Q827 832 800 832H22',
351 '4Q197 832 178.5 813.5Q160 795 160 768V320Q160 293 178.5 274.5Q197 256 224 256H800Q827 256 845.5 ',
352 '274.5Q864 293 864 320ZM656 544H368Q322 544 289.0 577.0Q256 610 256 656Q256 702 289.0 735.0Q322 7',
353 '68 368 768H656Q702 768 735.0 735.0Q768 702 768 656Q768 610 735.0 577.0Q702 544 656 544ZM560 608V',
354 '704H464V608ZM320 656Q320 636 334.0 622.0Q348 608 368 608H400V704H368Q348 704 334.0 690.0Q320 676',
355 ' 320 656ZM656 704H624V608H656Q676 608 690.0 622.0Q704 636 704 656Q704 676 690.0 690.0Q676 704 65',
356 '6 704ZM288 432Q288 412 302.0 398.0Q316 384 336 384Q356 384 370.0 398.0Q384 412 384 432Q384 452 3',
357 '70.0 466.0Q356 480 336 480Q316 480 302.0 466.0Q288 452 288 432ZM640 432Q640 412 654.0 398.0Q668 ',
358 '384 688 384Q708 384 722.0 398.0Q736 412 736 432Q736 452 722.0 466.0Q708 480 688 480Q668 480 654.',
359 '0 466.0Q640 452 640 432Z',
360 ].join(''),
361 },
362 outline: {
363 size: 1024,
364 path: [
365 'M288 416Q288 403 297.5 393.5Q307 384 320 384H704Q717 384 726.5 393.5Q736 403 736 416Q736 429 726',
366 '.5 438.5Q717 448 704 448H320Q307 448 297.5 438.5Q288 429 288 416ZM320 576H704Q717 576 726.5 566.',
367 '5Q736 557 736 544Q736 531 726.5 521.5Q717 512 704 512H320Q307 512 297.5 521.5Q288 531 288 544Q28',
368 '8 557 297.5 566.5Q307 576 320 576ZM928 224V832Q928 845 918.5 854.5Q909 864 896 864Q892 864 888.5',
369 ' 863.0Q885 862 881 861H882L768 804L654 861Q651 862 647.5 863.0Q644 864 640 864Q636 864 632.5 863',
370 '.0Q629 862 625 861H626L512 804L398 861Q395 862 391.5 863.0Q388 864 384 864Q380 864 376.5 863.0Q3',
371 '73 862 369 861H370L256 804L142 861Q139 862 135.5 863.0Q132 864 128 864Q115 864 105.5 854.5Q96 84',
372 '5 96 832V224Q96 197 114.5 178.5Q133 160 160 160H864Q891 160 909.5 178.5Q928 197 928 224ZM864 224',
373 'H160V780L242 739Q245 738 248.5 737.0Q252 736 256 736Q260 736 263.5 737.0Q267 738 271 739H270L384',
374 ' 796L498 739Q501 738 504.5 737.0Q508 736 512 736Q516 736 519.5 737.0Q523 738 527 739H526L640 796',
375 'L754 739Q757 738 760.5 737.0Q764 736 768 736Q772 736 775.5 737.0Q779 738 783 739H782L864 780Z',
376 ].join(''),
377 },
378 live: {
379 size: 1024,
380 path: [
381 'M800 303V160Q800 133 781.5 114.5Q763 96 736 96H288Q261 96 242.5 114.5Q224 133 224 160V304Q224 32',
382 '0 231.0 333.0Q238 346 249 355H250L459 512L250 669Q238 678 231.0 691.0Q224 704 224 720V864Q224 89',
383 '1 242.5 909.5Q261 928 288 928H736Q763 928 781.5 909.5Q800 891 800 864V721Q800 706 793.0 692.5Q78',
384 '6 679 775 670L565 512L775 354Q786 345 793.0 331.5Q800 318 800 303ZM288 160H736V303L713 320H309L2',
385 '88 304ZM512 472 395 384H628ZM736 864H288V720L480 576V672Q480 685 489.5 694.5Q499 704 512 704Q525',
386 ' 704 534.5 694.5Q544 685 544 672V576L736 721Z',
387 ].join(''),
388 },
389 last: {
390 size: 1024,
391 path: [
392 'M832 736H608Q595 736 585.5 745.5Q576 755 576 768V816Q576 876 618.0 918.0Q660 960 720 960Q780 960',
393 ' 822.0 918.0Q864 876 864 816V768Q864 755 854.5 745.5Q845 736 832 736ZM800 816Q800 849 776.5 872.',
394 '5Q753 896 720 896Q687 896 663.5 872.5Q640 849 640 816V800H800ZM416 640H192Q179 640 169.5 649.5Q1',
395 '60 659 160 672V720Q160 780 202.0 822.0Q244 864 304 864Q364 864 406.0 822.0Q448 780 448 720V672Q4',
396 '48 659 438.5 649.5Q429 640 416 640ZM384 720Q384 753 360.5 776.5Q337 800 304 800Q271 800 247.5 77',
397 '6.5Q224 753 224 720V704H384ZM304 64Q269 64 236.0 94.0Q203 124 177 180Q156 224 143 277Q130 329 12',
398 '8.5 381.5Q127 434 138 482Q150 529 178 564Q182 569 188.5 572.5Q195 576 203 576Q203 576 203.0 576.',
399 '0Q203 576 203 576H405Q405 576 405.0 576.0Q405 576 405 576Q412 576 418.5 572.5Q425 569 430 564Q45',
400 '8 530 470 482Q481 434 479.5 381.5Q478 329 465 277Q452 224 431 180Q404 124 371.5 94.0Q339 64 304 ',
401 '64ZM389 512H219Q202 485 196 447Q190 409 193.0 367.5Q196 326 207 284Q218 243 235 208Q254 167 273.',
402 '5 147.5Q293 128 304 128Q315 128 334.5 147.5Q354 167 373 208Q390 243 401 284Q412 326 415.0 367.5Q',
403 '418 409 412 447Q406 485 389 512ZM619 672H821Q821 672 821.0 672.0Q821 672 821 672Q829 672 835.5 6',
404 '69.0Q842 666 846 660Q874 626 886 578Q897 530 895.5 477.5Q894 425 881 373Q868 320 847 276Q821 220',
405 ' 788.0 190.0Q755 160 720 160Q685 160 652.5 190.0Q620 220 593 276Q572 320 559 373Q546 425 544.5 4',
406 '77.5Q543 530 554 578Q566 626 594 660Q599 665 605.0 668.5Q611 672 619 672ZM651 304Q670 263 689.5 ',
407 '243.5Q709 224 720 224Q731 224 750.5 243.5Q770 263 789 304Q806 339 817 380Q828 422 831.0 463.5Q83',
408 '4 505 828 543Q822 581 805 608H635Q618 581 612 543Q606 505 609.0 463.5Q612 422 623 380Q634 339 65',
409 '1 304Z',
410 ].join(''),
411 },
412 note: {
413 size: 1024,
414 path: [
415 'M919 233 791 105Q786 101 780.5 98.5Q775 96 768 96Q761 96 755.5 98.5Q750 101 745 105L361 489Q357 ',
416 '494 354.5 499.5Q352 505 352 512Q352 512 352.0 512.0Q352 512 352 512V640Q352 653 361.5 662.5Q371 ',
417 '672 384 672H512Q512 672 512.0 672.0Q512 672 512 672Q519 672 524.5 669.5Q530 667 535 663L919 279Q',
418 '923 274 925.5 268.5Q928 263 928 256Q928 249 925.5 243.5Q923 238 919 233ZM499 608H416V525L672 269',
419 'L755 352ZM800 307 717 224 768 173 851 256ZM896 512V832Q896 859 877.5 877.5Q859 896 832 896H192Q1',
420 '65 896 146.5 877.5Q128 859 128 832V192Q128 165 146.5 146.5Q165 128 192 128H512Q525 128 534.5 137',
421 '.5Q544 147 544 160Q544 173 534.5 182.5Q525 192 512 192H192V832H832V512Q832 499 841.5 489.5Q851 4',
422 '80 864 480Q877 480 886.5 489.5Q896 499 896 512Z',
423 ].join(''),
424 },
425 recap: {
426 size: 1024,
427 path: [
428 'M512 96Q426 96 350 129Q274 161 217.5 217.5Q161 274 129 350Q96 426 96 512Q96 598 129 674Q161 750 ',
429 '217.5 806.5Q274 863 350 895Q426 928 512 928Q598 928 674 895Q750 863 806.5 806.5Q863 750 895 674Q',
430 '928 598 928 512Q928 426 895 350Q862 274 806.0 218.0Q750 162 674 129Q598 96 512 96ZM512 864Q439 8',
431 '64 375 836Q311 809 263.0 761.0Q215 713 188 649Q160 585 160 512Q160 439 188 375Q215 311 263.0 263',
432 '.0Q311 215 375 188Q439 160 512 160Q585 160 649 188Q713 215 761.0 263.0Q809 311 836 375Q864 439 8',
433 '64 512Q864 585 836 649Q809 713 761.0 761.0Q713 809 649 836Q585 864 512 864ZM690 291 434 419Q429 ',
434 '422 425.5 425.5Q422 429 419 433V434L291 690Q290 693 289.0 696.5Q288 700 288 704Q288 717 297.5 72',
435 '6.5Q307 736 320 736Q320 736 320.0 736.0Q320 736 320 736Q324 736 327.5 735.0Q331 734 335 733H334L',
436 '590 605Q595 602 598.5 598.5Q602 595 605 591V590L733 334Q734 331 735.0 327.5Q736 324 736 320Q736 ',
437 '307 726.5 297.5Q717 288 704 288Q700 288 696.5 289.0Q693 290 689 291H690ZM552 552 392 632 472 472',
438 ' 633 392Z',
439 ].join(''),
440 },
441 total: {
442 size: 1024,
443 path: [
444 'M736 288V224H323L537 492Q540 496 542.0 501.0Q544 506 544 512Q544 518 542.0 523.0Q540 528 537 532',
445 'L323 800H736V736Q736 723 745.5 713.5Q755 704 768 704Q781 704 790.5 713.5Q800 723 800 736V832Q800',
446 ' 845 790.5 854.5Q781 864 768 864H256Q256 864 256.0 864.0Q256 864 256 864Q243 864 233.5 854.5Q224',
447 ' 845 224 832Q224 826 226.0 821.0Q228 816 231 812L471 512L231 212Q228 208 226.0 203.0Q224 198 224',
448 ' 192Q224 179 233.5 169.5Q243 160 256 160Q256 160 256.0 160.0Q256 160 256 160H768Q781 160 790.5 1',
449 '69.5Q800 179 800 192V288Q800 301 790.5 310.5Q781 320 768 320Q755 320 745.5 310.5Q736 301 736 288',
450 'Z',
451 ].join(''),
452 },
453 empty: {
454 size: 1024,
455 path: [
456 'M991 192Q990 179 981.5 170.5Q973 162 961 161H960Q883 157 814 166Q745 175 687.5 197.0Q630 219 586',
457 ' 253Q543 286 516 330Q480 389 480.0 457.5Q480 526 515 593Q496 616 481.5 643.0Q467 670 459 699L458',
458 ' 701L393 636Q417 587 416.0 537.5Q415 488 389 446Q370 413 337 388Q305 364 263.0 347.5Q221 331 171',
459 ' 325Q120 318 63 321Q51 322 42.5 330.5Q34 339 33 351Q30 408 37 458Q44 509 60.0 551.0Q76 593 101 6',
460 '25Q126 657 158 677Q179 690 203.0 697.0Q227 704 254 704Q280 704 304.0 698.0Q328 692 349 681H348L4',
461 '48 781V896Q448 909 457.5 918.5Q467 928 480 928Q493 928 502.5 918.5Q512 909 512 896V778Q512 778 5',
462 '12.0 778.0Q512 778 512 777Q512 738 525.0 702.0Q538 666 561 638Q590 653 624.0 662.0Q658 671 694 6',
463 '72Q694 672 694.5 672.0Q695 672 695 672Q730 672 762.5 662.5Q795 653 823 636H822Q866 609 899 566Q9',
464 '33 522 955.0 464.5Q977 407 986 338Q995 269 991 192ZM191 622Q145 595 120.0 532.0Q95 469 96 384Q18',
465 '1 383 244.0 408.0Q307 433 334 479Q349 503 351.5 530.5Q354 558 344 587L247 489Q242 485 236.5 483.',
466 '0Q231 481 225 481Q211 481 202.0 490.0Q193 499 193 513Q193 519 195.0 524.5Q197 530 201 535L299 63',
467 '2Q270 642 242.5 639.5Q215 637 191 622ZM788 582Q748 606 702.0 608.0Q656 610 608 590L823 375Q827 3',
468 '70 829.0 364.5Q831 359 831 353Q831 339 822.0 330.0Q813 321 799 321Q793 321 787.5 323.0Q782 325 7',
469 '77 329L563 544Q542 496 544.0 450.0Q546 404 571 363Q592 329 626 302Q661 276 707.0 258.0Q753 240 8',
470 '08 231Q864 223 928 224Q929 288 921 344Q912 399 894.0 445.0Q876 491 850 526Q823 561 788 582Z',
471 ].join(''),
472 },
473 folder: {
474 size: 1024,
475 path: [
476 'M864 288H525L416 179Q407 170 395.5 165.0Q384 160 371 160Q371 160 371.0 160.0Q371 160 371 160H160',
477 'Q133 160 114.5 178.5Q96 197 96 224V802Q96 828 114.0 846.0Q132 864 158 864H868Q893 864 910.5 846.',
478 '5Q928 829 928 804V352Q928 325 909.5 306.5Q891 288 864 288ZM160 224H371L435 288H160ZM864 800H160V',
479 '352H864Z',
480 ].join(''),
481 },
482 celebrate: {
483 size: 1024,
484 path: [
485 'M446 211Q437 202 425.5 197.0Q414 192 401 192Q381 192 365.0 203.5Q349 215 342 233V234L132 811Q130',
486 ' 816 129.0 821.5Q128 827 128 833Q128 859 146.5 877.5Q165 896 191 896Q197 896 202.5 895.0Q208 894',
487 ' 213 892L790 682Q809 675 820.5 659.0Q832 643 832 623Q832 610 827.0 598.5Q822 587 813 578ZM413 75',
488 '1 273 611 325 467 557 699ZM193 831 249 678 346 776ZM624 675 349 400 401 257 767 622ZM640 288Q640',
489 ' 271 644.5 255.0Q649 239 656 225L655 226Q671 194 700.0 177.0Q729 160 768 160Q788 160 801.5 153.0',
490 'Q815 146 823 131Q827 123 829.0 114.5Q831 106 832 96Q832 83 841.5 73.5Q851 64 864 64Q877 64 886.5',
491 ' 73.5Q896 83 896 96Q896 96 896.0 96.0Q896 96 896 96Q896 135 867.0 179.5Q838 224 768 224Q748 224 ',
492 '734.5 231.0Q721 238 713 253Q709 261 707.0 269.5Q705 278 704 288Q704 301 694.5 310.5Q685 320 672 ',
493 '320Q659 320 649.5 310.5Q640 301 640 288Q640 288 640.0 288.0Q640 288 640 288ZM544 160V64Q544 51 5',
494 '53.5 41.5Q563 32 576 32Q589 32 598.5 41.5Q608 51 608 64V160Q608 173 598.5 182.5Q589 192 576 192Q',
495 '563 192 553.5 182.5Q544 173 544 160ZM951 489Q955 494 957.5 499.5Q960 505 960 512Q960 525 950.5 5',
496 '34.5Q941 544 928 544Q921 544 915.5 541.5Q910 539 905 535L841 471Q837 466 834.5 460.5Q832 455 832',
497 ' 448Q832 435 841.5 425.5Q851 416 864 416Q871 416 876.5 418.5Q882 421 887 425ZM970 318 874 350Q87',
498 '2 351 869.5 351.5Q867 352 864 352Q851 352 841.5 342.5Q832 333 832 320Q832 309 838.0 301.0Q844 29',
499 '3 854 290L950 258Q952 257 954.5 256.5Q957 256 960 256Q973 256 982.5 265.5Q992 275 992 288Q992 29',
500 '9 986.0 307.0Q980 315 970 318Z',
501 ].join(''),
502 },
503 pending: {
504 size: 1024,
505 path: [
506 'M909 293 731 115Q722 106 710.5 101.0Q699 96 685 96Q672 96 660.5 101.0Q649 106 640 115L147 608Q13',
507 '8 617 133.0 628.5Q128 640 128 653Q128 653 128.0 653.0Q128 653 128 653V832Q128 859 146.5 877.5Q16',
508 '5 896 192 896H864Q877 896 886.5 886.5Q896 877 896 864Q896 851 886.5 841.5Q877 832 864 832H461L90',
509 '9 384Q918 375 923.0 363.5Q928 352 928 339Q928 325 923.0 313.5Q918 302 909 293ZM371 832H192V653L5',
510 '44 301L723 480ZM768 435 589 256 685 160 864 339Z',
511 ].join(''),
512 },
513 past: {
514 size: 1024,
515 path: [
516 'M544 320V494L688 581Q696 585 700.0 592.0Q704 599 704 608Q704 621 694.5 630.5Q685 640 672 640Q667',
517 ' 640 663.0 639.0Q659 638 655 635H656L496 539Q488 535 484.0 528.0Q480 521 480 512Q480 512 480.0 5',
518 '12.0Q480 512 480 512V320Q480 307 489.5 297.5Q499 288 512 288Q525 288 534.5 297.5Q544 307 544 320',
519 'ZM512 128Q512 128 511.5 128.0Q511 128 511 128Q432 128 362.0 158.0Q292 188 240 241Q219 263 199.0 ',
520 '284.5Q179 306 160 328V256Q160 243 150.5 233.5Q141 224 128 224Q115 224 105.5 233.5Q96 243 96 256V',
521 '416Q96 429 105.5 438.5Q115 448 128 448H288Q301 448 310.5 438.5Q320 429 320 416Q320 403 310.5 393',
522 '.5Q301 384 288 384H196Q217 359 239.0 334.5Q261 310 286 286Q329 242 387.5 217.0Q446 192 512 192Q5',
523 '78 192 636 217Q695 242 738.5 285.5Q782 329 807 387Q832 446 832 512Q832 578 807 637Q782 695 738.5',
524 ' 738.5Q695 782 636 807Q578 832 512 832Q448 832 391.5 808.5Q335 785 292 745Q288 741 282.5 738.5Q2',
525 '77 736 270 736Q257 736 247.5 745.5Q238 755 238 768Q238 775 241.0 781.0Q244 787 248 791Q300 840 3',
526 '67.5 868.0Q435 896 512 896Q591 896 661 866Q731 836 783.0 783.5Q835 731 866 661Q896 592 896 512Q8',
527 '96 433 866 363Q835 293 783.0 240.5Q731 188 661 158Q591 128 512 128Z',
528 ].join(''),
529 },
530 dot: {
531 size: 1024,
532 path: [
533 'M512 96Q426 96 350 129Q274 161 217.5 217.5Q161 274 129 350Q96 426 96 512Q96 598 129 674Q161 750 ',
534 '217.5 806.5Q274 863 350 895Q426 928 512 928Q598 928 674 895Q750 863 806.5 806.5Q863 750 895 674Q',
535 '928 598 928 512Q928 426 895 350Q862 274 806.0 218.0Q750 162 674 129Q598 96 512 96ZM512 864Q439 8',
536 '64 375 836Q311 809 263.0 761.0Q215 713 188 649Q160 585 160 512Q160 439 188 375Q215 311 263.0 263',
537 '.0Q311 215 375 188Q439 160 512 160Q585 160 649 188Q713 215 761.0 263.0Q809 311 836 375Q864 439 8',
538 '64 512Q864 585 836 649Q809 713 761.0 761.0Q713 809 649 836Q585 864 512 864Z',
539 ].join(''),
540 },
541 spinner: {
542 size: 1024,
543 path: [
544 'M544 128V256Q544 269 534.5 278.5Q525 288 512 288Q499 288 489.5 278.5Q480 269 480 256V128Q480 115',
545 ' 489.5 105.5Q499 96 512 96Q525 96 534.5 105.5Q544 115 544 128ZM693 363Q700 363 705.5 360.5Q711 3',
546 '58 716 354L806 263Q811 259 813.5 253.0Q816 247 816 240Q816 227 806.5 217.5Q797 208 784 208Q777 2',
547 '08 771.0 210.5Q765 213 761 218L670 308Q666 313 663.5 318.5Q661 324 661 331Q661 344 670.5 353.5Q6',
548 '80 363 693 363ZM896 480H768Q755 480 745.5 489.5Q736 499 736 512Q736 525 745.5 534.5Q755 544 768 ',
549 '544H896Q909 544 918.5 534.5Q928 525 928 512Q928 499 918.5 489.5Q909 480 896 480ZM716 670Q711 666',
550 ' 705.5 664.0Q700 662 694 662Q680 662 671.0 671.0Q662 680 662 694Q662 700 664.0 705.5Q666 711 670',
551 ' 716L761 806Q765 811 771.0 813.5Q777 816 784 816Q797 816 806.5 806.5Q816 797 816 784Q816 777 813',
552 '.5 771.0Q811 765 806 761ZM512 736Q499 736 489.5 745.5Q480 755 480 768V896Q480 909 489.5 918.5Q49',
553 '9 928 512 928Q525 928 534.5 918.5Q544 909 544 896V768Q544 755 534.5 745.5Q525 736 512 736ZM308 6',
554 '70 218 761Q213 765 210.5 771.0Q208 777 208 784Q208 797 217.5 806.5Q227 816 240 816Q247 816 253.0',
555 ' 813.5Q259 811 263 806L354 716Q358 711 360.0 705.5Q362 700 362 694Q362 680 353.0 671.0Q344 662 3',
556 '30 662Q324 662 318.5 664.0Q313 666 308 670ZM288 512Q288 499 278.5 489.5Q269 480 256 480H128Q115 ',
557 '480 105.5 489.5Q96 499 96 512Q96 525 105.5 534.5Q115 544 128 544H256Q269 544 278.5 534.5Q288 525',
558 ' 288 512ZM263 218Q259 213 253.0 210.5Q247 208 240 208Q227 208 217.5 217.5Q208 227 208 240Q208 24',
559 '7 210.5 253.0Q213 259 218 263L308 354Q313 358 318.5 360.0Q324 362 330 362Q344 362 353.0 353.0Q36',
560 '2 344 362 330Q362 324 360.0 318.5Q358 313 354 308Z',
561 ].join(''),
562 },
563}
564hooks/status.ts 143 lines1import type { Status } from '../types'
2import { LU, SPIN } from './icons'
3import type { LucideIcon } from './icons'
4
5/**
6 * 任务状态照 Obsidian 里 todoseq 的配置来(关键词、分组、颜色、流转、图标):
7 * .obsidian/plugins/todoseq/data.json 和 styles.css 末尾「关键词按状态上色」「关键词图标」两节。
8 * 图标是 Lucide(todoseq 照 lucide-animated 做的动效),颜色是那边压低饱和度的那套,按浅色底调的。
9 */
10export type StatusGroup = 'inactive' | 'active' | 'waiting' | 'completed'
11
12export type StatusInfo = {
13 /** 显示出来的词,首字母大写 */
14 label: string
15 color: string
16 group: StatusGroup
17 icon: LucideIcon
18 /** 交给模型判断状态时的说明 */
19 meaning: string
20 /** 停下来等我处理的:算进「需要我做的」 */
21 isForMe?: true
22 /** 只给我验收时用,总结的模型不挑这个状态 */
23 isVerdict?: true
24}
25
26export const STATUS_INFO: Record<Status, StatusInfo> = {
27 TODO: { label: 'Todo', color: '#5c86b4', group: 'inactive', icon: 'todo', meaning: '定下要做、还没开始' },
28 TODOFORAI: { label: 'Todo4AI', color: '#5c86b4', group: 'inactive', icon: 'bot', meaning: '留给 AI 之后去做的事' },
29 DOING: { label: 'Doing', color: '#c8635f', group: 'active', icon: 'doing', meaning: '还没做完,下一轮接着做' },
30 TOREVIEW: { label: 'ToReview', color: '#b39a3e', group: 'inactive', icon: 'review', meaning: '做完了,等用户看一眼结果', isForMe: true },
31 REWORK: { label: 'Rework', color: '#c27c4e', group: 'inactive', icon: 'rework', meaning: '用户验收时认为方向对、细节要改', isVerdict: true },
32 UNCLEAR: { label: 'Unclear?', color: '#7d8fb3', group: 'inactive', icon: 'unclear', meaning: '用户没看懂做了什么,等解释', isVerdict: true },
33 NEEDOK: { label: 'NeedOK?', color: '#bf7a45', group: 'inactive', icon: 'needok', meaning: '停在等用户批准、确认或选择上', isForMe: true },
34 ERRORED: { label: 'Errored', color: '#93606b', group: 'inactive', icon: 'errored', meaning: '出错停下了,需要用户处理', isForMe: true },
35 HITLIMIT: { label: 'Hit Limit!', color: '#8a5a8c', group: 'inactive', icon: 'hitlimit', meaning: '额度或用量上限到了,等重置' },
36 QUEUED: { label: 'Queued', color: '#6f86a8', group: 'inactive', icon: 'queued', meaning: '排上队了,到点自己接着跑' },
37 DELEGATED: { label: 'Delegated', color: '#5b9690', group: 'inactive', icon: 'delegated', meaning: '交给子代理或别的 Agent 在跑' },
38 PAUSED: { label: 'Paused', color: '#b39a3e', group: 'inactive', icon: 'paused', meaning: '停在半路(被打断),之后再接着做' },
39 WAITING: { label: 'Waiting', color: '#8f7fb6', group: 'waiting', icon: 'waiting', meaning: '在等用户或外部的东西', isForMe: true },
40 SUSPENDED: { label: 'Suspended', color: '#a17f66', group: 'waiting', icon: 'suspended', meaning: '先搁置,暂时不做' },
41 DONE: { label: 'Done', color: '#629c7f', group: 'completed', icon: 'done', meaning: '做完了' },
42 YES: { label: 'Yes', color: '#5f9e6e', group: 'completed', icon: 'yes', meaning: '用户回答了「是」,同意', isVerdict: true },
43 NO: { label: 'No', color: '#a4706b', group: 'completed', icon: 'no', meaning: '用户回答了「否」,不同意', isVerdict: true },
44 SKIP: { label: 'Skip', color: '#8a8f98', group: 'completed', icon: 'skip', meaning: '用户跳过了这件事,不用做也不用再提', isVerdict: true },
45 FAILED: { label: 'Failed', color: '#93606b', group: 'completed', icon: 'failed', meaning: '做了但没成功' },
46 CANCELLED: { label: 'Cancelled', color: '#a4a8ac', group: 'completed', icon: 'cancelled', meaning: '放弃了,不做了' },
47}
48
49export const STATUSES = Object.keys(STATUS_INFO) as Status[]
50
51/**
52 * S / ⌘/ 循环:照 todoseq 的流转,TODO → DOING → DONE;TOREVIEW 按一下是验收通过,到 DONE(要先看过证据)。
53 * 别的(PAUSED、NEEDOK…)都回 DOING
54 */
55export const nextStatus = (st: Status): Status => {
56 if (st === 'TODO') return 'DOING'
57 if (st === 'DOING' || st === 'TOREVIEW') return 'DONE'
58 if (st === 'DONE' || st === 'YES' || st === 'NO' || st === 'SKIP' || st === 'FAILED' || st === 'CANCELLED') return 'TODO'
59 return 'DOING'
60}
61
62/**
63 * B 循环:我验收时下的几种「不通过」,从轻到重:要改、没看懂、没做成、搁置、不做了(⇧ 加首字母可以直接标)。
64 * Errored、Hit Limit! 是 Agent 那边报给我的,不在这里,由 mod 和总结的模型来标
65 */
66export const BAD_STATUSES: readonly Status[] = ['REWORK', 'UNCLEAR', 'FAILED', 'SUSPENDED', 'CANCELLED']
67
68export const nextBadStatus = (st: Status): Status => BAD_STATUSES[(BAD_STATUSES.indexOf(st) + 1) % BAD_STATUSES.length]!
69
70export const isOpenStatus = (st: Status) => STATUS_INFO[st].group !== 'completed'
71
72export const isForMe = (st: Status) => STATUS_INFO[st].isForMe === true
73
74/** 状态和后面的字之间固定空两格,不按最长的状态词补齐 */
75export const STATUS_GAP = ' '
76
77/** 终端里的状态:Lucide 字 + 首字母大写的词;Doing 的图标按 frame 转 */
78export const statusText = (st: Status, frame = 0) => {
79 const info = STATUS_INFO[st]
80 const glyph = st === 'DOING' ? (SPIN[frame % SPIN.length] ?? LU.doing) : LU[info.icon]
81 return `${glyph} ${info.label}`
82}
83
84/** 旧版本存下的状态换成现在的写法 */
85export const normalizeStatus = (st: string): Status => {
86 const up = st.toUpperCase().replace(/[^A-Z0-9]/g, '')
87 if (up === 'CANCELED') return 'CANCELLED'
88 if (up === 'TODO4AI' || up === 'TODO2AI' || up === 'TODOTOAI') return 'TODOFORAI'
89 if (up === 'NOW' || up === 'INPROGRESS') return 'DOING'
90 if (up === 'LATER') return 'TODO'
91 if (up === 'WAIT') return 'WAITING'
92 return (STATUSES as string[]).includes(up) ? (up as Status) : 'DONE'
93}
94
95// ---------- 桌面版:todoseq 原样的 SVG 和动效 ----------
96
97const P = (d: string, cls?: string) => `<path${cls ? ` class="${cls}"` : ''} d="${d}"${cls?.includes('draw') ? ' pathLength="1"' : ''}/>`
98
99/** 照搬 todoseq main.js「关键词前面的动效小图标」里的 ICON */
100const BODY: Record<LucideIcon, string> = {
101 todo: ['M10.1 2.182a10 10 0 0 1 3.8 0', 'M17.609 3.721a10 10 0 0 1 2.69 2.7', 'M21.818 10.1a10 10 0 0 1 0 3.8', 'M20.279 17.609a10 10 0 0 1-2.7 2.69',
102 'M13.9 21.818a10 10 0 0 1-3.8 0', 'M6.391 20.279a10 10 0 0 1-2.69-2.7', 'M2.182 13.9a10 10 0 0 1 0-3.8', 'M3.721 6.391a10 10 0 0 1 2.7-2.69']
103 .map((d, i) => `<path class="seg" style="--i:${i}" d="${d}"/>`).join(''),
104 bot: P('M12 8V4H8') + '<rect x="4" y="8" width="16" height="12" rx="2"/>' + P('M2 14h2') + P('M20 14h2') + '<g class="eyes"><line x1="15" x2="15" y1="13" y2="15"/><line x1="9" x2="9" y1="13" y2="15"/></g>',
105 doing: '<g class="spin">' + P('M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8') + P('M21 3v5h-5') + '</g>',
106 done: '<circle cx="12" cy="12" r="10"/>' + P('m9 12 2 2 4-4', 'draw'),
107 paused: '<rect class="l" x="6" y="4" width="4" height="16" rx="1"/><rect class="r" x="14" y="4" width="4" height="16" rx="1"/>',
108 waiting: '<g class="flip">' + P('M5 22h14') + P('M5 2h14') + P('M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22') + P('M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2') + '</g>',
109 suspended: '<g class="wob">' + ['m10 20-1.25-2.5L6 18', 'M10 4 8.75 6.5 6 6', 'm14 20 1.25-2.5L18 18', 'm14 4 1.25 2.5L18 6', 'm17 21-3-6h-4', 'm17 3-3 6 1.5 3',
110 'M2 12h6.5L10 9', 'm20 10-1.5 2 1.5 2', 'M22 12h-6.5L14 15', 'm4 10 1.5 2L4 14', 'm7 21 3-6-1.5-3', 'm7 3 3 6h4'].map(d => P(d)).join('') + '</g>',
111 cancelled: '<circle class="draw" pathLength="1" cx="12" cy="12" r="10"/>' + P('m4.9 4.9 14.2 14.2', 'draw slash'),
112 failed: P('M18 6 6 18', 'draw') + P('m6 6 12 12', 'draw late'),
113 review: P('M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0', 'lid') + '<circle class="pupil" cx="12" cy="12" r="3"/>',
114 needok: '<g class="wave">' + P('M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2') + P('M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2') + P('M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8') + P('M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15') + '</g>',
115 errored: '<g class="shake">' + P('M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z') + '<line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></g>',
116 hitlimit: P('m12 14 4-4', 'needle') + P('M3.34 19a10 10 0 1 1 17.32 0'),
117 delegated: '<g class="fly">' + P('M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z') + P('m21.854 2.147-10.94 10.939') + '</g>',
118 rework: P('M13 21h8') + P('m15 5 4 4') + P('M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z', 'draw'),
119 unclear: P('M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719') + P('M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3', 'draw') + P('M12 17h.01'),
120 yes: P('M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z') + P('M7 10v12'),
121 no: P('M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z') + P('M17 14V2'),
122 skip: P('M21 4v16') + P('M6.029 4.285A2 2 0 0 0 3 6v12a2 2 0 0 0 3.029 1.715l9.997-5.998a2 2 0 0 0 .003-3.432z'),
123 queued: '<g class="ring">' + P('M18 20.5L19.5 22') + P('M6 20.5L4.5 22') + P('M21 13C21 17.968 16.968 22 12 22C7.032 22 3 17.968 3 13C3 8.032 7.032 4 12 4C16.968 4 21 8.032 21 13Z') + P('M15.339 15.862L12.549 14.197C12.063 13.909 11.667 13.216 11.667 12.649V8.95898') + '</g>' +
124 '<g class="bells">' + P('M18 2L21.747 5.31064') + P('M6 2L2.25304 5.31064') + '</g>',
125}
126
127/** 动效照 todoseq styles.css:Doing 一直转;Todo、Done 只在刚换成这个状态时播一次 */
128const CSS = `svg *{transform-box:view-box;transform-origin:12px 12px}
129.spin{animation:s 1.6s linear infinite}@keyframes s{to{transform:rotate(360deg)}}
130.hitlimit .needle{transform:translate(.5px,3px) rotate(72deg)}
131.fire .draw{stroke-dasharray:1;animation:d .4s ease-out both}.fire .draw.late{animation-delay:.2s}.fire .draw.slash{animation-delay:.5s}
132@keyframes d{from{stroke-dashoffset:1;opacity:0}25%{opacity:1}to{stroke-dashoffset:0;opacity:1}}
133.fire .seg{animation:f .3s ease-out both;animation-delay:calc((var(--i) + 1)*.1s)}@keyframes f{from{opacity:0}to{opacity:1}}`
134
135/** 一个状态图标的 SVG;isFire 是这个状态刚换上去,播一次出场动效 */
136export const statusSvg = (st: Status, isFire: boolean) => {
137 const info = STATUS_INFO[st]
138 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-1 -1 26 26" fill="none" stroke="${info.color}" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" class="${info.icon}${isFire ? ' fire' : ''}"><style>${CSS}</style>${BODY[info.icon]}</svg>`
139}
140
141/** 状态刚换上去之后多久内算「刚换」,播出场动效(和 todoseq 一样 2.5 秒) */
142export const FIRE_MS = 2500
143hooks/tree-view.tsx 229 lines1import type { ClientModule, RenderElement } from 'claude-code'
2
3import { SPIN } from './icons'
4import { STATUS_GAP, cellWidth, wrapCells } from './outline'
5import type { TreeLine, TreeProps, TreeRow } from './outline'
6
7/** 详情行的字能用几格:去掉续行的竖线、小节缩进和圆点 */
8const lineRoom = (line: TreeLine, room: number) => room - (line.level === 1 ? 3 : 0) - (line.isBullet === true ? 2 : 0) - 1
9
10/** 折出来的续行前面:竖线照画,后面补空格到 width 格,让引导线一行一行连下去 */
11const padTo = (guide: string, width: number) => guide + ' '.repeat(Math.max(0, width - cellWidth(guide)))
12
13/** 标题前面那一截(树枝、箭头、状态、角标)占几格 */
14const headWidth = (row: TreeRow) =>
15 cellWidth(row.prefix) + cellWidth(row.arrow) + 1 + cellWidth(row.status) + STATUS_GAP.length + (row.badge === undefined ? 0 : cellWidth(row.badge.text) + 3) + (row.tag === undefined ? 0 : cellWidth(row.tag) + 3)
16
17/** 菜单画在光标所在行下面,带一圈边框 */
18const menuHeight = (count: number) => count + 2
19
20/** Doing 的图标换成第 frame 个角度 */
21const spun = (status: string, frame: number) => {
22 const [first = '', ...rest] = Array.from(status)
23 return `${SPIN[frame % SPIN.length] ?? first}${rest.join('')}`
24}
25
26/** top:窗口从第几行(终端的行,不是块)开始;wheel:上次看到的滚轮累计格数;at:上次画的时候光标在第几块;isWheeled:光标上次动过以后用滚轮翻过 */
27type State = { top: number; menu: number | null; frame: number; wheel?: number; at?: number; isWheeled?: boolean }
28
29/** 摊平以后的一项:哪一块的、从第几行开始、占几行(菜单一整个算一项,不切半个);gap 是块前面的空行,menu 是回车菜单 */
30type Flat = { row: number; start: number; height: number; kind?: 'gap' | 'menu'; node: RenderElement }
31
32/** 滚轮一格翻几行 */
33const WHEEL_LINES = 3
34
35/** 计时器只开一个,它看这个变量决定要不要转(模块里只有一棵树) */
36let hasSpinning = false
37
38/**
39 * ⌘/(终端把 ⌘ 报成 meta 时)或 Ctrl+/:在 Todo、Doing、Done 之间切。
40 * 有的终端把 Ctrl+/ 报成 0x1f 这个控制字符;按编码比,源码里不写控制字符(mod 清单的扫描会把它当成 UI 兼容性问题)
41 */
42const isCycleKey = (key: string, ctrl: boolean, meta: boolean) =>
43 (key === '/' && (ctrl || meta)) || (key === '_' && ctrl) || (key.length === 1 && key.charCodeAt(0) === 0x1f)
44
45/**
46 * 大纲的树:点一下拿到键盘。只用方向键、Tab、回车、空格、数字就能做完所有操作,
47 * 中文输入法开着也不受影响(字母键会被输入法拿去组字,到不了这里)。
48 */
49const TreeView: ClientModule<TreeProps, State> = (props, surface) => {
50 const { Box, Text } = surface.elements
51 const columns = surface.columns > 0 ? surface.columns : 60
52 const rows = props.rows
53 const scope = props.scope ?? 'tree'
54 const at = rows.findIndex(r => r.isAt)
55 const menuItems = rows[at]?.lines === undefined || at < 0 ? props.menu : rows[at]!.isItem === true ? props.itemMenu : props.menu
56 const menu = surface.state?.menu ?? null
57 const frame = surface.state?.frame ?? 0
58 const isMenuOpen = scope === 'tree' && menu !== null && at >= 0 && menuItems.length > 0
59 // 留两行给「上面 / 下面还有几行」
60 const room = Math.max(3, props.height - 2)
61 hasSpinning = rows.some(r => r.isSpinning)
62
63 // 先把所有块摊成一行一行:块之间的空行、标题的每个折行、每个字段行,光标那块下面的菜单
64 const flats: Flat[] = []
65 let total = 0
66 const push = (row: number, node: RenderElement, height = 1, kind?: Flat['kind']) => {
67 flats.push({ row, start: total, height, node, ...(kind === undefined ? {} : { kind }) })
68 total += height
69 }
70 rows.forEach((row, i) => {
71 if (row.isGap) push(i, <Text> </Text>, 1, 'gap')
72 wrapCells(row.title, columns - headWidth(row)).forEach((part, k) =>
73 push(
74 i,
75 <Box flexDirection="row">
76 {/* 标题前面这一截不许被挤,挤了状态图标和字会被拆成两行 */}
77 {k === 0 ? (
78 <Box flexShrink={0} flexDirection="row">
79 {row.prefix !== '' && <Text dimColor>{row.prefix}</Text>}
80 <Text inverse={row.isAt && scope === 'tree'}>{row.arrow}</Text>
81 <Text> </Text>
82 <Text color={row.statusColor} bold={row.isOpen} dimColor={row.isContext}>{row.isSpinning ? spun(row.status, frame) : row.status}</Text>
83 <Text>{STATUS_GAP}</Text>
84 {row.badge !== undefined && <Text color={row.badge.color}>[{row.badge.text}] </Text>}
85 {row.tag !== undefined && <Text dimColor>[{row.tag}] </Text>}
86 </Box>
87 ) : (
88 <Box flexShrink={0}>
89 <Text dimColor>{padTo(row.cont, headWidth(row))}</Text>
90 </Box>
91 )}
92 <Text dimColor={row.isContext || row.isClosed} strikethrough={row.isCanceled} bold={(row.isAt && scope === 'tree') || (row.isOpen && !row.isContext)} color={row.badge?.color}>
93 {part}
94 </Text>
95 </Box>,
96 ),
97 )
98 for (const line of row.lines) {
99 const lead = `${row.cont}${line.level === 1 ? ' ' : ''}${line.isBullet === true ? '• ' : ''}`
100 wrapCells(line.text, lineRoom(line, columns - cellWidth(row.cont))).forEach((part, k) =>
101 push(
102 i,
103 <Box flexDirection="row">
104 <Box flexShrink={0}>
105 <Text dimColor>{k === 0 ? lead : padTo(row.cont, cellWidth(lead))}</Text>
106 </Box>
107 <Text color={line.color} dimColor={line.color === undefined}>{part}</Text>
108 </Box>,
109 ),
110 )
111 }
112 if (isMenuOpen && i === at) {
113 push(
114 i,
115 <Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
116 {menuItems.map((item, n) => (
117 <Text inverse={n === menu} color={item.color}>{item.label}</Text>
118 ))}
119 </Box>,
120 menuHeight(menuItems.length),
121 'menu',
122 )
123 }
124 })
125
126 const wheel = props.wheel ?? 0
127 const scrolled = (wheel - (surface.state?.wheel ?? wheel)) * WHEEL_LINES
128 const isCursorMoved = surface.state === undefined || surface.state.at !== at
129 // 滚轮翻过以后,窗口停在翻到的地方,光标再动才拉回来;没翻过就和以前一样,光标那块始终在窗口里
130 const isWheeled = scrolled !== 0 || (surface.state?.isWheeled === true && !isCursorMoved)
131
132 // 窗口按行取,不按块:底下不会空出一截,翻也是一行一行地翻
133 const lastTop = Math.max(0, total - room)
134 let top = Math.max(0, Math.min((surface.state?.top ?? 0) + scrolled, lastTop))
135 const own = flats.filter(f => f.row === at && f.kind !== 'gap')
136 if (at >= 0 && !isWheeled && own.length > 0) {
137 const from = own[0]!.start
138 const to = own.at(-1)!.start + own.at(-1)!.height
139 // 比窗口还高的块先露出开头;不然挪到刚好整块看得见
140 if (to - from > room || from < top) top = from
141 else if (to > top + room) top = to - room
142 }
143 // 菜单比窗口还高时也照画,从这一块的开头往下延伸,不能因为放不下就整个不见
144 const shown = flats.filter(f => f.start >= top && (f.start + f.height <= top + room || (f.kind === 'menu' && f.start < top + room)))
145 const below = total - (shown.length === 0 ? top : shown.at(-1)!.start + shown.at(-1)!.height)
146
147 if (surface.state === undefined) {
148 surface.setState({ top, menu: null, frame: 0, wheel, at, isWheeled })
149 // Doing 的圆圈箭头转起来:8 个角度,1.6 秒一圈(0.8 秒一圈太快,看着闪)
150 surface.every(200, () => {
151 if (hasSpinning) surface.setState({ ...(surface.state ?? { top: 0, menu: null, frame: 0 }), frame: ((surface.state?.frame ?? 0) + 1) % SPIN.length })
152 })
153 } else if (surface.state.top !== top || surface.state.wheel !== wheel || surface.state.at !== at || surface.state.isWheeled !== isWheeled) {
154 surface.setState({ ...surface.state, top, wheel, at, isWheeled })
155 }
156
157 const set = (patch: Partial<State>) => surface.setState({ top, menu, frame, wheel, at, isWheeled, ...patch })
158 const runMenu = (i: number) => {
159 const item = menuItems[i]
160 set({ menu: null })
161 if (item !== undefined) surface.post({ type: 'action', action: item.id })
162 }
163
164 surface.onKey(event => {
165 const ctrl = event.ctrl === true
166 const meta = event.meta === true
167 // 视图栏:只能选视图和进入,碰不到树
168 if (scope === 'tabs') {
169 if (event.key === 'left' || event.key === 'right') surface.post({ type: 'action', action: event.key === 'left' ? 'view-prev' : 'view-next' })
170 else if (/^[1-9!@#!@#¡™£]$/.test(event.key)) surface.post({ type: 'key', key: event.key, shift: event.shift === true, ctrl: false, meta: event.meta === true })
171 else if (event.key === 'return' || event.key === 'down' || event.key === 'j' || event.key === ' ') surface.post({ type: 'action', action: 'enter-view' })
172 return
173 }
174 if (event.key === 'backspace' && !isMenuOpen) {
175 surface.post({ type: 'action', action: 'leave-view' })
176 return
177 }
178 if (isCycleKey(event.key, ctrl, meta)) {
179 surface.post({ type: 'action', action: 'cycle' })
180 return
181 }
182 if (isMenuOpen) {
183 const n = menuItems.length
184 if (event.key === 'up') set({ menu: (menu - 1 + n) % n })
185 else if (event.key === 'down') set({ menu: (menu + 1) % n })
186 else if (event.key === 'return' || event.key === ' ' || event.key === 'right') runMenu(menu)
187 else set({ menu: null })
188 return
189 }
190 if (event.key === 'return' && at >= 0) {
191 set({ menu: 0 })
192 return
193 }
194 surface.post({ type: 'key', key: event.key === ' ' ? 'space' : event.key, shift: event.shift === true, ctrl, meta })
195 })
196
197 surface.onPointer(event => {
198 if (event.type !== 'down') return
199 const y = event.y - (top > 0 ? 1 : 0)
200 for (const f of shown) {
201 const at = f.start - top
202 if (y < at || y >= at + f.height) continue
203 // 菜单:上下各一行边框
204 if (f.kind === 'menu') {
205 const item = y - at - 1
206 if (item >= 0 && item < menuItems.length) runMenu(item)
207 return
208 }
209 if (f.kind !== 'gap') {
210 surface.post({ type: 'click', id: rows[f.row]!.key })
211 return
212 }
213 }
214 // 没点中哪一行(空白处、「↑ n」、空视图),树也拿到了键盘:照样切英文
215 surface.post({ type: 'focus' })
216 })
217
218 return (
219 <Box flexDirection="column">
220 {top > 0 && <Text dimColor> ↑ {String(top)} 行</Text>}
221 {props.empty !== undefined && <Text dimColor>{props.empty}</Text>}
222 {shown.map(f => f.node)}
223 {below > 0 && <Text dimColor> ↓ {String(below)} 行</Text>}
224 </Box>
225 )
226}
227
228export default TreeView
229types/index.d.ts 182 lines1/** 照 Obsidian todoseq 的关键词(见 hooks/status.ts) */
2export type Status =
3 | 'TODO' | 'TODOFORAI' | 'DOING' | 'TOREVIEW' | 'REWORK' | 'UNCLEAR' | 'YES' | 'NO' | 'SKIP' | 'NEEDOK' | 'ERRORED' | 'HITLIMIT' | 'QUEUED' | 'DELEGATED' | 'PAUSED'
4 | 'WAITING' | 'SUSPENDED' | 'DONE' | 'FAILED' | 'CANCELLED'
5
6/** turn:我发的一轮;agent / fork:Claude Code 切出去的子任务;btw:/btw 的旁支问题;recap:回顾 */
7export type Kind = 'turn' | 'agent' | 'fork' | 'btw' | 'recap'
8
9/** 一轮对话的技术信息,全部来自事件本身,不额外调用模型;只记技能、MCP、斜杠命令和用量 */
10export type Tech = {
11 durationMs: number
12 model: string
13 input: number
14 output: number
15 cacheRead: number
16 cacheWrite: number
17 /** 用到的斜杠命令(不含参数);Read、Grep、Bash 这类内置工具不记 */
18 slash: string[]
19 skills: string[]
20 /** MCP 工具,`服务名/工具名` */
21 mcp: string[]
22 agents: number
23 /** 子代理花掉的 token(输入 + 输出 + 缓存) */
24 agentTokens: number
25 /** 大纲自己总结这一轮花掉的 token */
26 outlineTokens: number
27}
28
29/** 一块 = 一轮对话:我发一条消息,到 Claude Code 执行完。 */
30export type Block = {
31 id: string
32 kind: Kind
33 /** 第几轮,从 1 开始;子任务和 btw 跟着它所在的那一轮 */
34 turn: number
35 /** 我的原话(截断) */
36 ask: string
37 /** 我要它做什么,一句话 */
38 title: string
39 status: Status
40 /** 它做了什么 */
41 did: string[]
42 /** 这一轮的证据:改的文件、跑的命令和结果,全部来自工具调用本身,不经过模型 */
43 evidence?: Evidence[]
44 /** did 每一条挂的证据(下标对 evidence);模型只能从 evidence 里挑,挑不出就是空的 */
45 didEvidence?: number[][]
46 /** 看过证据的时间:ToReview 要先看过证据才能改成 Done */
47 reviewedAt?: number
48 /** 验收时被我放行过(ToReview → Done) */
49 reviewPassed?: true
50 /** 验收时被我拦下过(ToReview → 别的状态) */
51 reviewBlocked?: true
52 /** 标了 Done 之后被我重新打开过 */
53 isReopened?: true
54 conclusion: string
55 questions: string[]
56 /** 需要我做什么;解决后保留,按状态显示成已完成 */
57 needsMe: string[]
58 /** 「需要我做」每一条单独改过的状态(下标对 needsMe);没改过的跟着块的状态走 */
59 needsMeStatus?: (Status | null)[]
60 /** 「需要我做」里哪几条是问我的问题(下标):答 Yes / No 要马上告诉 Claude;其余是交给我做的事 */
61 needsMeAsks?: number[]
62 /** 「需要我做」每一条是不是后来的对话自动了结的(下标对 needsMe):user 是我在消息里说了,claude 是 Claude 说替我办了;我手动改过就清掉 */
63 needsMeAuto?: (Resolver | null)[]
64 /** 块本身是不是被后来的对话自动了结的(没有「需要我做」条目、只是在等我的块) */
65 autoResolved?: Resolver
66 /** 状态最近一次变的时候,桌面版刚变时播一次出场动效 */
67 statusAt?: number
68 /** 父块 id,null 为顶层 */
69 parent: string | null
70 /** 层级被我手动改过,AI 不再动 */
71 isManual: boolean
72 /** 状态被我手动改过,AI 不再动 */
73 isStatusManual: boolean
74 /** 还在等模型总结 */
75 isPending: boolean
76 files: string[]
77 commands: number
78 /** 在第几轮被后来的对话解决 */
79 resolvedIn?: number
80 /** 我那条消息在对话里的 id,用来跳转 */
81 messageId?: string
82 /** 这一轮在对话里的助手消息和工具调用行,正文折叠时藏起来 */
83 rowIds?: string[]
84 tech?: Tech
85 /** 这一轮还没结束时的实时进度;结束后去掉 */
86 live?: Live
87}
88
89/** 谁了结了一件等我的事:user 是我在消息里说做完了、给了答复;claude 是 Claude Code 说它替我办了或者不用我做了 */
90export type Resolver = 'user' | 'claude'
91
92/** 总结时模型给的一条「这一轮了结了哪件等我的事」:key 是块 id,或者「块 id#第几条」 */
93export type Resolution = { key: string; by: Resolver }
94
95/** 一条证据:edit 是改了一个文件,run 是跑了一条命令;isOk 看工具有没有报错,tail 是输出的最后一行 */
96export type Evidence = { kind: 'edit' | 'run'; text: string; isOk: boolean; tail?: string }
97
98/** 验收和返工的指标,从块上的标记数出来;跨会话就是把各个会话的存档加起来 */
99export type Metrics = {
100 /** 挂过证据的「做了」一共几条,其中几条有证据 */
101 did: number
102 proven: number
103 /** 我验收过几块,其中拦下几块 */
104 reviewed: number
105 blocked: number
106 /** 标过 Done 的块,其中被重新打开的 */
107 finished: number
108 reopened: number
109 /** 我手动纠正过层级的块 */
110 fixed: number
111}
112
113export type Live = {
114 startedAt: number
115 /** 到目前为止调了几次工具 */
116 steps: number
117 /** 最近一步做了什么 */
118 last: string
119 /** 手动刷新时 haiku 写的小结 */
120 note?: string
121 refreshedAt?: number
122 isRefreshing?: boolean
123}
124
125export type View = 'all' | 'me' | 'asks' | 'done' | 'conclusions' | 'tech'
126
127export type Level = 'overview' | 'contents' | 'all' | 'undone'
128
129/** 正文折叠:我那条消息改画成一行摘要,这一轮其余的行藏起来 */
130export type Body = { labels: Record<string, string>; hide: string[] }
131
132/** 回顾:Claude Code 自带的 recap(claude),或大纲自己生成的(outline) */
133export type Recap = { text: string; source: 'claude' | 'outline'; at: number; isPending?: boolean }
134
135export type AgendaItem = { sid: string; blockId: string; title: string; needsMe: string[]; status: Status }
136
137/** 我在侧栏里改的一处状态,下次发消息时告诉 Claude */
138export type StatusChange = { key: string; what: string; from: Status; to: Status }
139
140export type AgendaGroup = { sid: string; label: string; isCurrent: boolean; items: AgendaItem[] }
141
142declare module 'claude-code' {
143 interface PluginState {
144 'outline-sidebar': {
145 blocks: Block[]
146 /** 子块被折起来的块 */
147 folded: string[]
148 /** 整棵都了结、本该自动折起来,但我手动打开过的块:不再自动折 */
149 unfolded: string[]
150 /** 光标刚离开的块(连同祖先)先别自动折,到这个时间为止 */
151 lingering: Record<string, number>
152 /** 滚轮在面板上滚过的格数,累加;交给大纲树自己翻 */
153 wheel: number
154 /** 展开了详情字段的块 */
155 open: string[]
156 view: View
157 level: Level
158 cursor: string | null
159 zoom: string | null
160 /** 我手动纠正层级的次数,用来评估 AI 判断的准确率 */
161 corrections: number
162 /** 正文里折起来的块(全折模式下是例外:这些块展开) */
163 bodyFolded: string[]
164 bodyFoldAll: boolean
165 body: Body
166 agenda: AgendaGroup[]
167 /** 最近一次回顾,显示在面板顶部 */
168 recap: Recap | null
169 /** 面板顶部显示状态和图标的说明 */
170 legend: boolean
171 /** 侧栏里改过、还没告诉 Claude 的状态 */
172 changes: StatusChange[]
173 /** 键盘在哪一层:视图栏选视图,或者进了某个视图在树里操作 */
174 scope: 'tabs' | 'tree'
175 /** 所有会话加起来的验收指标,切到技术信息视图时重算 */
176 metricsAll: Metrics | null
177 /** 会话的名字(自动起的或 /rename 改的),画在大纲最上面;还没有名字时是 null */
178 sessionTitle: string | null
179 }
180 }
181}
182