Ez2 AIB 每席一台電腦:今天 / 專案 / 私訊 / 圖表 / 自訂(讀 seat_screen.py 一份資料,打開才算,只在真人打字時打開);席位核心(從 aib-seat 搬來):系統提示的席位身份、狀態列、85% 交接提醒、守衛擋下提示、新信卡片、/aib room

/aib-boards(只有真人在終端機打字送出才會開;外掛 / 排程 / 別席送來的不開)開一個面板,佔畫面 70%(對話 15 : 面板 35)。
| 分頁 | 內容 |
|---|---|
| 1 今天 | 狀態列、應知雷達與燈號、待辦分組(🔴⏳🙋👥📝)與分布圓餅、人格職責 / 紅線、專案 |
| 2 專案 | 綁定專案的計畫樹(總綱 › 大項 › 子項 › Task + 狀態 + 進度,亮的是我的) |
| 3 私訊 | 信件列表(未讀 / 已讀未結 / 完成);新信靠檔案事件從右下彈出,不輪詢 |
| 4 圖表 | 依級別:艦長 13、主管 8、監看 6、工程 6(同示範網頁);每張會動 |
| 5 自訂 | 勾選要放哪些圖,存在 ~/.ez2-aib/state/aib-boards/<seat_id>.json |
src/cockpit/seat_screen.py(+ 艦長級 seat_screen_fleet.py)一份;打開才算,今天 15 秒、圖表 / 專案 60 秒、私訊 15 秒快取。/aib room(開艦長室網頁)是艦長室外掛 captain-room 那段,拆在 hooks/room.ts:這份 aib 沒有艦長室(公開版)就只說開發中。探測程式 bin/seat-info.py。/plugin marketplace add <repo>/tools/claude-mods/aib-boards,再 /plugin install aib-boards@ez2-aib-boards。tests/test-claude-mods-boards-hosts-86ff0-1014.py(複製到暫存目錄跑 claude plugin validate + claude plugin test)。設計稿 docs/DESIGN-seat-screen-86ff0-1013.md §21、§22。舊的 aib-seat 外掛要從各機移除:claude plugin uninstall aib-seat@ez2-aib-mods,再 claude plugin marketplace remove ez2-aib-mods。
hooks/register.tsx 634 lines1// Ez2 AIB「每席一台電腦」免費分頁(the maintainer 10-14 00:1x「aib-boards 才是免費的」):今天 / 專案 / 私訊 / 圖表 / 自訂。
2// 資料只讀 src/cockpit/seat_screen.py 一份(§21 第 6、7 條);打開才算、各自快取、不輪詢(§21.2);新信靠檔案事件推。
3// 只在真人打字時打開(the maintainer 10-14 00:0x);畫面 1:4;動畫 20 秒自停、可省電(對話要能捲)。
4import { atom, read, update } from 'claude-code'
5import type { Register, EngineInterface, Timer } from 'claude-code'
6import type { Screen, Tab, Charts, Dm, Plan } from '../types'
7import type { Rows } from './charts'
8import { ctxCell, leaseCell, mailCell, myPkgs, pkgCell, pkgStages, bar as meter, hhmm, shortBranch, logRows, fit, typeIn, reveal, withLogo, logoSegs } from './lib'
9import type { Pkg } from './lib'
10import { sw, labelW, rowLabelW, logTagW } from './lib'
11import { knowParts, split41, SPIN, knowLine, TABS, CACHE_S, isHumanTyped, groupTodos, levelColor, levelDot, roleFlow, flowColor, themeOf, bumpUsage, effectiveEx, foldOrder, ago, isStale, nowState, parseScreen, parseCharts, parseDm, parsePlan } from './lib'
12import { TIER_DEFAULT, TIER_ALL, CHART_TITLE, tierOf, buildCard, pinFirst } from './chartset'
13import { tr as t_ } from './i18n'
14import { registerSeat, seatOnStart, seatOnFileChanged, seatWatchDir } from './seatcore'
15import { ready } from './ready'
16
17const PANE = 'aib-boards'
18const tab = atom({ plugin: 'aib-boards', key: 'tab' } as const, 'today' as Tab)
19const screen = atom({ plugin: 'aib-boards', key: 'screen' } as const, { at: 0, data: null as Screen | null, err: null as string | null })
20const charts = atom({ plugin: 'aib-boards', key: 'charts' } as const, { at: 0, data: null as Charts | null, err: null as string | null })
21const dm = atom({ plugin: 'aib-boards', key: 'dm' } as const, { at: 0, data: null as Dm | null, err: null as string | null })
22const plan = atom({ plugin: 'aib-boards', key: 'plan' } as const, { at: 0, data: null as Plan | null, err: null as string | null })
23const now = () => Date.now() / 1000
24// 正在讀哪幾種資料(審查 10-14:原本四種共用一個 busy,打開時正在算「今天」就按 3 ⇒ 私訊頁永遠「量中」;
25// 重載時舊程式的等待被取消、finally 不跑 ⇒ busy 卡 true)。改成每種各記開始時間,60 秒過期,模組變數重載歸零。
26const inflight: Record<string, number> = {}
27const isBusy = (k: string) => !!inflight[k] && Date.now() - inflight[k]! < 60_000
28const anyBusy = () => Object.keys(inflight).some(isBusy)
29// 這次對話真人打開過面板(彈窗只在那之後跳);記在 state,重載後還在
30const openedAtom = atom({ plugin: 'aib-boards', key: 'opened' } as const, false)
31// 今天頁摺疊(人格 / 專案 / 應知 / 包清單)展開了哪幾個;記在 state,重畫不歸零
32const expand = atom({ plugin: 'aib-boards', key: 'expand' } as const, {} as Record<string, boolean>)
33const dmDirAtom = atom({ plugin: 'aib-boards', key: 'dmDir' } as const, '')
34
35// ── 動畫:面板開著才 400ms 一格;20 秒自停(一直重畫會把對話拉回底部);按鈕 / 重新整理 / 在面板上滾再動 20 秒 ──
36const ANIM_MS = 200, ANIM_RUN_MS = 20_000 // 200ms 一格(the maintainer 03:09「光點走快一點」;原 400)
37let anim: Timer | undefined, frame = 0, saver = false, animUntil = 0
38let logAt = -1, logStart = 0 // 紀錄逐行吐出:資料換新(或重新整理)就從這一格重吐
39function animStart($: EngineInterface) {
40 if (saver) return
41 animUntil = Date.now() + ANIM_RUN_MS
42 if (anim) return
43 anim = $.clock.every(ANIM_MS, () => { if (Date.now() > animUntil) { animStop(); $.ui.invalidate('ui.render'); return } frame++; $.ui.invalidate('ui.render') })
44}
45function animStop() { anim?.cancel(); anim = undefined }
46
47// ── 語系與字典:ready.ts(與 seatcore.tsx 共用);純檔一律走 i18n.ts 的 tr()。
48// ── 資料層位置:$EZ2_AIB_SEAT_SCREEN → 開發期 dev-seat-screen.txt → $EZ2_AIB_ROOT/src/cockpit → ~/ez2-ai-b/src/cockpit ──
49let HOME = ''
50async function scriptPath($: EngineInterface): Promise<string | null> {
51 const r = await $.process.run(['sh', '-c', 'printf "%s|%s|%s" "$EZ2_AIB_SEAT_SCREEN" "$EZ2_AIB_ROOT" "$HOME"'], { timeoutMs: 5000 }).catch(() => null)
52 const [explicit = '', root = '', home = ''] = (r?.stdout ?? '').split('|')
53 HOME = home
54 let dev = ''
55 try { const f = `${home}/.ez2-aib/state/aib-boards/dev-seat-screen.txt`; if (home && (await $.fs.exists(f))) dev = (await $.fs.read(f)).trim() } catch { /* 沒有就算了 */ }
56 for (const p of [explicit, dev, root && `${root}/src/cockpit/seat_screen.py`, home && `${home}/ez2-ai-b/src/cockpit/seat_screen.py`]) if (p && (await $.fs.exists(p))) return p
57 return null
58}
59async function runS1($: EngineInterface, flags: string[]): Promise<string> {
60 const p = await scriptPath($)
61 if (!p) throw new Error(t_('no_s1'))
62 const r = await $.process.run(['python3', '-I', p, ...flags], { timeoutMs: 25000 })
63 if (r.exitCode !== 0) throw new Error(r.stderr.trim().split('\n').pop() || `rc=${r.exitCode}`)
64 return r.stdout
65}
66async function refresh($: EngineInterface, fresh = false) {
67 if (isBusy('today')) return
68 inflight.today = Date.now() // 先占位再查快取(審查:按鈕與重畫同時叫會各起一次重的計算)
69 let cur; try { cur = await read($, screen) } catch { delete inflight.today; return }
70 if (!fresh && cur.data && now() - cur.at < CACHE_S) { delete inflight.today; return }
71 $.ui.invalidate('ui.render')
72 try { const data = parseScreen(await runS1($, fresh ? ['--fresh'] : [])); await update($, screen, () => ({ at: now(), data, err: null })) }
73 catch (err) { await update($, screen, v => ({ ...v, at: now(), err: String((err as Error).message ?? err) })) }
74 finally { delete inflight.today; $.ui.invalidate('ui.render') }
75}
76async function refreshCharts($: EngineInterface, fresh = false) {
77 if (isBusy('charts')) return
78 inflight.charts = Date.now() // 先占位再查快取(審查:按鈕與重畫同時叫會各起一次重的計算)
79 let cur; try { cur = await read($, charts) } catch { delete inflight.charts; return }
80 if (!fresh && cur.data && now() - cur.at < 60) { delete inflight.charts; return }
81 $.ui.invalidate('ui.render')
82 try { const data = parseCharts(await runS1($, ['--charts', ...(fresh ? ['--fresh'] : [])])); await update($, charts, () => ({ at: now(), data, err: null })) }
83 catch (err) { await update($, charts, v => ({ ...v, at: now(), err: String((err as Error).message ?? err) })) }
84 finally { delete inflight.charts; $.ui.invalidate('ui.render') }
85}
86async function refreshDm($: EngineInterface, fresh = false) {
87 if (isBusy('dm')) return
88 inflight.dm = Date.now() // 先占位再查快取(審查:按鈕與重畫同時叫會各起一次重的計算)
89 let cur; try { cur = await read($, dm) } catch { delete inflight.dm; return }
90 if (!fresh && cur.data && now() - cur.at < 15) { delete inflight.dm; return }
91 $.ui.invalidate('ui.render')
92 try { const data = parseDm(await runS1($, ['--dm', ...(fresh ? ['--fresh'] : [])])); await update($, dm, () => ({ at: now(), data, err: null })) }
93 catch (err) { await update($, dm, v => ({ ...v, at: now(), err: String((err as Error).message ?? err) })) }
94 finally { delete inflight.dm; $.ui.invalidate('ui.render') }
95}
96async function refreshPlan($: EngineInterface, fresh = false) {
97 if (isBusy('plan')) return
98 inflight.plan = Date.now() // 先占位再查快取(審查:按鈕與重畫同時叫會各起一次重的計算)
99 let cur; try { cur = await read($, plan) } catch { delete inflight.plan; return }
100 if (!fresh && cur.data && now() - cur.at < 60) { delete inflight.plan; return }
101 $.ui.invalidate('ui.render')
102 try { const data = parsePlan(await runS1($, ['--plan', ...(fresh ? ['--fresh'] : [])])); await update($, plan, () => ({ at: now(), data, err: null })) }
103 catch (err) { await update($, plan, v => ({ ...v, at: now(), err: String((err as Error).message ?? err) })) }
104 finally { delete inflight.plan; $.ui.invalidate('ui.render') }
105}
106async function refreshTab($: EngineInterface, id: Tab, fresh = false) {
107 if (id === 'today') await refresh($, fresh)
108 else if (id === 'proj') await refreshPlan($, fresh)
109 else if (id === 'dm') await refreshDm($, fresh)
110 else await refreshCharts($, fresh)
111}
112
113async function readCtx($: EngineInterface): Promise<number | null> {
114 const u = await $.session.usage().catch(() => undefined)
115 const p = u?.context?.percent
116 return typeof p === 'number' ? p : null
117}
118async function paneRows($: EngineInterface): Promise<number> {
119 const r = await $.process.run(['sh', '-c', '[ -n "$TMUX" ] && tmux display -p -t "$TMUX_PANE" "#{pane_height}"'], { timeoutMs: 3000 }).catch(() => null)
120 const n = Number((r?.stdout ?? '').trim()); return Number.isFinite(n) && n > 0 ? n : 40
121}
122
123// ── 自訂:這一席要放哪些圖,存 ~/.ez2-aib/state/aib-boards/<seat_id>.json ──
124let picks: string[] | null = null, picksFor = ''
125const picksFile = (seatId: string) => `${HOME}/.ez2-aib/state/aib-boards/${seatId}.json`
126async function loadPicks($: EngineInterface, seatId: string, tier: string): Promise<string[]> {
127 if (picks && picksFor === seatId) return picks
128 picksFor = seatId; picks = null
129 try { const f = picksFile(seatId); if (HOME && (await $.fs.exists(f))) { const v = (JSON.parse(await $.fs.read(f)) as { charts?: string[] }).charts; if (Array.isArray(v)) picks = v } } catch { /* 用預設 */ }
130 return picks ?? TIER_DEFAULT[tier]!
131}
132// 同一個檔還放 usage(§22.5 第 3 步):寫的時候先讀回別的欄位再合併,誰寫都不蓋掉對方
133async function readPrefs($: EngineInterface, seatId: string): Promise<Record<string, unknown>> {
134 try { const f = picksFile(seatId); if (HOME && (await $.fs.exists(f))) return JSON.parse(await $.fs.read(f)) as Record<string, unknown> } catch { /* 壞檔當沒有 */ }
135 return {}
136}
137async function writePrefs($: EngineInterface, seatId: string, patch: Record<string, unknown>) {
138 if (HOME) await $.fs.write(picksFile(seatId), JSON.stringify({ ...(await readPrefs($, seatId)), ...patch, at: now() })).catch(() => undefined)
139}
140async function savePicks($: EngineInterface, seatId: string, list: string[]) { picks = list; picksFor = seatId; await writePrefs($, seatId, { charts: list }) }
141// 使用習慣:這一席今天頁每個區塊點了幾次(只存本機這一席的設定檔,不外傳)
142let usage: Record<string, number> | null = null, usageFor = ''
143async function loadUsage($: EngineInterface, seatId: string): Promise<Record<string, number>> {
144 if (usage && usageFor === seatId) return usage
145 usageFor = seatId
146 const v = (await readPrefs($, seatId)).usage
147 usage = v && typeof v === 'object' ? v as Record<string, number> : {}
148 return usage
149}
150async function saveUsage($: EngineInterface, seatId: string, u: Record<string, number>) { usage = u; usageFor = seatId; await writePrefs($, seatId, { usage: u }) }
151
152// ── 新信彈窗:監看這一席的信件資料夾(檔案事件,不輪詢);只在這次對話真人打開過面板後才跳 ──
153async function dmWatchDir($: EngineInterface): Promise<string> {
154 const cur = await read($, dmDirAtom); if (cur) return cur
155 const p = await scriptPath($).catch(() => null); if (!p) return ''
156 const cockpit = p.replace(/\/seat_screen\.py$/, '')
157 // 席碼用正本 seat_now.self_code()(審查第二輪:只看 AIB_RESUME_SESSION_UUID 的話,一般新開 / 分出的席是空的 ⇒ 永遠不跳;繼承到死掉的舊 uuid ⇒ 監看到別席)
158 const py = 'import os,sys;sys.path.insert(0,sys.argv[1]);from seat_now import self_code;from seats_dir_lib import state_root;c=self_code();print(os.path.join(state_root()[0],"state","dm-queue",c) if c else "")'
159 const r = await $.process.run(['python3', '-I', '-c', py, cockpit], { timeoutMs: 3000 }).catch(() => null)
160 const dir = (r?.exitCode === 0 ? r.stdout.trim() : '')
161 if (dir) await update($, dmDirAtom, () => dir)
162 return dir
163}
164let dmDirtyAt = 0, dmWake = false, dmUpgraded = false, s1Upgraded = false // 去抖動計時器同時只掛一個(畫面每 0.4 秒重畫,不能每次都掛)
165
166export const register: Register = on => {
167 registerSeat(on) // 席位核心(從 aib-seat 搬來,設計稿 §24);外掛只能有一個 hooks 模組 ⇒ 由這裡掛上
168 on('session.start', async ($, e, next) => {
169 // 指令說明只在開席時登記一次,切語系跟不上 ⇒ 固定英文(同 aib-languages)
170 await $.command.register({ name: 'aib-boards', description: "This seat's computer: Today / Project / Chat / Charts / Custom" })
171 await seatOnStart($).catch(() => undefined) // 席位核心:身份、狀態列、交接提醒、新信卡片(seatcore.tsx)
172 return next(e)
173 })
174 on('classic.SessionStart', async ($, e, next) => {
175 const r = await next(e), dir = await dmWatchDir($).catch(() => ''), sdir = await seatWatchDir($).catch(() => '') // 新信卡片也要同一個資料夾(兩支各自量,量到的都登記、去重)
176 const add = [...new Set([dir, sdir].filter(Boolean))]
177 return add.length ? { ...r, watchPaths: [...(r.watchPaths ?? []), ...add] } : r
178 }).catch(($, e, next) => (next.called ? next(e) : next(e))) // 只顯示的 mod 出錯不擋流程
179 on('classic.FileChanged', async ($, e, next) => {
180 const r = await next(e)
181 await seatOnFileChanged($, e.file_path, e.event).catch(() => undefined) // 新信卡片(輸入框上方,seatcore.tsx;原本在 aib-seat)
182 const dmDir = await read($, dmDirAtom)
183 if (dmDir && e.file_path.startsWith(dmDir + '/') && /\/q_[^/]+\.json$/.test(e.file_path) && e.event !== 'unlink') {
184 dmDirtyAt = Date.now() // 信件連續改檔:等 2 秒沒新變動才重讀一次(去抖動);之前失敗過也會重讀
185 await update($, dm, v => ({ ...v, err: null }))
186 $.ui.invalidate('ui.render')
187 }
188 return r
189 }).catch(($, e, next) => (next.called ? next(e) : next(e))) // 只顯示的 mod 出錯不擋流程
190 on('command.run', { command: 'aib-boards' }, async ($, e) => {
191 const { t } = await ready($)
192 if (!isHumanTyped(e.origin)) return { text: t('only_human', { what: e.origin?.kind ?? t('origin_unknown') }) }
193 // 面板標題只在打開時給一次:切語系後要關掉再開才換(面板內容每次重畫都照新語系)
194 const o = await $.ui.open({ id: PANE, title: t('pane_title'), ...split41(e.presentation?.columns ?? 120, await paneRows($)) })
195 await update($, openedAtom, () => true)
196 if (o.isPlaced) animStart($)
197 void refresh($)
198 return { text: t('opened') }
199 })
200 on('ui.close', async ($, e, next) => { if (e.id === PANE) animStop(); return next(e) }).catch(($, e, next) => (next.called ? next(e) : next(e))) // 只顯示的 mod 出錯不擋流程
201 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => { animStart($); return next(e) }).catch(($, e, next) => (next.called ? next(e) : next(e))) // 只顯示的 mod 出錯不擋流程
202
203 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
204 const { Box, Text, Button } = $.ui.resolve(e)
205 const { t: T, loc } = await ready($)
206 const tierLabel = (x?: string | null) => T(`tier_${x && ['captain', 'lead', 'qa', 'eng', 'design'].includes(x) ? x : 'eng'}`)
207 const took = (ms?: number) => (ms !== undefined ? T('took', { n: ms }) : '')
208 const [t, S] = await Promise.all([read($, tab), read($, screen)]), bz = anyBusy()
209 const rowsEl = (rows: Rows) => rows.map(r => <Text>{r.map(sg => <Text color={sg.color} dimColor={sg.dim}>{sg.text}</Text>)}</Text>)
210 const go = (id: Tab) => () => { animStart($); void update($, tab, () => id); void refreshTab($, id) }
211 const bar = (
212 <Box flexDirection="row" gap={1} flexWrap="wrap">
213 {TABS.map(x => <Button key={`tab-${x.id}`} hotkey={x.hotkey} variant={t === x.id ? 'primary' : 'secondary'} onPress={go(x.id)}>{x.label}</Button>)}
214 <Button key="refresh" onPress={() => { animStart($); void refresh($, true); if (t !== 'today') void refreshTab($, t, true) }}>{T('refresh')}</Button>
215 <Button key="saver" variant={saver ? 'primary' : 'secondary'} onPress={() => { saver = !saver; if (saver) animStop(); else animStart($); $.ui.invalidate('ui.render') }}>{saver ? T('anim_saver') : T('anim_on')}</Button>
216 {bz ? <Text color="warning">⏳ {T('measuring')}</Text> : null}
217 </Box>)
218 const d = S.data
219 // 標誌一律原廠色(the maintainer 10-14「仔細把 aib-boards 的所有 logo 補回原廠色」):Pw 切已帶標誌的字串,P 先補標誌再切;
220 // 標誌那一格只給原廠色、不跟外層變暗(外層 dimColor 會把整串連標誌一起變暗,所以變暗改由每段自己帶)
221 const Lgw = (s: string, o: { color?: string; dim?: boolean; bold?: boolean } = {}) => logoSegs(s, d?.logos, d?.logo_colors).map(x => x.color ? <Text color={x.color}>{x.text}</Text> : <Text color={o.color} dimColor={o.dim} bold={o.bold}>{x.text}</Text>)
222 const Lg = (s: string, o: { color?: string; dim?: boolean; bold?: boolean } = {}) => Lgw(withLogo(s, d?.logos), o)
223 // 手上是舊格式(有標誌、沒原廠色,資料層升級前抓的)⇒ 自己重抓一次
224 if (d?.logos && Object.keys(d.logos).length && !d.logo_colors && !isBusy('today') && !s1Upgraded) { s1Upgraded = true; void refresh($, true) }
225 const head = d
226 ? <Box flexDirection="column" marginTop={1}>
227 <Text><Text color="claude">{saver ? '·' : SPIN[frame % SPIN.length]} </Text><Text bold>{d.persona?.emoji ?? '🖥'} {d.persona?.name ?? T('no_persona')}</Text>{isStale(d.measured_at, now()) ? <Text color="warning"> {T('stale')}</Text> : null}</Text>
228 <Text wrap="truncate-end">{Lg(` @${d.code ?? '?'} · ${d.role ?? '?'} · ${tierLabel(d.tier)} · ${T('cabin', { value: (d.cabin ?? '?').split('-').pop() ?? '?' })} · ${T('measured_ago', { when: ago(now() - d.measured_at) })}${took(d.took_ms)}`, { dim: true })}</Text>
229 </Box>
230 : <Text color={S.err ? 'error' : 'warning'}>{S.err ? T('na_err', { err: S.err }) : T('measuring')}</Text>
231 // 今天頁自己有狀態列,不再疊舊標題(the maintainer 02:5x「版面還要再調整」:實看面板 103×45,上方重複)
232 const page = (...body: unknown[]) => (<Box flexDirection="column">{bar}{t === 'today' && d ? null : head}{body as never}</Box>)
233 const cols = e.props?.bodyColumns ?? e.viewport?.columns ?? 120, half = Math.max(44, Math.floor((cols - 3) / 2))
234 const tier = tierOf(d?.tier)
235
236 if (t === 'charts' || t === 'custom') {
237 const C = await read($, charts), cd = C.data
238 if (!cd && !isBusy('charts') && !C.err) void refreshCharts($)
239 if (!cd) return page(<Text color={C.err ? 'error' : 'warning'}>{C.err ? T('charts_err', { err: C.err }) : T('charts_loading')}</Text>)
240 const seatId = d?.seat_id ?? 'unknown', list = await loadPicks($, seatId, tier)
241 if (t === 'custom') {
242 const all = TIER_ALL[tier]!, toggle = (k: string) => () => { animStart($); const cur = list.includes(k) ? list.filter(x => x !== k) : [...list, k]; void savePicks($, seatId, cur).then(() => $.ui.invalidate('ui.render')) }
243 // the maintainer 10-14 04:4x「這有點擠,不美觀,請重新排列對齊」:等寬格子排(每格同寬、每列同欄數),勾了的標順序號;段與段空一行
244 const tw = (x: string) => [...x].reduce((a, ch) => a + (/[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\uff00-\uff60]/.test(ch) ? 2 : 1), 0)
245 const cellW = Math.max(...all.map(k => tw(CHART_TITLE[k] ?? k))) + 14, inner = Math.max(cellW, Math.min(cols - 8, 120))
246 const perRow = Math.max(1, Math.floor(inner / cellW)), gridRows: string[][] = []
247 for (let k = 0; k < all.length; k += perRow) gridRows.push(all.slice(k, k + perRow))
248 const order = pinFirst(list), no = (k: string) => (list.includes(k) ? String(order.indexOf(k) + 1).padStart(2, '0') : ' ')
249 return page(
250 <Box flexDirection="column" paddingX={2} marginTop={1}>
251 <Text bold>{T('custom_title')}</Text>
252 <Text dimColor>{T('custom_sub', { value: tierLabel(d?.tier), v1: list.length, v2: all.length })}</Text>
253 <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={2} paddingY={1} marginTop={1} width={perRow * cellW + 6}>
254 {gridRows.map((row, ri) => (
255 <Box flexDirection="row" marginBottom={ri < gridRows.length - 1 ? 1 : 0}>
256 {row.map(k => (
257 <Box width={cellW}>
258 <Button key={`pick-${k}`} variant={list.includes(k) ? 'primary' : 'secondary'} onPress={toggle(k)}>{list.includes(k) ? '☑' : '☐'} {no(k)} {CHART_TITLE[k] ?? k}</Button>
259 </Box>))}
260 </Box>))}
261 </Box>
262 <Box flexDirection="row" gap={2} marginTop={1}><Button key="pick-reset" onPress={() => { void savePicks($, seatId, TIER_DEFAULT[tier]!).then(() => $.ui.invalidate('ui.render')) }}>{T('pick_reset')}</Button><Button key="pick-all" onPress={() => { void savePicks($, seatId, all).then(() => $.ui.invalidate('ui.render')) }}>{T('pick_all')}</Button><Button key="pick-none" onPress={() => { void savePicks($, seatId, []).then(() => $.ui.invalidate('ui.render')) }}>{T('pick_none')}</Button></Box>
263 <Box marginTop={1}><Text dimColor>{T('custom_hint')}</Text></Box>
264 </Box>)
265 }
266 const ctx = await readCtx($), cx = { cd, ctx, frame, half, full: cols - 2, now: now(), scr: d }
267 // 半寬放不下的圖(雷達、熱力格、和弦這類固定大小的)⇒ 用整寬重畫、獨佔一列(同 aib boards 小窗格),不讓它被折行
268 const rowW = (c: { body: { text: string }[][] }) => Math.max(0, ...c.body.map(r => sw(r.map(sg => sg.text).join(''))))
269 const cards = pinFirst(list).map(k => { const c = buildCard(k, cx); if (!c || c.wide || rowW(c) <= half - 4) return c
270 const w = buildCard(k, { ...cx, half: cols - 2 }); return w ? { ...w, wide: true } : c }).filter((x): x is NonNullable<typeof x> => !!x)
271 const missingCards = pinFirst(list).filter(k => !buildCard(k, cx)).map(k => CHART_TITLE[k] ?? k)
272 return page(
273 <Text dimColor>{T('charts_head', { value: tierLabel(d?.tier), n: cards.length, when: ago(now() - cd.measured_at) })}{took(cd.took_ms)}{missingCards.length ? T('charts_missing', { what: missingCards.join(T('sep')) }) : ''}</Text>,
274 (() => {
275 // 兩欄各自往下疊(the maintainer 03:13「這個圖表區,如果一個過高,其他會很怪」):每張卡放到目前比較矮的那一欄;寬卡獨佔一列、前後重新起欄
276 const card = (c: (typeof cards)[number], i: number) => (
277 <Box flexDirection="column" width={c.wide ? cols - 2 : half} borderStyle="round" borderColor="subtle" paddingX={1} marginBottom={1}>
278 <Text wrap="truncate-end"><Text color="warning">{String(i + 1).padStart(2, '0')} </Text><Text bold>{c.title}</Text>{c.big ? <Text> · {c.big}</Text> : null}</Text>
279 <Text dimColor wrap="truncate-end">{c.how}</Text>
280 {rowsEl(c.body)}
281 <Text dimColor wrap="truncate-end">{c.src}</Text>
282 </Box>)
283 const blocks: unknown[] = []
284 let colA: unknown[] = [], colB: unknown[] = [], hA = 0, hB = 0
285 const flush = () => { if (colA.length || colB.length) blocks.push(<Box flexDirection="row" gap={1} alignItems="flex-start"><Box flexDirection="column" width={half}>{colA as never}</Box><Box flexDirection="column" width={half}>{colB as never}</Box></Box>); colA = []; colB = []; hA = 0; hB = 0 }
286 cards.forEach((c, i) => {
287 if (c.wide) { flush(); blocks.push(card(c, i)); return }
288 const h = c.body.length + 4
289 if (hA <= hB) { colA.push(card(c, i)); hA += h } else { colB.push(card(c, i)); hB += h }
290 })
291 flush()
292 return <Box flexDirection="column">{blocks as never}</Box>
293 })())
294 }
295 if (t === 'dm') {
296 const D = await read($, dm), dd = D.data
297 if (dmDirtyAt && Date.now() - dmDirtyAt > 2000 && !isBusy('dm')) { dmDirtyAt = 0; void refreshDm($, true) }
298 else if (!dd && !isBusy('dm') && !D.err) void refreshDm($)
299 else if (dmDirtyAt && !dmWake) { dmWake = true; $.clock.after(2100, () => { dmWake = false; $.ui.invalidate('ui.render') }) }
300 if (!dd) return page(<Text color={D.err ? 'error' : 'warning'}>{D.err ? T('dm_err', { err: D.err }) : T('dm_loading')}</Text>)
301 // 手上是舊格式(沒有狀態 / 原廠色,資料層升級前抓的)⇒ 自己重抓一次,不等人按重新整理(the maintainer 03:4x「icon 色還是沒出現」)
302 if (!isBusy('dm') && ((dd.chat?.length ? !dd.chat.some(r => r.state) : false) || !Object.values(dd.seats ?? {}).some(x => x.color)) && !dmUpgraded) { dmUpgraded = true; void refreshDm($, true) }
303 const col = (s: string) => s === 'waiting' ? 'error' : s === 'read' ? 'warning' : 'success', lbl = (s: string) => (s === 'waiting' ? T('st_unread') : s === 'read' ? T('st_read_open') : s === 'done' ? T('st_done') : s)
304 // 「對話」頁(the maintainer 10-14 03:3x「私訊改成對話,分左右 8:2;現在的內容改到右邊,左邊改成像 telegram 的對話,要帶 AI 的 logo、顏色、人格」)
305 const ex = await read($, expand)
306 const TW2 = cols - 4, LW = Math.floor(TW2 * 0.8), RW = Math.max(18, TW2 - LW - 1)
307 const seats = dd.seats ?? {}, me = d?.code ?? ''
308 // the maintainer 03:46:框色每席不同(由席碼固定挑);只有標誌用原廠色(render_lib.ENGINE_LETTER_COLORS,grok 黑白 ⇒ 預設字色)
309 const FRAME = ['claude', 'success', 'suggestion', 'warning', 'permission', 'autoAccept', 'planMode']
310 const seatColor = (c?: string | null) => { if (!c) return 'inactive'; let h = 0; for (const ch of c) h = (h * 31 + ch.charCodeAt(0)) >>> 0; return FRAME[h % FRAME.length]! }
311 const logoEl = (c?: string | null) => (c && seats[c]?.logo ? <Text color={seats[c]?.color || undefined}>{seats[c]!.logo} </Text> : null)
312 const who = (c?: string | null) => { const x = c ? seats[c] : undefined
313 return `${x?.emoji ? x.emoji + ' ' : ''}${x?.name ?? x?.role ?? ''}${c ? ` @${c}` : ''}`.trim() }
314 // 真實狀態(the maintainer 03:4x):每則訊息頭帶狀態;最上方總覽 收到 / 寄出 × 未讀 / 已讀 / 完成 / 卡住 / 封存,點了只看那一種
315 const ST: Record<string, [string, string]> = { unread: [T('st_unread'), 'error'], read: [T('st_read'), 'warning'], done: [T('st_done'), 'success'], held: [T('st_held'), 'permission'], archived: [T('st_archived'), 'inactive'] }
316 const allChat = dd.chat ?? [], fk = (ex as Record<string, unknown>).chatf as string | undefined
317 const chat = fk ? allChat.filter(r => `${r.out ? 'out' : 'in'}:${r.state ?? 'unread'}` === fk) : allChat, shown = ex.chat ? chat : chat.slice(-24)
318 const tally = (out: boolean) => Object.keys(ST).map(k => [k, allChat.filter(r => r.out === out && (r.state ?? 'unread') === k).length] as [string, number]).filter(x => x[1])
319 const setF = (v?: string) => () => { void update($, expand, x => ({ ...x, chatf: v as never })) }
320 const summary = (
321 <Box flexDirection="column" marginBottom={1}>
322 {([[false, T('recv')], [true, T('sent')]] as [boolean, string][]).map(([out, nm]) => (
323 <Box flexDirection="row" gap={1} flexWrap="wrap"><Box width={11}><Text dimColor>{nm}</Text></Box>
324 {tally(out).map(([k, n]) => <Button key={`f-${out ? 'out' : 'in'}-${k}`} variant={fk === `${out ? 'out' : 'in'}:${k}` ? 'primary' : 'secondary'} onPress={setF(fk === `${out ? 'out' : 'in'}:${k}` ? undefined : `${out ? 'out' : 'in'}:${k}`)}>{ST[k]![0]} {n}</Button>)}
325 {tally(out).length ? null : <Text dimColor>{T('none')}</Text>}
326 </Box>))}
327 {fk ? <Box><Button key="f-all" variant="secondary" onPress={setF(undefined)}>{T('view_all', { n: allChat.length })}</Button></Box> : null}
328 </Box>)
329 // 我在右、對方在左(the maintainer 10-14 04:0x「自已是在右邊,而對方是在左邊」):泡泡只佔六成寬,左右才分得開(七成二時兩邊疊在中間,看不出誰是誰)
330 const bubbleW = Math.max(28, Math.floor((LW - 4) * 0.6))
331 let lastDay = ''
332 const msgs = shown.map(r => {
333 const day = new Date(r.at * 1000).toLocaleDateString(loc, { month: '2-digit', day: '2-digit' }), sep = day !== lastDay ? day : ''
334 lastDay = day
335 const other = r.out ? r.to : r.from, c = seatColor(r.out ? me : r.from)
336 return (
337 <Box flexDirection="column" width={LW - 4}>
338 {sep ? <Box justifyContent="center" marginY={1}><Text dimColor>──── {sep} ────</Text></Box> : null}
339 <Box flexDirection="column" alignItems={r.out ? 'flex-end' : 'flex-start'} marginBottom={1}>
340 <Text>{(() => { const stt = r.state ? ST[r.state] : undefined; const badge = stt ? <Text color={stt[1]} bold={r.state === 'unread'}>[{r.out ? T('badge_them', { text: stt[0] }) : stt[0]}]</Text> : null
341 // the maintainer 04:08:右邊那則的頭原本秀對方的標誌與人格(「我 → ⚓ 第二艦長」),一眼看成右邊是對方 ⇒ 右邊頭只放我自己;收件人收成一小段灰字
342 return r.out ? <Text>{badge}<Text dimColor> {hhmm(r.at)} {T('to_who', { who: other ? `@${other}` : '?' })} </Text>{logoEl(me)}<Text color={c} bold>{T('me_suffix', { name: who(me) })}</Text></Text> : <Text>{logoEl(r.from)}<Text color={c} bold>{who(r.from)}</Text><Text dimColor> {hhmm(r.at)} </Text>{badge}</Text> })()}</Text>
343 <Box borderStyle="round" borderColor={c} paddingX={1} width={bubbleW}><Text wrap="wrap">{r.text}</Text></Box>
344 </Box>
345 </Box>)
346 })
347 const left = (
348 <Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1} width={LW}>
349 <Box flexDirection="row" justifyContent="space-between"><Text><Text bold>{T('tab_dm')}</Text><Text dimColor>{T('chat_sub', { n: chat.length })}</Text>{logoEl(me)}<Text color={seatColor(me)}>{who(me)}</Text></Text>
350 {chat.length > 24 ? <Button key="ex-chat" variant="secondary" onPress={() => { void update($, expand, v => ({ ...v, chat: !v.chat })) }}>{ex.chat ? T('only_recent') : T('earlier', { n: chat.length - 24 })}</Button> : null}</Box>
351 {summary}
352 {msgs.length ? msgs : [<Text dimColor>{fk ? T('no_msg_state') : T('no_chat')}</Text>]}
353 </Box>)
354 const right = (
355 <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1} width={RW}>
356 <Text bold>{T('inbox_title')}</Text>
357 <Text><Text color="error">{T('hex_unread', { n: dd.counts.unread })}</Text><Text dimColor> · </Text><Text color="warning">{T('open_short', { n: dd.counts.open })}</Text></Text>
358 <Text dimColor>{T('done7', { n: dd.counts.done_7d })}</Text>
359 {dd.items.slice(0, 20).map(m => (
360 <Box flexDirection="column" marginTop={1}>
361 <Text wrap="truncate-end"><Text color={col(m.status)} dimColor={m.status === 'waiting' && !saver && frame % 4 >= 2}>●</Text> {Lg(m.sender_seat ? `@${m.sender_seat}` : (m.sender ?? '?'), { color: seatColor(m.sender_seat), bold: true })}</Text>
362 <Text dimColor wrap="truncate-end">{m.when ?? ''} · {lbl(m.status)}</Text>
363 <Text wrap="truncate-end" dimColor={m.status === 'done'}>{m.text.replace(/\s+/g, ' ')}</Text>
364 </Box>))}
365 </Box>)
366 return page(<Box flexDirection="row" gap={1} paddingX={2} marginTop={1}>{left}{right}</Box>)
367 }
368 if (t === 'proj') {
369 const P = await read($, plan), pd = P.data
370 if (!pd && !isBusy('plan') && !P.err) void refreshPlan($)
371 if (!pd) return page(<Text color={P.err ? 'warning' : 'warning'}>{P.err ? T('proj_err', { err: P.err }) : T('plan_loading')}</Text>, !d?.project ? <Text dimColor>{T('bind_hint')}</Text> : null)
372 const kids = new Map<string, typeof pd.nodes>(); pd.nodes.forEach(n => { const k = n.parent ?? ''; kids.set(k, [...(kids.get(k) ?? []), n]) })
373 const out: unknown[] = [], ST: Record<string, [string, string]> = { done: ['✔', 'success'], in_progress: ['▶', 'suggestion'], waiting_prereq: ['⛓', 'warning'], blocked: ['✖', 'error'], partial: ['◐', 'inactive'], pending: ['○', 'inactive'] }
374 const LY: Record<string, string> = { goal: T('layer_goal'), major: T('layer_major'), sub: T('layer_sub'), task: 'Task' }
375 const walk = (pid: string, depth: number) => (kids.get(pid) ?? []).forEach(n => {
376 const [ic, c] = ST[n.status] ?? ['·', 'inactive'], mine = !!d?.code && (n.owner ?? '').includes(d.code), pr = n.progress == null ? null : Math.max(0, Math.min(1, n.progress))
377 out.push(<Text wrap="truncate-end"><Text>{' '.repeat(depth)}</Text><Text color={c} dimColor={n.status === 'in_progress' && !saver && frame % 4 >= 2}>{ic} </Text><Text dimColor>{LY[n.layer] ?? n.layer} </Text><Text bold={mine} color={mine ? 'claude' : 'text'}>{n.title}</Text>{pr != null ? <Text color="success"> {'█'.repeat(Math.round(pr * 8))}<Text color="subtle">{'░'.repeat(8 - Math.round(pr * 8))}</Text> {Math.round(pr * 100)}%</Text> : null}{n.owner ? Lg(` · ${n.owner}`, { dim: true }) : null}</Text>)
378 walk(n.id, depth + 1) })
379 const roots = pd.nodes.filter(n => !n.parent || !pd.nodes.some(m => m.id === n.parent)); roots.forEach(r => { kids.set('__root', roots) }); walk('__root', 0)
380 const cnt = (s: string) => pd.nodes.filter(n => n.status === s).length
381 const PW = Math.min(120, cols - 4)
382 return page(
383 <Box flexDirection="column" paddingX={2} marginTop={1} width={PW + 4}>
384 <Box flexDirection="row" gap={3}><Text bold>{T('tab_proj')}</Text><Text>{pd.title ?? ''}</Text><Text dimColor>{pd.project_uid}</Text></Box>
385 <Box flexDirection="row" gap={3} marginBottom={1}><Text dimColor>{T('nodes_n', { n: pd.nodes.length })}</Text><Text color="success">✔ {cnt('done')}</Text><Text color="suggestion">▶ {cnt('in_progress')}</Text><Text color="warning">⛓ {cnt('waiting_prereq')}</Text><Text color="error">✖ {cnt('blocked')}</Text><Text dimColor>{T('mine_lit')}</Text></Box>
386 <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={2}>{out as never}</Box>
387 </Box>)
388 }
389 if (!d) return page()
390 // 今天頁 = 流程儀表板(the maintainer 10-14 02:5x 參考圖:上方狀態列、左欄此刻、中間流程框 + 條、下方紀錄、底部一行狀態)
391 // 內容照 §22.4:此刻 / 待辦 / 我的包走到哪 / 人格專案應知收一行;雷達與圓餅在「自訂」
392 const W = Math.min(120, cols - 4), SW = 30, FW = Math.max(40, W - SW - 2), LBL = labelW(), RL = rowLabelW()
393 // §22.5 第 3 步:沒手動開關過的區塊照使用次數決定開合;每次點都記一筆(這一席自己的設定檔)
394 const sid = d.seat_id ?? d.code ?? 'unknown', use = await loadUsage($, sid)
395 const ex = effectiveEx(await read($, expand), use)
396 const flip = (k: string) => () => { const was = !!ex[k]; void update($, expand, v => ({ ...v, [k]: !was })); void saveUsage($, sid, bumpUsage(usage, k)) }
397 const resetLayout = () => { void update($, expand, () => ({})); void saveUsage($, sid, {}) }
398 const pk = myPkgs(d.know), ctxP = await readCtx($), nowS = now()
399 const cells = [ctxCell(ctxP), leaseCell(d.know, d.code, nowS), mailCell(d.know, d.todo), pkgCell(pk)]
400 const live = !saver, spin = live ? SPIN[frame % SPIN.length] : '·', pulse = (c: string) => live && c === 'error' && frame % 4 >= 2
401 const kd = (s: string) => d.know.find(k => k.sensor === s)
402 const lease = (kd('machine_lease')?.data ?? {}) as { state?: string; owner?: string; acquired_epoch?: number; expiry_epoch?: number; queue?: number }
403 const push = (kd('push_chain')?.data ?? {}) as { running?: { pid: number; elapsed_s: number; branch?: string | null }[]; queued?: number | null }
404 const mainK = kd('main_head'), main = (mainK?.data ?? {}) as { sha?: string; committed_at?: number }
405 const groups = groupTodos(d.todo), cnt = (id: string) => groups.find(g => g.id === id)?.items.length ?? 0
406 const sd = d.now?.steps_done ?? null, st = d.now?.steps_total ?? null
407 const [bf, be] = meter(st ? (sd ?? 0) / st : 0, 12)
408 const th = themeOf(d.persona?.theme) // §22.5 第 2 步:人格主題(主色 / 光點 / 速度 / 框線)
409 const frameBox = (title: string, color: string, w: number, ...kids: unknown[]) => (
410 <Box flexDirection="column" borderStyle={th.border} borderColor={color} paddingX={1} width={w}>
411 <Text wrap="truncate-end"><Text bold color={color}>{title}</Text></Text>
412 {kids as never}
413 </Box>)
414 // 流程框之間的連接線:一顆光點往下流(動畫開著才動)
415 // 框之間的連接線(the maintainer 03:06 高一點 + 粒子;03:07 有順序、線性;03:09 走快一點、尾巴拉長、所有粒子端點與方向一致):
416 // 全部由上往下;每條線(含分岔的三道)同一個相位:光點從線頂出發、走到線底,後面拖 3 格漸暗尾巴,尾巴走完才重來
417 const LH = 4, TAIL = 3, pos = Math.floor((frame * th.mul) / th.div) % (LH + TAIL)
418 const dot = (r: number): [string, string, boolean] => { // 第 r 行(0 = 線頂)該畫什麼:[字, 顏色, 暗]
419 if (!live) return ['│', 'subtle', true]
420 const k = pos - r
421 return k === 0 ? [th.particle, th.accent, false] : k === 1 ? [th.particle, th.accent, true] : k === 2 ? ['•', th.accent, true] : k === 3 ? ['│', th.accent, true] : ['│', 'subtle', true]
422 }
423 // 端點要正確(the maintainer 03:09):所有光點落在同一欄 MID = 中間那個框的正中(分岔線的 ┴ 也在這一欄);標籤一律在光點右邊,不參與置中
424 const T3 = Math.floor((FW - 2) / 3), MID = T3 + 1 + Math.floor(T3 / 2)
425 const link = (label?: string) => (
426 <Box flexDirection="column" paddingLeft={MID} width={FW}>
427 {Array.from({ length: LH }, (_, r) => { const [ch, c, dim] = dot(r)
428 return <Text><Text color={c} dimColor={dim} bold={!dim}>{ch}</Text>{r === 1 && label ? <Text dimColor> {label}</Text> : null}</Text> })}
429 </Box>)
430 // 分岔線:上面一條橫線(接住上一條線,不跑光點),下面三道跟其他線同一相位往下流到三個框的正中
431 const fork = (third: number) => {
432 const c0 = Math.floor(third / 2), c1 = MID, c2 = 2 * (third + 1) + Math.floor(third / 2)
433 const line = Array.from({ length: c2 + 1 }, (_, x) => x === c0 ? '┌' : x === c1 ? '┴' : x === c2 ? '┐' : x > c0 && x < c2 ? '─' : ' ').join('')
434 const pad = (n: number) => ' '.repeat(Math.max(0, n))
435 return (
436 <Box flexDirection="column" width={FW}>
437 <Text color="subtle">{line}</Text>
438 {Array.from({ length: LH - 1 }, (_, r) => { const [ch, c, dim] = dot(r + 1)
439 const g = <Text color={c} dimColor={dim} bold={!dim}>{ch}</Text>
440 return <Text>{pad(c0)}{g}{pad(c1 - c0 - 1)}{g}{pad(c2 - c1 - 1)}{g}</Text> })}
441 </Box>)
442 }
443 const kv = (k: string, v: unknown, c?: string) => (
444 <Box flexDirection="row"><Box width={LBL}><Text dimColor>{k}</Text></Box><Box flexGrow={1} flexShrink={1}><Text color={c} wrap="truncate-end">{v as never}</Text></Box></Box>)
445
446 // 上方狀態列
447 const top = (
448 <Box flexDirection="row" borderStyle={th.border} borderColor="subtle" paddingX={1} width={W} justifyContent="space-between">
449 <Text wrap="truncate-end"><Text bold>{d.persona?.emoji ?? '🖥'} {d.persona?.name ?? T('no_persona')}</Text>{Lg(` @${d.code ?? '?'} · ${d.role ?? '?'} · ${T('cabin', { value: (d.cabin ?? '?').split('-').pop() ?? '?' })}`, { dim: true })}</Text>
450 <Text>{cells.map((c, k) => <Text>{k ? ' ' : ''}<Text color={c.color} dimColor={pulse(c.color)}>■</Text><Text dimColor> {c.label} </Text><Text color={c.color}>{c.text}</Text></Text>)}</Text>
451 </Box>)
452
453 // 左欄:此刻(像參考圖的評審面板)
454 // 步數條(the maintainer 03:26「這個方格子是不是也沒有動態?」):一步一段,做完的段填滿,
455 // 光波同「我的包」—— 慢、從第一格掃到最後一格(做完的也掃亮)
456 const stepBar = (() => {
457 const n = Math.max(1, st ?? 1), tail = Math.max(8, sw(T('steps', { v1: sd ?? 0, v2: st ?? 0 }))), segW = Math.max(1, Math.floor((SW - 4 - tail - (n - 1)) / n)), done = Math.min(n, sd ?? 0) // 尾巴「 2/5 步」照語系量寬(日文「ステップ」較長)
458 const span = n * (segW + 1), wave = live ? Math.floor(frame / 2) % (span + 6) : -99
459 return <Text>{Array.from({ length: n }, (_, k) => <Text>{k ? ' ' : ''}{Array.from({ length: segW }, (_, x) => {
460 const dist = wave - (k * (segW + 1) + x), fill = k < done
461 return dist === 0 ? <Text color={th.accent} bold>█</Text> : dist === 1 || dist === 2 ? <Text color={th.accent} dimColor>{dist === 1 ? '▓' : '▒'}</Text> : <Text color={fill ? th.accent : 'subtle'} dimColor={!fill}>{fill ? '█' : '░'}</Text> })}</Text>)}<Text dimColor>{T('steps', { v1: sd ?? 0, v2: st ?? 0 })}</Text></Text>
462 })()
463 const side = (
464 <Box flexDirection="column" borderStyle={th.border} borderColor={th.accent} paddingX={1} width={SW}>
465 <Text><Text bold color="claude">{T('now_title')}</Text><Text dimColor>{T('now_sub')}</Text></Text>
466 <Text color={d.now?.state === 'decide' ? 'warning' : 'claude'}>{spin} 【{nowState(d.now?.state)}】{d.now?.at ? <Text dimColor> {ago(nowS - d.now.at)}</Text> : null}</Text>
467 <Box borderStyle="single" borderColor="subtle" paddingX={1}><Text wrap="wrap">{d.now?.text ?? T('now_none')}</Text></Box>
468 {st ? stepBar : <Text dimColor>{T('no_steps')}</Text>}
469 <Box marginTop={1}><Text dimColor>{T('checks')}</Text></Box>
470 {cells.map(c => (
471 <Box flexDirection="column">
472 <Text><Text color={c.color} dimColor={pulse(c.color)}>◆</Text> <Text>{c.label}</Text> <Text bold color={c.color}>{c.text}</Text></Text>
473 {c.sub ? <Box paddingLeft={2}><Text wrap="truncate-end">{Lg(c.sub, { dim: true })}</Text></Box> : null}
474 </Box>))}
475 <Box marginTop={1} flexDirection="column">
476 {kv(T('kv_todo'), T('todo_n', { n: d.todo.length }))}
477 {kv(T('grp_now'), cnt('now'), cnt('now') ? 'error' : undefined)}
478 {kv(T('kv_human'), cnt('human'), cnt('human') ? 'warning' : undefined)}
479 {kv(T('kv_know'), T('know_n', { n: d.know.length, v1: d.know.filter(k => k.level === 'warn').length, v2: d.know.filter(k => k.level === 'red').length }))}
480 {kv(T('kv_measured'), `${ago(nowS - d.measured_at)}${took(d.took_ms)}`)}
481 </Box>
482 </Box>)
483
484 // 中間流程:我 → 我的包 → 推包席 → 機器 / 主線 / 信箱 → 回到我
485 const shown = ex.pkgs ? pk.open : pk.open.slice(0, 4)
486 // 每條包一列(the maintainer 03:16「數值旁的格子太近?而且沒有變化?」):名字 | 三段格(交 / 合 / 主,走過的填滿,下一關有亮點往右流,還沒到的暗格)| 關卡字 | 顆數,欄間留 2 格
487 const SEG = Math.max(3, Math.min(5, Math.floor((FW - 4 - 24 - 2 - 2 - 12 - 2 - 8) / 3)))
488 const pkgRow = (t: Pkg) => {
489 const st0 = typeof t.stage === 'number' ? t.stage : -1, red = !!t.reds?.length
490 // 光波(the maintainer 03:29「太快了,改更優雅一點,要隨機感,光波可以一直推到最後」):一道光從第一格掃到最後一格(走過的段也被掃亮);
491 // 每列起點與速度由分支名決定(看起來隨機、每次一樣),2~3 格(0.4~0.6 秒)才走一格,掃完停一下再來
492 let hsh = 0; for (const ch of t.branch) hsh = (hsh * 31 + ch.charCodeAt(0)) >>> 0
493 const span = 3 * SEG + 2, slow = 2 + (hsh % 2), cyc = span + 6 + (hsh % 5)
494 const wave = live ? Math.floor((frame + (hsh % 97)) / slow) % cyc : -99
495 const cell = (k: number, x: number) => { // 第 k 段第 x 格
496 const g = k * (SEG + 1) + x, dist = wave - g, filled = st0 > k
497 const base = filled ? (red ? 'error' : 'success') : 'subtle'
498 if (dist === 0) return <Text color={red ? 'error' : filled ? 'success' : 'warning'} bold>█</Text>
499 if (dist === 1 || dist === 2) return <Text color={red ? 'error' : filled ? 'success' : 'warning'} dimColor>{dist === 1 ? '▓' : '▒'}</Text>
500 return <Text color={base} dimColor={!filled}>{filled ? '█' : '░'}</Text>
501 }
502 const segs = [0, 1, 2].map(k => <Text>{Array.from({ length: SEG }, (_, x) => cell(k, x))}</Text>)
503 return (
504 <Box flexDirection="column">
505 <Box flexDirection="row">
506 <Box width={24} marginRight={2}><Text wrap="truncate-middle" bold={red}>{shortBranch(t.branch)}</Text></Box>
507 <Text>{segs[0]}<Text> </Text>{segs[1]}<Text> </Text>{segs[2]}</Text>
508 <Box marginLeft={2}><Text>{pkgStages(t).map(([n, v], k) => { const cur = st0 === k
509 // 正在走的那關:光波推到最後一格才亮(the maintainer 03:3x「交● 等到光推到,再亮」),亮 4 拍後暗回去
510 const lit = cur && wave >= span && wave < span + 4
511 return <Text>{k ? ' ' : ''}<Text color={v === null ? 'inactive' : v ? 'success' : lit ? 'warning' : 'subtle'} bold={lit} dimColor={cur && !lit}>{n[0]}{v === null ? '?' : v ? '✓' : cur ? '●' : '·'}</Text></Text> })}</Text></Box>
512 <Box marginLeft={2}><Text dimColor>{T('big_commits', { n: t.commits ?? '?' })}{t.dirty ? ' ✎' : ''}</Text></Box>
513 </Box>
514 {red ? <Box paddingLeft={2}><Text color="error" wrap="truncate-end">{T('reds_line', { what: t.reds!.join(T('sep')) })}{t.refused ? T('refused', { what: t.refused }) : ''}</Text></Box> : null}
515 </Box>) }
516 const running = push.running?.[0]
517 const leaseUsed = lease.acquired_epoch && lease.expiry_epoch ? (nowS - lease.acquired_epoch) / Math.max(1, lease.expiry_epoch - lease.acquired_epoch) : 0
518 const [lf, le] = meter(leaseUsed, 10)
519 const third = Math.floor((FW - 2) / 3)
520 const unreadTodo = d.todo.filter(t => t.source === 'dm').sort((a, b) => (b.since ?? 0) - (a.since ?? 0))
521 const topTodo = groups[0]?.items[0]
522 // §22.5 第 1 步 依角色換流程:驗收席 待驗 → 驗過 → 退回;bot 席 來訊 → 回覆 → 結案(節點與資料由 S1 給,這裡只照著畫)
523 const rf = roleFlow(d.flow)
524 const flowRole = rf ? (
525 <Box flexDirection="column" width={FW}>
526 {rf.nodes.flatMap((n, k) => [link(k ? undefined : T('flow_from')),
527 frameBox(T(`flow_${n.key}`, { n: n.count }), flowColor(n.level), FW,
528 ...(n.items.length ? n.items.map(i => <Text wrap="truncate-end">{Lg(i.who.replace(/[((].*$/, ''), { color: 'claude' })}<Text> {i.text.replace(/\s+/g, ' ')}</Text></Text>)
529 : [<Text dimColor>{T('flow_empty')}</Text>]),
530 n.guess ? <Text dimColor wrap="truncate-end">{T('flow_guess')}</Text> : null)])}
531 </Box>) : null
532 const flowDev = (
533 <Box flexDirection="column" width={FW}>
534 {link(T('link_out'))}
535 {frameBox(T('pkgs_title', { what: pk.known ? `${T('pkgs_open', { n: pk.open.length })}${pk.landed.length ? T('pkgs_landed', { n: pk.landed.length }) : ''}` : T('na') }), pk.open.some(t => t.reds?.length) ? 'error' : 'success', FW,
536 ...(pk.known ? (shown.length ? shown.map(t => pkgRow(t)) : [<Text color="success">{T('all_landed')}</Text>]) : [<Text dimColor>{T('no_trees_role')}</Text>]),
537 pk.open.length > 4 ? <Box><Button key="ex-pkgs" variant="secondary" onPress={flip('pkgs')}>{ex.pkgs ? T('collapse') : T('more_pkgs', { n: pk.open.length - 4 })}</Button></Box> : null,
538 <Text dimColor wrap="truncate-end">{T('stage_legend')}</Text>)}
539 {link(T('link_batch'))}
540 {frameBox(T('ship_title', { what: running ? T('ship_running') : T('ship_idle') }), running ? 'warning' : 'subtle', FW,
541 running ? <Text wrap="truncate-end"><Text color="warning">{spin} </Text>{running.branch ?? '?'}<Text dimColor>{T('ship_ran', { n: Math.round(running.elapsed_s / 60), pid: running.pid })}</Text></Text> : <Text dimColor>{T('ship_nobody')}</Text>,
542 push.queued ? <Text dimColor>{T('machine_queue', { n: push.queued })}</Text> : null)}
543 {third >= 26 ? fork(third) : link(T('link_split'))}
544 {third >= 26 ? (
545 <Box flexDirection="row" gap={1} width={FW}>
546 {frameBox(T('lease_title'), lease.state === 'free' ? 'success' : 'warning', third,
547 <Text wrap="truncate-end">{lease.state === 'free' ? T('lease_free') : Lg(String(lease.owner ?? T('na')))}</Text>,
548 lease.state === 'held' ? <Text><Text color="warning">{lf}</Text><Text color="subtle">{le}</Text></Text> : <Text dimColor> </Text>,
549 <Text dimColor wrap="truncate-end">{lease.state === 'held' ? T('lease_span', { v1: hhmm(lease.acquired_epoch), v2: hhmm(lease.expiry_epoch) }) : lease.queue ? T('queue_n', { n: lease.queue }) : T('can_test')}</Text>)}
550 {frameBox(T('sensor_main_head'), 'success', third,
551 <Text wrap="truncate-end"><Text color="success">{main.sha ?? '?'}</Text><Text dimColor> {main.committed_at ? ago(nowS - main.committed_at) : ''}</Text></Text>,
552 <Text wrap="truncate-end" dimColor>{mainK?.line ?? T('na')}</Text>,
553 <Text dimColor> </Text>)}
554 {frameBox(T('sensor_inbox'), cells[2]!.color, third,
555 <Text wrap="truncate-end" color={cells[2]!.color}>{cells[2]!.text}</Text>,
556 <Text wrap="truncate-end">{unreadTodo[0] ? <Text>{Lg((unreadTodo[0].from ?? '').replace(/[((].*$/, ''), { color: 'claude' })} {unreadTodo[0].text}</Text> : <Text dimColor>{T('no_mail_todo')}</Text>}</Text>,
557 <Text dimColor>{unreadTodo.length ? T('mail_todo_n', { n: unreadTodo.length }) : ' '}</Text>)}
558 </Box>
559 ) : frameBox(T('trio_title'), lease.state === 'held' ? 'warning' : 'success', FW,
560 kv(T('sensor_machine_lease'), <Text>{lease.state === 'free' ? <Text color="success">{T('lease_free')}</Text> : <Text>{Lg(`${String(lease.owner ?? T('na'))} `, { color: 'warning' })}<Text color="warning">{lf}</Text><Text color="subtle">{le}</Text><Text dimColor>{T('latest', { when: hhmm(lease.expiry_epoch) })}</Text></Text>}</Text>),
561 kv(T('sensor_main_head'), <Text><Text color="success">{main.sha ?? '?'}</Text><Text dimColor> {main.committed_at ? ago(nowS - main.committed_at) : ''} {mainK?.line ?? ''}</Text></Text>),
562 kv(T('sensor_inbox'), <Text><Text color={cells[2]!.color}>{cells[2]!.text}</Text>{unreadTodo[0] ? Lg(` · ${(unreadTodo[0].from ?? '').replace(/[\uff08(].*$/, '')} ${unreadTodo[0].text}`, { dim: true }) : null}</Text>))}
563 {link(T('link_back'))}
564 {frameBox(T('next_title'), topTodo ? (groups[0]!.id === 'now' ? 'error' : 'claude') : 'subtle', FW,
565 topTodo ? <Text wrap="truncate-end"><Text>{groups[0]!.icon} </Text>{topTodo.from ? Lg(`${topTodo.from.replace(/[((].*$/, '')} `, { color: 'claude' }) : null}{topTodo.text.replace(/\s+/g, ' ')}</Text> : <Text dimColor>{T('no_todo')}</Text>)}
566 </Box>)
567 const flow = flowRole ?? flowDev
568
569 // 下方紀錄(像參考圖的 session log)
570 const rows = logRows(d.todo, d.know, d.events ?? [], ex.log ? 40 : 12, d.now?.at)
571 if (S.at !== logAt) { logAt = S.at; logStart = frame }
572 const TG = logTagW(), TW = Math.max(10, W - 4 - 6 - 16 - TG - 3 - 1)
573 const rv = live ? reveal(frame, logStart, rows.length) : { rows: rows.length, typing: -1, chars: Infinity }
574 const scan = live && rv.chars === Infinity && rows.length ? (frame - logStart) % rows.length : -1
575 const clock = new Date(), hms = `${String(clock.getHours()).padStart(2, '0')}:${String(clock.getMinutes()).padStart(2, '0')}:${String(clock.getSeconds()).padStart(2, '0')}`
576 const log = (
577 <Box flexDirection="column" borderStyle={th.border} borderColor={live ? th.accent : 'subtle'} paddingX={1} width={W}>
578 <Box flexDirection="row" justifyContent="space-between">
579 <Text><Text color={live && frame % 2 ? 'error' : 'inactive'}>●</Text><Text bold color={live ? 'error' : 'inactive'}> LIVE </Text><Text bold>{T('log_title')}</Text><Text dimColor>{T('log_sub', { value: `${rows.length}${d.events?.length ? `/${d.events.length}` : ''}` })}</Text><Text color="success">{hms}</Text></Text>
580 <Button key="ex-log" variant="secondary" onPress={flip('log')}>{ex.log ? T('less') : T('more')}</Button>
581 </Box>
582 {rows.length ? rows.slice(0, rv.rows).map((r, k) => {
583 const typing = k === rv.typing && rv.chars !== Infinity, hot = k === scan
584 const [done, noise] = typing ? typeIn(r.text, TW, rv.chars, frame * 7919 + k) : [fit(r.text, TW), '']
585 return (
586 <Text bold={hot}><Text color={hot ? 'success' : undefined} dimColor={!hot} inverse={hot}>{fit(hhmm(r.at), 6)}</Text><Text> </Text>{Lgw(`${fit(withLogo(r.who, d.logos), 16)} `, { color: 'claude' })}<Text color={r.color}>{fit(r.tag, TG)} </Text><Text color={typing ? 'success' : undefined}>{done}</Text>{noise ? <Text><Text color="success">█</Text><Text color="success" dimColor>{noise.slice(1)}</Text></Text> : null}</Text>)
587 }) : [<Text dimColor>{T('no_log')}</Text>]}
588 {live && rv.rows < rows.length ? <Text color="success" dimColor>{T('log_loading') + '.'.repeat(frame % 4)}</Text> : null}
589 </Box>)
590
591 // 收成一行的:人格 / 專案 / 應知
592 const fold = (k: string, title: string, oneLine: unknown, ...more: unknown[]) => (
593 <Box flexDirection="column" width={W}>
594 <Box flexDirection="row" gap={1}>
595 <Button key={`ex-${k}`} variant="secondary" onPress={flip(k)}>{ex[k] ? '▾' : '▸'} {title}</Button>
596 <Box flexGrow={1} flexShrink={1}><Text wrap="truncate-end">{oneLine as never}</Text></Box>
597 </Box>
598 {ex[k] ? <Box flexDirection="column" paddingLeft={4} marginBottom={1}>{more as never}</Box> : null}
599 </Box>)
600 const row = (dot: unknown, label: string, value: unknown, tail?: string) => (
601 <Box flexDirection="row">
602 <Box width={2}>{dot as never}</Box>
603 <Box width={RL}><Text dimColor wrap="truncate-end">{label}</Text></Box>
604 <Box flexGrow={1} flexShrink={1}><Text wrap="truncate-end">{value as never}</Text></Box>
605 {tail ? <Box marginLeft={1}><Text dimColor>{tail}</Text></Box> : null}
606 </Box>)
607 const kc = (lv: string) => d.know.filter(k => k.level === lv).length
608 const footer = (
609 <Text wrap="truncate-end"><Text dimColor>{T('ft_seat')} [</Text>{Lg(`@${d.code} · ${d.role}`, { color: 'claude', dim: true })}<Text dimColor>] {T('ft_state')} [<Text color="claude">{nowState(d.now?.state)}{st ? ` ${sd ?? 0}/${st}` : ''}</Text>] {T('ft_machine')} [<Text color={cells[1]!.color}>{cells[1]!.text}</Text>] {T('ft_main')} [<Text color="success">{main.sha ?? '?'}</Text>] {T('ft_data')} [{ago(nowS - d.measured_at)}]</Text></Text>)
610 return page(
611 <Box flexDirection="column" paddingX={2} marginTop={1} width={W + 4}>
612 {top}
613 <Box flexDirection="row" gap={2} marginTop={1}>{side}{flow}</Box>
614 <Box marginTop={1}>{log}</Box>
615 <Box flexDirection="column" marginTop={1}>
616 {/* 常用的往上(§22.5 第 3 步):照這一席點過的次數排 */}
617 {foldOrder(['persona', 'project', 'know'], use).map(k => (k === 'persona'
618 ? fold('persona', T('fold_persona'), <Text><Text bold>{d.persona?.emoji ?? ''} {d.persona?.name ?? T('no_persona')}</Text><Text dimColor>{T('persona_counts', { v1: (d.persona?.duties ?? []).length, v2: (d.persona?.red_lines ?? []).length })}{d.persona?.uid ? ` [[${d.persona.uid}]]` : ''}</Text></Text>,
619 ...(d.persona?.duties ?? []).map(x => row(<Text color="success">•</Text>, T('duty'), x)),
620 ...(d.persona?.red_lines ?? []).map(x => row(<Text color="error">•</Text>, T('red_line'), x)))
621 : k === 'project'
622 ? fold('project', T('fold_project'), d.project ? <Text><Text bold>{d.project.title ?? ''}</Text><Text dimColor> {d.project.uid ?? ''}</Text></Text> : <Text dimColor>{T('proj_unbound')}</Text>,
623 d.project?.work_state ? <Text wrap="wrap" dimColor>{d.project.work_state}</Text> : <Text dimColor>{T('no_progress')}</Text>)
624 : fold('know', T('fold_know'), <Text><Text dimColor>{T('know_count', { n: d.know.length })}</Text><Text color="error">● {kc('red')}</Text><Text> </Text><Text color="warning">● {kc('warn')}</Text><Text> </Text><Text color="success">● {kc('ok')}</Text><Text> </Text><Text color="inactive">○ {kc('unknown')}</Text></Text>,
625 ...d.know.map(k => { const [lb, val] = knowParts(k)
626 return row(<Text color={levelColor(k.level)}>{levelDot(k.level)}</Text>, lb, val, k.measured_at ? ago(nowS - k.measured_at) : '') }))))}
627 {Object.keys(use).length ? <Box><Button key="usage-reset" variant="secondary" onPress={resetLayout}>{T('usage_reset')}</Button><Text dimColor> {T('usage_hint')}</Text></Box> : null}
628 </Box>
629 {d.missing?.length ? <Text dimColor wrap="truncate-end">{T('missing', { what: d.missing.join(T('sep')) })}</Text> : null}
630 <Box marginTop={1}>{footer}</Box>
631 </Box>)
632 })
633}
634hooks/charts.ts 237 lines1// 原本從 tools/claude-mods/aib-seat/hooks/charts.ts(aib-seat 0.10.4)整份搬來;10-14 aib-seat 已拿掉(設計稿 §24),這裡是唯一一份。
2// 面板圖表(the maintainer 10-09「圓餅圖、曲線圖、直條圖、六角寶石圖」):終端機沒有圖片可用,改用點字點陣(每格 2×4 點)與方塊字。
3// 全部是純函式:輸入數值與 frame,輸出「每列一串 {文字, 顏色}」;register.tsx 只負責把它們包成 <Text>。
4
5export type Seg = { text: string; color?: string; dim?: boolean }
6export type Rows = Seg[][]
7
8export const PALETTE = ['claude', 'success', 'suggestion', 'warning', 'permission', 'error', 'autoAccept', 'planMode'] as const
9
10const DOT = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]] as const
11
12/** 點字畫布:w×h 格 = (2w)×(4h) 點。同一格最後畫上的顏色勝出。 */
13export class Canvas {
14 readonly bits: number[]
15 readonly colors: (string | undefined)[]
16 readonly w: number
17 readonly h: number
18 // 不用「參數屬性」寫法:aib boards 小窗格用 Node 直接跑這支,Node 只會去掉型別、不支援這種寫法(設計稿 §23.3)
19 constructor(w: number, h: number) {
20 this.w = w; this.h = h
21 this.bits = new Array(w * h).fill(0)
22 this.colors = new Array(w * h).fill(undefined)
23 }
24 set(x: number, y: number, color?: string): void {
25 const px = Math.round(x), py = Math.round(y)
26 if (px < 0 || py < 0 || px >= this.w * 2 || py >= this.h * 4) return
27 const i = Math.floor(py / 4) * this.w + Math.floor(px / 2)
28 this.bits[i] = (this.bits[i] ?? 0) | DOT[py % 4]![px % 2]!
29 if (color) this.colors[i] = color
30 }
31 line(x0: number, y0: number, x1: number, y1: number, color?: string, dash = 0): void {
32 const n = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0))))
33 for (let k = 0; k <= n; k++) {
34 if (dash && Math.floor(k / dash) % 2 === 1) continue
35 this.set(x0 + ((x1 - x0) * k) / n, y0 + ((y1 - y0) * k) / n, color)
36 }
37 }
38 rows(): Rows {
39 const out: Rows = []
40 for (let r = 0; r < this.h; r++) {
41 const segs: Seg[] = []
42 for (let c = 0; c < this.w; c++) {
43 const i = r * this.w + c
44 const b = this.bits[i] ?? 0
45 push(segs, b ? String.fromCharCode(0x2800 + b) : ' ', b ? this.colors[i] : undefined)
46 }
47 out.push(segs)
48 }
49 return out
50 }
51}
52
53function push(segs: Seg[], ch: string, color?: string, dim?: boolean): void {
54 const last = segs[segs.length - 1]
55 if (last && last.color === color && last.dim === dim) last.text += ch
56 else segs.push({ text: ch, color, dim })
57}
58
59export function rowsText(rows: Rows): string[] { return rows.map(r => r.map(s => s.text).join('')) }
60
61/** 甜甜圈圓餅:w 格寬、h 列高(格子約 1:2,w≈2h 才圓)。一道亮光(▓)隨 frame 繞圈;進場時扇形依 frame 逐步展開。 */
62export function donut(parts: { label: string; n: number }[], w: number, h: number, frame: number): Rows {
63 const total = parts.reduce((a, p) => a + p.n, 0)
64 const out: Rows = []
65 const sweep = Math.min(1, (frame + 1) / 10) * Math.PI * 2
66 const glint = ((frame * 0.35) % (Math.PI * 2))
67 for (let r = 0; r < h; r++) {
68 const segs: Seg[] = []
69 for (let c = 0; c < w; c++) {
70 const x = (c + 0.5 - w / 2) / (w / 2), y = (r + 0.5 - h / 2) / (h / 2)
71 const d = Math.sqrt(x * x + y * y)
72 if (total === 0 || d > 1 || d < 0.48) { push(segs, d <= 1 && d >= 0.48 ? '·' : ' ', 'subtle', true); continue }
73 let a = Math.atan2(x, -y); if (a < 0) a += Math.PI * 2 // 12 點鐘起、順時針
74 if (a > sweep) { push(segs, '·', 'subtle', true); continue }
75 let acc = 0, k = 0
76 for (; k < parts.length; k++) { acc += (parts[k]!.n / total) * Math.PI * 2; if (a <= acc) break }
77 const near = Math.abs(((a - glint + Math.PI * 3) % (Math.PI * 2)) - Math.PI) > Math.PI - 0.35
78 push(segs, near ? '▓' : '█', PALETTE[Math.min(k, parts.length - 1) % PALETTE.length])
79 }
80 out.push(segs)
81 }
82 return out
83}
84
85/** 曲線:values 0–100,最新在右;一條紅色虛線標 limit(85%)。 */
86export function curve(values: number[], w: number, h: number, limit = 85, color = 'claude'): Rows {
87 const cv = new Canvas(w, h)
88 const W = w * 2 - 1, H = h * 4 - 1
89 const yOf = (v: number) => H - (Math.max(0, Math.min(100, v)) / 100) * H
90 cv.line(0, yOf(limit), W, yOf(limit), 'error', 2)
91 const pts = values.slice(-(W + 1))
92 const x0 = W - (pts.length - 1)
93 for (let i = 1; i < pts.length; i++) cv.line(x0 + i - 1, yOf(pts[i - 1]!), x0 + i, yOf(pts[i]!), pts[i]! >= limit ? 'error' : color)
94 if (pts.length === 1) cv.set(W, yOf(pts[0]!), color)
95 return cv.rows()
96}
97
98const EIGHTHS = ' ▁▂▃▄▅▆▇█'
99/** 直條:每根 2 格寬、間隔 1 格;高 h 列用八分之一方塊收尾。最新一根依 frame 閃(亮/暗交替)。 */
100export function bars(values: number[], h: number, frame: number, color = 'success'): Rows {
101 const max = Math.max(1, ...values)
102 const out: Rows = []
103 for (let r = 0; r < h; r++) {
104 const segs: Seg[] = []
105 values.forEach((v, i) => {
106 const eighths = Math.round((v / max) * h * 8)
107 const fromBottom = h - 1 - r
108 const cell = Math.max(0, Math.min(8, eighths - fromBottom * 8))
109 const newest = i === values.length - 1
110 push(segs, EIGHTHS[cell]!.repeat(2), newest ? (frame % 2 ? 'warning' : color) : color, !newest && cell === 0)
111 if (i < values.length - 1) push(segs, ' ')
112 })
113 out.push(segs)
114 }
115 return out
116}
117
118/** 六角寶石(雷達):6 個 0–1 的值。三圈六角網格、六根輻條、值的多邊形、一根依 frame 旋轉的掃描線。 */
119export function hexRadar(values: number[], w: number, h: number, frame: number): Rows {
120 const cv = new Canvas(w, h)
121 const cx = w - 0.5, cy = h * 2 - 0.5
122 const R = Math.min(w - 1, h * 2 - 1)
123 const at = (k: number, r: number): [number, number] => {
124 const a = -Math.PI / 2 + (k * Math.PI) / 3
125 return [cx + Math.cos(a) * r, cy + Math.sin(a) * r]
126 }
127 for (const ring of [1 / 3, 2 / 3, 1]) {
128 for (let k = 0; k < 6; k++) {
129 const [x0, y0] = at(k, R * ring), [x1, y1] = at(k + 1, R * ring)
130 cv.line(x0, y0, x1, y1, 'subtle', ring < 1 ? 2 : 0)
131 }
132 }
133 for (let k = 0; k < 6; k++) { const [x, y] = at(k, R); cv.line(cx, cy, x, y, 'subtle', 2) }
134 const sa = -Math.PI / 2 + frame * 0.3
135 cv.line(cx, cy, cx + Math.cos(sa) * R, cy + Math.sin(sa) * R, 'warning')
136 const v = Array.from({ length: 6 }, (_, k) => Math.max(0.05, Math.min(1, values[k] ?? 0)))
137 for (let k = 0; k < 6; k++) {
138 const [x0, y0] = at(k, R * v[k]!), [x1, y1] = at((k + 1) % 6, R * v[(k + 1) % 6]!)
139 cv.line(x0, y0, x1, y1, 'claude')
140 cv.set(x0, y0, 'claude')
141 }
142 return cv.rows()
143}
144
145/** "HH:MM:SS" ⇒ 當天第幾秒;認不得回 null。 */
146export function secOfDay(t: string): number | null {
147 const m = /^(\d\d):(\d\d)(?::(\d\d))?$/.exec(t)
148 return m ? Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3] ?? 0) : null
149}
150
151/** 把時間點分桶:以最新一點為準,往回 n 桶、每桶 size 秒,回每桶的筆數(最舊在左)。 */
152export function bucket(times: number[], n: number, size: number): number[] {
153 const out = new Array(n).fill(0)
154 if (!times.length) return out
155 const end = Math.max(...times)
156 for (const t of times) {
157 const k = n - 1 - Math.floor((end - t) / size)
158 if (k >= 0 && k < n) out[k]++
159 }
160 return out
161}
162
163/** 動態圓餅:整圈依 frame 慢轉;第 focus 片往外推(其他片外徑 0.86)並用 ▓ 加亮;中心兩列寫那一片的名稱與百分比。 */
164export function spinPie(parts: { label: string; n: number }[], w: number, h: number, frame: number): Rows {
165 const total = parts.reduce((a, p) => a + p.n, 0)
166 const focus = parts.length ? Math.floor(frame / 8) % parts.length : -1
167 const rot = frame * 0.06
168 const out: Rows = []
169 const label = focus >= 0 ? [parts[focus]!.label.slice(0, 6), `${Math.round((parts[focus]!.n / total) * 100)}%`] : ['', '']
170 const mid = Math.floor(h / 2) - 1
171 for (let r = 0; r < h; r++) {
172 const segs: Seg[] = []
173 for (let c = 0; c < w; c++) {
174 const x = (c + 0.5 - w / 2) / (w / 2), y = (r + 0.5 - h / 2) / (h / 2)
175 const d = Math.sqrt(x * x + y * y)
176 if (d < 0.5) {
177 const txt = r === mid ? label[0]! : r === mid + 1 ? label[1]! : ''
178 const start = Math.floor((w - txt.length) / 2)
179 const ch = txt && c >= start && c < start + txt.length ? txt[c - start]! : ' '
180 push(segs, ch, focus >= 0 ? PALETTE[focus % PALETTE.length] : undefined)
181 continue
182 }
183 if (total === 0 || d > 1) { push(segs, ' '); continue }
184 let a = Math.atan2(x, -y) - rot; a = ((a % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)
185 let acc = 0, k = 0
186 for (; k < parts.length; k++) { acc += (parts[k]!.n / total) * Math.PI * 2; if (a <= acc) break }
187 k = Math.min(k, parts.length - 1)
188 if (k !== focus && d > 0.86) { push(segs, ' '); continue }
189 push(segs, k === focus ? '▓' : '█', PALETTE[k % PALETTE.length], k !== focus && focus >= 0)
190 }
191 out.push(segs)
192 }
193 return out
194}
195
196export type Blip = { angle: number; r: number; color: string }
197/** 雷達掃描:三圈距離環、十字線、依 frame 旋轉的掃描線 + 4 道漸暗餘光;光點在掃描線剛掃過(1.2 弧度內)時亮成一團,其餘只剩一點。
198 * r 0 = 中心(最近有動靜),1 = 外圈。 */
199export function scanRadar(blips: Blip[], w: number, h: number, frame: number): Rows {
200 const cv = new Canvas(w, h)
201 const cx = w - 0.5, cy = h * 2 - 0.5, R = Math.min(w - 1, h * 2 - 1)
202 for (const ring of [1 / 3, 2 / 3, 1]) {
203 const n = Math.ceil(R * ring * 6)
204 for (let i = 0; i < n; i += ring < 1 ? 3 : 1) {
205 const a = (i / n) * Math.PI * 2
206 cv.set(cx + Math.cos(a) * R * ring, cy + Math.sin(a) * R * ring, 'subtle')
207 }
208 }
209 cv.line(cx - R, cy, cx + R, cy, 'subtle', 2); cv.line(cx, cy - R, cx, cy + R, 'subtle', 2)
210 const sweep = (frame * 0.25) % (Math.PI * 2)
211 const trail = ['success', 'success', 'subtle', 'subtle'] as const
212 for (let k = trail.length; k >= 1; k--) {
213 const a = sweep - k * 0.11
214 cv.line(cx, cy, cx + Math.cos(a) * R, cy + Math.sin(a) * R, trail[k - 1], k > 2 ? 2 : 0)
215 }
216 cv.line(cx, cy, cx + Math.cos(sweep) * R, cy + Math.sin(sweep) * R, 'warning')
217 for (const b of blips) {
218 const x = cx + Math.cos(b.angle) * R * b.r, y = cy + Math.sin(b.angle) * R * b.r
219 const behind = ((sweep - b.angle) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2)
220 if (behind < 1.2) { for (const [dx, dy] of [[0, 0], [1, 0], [0, 1], [1, 1], [-1, 0], [0, -1]] as const) cv.set(x + dx, y + dy, b.color) }
221 else cv.set(x, y, 'subtle')
222 }
223 return cv.rows()
224}
225
226/** 席碼 ⇒ 固定角度(同一席每次都在同一方位)。 */
227export function angleOf(code: string): number {
228 let hsh = 0
229 for (const ch of code) hsh = (hsh * 31 + ch.charCodeAt(0)) >>> 0
230 return ((hsh % 360) / 360) * Math.PI * 2
231}
232
233/** 閒了幾秒 ⇒ 距中心 0–1(對數:剛動 ≈ 0.1,一小時以上 = 1)。 */
234export function idleToR(sec: number): number {
235 return Math.max(0.1, Math.min(1, Math.log10(1 + Math.max(0, sec)) / Math.log10(3600)))
236}
237hooks/lib.ts 297 lines1// 純計算(不碰 $):分頁、待辦分組、燈號顏色、時間。lib.test.ts 測這些。
2import type { Screen, Todo, Tab, Level, Know, Charts, Dm, Plan } from '../types'
3import { tr } from './i18n'
4
5// 顯示字一律走 tr()(mod.aib_boards.* 鍵,語系由 register.tsx / tui 換進來);label / title 寫成 getter,呼叫端照舊 x.label
6const tabOf = (id: Tab, hotkey: string, pkg: string) => ({ id, hotkey, pkg, get label() { return tr(`tab_${id}`) } })
7export const TABS: { id: Tab; label: string; hotkey: string; pkg: string }[] = [
8 tabOf('today', '1', 'M1'), tabOf('proj', '2', 'M3'), tabOf('dm', '3', 'M4'), tabOf('charts', '4', 'M2'), tabOf('custom', '5', 'M5'),
9]
10export const CACHE_S = 15 // 跟 seat_screen.py 自己的快取同秒數;打開才算,不輪詢(§21.2)
11
12// 只在用戶端打開(the maintainer 10-14):引擎蓋章的 origin.kind === 'composer' = 真人在終端機按 Enter
13export const isHumanTyped = (origin: { kind: string } | undefined) => origin?.kind === 'composer'
14
15// 待辦分組順序(設計稿 §4.2):🔴 現在要做 → ⏳ 在做 → 🙋 等 the maintainer → 👥 等別席 → 📝 自己記的
16const grpOf = (id: string, icon: string) => ({ id, icon, get title() { return tr(`grp_${id}`) } })
17export const GROUPS: { id: string; icon: string; title: string }[] = [grpOf('now', '🔴'), grpOf('doing', '⏳'), grpOf('human', '🙋'), grpOf('others', '👥'), grpOf('self', '📝')]
18export function groupTodos(todo: Todo[]): { id: string; icon: string; title: string; items: Todo[] }[] {
19 const known = new Set(GROUPS.map(g => g.id)), norm = (x: string) => known.has(x) ? x : 'self' // S1 組名 human
20 return GROUPS.map(g => ({ id: g.id, icon: g.icon, title: g.title, items: todo.filter(t => norm(t.group) === g.id) })).filter(g => g.items.length) // 展開 getter 只拷值,所以逐欄寫
21}
22export const levelColor = (l: Level): 'success' | 'warning' | 'error' | 'inactive' => l === 'ok' ? 'success' : l === 'warn' ? 'warning' : l === 'red' ? 'error' : 'inactive'
23export const levelDot = (l: Level) => l === 'unknown' ? '○' : '●'
24// §22.5 第 1 步:驗收席 / bot 席照 S1 給的節點畫流程;dev 家族(或舊版 S1 沒給)回 null ⇒ 畫原本的流程
25export const roleFlow = (f?: { family: string; nodes: { key: string }[] } | null) => (f && (f.family === 'qa' || f.family === 'bot') && f.nodes.length ? f : null)
26// §22.5 第 2 步 依人格換主題:人格卡設定區的 theme_*(S1 persona.theme);沒寫的那項用預設 —— 主色 claude、光點 ●、速度 normal、框線 round。
27// 速度:光點每格走 mul / div 步(slow = 半速、fast = 兩倍)
28export type Theme = { color?: string; particle?: string; speed?: string; border?: string }
29export const themeOf = (t?: Theme | null) => ({ accent: t?.color ?? 'claude', particle: t?.particle ?? '●',
30 mul: t?.speed === 'fast' ? 2 : 1, div: t?.speed === 'slow' ? 2 : 1, border: (t?.border ?? 'round') as 'round' | 'single' | 'double' | 'bold' })
31// §22.5 第 3 步 依使用習慣調整:記這一席今天頁每個區塊被點開 / 收起幾次(存 state/aib-boards/<seat_id>.json 的 usage,不外傳)。
32// 沒手動開關過的區塊:點過 ≥ USAGE_OPEN 次 ⇒ 預設展開;下方收合區塊照點過的次數排(多的在上,同分照原順序);一鍵回預設 = 清掉 usage 與開關。
33export const USAGE_OPEN = 3
34export const USAGE_KEYS = ['pkgs', 'log', 'persona', 'project', 'know'] as const
35export type Usage = Record<string, number>
36export const bumpUsage = (u: Usage | null | undefined, k: string): Usage => ({ ...(u ?? {}), [k]: (u?.[k] ?? 0) + 1 })
37export function effectiveEx<T extends Record<string, unknown>>(ex: T, u: Usage | null | undefined, keys: readonly string[] = USAGE_KEYS): T {
38 const o: Record<string, unknown> = { ...ex }
39 for (const k of keys) if (o[k] === undefined) o[k] = (u?.[k] ?? 0) >= USAGE_OPEN
40 return o as T
41}
42export const foldOrder = (keys: readonly string[], u: Usage | null | undefined): string[] =>
43 keys.map((k, i) => [k, i] as const).sort((a, b) => (u?.[b[0]] ?? 0) - (u?.[a[0]] ?? 0) || a[1] - b[1]).map(x => x[0])
44export const flowColor = (l: string): 'success' | 'warning' | 'error' => (l === 'error' ? 'error' : l === 'warning' ? 'warning' : 'success')
45export const ago = (s: number) => s < 60 ? tr('ago_s', { n: Math.max(0, Math.round(s)) }) : s < 3600 ? tr('ago_m', { n: Math.round(s / 60) }) : s < 172800 ? tr('ago_h', { n: Math.round(s / 3600) }) : tr('ago_d', { n: Math.round(s / 86400) })
46// 資料舊要看得出來(§21 第 3 條):超過 10 分鐘標「資料過期」
47export const isStale = (measuredAt: number, now: number) => now - measuredAt > 600
48export const nowState = (s: string | undefined) => (s === 'busy' || s === 'reply' || s === 'decide' ? tr(`state_${s}`) : (s || '—'))
49export function parseScreen(text: string): Screen {
50 const d = JSON.parse(text) as Screen
51 if (d.schema !== 'aib-seat-screen/v1') throw new Error(tr('err_schema', { what: String(d.schema) }))
52 return { ...d, todo: d.todo ?? [], know: d.know ?? [], missing: d.missing ?? [] }
53}
54
55// 卡片沒寫 line(S1 v1 的 inbox / my_trees 只給 data)⇒ 用 data 組一行;都沒有就只寫感測器名
56const SENSORS = new Set(['machine_lease', 'push_chain', 'main_head', 'inbox', 'my_trees', 'red_lines', 'project_state', 'my_tests'])
57const sensorName = (s: string) => (s === 'ctx' ? 'CTX' : SENSORS.has(s) ? tr(`sensor_${s}`) : s)
58// [項目名, 內容];沒內容 = null(只寫項目名)。knowLine 與 knowParts 共用,不再拿全形冒號切(英文是半形 + 空白)
59function knowPieces(k: { sensor: string; line?: string | null; data?: Record<string, unknown> }): [string, string | null] {
60 const name = sensorName(k.sensor), d = k.data ?? {}
61 if (k.sensor === 'inbox' && (d.unread !== undefined || d.open !== undefined)) return [name, tr('know_inbox', { v1: String(d.unread ?? 0), v2: String(d.open ?? 0) })]
62 if (k.sensor === 'my_trees' && d.ahead !== undefined) return [name, tr('know_trees', { v1: String(Array.isArray(d.trees) ? d.trees.length : (d.trees ?? '?')), v2: String(d.ahead), v3: String(d.dirty ?? 0) }) + (k.line ? tr('know_trees_last', { text: k.line }) : '')]
63 return [name, k.line ? k.line : null]
64}
65export function knowLine(k: { sensor: string; line?: string | null; data?: Record<string, unknown> }): string {
66 const [name, body] = knowPieces(k)
67 return body === null ? name : tr('know_line', { name, text: body })
68}
69
70export const SPIN = ['◐', '◓', '◑', '◒']
71// 應知卡 → 雷達光點:每張卡一個角度;紅最近中心、黃中間、綠外圈、量不到最外圈灰
72export function knowBlips(know: Know[]): { angle: number; r: number; color: string }[] {
73 const R: Record<string, number> = { red: 0.2, warn: 0.5, ok: 0.85, unknown: 1 }
74 const C: Record<string, string> = { red: 'error', warn: 'warning', ok: 'success', unknown: 'inactive' }
75 return know.map((k, i) => ({ angle: (i / Math.max(1, know.length)) * Math.PI * 2, r: R[k.level] ?? 1, color: C[k.level] ?? 'inactive' }))
76}
77// 待辦 → 圓餅:每組一片(照 GROUPS 順序)
78export function todoParts(todo: Todo[]): { label: string; n: number }[] {
79 return groupTodos(todo).map(g => ({ label: g.title, n: g.items.length }))
80}
81
82// 畫面 1:4(the maintainer 10-14 00:3x 先說 1:4、看過後改 15:35;02:5x 看過流程儀表板又改回「1:4」):對話 1 份、面板 4 份 = 面板拿 80%。
83// 放在輸入框上方時看 rows(格高 70%),停靠在對話旁時看 columns(終端寬 70%);都是請求,人手拖過的大小優先。
84export const PANE_SHARE = 4 / 5
85export function split41(termCols: number, paneRows: number): { columns: number; rows: number } {
86 return { columns: Math.max(40, Math.floor(termCols * PANE_SHARE)), rows: Math.max(12, Math.floor(paneRows * PANE_SHARE)) }
87}
88
89// 六角寶石六個角(跟示範頁 extract4 同一算法):成功 = 綠率;速度 = 1 − 讀信中位分 / 60;餘量 = 1 − CTX;收信 = 1/(1+未讀);交接 = 收據 / 輪;協作 = 7 天寄給幾席 / 4
90export function hexValues(h: Charts['hex'], ctxPercent: number | null): { v: (number | null)[]; raw: string[] } {
91 const r2 = (x: number) => Math.round(x * 100) / 100
92 const g = h.success?.green ?? 0, rd = h.success?.red ?? 0
93 const v = [g + rd ? r2(g / (g + rd)) : null, h.speed ? r2(Math.max(0, 1 - Math.min(1, h.speed.median_min / 60))) : null,
94 ctxPercent !== null ? r2(1 - ctxPercent / 100) : null, h.inbox ? r2(1 / (1 + h.inbox.unread)) : null,
95 h.handoff && h.handoff.rounds ? r2(Math.min(1, h.handoff.receipts / h.handoff.rounds)) : null, h.collab ? r2(Math.min(1, h.collab.seats / 4)) : null]
96 const raw = [tr('hex_green_red', { v1: g, v2: rd }), h.speed ? tr('hex_speed', { n: Math.round(h.speed.median_min) }) : tr('hex_no_sample'), ctxPercent !== null ? `CTX ${Math.round(ctxPercent)}%` : tr('na'),
97 h.inbox ? tr('hex_unread', { n: h.inbox.unread }) : tr('na'), h.handoff ? tr('hex_handoff', { v1: h.handoff.rounds, v2: h.handoff.receipts }) : tr('hex_no_handoff'), h.collab ? tr('hex_collab', { n: h.collab.seats }) : tr('na')]
98 return { v, raw }
99}
100export function parseCharts(text: string): Charts {
101 const d = JSON.parse(text) as { charts?: Charts; measured_at?: number; took_ms?: number }
102 if (!d.charts) throw new Error(tr('err_no_charts'))
103 const c = d.charts
104 // 量的時間 / 花多久在 S1 外層,不在 charts 裡;不帶過來 ⇒ 圖表頁寫「量於 NaN 天前」
105 return { ...c, measured_at: c.measured_at ?? d.measured_at ?? NaN, took_ms: c.took_ms ?? d.took_ms, trees: c.trees ?? [], trend: c.trend ?? [], slowest: c.slowest ?? [], commits: c.commits ?? [], files: c.files ?? [], hex: c.hex ?? {} }
106}
107
108export function parseDm(text: string): Dm {
109 const d = (JSON.parse(text) as { dm?: Dm }).dm
110 if (!d) throw new Error(tr('err_no_dm'))
111 return { items: d.items ?? [], counts: d.counts ?? { unread: 0, open: 0, done_7d: 0 }, chat: d.chat ?? [], seats: d.seats ?? {} }
112}
113export function parsePlan(text: string): Plan {
114 const d = (JSON.parse(text) as { plan?: Plan | null }).plan
115 if (!d) throw new Error(tr('err_no_plan'))
116 return { ...d, nodes: d.nodes ?? [] }
117}
118
119// 「新信」= 還沒讀、沒結案、2 分鐘內寄到、這次沒跳過的。標讀 / 結案時改檔(暫存 + 改名)也可能報 add,靠這幾條擋住誤跳
120export function isFreshMail(m: { id?: string; read_at?: unknown; done_at?: unknown; ts?: number; sent_at?: number }, nowS: number, seen: Set<string>): boolean {
121 const t = Number(m.sent_at ?? m.ts ?? 0)
122 return !!m.id && !m.read_at && !m.done_at && t > 0 && nowS - t < 120 && !seen.has(m.id)
123}
124
125// 新信要不要跳、跳什麼:真的新信才跳,同一封只跳一次(之後改檔不重跳);seen 只留最近 200 筆
126export function mailToast(m: { id?: string; sender?: string; sender_seat?: string; seat?: string; msg?: string; read_at?: unknown; done_at?: unknown; ts?: number; sent_at?: number }, nowS: number, seen: Set<string>, scr?: { code?: string | null; logos?: Record<string, string> } | null): string | null {
127 if (!isFreshMail(m, nowS, seen)) return null
128 seen.add(m.id!)
129 if (seen.size > 200) seen.delete(seen.values().next().value as string)
130 // 誰 DM 誰、時間、內容,兩邊帶引擎標誌(the maintainer 10-14 03:1x)
131 const lg = (c?: string | null) => (c && scr?.logos?.[c] ? `${scr.logos[c]} ` : '')
132 const from = m.sender_seat ? `${lg(m.sender_seat)}@${m.sender_seat}` : (m.sender ?? tr('mail_new')), to = m.seat ?? scr?.code
133 const d = new Date(Number(m.sent_at ?? m.ts ?? nowS) * 1000), hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
134 return `📨 ${from}${to ? ` → ${lg(to)}@${to}` : ''} · ${hm}\n${String(m.msg ?? '').replace(/\s+/g, ' ').slice(0, 120)}`
135}
136
137// 應知一列拆成「項目名 / 內容」兩欄(排版對齊用;the maintainer 10-14 02:2x「字太擁擠,要優雅有間隔並對齊」)
138export function knowParts(k: { sensor: string; line?: string | null; data?: Record<string, unknown> }): [string, string] {
139 const [name, body] = knowPieces(k)
140 return [name, body ?? '']
141}
142
143// ── §22.4 今天頁重排(the maintainer 10-14 02:2x「雷達與分布是必要的嗎?…照這樣做,雷達搬到自訂」)──
144// 最上一條「此刻」四小格:CTX 離 85% 多遠 / 機器誰在用、幾點放 / 信箱未讀與最新寄件人 / 我的包有沒有紅燈
145export type Cell = { label: string; text: string; sub: string; color: 'success' | 'warning' | 'error' | 'inactive' }
146const hm = (s: number) => { const d = new Date(s * 1000); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` }
147export function ctxCell(p: number | null): Cell {
148 if (p === null) return { label: 'CTX', text: tr('na'), sub: '', color: 'inactive' }
149 const left = Math.round(85 - p)
150 return { label: 'CTX', text: `${Math.round(p)}%`, sub: left > 0 ? tr('ctx_left', { n: left }) : tr('ctx_hit'), color: p >= 85 ? 'error' : p >= 70 ? 'warning' : 'success' }
151}
152export function leaseCell(know: Know[], code: string | null, nowS: number): Cell {
153 const k = know.find(x => x.sensor === 'machine_lease'), d = (k?.data ?? {}) as { state?: string; owner?: string; acquired_epoch?: number; expiry_epoch?: number; queue?: number }
154 const label = tr('sensor_machine_lease')
155 if (!k || k.level === 'unknown') return { label, text: tr('na'), sub: '', color: 'inactive' }
156 if (d.state === 'free') return { label, text: tr('lease_free'), sub: d.queue ? tr('queue_n', { n: d.queue }) : '', color: 'success' }
157 const who = String(d.owner ?? '?'), mine = !!code && who.includes(code)
158 const used = d.acquired_epoch ? tr('lease_used', { n: Math.max(0, Math.round((nowS - d.acquired_epoch) / 60)) }) : ''
159 const until = d.expiry_epoch ? tr('lease_until', { when: hm(d.expiry_epoch) }) : ''
160 return { label, text: mine ? tr('lease_mine') : who, sub: [used, until].filter(Boolean).join(' · '), color: mine ? 'success' : 'warning' }
161}
162export function mailCell(know: Know[], todo: Todo[]): Cell {
163 const k = know.find(x => x.sensor === 'inbox'), d = (k?.data ?? {}) as { unread?: number; open?: number }
164 const label = tr('sensor_inbox')
165 if (!k || k.level === 'unknown') return { label, text: tr('na'), sub: '', color: 'inactive' }
166 const last = todo.filter(t => t.source === 'dm').sort((a, b) => (b.since ?? 0) - (a.since ?? 0))[0]
167 const unread = d.unread ?? 0
168 return { label, text: unread ? tr('hex_unread', { n: unread }) : tr('no_unread'), sub: last?.from ? tr('latest_from', { who: last.from.replace(/[\uff08(].*$/, '') }) : (d.open ? tr('open_n', { n: d.open }) : ''), color: unread ? 'warning' : 'success' }
169}
170// 我的包:S1 my_trees 每棵樹帶 stage(0 都沒收 / 1 部分 / 2 都在合包 / 3 都在主線)與 reds(推包被擋的新紅裡、這棵樹動過的檔)
171export type Pkg = { branch: string; head?: string; stage?: number; commits?: number; reds?: string[]; refused?: string | null; dirty?: boolean | null }
172export function myPkgs(know: Know[]): { open: Pkg[]; landed: Pkg[]; known: boolean } {
173 const k = know.find(x => x.sensor === 'my_trees')
174 const trees = (((k?.data ?? {}) as { trees?: Pkg[] }).trees ?? []).filter(t => t.branch)
175 const landed = trees.filter(t => t.stage === 3)
176 const open = trees.filter(t => t.stage !== 3).sort((a, b) => (b.reds?.length ?? 0) - (a.reds?.length ?? 0) || (a.stage ?? -1) - (b.stage ?? -1))
177 return { open, landed, known: !!k && k.level !== 'unknown' }
178}
179export function pkgCell(p: { open: Pkg[]; known: boolean }): Cell {
180 const label = tr('my_pkgs')
181 if (!p.known) return { label, text: tr('na'), sub: '', color: 'inactive' }
182 const reds = p.open.reduce((a, t) => a + (t.reds?.length ?? 0), 0)
183 if (reds) return { label, text: tr('reds_n', { n: reds }), sub: tr('pkgs_blocked', { n: p.open.filter(t => t.reds?.length).length }), color: 'error' }
184 return { label, text: tr('no_reds'), sub: tr('pkgs_open', { n: p.open.length }), color: 'success' }
185}
186// 三關:交出(至少一顆已被收進合包或主線)→ 合包(每一顆都在合包或主線)→ 主線(每一顆都在主線);量不到 = null
187export function pkgStages(t: Pkg): [string, boolean | null][] {
188 const s = typeof t.stage === 'number' ? t.stage : null
189 return [[tr('stage_1'), s === null ? null : s >= 1], [tr('stage_2'), s === null ? null : s >= 2], [tr('stage_3'), s === null ? null : s >= 3]]
190}
191
192// ── 今天頁流程儀表板(the maintainer 10-14 02:5x 給參考圖:上方狀態列、左欄此刻、中間流程框 + 條、下方紀錄、底部一行)──
193export const bar = (v: number, w: number) => { const f = Math.max(0, Math.min(w, Math.round(v * w))); return ['█'.repeat(f), '░'.repeat(w - f)] as const }
194export const hhmm = (s?: number | null) => { if (!s) return '--:--'; const d = new Date(s * 1000); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` }
195export const shortBranch = (b: string) => b.replace(/^(feat|fix|docs|chore|ci|int)\//, '')
196export type LogRow = { at: number; who: string; tag: string; text: string; color: string }
197export type Ev = { at: number; kind: string; who: string; text: string; status?: string | null }
198const EV: Record<string, string> = { dm_in: 'claude', dm_out: 'suggestion', commit: 'permission', ship_push: 'success', ship_refuse: 'error' } // 事件種類 → 顏色;標籤字走 ev_<種類>
199// 欄寬照語系量(審查 10-14:英文 / 日文 / 韓文標籤比中文長,固定欄寬會截字或黏到下一欄):最長標籤 + 1 格間隔,不小於原本的寬度(中文寬度不變)
200const widest = (keys: string[], min: number) => Math.max(min, ...keys.map(k => sw(tr(k)) + 1))
201/** 紀錄區「種類」欄寬(原本 9) */
202export const logTagW = () => widest(['ev_dm_in', 'ev_dm_out', 'ev_commit', 'ev_ship_push', 'ev_ship_refuse', 'log_now', 'log_self', 'log_lease', 'log_main'], 9)
203/** 今天頁左欄(此刻)與「機器 · 主線 · 信箱」框的標籤欄寬(原本 10;左欄只有 30 格,不跟下面的摺疊列一起算,免得中文也被擠) */
204export const labelW = () => widest(['kv_todo', 'grp_now', 'kv_human', 'kv_know', 'kv_measured', 'sensor_machine_lease', 'sensor_main_head', 'sensor_inbox'], 10)
205/** 摺疊列(人格職責 / 紅線、應知各項)的標籤欄寬(原本 10;中文「我的工作樹」剛好 10 格、原本就黏著內容 ⇒ 變 11) */
206export const rowLabelW = () => widest(['duty', 'red_line', 'sensor_machine_lease', 'sensor_push_chain', 'sensor_main_head', 'sensor_inbox', 'sensor_my_trees', 'sensor_red_lines',
207 'sensor_project_state', 'sensor_my_tests'], 10)
208// 紀錄(the maintainer 02:59「這地方不夠」):S1 events(近 24 小時收信 / 寄信 / 提交 / 推包)+ 狀態列 + 自己記的 + 拿機器 + 主線最新一顆;新的在上
209export function logRows(todo: Todo[], know: Know[], events: Ev[], n = 12, nowAt?: number | null): LogRow[] {
210 const rows: LogRow[] = events.map(e => { const [tag, color] = EV[e.kind] ? [tr(`ev_${e.kind}`), EV[e.kind]!] : [e.kind, 'inactive']; return { at: e.at, who: e.who.replace(/[\uff08(].*$/, ''), tag, text: e.text.replace(/\s+/g, ' '), color } })
211 for (const t of todo) {
212 if (t.source === 'dm' || !t.since) continue // 信件已在 events 裡
213 const at = t.source === 'now' ? (nowAt ?? t.since) : t.since // 狀態列用最近一次更新的時間,不是開始做的時間
214 rows.push({ at, who: (t.from ?? '').replace(/[\uff08(].*$/, '') || tr('me'), tag: t.source === 'now' ? tr('log_now') : tr('log_self'), text: t.text.replace(/\s+/g, ' '), color: t.source === 'now' ? 'claude' : 'inactive' })
215 }
216 const lease = know.find(k => k.sensor === 'machine_lease')?.data as { state?: string; owner?: string; acquired_epoch?: number } | undefined
217 if (lease?.state === 'held' && lease.acquired_epoch) rows.push({ at: lease.acquired_epoch, who: String(lease.owner ?? '?'), tag: tr('log_lease'), text: tr('log_lease_text'), color: 'warning' })
218 const main = know.find(k => k.sensor === 'main_head')
219 const mc = (main?.data as { committed_at?: number; sha?: string } | undefined)
220 if (mc?.committed_at) rows.push({ at: mc.committed_at, who: tr('log_main_who'), tag: tr('log_main'), text: `${mc.sha ?? ''} ${main?.line ?? ''}`.trim(), color: 'success' })
221 return rows.sort((x, y) => y.at - x.at).slice(0, n)
222}
223
224// ── 對齊(the maintainer 03:02「沒有對齊」):→ … — · 這類「寬度不定」的字,Ink 算 1 格、終端機(東亞寬度)畫 2 格 ⇒ 那一列被推歪。
225// 紀錄區改成自己算寬度(中日韓 / 全形 / emoji 算 2)、自己補空白與截斷,寬度不定的字先換成半形。
226const AMBIG: Record<string, string> = { '→': '->', '←': '<-', '…': '...', '—': '-', '–': '-', '·': '.', '•': '*', '“': '"', '”': '"', '‘': "'", '’': "'", '×': 'x', '○': 'o', '●': '*', '◆': '*', '■': '#', '▶': '>', '✓': 'v', '✗': 'x' }
227export const plain = (s: string) => Array.from(s.replace(/\s+/g, ' ')).map(c => AMBIG[c] ?? c).join('')
228export function cw(c: string): number {
229 const p = c.codePointAt(0) ?? 0
230 if (p < 0x1100) return 1
231 if ((p <= 0x115f) || (p >= 0x2e80 && p <= 0xa4cf) || (p >= 0xac00 && p <= 0xd7a3) || (p >= 0xf900 && p <= 0xfaff) || (p >= 0xfe30 && p <= 0xfe4f)
232 || (p >= 0xff00 && p <= 0xff60) || (p >= 0xffe0 && p <= 0xffe6) || (p >= 0x1f300 && p <= 0x1faff) || (p >= 0x20000 && p <= 0x3fffd)) return 2
233 return 1
234}
235export const sw = (s: string) => Array.from(s).reduce((a, c) => a + cw(c), 0)
236// 剛好 w 格:太長就截掉補 ...,太短補空白(寬字不會被切一半)
237export function fit(s: string, w: number): string {
238 const t = plain(s)
239 if (sw(t) <= w) return t + ' '.repeat(w - sw(t))
240 let out = '', used = 0
241 for (const c of Array.from(t)) { if (used + cw(c) > w - 3) break; out += c; used += cw(c) }
242 return out + '...' + ' '.repeat(Math.max(0, w - 3 - used))
243}
244
245// ── 紀錄動態(the maintainer 03:0x「要像影片一樣一行一行有動態、數碼感」):逐行吐出、正在吐的那行打字 + 後段亂碼、掃描光棒 ──
246const NOISE = '0123456789ABCDEF#$%&*+=<>/'
247// 一行打到 shown 格:前段是真字,後段用亂碼補到同寬(寬字補兩個半形,寬度不變);seed 換了亂碼就跳
248export function typeIn(s: string, w: number, shown: number, seed: number): [string, string] {
249 const t = fit(s, w)
250 let done = '', used = 0, rest = ''
251 const cs = Array.from(t)
252 let i = 0
253 for (; i < cs.length; i++) { const c = cs[i]!; if (used + cw(c) > shown) break; done += c; used += cw(c) }
254 let k = seed
255 for (; i < cs.length; i++) { const c = cs[i]!; const n = cw(c); for (let j = 0; j < n; j++) { k = (k * 1103515245 + 12345) & 0x7fffffff; rest += c === ' ' ? ' ' : NOISE[k % NOISE.length] } }
256 return [done, rest]
257}
258// 第幾格開始吐、現在到哪:每格吐 1 行;正在吐的那行每格打 step 格寬
259export function reveal(frame: number, start: number, n: number, step = 24): { rows: number; typing: number; chars: number } {
260 const f = Math.max(0, frame - start)
261 const rows = Math.min(n, Math.floor(f / 2) + 1)
262 return { rows, typing: rows - 1, chars: rows >= n && f >= n * 2 ? Infinity : (f % 2 + 1) * step }
263}
264
265// 席碼前面加引擎標誌(the maintainer 10-14 03:1x「所有的 mods 有帶到 aib 席位的,都要帶上他的 logo」;標誌由 S1 logos 給)
266// 「pm@86ff0」這種黏著寫的(租約持有者)標誌前補一格,不黏成「pm @86ff0」
267export const withLogo = (s: string, logos?: Record<string, string> | null) => !logos ? s : s.replace(/@([0-9a-f]{5})\b/g, (m, c: string, at: number) => (logos[c] ? `${at > 0 && /\S/.test(s[at - 1]!) ? ' ' : ''}${logos[c]} ${m}` : m))
268// 標誌補原廠色(the maintainer 10-14「仔細把 aib-boards 的所有 logo 補回原廠色」):把已帶標誌的字串切段,
269// 「標誌 @席碼」的標誌那一格給 colors[席碼];其他字不給色(沿用外層)。先 withLogo / fit 再切,截斷過的也認得出來
270export function logoSegs(s: string, logos?: Record<string, string> | null, colors?: Record<string, string> | null): { text: string; color?: string }[] {
271 if (!logos || !colors) return [{ text: s }]
272 const out: { text: string; color?: string }[] = []
273 let last = 0
274 for (const m of s.matchAll(/(\S+) @([0-9a-f]{5})\b/gu)) {
275 const c = m[2]!, g = logos[c], col = colors[c]
276 if (!g || !col || !m[1]!.endsWith(g)) continue
277 const at = m.index! + m[1]!.length - g.length
278 if (at > last) out.push({ text: s.slice(last, at) })
279 out.push({ text: g, color: col })
280 last = at + g.length
281 }
282 if (last < s.length) out.push({ text: s.slice(last) })
283 return out
284}
285// 圖表格子裡的 @席碼也補標誌 + 原廠色(the maintainer 10-14「繼續做標誌原廠色」):每段先 withLogo 再切;
286// 加標誌會多出「標誌 + 空白」的寬,段尾的補白吃掉同樣寬(至少留一格),欄位不歪(時間軸 / 熱力格 / 租約泳道的左欄都是補白過的)
287export function logoRows(rows: { text: string; color?: string; dim?: boolean }[][], logos?: Record<string, string> | null, colors?: Record<string, string> | null) {
288 if (!logos) return rows
289 return rows.map(r => r.flatMap(sg => {
290 let t = withLogo(sg.text, logos)
291 if (t === sg.text) return [sg]
292 const grow = [...t].length - [...sg.text].length, tail = /( +)$/.exec(t)?.[1]?.length ?? 0
293 t = t.slice(0, t.length - Math.min(grow, Math.max(0, tail - 1))) // 至少留一格空白,席碼不黏到後面的圖
294 return logoSegs(t, logos, colors).map(x => ({ ...sg, text: x.text, ...(x.color ? { color: x.color, dim: false } : {}) }))
295 }))
296}
297hooks/chartset.ts 84 lines1// 每一級有哪些圖、預設放哪幾張(設計稿 §18 5:4:3 + §18.2 艦長放回 3 張):艦長 14、主管 9、監看 7、工程 6(影子續跑 03:1x 起各級都放第一張);其餘收進「自訂」可勾回。
2import type { Rows } from './charts'
3import type { Charts, Screen } from '../types'
4import { scanRadar, spinPie } from './charts'
5import { hexGem, hexKeys, treeRows, shadowRows, stackCols, commitCurve, hbarRows } from './engcharts'
6import { seatRadar, seatTable, funnelRows, waitHist, ganttRows, listBars, flakyRows, eventTimeline, shadowCumulative, riverRows, leaseLanes, heatGrid, waterfallRows, chordMatrix, decideCards, beatRows, sankeyRows } from './tiercharts'
7import { hexValues, knowBlips, todoParts, logoRows } from './lib'
8import { tr } from './i18n'
9
10export type Card = { key: string; title: string; big?: string; how: string; body: Rows; src: string; wide?: boolean }
11type Ctx = { cd: Charts; ctx: number | null; frame: number; half: number; full: number; now: number; scr?: Screen | null }
12const none = (t: string): Rows => [[{ text: t, color: 'inactive' }]]
13
14export const TIER_DEFAULT: Record<string, string[]> = {
15 captain: ['SH', 'SC', 'FR', 'H', 'RV', 'LS', 'HT', 'WF', 'CH', 'FU', 'SK', 'CT', 'BT', 'DC'],
16 lead: ['SH', 'SR', 'H', 'PK', 'ST', 'LF', 'WT', 'GT', 'HOT'],
17 qa: ['SH', 'H', 'QT', 'FL', 'PA', 'GB', 'EV'],
18 eng: ['SH', 'H', 'TR', 'RG', 'CM', 'FI'],
19 design: ['SH', 'H', 'TR', 'RG', 'CM', 'FI'],
20}
21// 自訂頁可勾的全部(預設 + 收起的)
22export const TIER_ALL: Record<string, string[]> = {
23 captain: [...TIER_DEFAULT.captain!, 'KR', 'TP', 'TR', 'RG', 'CM', 'FI', 'SL'],
24 lead: [...TIER_DEFAULT.lead!, 'KR', 'TP', 'TR', 'RG', 'CM', 'FI', 'SL', 'MY'],
25 qa: [...TIER_DEFAULT.qa!, 'KR', 'TP', 'TR', 'RG', 'CM', 'FI', 'SL', 'MY'],
26 eng: [...TIER_DEFAULT.eng!, 'KR', 'TP', 'SL', 'MY'],
27 design: [...TIER_DEFAULT.design!, 'KR', 'TP', 'SL', 'MY'],
28}
29// 圖表名走 ct_<代碼小寫>;寫成 getter,呼叫端照舊 CHART_TITLE[k]
30export const CHART_TITLE: Record<string, string> = Object.defineProperties({} as Record<string, string>,
31 Object.fromEntries(['H', 'TR', 'SH', 'RG', 'CM', 'FI', 'SL', 'MY', 'KR', 'TP', 'SR', 'PK', 'ST', 'LF', 'WT', 'GT', 'HOT', 'QT', 'FL', 'PA', 'GB', 'EV', 'SC', 'FR', 'RV', 'LS', 'HT', 'WF', 'CH', 'FU', 'SK', 'CT', 'BT', 'DC'].map(k => [k, { enumerable: true, get: () => tr(`ct_${k.toLowerCase()}`) }])))
32// 影子續跑最重要(the maintainer 10-14 03:1x「一定要放最上面」):不管自訂怎麼勾、勾的順序,影子續跑(與艦長的累積圖)一律排最前
33export const PINNED = ['SH', 'SC']
34export const pinFirst = (list: string[]) => [...PINNED.filter(k => list.includes(k)), ...list.filter(k => !PINNED.includes(k))]
35export const tierOf = (t?: string | null) => (t && TIER_DEFAULT[t] ? t : 'eng')
36
37export function buildCard(key: string, c: Ctx): Card | null {
38 const card = buildCard0(key, c)
39 return card && c.scr?.logos ? { ...card, body: logoRows(card.body, c.scr.logos, c.scr.logo_colors) } : card
40}
41function buildCard0(key: string, c: Ctx): Card | null {
42 const { cd, frame, half, full, now } = c, L = cd.lead, Q = cd.qa, K = cd.captain
43 switch (key) {
44 case 'H': { const hv = hexValues(cd.hex, c.ctx)
45 const hk = hexKeys(), body = hexGem(hv.v, 13, 6, frame).map((row, i) => [...row, { text: ' ' }, ...(i < 6 ? [{ text: `${hk[i]} `, color: hv.v[i] === null ? 'inactive' : 'text' }, { text: hv.v[i] === null ? '—' : String(Math.round(hv.v[i]! * 100)), color: 'claude' }, { text: ` ${hv.raw[i]}`, color: 'inactive' }] : [])])
46 return { key, title: CHART_TITLE.H!, how: tr('how_h'), body, src: 'verify-shadow / dm-queue / handoff / CTX' } }
47 case 'TR': return { key, title: CHART_TITLE.TR!, big: tr('big_trees', { n: cd.trees.length }), how: tr('how_tr'), body: cd.trees.length ? treeRows(cd.trees, frame, half - 4) : none(tr('none_own_tree')), src: 'git worktree + verify-shadow' }
48 case 'SH': return { key, title: CHART_TITLE.SH!, wide: true, how: tr('how_sh'), body: cd.shadow ? shadowRows(cd.shadow, full - 4, frame) : none(tr('none_shadow')), src: tr('src_sh') }
49 case 'RG': return { key, title: CHART_TITLE.RG!, big: tr('big_runs', { n: cd.trend.reduce((a, d) => a + d[1] + d[2], 0) }), how: tr('how_rg'), body: cd.trend.some(d => d[1] + d[2]) ? stackCols(cd.trend, 6, frame, half - 4) : none(tr('none_tests_7d')), src: tr('src_rg') }
50 case 'CM': return { key, title: CHART_TITLE.CM!, big: tr('big_commits', { n: cd.commits.reduce((a, d) => a + d[1], 0) }), how: tr('how_cm'), body: cd.commits.some(d => d[1]) ? commitCurve(cd.commits, half - 6, 5, frame) : none(tr('none_commits_7d')), src: tr('src_cm') }
51 case 'FI': return { key, title: CHART_TITLE.FI!, big: tr('big_top', { n: 10 }), how: tr('how_fi'), body: cd.files.length ? hbarRows(cd.files, half - 4) : none(tr('none_commits')), src: 'git show --numstat' }
52 case 'SL': return { key, title: CHART_TITLE.SL!, big: tr('big_top', { n: 8 }), how: tr('how_sl'), body: listBars(cd.slowest.map(x => [`${x[0]} ×${x[2]}`, x[1]] as [string, number]), half - 4, 'warning', tr('unit_sec')), src: 'verify-shadow duration_s' }
53 case 'MY': { const v = c.ctx; return { key, title: CHART_TITLE.MY!, how: tr('how_my'), body: v === null ? none(tr('na')) : seatTable([{ code: tr('me'), prov: '', ctx: v, unread: null, now_state: '' }], frame), src: tr('src_my') } }
54 // §22.4:從今天頁搬來(雷達 = 應知換個畫法、圓餅 = 待辦件數),預設不勾
55 case 'KR': return c.scr ? { key, title: CHART_TITLE.KR!, big: tr('big_items', { n: c.scr.know.length }), how: tr('how_kr'), body: c.scr.know.length ? scanRadar(knowBlips(c.scr.know), 14, 6, frame) : none(tr('none_know')), src: tr('src_kr') } : null
56 case 'TP': return c.scr ? { key, title: CHART_TITLE.TP!, big: tr('big_todos', { n: c.scr.todo.length }), how: tr('how_tp'), body: c.scr.todo.length ? spinPie(todoParts(c.scr.todo), 18, 6, frame) : none(tr('no_todo')), src: tr('src_tp') } : null
57 case 'SR': return L ? { key, title: CHART_TITLE.SR!, big: tr('big_seats', { n: L.subs.length }), how: tr('how_sr'), body: L.subs.length ? seatRadar(L.subs, 14, 6, frame) : none(tr('none_subs')), src: tr('src_sr') } : null
58 case 'PK': return { key, title: CHART_TITLE.PK!, big: tr('big_in_batch', { v1: cd.trees.filter(t => t.in_batch).length, v2: cd.trees.length }), how: tr('how_pk'), body: cd.trees.length ? treeRows(cd.trees, frame, half - 4) : none(tr('none_trees')), src: tr('src_pk') }
59 case 'ST': return L ? { key, title: CHART_TITLE.ST!, big: tr('big_seats', { n: L.subs.length }), how: tr('how_st'), body: seatTable(L.subs, frame), src: 'seat-info --fleet' } : null
60 case 'LF': return L ? { key, title: CHART_TITLE.LF!, how: tr('how_lf'), body: funnelRows(L.funnel, half - 4, frame), src: tr('src_lf') } : null
61 case 'WT': return L ? { key, title: CHART_TITLE.WT!, big: tr('big_mails', { n: L.wait.length }), how: tr('how_wt'), body: waitHist(L.wait, frame), src: 'dm-queue audit' } : null
62 case 'GT': return L ? { key, title: CHART_TITLE.GT!, how: tr('how_gt'), body: ganttRows(L.gantt, half - 4, frame), src: 'plan.jsonl + git log' } : null
63 case 'HOT': return L ? { key, title: CHART_TITLE.HOT!, how: tr('how_hot'), body: listBars(L.hot.map(h => [tr('hot_runs', { name: h[0], n: h[2] }), h[1]] as [string, number]), half - 4, 'error', tr('unit_red')), src: 'verify-shadow' } : null
64 case 'QT': return Q ? { key, title: CHART_TITLE.QT!, big: tr('big_days', { n: 7 }), how: tr('how_qt'), body: Q.fleet_trend.some(d => d[1] + d[2]) ? stackCols(Q.fleet_trend, 6, frame, half - 4) : none(tr('none_tests_7d')), src: 'verify-shadow' } : null
65 case 'FL': return Q ? { key, title: CHART_TITLE.FL!, how: tr('how_fl'), body: flakyRows(Q.flaky, half - 4, frame), src: 'verify-shadow' } : null
66 case 'PA': return Q ? { key, title: CHART_TITLE.PA!, big: tr('big_days', { n: 3 }), how: tr('how_pa'), body: listBars(Q.pareto, half - 4, 'permission', '', true), src: 'verify-shadow/batch-pushgate.jsonl' } : null
67 case 'GB': return Q ? { key, title: CHART_TITLE.GB!, how: tr('how_gb'), body: listBars(Q.gate_blocks.map(g => ['@' + g[0], g[1]] as [string, number]), half - 4, 'warning', tr('unit_times')), src: 'logs/now-gate.log' } : null
68 case 'EV': return Q ? { key, title: CHART_TITLE.EV!, big: tr('big_hours', { n: 48 }), how: tr('how_ev'), body: eventTimeline(Q.events ?? [], half - 4, now), src: 'now-gate.log + report-lint-stop.jsonl' } : null
69 case 'SC': return K?.shadow ? { key, title: CHART_TITLE.SC!, how: tr('how_sc'), body: shadowCumulative(K.shadow.runs, Math.min(60, full - 6), 7), src: 'test-stats + ship-stats', wide: true } : null
70 case 'FR': return K?.seats ? { key, title: CHART_TITLE.FR!, big: tr('big_seats', { n: K.seats.length }), how: tr('how_fr'), body: seatRadar(K.seats, 14, 6, frame), src: tr('src_fr') } : null
71 case 'RV': return K?.river ? { key, title: CHART_TITLE.RV!, how: tr('how_rv'), body: riverRows(K.river, frame), src: 'ship-stats' } : null
72 case 'LS': return K?.lease ? { key, title: CHART_TITLE.LS!, big: tr('big_hours', { n: 48 }), how: tr('how_ls'), body: leaseLanes(K.lease, half - 4, now), src: 'state/host-lease.json.log' } : null
73 case 'HT': return K?.heat ? { key, title: CHART_TITLE.HT!, big: tr('big_hours', { n: 24 }), how: tr('how_ht'), body: heatGrid(K.heat), src: 'dm-queue audit' } : null
74 case 'WF': return K?.waterfall ? { key, title: CHART_TITLE.WF!, how: tr('how_wf'), body: waterfallRows(K.waterfall, half - 4, frame), src: tr('src_wf') } : null
75 case 'CH': return K?.chord ? { key, title: CHART_TITLE.CH!, big: tr('big_days', { n: 7 }), how: tr('how_ch'), body: chordMatrix(K.chord), src: 'dm-queue' } : null
76 case 'FU': return K?.funnel ? { key, title: CHART_TITLE.FU!, how: tr('how_fu'), body: funnelRows(K.funnel, half - 4, frame), src: tr('src_lf') } : null
77 case 'SK': return K?.sankey ? { key, title: CHART_TITLE.SK!, big: tr('big_hours', { n: 72 }), how: tr('how_sk'), body: sankeyRows(K.sankey), src: 'batch-pushgate' } : null
78 case 'CT': return K?.seats ? { key, title: CHART_TITLE.CT!, how: tr('how_ct'), body: seatTable(K.seats, frame), src: 'seat-info --fleet' } : null
79 case 'BT': return K?.beat ? { key, title: CHART_TITLE.BT!, big: tr('big_hours', { n: 72 }), how: tr('how_bt'), body: beatRows(K.beat, frame), src: 'git log main', wide: true } : null
80 case 'DC': return K?.decide ? { key, title: CHART_TITLE.DC!, big: tr('big_cards', { n: K.decide.length }), how: tr('how_dc'), body: decideCards(K.decide, half - 4, now), src: tr('src_dc') } : null
81 }
82 return null
83}
84hooks/i18n.ts 44 lines1// mod 端 i18n(docs/DESIGN-aib-languages-04e0a-1014.md §5):字放 repo i18n/<loc>.json 的 mod.<名>.* 鍵,
2// 由 `i18n_lib.py --locale L --prefix mod.<名> --json` 一次取整組(缺譯已由 i18n_lib 補 en);依語系快取;
3// 缺鍵回 !!key!!(loader-spec fail-loud,畫面上一眼看得到)。
4// 這一檔只放**純函式**:驗證器不准把 $ 傳進別的檔的函式,所以讀字典那段(要 $)住在 register.tsx 的 bundle()。別的 mod 照抄兩段即可。
5export const LOCALES = ['zh-TW', 'en', 'ja', 'ko'] as const
6export type Loc = (typeof LOCALES)[number]
7/** 每個語言用自己的寫法(選單上不管現在是哪個語系,都看得懂自己那顆) */
8export const SELF_NAMES: Record<Loc, string> = { 'zh-TW': '繁體中文', en: 'English', ja: '日本語', ko: '한국어' }
9
10export function normalize(raw: string | undefined | null): Loc | null {
11 const t = (raw ?? '').trim().split('@')[0]!.split('.')[0]!.replace('_', '-').toLowerCase()
12 const m: Record<string, Loc> = { zh: 'zh-TW', 'zh-tw': 'zh-TW', 'zh-hant': 'zh-TW', 'zh-hant-tw': 'zh-TW', en: 'en', 'en-us': 'en', 'en-gb': 'en', ja: 'ja', 'ja-jp': 'ja', ko: 'ko', 'ko-kr': 'ko' }
13 return m[t] ?? null
14}
15
16export function fmt(template: string, params?: Record<string, string | number>): string {
17 if (!params) return template
18 return template.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (whole, name: string) => (name in params ? String(params[name]) : whole))
19}
20
21const bundles = new Map<string, Record<string, string>>()
22
23/** 快取:prefix@loc → 整組字;切語系時 forget() */
24export function cached(prefix: string, loc: Loc): Record<string, string> | undefined { return bundles.get(`${prefix}@${loc}`) }
25export function remember(prefix: string, loc: Loc, data: Record<string, string>): void { bundles.set(`${prefix}@${loc}`, data) }
26export function forget(): void { bundles.clear() }
27
28export function makeT(prefix: string, data: Record<string, string>) {
29 return (key: string, params?: Record<string, string | number>): string => {
30 const full = `${prefix}.${key}`
31 const v = data[full]
32 return v === undefined ? `!!${full}!!` : fmt(v, params)
33 }
34}
35
36// ── 以上照抄 aib-languages/hooks/i18n.ts;以下是這個 mod 自己加的 ──
37// 純檔(lib / chartset / engcharts / tiercharts)要用字:register.tsx(或 tui)讀到這一席語系的整組字後 useT() 換進來,
38// 純檔一律走 tr()。不把 t 一路當參數傳:幾十個畫圖函式的簽名不動,同一行程同一時間只有一個語系。
39export type T = (key: string, params?: Record<string, string | number>) => string
40let cur: { loc: Loc; t: T } = { loc: 'zh-TW', t: key => `!!${key}!!` } // 還沒 useT ⇒ 一眼看得出來(fail-loud),不偷偷用中文
41export function useT(loc: Loc, t: T): void { cur = { loc, t } }
42export const tr: T = (key, params) => cur.t(key, params)
43export const curLoc = (): Loc => cur.loc
44hooks/seatcore.tsx 198 lines1import { atom, read, update } from 'claude-code'
2import type { Register, EngineInterface, Timer } from 'claude-code'
3import { fleetPromptLines, fleetAlerts } from './seat/captain'
4import { integPromptLines, integAlerts } from './seat/integrator'
5import { devPromptLines, devAlerts } from './seat/dev'
6import { qaPromptLines, qaAlerts } from './seat/qa'
7import { cardOf, upsert, relayPath, iconOf, clip, rasterCells, STATUS, type MailCard, type QItem, type Icon } from './seat/mailcard'
8import { statusLabel, promptSection, handoffState, newBlocks, label, type SeatInfo } from './seat/seat-label'
9import { tr } from './i18n'
10import { openRoom } from './room' // /aib room(艦長室外掛)拆在 room.ts
11import { ready } from './ready' // 出字前先讀語系 + 字典(與 register.tsx 同一份快取)
12
13// 席位核心(設計稿 §24,the maintainer 10-14「直接把 aib-seat 拿掉」→「整個拿掉,必要的先搬」):原本 aib-seat 外掛裡每一席都要的那一層搬到這裡 ——
14// 系統提示的席位身份(+ 艦長 / 整合 / dev / qa 摘要)、狀態列一行、85% 交接提醒、守衛擋下的提示、新信卡片(輸入框上方)。/aib room 是艦長室外掛那段,拆在 room.ts(沒裝就說開發中)。
15// aib-seat 的面板、圖表、角色視角、事件流、子代理卡、demo、mod off 都不搬(跟 aib-boards 重複)。字走 mod.aib_boards.seat_* / st_* / mail_*
16// (出字前先 ready($) 讀語系與字典,ready.ts 與 register.tsx 共用)。
17
18const REFRESH_MS = 60_000
19const mailCards = atom({ plugin: 'aib-boards', key: 'mailCards' } as const, [] as MailCard[])
20const mailDismissed = atom({ plugin: 'aib-boards', key: 'mailDismissed' } as const, [] as string[])
21const blockSeen = atom({ plugin: 'aib-boards', key: 'blockSeen' } as const, '')
22
23let seat: SeatInfo | undefined
24let probeWhy = ''
25let ctxPercent: number | null = null
26let warnedHandoff = false
27let toldHandoffDone = false
28let tick: Timer | undefined
29// 新信卡片:開席時已經在的未讀不做卡(只做之後來的);人按過 ✕ 的記在 mailDismissed
30let mailBaseline: Set<string> | null = null
31let dmDir = ''
32const iconCache = new Map<string, { columns: number; rows: number; cells: string } | null>()
33const ICON_W = 14, ICON_H = 14 // 大頭像 14×14 像素 = 14 格寬、7 行高
34
35// 席位檔 mod.enabled=false(依角色關)⇒ 不寫系統提示、狀態列只留一句
36const modOn = () => seat?.mod?.enabled !== false
37const isRealCaptain = () => !!seat?.code && seat.code === seat.captain
38const isRealIntegrator = () => seat?.role === 'integrator'
39const isRealDev = () => seat?.role === 'dev'
40const isRealQa = () => seat?.role === 'qa' || seat?.role === 'acceptance'
41
42async function py($: EngineInterface): Promise<string> { return (await $.env.get('EZ2_PY3')) ?? 'python3' }
43async function probe($: EngineInterface): Promise<SeatInfo | undefined> {
44 const uuid = await $.session.id().catch(() => '')
45 const flags = [...(isRealCaptain() ? ['--fleet'] : []), ...(isRealIntegrator() ? ['--integrator'] : []), ...(isRealDev() ? ['--dev'] : []), ...(isRealQa() ? ['--qa'] : [])]
46 const r = await $.process.run([await py($), '-I', `${$.plugin.root}/bin/seat-info.py`, uuid, ...flags], { timeoutMs: 25_000 }).catch((err: unknown) => {
47 probeWhy = tr('seat_probe_spawn', { err: String(err).slice(0, 80) }); return undefined })
48 if (!r) return undefined
49 if (r.exitCode !== 0 || !r.stdout.trim()) { probeWhy = `rc=${r.exitCode} ${(r.stderr ?? '').trim().split('\n').pop()?.slice(0, 80) ?? ''}`; return undefined }
50 try { probeWhy = ''; return JSON.parse(r.stdout) as SeatInfo } catch { probeWhy = tr('seat_probe_json'); return undefined }
51}
52async function readCtx($: EngineInterface): Promise<number | null> {
53 const u = await $.session.usage().catch(() => undefined)
54 const p = u?.context?.percent
55 return typeof p === 'number' ? p : null
56}
57async function dmDirOf($: EngineInterface): Promise<string> {
58 if (dmDir) return dmDir
59 const r = await $.process.run([await py($), '-I', `${$.plugin.root}/bin/seat-info.py`, '--dm-dir'], { timeoutMs: 5000 }).catch(() => null)
60 dmDir = r?.exitCode === 0 ? r.stdout.trim() : ''
61 return dmDir
62}
63async function readQ($: EngineInterface, path: string): Promise<QItem | null> {
64 try { return JSON.parse(await $.fs.read(path)) as QItem } catch { return null }
65}
66// 一份信件檔 → 卡片(長訊落檔的讀全文,最多 4000 字)→ 進 atom
67async function takeMail($: EngineInterface, q: QItem): Promise<void> {
68 const me = seat?.code ?? null
69 if (q.seat && me && q.seat !== me) return
70 if (mailBaseline?.has(q.id ?? '') && !(await read($, mailCards)).some(c => c.id === q.id)) return
71 const rp = relayPath(String(q.msg ?? ''), seat?.home ?? null)
72 const full = rp ? await $.fs.read(rp).then(t => t.slice(0, 4000)).catch(() => null) : null
73 const c = cardOf(q, me, full); if (!c) return
74 const gone = new Set(await read($, mailDismissed))
75 await update($, mailCards, list => upsert(list, c, gone))
76 ensureIcon($, iconOf(c, seat?.marks))
77 $.ui.invalidate('ui.render')
78}
79// 人格圖示畫成像素圖(macOS 的 Apple Color Emoji;別的系統或失敗 ⇒ 退回文字 emoji)
80function ensureIcon($: EngineInterface, emoji: string): void {
81 if (iconCache.has(emoji)) return
82 iconCache.set(emoji, null)
83 void $.process.run(['osascript', '-l', 'JavaScript', `${$.plugin.root}/bin/emoji-raster.js`, emoji, String(ICON_W), String(ICON_H)], { timeoutMs: 8000 })
84 .then(r => { if (r.exitCode === 0) { iconCache.set(emoji, rasterCells(JSON.parse(r.stdout) as Icon)); $.ui.invalidate('ui.render') } })
85 .catch(() => undefined)
86}
87async function refresh($: EngineInterface): Promise<void> {
88 await ready($)
89 const [s, pct] = await Promise.all([probe($), readCtx($)])
90 ctxPercent = pct
91 if (!s) { $.ui.status(tr('seat_st_na', { context: pct === null ? '' : ` · CTX ${Math.round(pct)}%` })); return }
92 seat = s
93 if (!modOn()) { $.ui.status(tr('seat_st_off', { role: s.role ?? tr('seat_unbound'), why: s.mod?.why ?? '' })); return }
94 $.ui.status(statusLabel(s, pct))
95 // 新信卡片的後備(監看沒登記上時):照未讀清單補卡;已有的卡重讀一次信件檔更新狀態(已讀 / 完成)
96 if (mailBaseline === null) mailBaseline = new Set((s.unread_list ?? []).map(m => m.id))
97 const dir = await dmDirOf($)
98 if (dir) {
99 const ids = new Set([...(s.unread_list ?? []).map(m => m.id), ...(await read($, mailCards)).map(c => c.id)])
100 for (const id of ids) { const q = await readQ($, `${dir}/${id}.json`); if (q) await takeMail($, q) }
101 }
102 const fresh = newBlocks(s.blocks, await read($, blockSeen), Date.now())
103 for (const b of fresh) $.ui.toast(tr('seat_toast_block', { label: label(b.gate), why: b.why }), { timeoutMs: 8000 })
104 if (fresh.length) await update($, blockSeen, () => fresh[fresh.length - 1]!.ts)
105 const hs = handoffState(s, pct)
106 if (hs === 'due' && !warnedHandoff) {
107 warnedHandoff = true
108 $.ui.toast(tr('seat_toast_due', { pct: Math.round(pct ?? 0) }), { timeoutMs: 12000 })
109 }
110 if (hs === 'done' && !toldHandoffDone) {
111 toldHandoffDone = true
112 $.ui.toast(tr('seat_toast_done', { at: s.handoff?.at ?? '?' }), { timeoutMs: 10000 })
113 }
114}
115
116// 開席(session.start)與信件檔異動(classic.FileChanged)由 register.tsx 現有的那一個 handler 呼叫 ——
117// 同一個事件不能登記兩次;信件資料夾由 classic.SessionStart 一起登記監看
118export async function seatOnStart($: EngineInterface): Promise<void> {
119 warnedHandoff = false; toldHandoffDone = false; ctxPercent = null
120 await $.command.register({ name: 'aib', description: 'Ez2 AIB seat: /aib room opens the captain-room web page' })
121 await refresh($)
122 if (seat?.seat_file_has_uuid === false && modOn()) $.ui.toast(tr('seat_toast_no_uuid'), { timeoutMs: 10000 })
123 tick?.cancel() // /clear 會再跑一次 session.start:舊計時器先停,不疊兩個
124 tick = $.clock.every(REFRESH_MS, () => { void refresh($) })
125}
126export async function seatWatchDir($: EngineInterface): Promise<string> { return dmDirOf($) }
127export async function seatOnFileChanged($: EngineInterface, path: string, event: string): Promise<void> {
128 if (!dmDir || !path.startsWith(dmDir + '/') || !/\/q_[^/]+\.json$/.test(path) || event === 'unlink') return
129 const q = await readQ($, path); if (q) await takeMail($, q)
130}
131
132export const registerSeat: Register = on => {
133 on('command.run', { command: 'aib' }, async ($, e) => {
134 const arg = e.args.trim()
135 if (arg === 'room') {
136 await ready($)
137 // 探測沒回來(seat 空)不能說「開發中」:當作裝了,走「還沒跑過」那句
138 return { text: await openRoom($, seat ? seat.room_installed : true, seat?.room) }
139 }
140 await ready($)
141 return { text: tr('seat_cmd_usage') }
142 })
143
144 on('prompt.compose', async ($, e, next) => {
145 const r = await next(e)
146 if (seat && !modOn()) return r
147 await ready($)
148 if (!seat) return { sections: [...r.sections, { id: 'aib-seat', scope: 'session' as const,
149 text: tr('seat_p_noseat', { src: 'aib-boards', why: probeWhy ? tr('seat_paren', { value: probeWhy }) : '' }) }] }
150 ctxPercent = await readCtx($)
151 let text = promptSection(seat, ctxPercent)
152 if (isRealCaptain() && seat.fleet?.length) text += '\n' + fleetPromptLines(seat.fleet, fleetAlerts(seat.fleet, seat.code)).join('\n')
153 if (isRealIntegrator() && seat.integ) text += '\n' + integPromptLines(seat.integ, integAlerts(seat.integ, seat.code, Date.now())).join('\n')
154 if (isRealDev() && seat.dev) text += '\n' + devPromptLines(seat.dev, devAlerts(seat.dev)).join('\n')
155 if (isRealQa() && seat.qa) text += '\n' + qaPromptLines(seat.qa, qaAlerts(seat.qa)).join('\n')
156 return { sections: [...r.sections, { id: 'aib-seat', text, scope: 'session' as const }] }
157 }).catch(($, e, next) => (next.called ? next(e) : next(e)))
158
159 // 新信卡片(輸入框上方):狀態色外框、角色大頭像、收起三行可展開、按 ✕ 才關
160 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
161 const cards = await read($, mailCards)
162 if (e.props.hasSurvey || !cards.length) return next(e)
163 await ready($)
164 const { Box, Text, Button, Raster } = $.ui.resolve(e) as ReturnType<typeof $.ui.resolve> & { Raster?: unknown }
165 const W = Math.max(30, e.props.bodyColumns), me = seat?.code ?? '?'
166 const lg = (c?: string | null) => (c && seat?.logos?.[c] ? <Text color={seat?.logo_colors?.[c]}>{seat.logos[c]} </Text> : null)
167 const close = (id: string) => async () => { await update($, mailDismissed, l => [...l.filter(x => x !== id), id].slice(-200)); await update($, mailCards, l => l.filter(x => x.id !== id)); $.ui.invalidate('ui.render') }
168 const flip = (id: string) => async () => { await update($, mailCards, l => l.map(x => (x.id === id ? { ...x, open: !x.open } : x))); $.ui.invalidate('ui.render') }
169 const closeAll = async () => { const ids = cards.map(c => c.id); await update($, mailDismissed, l => [...l, ...ids].slice(-200)); await update($, mailCards, () => []); $.ui.invalidate('ui.render') }
170 const hm = (t: number) => { if (!t) return '?'; const d = new Date(t * 1000); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` }
171 return (
172 <Box flexDirection="column" width={W}>
173 <Box flexDirection="row" justifyContent="space-between"><Text bold>{tr('mail_band', { n: cards.length })}<Text dimColor>{tr('mail_close_hint')}</Text></Text>
174 {cards.length > 1 ? <Button key="mail-x-all" variant="secondary" onPress={closeAll}>{tr('mail_close_all')}</Button> : null}</Box>
175 {cards.map(c => {
176 const st = STATUS[c.status], emoji = iconOf(c, seat?.marks), pic = iconCache.get(emoji), p = c.from ? seat?.personas?.[c.from] : undefined
177 const body = c.open ? { text: c.text, cut: false } : clip(c.text.replace(/\s+\n/g, '\n'), Math.max(10, W - ICON_W - 8), 3)
178 return (
179 <Box key={`mail-${c.id}`} flexDirection="row" borderStyle="round" borderColor={st.color} paddingX={1} width={W}>
180 <Box flexDirection="column" width={ICON_W + 1} alignItems="center">
181 {pic && Raster ? <Raster key={`mail-pic-${c.id}`} columns={pic.columns} rows={pic.rows} cells={pic.cells} /> : <Text>{emoji}</Text>}
182 </Box>
183 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
184 <Box flexDirection="row" justifyContent="space-between">
185 <Text wrap="truncate-end"><Text color={st.color} bold>● {tr(st.key)}</Text><Text> </Text>{lg(c.from)}<Text bold>{p?.name ?? c.label ?? c.from ?? '?'}</Text><Text dimColor>{c.from ? ` @${c.from}` : ''} → </Text>{lg(me)}<Text dimColor>@{me} · {hm(c.at)}</Text></Text>
186 <Box flexDirection="row" gap={1}>
187 {body.cut || c.open ? <Button key={`mail-open-${c.id}`} variant="secondary" onPress={flip(c.id)}>{c.open ? tr('collapse') : tr('expand')}</Button> : null}
188 <Button key={`mail-x-${c.id}`} variant="secondary" onPress={close(c.id)}>✕</Button>
189 </Box>
190 </Box>
191 <Text wrap="wrap">{body.text}</Text>
192 </Box>
193 </Box>)
194 })}
195 </Box>)
196 }).catch(($, e, next) => (next.called ? next(e) : next(e)))
197}
198hooks/ready.ts 61 lines1// 語系 + 字典(docs/DESIGN-aib-languages-04e0a-1014.md §5),register.tsx 與 seatcore.tsx 共用:驗證器只准把 $ 傳給本檔頂層
2// 宣告的函式或從外掛自己的檔匯入的函式 ⇒ 讀語系 / 字典放這一檔,兩邊都 import。
3import type { EngineInterface } from 'claude-code'
4import { normalize, cached, remember, makeT, useT, tr as t_ } from './i18n'
5import type { Loc } from './i18n'
6
7export const PREFIX = 'mod.aib_boards'
8export async function repoRoot($: EngineInterface): Promise<string | null> {
9 // 驗證器要 $.env.get 的名字是字面值,所以不用迴圈(同 aib-languages)
10 const explicit = (await $.env.get('EZ2_AIB_ROOT').catch(() => undefined)) || (await $.env.get('AIB_SOURCE_ROOT').catch(() => undefined))
11 if (explicit && (await $.fs.exists(`${explicit}/src/cockpit/i18n_lib.py`).catch(() => false))) return explicit
12 const home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
13 const guess = `${home}/ez2-ai-b`
14 if (home && (await $.fs.exists(`${guess}/src/cockpit/i18n_lib.py`).catch(() => false))) return guess
15 return null
16}
17let bundleFailAt = 0 // 讀字典失敗:30 秒內不重試(畫面 0.2 秒重畫一次,不能每格起一支 Python)
18async function bundle($: EngineInterface, loc: Loc): Promise<Record<string, string>> {
19 const hit = cached(PREFIX, loc)
20 if (hit) return hit
21 if (Date.now() - bundleFailAt < 30_000) return {}
22 const root = await repoRoot($)
23 const py = (await $.env.get('EZ2_PY3').catch(() => undefined)) ?? 'python3'
24 const r = root ? await $.process.run([py, '-I', `${root}/src/cockpit/i18n_lib.py`, '--locale', loc, '--prefix', PREFIX, '--json'], { timeoutMs: 10_000 }).catch(() => null) : null
25 let data: Record<string, string> | null = null
26 try { if (r && r.exitCode === 0) data = JSON.parse(r.stdout.trim().split('\n').pop() || '{}') as Record<string, string> } catch { data = null }
27 if (!data) { bundleFailAt = Date.now(); return {} }
28 remember(PREFIX, loc, data)
29 return data
30}
31// 沒有 aib-languages(env 沒設)⇒ 問 i18n_lib.py --resolve(同 tui 的單一順位,比起 aib lang 便宜:不起 bash 分派);結果留 10 秒,
32// 同一時間只問一次(畫面 0.2 秒重畫一次,問的時候別的重畫拿上一次的答案,沒有答案就先用 zh-TW)
33let askedLoc: { loc: Loc; at: number } | null = null, asking: Promise<Loc> | null = null, resolveFailAt = -Infinity // 問失敗後 5 秒內不重問(畫面一直重畫,不能每格起一支)
34async function localeOf($: EngineInterface): Promise<Loc> {
35 const env = normalize(await $.env.get('EZ2_AIB_LOCALE').catch(() => undefined))
36 if (env) return env
37 const nowMs = await $.clock.now().catch(() => Date.now()) // 用引擎的鐘(測試可推),不用 Date.now
38 if (askedLoc && nowMs - askedLoc.at < 10_000) return askedLoc.loc
39 if (asking) return askedLoc?.loc ?? asking
40 if (nowMs - resolveFailAt < 5_000) return askedLoc?.loc ?? 'zh-TW'
41 asking = (async () => {
42 const root = await repoRoot($)
43 const py = (await $.env.get('EZ2_PY3').catch(() => undefined)) ?? 'python3'
44 const r = root ? await $.process.run([py, '-I', `${root}/src/cockpit/i18n_lib.py`, '--resolve'], { timeoutMs: 10_000 }).catch(() => null) : null
45 let loc: Loc | null = null
46 try { if (r && r.exitCode === 0) loc = normalize((JSON.parse(r.stdout.trim().split('\n').pop() || '{}') as { locale?: string }).locale) } catch { loc = null }
47 // 問失敗 / 逾時:沿用上一次問到的(英文畫面不會因為一次失敗閃回中文),也不記快取 ⇒ 下一次重畫再問;從沒問到過才用 zh-TW
48 if (!loc) { resolveFailAt = nowMs; return askedLoc?.loc ?? 'zh-TW' }
49 askedLoc = { loc, at: nowMs }
50 return loc
51 })().finally(() => { asking = null })
52 return asking
53}
54/** 這一輪要用的字:讀語系 + 字典,換給純檔,回 t */
55export async function ready($: EngineInterface) {
56 const loc = await localeOf($)
57 useT(loc, makeT(PREFIX, await bundle($, loc)))
58 return { t: t_, loc }
59}
60
61hooks/engcharts.ts 164 lines1// 工程級 6 張圖(對齊示範網頁 §17/§18):純函式,輸入 S1 的 charts + frame,輸出 Rows(每列一串 {文字, 顏色})。
2import { Canvas } from './charts'
3import type { Rows, Seg } from './charts'
4import { fit } from './lib'
5import { tr } from './i18n'
6
7const seg = (text: string, color?: string, dim?: boolean): Seg => ({ text, color, dim })
8// 終端機顯示寬:中日韓全形字占 2 格(對不齊的根因:原本當 1 格算)
9const cw = (ch: string) => /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/.test(ch) ? 2 : 1
10export const dispW = (s: string) => [...s].reduce((a, ch) => a + cw(ch), 0)
11const pad = (s: string, n: number) => {
12 if (dispW(s) <= n) return s + ' '.repeat(n - dispW(s))
13 let out = '', w = 0
14 for (const ch of s) { if (w + cw(ch) > n - 1) break; out += ch; w += cw(ch) }
15 return out + '…' + ' '.repeat(Math.max(0, n - 1 - w))
16}
17const padL = (s: string, n: number) => ' '.repeat(Math.max(0, n - dispW(s))) + s
18const blocks = (n: number) => '█'.repeat(Math.max(0, Math.round(n)))
19
20// 01 六角寶石:實心寶石,切面依順時針輪流亮(3.6 秒一圈 ≈ 9 格),頂點閃星(值越高閃越常),沒來源的角縮在中心
21const HEX_K = ['hex_success', 'hex_speed_k', 'hex_room', 'hex_mail', 'hex_handoff_k', 'hex_team']
22// 六個角的名字(照語系;補到同寬,英文長短不一時數字才對得齊 —— 中文都是兩字,補了也不變)
23export const hexKeys = (): string[] => { const k = HEX_K.map(x => tr(x)), w = Math.max(...k.map(dispW)); return k.map(x => x + ' '.repeat(w - dispW(x))) }
24export function hexGem(values: (number | null)[], w: number, h: number, frame: number): Rows {
25 const cv = new Canvas(w, h), cx = w - 0.5, cy = h * 2 - 0.5, R = Math.min(w - 1, h * 2 - 1)
26 const at = (k: number, r: number): [number, number] => { const a = -Math.PI / 2 + (k * Math.PI) / 3; return [cx + Math.cos(a) * r, cy + Math.sin(a) * r] }
27 for (const ring of [1 / 3, 2 / 3, 1]) for (let k = 0; k < 6; k++) { const [x0, y0] = at(k, R * ring), [x1, y1] = at(k + 1, R * ring); cv.line(x0, y0, x1, y1, 'subtle', ring < 1 ? 2 : 0) }
28 const v = values.map(x => (x == null ? 0.06 : Math.max(0.08, Math.min(1, x))))
29 const P = v.map((f, k) => at(k, R * f))
30 const lit = Math.floor(frame / 1.5) % 6
31 // 每個切面 = 中心 + 相鄰兩個頂點的三角形;逐點判斷在不在裡面
32 const inTri = (px: number, py: number, a: [number, number], b: [number, number], c: [number, number]) => {
33 const s = (p: [number, number], q: [number, number], r: [number, number]) => (p[0] - r[0]) * (q[1] - r[1]) - (q[0] - r[0]) * (p[1] - r[1])
34 const d1 = s([px, py], a, b), d2 = s([px, py], b, c), d3 = s([px, py], c, a)
35 return !((d1 < 0 || d2 < 0 || d3 < 0) && (d1 > 0 || d2 > 0 || d3 > 0))
36 }
37 for (let y = 0; y < h * 4; y++) for (let x = 0; x < w * 2; x++) for (let k = 0; k < 6; k++) {
38 if (inTri(x, y, [cx, cy], P[k]!, P[(k + 1) % 6]!)) { if ((x + y + k) % 2 === 0 || k === lit) cv.set(x, y, k === lit ? 'warning' : 'claude'); break }
39 }
40 for (let k = 0; k < 6; k++) { const [x0, y0] = P[k]!, [x1, y1] = P[(k + 1) % 6]!; cv.line(x0, y0, x1, y1, 'claude') }
41 const rows = cv.rows()
42 // 頂點閃星:值越高,閃得越常(週期 4.2 − 3×值 秒 ≈ 格數)
43 v.forEach((f, k) => {
44 if (values[k] == null) return
45 const period = Math.max(3, Math.round((4.2 - 3 * f) / 0.4))
46 if (frame % period !== 0) return
47 const [x, y] = P[k]!, r = Math.max(0, Math.min(h - 1, Math.floor(y / 4))), c = Math.max(0, Math.min(w - 1, Math.floor(x / 2)))
48 const row = rows[r]; if (!row) return
49 let col = 0
50 for (let i = 0; i < row.length; i++) { const L = [...row[i]!.text].length; if (c < col + L) { const t = [...row[i]!.text]; t[c - col] = '✦'; row[i] = { ...row[i]!, text: t.join(''), color: 'warning' }; break } col += L }
51 })
52 return rows
53}
54
55// 02 我的分支測試:分支、領先主線(開根號比例)、最近一次測試綠紅(紅段閃)、沒跑過虛線、合包圓點
56export type TreeRow = { branch: string; ahead: number; in_batch: boolean; green: number; red: number }
57// 一列一棵樹,整列剛好 width 格(the maintainer 03:1x 實看:原本固定 30+18+20 格,半寬卡放不下 ⇒ 每棵樹折成兩行、表頭「合包」掉下去)
58// 分支名照寬度截(中文算 2 格)、領先條 8 格、測試條 6 格 + 數字 8 格、合包燈 2 格
59export function treeRows(trees: TreeRow[], frame: number, width = 64): Rows {
60 const mxA = Math.max(1, ...trees.map(t => t.ahead)), mxN = Math.max(1, ...trees.map(t => t.green + t.red))
61 // 分支名欄 = 寬度扣掉後面固定 35 格;原本最少給 14,窄卡(46 格)時整列 49 格超出卡片、末尾的 ○ / ● 被折到下一行
62 // 合包欄 = 表頭字寬(至少 4 格;英文 Batch 5 格)⇒ 分支名欄讓出去,整列不超寬
63 // 測試欄的字(「沒跑過」/「3綠1紅」)同理:至少 8 格,照語系最長的字 + 1 格間隔
64 const B4 = Math.max(4, dispW(tr('th_batch'))), NW = Math.max(8, dispW(tr('never_ran')) + 1), BW = Math.max(8, width - (8 + 7 + 6 + 1 + NW + B4 + 1))
65 const head: Rows = [[seg(fit(tr('th_branch'), BW + 1), 'inactive'), seg(fit(tr('th_ahead'), 15), 'inactive'), seg(fit(tr('th_last_test'), 7 + NW), 'inactive'), seg(tr('th_batch'), 'inactive')]]
66 const MAX = 10, more = trees.length - MAX
67 return head.concat(trees.slice(0, MAX).map(t => {
68 const a = Math.round(Math.sqrt(t.ahead / mxA) * 8), n = t.green + t.red
69 const g = n ? Math.max(t.green ? 1 : 0, Math.round((t.green / mxN) * 6)) : 0, r = n ? Math.min(6 - g, Math.max(t.red ? 1 : 0, Math.round((t.red / mxN) * 6))) : 0
70 return [seg(fit(t.branch || tr('no_branch'), BW) + ' '), seg(blocks(a), 'suggestion', frame % 6 >= 3), seg('░'.repeat(8 - a), 'subtle'), seg(' ' + fit(t.ahead ? '+' + t.ahead : '0', 6), 'inactive'),
71 ...(n ? [seg(blocks(g), 'success'), seg(blocks(r), 'error', frame % 4 >= 2), seg('░'.repeat(6 - g - r), 'subtle'), seg(' ' + fit(`${tr('green_n', { n: t.green })}${t.red ? tr('red_n', { n: t.red }) : ''}`, NW), t.red ? 'error' : 'success')]
72 : [seg('┄┄┄┄┄┄', 'subtle'), seg(' ' + fit(tr('never_ran'), NW), 'inactive')]),
73 seg(' ' + (t.in_batch ? '●' : '○') + ' '.repeat(B4 - 3), t.in_batch ? 'warning' : 'inactive')]
74 }), more > 0 ? [[seg(tr('more_trees', { n: more }), 'inactive')]] : [])
75}
76
77// 03 影子續跑:三色條(沿用 / 挑掉不跑 / 實跑)、兩條時間對照(沒省到 = 紅、閃)、沒省到的原因
78export type Shadow = { kept: number; skipped: number; ran: number; without_s: number | null; actual_s: number | null; reasons: [string, number][] }
79export function shadowRows(s: Shadow, width: number, frame: number): Rows {
80 // the maintainer 10-14 04:3x「圖與字高度與間隔太近,而且換行有問題」:段與段之間空一行;每列(標籤 + 條 + 尾巴字)量好顯示寬再定條長,
81 // 不准超出卡片寬(超出就被終端機折到下一行,「沒省到」掉到下一行就是這個);原因整句放條的右邊,不再塞進 20 格被切
82 const tot = s.kept + s.skipped + s.ran, W = Math.max(20, width - 2), gap: Seg[] = [seg(' ')]
83 const out: Rows = [[seg(tr('sh_how', { n: tot }), 'inactive')], gap]
84 if (tot > 0) {
85 // 三段長度用最大餘數分配,加起來剛好 W(各自四捨五入會多出一兩格把整條擠到下一行)
86 const raw = [s.kept, s.skipped, s.ran].map(n => (n / tot) * W), len = raw.map(Math.floor)
87 raw.map((r, k) => [r - Math.floor(r), k] as [number, number]).sort((a, b) => b[0] - a[0]).slice(0, W - len.reduce((a, b) => a + b, 0)).forEach(([, k]) => { len[k]! += 1 })
88 out.push([seg(blocks(len[0]!), 'success'), seg(blocks(len[1]!), 'suggestion'), seg(blocks(len[2]!), 'warning')])
89 }
90 out.push([seg(`■${tr('sh_kept', { n: s.kept })} `, 'success'), seg(`■${tr('sh_skipped', { n: s.skipped })} `, 'suggestion'), seg(`■${tr('sh_ran', { n: s.ran })}`, 'warning')])
91 if (s.without_s != null && s.actual_s != null) {
92 const saved = s.actual_s < s.without_s, mx = Math.max(s.without_s, s.actual_s, 1)
93 const L1 = tr('sh_without'), L2 = tr('sh_actual'), LBL = Math.max(dispW(L1), dispW(L2)) + 2
94 const tails = [tr('secs', { n: s.without_s }), tr('secs', { n: s.actual_s }) + (saved ? '' : tr('sh_not_saved'))]
95 const bw = Math.max(6, W - LBL - Math.max(...tails.map(dispW)))
96 out.push(gap, [seg(tr('sh_time'), 'inactive')])
97 out.push([seg(padL(L1 + ' ', LBL)), seg(blocks((s.without_s / mx) * bw), 'inactive'), seg(tails[0]!)])
98 out.push([seg(padL(L2 + ' ', LBL)), seg(blocks((s.actual_s / mx) * bw), saved ? 'success' : 'error', !saved && frame % 4 >= 2), seg(tails[1]!, saved ? 'success' : 'error')])
99 }
100 if (s.reasons.length) {
101 const mx = Math.max(1, ...s.reasons.map(r => r[1])), BW = 12, nW = Math.max(...s.reasons.map(r => dispW(` ${r[1]}`)))
102 out.push(gap, [seg(tr('sh_reasons'), 'inactive')])
103 for (const [t, n] of s.reasons.slice(0, 4)) {
104 const num = ` ${n}`, room = Math.max(6, W - 2 - BW - nW - 2)
105 out.push([seg(' '), seg(blocks((n / mx) * BW), 'permission'), seg(' '.repeat(BW - Math.round((n / mx) * BW))), seg(num + ' '.repeat(nW - dispW(num)) + ' '), seg(dispW(t) > room ? pad(t, room) : t)])
106 }
107 }
108 return out
109}
110
111// 04 我的測試紅綠(7 天):每天一根,綠在下、紅疊上;最新一天亮暗交替
112const EIGHTHS = ' ▁▂▃▄▅▆▇█'
113// width:卡片內寬;每天一欄平分整張卡(the maintainer 10-14 04:3x「這感覺右邊空掉了」:原本固定每天 6 格 = 42 格寬,寬卡右邊一大塊空白)
114export function stackCols(trend: [string, number, number][], h: number, frame: number, width = 42): Rows {
115 const mx = Math.max(1, ...trend.map(d => d[1] + d[2])), out: Rows = []
116 const colW = Math.max(6, Math.floor(width / Math.max(1, trend.length))), barW = Math.max(3, colW - 2)
117 for (let r = 0; r < h; r++) {
118 const fromBottom = h - 1 - r, row: Seg[] = []
119 trend.forEach(([, g, rd], i) => {
120 const ge = Math.round((g / mx) * h * 8), te = Math.round(((g + rd) / mx) * h * 8)
121 const cellT = Math.max(0, Math.min(8, te - fromBottom * 8)), cellG = Math.max(0, Math.min(8, ge - fromBottom * 8))
122 const red = cellT > cellG, ch = EIGHTHS[cellT]!.repeat(barW)
123 row.push(seg(ch, red ? 'error' : 'success', i === trend.length - 1 && frame % 6 >= 3), seg(' '.repeat(colW - barW)))
124 })
125 out.push(row)
126 }
127 out.push(trend.map(([d]) => seg(pad(d.slice(3), colW), 'inactive')))
128 out.push(trend.map(([, g, r]) => seg(pad(g + r ? `${g}${r ? '/' + r : ''}` : '·', colW), r ? 'error' : 'inactive')))
129 return out
130}
131
132// 05 提交節奏(7 天):點陣曲線 + 面積點 + 每天數字
133export function commitCurve(commits: [string, number][], w: number, h: number, frame: number): Rows {
134 // 點從最左排到最右(the maintainer 10-14 04:5x「這個右邊有點空」:原本每天一欄、最後一天點在欄的第 1 格,右邊空一截);
135 // 日期與數字標在點的正下方(每個標籤從點那一格開始寫,最後一個靠右對齊)
136 const n = Math.max(1, commits.length), cols = Math.max(n * 3, w), last = cols - 2
137 const cx = commits.map((_, i) => (n === 1 ? 0 : Math.round((i * last) / (n - 1))))
138 const cv = new Canvas(cols, h), H = h * 4 - 1, mx = Math.max(1, ...commits.map(c => c[1]))
139 const xs = cx.map(c => c * 2), ys = commits.map(c => H - (c[1] / mx) * (H - 1))
140 for (let i = 0; i < xs.length; i++) for (let y = Math.ceil(ys[i]!); y <= H; y += 2) cv.set(xs[i]!, y, 'subtle')
141 for (let i = 1; i < xs.length; i++) cv.line(xs[i - 1]!, ys[i - 1]!, xs[i]!, ys[i]!, 'suggestion')
142 const live = frame % n
143 xs.forEach((x, i) => { cv.set(x, ys[i]!, i === live ? 'warning' : 'suggestion'); cv.set(x + 1, ys[i]!, i === live ? 'warning' : 'suggestion') })
144 const rows = cv.rows()
145 const under = (lab: (c: [string, number]) => string, color: (c: [string, number]) => string) => {
146 const segs: Seg[] = []; let at = 0
147 commits.forEach((c, i) => { const t = lab(c), tw = dispW(t), x = i === n - 1 ? Math.max(at, cols - tw) : Math.max(at, cx[i]!)
148 if (x > at) segs.push(seg(' '.repeat(x - at))); segs.push(seg(t, color(c))); at = x + tw })
149 return segs
150 }
151 rows.push(under(c => c[0].slice(3), () => 'inactive'))
152 rows.push(under(c => (c[1] ? String(c[1]) : '·'), c => (c[1] ? 'suggestion' : 'inactive')))
153 return rows
154}
155
156// 06 改動熱區:前 10 檔,紫色橫條 + 行數
157export function hbarRows(files: [string, number][], width: number): Rows {
158 // 行數尾巴照語系量寬:中文「 0 行」5 格是原本算好的,其他語言多出幾格就從條上扣
159 const unitX = Math.max(0, dispW(tr('lines_n', { n: 0 })) - 5)
160 const mx = Math.max(1, ...files.map(f => f[1])), nameW = Math.min(38, Math.max(16, width - 24)), bw = Math.max(8, width - nameW - 10 - unitX)
161 return files.map(([f, n]) => [seg(padL(f.length > nameW ? '…' + f.slice(-(nameW - 1)) : f, nameW) + ' '), seg(blocks(Math.max(1, (n / mx) * bw)), 'permission'), seg(tr('lines_n', { n }), 'inactive')])
162}
163export { pad, padL, seg, blocks }
164hooks/tiercharts.ts 173 lines1// 主管 / 監看 / 艦長三級的圖(對齊示範網頁 §12、§18):純函式,輸入 S1 的 charts.lead / qa / captain + frame,輸出 Rows。
2import { Canvas, scanRadar } from './charts'
3import type { Rows, Seg } from './charts'
4import { pad, padL, seg, blocks, dispW } from './engcharts'
5import type { SeatRow } from '../types'
6import { tr } from './i18n'
7
8const SHADE = ' ░▒▓█'
9const none = (t: string): Rows => [[seg(t, 'inactive')]]
10const hm = (t: number) => { const d = new Date(t * 1000); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` }
11
12// 掃描雷達(全艦 / 手下):光點 = 每一席,離中心越近 = 越剛有動作;紅 = 要處理(CTX ≥ 85 / 未讀 ≥ 3 / 狀態列忙碌太久沒更新)
13export const needsAttention = (s: SeatRow) => (s.ctx ?? 0) >= 85 || (s.unread ?? 0) >= 3 || !!s.stale
14export function seatRadar(seats: SeatRow[], w: number, h: number, frame: number): Rows {
15 const blips = seats.map((s, i) => ({ angle: (i / Math.max(1, seats.length)) * Math.PI * 2, r: Math.min(1, Math.max(0.12, Math.log10(1 + (s.idle_s ?? 3600)) / 4.5)), color: needsAttention(s) ? 'error' : s.idle_s != null && s.idle_s < 300 ? 'success' : 'suggestion' }))
16 const rows = scanRadar(blips, w, h, frame)
17 const legend = seats.map(s => seg(`${needsAttention(s) ? '●' : '○'}${s.code} `, needsAttention(s) ? 'error' : 'inactive'))
18 return [...rows, legend]
19}
20
21// 各席狀態表:席碼 / 引擎 / CTX 條(80 黃、85 紅)/ 未讀 / 狀態列
22export function seatTable(seats: SeatRow[], frame: number): Rows {
23 if (!seats.length) return none(tr('none_subs'))
24 // 欄寬照語系最長的表頭 + 1 格間隔,不小於原本(席 8、引擎 8、未讀 5);資料列跟著同寬
25 const WS = Math.max(8, dispW(tr('th_seat')) + 1), WE = Math.max(8, dispW(tr('th_engine')) + 1), WU = Math.max(5, dispW(tr('th_unread')) + 1)
26 return [[seg(pad(tr('th_seat'), WS), 'inactive'), seg(pad(tr('th_engine'), WE), 'inactive'), seg(pad('CTX', 18), 'inactive'), seg(pad(tr('th_unread'), WU), 'inactive'), seg(tr('th_status'), 'inactive')],
27 ...seats.map(s => { const c = s.ctx ?? null, n = c === null ? 0 : Math.max(0, Math.min(10, Math.round(c / 10))), col = c === null ? 'inactive' : c >= 85 ? 'error' : c >= 80 ? 'warning' : 'success'
28 return [seg(pad('@' + s.code, WS)), seg(pad(s.prov ?? '?', WE), 'inactive'), seg(blocks(n), col, c !== null && c >= 85 && frame % 4 >= 2), seg('░'.repeat(10 - n), 'subtle'), seg(' ' + pad(c === null ? '—' : `${Math.round(c)}%`, 6), col),
29 seg(pad(String(s.unread ?? '—'), WU), (s.unread ?? 0) >= 3 ? 'error' : 'text'), seg(s.stale ? tr('busy_stale') : (s.now_state ?? '—'), s.stale ? 'error' : 'inactive')] })]
30}
31
32// 漏斗:有東西交出去 → 全部進合包 → 全部上主線(每關剩幾條)
33export function funnelRows(f: number[], width: number, frame: number): Rows {
34 const [total = 0, delivered = 0, inBatch = 0, onMain = 0] = f.length === 4 ? [f[3]!, f[0]!, f[1]!, f[2]!] : f
35 const steps: [string, number, string][] = [[tr('fn_all'), total, 'subtle'], [tr('fn_out'), delivered, 'suggestion'], [tr('fn_batch'), inBatch, 'warning'], [tr('fn_main'), onMain, 'success']]
36 const mx = Math.max(1, total), bw = Math.max(10, width - 24)
37 return steps.map(([t, n, col], i) => { const len = Math.max(n ? 1 : 0, Math.round((n / mx) * bw)), indent = Math.round((bw - len) / 2)
38 return [seg(pad(t, 14)), seg(' '.repeat(indent)), seg(blocks(len), col, i === frame % 4 && n > 0), seg(` ${n}`)] })
39}
40
41// 等待時間分布:讀信要等幾分鐘的直方圖
42export function waitHist(mins: number[], frame: number): Rows {
43 if (!mins.length) return none(tr('hex_no_sample'))
44 const B: [string, number][] = [[tr('wb_1m'), 1], ['1-5', 5], ['5-15', 15], ['15-60', 60], [tr('wb_4h'), 240], [tr('wb_over'), Infinity]]
45 const CW = Math.max(6, ...B.map(b => dispW(b[0]) + 1)) // 每格寬:照最長的區間名 + 1(日韓「1-4時間」比中文長),至少 6
46 const cnt = B.map((b, i) => mins.filter(m => m < b[1] && m >= (i ? B[i - 1]![1] : 0)).length), mx = Math.max(1, ...cnt), H = 5, out: Rows = []
47 for (let r = 0; r < H; r++) out.push(cnt.map((c, i) => { const e = Math.round((c / mx) * H * 8), cell = Math.max(0, Math.min(8, e - (H - 1 - r) * 8)); return seg(' ▁▂▃▄▅▆▇█'[cell]!.repeat(CW - 1) + ' ', i >= 4 ? 'warning' : 'suggestion', i === frame % 6 && cell > 0) }))
48 out.push(B.map(b => seg(pad(b[0], CW), 'inactive'))); out.push(cnt.map(c => seg(pad(String(c), CW), 'inactive')))
49 return out
50}
51
52// 甘特:每個 Task 一條時間條(第一顆到最後一顆提交)
53export function ganttRows(rows: { title: string; owner?: string; status?: string; s: number; e: number }[], width: number, frame: number): Rows {
54 if (!rows.length) return none(tr('none_gantt'))
55 const s0 = Math.min(...rows.map(r => r.s)), e0 = Math.max(...rows.map(r => r.e)), span = Math.max(1, e0 - s0), tw = 22, bw = Math.max(10, width - tw - 2)
56 const col = (st?: string) => st === 'done' ? 'success' : st === 'blocked' ? 'error' : st === 'in_progress' ? 'suggestion' : 'warning'
57 return [...rows.slice(0, 10).map(r => { const a = Math.round(((r.s - s0) / span) * bw), b = Math.max(a + 1, Math.round(((r.e - s0) / span) * bw))
58 return [seg(pad(r.title, tw)), seg(' '.repeat(a)), seg('━'.repeat(b - a), col(r.status), r.status === 'in_progress' && frame % 4 >= 2), seg(' ' + (r.owner ?? ''), 'inactive')] }),
59 [seg(pad('', tw)), seg(pad(hm(s0), Math.floor(bw / 2)), 'inactive'), seg(padL(hm(e0), Math.ceil(bw / 2)), 'inactive')]]
60}
61
62// 通用橫條清單(測試熱點、時紅時綠、帕雷托、狀態列擋人)
63export function listBars(items: [string, number][], width: number, color: string, unit = '', cumulative = false): Rows {
64 if (!items.length) return none(tr('none_data'))
65 // 每段量顯示寬(中文兩格):名字欄 + 空一格 + 條 + 尾巴(數字、累計 %)剛好 ≤ width;原本名字照字數切、尾巴沒算,帕雷托多出一格被折行
66 const mx = Math.max(1, ...items.map(i => i[1])), tot = items.reduce((a, i) => a + i[1], 0), list = items.slice(0, 8)
67 let acc = 0
68 const nums = list.map(([, n]) => ` ${n}${unit}`), cums = list.map(([, n]) => { acc += n; return cumulative ? tr('cumulative', { n: Math.round((acc / Math.max(1, tot)) * 100) }) : '' })
69 const tails = nums.map((x, k) => x + cums[k])
70 const tw = Math.max(...tails.map(dispW)), nw = Math.min(30, Math.max(8, width - tw - 1 - 6)), bw = Math.max(1, width - nw - 1 - tw)
71 return list.map(([t, n], k) => {
72 const name = dispW(t) > nw ? pad(t, nw) : padL(t, nw), tail = tails[k]!
73 return [seg(name + ' '), seg(blocks(Math.max(1, (n / mx) * bw)), color), seg(cumulative ? nums[k]! : tail), ...(cumulative ? [seg(cums[k]!, 'inactive')] : [])]
74 })
75}
76export function flakyRows(items: [string, number, number][], width: number, frame: number): Rows {
77 if (!items.length) return none(tr('none_flaky'))
78 const nw = Math.min(30, Math.max(14, width - 22)), mx = Math.max(1, ...items.map(i => i[1] + i[2]))
79 return items.slice(0, 8).map(([t, g, r]) => [seg(padL(t.length > nw ? '…' + t.slice(-(nw - 1)) : t, nw) + ' '), seg(blocks(Math.max(1, (g / mx) * 10)), 'success'), seg(blocks(Math.max(1, (r / mx) * 10)), 'error', frame % 4 >= 2), seg(` ${tr('green_n', { n: g })} ${tr('red_n', { n: r })}`, 'inactive')])
80}
81// 巡查時間軸:過去 48 小時,每席一列,事件畫成點
82export function eventTimeline(events: { t: number; who: string; kind: string }[], width: number, now: number): Rows {
83 if (!events.length) return none(tr('none_events'))
84 const who = [...new Set(events.map(e => e.who))].slice(0, 8), bw = Math.max(20, width - 12), t0 = now - 48 * 3600
85 const rows: Rows = who.map(w => { const line = new Array(bw).fill('·'); events.filter(e => e.who === w).forEach(e => { const x = Math.min(bw - 1, Math.max(0, Math.floor(((e.t - t0) / (48 * 3600)) * bw))); line[x] = e.kind.includes('now') ? '◆' : '●' })
86 return [seg(pad('@' + w, 10)), ...line.map(c => seg(c, c === '·' ? 'subtle' : c === '◆' ? 'warning' : 'error'))] })
87 rows.push([seg(pad('', 10)), seg(pad('-48h', Math.floor(bw / 2)), 'inactive'), seg(padL(tr('now_axis'), Math.ceil(bw / 2)), 'inactive')])
88 return rows
89}
90
91// ── 艦長級 ──
92// 影子續跑累積:不靠 aib 要等(紅)vs 實際花(藍),點陣兩條線,中間差 = 少等
93export function shadowCumulative(runs: [number, number, number, number][], w: number, h: number): Rows {
94 if (!runs.length) return none(tr('none_runs'))
95 let a = 0, b = 0; const A: number[] = [], Bv: number[] = []
96 for (const r of runs) { a += r[1]; b += r[2]; A.push(a); Bv.push(b) }
97 const cv = new Canvas(w, h), W = w * 2 - 1, H = h * 4 - 1, mx = Math.max(1, a)
98 const xy = (i: number, v: number): [number, number] => [Math.round((i / Math.max(1, runs.length - 1)) * W), H - (v / mx) * H]
99 for (let i = 0; i < runs.length; i++) { const [x, ya] = xy(i, A[i]!), [, yb] = xy(i, Bv[i]!); for (let y = Math.ceil(ya); y < yb; y += 2) cv.set(x, y, 'success') }
100 for (let i = 1; i < runs.length; i++) { const p = xy(i - 1, A[i - 1]!), q = xy(i, A[i]!); cv.line(p[0], p[1], q[0], q[1], 'error'); const p2 = xy(i - 1, Bv[i - 1]!), q2 = xy(i, Bv[i]!); cv.line(p2[0], p2[1], q2[0], q2[1], 'suggestion') }
101 return [...cv.rows(), [seg(`■${tr('sc_without', { value: (a / 3600).toFixed(1) })} `, 'error'), seg(`■${tr('sc_actual', { value: (b / 3600).toFixed(1) })} `, 'suggestion'), seg(`■${tr('sc_saved', { value: ((a - b) / 3600).toFixed(1) })}`, 'success')]]
102}
103// 推包河流:每 6 小時一段,寬度 = 推了幾輪,顏色 = 結果
104export function riverRows(river: [number, number, number, number][], frame: number): Rows {
105 if (!river.length) return none(tr('none_ship'))
106 const mx = Math.max(1, ...river.map(r => r[1] + r[2] + r[3])), H = 6, out: Rows = []
107 for (let r = 0; r < H; r++) {
108 const fb = H - 1 - r
109 out.push(river.slice(-16).map(([, g, rd, o], i, arr) => { const tot = g + rd + o, e = Math.round((tot / mx) * H), cell = fb < e
110 const share = tot ? (fb / Math.max(1, e)) : 0, col = !cell ? 'subtle' : share < g / tot ? 'success' : share < (g + rd) / tot ? 'error' : 'warning'
111 return seg(cell ? '██ ' : ' ', col, i === arr.length - 1 && frame % 4 >= 2) }))
112 }
113 out.push([seg(`■${tr('rv_ok')} `, 'success'), seg(`■${tr('rv_red')} `, 'error'), seg(`■${tr('rv_other')}`, 'warning'), seg(tr('rv_note', { n: Math.min(16, river.length) }), 'inactive')])
114 return out
115}
116// 機器租約泳道:過去 48 小時,每個持有者一列,斜線 = 獨佔
117export function leaseLanes(lease: { s: number; e: number; who: string; ex?: boolean }[], width: number, now: number): Rows {
118 if (!lease.length) return none(tr('none_lease'))
119 const t0 = now - 48 * 3600, bw = Math.max(20, width - 16), who = [...new Set(lease.map(l => l.who))].slice(0, 8)
120 const rows: Rows = who.map(w => { const line = new Array(bw).fill(' '); lease.filter(l => l.who === w && l.e > t0).forEach(l => { const a = Math.max(0, Math.floor(((l.s - t0) / (48 * 3600)) * bw)), b = Math.min(bw, Math.max(a + 1, Math.ceil(((l.e - t0) / (48 * 3600)) * bw))); for (let x = a; x < b; x++) line[x] = l.ex ? '▚' : '█' })
121 return [seg(pad(w, 14)), ...line.map(c => seg(c === ' ' ? '·' : c, c === ' ' ? 'subtle' : w.includes('ship') || w.includes('推包') ? 'warning' : 'suggestion'))] })
122 rows.push([seg(pad('', 14)), seg(pad('-48h', Math.floor(bw / 2)), 'inactive'), seg(padL(tr('now_axis'), Math.ceil(bw / 2)), 'inactive')])
123 return rows
124}
125// 席位熱力格:每格 = 一席那一小時的信件往來,越亮越忙
126export function heatGrid(heat: { codes: string[]; rows: Record<string, number[]> }): Rows {
127 const mx = Math.max(1, ...heat.codes.flatMap(c => heat.rows[c] ?? []))
128 const rows: Rows = heat.codes.map(c => [seg(pad('@' + c, 8)), ...(heat.rows[c] ?? []).map(v => { const k = v ? Math.max(1, Math.min(4, Math.ceil(Math.sqrt(v / mx) * 4))) : 0; return seg(SHADE[k]!.repeat(2), k >= 3 ? 'warning' : k ? 'suggestion' : 'subtle') })])
129 rows.push([seg(pad('', 8)), seg(pad('-24h', 24), 'inactive'), seg(padL(tr('now_axis'), 24), 'inactive')])
130 return rows
131}
132// 瀑布:從「不靠 aib 要等多久」一段一段扣到實際
133export function waterfallRows(w: { without_h: number; reuse_h: number; select_h: number; shard_h: number; actual_h: number }, width: number, frame: number): Rows {
134 const steps: [string, number, number, string][] = [[tr('wf_without'), 0, w.without_h, 'error'], [tr('wf_reuse'), w.without_h - w.reuse_h, w.reuse_h, 'success'], [tr('wf_select'), w.without_h - w.reuse_h - w.select_h, w.select_h, 'success'], [tr('wf_shard'), w.actual_h, w.shard_h, 'success'], [tr('wf_actual'), 0, w.actual_h, 'suggestion']]
135 // 標籤欄照語系最長的字 + 1 格間隔(至少 16,中文不變);多出來的從條上扣,整列寬度不變
136 const LW = Math.max(16, ...steps.map(s => dispW(s[0]) + 1)), bw = Math.max(16, width - 30 - (LW - 16)), mx = Math.max(1, w.without_h), x = (v: number) => Math.round((v / mx) * bw)
137 return steps.map(([t, from, len, col], i) => [seg(pad(t, LW)), seg(' '.repeat(x(from))), seg(blocks(Math.max(1, x(len))), col, i === frame % 5), seg(tr('hours_v', { value: len.toFixed(1) }))])
138}
139// 和弦(終端版):誰寄給誰的矩陣,越亮越多
140export function chordMatrix(c: { names: string[]; m: number[][] }): Rows {
141 const mx = Math.max(1, ...c.m.flat()), n = c.names.map(x => x.slice(0, 5))
142 const HW = Math.max(8, dispW(tr('chord_head')) + 1) // 左上角表頭照語系量寬(韓文 9 格)
143 return [[seg(pad(tr('chord_head'), HW), 'inactive'), ...n.map(x => seg(pad(x, 6), 'inactive'))],
144 ...c.m.map((row, i) => [seg(pad(n[i]!, HW)), ...row.map((v, j) => { const k = v ? Math.max(1, Math.min(4, Math.ceil(Math.sqrt(v / mx) * 4))) : 0; return seg(pad(i === j ? '·' : (SHADE[k]!.repeat(2) + (v ? String(v) : '')), 6), k >= 3 ? 'warning' : k ? 'suggestion' : 'subtle') })])]
145}
146// 等 the maintainer 決定:卡片 + 等了多久(每秒在跑)
147export function decideCards(d: { since: number; text: string; from?: string }[], width: number, now: number): Rows {
148 if (!d.length) return none(tr('none_decide'))
149 return d.slice(0, 5).flatMap(x => { const s = Math.max(0, now - x.since), h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = Math.floor(s % 60), ring = Math.min(10, Math.floor((s / 86400) * 10))
150 return [[seg('◔◑◕●'[Math.min(3, Math.floor(ring / 3))]! + ' ', 'warning'), seg(`${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')} `, 'error'), seg(pad(x.text, Math.max(10, width - 14)))], [seg(' ' + (x.from ?? ''), 'inactive')]] })
151}
152// 主線心跳:過去 72 小時每小時幾顆提交
153export function beatRows(beat: number[], frame: number): Rows {
154 const mx = Math.max(1, ...beat), H = 4, out: Rows = []
155 for (let r = 0; r < H; r++) out.push(beat.map((v, i) => { const e = Math.round((Math.sqrt(v / mx)) * H * 8), cell = Math.max(0, Math.min(8, e - (H - 1 - r) * 8)); return seg(' ▁▂▃▄▅▆▇█'[cell]!, i === beat.length - 1 - (frame % 6) && cell ? 'warning' : 'success') }))
156 out.push([seg(pad('-72h', Math.floor(beat.length / 2)), 'inactive'), seg(padL(tr('now_axis'), Math.ceil(beat.length / 2)), 'inactive')])
157 return out
158}
159// 紅燈去向(終端版 sankey):哪一區 → 哪一天 → 之後有沒有轉綠
160export function sankeyRows(s0: { a: string; d: string; o: string; n: number }[]): Rows {
161 let s = s0
162 if (!s.length) return none(tr('none_reds'))
163 const by = (k: 'a' | 'd' | 'o') => { const m = new Map<string, number>(); s.forEach(x => m.set(x[k], (m.get(x[k]) ?? 0) + x.n)); return [...m.entries()].sort((p, q) => q[1] - p[1]) }
164 // S1 給機器鍵(fixed / open),畫面翻成人話;顏色看機器鍵,不看翻出來的字
165 const OUT = (o: string) => (o === 'fixed' ? tr('sk_fixed') : o === 'open' ? tr('sk_open') : o), stillRed = new Set([tr('sk_open')])
166 s = s.map(x => ({ ...x, o: OUT(x.o) }))
167 const A = by('a').slice(0, 6), D = by('d'), O = by('o'), rows = Math.max(A.length, D.length, O.length), out: Rows = [[seg(pad(tr('sk_area'), 18), 'inactive'), seg(pad(tr('sk_day'), 14), 'inactive'), seg(tr('sk_after'), 'inactive')]]
168 for (let i = 0; i < rows; i++) { const a = A[i], d = D[i], o = O[i]
169 out.push([seg(pad(a ? `${a[0]} ${blocks(Math.min(6, a[1]))}${a[1]}` : '', 18), 'error'), seg(pad(d ? `→ ${d[0]} ${d[1]}` : '', 14), 'warning'), seg(o ? `→ ${o[0]} ${o[1]}` : '', o && stillRed.has(o[0]) ? 'error' : 'success')]) }
170 return out
171}
172export const segText = (r: Seg[]) => r.map(s => s.text).join('')
173hooks/seat/captain.ts 56 lines1// 艦長視角(the maintainer 10-09「先做艦長的分角色畫面」):全艙每一席的 CTX、未讀、交接、閒置、手上派工,以及要處理的事。
2// 純函式:資料由 seat-info.py --fleet 量好,這裡只判斷與排版。
3export type FleetRow = {
4 code: string; role: string | null; provider: string | null; window: string | null; idle_s: number | null
5 ctx: number | null; ctx_stale: boolean | null; unread: number | null
6 handoff: { done: boolean; steps: string[] } | null
7 dispatch: { sender: string; age_min: number | null; msg: string } | null
8}
9export type Alert = { level: 'red' | 'amber' | 'info'; code: string; text: string }
10
11import { HANDOFF_PCT } from './seat-label' // 單一來源(the maintainer 10-13 17:5x 改 80%)
12import { tr } from '../i18n'
13// statusline 寫 .pct 的引擎;bot 席(grokbot / musebot / claudebot)量不到 CTX 是正常的,不報
14const CTX_PROVIDERS = new Set(['claude', 'codex', 'grok', 'agy'])
15
16export function ago(sec: number | null): string {
17 if (sec === null) return '?'
18 if (sec < 60) return `${Math.round(sec)}s`
19 if (sec < 3600) return `${Math.round(sec / 60)}m`
20 return `${(sec / 3600).toFixed(1)}h`
21}
22
23/** 要艦長處理的事,紅的在前。me = 艦長自己的席碼(自己的事也列,但標「你」)。 */
24export function fleetAlerts(rows: FleetRow[], me: string | null): Alert[] {
25 const out: Alert[] = []
26 for (const r of rows) {
27 const who = r.code === me ? tr('seat_you', { code: r.code }) : r.code
28 if (r.ctx !== null && r.ctx >= HANDOFF_PCT && !r.handoff?.done)
29 out.push({ level: 'red', code: r.code, text: tr('seat_f_ctx', { who, pct: Math.round(r.ctx) }) })
30 if ((r.unread ?? 0) >= 3) out.push({ level: 'amber', code: r.code, text: tr('seat_f_unread', { who, n: r.unread ?? 0 }) })
31 if (r.idle_s !== null && r.idle_s > 2 * 3600 && r.dispatch)
32 out.push({ level: 'amber', code: r.code, text: tr('seat_f_idle', { who, ago: ago(r.idle_s), sender: r.dispatch.sender.replace(/\(.*\)/, '') }) })
33 if (r.ctx_stale) out.push({ level: 'info', code: r.code, text: tr('seat_f_stale', { who }) })
34 if (r.ctx === null && CTX_PROVIDERS.has(r.provider ?? '')) out.push({ level: 'info', code: r.code, text: tr('seat_f_ctx_na', { who }) })
35 }
36 const rank = { red: 0, amber: 1, info: 2 } as const
37 return out.sort((a, b) => rank[a.level] - rank[b.level])
38}
39
40/** 表格順序:有紅的 → 有黃的 → 其他;同級依最近有動靜排前面。 */
41export function fleetOrder(rows: FleetRow[], alerts: Alert[]): FleetRow[] {
42 const worst = new Map<string, number>()
43 for (const a of alerts) worst.set(a.code, Math.min(worst.get(a.code) ?? 9, a.level === 'red' ? 0 : a.level === 'amber' ? 1 : 2))
44 return [...rows].sort((a, b) => (worst.get(a.code) ?? 9) - (worst.get(b.code) ?? 9) || (a.idle_s ?? 1e9) - (b.idle_s ?? 1e9))
45}
46
47/** 給艦長 AI 每回合讀的摘要(最多 5 條要處理的事)。 */
48export function fleetPromptLines(rows: FleetRow[], alerts: Alert[]): string[] {
49 const busy = rows.filter(r => r.idle_s !== null && r.idle_s < 600).length
50 const lines = [tr('seat_f_head', { n: rows.length, count: busy })]
51 const act = alerts.filter(a => a.level !== 'info').slice(0, 5)
52 if (act.length) { lines.push(tr('seat_todo')); for (const a of act) lines.push(` - ${a.text}`) }
53 else lines.push(tr('seat_f_none'))
54 return lines
55}
56hooks/seat/integrator.ts 69 lines1// 整合席視角(the maintainer 10-09「推完再做整合席的畫面」):待併的包、推包閘門、機器鎖、主線。純函式,資料由 seat-info.py --integrator 量好。
2import type { Alert } from './captain'
3import { tr } from '../i18n'
4
5export type Pkg = { path: string; branch: string; ahead: number | null; behind: number | null; dirty: number; last_age_s: number | null; subject: string }
6export type GateRun = { time: string; day: string; rc: number; ran: number; total: number; wall_s: number; early_stop: boolean }
7export type Lease = { state: string | null; owner: string | null; what: string | null; acquired_at: string | null; pid: number | null; queued: string[] }
8export type IntegInfo = {
9 repo: string | null; main_remote: string | null
10 main: { sha: string; age_s: number; subject: string } | null
11 packages: Pkg[]; gate: GateRun[]; lease: Lease | null
12 ship_running: { pid: number; elapsed_s: number; branch: string | null }[]; err: string[]
13}
14
15export function agoS(sec: number | null | undefined): string {
16 if (sec === null || sec === undefined) return '?'
17 if (sec < 60) return `${Math.round(sec)}s`
18 if (sec < 3600) return `${Math.round(sec / 60)}m`
19 if (sec < 86400) return `${(sec / 3600).toFixed(1)}h`
20 return `${(sec / 86400).toFixed(1)}d`
21}
22
23/** 租約拿了多久(秒);時間字串認不得回 null。 */
24export function leaseAge(l: Lease | null, nowMs: number): number | null {
25 if (!l?.acquired_at) return null
26 const t = Date.parse(l.acquired_at)
27 return Number.isNaN(t) ? null : Math.max(0, Math.round((nowMs - t) / 1000))
28}
29
30export function gateVerdict(g: GateRun): 'green' | 'red' | 'early_stop' | 'unverified' {
31 if (g.rc === 0) return 'green'
32 if (g.rc === 5 || g.early_stop) return 'early_stop'
33 if (g.rc === 4) return 'unverified'
34 return 'red'
35}
36
37/** 要整合席處理的事,紅的在前。me = 本席席碼(租約是自己的就不算別人佔著)。 */
38export function integAlerts(i: IntegInfo, me: string | null, nowMs: number): Alert[] {
39 const out: Alert[] = []
40 const l = i.lease
41 const la = leaseAge(l, nowMs)
42 if (l?.state === 'held' && l.owner && !(me && l.owner.endsWith(me)) && la !== null && la > 3600)
43 out.push({ level: 'amber', code: l.owner, text: tr('seat_i_lease', { who: l.owner, ago: agoS(la), what: l.what ?? '?' }) })
44 for (const r of i.ship_running) out.push({ level: 'info', code: r.branch ?? '?', text: tr('seat_i_ship', { ago: agoS(r.elapsed_s), branch: r.branch ? ` · ${r.branch}` : '' }) })
45 const last = i.gate[i.gate.length - 1]
46 if (last && gateVerdict(last) === 'red') out.push({ level: 'red', code: 'gate', text: tr('seat_i_gate_red', { d: last.day, ts: last.time, ran: last.ran, total: last.total }) })
47 for (const p of i.packages) {
48 const name = p.branch === 'HEAD' ? p.path.split('/').pop() ?? p.path : p.branch
49 if (p.dirty > 0 && (p.ahead ?? 0) > 0) out.push({ level: 'amber', code: name, text: tr('seat_i_dirty', { name, v1: p.ahead ?? 0, v5: p.dirty }) })
50 if ((p.behind ?? 0) > 20 && (p.ahead ?? 0) > 0) out.push({ level: 'amber', code: name, text: tr('seat_i_behind', { name, n: p.behind ?? 0 }) })
51 if ((p.ahead ?? 0) > 0 && p.last_age_s !== null && p.last_age_s > 3 * 86400) out.push({ level: 'info', code: name, text: tr('seat_i_cold', { name, ago: agoS(p.last_age_s) }) })
52 }
53 const rank = { red: 0, amber: 1, info: 2 } as const
54 return out.sort((a, b) => rank[a.level] - rank[b.level])
55}
56
57/** 給整合席 AI 每回合讀的摘要。 */
58export function integPromptLines(i: IntegInfo, alerts: Alert[]): string[] {
59 const waiting = i.packages.filter(p => (p.ahead ?? 0) > 0)
60 const lines = [tr('seat_i_head', { remote: i.main_remote ?? '?', sha: i.main?.sha ?? '?', ago: agoS(i.main?.age_s), n: waiting.length,
61 items: waiting.map(p => `${p.branch} +${p.ahead}`).slice(0, 6).join(tr('seat_sep')) || tr('seat_none'),
62 state: i.lease?.state === 'held' ? tr('seat_i_held', { who: i.lease.owner ?? '?' }) : tr('seat_i_free') })]
63 const act = alerts.filter(a => a.level !== 'info').slice(0, 5)
64 if (act.length) { lines.push(tr('seat_todo')); for (const a of act) lines.push(` - ${a.text}`) }
65 else lines.push(tr('seat_todo_none'))
66 lines.push(tr('seat_i_rule'))
67 return lines
68}
69hooks/seat/dev.ts 38 lines1// dev 視角:我的工作樹(分支名或路徑含席碼)改了什麼、對主線領先/落後、沒 commit 的檔、這棵樹最近一次 aib test 的結果。
2// 純函式,資料由 seat-info.py --dev 量好。
3import type { Alert } from './captain'
4import { tr } from '../i18n'
5
6export type DevTests = { green: number; red: number; rows: number; stale?: number; last: string | null; red_names?: string[] }
7export type DevTree = { path: string; branch: string; ahead: number | null; behind: number | null; dirty: number; dirty_files: string[]
8 files: number; ins: number; dels: number; last_age_s: number | null; subject: string; tests: DevTests }
9export type DevInfo = { trees: DevTree[]; err: string[] }
10
11export function devAlerts(d: DevInfo): Alert[] {
12 const out: Alert[] = []
13 for (const t of d.trees) {
14 const name = t.branch === 'HEAD' ? t.path.split('/').pop() ?? t.path : t.branch
15 if (t.tests.red > 0) out.push({ level: 'red', code: name, text: tr('seat_d_red', { name, n: t.tests.red, items: (t.tests.red_names ?? []).join(tr('seat_sep')) }) })
16 if ((t.ahead ?? 0) > 0 && t.tests.rows === 0)
17 out.push({ level: 'amber', code: name, text: (t.tests.stale ?? 0) > 0
18 ? tr('seat_d_stale', { name, n: t.tests.stale ?? 0 })
19 : tr('seat_d_untested', { name, n: t.ahead ?? 0 }) })
20 if (t.dirty > 0) out.push({ level: 'amber', code: name, text: tr('seat_d_dirty', { name, n: t.dirty, items: t.dirty_files.slice(0, 3).join(tr('seat_sep')) + (t.dirty > 3 ? '…' : '') }) })
21 if ((t.behind ?? 0) > 20) out.push({ level: 'info', code: name, text: tr('seat_d_behind', { name, n: t.behind ?? 0 }) })
22 }
23 if (d.trees.length === 0) out.push({ level: 'info', code: '-', text: tr('seat_d_none') })
24 const rank = { red: 0, amber: 1, info: 2 } as const
25 return out.sort((a, b) => rank[a.level] - rank[b.level])
26}
27
28export function devPromptLines(d: DevInfo, alerts: Alert[]): string[] {
29 const trees = d.trees.map(t => tr('seat_d_tree', { branch: t.branch, v1: t.ahead ?? '?', v2: t.behind ?? '?', files: t.files, v3: t.ins, v4: t.dels,
30 v5: t.dirty ? tr('seat_d_unsaved', { n: t.dirty }) : '', green: t.tests.green, red: t.tests.red, v6: t.tests.stale ? tr('seat_d_old', { n: t.tests.stale }) : '' }))
31 const lines = [tr('seat_d_head', { n: d.trees.length, items: trees.join(tr('seat_sep2')) || tr('seat_none') })]
32 const act = alerts.filter(a => a.level !== 'info').slice(0, 5)
33 if (act.length) { lines.push(tr('seat_todo')); for (const a of act) lines.push(` - ${a.text}`) }
34 else lines.push(tr('seat_todo_none'))
35 lines.push(tr('seat_d_rule'))
36 return lines
37}
38