回覆戰況簡報:請模型在回覆最後附一段 ui-summary,畫面上藏起來,改在正文下方畫結論框(狀態、一句結論、你要做的事、可收合的段落、按選項填入提示框不送出的待決題目);提示框上方一行待決列可鍵盤選答,舊回合的框收成一行

Formerly named reply-lens. "Sitrep" is short for situation report.
繁體中文版:README.zh-TW.md. The mod's on-screen text is in Traditional Chinese.
A Claude Code mod that makes replies easier to scan. This is a P0 experiment and is not in the marketplace yet.
`ui-summary block: one line of JSON with a one-sentence outcome, the decisions waiting on you with their options, and the sections that can be collapsed.? 等你決定 decisions waiting, > 等你動手 something for you to do by hand, ! 卡住 blocked, ~ 進行中 in progress, ✓ 完成 done) with the duration at the right. The status follows the one the model gave: a done or blocked reply stays green or red even with a 你要做 line; only a reply that stopped halfway to wait for you is 等你動手. The border is a darker shade of the status color; only the two "waiting on you" states are yellow. The box has a fixed width (terminal width minus 4, at most 88 columns).你要做 (what you have to do) in yellow.之後我 stays the dimmest); ▸ puts that section back in its place in the body. When there are decisions, a body section whose heading contains 決定 (for example ## 待你決定) is folded the same way as 利弊 (pros and cons), so the questions are not written twice.之後我 (what the model does next) comes last, dim.▸ ✓ outcome 8s); ▸ draws the full box again.? N 題待決 with the first open question's number and options; focus the row and press an option's letter (a, b…), which fills the prompt box the same way. With no questions but something for you to do, it reads 等你動手:… (suggestions in a done reply do not count). The row steps aside for a survey, stacks under other mods' rows (ctx-relay), and goes away once a newer reply has neither.Baked for 3s) becomes a dim – 本輪結束(無摘要) ("turn ended, no summary").Message from @…) with the report's first sentence.✓ completed, red ! failed, dim – killed.⠋ Explore description 執行中 42s, the spinner and elapsed time tick every 250 ms while any subagent runs) and switch to ✓ with their run time when they finish; the engine's own rows stay as they are./sitrep-pane opens or closes it; it never opens by itself. Top to bottom: needs you (one tinted block per question; focus the pane and press an option's letter), running (subagents and background jobs with their run time), tasks (the step list the model adds to the summary block for multi-step work, ✓/▸/○), evidence (the latest verify summary as small tags, kept until the next one), changes (files this session edited with +/−; the rest folded into "not changed this time, N files"). While the pane is open, questions are answered there only: the row above the prompt is not drawn and each reply ends with a one-line conclusion instead of the box.[Subagent hand-back] … framing written for the model; for a turn that dispatched background subagents, the engine's "Waiting for N background agents" line is not drawn (the box already lists them).text, holding box-drawing characters, arrows, or plain-ASCII +-- / |--) is drawn on a gray background under a dim 圖 ("figure") label, so it stands out from the text around it.Glyphs: ? yellow = waiting on you, > yellow = for you to do, ! red = blocked, ~ partly done, ✓ done, – no summary.
claude --plugin-dir ~/projects/mango-mods/sitrep
turn.complete by duration (±3 s); two turns with close durations may swap. The engine may skip that line on short turns, which is why the outcome bar is drawn inside the reply.▸ to put a collapsed section back./focus) the engine hides background-task notification rows, so their status glyph is not shown there.claude plugin test ., npx -p typescript@5 tsc -p ..
hooks/register.tsx 939 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, Timer } from 'claude-code'
3
4import type { AgentNote, ChangeNote, Evidence, Facet, Item, Summary, SummaryTask, TaskNote, TurnNote } from '../types'
5
6// sitrep:
7// 1. 在系統提示尾端加一段(prompt.compose),請模型在交還給使用者的最後一則回覆末尾附 ```ui-summary 區塊(一行 JSON)。
8// 2. 畫回覆時(AssistantMessage)把區塊藏起來,正文下面畫一個圓角結論框:標題(狀態+耗時)疊在上框線,
9// 框裡是粗體結論、facets 指到的段落(從正文拿掉,一行摘要;展開時放回正文原位)、待你決定的題目(按選項把「題號. key」填進提示框,不送出)。
10// 存下來的訊息不變;但 AssistantMessage 沒有 isExpanded,分不出 ctrl+o 畫面,那裡一樣畫成結論框。
11// 3. 沒附區塊的回合,回合結束那列(TurnDuration,「Baked for 3s」)換成「– 本輪結束(無摘要)」,不假裝沒事;
12// 那列沒有輪次編號,用耗時對回 turn.complete。
13// 4. 子代理回報那列(UserMessage,收合時「Message from @…」)換成它回報的第一句,ctrl+o 照原樣展開。
14// 5. 還有沒答的題目時,prompt 上方(AbovePrompt)一行「? N 項等你決定」,聚焦後按選項字母選答(一樣只填提示框)。
15// 6. 背景工作結束的通知列開頭換成狀態符號(✓ 完成、! 失敗、– 中止);那一輪派出的背景子代理也列在結論框裡,跑完自動更新。
16// 7. 回覆裡的 ASCII 圖(含框線字元或箭頭的程式碼區塊)畫在灰底區塊、上方一行灰字「圖」,不被大量文字蓋過。
17// 8. 回覆裡的 ```dot 區塊照 7 畫原文,標籤附粗算的節點與邊數。
18
19const TAG = 'sitrep'
20// 圍欄要在行首:正文裡行內提到 ` ```ui-summary ` 不算(實機:行內那個被當成開頭,把真的區塊吃掉,結論框讀不懂)
21const FENCE_RE = /^```ui-summary[^\n]*\n([\s\S]*?)\n?^```[^\n]*\n?/gm
22const KEEP_TURNS = 50
23const KEEP_AGENTS = 50
24const PANE = 'sitrep'
25// 子代理在跑時的重畫計時器(模組層,同一時間只開一個):每 250ms 一幀,轉圈字形與已跑時間跟著走
26let tick: Timer | null = null
27let frame = 0
28const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
29const spin = () => SPIN[frame % SPIN.length]
30// 顏色只給「需要你」:黃=等你決定;紅=卡住;其餘灰階(第三輪設計稿的安靜版)
31const YELLOW = '#F0E442'
32const RED = '#e0745a'
33const GREEN = '#3fc39a'
34const DIM = '#7d8794'
35const SOFT = '#9aa4b1'
36// ASCII 圖的灰底(比終端底色亮一階,跟 DIM 同系)
37const FIG_BG = '#2b3038'
38const BRIGHT = '#f5f7fa'
39// pane(設計稿駕駛艙版):進行中的藍、需要你那塊的黃底、證據小標籤的框線
40const BLUE = '#56B4E9'
41const NEED_BG = '#29271a'
42const LINE = '#3a414c'
43// pane 選項的鍵帽底色:沒選的暗黃、選過的亮一階(比題目區塊的黃底明顯)
44// 每輪結論框(學 your-turn 的圓角框,標題嵌在上框線):框線用暗一階的狀態色,標題用亮的;只有「等你決定」是黃
45const FRAMES: Record<string, { border: string; title: string; word: string }> = {
46 '?': { border: '#8a8128', title: YELLOW, word: '等你決定' },
47 '>': { border: '#8a8128', title: YELLOW, word: '等你動手' },
48 '!': { border: '#8a4a3c', title: RED, word: '卡住' },
49 '~': { border: '#3d5a72', title: '#56B4E9', word: '進行中' },
50 '✓': { border: '#3d5a4c', title: GREEN, word: '完成' },
51}
52const DONE_FRAME = { border: '#3d5a4c', title: GREEN, word: '完成' }
53const BOX_MAX = 88
54// TurnDuration 的耗時和 turn.complete 的不一定分毫不差(實測 13s 對得上、42s 對不上):取差距最小、在這範圍內的那筆
55const MATCH_MS = 3000
56
57const SECTION = [
58 'sitrep:當你結束這一輪、把話交還給使用者時,在那則回覆的最末尾附一個 ```ui-summary 程式碼區塊,內容是一行 JSON:',
59 '{"status":"done|blocked|partial","outcome":"一句話結果,40 字內","items":[...],"facets":[...],"tasks":[...]}',
60 '- items 每項 {"kind":"decision|user-next|agent-next","text":"…","options":[{"key":"A","label":"12 字內"}],"recommended":"A"}。只有真的要使用者選擇才是 decision,並附 options;要使用者親手做的事是 user-next;你接著會做的是 agent-next。沒有就給空陣列。',
61 '- facets 每項 {"label":"變更","summary":"30 字內","heading":"回覆裡那一段標題的原文"},只列回覆裡確實有、適合預設收合的段落(例如變更、驗證、殘留風險)。',
62 '- tasks 只在多步驟的工作才給(否則省略):整件事的步驟依序列出,每項 {"text":"20 字內","done":true|false},最多 7 項,已做完的標 done。',
63 '- 這個區塊只給介面讀,使用者看不到。正文照常寫完整,不要在正文提到它。工具呼叫之間的訊息不附。',
64 '- 被要求寫 recap、session 摘要或其他不是回覆使用者的文字時,也不附。',
65].join('\n')
66
67const answersAtom = atom({ plugin: 'sitrep', key: 'answers' } as const, {} as Record<string, Record<number, string>>)
68const openAtom = atom({ plugin: 'sitrep', key: 'open' } as const, {} as Record<string, boolean>)
69const turnsAtom = atom({ plugin: 'sitrep', key: 'turns' } as const, [] as TurnNote[])
70const agentsAtom = atom({ plugin: 'sitrep', key: 'agents' } as const, [] as AgentNote[])
71const lastAtom = atom({ plugin: 'sitrep', key: 'last' } as const, null as { id: string; summary: Summary } | null)
72const evidenceAtom = atom({ plugin: 'sitrep', key: 'evidence' } as const, null as Evidence | null)
73const backgroundAtom = atom({ plugin: 'sitrep', key: 'background' } as const, [] as string[])
74const tasksAtom = atom({ plugin: 'sitrep', key: 'tasks' } as const, [] as TaskNote[])
75const changesAtom = atom({ plugin: 'sitrep', key: 'changes' } as const, [] as ChangeNote[])
76const touchedAtom = atom({ plugin: 'sitrep', key: 'touched' } as const, [] as string[])
77const paneOpenAtom = atom({ plugin: 'sitrep', key: 'paneOpen' } as const, false)
78const pendingAtom = atom({ plugin: 'sitrep', key: 'pending' } as const, null as { id: string; decisions: Item[]; todo: string[] } | null)
79
80export const register: Register = on => {
81 on('prompt.compose', async ($, e, next) => {
82 const result = await next(e)
83 return { sections: [...result.sections, { id: `${TAG}:summary`, text: SECTION, scope: 'session' as const }] }
84 })
85
86 on('session.start', async ($, e, next) => {
87 const result = await next(e)
88 // 名稱衝突會丟例外,包住免得中斷 hook
89 try {
90 await $.command.register({ name: 'sitrep-pane', description: 'sitrep:開關狀態 pane(需要你、進行中、任務、證據、變更)' })
91 } catch (err) {
92 $.ui.log(`${TAG} 註冊 /sitrep-pane 失敗:${String(err)}`)
93 }
94 return result
95 })
96
97 // pane 只能手動開關(2026-10-08 使用者:pane 視覺另案設計,平常只用安靜版,不再自動開)
98 on('command.run', { command: 'sitrep-pane' }, async $ => {
99 if ((await $.ui.panes()).some(p => p.id === PANE)) {
100 await $.ui.close({ id: PANE })
101 return { text: `${TAG}:已關閉狀態 pane(/sitrep-pane 可再打開)` }
102 }
103 await openPane($)
104 return { text: `${TAG}:已打開狀態 pane(實驗中;再打一次 /sitrep-pane 關閉)` }
105 })
106
107 // pane 關了:結論框與 prompt 上方那列回來
108 on('ui.close', async ($, e, next) => {
109 if (e.id !== PANE) return next(e)
110 await update($, paneOpenAtom, () => false)
111 return next(e)
112 })
113
114 // 記下這個 session 用工具改過的檔:pane 的「變更」段只逐檔列這些,其餘當成「不是這次改的」
115 on('tool.call', async ($, e, next) => {
116 if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(e.tool)) {
117 const input = e as unknown as { file_path?: unknown; notebook_path?: unknown }
118 const path = input.file_path ?? input.notebook_path
119 if (typeof path === 'string') await update($, touchedAtom, list => (list.includes(path) ? list : [...list, path].slice(-200)))
120 }
121 return next(e)
122 })
123
124 // 主對話停下時引擎回報的背景工作(背景 shell、monitor、workflow;子代理另外從 $.agent.list() 拿)
125 on('classic.Stop', async ($, e, next) => {
126 const result = await next(e)
127 if (result.block !== undefined) return result
128 const work = (e.background_tasks ?? []).filter(t => t.type !== 'subagent').map(t => `${t.type} ${t.command ?? t.description}`)
129 await update($, backgroundAtom, () => work)
130 return result
131 })
132
133 on('classic.TaskCreated', async ($, e, next) => {
134 const result = await next(e)
135 await update($, tasksAtom, list => [...list.filter(t => t.id !== e.task_id), { id: e.task_id, subject: e.task_subject, done: false }].slice(-30))
136 return result
137 })
138
139 on('classic.TaskCompleted', async ($, e, next) => {
140 const result = await next(e)
141 await update($, tasksAtom, list => list.map(t => (t.id === e.task_id ? { ...t, done: true } : t)))
142 return result
143 })
144
145 on('turn.complete', async ($, e, next) => {
146 const result = await next(e)
147 if (e.agentId) {
148 // 背景子代理跑完:結論框裡它那一行改成完成(或中止)+耗時
149 const id = e.agentId
150 await update($, agentsAtom, list => list.map(a => (a.id === id ? { ...a, status: e.isAborted ? 'killed' : 'completed', durationMs: e.durationMs } : a)))
151 return result
152 }
153 const found = findSummary(e.answer)
154 await trackAgents($, found?.id)
155 // 有子代理在跑時每 250ms 重畫一次:轉圈字形和已跑時間證明它還活著(使用者 2026-10-08 選轉圈);都跑完就停
156 if (!tick && (await read($, agentsAtom)).some(a => !(a.status in TASK_MARKS))) {
157 tick = $.clock.every(250, () => {
158 void read($, agentsAtom).then(list => {
159 if (list.some(a => !(a.status in TASK_MARKS))) {
160 frame++
161 return $.ui.invalidate('ui.render')
162 }
163 tick?.cancel()
164 tick = null
165 })
166 })
167 }
168 const note: TurnNote = found?.summary
169 ? { id: found.id, durationMs: e.durationMs, glyph: glyphOf(found.summary), outcome: found.summary.outcome }
170 : { durationMs: e.durationMs, glyph: '–', outcome: '本輪結束(無摘要)' }
171 await update($, turnsAtom, list => [...list, note].slice(-KEEP_TURNS))
172 // 新的結論框沒有題目=之前的題目已經處理過(例如使用者自己打字回答),那列跟著消失
173 const summary = found?.summary
174 if (found && summary) {
175 // 完工回覆附的「你要做」多半是建議(例如手動再測一次),不在 prompt 上方催;卡住、做到一半才提示
176 const todo = summary.status === 'done' ? [] : summary.items.filter(i => i.kind === 'user-next').map(i => i.text)
177 await update($, pendingAtom, () => ({ id: found.id, decisions: decisionsOf(summary.items), todo }))
178 await update($, lastAtom, () => ({ id: found.id, summary }))
179 // 證據:沿用最近一輪有「驗證」段的結果,直到下一次驗證蓋過去(問題輪通常沒有驗證段,pane 卻正好開著)
180 const verify = summary.facets.map((f, i) => ({ f, i })).filter(({ f }) => f.label.includes('驗證'))
181 if (verify.length > 0) {
182 // 細節:回覆裡「驗證」那段的前幾行(跑過的指令與結果),pane 的證據段畫在小標籤下面
183 const { sections } = cutSections(found.rest, summary.facets, () => false)
184 const chips = verify.flatMap(({ f }) => f.summary.split(/\s*[·、,,]\s*/)).filter(c => c !== '')
185 const proof = verify.flatMap(({ i }) => (sections[i] ?? '').split('\n'))
186 .map(l => l.replace(/^\s*[-*]\s+|`/g, '').trim()).filter(l => l !== '' && !l.startsWith('```'))
187 await update($, evidenceAtom, () => ({ chips, proof, age: 0 }))
188 } else {
189 await update($, evidenceAtom, ev => (ev ? { ...ev, age: ev.age + 1 } : null))
190 }
191 }
192 // pane 的「變更」段:這一輪結束時還沒 commit 的改動(git diff --numstat,路徑換成絕對路徑好跟 touched 比);不在 git repo 就清空
193 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 }).catch(() => null)
194 const stat = top?.exitCode === 0 ? await $.process.run(['git', 'diff', 'HEAD', '--numstat'], { timeoutMs: 5000 }).catch(() => null) : null
195 const root = top?.stdout.trim() ?? ''
196 await update($, changesAtom, () => (stat?.exitCode === 0 ? parseNumstat(stat.stdout, root) : []))
197 return result
198 })
199
200 // 你自己送出訊息=已回應上一輪的題目或待辦:那列馬上消失,不等這一輪結束(使用者實機:核准後「等你動手」一直掛著)
201 on('prompt.submit', async ($, e, next) => {
202 const result = await next(e)
203 // 你打字(composer)、手機遙控(bridge),或其他 mod 代你送出(asUser,例如 ctx-relay 的接續鈕)才算;背景通知、別的 session 傳來的不算
204 const o = e.origin
205 if (o.kind === 'composer' || o.kind === 'bridge' || (o.kind === 'plugin' && o.asUser)) await update($, pendingAtom, () => null)
206 return result
207 })
208
209 // prompt 上方一行:有沒答的題目時「? N 題待決」+第一題的選項(題號代替題幹,題目本身在結論框裡),聚焦這列後按字母選答;
210 // 沒題目但有要你親手做的事時「等你動手:…」。熱鍵只在 band/pane 有焦點時有效,所以選項留在這裡(結論框的按鈕按不到鍵盤)
211 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
212 if (e.props.hasSurvey) return next(e)
213 const pending = await read($, pendingAtom)
214 const answers = pending ? (await read($, answersAtom))[pending.id] ?? {} : {}
215 const open = pending ? pending.decisions.map((d, n) => ({ d, n })).filter(({ n }) => answers[n] === undefined) : []
216 const first = open[0]
217 const todo = pending?.todo[0]
218 // pane 開著時題目在 pane 裡選:這列不畫,alt+n(ctrl+x tab)才會直接跳到 pane
219 if (!pending || (!first && todo === undefined) || (await read($, paneOpenAtom))) return next(e)
220 // band 只有一個:先讓排在下面的 mod 畫(例如 ctx-relay 的交接列),自己這列疊在它下面
221 const below = await next(e)
222 const { Box, Button, Text } = $.ui.resolve(e)
223 let line: RenderElement
224 if (first) {
225 const { d, n } = first
226 const total = pending.decisions.length
227 // hotkey 只收一個小寫字母;數字不給:空的提示框裡按數字會直接按到 band 的按鈕,也會撞 ctx-relay 的 "1"
228 line = (
229 <Box key="above-row" flexDirection="row">
230 <Text color={YELLOW} bold>{`? ${open.length} 題待決`}</Text>
231 <Text color={BRIGHT}>{total > 1 ? ` ${n + 1}.` : ' '}</Text>
232 {(d.options ?? []).map(o => {
233 const hotkey = /^[a-z]$/i.test(o.key) ? o.key.toLowerCase() : undefined
234 return (
235 <Box key={`above-opt-${o.key}`} flexDirection="row" marginLeft={2}>
236 <Button key={`above-pick-${n}-${o.key}`} plain label={hotkey ? o.label : `${o.key} ${o.label}`} {...(hotkey ? { hotkey } : {})} onPress={() => pick($, pending.id, n, o.key, total)} />
237 {d.recommended === o.key ? <Text color={YELLOW}>{' 建議'}</Text> : null}
238 </Box>
239 )
240 })}
241 <Text color={DIM} wrap="truncate-end">{' · 聚焦後按字母,只填入輸入框'}</Text>
242 </Box>
243 )
244 } else {
245 line = (
246 <Text key="above-todo" wrap="truncate-end">
247 <Text color={YELLOW} bold>{'等你動手:'}</Text>
248 <Text>{todo}</Text>
249 </Text>
250 )
251 }
252 return (
253 <Box flexDirection="column">
254 {below}
255 {line}
256 </Box>
257 )
258 })
259
260 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
261 if (e.requestId !== PANE) return next(e)
262 return drawPane($, e, e.props.bodyColumns)
263 })
264
265 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
266 // 正文交給引擎畫;ASCII 圖切出來畫在灰底區塊,前後段各自交回引擎(只有第一段帶回覆開頭的圓點)
267 const draw = async (text: string) => {
268 const parts = splitFigures(text)
269 const [only] = parts
270 if (parts.length === 1 && only?.kind === 'text') return next({ ...e, props: { ...e.props, text } })
271 const { Box, Text } = $.ui.resolve(e)
272 const out: RenderElement[] = []
273 for (const [i, p] of parts.entries()) {
274 if (p.kind === 'text') {
275 // 圓點和它後面的縮排是引擎畫在第一段裡的;後面幾段沒有,自己補兩欄對齊(實機 2026-10-08)
276 const indent = e.props.isFirstOfReply && i > 0 ? 2 : 0
277 out.push(<Box key={`seg-${i}`} marginLeft={indent}>{await next({ ...e, props: { ...e.props, text: p.text, isFirstOfReply: e.props.isFirstOfReply && i === 0 } })}</Box>)
278 continue
279 }
280 // dot 區塊跟 ASCII 圖一樣畫原文,標籤附粗算的節點與邊數(不畫成圖片:Claude Code 在 tmux 裡關掉圖片,使用者 2026-10-08 選不做)
281 let label = '圖'
282 if (p.kind === 'dot') {
283 const c = countDot(p.text)
284 label = `圖:${c.nodes} 個節點、${c.edges} 條邊(dot 原文)`
285 }
286 out.push(
287 <Box key={`fig-${i}`} flexDirection="column" marginLeft={2} marginY={1} alignSelf="flex-start">
288 <Text color={DIM}>{label}</Text>
289 <Box flexDirection="column" backgroundColor={FIG_BG} paddingX={1}>
290 {p.text.split('\n').map((l, j) => <Text key={`l-${j}`} color={BRIGHT} wrap="truncate-end">{l === '' ? ' ' : l}</Text>)}
291 </Box>
292 </Box>,
293 )
294 }
295 return <Box flexDirection="column">{out}</Box>
296 }
297 const found = findSummary(e.props.text)
298 if (found === null) {
299 // 串流中區塊還沒收尾:先藏起來,不然 JSON 會先畫成程式碼、收尾時才被結論框換掉(實機看到會閃)
300 return draw(cutOpenFence(e.props.text) ?? e.props.text)
301 }
302 // pane 開著=駕駛艙模式:不畫安靜版的結論框,正文完整照畫,底下只留一行「✓ 結論 耗時」;需要你、證據、變更都在 pane
303 if (found.summary !== null && (await read($, paneOpenAtom))) {
304 const { Box, Text } = $.ui.resolve(e)
305 const glyph = glyphOf(found.summary)
306 const frame = FRAMES[glyph] ?? DONE_FRAME
307 const turn = (await read($, turnsAtom)).findLast(t => t.id === found.id)
308 return (
309 <Box flexDirection="column">
310 {await draw(found.rest)}
311 <Box key="lens-line" flexDirection="row" marginTop={1}>
312 <Text color={frame.title} bold>{`${glyph} `}</Text>
313 <Text color={BRIGHT}>{found.summary.outcome}</Text>
314 {turn ? <Text color={DIM}>{` ${duration(turn.durationMs)}`}</Text> : null}
315 </Box>
316 </Box>
317 )
318 }
319 const facets = withDecisionFacet(found.summary, found.rest)
320 const open = await read($, openAtom)
321 const { shown, sections } = cutSections(found.rest, facets, i => open[`${found.id}:${i}`] === true)
322 const body = await draw(shown)
323 const { Box, Text } = $.ui.resolve(e)
324 if (found.summary === null) {
325 return (
326 <Box flexDirection="column">
327 {body}
328 <Text color={DIM}>{' ? ui-summary 讀不懂,沒畫結論框'}</Text>
329 </Box>
330 )
331 }
332 // 結論框畫在帶區塊的那一段下面,不靠 TurnDuration:短回合、子代理回報觸發的回合,引擎不一定畫那列(實測 3s 那輪沒有)
333 const glyph = glyphOf(found.summary)
334 const frame = FRAMES[glyph] ?? DONE_FRAME
335 const turns = await read($, turnsAtom)
336 const turn = turns.findLast(t => t.id === found.id)
337 // 舊回合(之後還有別張結論框)收成一行,點 ▸ 才畫完整框:同一畫面不堆好幾個框(實機 s5 三個框)。
338 // 還沒進 turns 的(串流中、剛收尾)一律當最新,免得先畫一行再展開
339 const at = turns.findIndex(t => t.id === found.id)
340 const isOld = at >= 0 && turns.slice(at + 1).some(t => t.id !== undefined && t.id !== found.id)
341 const boxKey = `${found.id}:box`
342 const toggleBox = () => update($, openAtom, o => ({ ...o, [boxKey]: !o[boxKey] }))
343 const { Button } = $.ui.resolve(e)
344 if (isOld && open[boxKey] !== true) {
345 return (
346 <Box flexDirection="column">
347 {body}
348 <Box key="lens-line" flexDirection="row" marginTop={1}>
349 <Button key="box-open" plain label="▸" onPress={toggleBox} />
350 <Text color={frame.title} bold>{` ${glyph} `}</Text>
351 <Text color={SOFT}>{found.summary.outcome}</Text>
352 {turn ? <Text color={DIM}>{` ${duration(turn.durationMs)}`}</Text> : null}
353 </Box>
354 </Box>
355 )
356 }
357 const items = await itemRows($, e, found.id, found.summary.items)
358 const rows = [
359 <Text key="outcome" color={BRIGHT} bold>{found.summary.outcome}</Text>,
360 ...items.todo,
361 ...facetRows($, e, found.id, facets, sections, open),
362 ...(await agentRows($, e, found.id)),
363 ...items.questions,
364 ...items.later,
365 ]
366 // 標題疊在上框線(2026-10-09 使用者要再試):框自己的子元素會被框線蓋掉(your-turn 2026-10-05 實測),
367 // 所以標題是框的「後一個兄弟」、absolute 疊上去。舊紀錄:框頂捲出畫面時標題曾疊到內容上(實機 opus/s2:1),這次重看
368 // 框寬固定(不跟內容跳,實機 36~81 欄):終端寬減 4,最寬 88
369 const width = Math.min((e.viewport?.columns ?? 92) - 4, BOX_MAX)
370 return (
371 <Box flexDirection="column">
372 {body}
373 <Box key="lens-wrap" flexDirection="column" marginTop={1} width={width}>
374 <Box key="lens" borderStyle="round" borderColor={frame.border} paddingX={1} flexDirection="column">
375 {rows}
376 </Box>
377 <Box key="title" position="absolute" top={0} left={2} flexDirection="row">
378 {isOld ? <Button key="box-close" plain label="▾" onPress={toggleBox} /> : null}
379 <Text color={frame.title} bold>{` ${glyph} ${frame.word} `}</Text>
380 </Box>
381 {turn ? <Box key="time" position="absolute" top={0} right={2}><Text color={DIM}>{` ${duration(turn.durationMs)} `}</Text></Box> : null}
382 </Box>
383 </Box>
384 )
385 })
386
387 // 結論已畫在回覆裡;這列只在「沒附區塊」的回合換成灰色提示,不假裝沒事
388 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
389 const note = matchTurn(await read($, turnsAtom), e.props.durationMs)
390 // 這一輪派了背景子代理:引擎這列會寫「Waiting for N background agents…」,結論框裡已經有子代理那一行,不畫
391 if (note?.id && (await read($, agentsAtom)).some(a => a.card === note.id)) return $.ui.resolve(e).Box({})
392 if (!note || note.glyph !== '–') return next(e)
393 const { Text } = $.ui.resolve(e)
394 return <Text color={DIM}>{`– ${note.outcome} ${duration(e.props.durationMs)}`}</Text>
395 })
396
397 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
398 // 背景工作(子代理、背景 shell…)結束的通知列:沒有 from,開頭換成狀態符號,只有失敗用紅色
399 const task = e.props.task
400 if (task && !e.props.isExpanded) {
401 const mark = TASK_MARKS[task.status ?? ''] ?? { glyph: '·', color: DIM }
402 const { Text } = $.ui.resolve(e)
403 return (
404 <Text>
405 <Text color={mark.color} bold>{`${mark.glyph} `}</Text>
406 <Text>{clip(e.props.text.split('\n')[0] ?? '', 90)}</Text>
407 {task.durationMs !== undefined ? <Text color={DIM}>{` ${duration(task.durationMs)}`}</Text> : null}
408 </Text>
409 )
410 }
411 const from = e.props.from
412 if (!from || e.props.origin.kind === 'composer') return next(e)
413 // 子代理回報開頭那段給模型看的框架說明([Subagent hand-back] … The report follows:)不畫,只留回報本文;存下來的訊息不動
414 const report = stripHandback(e.props.text)
415 if (e.props.isExpanded) return report === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text: report } })
416 const first = firstSentence(stripSummary(report))
417 if (first === '') return next(e)
418 const { Text } = $.ui.resolve(e)
419 return (
420 <Text>
421 <Text color={DIM}>{'› '}</Text>
422 <Text color={SOFT}>{`${from.name} `}</Text>
423 <Text>{clip(first, 90)}</Text>
424 <Text color={DIM}>{' (ctrl+o 看全文)'}</Text>
425 </Text>
426 )
427 })
428}
429
430// 開 pane;真的畫出來(isPlaced)才把題目從 prompt 上方那列移過去,太窄等著時題目留在原處
431async function openPane($: EngineInterface) {
432 // 寬度照設計稿(520px ≈ 52 欄);使用者自己拖過的寬度引擎會優先
433 const opened = await $.ui.open({ id: PANE, title: 'sitrep', columns: 52 })
434 await update($, paneOpenAtom, () => opened.isPlaced)
435}
436
437// pane 照設計稿「駕駛艙」版(spec R11):需要你、進行中、任務、證據、變更;空的段不畫,超過上限最後一列換成「…還有 N 項」
438async function drawPane($: EngineInterface, e: RenderEvent, width: number) {
439 const { Box, Button, Text } = $.ui.resolve(e)
440 const last = await read($, lastAtom)
441 const pending = await read($, pendingAtom)
442 const answers = pending ? (await read($, answersAtom))[pending.id] ?? {} : {}
443 const sections: { title: string; color: string; rows: RenderElement[]; max: number }[] = []
444
445 // 需要你:每題一塊黃底,選項照引擎問卷樣式畫成「a: 標籤」(第一題沒答的有字母熱鍵),選過的整題變灰、選中的打 ●
446 const need: RenderElement[] = []
447 let left = 0
448 if (pending) {
449 const total = pending.decisions.length
450 const firstOpen = pending.decisions.findIndex((_, n) => answers[n] === undefined)
451 pending.decisions.forEach((d, n) => {
452 const picked = answers[n]
453 if (picked === undefined) left++
454 need.push(
455 <Box key={`need-${n}`} flexDirection="column" backgroundColor={picked === undefined ? NEED_BG : undefined} paddingX={1}>
456 <Text color={picked === undefined ? BRIGHT : DIM}>{total > 1 ? `${n + 1}. ${d.text}` : d.text}</Text>
457 <Box flexDirection="row">
458 {(d.options ?? []).map(o => {
459 const isLetter = /^[a-z]$/i.test(o.key)
460 const letter = isLetter ? o.key.toLowerCase() : o.key
461 const hotkey = n === firstOpen && isLetter ? letter : undefined
462 return (
463 // 有熱鍵時引擎自己畫「a: 標籤」(字母用強調色);沒熱鍵的照同一個樣子把「a: 」寫進標籤
464 <Box key={`pane-opt-${n}-${o.key}`} flexDirection="row" marginRight={3}>
465 {picked === o.key ? <Text color={BRIGHT} bold>{'● '}</Text> : null}
466 <Button key={`pane-pick-${n}-${o.key}`} plain label={hotkey ? o.label : `${letter}: ${o.label}`} {...(hotkey ? { hotkey } : {})} {...(picked !== undefined && picked !== o.key ? { dimColor: true } : {})} onPress={() => pick($, pending.id, n, o.key, total)} />
467 {d.recommended === o.key && picked === undefined ? <Text color={YELLOW}>{' 建議'}</Text> : null}
468 </Box>
469 )
470 })}
471 </Box>
472 </Box>,
473 )
474 })
475 }
476 const userNext = (last?.summary.items ?? []).filter(x => x.kind === 'user-next')
477 for (const [i, item] of userNext.entries()) {
478 need.push(<Text key={`you-${i}`}><Text color={YELLOW} bold>{'> '}</Text>{clip(item.text, width - 2)}</Text>)
479 }
480 sections.push({ title: `需要你 · ${left + userNext.length}`, color: YELLOW, rows: need, max: 8 })
481
482 // 進行中:轉圈字形+子代理/背景 描述,已跑時間靠右
483 const now = await $.clock.now().catch(() => null)
484 const runningRow = (key: string, label: string, since: number | undefined) => (
485 <Box key={key} flexDirection="row">
486 <Text color={BLUE}>{`${spin()} `}</Text>
487 <Text>{clip(label, width - 10)}</Text>
488 <Box flexGrow={1} />
489 <Text color={DIM}>{now !== null && since !== undefined ? duration(now - since) : ''}</Text>
490 </Box>
491 )
492 const agents = (await read($, agentsAtom)).filter(a => !(a.status in TASK_MARKS))
493 sections.push({
494 title: '進行中',
495 color: DIM,
496 rows: [
497 ...agents.map(a => runningRow(`run-${a.id}`, `子代理 ${a.description}`, a.startedAt)),
498 ...(await read($, backgroundAtom)).map((b, i) => runningRow(`bg-${i}`, `背景 ${b}`, undefined)),
499 ],
500 max: 5,
501 })
502
503 // 任務 完成數/總數:✓ 完成(灰)、▸ 下一項(亮)、○ 還沒做(灰)。
504 // 來源:最近一張結論框的 tasks(使用者環境沒有任務工具);沒有才用引擎的 TaskCreated/TaskCompleted
505 const fromSummary = (last?.summary.tasks ?? []).map((t, i) => ({ id: `s${i}`, subject: t.text, done: t.done }))
506 const tasks = fromSummary.length > 0 ? fromSummary : await read($, tasksAtom)
507 const current = tasks.findIndex(t => !t.done)
508 sections.push({
509 title: `任務 ${tasks.filter(t => t.done).length}/${tasks.length}`,
510 color: DIM,
511 rows: tasks.map((t, i) => (
512 <Text key={`task-${t.id}`}>
513 <Text color={t.done ? GREEN : i === current ? undefined : DIM}>{t.done ? '✓ ' : i === current ? '▸ ' : '○ '}</Text>
514 <Text color={i === current ? BRIGHT : DIM}>{clip(t.subject, width - 2)}</Text>
515 </Text>
516 )),
517 max: 7,
518 })
519
520 // 證據:最近一次「驗證」摘要拆成一顆顆有框的小標籤(51 pass · tsc ✓ → [✓ 51 pass] [✓ tsc]);不是這一輪的,標題註明幾輪前
521 const evidence = await read($, evidenceAtom)
522 const chips = evidence?.chips ?? []
523 const proof = evidence?.proof ?? []
524 sections.push({
525 title: evidence && evidence.age > 0 ? `證據 · ${evidence.age} 輪前` : '證據',
526 color: DIM,
527 rows: chips.length === 0 ? [] : [
528 ...[
529 <Box key="chips" flexDirection="row" flexWrap="wrap">
530 {chips.map((c, i) => {
531 const chip = chipOf(c)
532 return (
533 <Box key={`chip-${i}`} borderStyle="round" borderColor={LINE} paddingX={1} marginRight={1}>
534 <Text><Text color={chip.color}>{chip.glyph}</Text>{` ${clip(chip.text, 24)}`}</Text>
535 </Box>
536 )
537 })}
538 </Box>,
539 ],
540 // 細節:回覆「驗證」段的前 4 行(指令與結果),超過的看結論框的 › 驗證
541 ...proof.slice(0, 4).map((l, i) => <Text key={`proof-${i}`} color={SOFT}>{clip(l, width)}</Text>),
542 ...(proof.length > 4 ? [<Text key="proof-more" color={DIM}>{`…還有 ${proof.length - 4} 行,點結論框的 › 驗證看全部`}</Text>] : []),
543 ],
544 max: 6,
545 })
546
547 // 變更 · 未 commit:這個 session 改過的檔逐檔列(+綠 −紅);其餘依最上層目錄收成一行「(不是這次改的)N 檔」
548 const changes = await read($, changesAtom)
549 const touched = new Set(await read($, touchedAtom))
550 const mine = changes.filter(c => touched.has(c.abs))
551 const others = new Map<string, number>()
552 for (const c of changes.filter(c => !touched.has(c.abs))) {
553 const top = c.path.includes('/') ? c.path.slice(0, c.path.indexOf('/')) : c.path
554 others.set(top, (others.get(top) ?? 0) + 1)
555 }
556 const pathWidth = Math.max(12, width - 14)
557 sections.push({
558 title: '變更 · 未 commit',
559 color: DIM,
560 rows: [
561 ...mine.map((c, i) => (
562 <Box key={`chg-${i}`} flexDirection="row">
563 <Box width={pathWidth}><Text color={SOFT}>{clip(c.path, pathWidth - 1)}</Text></Box>
564 <Box width={6}><Text color={GREEN}>{`+${c.add}`}</Text></Box>
565 <Text color={RED}>{`−${c.del}`}</Text>
566 </Box>
567 )),
568 ...[...others].map(([top, n]) => (
569 <Box key={`other-${top}`} flexDirection="row">
570 <Box width={pathWidth}><Text color={DIM}>{clip(`${top}(不是這次改的)`, pathWidth - 1)}</Text></Box>
571 <Text color={DIM}>{`${n} 檔`}</Text>
572 </Box>
573 )),
574 ],
575 max: 6,
576 })
577
578 const shown = sections.filter(sec => sec.rows.length > 0)
579 if (shown.length === 0) return <Text color={DIM}>{'目前沒有需要你或進行中的事'}</Text>
580 return (
581 <Box flexDirection="column">
582 {shown.map(sec => (
583 <Box key={`sec-${sec.title}`} flexDirection="column" marginBottom={1}>
584 <Text color={sec.color} bold={sec.color === YELLOW}>{sec.title}</Text>
585 {sec.rows.length > sec.max ? [...sec.rows.slice(0, sec.max - 1), <Text key="more" color={DIM}>{`…還有 ${sec.rows.length - sec.max + 1} 項`}</Text>] : sec.rows}
586 </Box>
587 ))}
588 </Box>
589 )
590}
591
592// 證據小標籤的符號:摘要裡已經有 ✓/✗/– 就拿來用;寫了「未」「沒」的當成還沒驗
593function chipOf(text: string): { glyph: string; color: string; text: string } {
594 const bare = text.replace(/[✓✔✗✘]/g, '').trim()
595 if (/[✗✘]|fail|失敗/i.test(text)) return { glyph: '✗', color: RED, text: bare }
596 if (/未|沒|–|待/.test(text) && !/[✓✔]/.test(text)) return { glyph: '○', color: DIM, text: bare }
597 return { glyph: '✓', color: GREEN, text: bare }
598}
599
600// git diff HEAD --numstat:「加\t刪\t路徑」;二進位檔是「-\t-」,當成 0
601function parseNumstat(out: string, root: string): ChangeNote[] {
602 return out.split('\n').map(l => l.split('\t')).filter(f => f.length >= 3).map(([add, del, ...rest]) => {
603 const path = rest.join('\t')
604 return { path, abs: `${root}/${path}`, add: Number(add) || 0, del: Number(del) || 0 }
605 })
606}
607
608// 主對話派的子代理:第一次看到時記到這一輪的結論框下;已知的更新狀態,跑完的不再倒回
609async function trackAgents($: EngineInterface, card: string | undefined) {
610 const list = await $.agent.list().catch(() => [])
611 const now = await $.clock.now().catch(() => undefined)
612 await update($, agentsAtom, notes => {
613 const out = [...notes]
614 for (const a of list) {
615 if (a.parentId) continue
616 const at = out.findIndex(n => n.id === a.id)
617 const known = out[at]
618 if (known) {
619 if (!(known.status in TASK_MARKS)) out[at] = { ...known, status: a.status }
620 } else {
621 // 第一次看到=這一輪派的(之前每輪都記過);子代理可能在這輪結束前就跑完(實機),所以不限還在跑的。
622 // 沒有結論框的回合記成 card '',只為了之後不再被算到別張框
623 out.push({ id: a.id, type: a.type, description: a.description, status: a.status, card: card ?? '', startedAt: now })
624 }
625 }
626 return out.slice(-KEEP_AGENTS)
627 })
628}
629
630// 結論框裡這一輪派出去的背景子代理:轉圈+執行中 已跑時間;跑完照通知列的符號(✓ 完成、! 失敗、– 中止)
631async function agentRows($: EngineInterface, e: RenderEvent, card: string) {
632 const agents = (await read($, agentsAtom)).filter(a => a.card === card)
633 const { Text } = $.ui.resolve(e)
634 const now = await $.clock.now().catch(() => null)
635 return agents.map(a => {
636 const mark = TASK_MARKS[a.status] ?? { glyph: spin(), color: BLUE }
637 const running = now !== null && a.startedAt !== undefined ? `執行中 ${duration(now - a.startedAt)}` : '執行中'
638 const state = a.status === 'completed' ? (a.durationMs !== undefined ? duration(a.durationMs) : '完成') : a.status === 'failed' ? '失敗' : a.status === 'killed' ? '已中止' : running
639 return (
640 <Text key={`agent-${a.id}`}>
641 <Text color={mark.color} bold>{`${mark.glyph} `}</Text>
642 <Text color={SOFT}>{`${a.type} `}</Text>
643 <Text>{clip(a.description, 50)}</Text>
644 <Text color={DIM}>{` ${state}`}</Text>
645 </Text>
646 )
647 })
648}
649
650// 背景工作結束的狀態:完成、中止灰;失敗紅(顏色只給需要你)
651const TASK_MARKS: Record<string, { glyph: string; color: string }> = {
652 completed: { glyph: '✓', color: DIM },
653 failed: { glyph: '!', color: RED },
654 killed: { glyph: '–', color: DIM },
655}
656
657// 耗時最接近的一筆;同樣接近時取較新的
658function matchTurn(turns: TurnNote[], durationMs: number): TurnNote | undefined {
659 let best: TurnNote | undefined
660 for (const t of turns) {
661 const d = Math.abs(t.durationMs - durationMs)
662 if (d <= MATCH_MS && (!best || d <= Math.abs(best.durationMs - durationMs))) best = t
663 }
664 return best
665}
666
667// 回覆裡最後一個 ui-summary 區塊;沒有回 null。JSON 讀不懂時 summary 為 null(照樣把區塊藏起來)
668function findSummary(text: string): { id: string; summary: Summary | null; rest: string } | null {
669 const blocks = [...text.matchAll(FENCE_RE)]
670 const last = blocks.at(-1)
671 if (!last) return null
672 const raw = (last[1] ?? '').trim()
673 return { id: hash(raw), summary: parseSummary(raw), rest: text.replace(FENCE_RE, '').replace(/\s+$/, '') }
674}
675
676// 最後一個 ```ui-summary 後面還沒有收尾的 ```:回傳開頭之前的正文;沒有這種區塊回 null
677function cutOpenFence(text: string): string | null {
678 const at = [...text.matchAll(/^```ui-summary/gm)].at(-1)?.index ?? -1
679 if (at >= 0 && text.indexOf('```', at + 3) < 0) return text.slice(0, at).replace(/\s+$/, '')
680 // 開頭那行還沒傳完(```、```ui-sum)也藏:只在它是開新區塊時(前面的 ``` 行成對),收尾的 ``` 不動
681 const nl = text.lastIndexOf('\n')
682 const tail = text.slice(nl + 1)
683 const before = text.slice(0, nl + 1)
684 if (tail.length >= 3 && '```ui-summary'.startsWith(tail) && (before.match(/^```/gm)?.length ?? 0) % 2 === 0) return before.replace(/\s+$/, '')
685 return null
686}
687
688// ASCII 圖:沒標語言(或 text)的程式碼區塊,內含框線字元或箭頭
689// ```dot(或 graphviz)區塊也當成圖,標籤另附節點與邊數
690const FIG_FENCE_RE = /^```([A-Za-z]*)[ \t]*\n([\s\S]*?)\n```[ \t]*$/gm
691// 純 ASCII 圖(output style 規定回覆裡的圖用 +--+、|--、`--)也要認得
692const FIG_MARK_RE = /[─│┌┐└┘├┤┬┴┼╭╮╯╰═║]|[→←↑↓]|-->|<--|\+--|--\+|\|--|`--/
693
694type Part = { kind: 'text' | 'figure' | 'dot'; text: string }
695
696// 正文依圖切段;沒有圖時回一段原文
697function splitFigures(text: string): Part[] {
698 const parts: Part[] = []
699 let at = 0
700 for (const m of text.matchAll(FIG_FENCE_RE)) {
701 const lang = (m[1] ?? '').toLowerCase()
702 const body = m[2] ?? ''
703 const kind = lang === 'dot' || lang === 'graphviz' ? 'dot' : ['', 'text', 'txt'].includes(lang) && FIG_MARK_RE.test(body) ? 'figure' : null
704 if (kind === null) continue
705 const before = text.slice(at, m.index).trim()
706 if (before !== '') parts.push({ kind: 'text', text: before })
707 parts.push({ kind, text: body })
708 at = m.index + m[0].length
709 }
710 const rest = text.slice(at).trim()
711 if (rest !== '' || parts.length === 0) parts.push({ kind: 'text', text: parts.length === 0 ? text : rest })
712 return parts
713}
714
715// dot 原文粗算:-> 或 -- 算一條邊,出現在邊或節點宣告裡的名字算節點;屬性([...]、a=b)不算
716function countDot(source: string): { nodes: number; edges: number } {
717 const body = source.replace(/\[[^\]]*\]/g, ' ').replace(/^[^{]*\{/, ' ').replace(/\b\w+\s*=\s*("[^"]*"|\S+)/g, ' ')
718 const edges = (body.match(/->|--/g) ?? []).length
719 const words = body.match(/"[^"]*"|\b[A-Za-z_][\w]*\b/g) ?? []
720 const keywords = new Set(['graph', 'digraph', 'subgraph', 'node', 'edge', 'strict'])
721 return { nodes: new Set(words.filter(w => !keywords.has(w.toLowerCase()))).size, edges }
722}
723
724// 子代理回報開頭的框架說明:從 [Subagent hand-back] 到 The report follows: 拿掉,回報本文去掉引擎加的縮排;沒有就原樣回傳
725function stripHandback(text: string): string {
726 const m = /^\s*\[Subagent hand-back\][\s\S]*?The report follows:[^\n]*\n/.exec(text)
727 if (!m) return text
728 const lines = text.slice(m[0].length).split('\n')
729 const indent = Math.min(...lines.filter(l => l.trim() !== '').map(l => /^ */.exec(l)?.[0].length ?? 0))
730 return lines.map(l => l.slice(Number.isFinite(indent) ? indent : 0)).join('\n').trim()
731}
732
733function stripSummary(text: string): string {
734 return text.replace(FENCE_RE, '').trim()
735}
736
737function parseSummary(raw: string): Summary | null {
738 let data: unknown
739 try {
740 data = JSON.parse(raw)
741 } catch {
742 return null
743 }
744 if (typeof data !== 'object' || data === null) return null
745 const d = data as Record<string, unknown>
746 if (typeof d.outcome !== 'string') return null
747 const items = Array.isArray(d.items) ? d.items.filter(isItem) : []
748 const facets = Array.isArray(d.facets) ? d.facets.filter(isFacet) : []
749 const status = d.status === 'blocked' || d.status === 'partial' || d.status === 'done' ? d.status : undefined
750 const tasks = Array.isArray(d.tasks) ? d.tasks.filter((t): t is SummaryTask => typeof t?.text === 'string' && typeof t?.done === 'boolean') : []
751 return { ...(status ? { status } : {}), outcome: d.outcome, items, facets, ...(tasks.length > 0 ? { tasks } : {}) }
752}
753
754function isItem(x: unknown): x is Item {
755 if (typeof x !== 'object' || x === null) return false
756 const i = x as Record<string, unknown>
757 const kinds = ['decision', 'user-next', 'agent-next']
758 const options = i.options === undefined || (Array.isArray(i.options) && i.options.every(o => typeof o?.key === 'string' && typeof o?.label === 'string'))
759 return kinds.includes(String(i.kind)) && typeof i.text === 'string' && options
760}
761
762function isFacet(x: unknown): x is Facet {
763 if (typeof x !== 'object' || x === null) return false
764 const f = x as Record<string, unknown>
765 return typeof f.label === 'string' && typeof f.summary === 'string' && (f.heading === undefined || typeof f.heading === 'string')
766}
767
768// 要使用者選的題目(有選項的 decision);題號就是在這個陣列裡的位置
769function decisionsOf(items: Item[]): Item[] {
770 return items.filter(i => i.kind === 'decision' && (i.options?.length ?? 0) > 0)
771}
772
773// 等你決定>卡住>等你動手>部分完成>完成。照模型標的狀態走:完成、卡住不因附了「你要做」變黃(使用者實機:幾乎每個框都黃);
774// 只有做到一半停下來等你才是等你動手(實機 s3 原本標成進行中,兩個評審都誤判成還在跑)
775function glyphOf(s: Summary): string {
776 if (decisionsOf(s.items).length > 0) return '?'
777 if (s.status === 'blocked') return '!'
778 if (s.status !== 'done' && s.items.some(i => i.kind === 'user-next')) return '>'
779 if (s.status === 'partial') return '~'
780 return '✓'
781}
782
783// 把 facets 指到的段落(標題那行到下一個同級或更高的標題前)從正文拿掉;找不到標題的 facet 只畫摘要列。
784// 展開中的段落留在正文原位:放進框裡的 Markdown 會照整個終端寬度排表格,被框線擠到跑版(實機截圖)
785function cutSections(text: string, facets: Facet[], isOpen: (i: number) => boolean): { shown: string; sections: (string | null)[] } {
786 let lines = text.split('\n')
787 const sections = facets.map((f, n) => {
788 if (!f.heading) return null
789 const want = norm(f.heading)
790 const start = lines.findIndex(l => {
791 const m = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(l)
792 return m !== null && norm(m[2] ?? '') === want
793 })
794 if (start < 0) return null
795 const level = /^(#+)/.exec(lines[start] ?? '')?.[1]?.length ?? 1
796 let end = lines.length
797 for (let i = start + 1; i < lines.length; i++) {
798 const m = /^(#{1,6})\s/.exec(lines[i] ?? '')
799 if (m && (m[1]?.length ?? 7) <= level) {
800 end = i
801 break
802 }
803 }
804 const section = lines.slice(start + 1, end).join('\n').trim()
805 if (!isOpen(n)) lines = [...lines.slice(0, start), ...lines.slice(end)]
806 return section
807 })
808 return { shown: lines.join('\n').replace(/\n{3,}/g, '\n\n').trim(), sections }
809}
810
811// 有題目時,正文裡標題含「決定」的那段(例如「## 待你決定」)也收進框,列成「利弊」:題目本身已畫在框裡,
812// 正文再寫一次就是同一件事說兩次(實機 s2 三個模型都這樣)。沒有標題的寫法(直接列 1. 2.)抓不到,照原樣留在正文
813function withDecisionFacet(summary: Summary | null, text: string): Facet[] {
814 const facets = summary?.facets ?? []
815 if (!summary || decisionsOf(summary.items).length === 0) return facets
816 const taken = new Set(facets.map(f => norm(f.heading ?? '')))
817 const heading = text.split('\n').map(l => /^#{1,6}\s+(.+?)\s*#*\s*$/.exec(l)?.[1]).find(h => h !== undefined && h.includes('決定') && !taken.has(norm(h)))
818 return heading === undefined ? facets : [...facets, { label: '利弊', summary: '各選項的說明與建議', heading }]
819}
820
821function norm(s: string): string {
822 return s.replace(/[*_`]/g, '').trim()
823}
824
825type RenderEvent = Parameters<EngineInterface['ui']['resolve']>[0]
826
827function facetRows($: EngineInterface, e: RenderEvent, id: string, facets: Facet[], sections: (string | null)[], open: Record<string, boolean>) {
828 const { Box, Button, Text } = $.ui.resolve(e)
829 // 標籤補到同寬,摘要排成一直欄(「變更」與「殘留風險」長度不同,摘要起點原本對不齊)
830 const width = Math.max(0, ...facets.map(f => cols(f.label)))
831 return facets.map((f, i) => {
832 const key = `${id}:${i}`
833 const isOpen = open[key] === true
834 const section = sections[i]
835 return (
836 <Box key={`facet-${i}`} flexDirection="row">
837 {section
838 ? <Button key={`toggle-${i}`} plain label={isOpen ? '▾' : '▸'} onPress={() => update($, openAtom, o => ({ ...o, [key]: !isOpen }))} />
839 : <Text color={DIM}>{'·'}</Text>}
840 {/* 灰階分層(不加新顏色):標籤亮一階加粗、內文用亮字,「之後我」維持最暗,兩者分得開(使用者實機:收合列太灰不顯眼) */}
841 <Text color={SOFT} bold>{` ${f.label}${' '.repeat(width - cols(f.label))} `}</Text>
842 <Text color={BRIGHT}>{isOpen ? `${f.summary}(已放回正文)` : f.summary}</Text>
843 </Box>
844 )
845 })
846}
847
848// 待你決定的題目(選項按鈕)、你要做的事(黃)、之後我接著做的事(灰)
849async function itemRows($: EngineInterface, e: RenderEvent, id: string, items: Item[]) {
850 const answers = (await read($, answersAtom))[id] ?? {}
851 const { Box, Button, Text } = $.ui.resolve(e)
852 const decisions = decisionsOf(items)
853 const others = items.filter(i => !decisions.includes(i))
854 const questions = decisions.map((d, n) => (
855 <Box key={`q-${n}`} flexDirection="column" marginTop={n === 0 ? 1 : 0}>
856 <Text>
857 <Text color={YELLOW} bold>{'? '}</Text>
858 <Text color={BRIGHT}>{decisions.length > 1 ? `${n + 1}. ${d.text}` : d.text}</Text>
859 </Text>
860 <Box flexDirection="row" marginLeft={2}>
861 {(d.options ?? []).map(o => {
862 const picked = answers[n] === o.key
863 return (
864 <Box key={`opt-${n}-${o.key}`} flexDirection="row" marginRight={3}>
865 <Button key={`pick-${n}-${o.key}`} plain label={`${picked ? '●' : ' '}${o.key}`} onPress={() => pick($, id, n, o.key, decisions.length)} />
866 <Text>{` ${o.label}`}</Text>
867 {d.recommended === o.key ? <Text color={YELLOW}>{' 建議'}</Text> : null}
868 </Box>
869 )
870 })}
871 </Box>
872 </Box>
873 ))
874 // 符號換成字(評審:> 和 ~ 第一次看不懂);你要做的排在結論下第一行,之後我排最後
875 const todo = others.filter(o => o.kind === 'user-next').map((o, i) => (
876 <Text key={`todo-${i}`}>
877 <Text color={YELLOW} bold>{'你要做 '}</Text>
878 <Text>{o.text}</Text>
879 </Text>
880 ))
881 const later = others.filter(o => o.kind !== 'user-next').map((o, i) => (
882 <Text key={`later-${i}`} color={DIM}>{`之後我 ${o.text}`}</Text>
883 ))
884 return { todo, questions, later }
885}
886
887// 記下這題的答案,把這張卡已選的答案依題號排好填進提示框(取代草稿)
888async function pick($: EngineInterface, id: string, n: number, key: string, total: number) {
889 const all = await update($, answersAtom, a => ({ ...a, [id]: { ...(a[id] ?? {}), [n]: key } }))
890 const mine = all[id] ?? {}
891 const draft = Object.keys(mine).map(Number).sort((a, b) => a - b).map(i => (total > 1 ? `${i + 1}. ${mine[i]}` : `${mine[i]}`)).join(' ')
892 const filled = await $.prompt.fill({ text: draft })
893 if (!filled.isFilled) $.ui.toast(`${TAG}:沒填進提示框(${filled.refusal ?? '原因不明'})`, { timeoutMs: 4000 })
894}
895
896function firstSentence(text: string): string {
897 // 跳過標題行與 [Subagent hand-back] 這類方括號開頭的框架行,取第一行正文
898 const line = text.split('\n').filter(l => !/^\s*#/.test(l) && !/^\s*\[[^\]]+\]/.test(l))
899 .map(l => l.replace(/^[-*>]\s+|\*\*/g, '').trim()).find(l => l !== '' && !l.startsWith('```')) ?? ''
900 // 全形句號後面不必有空白;半形的要接空白或行尾(1.5、v0.9.0 不算句尾)
901 const m = /^(.+?(?:[。!?]|[.!?](?=\s|$)))/.exec(line)
902 return (m?.[1] ?? line).trim()
903}
904
905function duration(ms: number): string {
906 const s = Math.round(ms / 1000)
907 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`
908}
909
910function hash(s: string): string {
911 let h = 5381
912 for (const ch of s) h = ((h * 33) ^ (ch.codePointAt(0) ?? 0)) >>> 0
913 return h.toString(36)
914}
915
916// 終端欄寬:CJK 與全形字佔 2 欄
917function charCols(ch: string): number {
918 const c = ch.codePointAt(0) ?? 0
919 return (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xff00 && c <= 0xff60) ? 2 : 1
920}
921
922function cols(s: string): number {
923 let n = 0
924 for (const ch of s) n += charCols(ch)
925 return n
926}
927
928function clip(s: string, width: number): string {
929 let out = ''
930 let used = 0
931 for (const ch of s) {
932 const w = charCols(ch)
933 if (used + w > width - 1) return `${out}…`
934 out += ch
935 used += w
936 }
937 return out
938}
939types/index.d.ts 47 lines1// 回覆最後附的 ui-summary 區塊(模型照 sitrep 注入的系統提示寫)
2export type Option = { key: string; label: string }
3export type Item = { kind: 'decision' | 'user-next' | 'agent-next'; text: string; options?: Option[]; recommended?: string }
4export type Facet = { label: string; summary: string; heading?: string }
5// 多步驟工作的步驟清單(pane 的任務段)
6export type SummaryTask = { text: string; done: boolean }
7export type Summary = { status?: 'done' | 'blocked' | 'partial'; outcome: string; items: Item[]; facets: Facet[]; tasks?: SummaryTask[] }
8// 一輪結束時記下的結論:TurnDuration 那列沒有輪次編號,只能用耗時對回來
9export type TurnNote = { id?: string; durationMs: number; glyph: string; outcome: string }
10// 主對話派出的背景子代理:記在派它那一輪的結論框(card=ui-summary id)下
11export type AgentNote = { id: string; type: string; description: string; status: string; card: string; durationMs?: number; startedAt?: number }
12// 還沒 commit 的改動(git diff HEAD --numstat):repo 內路徑、絕對路徑(跟 touched 比)、增刪行數
13export type ChangeNote = { path: string; abs: string; add: number; del: number }
14// classic.TaskCreated/TaskCompleted 記下的任務
15export type TaskNote = { id: string; subject: string; done: boolean }
16// pane 證據段:沿用最近一輪有「驗證」段的結論,age=之後又過了幾輪有摘要的回合
17export type Evidence = { chips: string[]; proof: string[]; age: number }
18
19declare module 'claude-code' {
20 interface PluginState {
21 'sitrep': {
22 // 每個 ui-summary 區塊(以內容雜湊當 id)的已選答案:題號 → 選項 key
23 answers: Record<string, Record<number, string>>
24 // 展開中的收合段落:`${id}:${段落序號}`
25 open: Record<string, boolean>
26 // 最近幾輪的結論,給 TurnDuration 那列用
27 turns: TurnNote[]
28 // 最近一張結論框的待決題目,給 prompt 上方那列用;沒附區塊的回合不動它
29 // todo=要你親手做的事(user-next),沒題目時 prompt 上方那列改提示它
30 pending: { id: string; decisions: Item[]; todo: string[] } | null
31 // 背景子代理與它所屬的結論框
32 agents: AgentNote[]
33 // 狀態 pane:最近一張結論框、背景工作、任務、git diff --stat
34 last: { id: string; summary: Summary } | null
35 // 最近一次有「驗證」段的結論:小標籤、細節行、離現在幾輪(0=這一輪)
36 evidence: Evidence | null
37 background: string[]
38 tasks: TaskNote[]
39 changes: ChangeNote[]
40 // 這個 session 用 Edit/Write 改過的檔(絕對路徑)
41 touched: string[]
42 // pane 現在有畫出來:題目移到 pane,prompt 上方那列不畫
43 paneOpen: boolean
44 }
45 }
46}
47