SLOPSHOPPER

outline-sidebar

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

newpanerowsguardcommandprompt
v0.9.5MITupdated 2026-10-25MatsuriMW/outline-sidebar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · outline-sidebar
│ ┃ 大纲 ✕ › fix the failing auth test and add an audit log call │ ┃ 1: 结论 2: 需要我做的 3: 我让它做的 4: view- │ ┃ j: 下 k: 上 t: 折叠到母块 e: 展开子块 d: Don ⏺ Read(src/auth.ts) │ ┃ ──────────────────────────────────────────── ⎿ Read 6 lines │ ┃ ▣ client module ./tree-view.tsx ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /outline │ ⎿ outline-sidebar: 大纲面板已打开。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 大纲
1: 结论 2: 需要我做的 3: 我让它做的 4: view-all 5: 它已做的 j: 下 k: 上 t: 折叠到母块 e: 展开子块 d: Done y: Yes n: No l ──────────────────────────────────────────────────────── ▣ client module ./tree-view.tsx
Pane · Agenda
r: 刷新 􀀗 没有要你做的事
README

Outline Sidebar

English | 简体中文

Why I built this

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.

What it is

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.

Outline Sidebar: "What I asked it to do" on the left, "What it has done" on the right with evidence under each item

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.

More views

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 ▸).

What I need to do

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

Conclusions

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.

Technical info

What it does in the harness

In the harness it plays two roles:

  1. Observability: conclusions, decisions waiting on me, work handed out and work it finished are shown apart, so there is no scrolling up through one stream. This goes straight at the human bottleneck: attention during review.
  2. A structured feedback loop: statuses you change in the sidebar become structured information that reaches Claude with your next message. Mark "check whether the repo is public" as Done, and Claude receives an explicit status change instead of a paragraph it has to interpret. Human judgment returns to the agent's context in a fixed format, which is a harder piece of harness design than a sentence in the chat.

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:

  • Evidence under every "done": the files edited in each turn, the commands run, whether they succeeded and their last output line are recorded straight from the tool calls, without going through a model. When summarizing, the model can only pick the records that prove a claim from that list; if it finds none, the claim is marked "no evidence".
  • A review gate: to change a ToReview block to Done you must first expand its details and look at the evidence. Press S before you have looked, and it only expands the evidence for you without changing the status; press again after looking to approve. On approval Claude is told "the user approved after looking at the evidence", along with which "done" items had no evidence.
  • When review fails, Claude responds: ⇧ plus the first letter sets a status directly, and B cycles through them from mild to severe. When a block waiting for review gets one of these statuses, or is sent back to Doing from the ⏎ menu, the mod sends a message for you 4 seconds after you stop (time to switch to the status you actually want), telling Claude how to continue based on the final status:
KeyStatusHow Claude continues
⇧RReworkRight direction, details need changes: it first restates what to change, starts once you confirm, then hands it back for review
⇧UUnclear?It does not touch code yet; it explains, from this turn's edits and command results, what it did, why and how it checked
⇧FFailedIt 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
⇧SSuspendedIt leaves it alone and notes how far it got and what is missing, so it can be picked up later
⇧CCancelledIt stops; if it already edited files it lists them and asks whether to revert
⏎ menuDoing (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.

  • Items settle themselves one by one: if you say in a later message "restarted it" or "go with that", the summary marks the matching What I need to do item Done. If Claude says it did the task for you, or decides you no longer need to, the item only becomes ToReview and waits for you to glance at it; the AI does not get the final word. This is judged per item: if only one item in a block was settled, only that one changes. Automatic changes carry a "[自动]" (auto) mark that disappears as soon as you change the status.
  • Metrics: at the bottom of view 6, Technical info, computed once for this session and once across all sessions: evidence coverage (share of "done" items with evidence), review block rate (share of reviews you did not approve), rework rate (share of Done items reopened later) and the number of blocks whose hierarchy you corrected by hand. They show whether this harness actually catches anything, and how many of the AI's claims land on evidence.

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.

Built for the terminal and CJK input methods

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:

  • Clicking into the sidebar switches to English: clicking anywhere in the sidebar (outline tree, view bar, toolbar) switches the input method to an English keyboard. The first key you type back in the prompt switches back to your original input method; that key only does the switching and is not typed into the prompt. It uses the macOS input source API and switches straight to the U.S. keyboard whether you were on the system Pinyin or a third-party IME like Squirrel (Rime)
  • Usable without switching: arrow keys, Enter, Space, digits and ⌫ bypass the IME, and they alone are enough for every operation (moving, folding, changing status from the ⏎ menu, focusing, exporting)
  • CJK wraps by cell width: CJK characters take two cells and can break between any two characters; English words are never split. Lines stay tidy even in a narrow sidebar
  • Icons from custom fonts: status and field icons are packed into two fonts in the Unicode Supplementary Private Use Area-B, so they never compete with CJK fonts for code points; the icons are aligned to the text center and sit on the same baseline as the text after them
  • Out of the way of terminal shortcuts: expanding to a depth uses ⇧1 / ⇧2 / ⇧3. Terminals like Tern and iTerm use ⌥ plus a digit to switch tabs by default, so those keys never reach Claude Code

Usage

Recommended: make view 2 your main screen

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:

  • blocks Claude finished that wait for your review (ToReview);
  • questions it asks you, such as "shall I go on and add it?";
  • tasks handed to you, such as "restart the terminal to see the effect";
  • things where it got stuck and needs you (Errored, NeedOK?, Waiting).

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:

  1. Claude finishes a turn; view 2 shows a new block waiting for review and a few things for you to answer or do.
  2. Blocks waiting for review: press Space to expand and look at the evidence under "done". If it is good, press 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.
  3. Questions for you: press Y or N; 4 seconds after you stop it goes out as your answer and Claude acts on it.
  4. Tasks handed to you: press D (or Y) when done. Nothing is sent on its own; Claude hears about it with your next message.
  5. Once cleared, the queue is empty. Settled items fold away on their own, with no tidying needed. To see everything still open, press 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.

Commands

  • /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

Views (number keys)

KeyViewWhat you see
1ConclusionsThe conclusion reached in each turn
2What I need to doRecommended 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
3What I asked it to doEverything 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
4AllThe whole outline
5What it has doneEvery "done" item with its evidence underneath
6Technical infoUsage, 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.

Replying and reviewing

Click the outline tree once and the keyboard goes to the sidebar. These keys are used most in view 2.

KeyAction
SpaceExpand / collapse details to see the evidence under "done". Expanding a block waiting for review counts as having looked at its evidence
DDone: 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
YYes: answer "yes". On a block waiting for review it approves, same as D
NNo: 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)
⇧RRework: right direction, details need changes. Claude restates what to change first and starts once you confirm
⇧UUnclear?: you did not follow what it did. Claude does not touch code yet and explains with the evidence
⇧FFailed: it did not work. Claude looks for the cause in the evidence and reworks, or asks you if unsure
⇧SSuspended: put it aside. Claude leaves it alone and notes how far it got
⇧CCancelled: drop it. Edited files are listed and Claude asks whether to revert
BCycle through the five above from mild to severe: Rework → Unclear? → Failed → Suspended → Cancelled
SNormal 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:

  • Sent right away (4 seconds after you stop, time to switch to the status you want): a block waiting for review marked as not approved or sent back to Doing; a question to you answered Yes / No. When you send back several items at once, Claude is also asked to hand independent ones to subagents in parallel.
  • Carried with your next message: Done, review approval, Y / N on tasks handed to you. No extra turn for a single "OK".

