SLOPSHOPPER

aib-boards

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

newpanebandcommandtoaststatus
v0.2.0Apache-2.0updated 2026-10-15ez2app/ez2-aib/extensions/aib-boards
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · aib-boards
│ ┃ !!mod.aib_boards.pane_title!! ✕ › fix the failing auth test and add an audit log call │ ┃ [ !!mod.aib_boards.tab_today!! ] [ !!mod.aib │ ┃ !!mod.aib_boards.na_err!! ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /aib-boards │ ⎿ aib-boards: !!mod.aib_boards.opened!! │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ aib-boards: !!mod.aib_boards.seat_st_na!!

Draws

Pane · !!mod.aib_boards.pane_title!!
[ !!mod.aib_boards.tab_today!! ] [ !!mod.aib_boards.tab_proj !!mod.aib_boards.na_err!!
README

aib-boards — 每一席的電腦(Claude Code mod,免費)

/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 秒快取。
  • 動畫 0.4 秒一格,打開後動 20 秒自停(一直重畫會把對話拉回底部);按按鈕、重新整理、在面板上滾再動 20 秒;「動畫:省電」全停。
  • 要捲對話:關掉面板(滾輪在面板上 = 捲面板,mod 無法轉交給對話)。
  • 席位核心(10-14 從 aib-seat 搬來,aib-seat 已拿掉,設計稿 §24):每回合寫進系統提示的席位身份(艦長 / 整合 / dev / qa 另加摘要)、狀態列一行、85% 交接提醒、守衛擋下提示、新信卡片(輸入框上方,狀態色、可展開、✕ 才關)。/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。

Source 17 files
hooks/register.tsx 634 lines
1// 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}
634
hooks/charts.ts 237 lines
1// 原本從 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}
237
hooks/lib.ts 297 lines
1// 純計算(不碰 $):分頁、待辦分組、燈號顏色、時間。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}
297
hooks/chartset.ts 84 lines
1// 每一級有哪些圖、預設放哪幾張(設計稿 §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}
84
hooks/i18n.ts 44 lines
1// 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
44
hooks/seatcore.tsx 198 lines
1import { 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}
198
hooks/ready.ts 61 lines
1// 語系 + 字典(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
61
hooks/engcharts.ts 164 lines
1// 工程級 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 }
164
hooks/tiercharts.ts 173 lines
1// 主管 / 監看 / 艦長三級的圖(對齊示範網頁 §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('')
173
hooks/seat/captain.ts 56 lines
1// 艦長視角(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}
56
hooks/seat/integrator.ts 69 lines
1// 整合席視角(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}
69
hooks/seat/dev.ts 38 lines
1// 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