⇧ 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.

Moving and folding

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.

  • It does not fold while the cursor is inside the subtree; after the cursor leaves it waits 3 seconds before folding, and coming back within that window cancels it. So right after you mark the last item Done, nothing vanishes in front of you.
  • Blocks you opened by hand with → or E are not auto-folded again; when O is on "expand all" or ⇧1/2/3 sets a depth, your instruction wins.
  • It only applies in the three views that track current progress: What I need to do, What I asked it to do and All. Conclusions, What it has done and Technical info are about finished work anyway and never fold; exports are not affected.
KeyAction
J / KMove the cursor down / up (↓ / ↑ also work)
Scroll wheelWhen 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
TOn 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
EThe 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 ⇧3Expand the whole outline to depth 1 / 2 / 3 and fold anything deeper (⌥1 / ⌥2 / ⌥3 also work if your terminal does not take them)
OCycle 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
ZFocus: show only the cursor's block and its children; press again to leave
L / HIndent / 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

Other keys

KeyAction
FFold in the transcript: turn this turn's messages in the conversation into a one-line summary; unrelated to folding in the sidebar
WFold the whole transcript: every turn except the latest keeps only a one-line summary; press again to expand all
PRefresh progress: for the turn that is running, have the model write one line on how far it got
RRecap: generate a recap of this session from the outline
GJump: scroll the conversation to the message this block belongs to
XExport: copy the current view as Markdown
ILegend: 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
  • The keyboard has two layers: pick a view in the view bar with digits or ←→, press ⏎ to enter and the cursor moves only through that view's rows, ⌫ returns to the view bar; digits in the tree switch views directly too
  • You can only enter the tree with a mouse click (Claude Code does not let a key hand the keyboard to a custom-drawn area). Esc belongs to Claude Code and returns the keyboard to the whole pane; click the tree again to come back
  • Clicking only moves the cursor and never changes status; status changes are keyboard-only

Task statuses

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

StatusMeaningWho sets itWhat happens next
TodoDecided, not started yetThe summarizing model; you, by cycling with SS turns it into Doing
Todo4AILeft for the AI to do laterThe summarizing model—
DoingNot finished, continues next turn. The turn that is running is also Doing, with a spinning iconThe summarizing model; the mod at the start of a turnS turns it into Done. Picking Doing in the ⏎ menu on a block waiting for review sends it back to Claude to keep working
QueuedIn the queue, will resume on scheduleThe summarizing model—
DelegatedRunning in a subagent or another agentThe summarizing model—
PausedStopped halfway, interrupted, to be resumed laterThe 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)**

StatusMeaningWho sets itWhat happens next
ToReviewFinished, waiting for your reviewThe 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 choiceThe summarizing modelAnswer with Y / N, sent to Claude right away
WaitingWaiting on you or on something externalThe summarizing modelOnce 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
ErroredStopped on an error, needs youThe mod: set as soon as a turn ends on an API error, without waiting for the modelAfter you deal with it, change it with S or D

Stuck on the agent's side

StatusMeaningWho sets itWhat happens next
Hit Limit!Hit a quota or usage limit, waiting for the resetThe 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)

StatusMeaningHow to set itWhat happens next
ReworkRight direction, details need changes⇧R, or cycle with BHuman-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 BHuman-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

Source 6 files
hooks/register.tsx 1860 lines
1import { 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 lines
1import 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}
652
hooks/icons.ts 564 lines
1// 由 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}
564
hooks/status.ts 143 lines
1import 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
143
hooks/tree-view.tsx 229 lines
1import 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
229
types/index.d.ts 182 lines
1/** 照 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