控制台:輸入框上方的 Session/Tool/MCP/流程/記憶/額度 按鈕與側邊面板(未結案/暫時結案/已結案 session 點選切換、工具清單、MCP 伺服器啟用/停用、計畫進度與本 session 即時活動、記憶檔檢視與複製路徑、Claude/Codex…

在 Claude Code 輸入框上方放一列按鈕,點開側邊面板「控制台」,有六個分頁(版本 0.7.0);另外每則助理回覆下方會列出路徑與網址的複製按鈕(S5):
planned/ 最近一份計畫書的節點進度,下半是本 session 即時的工具與 subagent 活動(S3;S4 起工具目標會先遮罩秘密)。<設定目錄>/projects/<工作目錄轉成的目錄名>/,目錄名是把非英數字元全換成 -)。custom-title 第一個 |/| 之前的前綴(去頭尾空白、整段完全相等):進行中、待驗收 → 未結案;暫結案、暫時結案 → 暫時結案;結案 → 已結案(所以「暫結案 | x」不會落到已結案);沒有狀態前綴的不列。/resume <sessionId>;不開新終端機分頁。| 入口 | 作用 |
|---|---|
輸入框上方的 Session、Tool、MCP、流程、記憶、額度 按鈕 | 開啟(或切到)控制台並顯示該分頁 |
/panel | 開啟控制台,顯示上一次的分頁 |
/panel auto on、/panel auto off | 設定「開新對話、/clear、resume、fork 時自動開面板」,預設開;設定存在外掛的 $.store(key autoOpen),指令會回覆確認 |
/panel copybar on、/panel copybar off | 設定「助理回覆下方的複製按鈕」是否顯示,預設開;設定存在 $.store(key copyBar),啟動與 /clear、resume、fork 時讀進外掛狀態,指令會回覆確認 |
/sessions | 開啟控制台並切到 Session 分頁 |
#FFF3B0、使用中的分頁底 #F2C94C(字粗體),字一律深色 #3B3000。不再跟 /theme、/color 變(色碼寫死,常數是 hooks/register.tsx 的 CHIP_BG、CHIP_FG、CHIP_ACTIVE_BG);Button 沒有文字色屬性,所以每顆按鈕都用子層 Text 指定深色字,按鈕的 label、key、行為不變。未結案(N)、暫時結案(N)、已結案(N)(使用中的顯示成 […]),各列最近 10 個、依 transcript 修改時間新到舊;點列切換(含已結案的,用來重開舊對話)。腳本一次掃完、三組一起回傳,切換子分頁不重跑腳本;「重新整理」(快捷鍵 r)會重讀並保留目前子分頁;/sessions 一律落在「未結案」。目前這個 session 不列出。子分頁按鈕不設快捷鍵(與其他分頁按鈕一致)。$.tool.list(),分成「內建」與「MCP」(名稱以 mcp__ 開頭、或引擎標為 MCP)兩組,每列「名稱+說明第一行」,依面板欄寬截斷;上方顯示總數。沒有任何動作。$.session.usage({ breakdown: "summary" }) 的 MCP 工具清單;已停用的伺服器來自設定檔(見下)。按鈕執行:/mcp disable <名稱>/mcp enable <名稱>分頁列與按鈕列各多一個「流程」(key tab-flow/band-flow,仍無快捷鍵)。分兩區:
上:計畫進度
<session root>/planned/ 直接底下的 *.md(complete/、shelved/ 等子目錄與非 .md 檔不看),依修改時間新到舊取前 5 份,預設選最新一份。‹/›(plan-prev/plan-next)在這 5 份之間切換(到頭繞回,旁邊顯示 1/5);「重新整理」(flow-refresh,快捷鍵 r)重讀目前這份並保留選擇。計畫:<檔名> 已執行 a/共 n(太窄時縮檔名、保留計數)。 圍欄區塊內的行;找 [ID](英數字與連字號),其後用全形「|」切成 名稱|agent|狀態`,狀態以「已執行/未執行/有問題」開頭。欄位不全、狀態不是這三個詞、ID 不合法的行直接忽略;多個區塊依檔案順序合併。| 符號 | 意義 |
|---|---|
✔ P0 … | 已執行 |
▶ P4 … ← 目前 | 第一個未執行的節點(不含「未觸發」分支) |
○ P5 … | 其餘未執行 |
✘ P5 …(有問題) | 有問題 |
· W2 …(未觸發) | 狀態備註含「未觸發」的分支(不算待辦、不會成為「目前」) |
節點名稱後面接淡色的 |<agent>,依面板欄寬截短。
turn.complete(只在上次顯示的是流程分頁、且控制台還開著時)。讀檔失敗只顯示一行錯誤,不影響其他區塊。下:本 session 即時活動
本 session:工具 x 次(MCP y)・agent z 個;沒有活動時顯示「外掛載入後的活動會顯示在這裡(載入前的不會補上)」。第 12 輪 ─┬─ Bash node scripts/review-check.mjs 3.2s
├─ Agent executor(sonnet)─┬─ Read register.tsx 40ms
│ └─ Bash claude plugin test 2.5s
└─ MCP brave-search › brave_web_search 1.1s ✘
mcp__<伺服器>__<工具> 顯示成 MCP <伺服器> › <工具>;失敗(工具回報錯誤、被拒、丟錯)加 ✘;還在執行的顯示 …。
turn.start(只算主迴圈,算出輪次)、tool.call、agent.spawn、turn.complete(見上)四個 hook,一律原樣 return next(e)、不改寫、不阻擋;記錄寫失敗也不影響工具。Agent 工具本身由 agent.spawn 記成一個 agent 節點,不重複記成工具。tool.call 帶有 agentId(型別檔 AgentLoop),agent.spawn 的回覆帶同一個 agentId,兩者相同就把該工具掛在那個 agent 底下;巢狀 spawn 用 parentAgentId。對不到 agent 紀錄的(例如 spawn 回覆前就先呼叫)平放在該輪頂層。/clear、resume、fork 時整個重置。分頁列與按鈕列各多一個「記憶」(key tab-memory/band-memory,仍無快捷鍵)。資料只在切到這個分頁、或按「重新整理」(memory-refresh,快捷鍵 r)時讀,不是每次重畫都讀。
已載入的記憶檔
$.session.usage({ breakdown: "summary" }) 的 context.breakdown.memoryFiles(每筆有絕對路徑、類型、估算 tokens;summary 只在本機估算、不送請求)。與 MCP 分頁共用同一個呼叫函式。Project、User、Local、Managed、AutoMem,原樣顯示)與 tokens(例如 1.2k tokens)、絕對路徑、兩個按鈕「看內容」(mem-view:<序號>)與「複製路徑」(mem-copy:<序號>)。C:/…、F:/…)的才把 / 換成反斜線,其他路徑(例如 /home/…)原樣。記憶資料夾
$.fs.list 列出該目錄直接底下的 *.md,排除已在上面清單的檔案,依檔名排序;區塊標題「記憶資料夾(未載入,被引用時才讀)M 個」,每列同樣有「看內容」(memdir-view:<序號>)與「複製路徑」(memdir-copy:<序號>)。.md 檔不列。看內容、複製路徑
$.fs.read 讀檔,面板切到檢視畫面——第一行是絕對路徑,接著「返回」(mem-back)與「複製路徑」(mem-view-copy),下面是檔案原文逐行顯示。原文不遮罩(這是使用者讀自己的檔案,刻意如此);只把控制字元(含 ANSI 跳脫)換成空白、tab 換成兩個空白,避免弄亂畫面;超過 2000 行只顯示前 2000 行並註明還有幾行。讀取失敗顯示「讀取失敗:…」並停在清單。$.ui.copy({ text: <Windows 絕對路徑>, surface }),surface 取自按鈕 onPress 的引數(press.surface)。成功顯示「✔ 已複製:<路徑>」,失敗顯示「✘ 複製失敗(<原因>):<路徑>」(原因是引擎回的 no-surface、no-clipboard、refused,或丟出的錯誤訊息),路徑仍在畫面上可手動選取。分頁列與按鈕列各多一個「額度」(key tab-quota/band-quota,無快捷鍵)。三個區塊:
額度預測
Claude
週用量 █████████░░░░│░ 62% ⚠15時後95%
5 小時 ██░░░░░░░░░░░│░ 18% 資料不足
Codex 最後更新 12 分鐘前
週用量 ███░░░░░░░░░░│░ 25% 重置前安全
5 小時 ████░░░░░░░░░│░ 27% 資料不足
AGY 無額度與 token 資料
使用次數:今天 3|近 7 天 11(依本機對話資料夾)
error、≥ 70 用 warning、其他用 success(引擎的主題色名稱)。turn.complete 用 $.session.usage() 取 rateLimits(five_hour、seven_day 的 percentUsed、resetsAt),記一筆讀數;不必開著分頁,否則沒有歷史可算速度。subagent 的 turn.complete 不取樣。scripts/read-quota.mjs 找 ~/.codex/sessions/YYYY/MM/DD/ 最新的 rollout-*.jsonl,只讀檔尾 512 KB,取最後一筆 rate_limits(頂層或 payload 底下都認);primary 當 5 小時、secondary 當週。最新檔沒有讀數時往較舊的檔案最多再找 5 個。讀數時間用紀錄檔的修改時間。~/.gemini/antigravity-cli/brain/、~/.gemini/antigravity/brain/ 做 readdir+stat,數修改時間在今天、近 7 天的子資料夾(一個資料夾算一次),不讀任何檔案內容。AGY 的本機紀錄沒有額度或 token 欄位,CLI 也沒有查額度的指令,所以只能給次數;要剩餘額度得讀 OAuth 憑證,不在本外掛範圍。{ codex: { primary, secondary, observedAt } | null, codexReason?, agy: { today, week } | null, agyReason? };對話內容、指令、帳號、credits 一律不輸出(有測試)。resetsAt 相差 ≤ 120 秒算同一個重置週期;resetsAt 改變或百分比下降 ≥ 5 點算新週期,舊資料不參與。quota-refresh,快捷鍵 r)、分頁開著時每次主迴圈 turn.complete 才跑腳本(逾時 10 秒)。<CLAUDE_CONFIG_DIR,未設則 ~/.claude>/plugin-data/session-picker/quota-history.json,格式 { samples: [{ source, window, pct, resetsAt, at }] },只有這五個欄位。有新讀數且距上次寫入 ≥ 2 分鐘才寫;寫前先讀檔再合併去重,多個同時開著的 session 不會互相蓋掉。每個來源與視窗只留最近 7 天、最多 500 筆;相鄰同值且相隔不到 5 分鐘的不重複存。檔案損壞時顯示「歷史檔格式不符,改用本次記錄重建」並以這次的記錄覆寫;讀寫失敗只顯示一行淡色字,預測改用記憶體中的讀數。刪掉這個檔不影響其他功能。rateLimits 是空的(不是訂閱方案)顯示「此帳號沒有額度視窗」。腳本失敗顯示「讀取 Codex/AGY 失敗(…)」,其他區塊照常。每則助理回覆(ui.render 的 AssistantMessage)下方,若文字裡有 Windows 絕對路徑、網址或 `` 圍欄裡的指令,就多一列複製按鈕。做法:先 next(e) 取得引擎的繪製,原樣放在上面,再把一列按鈕疊在它下面;e.props 一個字都不改(isSummary、onScreen` 照引擎給的)。只改畫面,不改存下來的訊息(ctrl+o 看到的仍是原文)。
isSummary)、/panel copybar off、或沒有任何目標——這三種情況直接把 next(e) 的結果(同一個物件)回傳。:\ 或 :/,後面接路徑字元,遇到空白、反引號、引號、<>|*? 與中文標點(,。、;:「」『』()!?)就停;結尾的 .,;:)]} 與全形標點剝掉。原樣保留(不轉換斜線)。磁碟代號前面不可緊接英數字(xF:\foo 不算),中文字緊接在前面則算(「在F:\x」)。F: 單獨、12:30 這類時間不算。http://、https:// 開頭,遇到空白、反引號、引號、<> 與中文標點停;結尾的 .,;:)]}> 剝掉。網址裡的 C:/… 不會另外當成路徑。 `text 區塊與行內反引號裡的路徑、網址也抓。 圍欄區塊(\r\n 與 \n` 都認、圍欄可縮排)。先去掉頭尾空白行、再去掉非空白行共同的最小縮排,之後依序判斷:# 標題(先於單行規則判斷):標題當標籤,複製內容是其餘各行(去掉標題後緊接的空白行);只有標題、沒有其餘內容就沒有按鈕。/ 開頭:標籤是不含斜線的指令名稱(/rename 結案 | x|y → rename、/copy 2 → copy;名稱是空的就用那個字本身),複製整行。\ 或 / 的字只留最後一段(node harness/cli/review-loop.mjs a.json → node review-loop.mjs、npm.cmd test → npm.cmd test),複製整行。指令(N 行),複製整段內容。# 標題;按下去複製的不含這一行。主機[:埠] 加 / 加最後一個非空路徑區段(例如 127.0.0.1:5181/n5.html、github.com/paneline),查詢字串、片段與帳密(@ 之前)不進標籤;指令按鈕見上。標籤先套 maskSecrets(見下方「流程分頁的秘密遮罩」)再截到 28 欄(超過加 …),前面加 ⧉ 。$.ui.copy({ text: <訊息裡的原文>, surface: press.surface }),標籤遮罩不影響複製內容(含 token 的網址會完整複製)。狀態列顯示在那一則訊息的按鈕下面:成功「✔ 已複製:<原文>」、失敗「✘ 複製失敗(<原因>):<原文>」。狀態列顯示的目標會經 maskSecrets 遮罩(例如 token=*),但實際複製的文字是原文、不遮罩**;多行指令的狀態列只有一行:遮罩後的第一個非空行加 …(共 N 行)(copyShownOf);記憶分頁複製路徑的狀態列同樣遮罩顯示、複製原路徑。全外掛只留最近一筆狀態,按了另一則訊息的按鈕,前一則的狀態就消失。訊息以內容的 FNV-1a 雜湊(messageKeyOf)辨識;回覆還在串流、文字變動時,狀態不會跟著帶過去。copy:<訊息鍵>:<序號>,沒有快捷鍵。複製按鈕用 plain 外觀(沒有 [ ] 外框),配色與其他按鈕相同。\\server\share)、相對路徑、file://、ftp:// 都不抓;只對 AssistantMessage 文字比對,不抓工具輸出;路徑末尾的 ) 一律剝掉(C:\a\f(1) 會變成 C:\a\f(1);沒有結尾的 `` 圍欄會被忽略、維持一般文字(不出指令按鈕);指令按鈕只認 `` 圍欄(不認縮排四格的程式碼區塊或行內反引號),也不判斷內容是不是真的指令。ui.copy 的 OSC 52 路徑不回報成功與否(見記憶分頁),貼不出來時請直接選取畫面上的路徑。流程分頁的工具目標(以及 agent 說明)在截短之前先經過 maskSecrets(hooks/flow-model.ts),所以外掛狀態與畫面都不會留下秘密本體;秘密部分換成 ***,保留可辨識的前綴:
| 樣式 | 遮罩結果範例 |
|---|---|
sk- 加 8 個以上英數、_、-(含 sk-ant-…、sk-proj-…;前面不可緊接英數字,所以 task-… 不受影響) | sk-ant-api03-ABCDEFGH… → sk-*** |
ghp_/gho_/ghu_/ghs_/ghr_ 加 20 個以上英數 | ghp_*** |
github_pat_ 加 20 個以上英數、_ | github_pat_*** |
xoxa-/xoxb-/xoxp-/xoxr-/xoxs- 加 10 個以上 | xoxb-*** |
AKIA 加 16 個大寫英數 | AKIA*** |
AIza 加 30 個以上 | AIza*** |
Bearer <8 個以上字元>(不分大小寫) | Bearer *** |
Authorization: <值>(含 Bearer/Basic/Token 前綴) | Authorization: *** |
鍵名為 api_key/api-key/apikey/token/secret/password/passwd/pwd/auth/authorization 的 鍵=值、鍵: 值、JSON "鍵": "值",以及 --鍵 值、--鍵=值 | API_KEY=*、password: *、--token *** |
node scripts/review-check.mjs、git status、檔案路徑、task-management、--max-tokens 4096、author=bob、cat /etc/passwd。https://帳號:密碼@主機 內嵌的帳密、鍵名不在清單上的變數都不會被遮;鍵名後面再接字(例如 token_file=…)也不在比對範圍。PWD=/work 這種環境變數會被當成 pwd 一併遮掉(過度遮罩,不影響安全)。遮罩只作用在流程分頁;記憶分頁看內容的原文不遮罩。資料只在分頁顯示時(以及按「重新整理」或操作後)讀取,不是每次重畫都讀。
.claude-plugin/plugin.json、types/index.d.ts:外掛清單與狀態型別。hooks/hooks.json、hooks/register.tsx:註冊指令、按鈕列、面板與六個分頁、自動開啟、即時活動追蹤 hook、額度取樣與歷史檔、記憶分頁的讀檔與複製、助理回覆下方的複製按鈕。hooks/copy-model.ts:複製按鈕的純函式(路徑、網址與圍欄指令的抽取、標籤、狀態列顯示文字、訊息鍵;不碰 $)。hooks/flow-model.ts:流程與記憶分頁的純函式(計畫書解析、目標摘要、秘密遮罩、活動紀錄、文字樹、路徑與 tokens 顯示;不碰 $,驗證器規定 $ 不跨檔)。scripts/list-sessions.mjs:唯讀 Node 腳本(只用內建模組),找出每個 transcript 最後一筆標題,輸出 { ok, dir, groups: { open, paused, closed } }(每組至多 --limit 個,三組都滿就提前停止掃描)。transcript 可達 20 MB 以上,超過引擎 $.fs.read 的 4 MiB 上限,所以不在 hook 內讀檔。scripts/list-sessions.test.mjs:腳本測試(node --test)。hooks/quota-model.ts:額度分頁的純函式(分段、速度、預測、歷史合併與上限、腳本輸出驗證、顯示字串;不碰 $)。scripts/read-quota.mjs:唯讀 Node 腳本(只用內建模組),讀 Codex 紀錄檔尾的 rate_limits 與 AGY 對話資料夾數,輸出規定欄位的 JSON;錯誤只寫原因、結束碼一律 0。scripts/read-quota.test.mjs:讀取腳本測試(node --test,在暫存目錄造假資料)。tests/quota-model.test.ts:額度純函式測試。tests/quota.test.tsx:額度分頁(顯示、腳本失敗、歷史檔讀寫與合併、取樣時機、寫入間隔、設定目錄)測試。tests/register.test.tsx:Session 分頁測試(S1)。tests/panel.test.tsx:按鈕列、按鈕配色(淡黃色)、分頁、Tool、MCP、自動開啟測試。tests/flow-model.test.ts:計畫書解析、目標摘要、文字樹、200 筆上限、秘密遮罩、路徑顯示的純函式測試。tests/flow.test.tsx:流程分頁(計畫切換、讀檔失敗、即時活動、不存輸入全文、遮罩、重置)測試。tests/copy-model.test.ts:路徑與網址抽取、圍欄指令按鈕(各判斷規則、CRLF、縮排、未結尾圍欄、混合順序與去重、8 顆上限)、標籤、遮罩、狀態列顯示文字、訊息鍵的純函式測試。tests/copybar.test.tsx:回覆下方複製按鈕(引擎繪製保留、摘要與無目標原樣回傳、開關、複製與狀態、失敗原因、8 顆上限、多行標題指令按鈕、配色)測試。tests/memory.test.tsx:記憶分頁(已載入清單、記憶資料夾、看內容、複製路徑、不寫檔)測試。scripts/review-check.mjs:依序跑 validate、plugin test、兩支腳本測試。tsconfig.json:TypeScript 檢查設定。先切到要工作的資料夾(Session 分頁列的是這個資料夾的 session),再在 PowerShell 執行:
claude --plugin-dir <外掛資料夾的完整路徑>
進入後輸入框上方會出現 控制台 Session Tool MCP 流程 記憶 額度。
open-claude-with-panel.cmd:切到工作資料夾、確認以 API 金鑰登入、用 --plugin-dir 載入本外掛。
<repo>\projects\claude-session-picker 配置)。CLAUDE_WORKDIR 為你的工作資料夾,例如 PowerShell:[Environment]::SetEnvironmentVariable('CLAUDE_WORKDIR','D:\work','User'),重開終端機後雙擊即可。.claude-plugin/types/ 是 Claude Code 在本機以 /plugin-types 產生的型別檔,不放進版本庫(見 .gitignore)。Tab 移動焦點、Enter 按下按鈕、r 重新整理、Esc 關閉。ctrl+x ctrl+a 收合(型別檔註解)、ctrl+x tab 把焦點移到 band 或面板(型別檔註解)。<設定目錄>/projects/<目錄名>/*.jsonl 的檔案屬性與標題行(唯讀;設定目錄為環境變數 CLAUDE_CONFIG_DIR,未設則 ~/.claude)。$.session.usage(MCP 工具清單)、$.tool.list(),以及 <CLAUDE_CONFIG_DIR 或家目錄>/.claude.json 一個檔,只取出本專案的 disabledMcpServers、disabledMcpjsonServers 兩個名單裡的伺服器名稱;其他欄位(含 env、headers、args、token)不顯示、不記錄、不存。$.fs.list/$.fs.read 讀 <session root>/planned/*.md(只讀);工具與 agent 活動來自 tool.call、agent.spawn、turn.start、turn.complete 事件(見上,只存摘要)。$.session.usage(memoryFiles)、$.fs.list 列 AutoMem 所在資料夾、$.fs.read 讀你按「看內容」的那一個檔(只讀);「複製路徑」用 $.ui.copy,只複製路徑文字。AssistantMessage 的文字(樣式比對)與 $.store 的 copyBar;按下去用 $.ui.copy 複製該則訊息裡的路徑、網址或圍欄指令原文,不碰檔案、不執行程序(指令只是複製,不會被執行)。$.session.usage(rateLimits);scripts/read-quota.mjs 讀 ~/.codex/sessions/ 最新紀錄檔的檔尾、列 AGY 對話資料夾(見上);讀自己的歷史檔。CLAUDE_CONFIG_DIR、HOME、USERPROFILE(只為了找上述設定檔與歷史檔)。<CLAUDE_CONFIG_DIR 或 ~/.claude>/plugin-data/session-picker/quota-history.json(只有讀數的五個欄位,見上);autoOpen、copyBar 開關存在引擎的 $.store,不是檔案。唯一對外動作是 /resume <id>、/mcp enable|disable <名稱>(由使用者按鈕觸發)與本外掛的兩支 node 腳本(argv、無 shell);記憶分頁只多一個動作:把路徑文字複製到剪貼簿。$.http、$.model、$.settings、$.session.messages。$.command.run({ command: "resume", args }) 觸發內建 /resume 並在原 session 內切換(S1 已由使用者實機確認可用);$.command.run({ command: "mcp", args: "disable <名稱>" }) 是否被引擎接受尚未實機確認,被拒絕時面板顯示「MCP 指令失敗:…」,不改動其他東西。session.usage 的 MCP 工具清單裡),所以連線失敗或還在連線的伺服器不會列出。.claude.json 裡本專案的 disabledMcpServers/disabledMcpjsonServers(paneline 同法);讀不到檔案(不存在、超過 4 MiB、被拒)時面板顯示提示,已停用的伺服器可能沒列出。turn.complete 才會自動重讀計畫書(計畫書在一輪中途改變,要等到那輪結束或按「重新整理」才看得到)。subagent 的歸屬以 agentId 相符為準,agent.spawn 回覆前的短暫空檔內該 agent 的工具會先平放在頂層。tsc,型別只靠驗證器與測試執行把關。$.session.usage 的 memoryFiles 在實機是否含 AutoMem、剪貼簿在你的終端機是否真的寫入,都要實測;ui.copy 的 OSC 52 路徑不回報成功與否(見上)。plain 的按鈕(面板內的「重新整理」「看內容」等)在終端機會畫 [ ] 外框,外框字元的顏色由引擎決定,不一定是深色;請實機看可讀性。$.session.usage() 的 rateLimits 在實機每輪是否都有值、Codex 紀錄格式日後是否改版(找不到欄位時顯示「讀不到」),都要實測;預測只是線性估計。.claude-plugin/types/claude-code/index.d.ts)。node scripts/review-check.mjshooks/register.tsx 1417 lines1import { atom, read, update } from "claude-code";
2import type { ElementTable, EngineInterface, Register, RenderSurface, ToolCallResult, UiPressArgument } from "claude-code";
3
4import { copyShownOf, extractCopyTargets, messageKeyOf } from "./copy-model";
5import {
6 activityLines,
7 addEvent,
8 CONTROL_CHARS,
9 DESCRIPTION_COLUMNS,
10 dirnameOf,
11 displayPath,
12 dropEvent,
13 emptyActivity,
14 fileNameOf,
15 fit,
16 formatTokens,
17 isDrivePath,
18 isMcpTool,
19 maskSecrets,
20 mcpCommandName,
21 parsePlan,
22 patchEvent,
23 planHeader,
24 planLines,
25 singleLine,
26 startTurn,
27 targetOf,
28 truncate,
29} from "./flow-model";
30import type { ActivityState, PlanNode } from "./flow-model";
31import {
32 addSample,
33 agoLabel,
34 claudeSamples,
35 codexSamples,
36 currentCycle,
37 forecastOf,
38 mergeHistory,
39 parseHistory,
40 parseScriptOutput,
41 quotaBarLine,
42 serializeHistory,
43 trimHistory,
44} from "./quota-model";
45import type { AgyCounts, QuotaSource, QuotaWindow, Sample } from "./quota-model";
46
47const SESSIONS_COMMAND = "sessions";
48const PANEL_COMMAND = "panel";
49const PANE_ID = "control-panel";
50const PANE_TITLE = "控制台";
51const DOCK_COLUMNS = 44;
52const AUTO_OPEN_KEY = "autoOpen";
53const LIST_LIMIT = 10;
54const TITLE_COLUMNS = 40;
55const BAND_LABEL_COLUMNS = 28;
56const PLAN_DIR = "planned";
57const KEPT_PLANS = 5;
58// 狀態前綴(進行中、待驗收、暫結案、暫時結案、結案),顯示標題時去掉;長的「暫時結案」「暫結案」要排在「結案」前面。
59const PREFIX_PATTERN = /^\s*(?:進行中|待驗收|暫時結案|暫結案|結案)\s*[||]\s*/;
60const UNSAFE_SERVER_NAME = /[\s\p{Cc}]/u;
61const CLAUDE_CONFIG_FILE = ".claude.json";
62const MEMORY_DIR_LIMIT = 30;
63const MEMORY_VIEW_LINES = 2000;
64const AUTO_MEMORY_TYPE = "AutoMem";
65const COPY_BAR_KEY = "copyBar";
66// 按鈕一律是固定的淡黃色色塊(S5,使用者指定;不再跟 /theme、/color 變):
67// 一般按鈕底 #FFF3B0、使用中的分頁底 #F2C94C,兩者字都是深色 #3B3000(Button 沒有文字色屬性,改用子層 Text)。
68const CHIP_BG = "#FFF3B0";
69const CHIP_FG = "#3B3000";
70const CHIP_ACTIVE_BG = "#F2C94C";
71// 額度分頁:歷史檔放在 Claude 設定資料夾底下;兩次寫檔至少隔 2 分鐘;讀取腳本 10 秒逾時。
72const QUOTA_HISTORY_FILE = "plugin-data/session-picker/quota-history.json";
73const QUOTA_WRITE_GAP_MS = 120_000;
74const QUOTA_SCRIPT_TIMEOUT_MS = 10_000;
75const WRITE_ERROR_PREFIX = "寫入歷史檔失敗:";
76const QUOTA_WINDOWS: [QuotaWindow, string][] = [
77 ["seven_day", "週用量"],
78 ["five_hour", "5 小時"],
79];
80
81type ChipUi = Pick<ElementTable, "Box" | "Button" | "Text">;
82type ChipOptions = {
83 boxKey: string;
84 // Button 的 key(按下、查找用的位址);label 同時是畫面上的字。
85 key: string;
86 label: string;
87 onPress: (press: UiPressArgument) => void;
88 hotkey?: string;
89 plain?: boolean;
90 active?: boolean;
91 // 左右各留 1 格(plain 按鈕沒有 [ ] 外框)。
92 padded?: boolean;
93};
94
95// 一顆色塊:有底色的小方塊包一個 Button,Button 的子層 Text 指定深色字(粗體只給使用中的分頁)。
96function chipButton(ui: ChipUi, options: ChipOptions) {
97 const { Box, Button, Text } = ui;
98 return (
99 <Box
100 key={options.boxKey}
101 flexDirection="row"
102 flexShrink={0}
103 alignSelf="flex-start"
104 paddingX={options.padded ? 1 : 0}
105 backgroundColor={options.active ? CHIP_ACTIVE_BG : CHIP_BG}
106 >
107 <Button
108 key={options.key}
109 label={options.label}
110 {...(options.plain ? { plain: true as const } : {})}
111 {...(options.hotkey === undefined ? {} : { hotkey: options.hotkey })}
112 onPress={options.onPress}
113 >
114 <Text bold={options.active === true} color={CHIP_FG}>
115 {options.label}
116 </Text>
117 </Button>
118 </Box>
119 );
120}
121
122type Tab = "session" | "tool" | "mcp" | "flow" | "memory" | "quota";
123
124const TABS: { id: Tab; label: string }[] = [
125 { id: "session", label: "Session" },
126 { id: "tool", label: "Tool" },
127 { id: "mcp", label: "MCP" },
128 { id: "flow", label: "流程" },
129 { id: "memory", label: "記憶" },
130 { id: "quota", label: "額度" },
131];
132
133type SessionGroup = "open" | "paused" | "closed";
134type SessionEntry = { id: string; title: string; mtimeMs: number };
135
136// Session 分頁的三個子分頁:未結案/暫時結案/已結案(腳本一次回傳三組,切換子分頁不重跑腳本)。
137const SESSION_GROUPS: { id: SessionGroup; label: string; header: (count: number) => string; empty: string }[] = [
138 { id: "open", label: "未結案", header: (count) => `最近 ${count} 個未結案 session`, empty: "沒有未結案的 session" },
139 { id: "paused", label: "暫時結案", header: (count) => `最近 ${count} 個暫時結案 session`, empty: "沒有暫時結案的 session" },
140 { id: "closed", label: "已結案", header: (count) => `最近 ${count} 個已結案 session`, empty: "沒有已結案的 session" },
141];
142
143type Picker = {
144 groups: Record<SessionGroup, SessionEntry[]>;
145 error: string;
146 actionError: string;
147 loadedAt: number;
148};
149
150type ToolRow = { name: string; description: string; mcp: boolean };
151type ToolsState = { tools: ToolRow[]; error: string };
152
153type McpServer = { name: string; connected: boolean; tools: number };
154type McpState = {
155 servers: McpServer[];
156 pending: string[];
157 error: string;
158 actionError: string;
159 notice: string;
160};
161
162const pickerAtom = atom(
163 { plugin: "session-picker", key: "picker" } as const,
164 { groups: emptyGroups(), error: "", actionError: "", loadedAt: 0 } as Picker,
165);
166// 目前選的 Session 子分頁;獨立成 atom,重新整理(整個 picker 被換掉)時不會跳回「未結案」。
167const sessionGroupAtom = atom({ plugin: "session-picker", key: "sessionGroup" } as const, "open" as SessionGroup);
168const tabAtom = atom({ plugin: "session-picker", key: "tab" } as const, "session" as Tab);
169const toolsAtom = atom(
170 { plugin: "session-picker", key: "tools" } as const,
171 { tools: [], error: "" } as ToolsState,
172);
173const mcpAtom = atom(
174 { plugin: "session-picker", key: "mcp" } as const,
175 { servers: [], pending: [], error: "", actionError: "", notice: "" } as McpState,
176);
177
178type PlanState = {
179 files: { name: string; mtimeMs: number }[];
180 selected: string;
181 nodes: PlanNode[];
182 error: string;
183};
184
185const planAtom = atom(
186 { plugin: "session-picker", key: "plan" } as const,
187 { files: [], selected: "", nodes: [], error: "" } as PlanState,
188);
189let spawnSeq = 0;
190
191const activityAtom = atom({ plugin: "session-picker", key: "activity" } as const, emptyActivity() as ActivityState);
192
193// 回覆下方複製按鈕:開關讀進 atom(render hook 不必每次 await store);
194// 「已複製」狀態只留最近一筆(key 是訊息鍵,只顯示在那一則訊息下面)。
195type CopyStatus = { key: string; text: string; isError: boolean };
196
197const copyBarAtom = atom({ plugin: "session-picker", key: "copyBar" } as const, true);
198const copyStatusAtom = atom(
199 { plugin: "session-picker", key: "copyStatus" } as const,
200 { key: "", text: "", isError: false } as CopyStatus,
201);
202
203type MemoryFile = { type: string; path: string; tokens: number };
204type MemoryState = {
205 files: MemoryFile[];
206 // 載入清單裡有沒有 AutoMem 那筆(沒有就不知道記憶資料夾在哪)。
207 hasAutoMem: boolean;
208 // 記憶資料夾裡未載入的 *.md(絕對路徑,依檔名排序,最多顯示 30 個)與其餘個數。
209 folder: string[];
210 folderMore: number;
211 folderError: string;
212 error: string;
213 viewing: { path: string; lines: string[]; omitted: number } | null;
214 status: string;
215 statusIsError: boolean;
216};
217
218const memoryAtom = atom(
219 { plugin: "session-picker", key: "memory" } as const,
220 {
221 files: [],
222 hasAutoMem: false,
223 folder: [],
224 folderMore: 0,
225 folderError: "",
226 error: "",
227 viewing: null,
228 status: "",
229 statusIsError: false,
230 } as MemoryState,
231);
232
233type QuotaState = {
234 // 記憶體裡的讀數(含從歷史檔合併進來的),只有 Sample 的五個欄位。
235 samples: Sample[];
236 // Codex 最後一筆讀數的紀錄檔修改時間(0=沒有)。
237 codexAt: number;
238 codexReason: string;
239 agy: AgyCounts | null;
240 agyReason: string;
241 // Claude 回報的額度視窗是空的(不是訂閱方案或還沒有第一筆讀數)。
242 claudeEmpty: boolean;
243 historyError: string;
244 scriptError: string;
245 lastWriteAt: number;
246};
247
248const quotaAtom = atom(
249 { plugin: "session-picker", key: "quota" } as const,
250 {
251 samples: [],
252 codexAt: 0,
253 codexReason: "",
254 agy: null,
255 agyReason: "",
256 claudeEmpty: false,
257 historyError: "",
258 scriptError: "",
259 lastWriteAt: 0,
260 } as QuotaState,
261);
262
263function emptyGroups(): Picker["groups"] {
264 return { open: [], paused: [], closed: [] };
265}
266
267function messageOf(error: unknown): string {
268 return error instanceof Error ? error.message : String(error);
269}
270
271// ---------- Session 分頁(S1:讀清單、切換)----------
272
273// 讀取清單:唯一的外部呼叫是 node 腳本(argv、無 shell);失敗都存成錯誤字串,不丟出。
274async function loadList($: EngineInterface): Promise<void> {
275 const next: Picker = { groups: emptyGroups(), error: "", actionError: "", loadedAt: 0 };
276 try {
277 const [root, id, now] = await Promise.all([$.session.root(), $.session.id(), $.clock.now()]);
278 next.loadedAt = now;
279 const result = await $.process.run(
280 [
281 "node",
282 `${$.plugin.root}/scripts/list-sessions.mjs`,
283 "--cwd",
284 root,
285 "--exclude",
286 id,
287 "--limit",
288 String(LIST_LIMIT),
289 ],
290 { timeoutMs: 20000 },
291 );
292 const firstLine = (result.stderr.trim().split("\n")[0] ?? "").trim();
293 if (result.exitCode !== 0) {
294 next.error = `讀取 session 清單失敗(結束碼 ${result.exitCode})${firstLine ? `:${firstLine}` : ""}`;
295 } else {
296 const parsed = JSON.parse(result.stdout) as {
297 ok?: boolean;
298 error?: string;
299 groups?: Partial<Record<SessionGroup, SessionEntry[]>>;
300 };
301 const groups = parsed.groups;
302 if (parsed.ok === true && groups !== undefined && SESSION_GROUPS.every((entry) => Array.isArray(groups[entry.id]))) {
303 next.groups = { open: groups.open ?? [], paused: groups.paused ?? [], closed: groups.closed ?? [] };
304 } else next.error = `讀取 session 清單失敗:${parsed.error ?? "回應格式不符"}`;
305 }
306 } catch (error) {
307 next.error = `讀取 session 清單失敗:${messageOf(error)}`;
308 }
309 await update($, pickerAtom, () => next);
310}
311
312async function resumeSession($: EngineInterface, id: string): Promise<void> {
313 try {
314 await $.command.run({ command: "resume", args: id });
315 } catch (error) {
316 await update($, pickerAtom, (state) => ({ ...state, actionError: `切換失敗:${messageOf(error)}` }));
317 }
318}
319
320// ---------- Tool 分頁(唯讀)----------
321
322async function loadTools($: EngineInterface): Promise<void> {
323 let next: ToolsState;
324 try {
325 const list = await $.tool.list();
326 next = {
327 tools: list.map((tool) => ({
328 name: singleLine(tool.name),
329 description: singleLine((tool.description ?? "").split("\n")[0] ?? ""),
330 mcp: tool.mcp === true || tool.name.startsWith("mcp__"),
331 })),
332 error: "",
333 };
334 } catch (error) {
335 next = { tools: [], error: `讀取工具清單失敗:${messageOf(error)}` };
336 }
337 await update($, toolsAtom, () => next);
338}
339
340// ---------- MCP 分頁 ----------
341
342function normalizePath(path: string): string {
343 return path.replace(/\\/g, "/").replace(/\/+$/, "") || "/";
344}
345
346function ancestorsOf(path: string): string[] {
347 const parts = normalizePath(path).split("/");
348 return parts.map((_, index) => parts.slice(0, parts.length - index).join("/") || "/");
349}
350
351// 只從設定檔取出「本專案的停用伺服器名稱」;其他欄位(env、headers、args、token…)一律不碰、不存、不顯示。
352function disabledNamesOf(configText: string, cwd: string): string[] {
353 let parsed: unknown;
354 try {
355 parsed = JSON.parse(configText);
356 } catch {
357 return [];
358 }
359 const projects = (parsed as { projects?: Record<string, unknown> } | null)?.projects;
360 if (projects === null || typeof projects !== "object") return [];
361 const byPath = new Map<string, unknown>();
362 for (const [path, project] of Object.entries(projects)) byPath.set(normalizePath(path), project);
363 const root = ancestorsOf(cwd).find((path) => byPath.has(path));
364 const project = root === undefined ? undefined : (byPath.get(root) as Record<string, unknown> | undefined);
365 if (project === undefined || project === null || typeof project !== "object") return [];
366 const names: string[] = [];
367 for (const key of ["disabledMcpServers", "disabledMcpjsonServers"]) {
368 const value = project[key];
369 if (Array.isArray(value)) for (const name of value) if (typeof name === "string") names.push(name);
370 }
371 return names;
372}
373
374async function configPathOf($: EngineInterface): Promise<string | undefined> {
375 const configDir = await $.env.get("CLAUDE_CONFIG_DIR");
376 if (configDir) return `${configDir}/${CLAUDE_CONFIG_FILE}`;
377 const home = (await $.env.get("HOME")) ?? (await $.env.get("USERPROFILE"));
378 return home ? `${home}/${CLAUDE_CONFIG_FILE}` : undefined;
379}
380
381// MCP 與記憶分頁共用:summary 只在本機估算、不送任何請求;memoryFiles、mcpTools 都在 context.breakdown 底下。
382async function usageBreakdown($: EngineInterface) {
383 const { context } = await $.session.usage({ breakdown: "summary" });
384 return context.breakdown;
385}
386
387async function loadMcp($: EngineInterface): Promise<void> {
388 const next: McpState = { servers: [], pending: [], error: "", actionError: "", notice: "" };
389 try {
390 const breakdown = await usageBreakdown($);
391 const counts = new Map<string, number>();
392 for (const tool of breakdown?.mcpTools ?? []) {
393 const name = mcpCommandName(tool.serverName);
394 counts.set(name, (counts.get(name) ?? 0) + 1);
395 }
396 const servers: McpServer[] = [...counts].map(([name, tools]) => ({ name, connected: true, tools }));
397
398 try {
399 const path = await configPathOf($);
400 if (path === undefined) throw new Error("找不到使用者目錄");
401 const text = await $.fs.read(path);
402 const cwd = await $.session.cwd();
403 for (const name of disabledNamesOf(text, cwd)) {
404 if (!counts.has(name)) servers.push({ name, connected: false, tools: 0 });
405 }
406 } catch (error) {
407 next.notice = `未能讀取停用名單(${messageOf(error)}),已停用的伺服器可能沒有列出`;
408 }
409 next.servers = servers;
410 } catch (error) {
411 next.error = `讀取 MCP 伺服器失敗:${messageOf(error)}`;
412 }
413 await update($, mcpAtom, (state) => ({ ...next, pending: state.pending }));
414}
415
416async function toggleMcp($: EngineInterface, server: McpServer): Promise<void> {
417 const action = server.connected ? "disable" : "enable";
418 const verb = server.connected ? "停用" : "啟用";
419 if (server.name === "" || UNSAFE_SERVER_NAME.test(server.name)) {
420 await update($, mcpAtom, (state) => ({
421 ...state,
422 actionError: `無法${verb}「${singleLine(server.name) || "(空白名稱)"}」:名稱含空白或控制字元,未送出指令`,
423 }));
424 return;
425 }
426 await update($, mcpAtom, (state) => ({
427 ...state,
428 actionError: "",
429 pending: [...state.pending, server.name],
430 }));
431 let failure = "";
432 try {
433 await $.command.run({ command: "mcp", args: `${action} ${server.name}` });
434 } catch (error) {
435 failure = `MCP 指令失敗:${singleLine(messageOf(error))}`;
436 }
437 await update($, mcpAtom, (state) => ({
438 ...state,
439 pending: state.pending.filter((name) => name !== server.name),
440 }));
441 await loadMcp($);
442 if (failure) await update($, mcpAtom, (state) => ({ ...state, actionError: failure }));
443}
444
445// ---------- 記憶分頁(S4:已載入的記憶檔+記憶資料夾;只讀、只複製路徑)----------
446
447function pathKey(path: string): string {
448 const normalized = normalizePath(path);
449 return isDrivePath(path) ? normalized.toLowerCase() : normalized;
450}
451
452async function loadMemory($: EngineInterface): Promise<void> {
453 const next: MemoryState = {
454 files: [],
455 hasAutoMem: false,
456 folder: [],
457 folderMore: 0,
458 folderError: "",
459 error: "",
460 viewing: null,
461 status: "",
462 statusIsError: false,
463 };
464 try {
465 const breakdown = await usageBreakdown($);
466 next.files = (breakdown?.memoryFiles ?? []).map((file) => ({
467 type: singleLine(file.type),
468 path: file.path,
469 tokens: file.tokens,
470 }));
471 } catch (error) {
472 next.error = `讀取記憶檔清單失敗:${singleLine(messageOf(error))}`;
473 }
474 const auto = next.files.find((file) => file.type === AUTO_MEMORY_TYPE);
475 next.hasAutoMem = auto !== undefined;
476 if (auto !== undefined) {
477 const dir = dirnameOf(auto.path);
478 try {
479 const loaded = new Set(next.files.map((file) => pathKey(file.path)));
480 const names = (await $.fs.list(dir))
481 .filter((entry) => entry.kind === "file" && /\.md$/i.test(entry.name))
482 .map((entry) => entry.name)
483 .filter((name) => !loaded.has(pathKey(`${dir}/${name}`)))
484 .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
485 next.folder = names.slice(0, MEMORY_DIR_LIMIT).map((name) => `${dir}/${name}`);
486 next.folderMore = Math.max(names.length - MEMORY_DIR_LIMIT, 0);
487 } catch (error) {
488 next.folderError = `讀取記憶資料夾失敗:${singleLine(messageOf(error))}`;
489 }
490 }
491 await update($, memoryAtom, () => next);
492}
493
494// 只讀 fs.read;內容原樣顯示(不遮罩,使用者讀自己的檔案),只把控制字元換成空白以免弄亂畫面。
495async function viewMemory($: EngineInterface, path: string): Promise<void> {
496 try {
497 const text = await $.fs.read(path);
498 const all = text.split(/\r?\n/).map((line) => line.replace(/\t/g, " ").replace(CONTROL_CHARS, " "));
499 await update($, memoryAtom, (state) => ({
500 ...state,
501 viewing: { path, lines: all.slice(0, MEMORY_VIEW_LINES), omitted: Math.max(all.length - MEMORY_VIEW_LINES, 0) },
502 status: "",
503 statusIsError: false,
504 }));
505 } catch (error) {
506 await update($, memoryAtom, (state) => ({
507 ...state,
508 status: `讀取失敗:${singleLine(messageOf(error))}`,
509 statusIsError: true,
510 }));
511 }
512}
513
514async function backToMemoryList($: EngineInterface): Promise<void> {
515 await update($, memoryAtom, (state) => ({ ...state, viewing: null, status: "", statusIsError: false }));
516}
517
518// 複製絕對路徑(Windows 反斜線形式);surface 取自按鈕的 press 引數。失敗時顯示原因,路徑仍在畫面上可手動選取。
519async function copyMemoryPath($: EngineInterface, path: string, surface: RenderSurface): Promise<void> {
520 const copied = displayPath(path);
521 const shown = maskSecrets(copied);
522 let status: string;
523 let statusIsError = false;
524 try {
525 const result = await $.ui.copy({ text: copied, surface });
526 if (result.isCopied) status = `✔ 已複製:${shown}`;
527 else {
528 status = `✘ 複製失敗(${result.reason}):${shown}`;
529 statusIsError = true;
530 }
531 } catch (error) {
532 status = `✘ 複製失敗(${singleLine(messageOf(error))}):${shown}`;
533 statusIsError = true;
534 }
535 await update($, memoryAtom, (state) => ({ ...state, status, statusIsError }));
536}
537
538// ---------- 回覆下方的複製按鈕(S5)----------
539
540// 複製原文(一字不改);狀態只顯示在該則訊息下面,顯示用的文字去掉控制字元並遮罩疑似祕密(複製的仍是原文);
541// 多行指令只顯示第一行加「…(共 N 行)」,狀態列保持單行。
542async function copyReplyTarget($: EngineInterface, msgKey: string, raw: string, surface: RenderSurface): Promise<void> {
543 const shown = copyShownOf(raw);
544 let text: string;
545 let isError = false;
546 try {
547 const result = await $.ui.copy({ text: raw, surface });
548 if (result.isCopied) text = `✔ 已複製:${shown}`;
549 else {
550 text = `✘ 複製失敗(${result.reason}):${shown}`;
551 isError = true;
552 }
553 } catch (error) {
554 text = `✘ 複製失敗(${singleLine(messageOf(error))}):${shown}`;
555 isError = true;
556 }
557 await update($, copyStatusAtom, () => ({ key: msgKey, text, isError }));
558}
559
560// 讀不到設定時視為預設值(開啟)。
561async function copyBarOf($: EngineInterface): Promise<boolean> {
562 try {
563 return (await $.store.get(COPY_BAR_KEY)) !== false;
564 } catch {
565 return true;
566 }
567}
568
569async function syncCopyBar($: EngineInterface): Promise<void> {
570 const isOn = await copyBarOf($);
571 await quietly(update($, copyBarAtom, () => isOn));
572}
573
574// ---------- 流程分頁(S3:計畫進度+本 session 即時活動)----------
575
576// 只讀:列出 <session root>/planned 直接底下的 *.md(子目錄不看),依修改時間新到舊取前 5 份,
577// 讀選定的那份並解析節點。任何失敗都存成錯誤字串,不丟出。
578async function loadPlan($: EngineInterface, pick?: string): Promise<void> {
579 const wanted = pick ?? (await read($, planAtom)).selected;
580 const next: PlanState = { files: [], selected: "", nodes: [], error: "" };
581 try {
582 const dir = `${normalizePath(await $.session.root())}/${PLAN_DIR}`;
583 const entries = await $.fs.list(dir);
584 next.files = entries
585 .filter((entry) => entry.kind === "file" && /\.md$/i.test(entry.name))
586 .sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name))
587 .slice(0, KEPT_PLANS)
588 .map((entry) => ({ name: entry.name, mtimeMs: entry.mtimeMs }));
589 const chosen = next.files.find((file) => file.name === wanted) ?? next.files[0];
590 if (chosen !== undefined) {
591 next.selected = chosen.name;
592 try {
593 next.nodes = parsePlan(await $.fs.read(`${dir}/${chosen.name}`));
594 } catch (error) {
595 next.error = `讀取計畫書失敗:${singleLine(messageOf(error))}`;
596 }
597 }
598 } catch (error) {
599 next.error = `讀取 ${PLAN_DIR}/ 失敗:${singleLine(messageOf(error))}`;
600 }
601 await update($, planAtom, () => next);
602}
603
604// ‹ 往較新、› 往較舊(清單新到舊),到頭就繞回另一端。
605async function stepPlan($: EngineInterface, delta: number): Promise<void> {
606 const { files, selected } = await read($, planAtom);
607 if (files.length === 0) return;
608 const at = Math.max(
609 files.findIndex((file) => file.name === selected),
610 0,
611 );
612 await loadPlan($, files[(at + delta + files.length) % files.length]?.name);
613}
614
615// 追蹤用的寫入失敗不可影響工具、turn 本身。
616async function quietly(work: Promise<unknown>): Promise<void> {
617 try {
618 await work;
619 } catch {
620 // 追蹤是附帶功能,寫不進去就算了
621 }
622}
623
624// 本輪面板若開在流程分頁就重讀計畫書(節點狀態會隨工作進度改變)。
625async function refreshFlowIfOpen($: EngineInterface): Promise<void> {
626 try {
627 if ((await read($, tabAtom)) !== "flow") return;
628 if (!(await $.ui.panes()).some((pane) => pane.id === PANE_ID)) return;
629 await loadPlan($);
630 } catch {
631 // 重讀失敗不影響 turn
632 }
633}
634
635// ---------- 額度分頁(Claude/Codex 預測、AGY 使用次數)----------
636
637// 歷史檔路徑:CLAUDE_CONFIG_DIR,否則 HOME(或 USERPROFILE)/.claude;都沒有就不存歷史。
638async function quotaHistoryPathOf($: EngineInterface): Promise<string | undefined> {
639 const configDir = await $.env.get("CLAUDE_CONFIG_DIR");
640 if (configDir) return `${normalizePath(configDir)}/${QUOTA_HISTORY_FILE}`;
641 const home = (await $.env.get("HOME")) ?? (await $.env.get("USERPROFILE"));
642 return home ? `${normalizePath(home)}/.claude/${QUOTA_HISTORY_FILE}` : undefined;
643}
644
645// 讀歷史檔:檔案不存在回傳 [];格式不符回傳 undefined(改用記憶體樣本重建)。讀檔錯誤往外丟。
646async function readQuotaHistory($: EngineInterface, path: string): Promise<Sample[] | undefined> {
647 if (!(await $.fs.exists(path))) return [];
648 return parseHistory(await $.fs.read(path));
649}
650
651// 把歷史檔合併進記憶體(分頁打開、重新整理時;其他 session 寫進去的讀數也會一起顯示)。
652async function mergeQuotaHistory($: EngineInterface, now: number): Promise<void> {
653 let historyError = "";
654 let fileSamples: Sample[] = [];
655 try {
656 const path = await quotaHistoryPathOf($);
657 if (path !== undefined) {
658 const parsed = await readQuotaHistory($, path);
659 if (parsed === undefined) historyError = "歷史檔格式不符,改用本次記錄重建";
660 else fileSamples = parsed;
661 }
662 } catch (error) {
663 historyError = `讀取歷史檔失敗:${singleLine(messageOf(error))}`;
664 }
665 await update($, quotaAtom, (state) => ({
666 ...state,
667 samples: trimHistory(mergeHistory(fileSamples, state.samples), now),
668 historyError,
669 }));
670}
671
672// 寫歷史檔:寫前先讀檔再合併(多個 session 同時開著也不會互相蓋掉);距上次寫入不到 2 分鐘就先不寫。
673async function persistQuota($: EngineInterface, now: number): Promise<void> {
674 const state = await read($, quotaAtom);
675 if (state.samples.length === 0 || now - state.lastWriteAt < QUOTA_WRITE_GAP_MS) return;
676 let writeError = "";
677 let merged = state.samples;
678 try {
679 const path = await quotaHistoryPathOf($);
680 if (path === undefined) return;
681 // 格式不符的舊檔當成空的,由這次的記錄重建。
682 const fileSamples = (await readQuotaHistory($, path)) ?? [];
683 merged = trimHistory(mergeHistory(fileSamples, state.samples), now);
684 await $.fs.write(path, serializeHistory(merged));
685 } catch (error) {
686 writeError = `${WRITE_ERROR_PREFIX}${singleLine(messageOf(error))}`;
687 }
688 await update($, quotaAtom, (current) => ({
689 ...current,
690 samples: trimHistory(mergeHistory(merged, current.samples), now),
691 // 寫成功只清掉上次的寫入錯誤,讀取時的說明(例如已重建)留著。
692 historyError: writeError || (current.historyError.startsWith(WRITE_ERROR_PREFIX) ? "" : current.historyError),
693 lastWriteAt: now,
694 }));
695}
696
697async function recordQuota($: EngineInterface, fresh: Sample[], now: number): Promise<void> {
698 if (fresh.length === 0) return;
699 await update($, quotaAtom, (state) => ({
700 ...state,
701 samples: fresh.reduce((history, sample) => addSample(history, sample, now), state.samples),
702 }));
703 await persistQuota($, now);
704}
705
706// 每輪(主迴圈)記一筆 Claude 讀數;不必開著分頁,否則沒有歷史可算速度。整段失敗都吞掉,不影響 turn。
707async function sampleClaudeQuota($: EngineInterface): Promise<void> {
708 try {
709 const { rateLimits } = await $.session.usage();
710 const now = await $.clock.now();
711 const fresh = claudeSamples(rateLimits, now);
712 const claudeEmpty = fresh.length === 0;
713 if ((await read($, quotaAtom)).claudeEmpty !== claudeEmpty) {
714 await update($, quotaAtom, (state) => ({ ...state, claudeEmpty }));
715 }
716 await recordQuota($, fresh, now);
717 } catch {
718 // 取樣是附帶功能,失敗就算了
719 }
720}
721
722// 讀 Codex 與 AGY:唯一的外部呼叫是 node 腳本(argv、無 shell);失敗都存成錯誤字串,不丟出。
723async function loadQuota($: EngineInterface): Promise<void> {
724 let now = 0;
725 try {
726 now = await $.clock.now();
727 await mergeQuotaHistory($, now);
728 const result = await $.process.run(["node", `${$.plugin.root}/scripts/read-quota.mjs`], {
729 timeoutMs: QUOTA_SCRIPT_TIMEOUT_MS,
730 });
731 const firstLine = (result.stderr.trim().split("\n")[0] ?? "").trim();
732 const parsed = result.exitCode === 0 ? parseScriptOutput(result.stdout) : undefined;
733 if (parsed === undefined) {
734 const why = result.exitCode !== 0 ? `結束碼 ${result.exitCode}` : "回應格式不符";
735 await update($, quotaAtom, (state) => ({
736 ...state,
737 scriptError: `讀取 Codex/AGY 失敗(${why})${firstLine ? `:${singleLine(firstLine)}` : ""}`,
738 }));
739 return;
740 }
741 await update($, quotaAtom, (state) => ({
742 ...state,
743 codexAt: parsed.codex?.observedAt ?? state.codexAt,
744 codexReason: parsed.codexReason ?? "",
745 agy: parsed.agy,
746 agyReason: parsed.agyReason ?? "",
747 scriptError: "",
748 }));
749 if (parsed.codex !== null) await recordQuota($, codexSamples(parsed.codex), now);
750 } catch (error) {
751 await update($, quotaAtom, (state) => ({
752 ...state,
753 scriptError: `讀取 Codex/AGY 失敗:${singleLine(messageOf(error))}`,
754 }));
755 }
756}
757
758// 本輪面板若開在額度分頁就重讀 Codex/AGY。
759async function refreshQuotaIfOpen($: EngineInterface): Promise<void> {
760 try {
761 if ((await read($, tabAtom)) !== "quota") return;
762 if (!(await $.ui.panes()).some((pane) => pane.id === PANE_ID)) return;
763 await loadQuota($);
764 } catch {
765 // 重讀失敗不影響 turn
766 }
767}
768
769// 一個來源的兩個視窗(週用量在前),每個視窗一行進度條;沒有讀數的視窗不列。
770function quotaRows(
771 samples: Sample[],
772 source: QuotaSource,
773 now: number,
774 columns: number,
775): ReturnType<typeof quotaBarLine>[] {
776 const rows: ReturnType<typeof quotaBarLine>[] = [];
777 for (const [window, label] of QUOTA_WINDOWS) {
778 const forecast = forecastOf(currentCycle(samples, source, window), now);
779 if (forecast !== undefined) rows.push(quotaBarLine(label, forecast, columns));
780 }
781 return rows;
782}
783
784// ---------- 面板開啟、分頁切換 ----------
785
786async function loadTab($: EngineInterface, tab: Tab): Promise<void> {
787 if (tab === "session") await loadList($);
788 else if (tab === "tool") await loadTools($);
789 else if (tab === "mcp") await loadMcp($);
790 else if (tab === "memory") await loadMemory($);
791 else if (tab === "quota") await loadQuota($);
792 else await loadPlan($);
793}
794
795async function switchTab($: EngineInterface, tab: Tab): Promise<void> {
796 await update($, tabAtom, () => tab);
797 await loadTab($, tab);
798}
799
800// 開啟控制台:先讀目前分頁的資料再開面板;被引擎拒絕時由呼叫端接住。
801async function openPanel($: EngineInterface, tab: Tab | undefined, focus: boolean): Promise<void> {
802 const target = tab ?? (await read($, tabAtom));
803 await switchTab($, target);
804 await $.ui.open({
805 id: PANE_ID,
806 title: PANE_TITLE,
807 closeOnEscape: true,
808 columns: DOCK_COLUMNS,
809 ...(focus ? { focus: true as const } : {}),
810 });
811}
812
813function openQuietly($: EngineInterface, tab: Tab | undefined, focus: boolean): void {
814 openPanel($, tab, focus).catch((error: unknown) => {
815 $.ui.log(`開啟控制台失敗:${messageOf(error)}`, { to: "debug" });
816 });
817}
818
819// 讀不到設定時視為預設值(開啟);不讓設定讀取失敗影響 session.start。
820async function autoOpenOf($: EngineInterface): Promise<boolean> {
821 try {
822 return (await $.store.get(AUTO_OPEN_KEY)) !== false;
823 } catch {
824 return true;
825 }
826}
827
828// ---------- 顯示輔助 ----------
829
830function relativeTime(mtimeMs: number, now: number): string {
831 const seconds = Math.max(0, Math.floor((now - mtimeMs) / 1000));
832 if (seconds < 60) return "剛剛";
833 const minutes = Math.floor(seconds / 60);
834 if (minutes < 60) return `${minutes} 分鐘前`;
835 const hours = Math.floor(minutes / 60);
836 if (hours < 24) return `${hours} 小時前`;
837 return `${Math.floor(hours / 24)} 天前`;
838}
839
840function labelOf(session: SessionEntry, now: number): string {
841 const title = session.title.replace(PREFIX_PATTERN, "").replace(/\s+/g, " ").trim() || "(無標題)";
842 return `${truncate(title, TITLE_COLUMNS)} · ${relativeTime(session.mtimeMs, now)}`;
843}
844
845function toolLine(tool: ToolRow, columns: number): string {
846 const text = tool.description ? `${tool.name} ${tool.description}` : tool.name;
847 return truncate(text, columns);
848}
849
850export const register: Register = (on) => {
851 on("session.start", { isInteractive: true }, async ($, e, next) => {
852 await $.command.register({ name: SESSIONS_COMMAND, description: "開啟控制台(Session 分頁:未結案/暫時結案/已結案 session)" });
853 await $.command.register({
854 name: PANEL_COMMAND,
855 description: "開啟控制台;/panel auto on|off 設定自動開啟;/panel copybar on|off 設定回覆下方的複製按鈕",
856 });
857 await syncCopyBar($);
858 if (await autoOpenOf($)) openQuietly($, undefined, false);
859 return next(e);
860 });
861
862 on("classic.SessionStart", { source: ["clear", "resume", "fork"] }, async ($, e, next) => {
863 // 對話換了(/clear、resume、fork):舊的即時活動不屬於「本 session」,清掉。
864 await quietly(update($, activityAtom, () => emptyActivity()));
865 await syncCopyBar($);
866 if (await autoOpenOf($)) openQuietly($, undefined, false);
867 return next(e);
868 });
869
870 // ----- 即時活動追蹤:每個 hook 都原樣交還 next(e),只記名稱、截短目標、耗時、成敗與從屬 -----
871
872 on("turn.start", async ($, e, next) => {
873 if (e.agentId === undefined) await quietly(update($, activityAtom, (state) => startTurn(state)));
874 return next(e);
875 });
876
877 on("turn.complete", async ($, e, next) => {
878 if (e.agentId === undefined) {
879 await sampleClaudeQuota($);
880 await refreshFlowIfOpen($);
881 await refreshQuotaIfOpen($);
882 }
883 return next(e);
884 });
885
886 on("tool.call", async ($, e, next) => {
887 // Agent 工具本身由 agent.spawn 記成一個 agent 節點,不重複記成工具。
888 const toolName: string = e.tool;
889 if (toolName === "Agent" || toolName === "Task") return next(e);
890 const id = e.tool_use_id;
891 const startedAt = await $.clock.now();
892 await quietly(
893 update($, activityAtom, (state) =>
894 addEvent(state, {
895 id,
896 kind: "tool",
897 name: singleLine(e.tool),
898 target: isMcpTool(e.tool) ? "" : targetOf(e),
899 ...(e.agentId === undefined ? {} : { agentId: e.agentId }),
900 model: "",
901 startedAt,
902 }),
903 ),
904 );
905 const finish = async (ok: boolean): Promise<void> => {
906 const durationMs = (await $.clock.now()) - startedAt;
907 await quietly(update($, activityAtom, (state) => patchEvent(state, id, { durationMs, ok })));
908 };
909 let ran: ToolCallResult;
910 try {
911 ran = await next(e);
912 } catch (error) {
913 await finish(false);
914 throw error;
915 }
916 await finish(ran.deny === undefined && ran.isError !== true);
917 return ran;
918 });
919
920 on("agent.spawn", async ($, e, next) => {
921 const id = `agent:${e.tool_use_id}:${await $.clock.now()}:${++spawnSeq}`;
922 await quietly(
923 update($, activityAtom, (state) =>
924 addEvent(state, {
925 id,
926 kind: "agent",
927 name: singleLine(e.subagentType),
928 target: fit(maskSecrets(singleLine(e.description)), DESCRIPTION_COLUMNS),
929 ...(e.parentAgentId === undefined ? {} : { parentAgentId: e.parentAgentId }),
930 model: singleLine(e.model ?? ""),
931 startedAt: 0,
932 }),
933 ),
934 );
935 let spawn: Awaited<ReturnType<typeof next>>;
936 try {
937 spawn = await next(e);
938 } catch (error) {
939 await quietly(update($, activityAtom, (state) => dropEvent(state, id)));
940 throw error;
941 }
942 if (spawn.deny !== undefined) {
943 await quietly(update($, activityAtom, (state) => dropEvent(state, id)));
944 } else {
945 await quietly(
946 update($, activityAtom, (state) =>
947 patchEvent(state, id, {
948 model: singleLine(spawn.model),
949 ...(spawn.agentId === undefined ? {} : { agentId: spawn.agentId }),
950 }),
951 ),
952 );
953 }
954 return spawn;
955 });
956
957 on("command.run", { command: SESSIONS_COMMAND }, async ($) => {
958 // /sessions 一律落在「未結案」子分頁。
959 await update($, sessionGroupAtom, () => "open");
960 await openPanel($, "session", true);
961 return {};
962 }).catch(($, e, next) => {
963 const text = `開啟控制台失敗:${next.error.message ?? "未知錯誤"}`;
964 $.ui.log(text, { to: "debug" });
965 return { text };
966 });
967
968 on("command.run", { command: PANEL_COMMAND }, async ($, e) => {
969 const args = e.args.trim().split(/\s+/).filter((part) => part !== "");
970 if (args.length === 0) {
971 await openPanel($, undefined, true);
972 return {};
973 }
974 const [sub, value] = args;
975 if (args.length === 2 && sub === "auto" && (value === "on" || value === "off")) {
976 await $.store.set(AUTO_OPEN_KEY, value === "on");
977 return {
978 text:
979 value === "on"
980 ? "控制台自動開啟:已開啟(開新對話、/clear、resume、fork 時自動開面板)"
981 : "控制台自動開啟:已關閉(仍可用 /panel、/sessions 或輸入框上方的按鈕開啟)",
982 };
983 }
984 if (args.length === 2 && sub === "copybar" && (value === "on" || value === "off")) {
985 await $.store.set(COPY_BAR_KEY, value === "on");
986 await update($, copyBarAtom, () => value === "on");
987 return {
988 text:
989 value === "on"
990 ? "回覆下方的複製按鈕:已開啟(回覆含 Windows 絕對路徑或網址時,下方列出複製按鈕)"
991 : "回覆下方的複製按鈕:已關閉",
992 };
993 }
994 return { text: "用法:/panel 開啟控制台;/panel auto on|off 設定自動開啟;/panel copybar on|off 設定回覆下方的複製按鈕" };
995 }).catch(($, e, next) => {
996 const text = `開啟控制台失敗:${next.error.message ?? "未知錯誤"}`;
997 $.ui.log(text, { to: "debug" });
998 return { text };
999 });
1000
1001 // 助理回覆下方的複製按鈕列:先取引擎的繪製(原樣保留),再把一列按鈕疊在它下面。
1002 // 摘要區塊、開關關閉、沒有路徑/網址時原樣回傳 drawn(同一個物件);絕不改動 e.props(isSummary、onScreen 照引擎給的)。
1003 on("ui.render", { component: "AssistantMessage" }, async ($, e, next) => {
1004 const drawn = await next(e);
1005 if (e.props.isSummary === true) return drawn;
1006 if (!(await read($, copyBarAtom))) return drawn;
1007 const { shown, more } = extractCopyTargets(e.props.text);
1008 if (shown.length === 0) return drawn;
1009
1010 const msgKey = messageKeyOf(e.props.text);
1011 const status = await read($, copyStatusAtom);
1012 const { Box, Button, Text } = $.ui.resolve(e);
1013 return (
1014 <Box key={`copybar-${msgKey}`} flexDirection="column">
1015 {drawn}
1016 <Box key={`copybar-row-${msgKey}`} flexDirection="row" flexWrap="wrap" columnGap={1}>
1017 {shown.map((target, index) =>
1018 chipButton(
1019 { Box, Button, Text },
1020 {
1021 boxKey: `copy-chip-${msgKey}-${index}`,
1022 key: `copy:${msgKey}:${index}`,
1023 label: target.label,
1024 plain: true,
1025 padded: true,
1026 onPress: (press) => void copyReplyTarget($, msgKey, target.raw, press.surface),
1027 },
1028 ),
1029 )}
1030 {more > 0 ? <Text dimColor>還有 {more} 個</Text> : null}
1031 </Box>
1032 {status.key === msgKey && status.text !== "" ? (
1033 status.isError ? (
1034 <Text key={`copy-status-${msgKey}`} color="error">
1035 {status.text}
1036 </Text>
1037 ) : (
1038 <Text key={`copy-status-${msgKey}`}>{status.text}</Text>
1039 )
1040 ) : null}
1041 </Box>
1042 );
1043 });
1044
1045 // 輸入框上方的按鈕列:與其他外掛的 AbovePrompt 疊加(先取下一層,再把自己放在它上面)。
1046 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
1047 const below = await next(e);
1048 if (e.props.hasSurvey) return below;
1049
1050 const { Box, Button, Text } = $.ui.resolve(e);
1051 const showLabel = e.props.bodyColumns >= BAND_LABEL_COLUMNS;
1052 return (
1053 <Box key="band" flexDirection="column">
1054 <Box key="band-row" flexDirection="row" columnGap={1} width={e.props.bodyColumns} height={1} overflow="hidden">
1055 {showLabel ? (
1056 <Text dimColor wrap="truncate-end">
1057 控制台
1058 </Text>
1059 ) : null}
1060 {TABS.map((tab) =>
1061 chipButton(
1062 { Box, Button, Text },
1063 {
1064 boxKey: `band-chip-${tab.id}`,
1065 key: `band-${tab.id}`,
1066 label: tab.label,
1067 plain: true,
1068 padded: true,
1069 onPress: () => openQuietly($, tab.id, true),
1070 },
1071 ),
1072 )}
1073 </Box>
1074 {below}
1075 </Box>
1076 );
1077 });
1078
1079 on("ui.render", { component: "Pane", requestId: PANE_ID }, async ($, e) => {
1080 const [tab, picker, sessionGroup, tools, mcp, memory, now] = await Promise.all([
1081 read($, tabAtom),
1082 read($, pickerAtom),
1083 read($, sessionGroupAtom),
1084 read($, toolsAtom),
1085 read($, mcpAtom),
1086 read($, memoryAtom),
1087 $.clock.now(),
1088 ]);
1089 const { Box, Button, Text } = $.ui.resolve(e);
1090 const chip = (options: ChipOptions) => chipButton({ Box, Button, Text }, options);
1091 const showKeyboardHint = e.surface === "terminal" && e.viewport?.isFullscreen === false;
1092 const columns = Math.max(e.props.bodyColumns, 12);
1093
1094 const tabBar = (
1095 <Box key="tabs" flexDirection="row" flexWrap="wrap" columnGap={1}>
1096 {TABS.map((entry) => {
1097 const isActive = entry.id === tab;
1098 const label = isActive ? `[${entry.label}]` : entry.label;
1099 return chip({
1100 boxKey: `tab-chip-${entry.id}`,
1101 key: `tab-${entry.id}`,
1102 label,
1103 plain: true,
1104 active: isActive,
1105 padded: true,
1106 onPress: () => void switchTab($, entry.id),
1107 });
1108 })}
1109 </Box>
1110 );
1111
1112 let body;
1113 if (tab === "session") {
1114 const group = SESSION_GROUPS.find((entry) => entry.id === sessionGroup) ?? SESSION_GROUPS[0]!;
1115 const sessions = picker.groups[group.id];
1116 const groupBar = (
1117 <Box key="groups" flexDirection="row" flexWrap="wrap" columnGap={1}>
1118 {SESSION_GROUPS.map((entry) => {
1119 const isActive = entry.id === group.id;
1120 const text = `${entry.label}(${picker.groups[entry.id].length})`;
1121 return chip({
1122 boxKey: `group-chip-${entry.id}`,
1123 key: `group-${entry.id}`,
1124 label: isActive ? `[${text}]` : text,
1125 plain: true,
1126 active: isActive,
1127 padded: true,
1128 onPress: () => void update($, sessionGroupAtom, () => entry.id),
1129 });
1130 })}
1131 </Box>
1132 );
1133 body = (
1134 <Box key="session" flexDirection="column">
1135 {groupBar}
1136 <Text>{group.header(sessions.length)}</Text>
1137 {picker.error ? <Text color="error">{picker.error}</Text> : null}
1138 {!picker.error && sessions.length === 0 ? <Text dimColor>{group.empty}</Text> : null}
1139 {sessions.map((session) =>
1140 chip({
1141 boxKey: `chip-${session.id}`,
1142 key: session.id,
1143 label: labelOf(session, now),
1144 onPress: () => void resumeSession($, session.id),
1145 }),
1146 )}
1147 {picker.actionError ? <Text color="error">{picker.actionError}</Text> : null}
1148 {chip({ boxKey: "chip-refresh", key: "refresh", label: "重新整理", hotkey: "r", onPress: () => void loadList($) })}
1149 </Box>
1150 );
1151 } else if (tab === "tool") {
1152 const builtin = tools.tools.filter((tool) => !tool.mcp);
1153 const external = tools.tools.filter((tool) => tool.mcp);
1154 body = (
1155 <Box key="tool" flexDirection="column">
1156 <Text>
1157 共 {tools.tools.length} 個工具(內建 {builtin.length}/MCP {external.length})
1158 </Text>
1159 {tools.error ? <Text color="error">{tools.error}</Text> : null}
1160 {!tools.error && tools.tools.length === 0 ? <Text dimColor>目前沒有可用工具</Text> : null}
1161 {builtin.length > 0 ? <Text bold>內建({builtin.length})</Text> : null}
1162 {builtin.map((tool) => (
1163 <Text key={`tool-${tool.name}`} wrap="truncate-end">
1164 {toolLine(tool, columns)}
1165 </Text>
1166 ))}
1167 {external.length > 0 ? <Text bold>MCP({external.length})</Text> : null}
1168 {external.map((tool) => (
1169 <Text key={`tool-${tool.name}`} wrap="truncate-end">
1170 {toolLine(tool, columns)}
1171 </Text>
1172 ))}
1173 {chip({
1174 boxKey: "chip-tool-refresh",
1175 key: "tool-refresh",
1176 label: "重新整理",
1177 hotkey: "r",
1178 onPress: () => void loadTools($),
1179 })}
1180 </Box>
1181 );
1182 } else if (tab === "memory") {
1183 const statusLine = memory.status ? (
1184 memory.statusIsError ? (
1185 <Text key="mem-status" color="error">
1186 {memory.status}
1187 </Text>
1188 ) : (
1189 <Text key="mem-status">{memory.status}</Text>
1190 )
1191 ) : null;
1192 if (memory.viewing !== null) {
1193 const { path, lines, omitted } = memory.viewing;
1194 body = (
1195 <Box key="memory-view" flexDirection="column">
1196 <Text bold>{displayPath(path)}</Text>
1197 <Box key="mem-view-actions" flexDirection="row" columnGap={1}>
1198 {chip({ boxKey: "chip-mem-back", key: "mem-back", label: "返回", onPress: () => void backToMemoryList($) })}
1199 {chip({
1200 boxKey: "chip-mem-view-copy",hooks/copy-model.ts 217 lines1// 回覆下方「複製按鈕」用的純函式:從助理回覆文字抽出 Windows 絕對路徑、網址與 ``` 圍欄裡的指令、產生按鈕標籤、算訊息鍵。
2// 這個檔不碰 `$`、不宣告 atom(驗證器規則:`$` 不跨檔),只收發純資料;只對文字做樣式比對,不碰檔案或程序。
3
4import { fit, maskSecrets, singleLine } from "./flow-model";
5
6export const MAX_COPY_CHIPS = 8;
7export const COPY_LABEL_COLUMNS = 28;
8export const COPY_LABEL_PREFIX = "⧉ ";
9
10// 連結類(路徑、網址);指令類是從 ``` 圍欄區塊來的。
11export type LinkKind = "path" | "url";
12export type CopyTargetKind = LinkKind | "command";
13
14export type CopyTarget = {
15 // 原文:按下去複製的就是它,一字不改(含結尾的斜線;指令區塊則是整段內容,含換行)。
16 raw: string;
17 kind: CopyTargetKind;
18 // 已遮罩、截短、加前綴的按鈕標籤。
19 label: string;
20};
21
22export type CopyTargets = {
23 // 最多 MAX_COPY_CHIPS 個,依第一次出現的順序。
24 shown: CopyTarget[];
25 // 去重後超出上限的個數。
26 more: number;
27};
28
29// 路徑/網址共同的終止字元:空白、反引號、各種引號、全形標點。
30const QUOTES = String.raw`\x60'"‘’“”`;
31const CJK_PUNCTUATION = ",。、;:「」『』()!?";
32const PATH_STOP = String.raw`\s${QUOTES}<>|*?${CJK_PUNCTUATION}`;
33const URL_STOP = String.raw`\s${QUOTES}<>${CJK_PUNCTUATION}`;
34
35// 一個 regex 同時掃網址與路徑(由左到右、網址優先),所以網址裡的 `C:/…` 不會被另外當成路徑。
36// 路徑:磁碟代號前面不可緊接英數字(`xF:\` 不算);中文字緊接在前面則算(「在F:\x」)。
37const TARGET_PATTERN = new RegExp(
38 String.raw`(https?:\/\/[^${URL_STOP}]+)|((?<![A-Za-z0-9])[A-Za-z]:[\\/][^${PATH_STOP}]+)`,
39 "gi",
40);
41
42// 結尾要剝掉的標點(句號、逗號、括號、閉引號、全形標點);中間的不動。
43const TRAILING = new Set([...".,;:)]}>", ...CJK_PUNCTUATION, "…"]);
44const URL_BODY = /^https?:\/\/./i;
45const DRIVE_ONLY = /^[A-Za-z]:$/;
46
47function stripTrailing(text: string): string {
48 let end = text.length;
49 while (end > 0 && TRAILING.has(text[end - 1] ?? "")) end--;
50 return text.slice(0, end);
51}
52
53// 依第一次出現的順序列出所有不重複的目標原文(不限個數)。
54export function findCopyTargets(text: string): { raw: string; kind: LinkKind }[] {
55 const seen = new Set<string>();
56 const found: { raw: string; kind: LinkKind }[] = [];
57 for (const match of text.matchAll(TARGET_PATTERN)) {
58 const kind: LinkKind = match[1] === undefined ? "path" : "url";
59 const raw = stripTrailing(match[0]);
60 if (kind === "url" && !URL_BODY.test(raw)) continue;
61 if (kind === "path" && raw.length < 3) continue;
62 if (seen.has(raw)) continue;
63 seen.add(raw);
64 found.push({ raw, kind });
65 }
66 return found;
67}
68
69// 路徑取最後一個非空的區段(資料夾或檔名);只有磁碟代號時顯示 `F:\`。
70function pathLabel(raw: string): string {
71 const segments = raw.split(/[\\/]+/).filter((segment) => segment !== "");
72 const last = segments.at(-1) ?? raw;
73 return segments.length === 1 && DRIVE_ONLY.test(last) ? `${last}\\` : last;
74}
75
76// 網址取 `主機[:埠]`,有路徑時再加 `/最後一個非空區段`;帳密(@ 之前)、查詢字串與片段都不進標籤。
77function urlLabel(raw: string): string {
78 const parts = /^https?:\/\/([^/?#]*)([^?#]*)/i.exec(raw);
79 const host = (parts?.[1] ?? raw).split("@").pop() ?? "";
80 const last = (parts?.[2] ?? "").split("/").filter((segment) => segment !== "").at(-1);
81 return last === undefined ? host : `${host}/${last}`;
82}
83
84// 標籤一律:先遮罩再截短(標籤不留秘密)、加前綴;複製的原文不受影響。
85function labelOfBase(base: string): string {
86 return `${COPY_LABEL_PREFIX}${fit(maskSecrets(singleLine(base)), COPY_LABEL_COLUMNS)}`;
87}
88
89export function copyLabelOf(raw: string, kind: LinkKind): string {
90 return labelOfBase(kind === "path" ? pathLabel(raw) : urlLabel(raw));
91}
92
93// ---------- ``` 圍欄裡的指令 ----------
94
95const FENCE_OPENER = /^\s*```[^`]*$/;
96const FENCE_CLOSER = /^\s*```\s*$/;
97const TITLE_LINE = /^#\s+(.+)$/;
98
99const isBlank = (line: string): boolean => line.trim() === "";
100
101// 去掉頭尾空白行,再去掉非空白行共同的最小縮排。
102function blockLines(lines: string[]): string[] {
103 let start = 0;
104 let end = lines.length;
105 while (start < end && isBlank(lines[start] ?? "")) start++;
106 while (end > start && isBlank(lines[end - 1] ?? "")) end--;
107 const body = lines.slice(start, end);
108 const indents = body.filter((line) => !isBlank(line)).map((line) => /^\s*/.exec(line)?.[0].length ?? 0);
109 const common = indents.length === 0 ? 0 : Math.min(...indents);
110 return body.map((line) => line.slice(common));
111}
112
113// 含 `\` 或 `/` 的字取最後一個非空區段(`harness/cli/review-loop.mjs` → `review-loop.mjs`)。
114function shortWord(word: string): string {
115 if (!/[\\/]/.test(word)) return word;
116 return word.split(/[\\/]+/).filter((segment) => segment !== "").at(-1) ?? word;
117}
118
119// 一個區塊(已去頭尾空白行與共同縮排)→ 指令按鈕;不產生按鈕時回 undefined(區塊維持一般文字)。
120function commandOfBlock(lines: string[]): { raw: string; label: string } | undefined {
121 const content = lines.join("\n");
122 if (content === "") return undefined;
123 const only = findCopyTargets(content);
124 if (only.length === 1 && only[0]?.raw === content) return undefined;
125
126 // `# 標題` 開頭:標題當標籤,其餘(去掉標題後緊接的空白行)當複製內容;先於單行規則判斷。
127 const title = TITLE_LINE.exec(lines[0] ?? "");
128 if (title !== null) {
129 const rest = lines.slice(1);
130 while (rest.length > 0 && isBlank(rest[0] ?? "")) rest.shift();
131 if (rest.length === 0) return undefined;
132 return { raw: rest.join("\n"), label: labelOfBase((title[1] ?? "").trim()) };
133 }
134
135 if (lines.length === 1) {
136 const words = content.split(/\s+/);
137 const first = words[0] ?? content;
138 if (first.startsWith("/")) {
139 return { raw: content, label: labelOfBase(first.slice(1) === "" ? first : first.slice(1)) };
140 }
141 return { raw: content, label: labelOfBase(words.slice(0, 2).map(shortWord).join(" ")) };
142 }
143 return { raw: content, label: labelOfBase(`指令(${lines.length} 行)`) };
144}
145
146// 狀態列顯示用:單行遮罩;多行只顯示遮罩後的第一個非空行加「…(共 N 行)」。複製的仍是原文。
147export function copyShownOf(raw: string): string {
148 const lines = raw.split(/\r?\n/);
149 if (lines.length === 1) return maskSecrets(singleLine(raw));
150 const first = lines.find((line) => !isBlank(line)) ?? "";
151 return `${maskSecrets(singleLine(first))}…(共 ${lines.length} 行)`;
152}
153
154// 依出現順序列出全部(不限個數、未去重):產生按鈕的圍欄區塊(開頭到結尾那行都不再掃路徑與網址)給指令按鈕,其餘文字照舊掃路徑與網址。
155function collectTargets(text: string): CopyTarget[] {
156 const out: CopyTarget[] = [];
157 const lines = text.split(/\r?\n/);
158 let prose: string[] = [];
159 const flush = (): void => {
160 for (const { raw, kind } of findCopyTargets(prose.join("\n"))) out.push({ raw, kind, label: copyLabelOf(raw, kind) });
161 prose = [];
162 };
163
164 let index = 0;
165 while (index < lines.length) {
166 const line = lines[index] ?? "";
167 let close = -1;
168 if (FENCE_OPENER.test(line)) {
169 for (let next = index + 1; next < lines.length; next++) {
170 if (FENCE_CLOSER.test(lines[next] ?? "")) {
171 close = next;
172 break;
173 }
174 }
175 }
176 // 沒有結尾的開頭圍欄忽略,維持一般文字(下一行照常往下看)。
177 if (close === -1) {
178 prose.push(line);
179 index++;
180 continue;
181 }
182 // 沒產生按鈕的圍欄區塊整塊(含結尾那行,避免它被當成下一個開頭)併回一般文字;只有產生按鈕的才整塊略過。
183 const command = commandOfBlock(blockLines(lines.slice(index + 1, close)));
184 if (command === undefined) {
185 prose.push(...lines.slice(index, close + 1));
186 index = close + 1;
187 continue;
188 }
189 flush();
190 out.push({ raw: command.raw, kind: "command", label: command.label });
191 index = close + 1;
192 }
193 flush();
194 return out;
195}
196
197export function extractCopyTargets(text: string, max = MAX_COPY_CHIPS): CopyTargets {
198 const seen = new Set<string>();
199 const all = collectTargets(text).filter((target) => {
200 const key = `${target.kind}\0${target.raw}`;
201 if (seen.has(key)) return false;
202 seen.add(key);
203 return true;
204 });
205 return { shown: all.slice(0, max), more: Math.max(all.length - max, 0) };
206}
207
208// 訊息鍵:FNV-1a 32 位元雜湊(純函式、不需要 crypto),只用來讓「已複製」狀態對得到同一則訊息。
209export function messageKeyOf(text: string): string {
210 let hash = 0x811c9dc5;
211 for (let index = 0; index < text.length; index++) {
212 hash ^= text.charCodeAt(index);
213 hash = Math.imul(hash, 0x01000193);
214 }
215 return (hash >>> 0).toString(16).padStart(8, "0");
216}
217hooks/flow-model.ts 429 lines1// 「流程」分頁用的純函式:計畫書解析、活動事件紀錄與文字樹。
2// 這個檔不碰 `$`、不宣告 atom(驗證器規則:`$` 不跨檔),只收發純資料。
3
4// ---------- 共用顯示輔助(Session/Tool/MCP 分頁也用)----------
5
6export const CONTROL_CHARS = /\p{Cc}/gu;
7const PLUGIN_TOOL_PREFIX = /^plugin_([^_]+)_(.+)$/;
8
9// 外部文字(工具說明、MCP 伺服器名稱、檔名)一律去掉控制字元(含 ANSI 跳脫)再顯示。
10export function singleLine(text: string): string {
11 return text.replace(CONTROL_CHARS, " ").replace(/\s+/g, " ").trim();
12}
13
14export function mcpCommandName(name: string): string {
15 return name.replace(PLUGIN_TOOL_PREFIX, "plugin:$1:$2");
16}
17
18export function displayWidth(char: string): number {
19 const code = char.codePointAt(0) ?? 0;
20 const isWide =
21 (code >= 0x1100 && code <= 0x115f) ||
22 (code >= 0x2e80 && code <= 0xa4cf) ||
23 (code >= 0xac00 && code <= 0xd7a3) ||
24 (code >= 0xf900 && code <= 0xfaff) ||
25 (code >= 0xfe30 && code <= 0xfe6f) ||
26 (code >= 0xff00 && code <= 0xff60) ||
27 (code >= 0xffe0 && code <= 0xffe6) ||
28 (code >= 0x1f300 && code <= 0x1faff);
29 return isWide ? 2 : 1;
30}
31
32export function textWidth(text: string): number {
33 let width = 0;
34 for (const char of text) width += displayWidth(char);
35 return width;
36}
37
38// 一律保留一格給省略號(即使剛好放得下也截斷);Session/Tool 分頁沿用這個行為。
39export function truncate(text: string, maxColumns: number): string {
40 let width = 0;
41 let out = "";
42 for (const char of text) {
43 const w = displayWidth(char);
44 if (width + w > maxColumns - 1) return `${out}…`;
45 width += w;
46 out += char;
47 }
48 return out;
49}
50
51// 放得下就原樣,放不下才截斷加省略號。
52export function fit(text: string, maxColumns: number): string {
53 return textWidth(text) <= maxColumns ? text : truncate(text, maxColumns);
54}
55
56// ---------- 計畫書解析 ----------
57
58export type PlanStatus = "done" | "problem" | "todo";
59
60export type PlanNode = {
61 id: string;
62 name: string;
63 agent: string;
64 status: PlanStatus;
65 // 狀態備註含「未觸發」:分支沒走到,不算待辦。
66 skipped: boolean;
67};
68
69const NODE_ID = /\[([A-Za-z0-9-]+)\]/g;
70const STATUS_WORD = /^(已執行|未執行|有問題)/;
71const FENCE = /^\s*```/;
72const BAR = "|";
73
74const STATUS_OF: Record<string, PlanStatus> = {
75 已執行: "done",
76 未執行: "todo",
77 有問題: "problem",
78};
79
80// 單行節點:找 `[ID]`,其後以全形「|」切成 名稱|agent|狀態;狀態以三個關鍵字之一開頭。
81export function parseNodeLine(line: string): PlanNode | undefined {
82 for (const match of line.matchAll(NODE_ID)) {
83 const id = match[1] ?? "";
84 const rest = line.slice((match.index ?? 0) + match[0].length);
85 const parts = rest.split(BAR);
86 if (parts.length < 3) continue;
87 const statusAt = parts.findIndex((part, index) => index >= 2 && STATUS_WORD.test(part.trim()));
88 if (statusAt < 0) continue;
89 const statusText = parts.slice(statusAt).join(BAR).trim();
90 const word = STATUS_WORD.exec(statusText)?.[1] ?? "";
91 return {
92 id,
93 name: singleLine(parts[0] ?? ""),
94 agent: singleLine(parts.slice(1, statusAt).join(BAR)),
95 status: STATUS_OF[word] ?? "todo",
96 skipped: statusText.includes("未觸發"),
97 };
98 }
99 return undefined;
100}
101
102// 只看 ``` 圍欄區塊內的行,依檔案順序收集所有節點;沒有節點的區塊自然被略過。
103export function parsePlan(text: string): PlanNode[] {
104 const nodes: PlanNode[] = [];
105 let inFence = false;
106 for (const line of text.split(/\r?\n/)) {
107 if (FENCE.test(line)) {
108 inFence = !inFence;
109 continue;
110 }
111 if (!inFence) continue;
112 const node = parseNodeLine(line);
113 if (node !== undefined) nodes.push(node);
114 }
115 return nodes;
116}
117
118// 第一個「未執行」且不是未觸發分支的節點;沒有則 -1。
119export function currentIndexOf(nodes: PlanNode[]): number {
120 return nodes.findIndex((node) => node.status === "todo" && !node.skipped);
121}
122
123export function doneCount(nodes: PlanNode[]): number {
124 return nodes.filter((node) => node.status === "done").length;
125}
126
127export type PlanLine = { head: string; agent: string; isMuted: boolean };
128
129// 每個節點一行:head 是符號、ID、名稱(含固定後綴),agent 另列(淡色);兩者合計不超過 columns。
130export function planLines(nodes: PlanNode[], columns: number): PlanLine[] {
131 const current = currentIndexOf(nodes);
132 return nodes.map((node, index) => {
133 let mark: string;
134 let suffix = "";
135 if (node.skipped && node.status === "todo") {
136 mark = "·";
137 suffix = "(未觸發)";
138 } else if (node.status === "done") {
139 mark = "✔";
140 } else if (node.status === "problem") {
141 mark = "✘";
142 suffix = "(有問題)";
143 } else if (index === current) {
144 mark = "▶";
145 suffix = " ← 目前";
146 } else {
147 mark = "○";
148 }
149 const lead = `${mark} ${node.id} `;
150 const room = Math.max(columns - textWidth(lead) - textWidth(suffix), 6);
151 const head = `${lead}${fit(node.name, room)}${suffix}`;
152 const left = columns - textWidth(head);
153 const agent = node.agent !== "" && left >= 6 ? fit(`|${node.agent}`, left) : "";
154 return { head, agent, isMuted: node.skipped && node.status === "todo" };
155 });
156}
157
158// 標題一行:「計畫:<檔名> 已執行 a/共 n」;放不下時只縮檔名,計數保留。
159export function planHeader(fileName: string, nodes: PlanNode[], columns: number): string {
160 const tail = ` 已執行 ${doneCount(nodes)}/共 ${nodes.length}`;
161 const room = Math.max(columns - textWidth("計畫:") - textWidth(tail), 8);
162 return `計畫:${fit(fileName, room)}${tail}`;
163}
164
165// ---------- 記憶分頁用的路徑與數字顯示 ----------
166
167const DRIVE_PATH = /^[A-Za-z]:[\\/]/;
168
169export function isDrivePath(path: string): boolean {
170 return DRIVE_PATH.test(path);
171}
172
173// 引擎給的路徑可能是 C:/… 形式;只有以磁碟代號開頭時才把 / 換成反斜線,其他路徑原樣。
174export function displayPath(path: string): string {
175 return isDrivePath(path) ? path.replace(/\//g, "\\") : path;
176}
177
178export function formatTokens(tokens: number): string {
179 if (tokens < 1000) return String(Math.round(tokens));
180 return `${(tokens / 1000).toFixed(1).replace(/\.0$/, "")}k`;
181}
182
183export function dirnameOf(path: string): string {
184 const at = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
185 return at <= 0 ? "/" : path.slice(0, at);
186}
187
188export function fileNameOf(path: string): string {
189 return path.slice(Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1);
190}
191
192// ---------- 秘密遮罩(只靠樣式比對,無法保證涵蓋所有秘密)----------
193
194// 先遮罩再截短:狀態與畫面都不會留下秘密。保留可辨識的前綴(`sk-`、`Bearer`、`token=`),只把秘密本體換成 `***`。
195const SECRET_KEYWORD = String.raw`(?:api[_-]?key|token|secret|password|passwd|pwd|auth(?:orization)?)`;
196const SECRET_VALUE = String.raw`("[^"]*"|'[^']*'|[^\s"']+)`;
197const AUTHORIZATION_HEADER = /\b(Authorization)(\s*:\s*)(?:(?:Bearer|Basic|Token)\s+)?[^\s"']+/gi;
198const BEARER_TOKEN = /\b(Bearer)(\s+)[A-Za-z0-9._~+/=-]{8,}/gi;
199// 鍵名+ = 或 :(含 JSON 的 "token": "…");關鍵字後面不能再接英數字,所以 tokens、author 不會被遮。
200const KEY_VALUE = new RegExp(String.raw`(${SECRET_KEYWORD}["']?\s*[=:]\s*)${SECRET_VALUE}`, "gi");
201// --token 值(空白分隔的旗標形式);只認 -- 開頭的旗標,一般文字裡的 "token abc" 不動。
202const KEY_FLAG = new RegExp(String.raw`(^|\s)(--[A-Za-z0-9_.-]{0,40}${SECRET_KEYWORD}\s+)${SECRET_VALUE}`, "gi");
203
204const TOKEN_FORMS: [RegExp, string][] = [
205 [/(?<![A-Za-z0-9])sk-[A-Za-z0-9_-]{8,}/g, "sk-***"],
206 [/(?<![A-Za-z0-9])(gh[pousr]_)[A-Za-z0-9]{20,}/g, "$1***"],
207 [/(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}/g, "github_pat_***"],
208 [/(?<![A-Za-z0-9])(xox[abprs]-)[A-Za-z0-9-]{10,}/g, "$1***"],
209 [/(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}/g, "AKIA***"],
210 [/(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{30,}/g, "AIza***"],
211];
212
213// 被引號包住的值保留引號,只換內容。
214function maskedValue(value: string): string {
215 const quote = value.startsWith("\"") || value.startsWith("'") ? value[0] : "";
216 return `${quote}***${quote}`;
217}
218
219export function maskSecrets(text: string): string {
220 let out = text
221 .replace(AUTHORIZATION_HEADER, "$1$2***")
222 .replace(BEARER_TOKEN, "$1$2***");
223 for (const [pattern, replacement] of TOKEN_FORMS) out = out.replace(pattern, replacement);
224 return out
225 .replace(KEY_VALUE, (_all, head: string, value: string) => `${head}${maskedValue(value)}`)
226 .replace(KEY_FLAG, (_all, lead: string, head: string, value: string) => `${lead}${head}${maskedValue(value)}`);
227}
228
229// ---------- 活動事件(只存名稱、截短目標、耗時、成敗、agent 從屬)----------
230
231export const TARGET_COLUMNS = 60;
232export const DESCRIPTION_COLUMNS = 40;
233export const KEPT_EVENTS = 200;
234export const SHOWN_TURNS = 5;
235
236export type FlowEvent = {
237 id: string;
238 kind: "tool" | "agent";
239 turn: number;
240 // 工具名稱;agent 則是 agent 類型。
241 name: string;
242 // 工具:截短目標(≤60 欄);agent:說明(≤40 欄)。
243 target: string;
244 // 工具:它在哪個 subagent 迴圈執行;agent:自己的 id(spawn 回覆後才有)。主迴圈為 undefined。
245 agentId?: string;
246 // 只有 agent:它是在哪個 agent 迴圈裡被 spawn 的。
247 parentAgentId?: string;
248 // 只有 agent:模型別名或 id。
249 model: string;
250 startedAt: number;
251 // 結束後才有;兩者都 undefined 代表還在執行。
252 durationMs?: number;
253 ok?: boolean;
254};
255
256export type ActivityState = {
257 events: FlowEvent[];
258 turn: number;
259 tools: number;
260 mcp: number;
261 agents: number;
262};
263
264export function emptyActivity(): ActivityState {
265 return { events: [], turn: 0, tools: 0, mcp: 0, agents: 0 };
266}
267
268export function isMcpTool(name: string): boolean {
269 return name.startsWith("mcp__");
270}
271
272function baseName(path: string): string {
273 return path.replace(/\\/g, "/").replace(/\/+$/, "").split("/").pop() ?? path;
274}
275
276function hostOf(url: string): string {
277 return /^[a-z][a-z0-9+.-]*:\/\/([^/?#:]*)/i.exec(url)?.[1] ?? url;
278}
279
280// 目標摘要:檔案取檔名、指令取第一行、pattern/query/description 原文、網址取主機;
281// 一律去控制字元、遮罩秘密(先遮罩再截短)並截到 60 欄。輸入的其餘欄位(內容、完整指令)不經過這裡就不會被保存。
282export function targetOf(call: object): string {
283 const fields = call as Record<string, unknown>;
284 const text = (key: string): string => {
285 const value = fields[key];
286 return typeof value === "string" ? singleLine(value) : "";
287 };
288 const picked =
289 text("file_path") !== ""
290 ? baseName(text("file_path"))
291 : text("notebook_path") !== ""
292 ? baseName(text("notebook_path"))
293 : text("command") !== ""
294 ? text("command")
295 : text("pattern") !== ""
296 ? text("pattern")
297 : text("url") !== ""
298 ? hostOf(text("url"))
299 : text("query") !== ""
300 ? text("query")
301 : text("description");
302 return fit(maskSecrets(picked), TARGET_COLUMNS);
303}
304
305export function addEvent(state: ActivityState, event: Omit<FlowEvent, "turn">): ActivityState {
306 const counted =
307 event.kind === "agent"
308 ? { agents: state.agents + 1 }
309 : { tools: state.tools + 1, mcp: state.mcp + (isMcpTool(event.name) ? 1 : 0) };
310 return {
311 ...state,
312 ...counted,
313 events: [...state.events, { ...event, turn: state.turn }].slice(-KEPT_EVENTS),
314 };
315}
316
317export function patchEvent(state: ActivityState, id: string, patch: Partial<FlowEvent>): ActivityState {
318 return { ...state, events: state.events.map((event) => (event.id === id ? { ...event, ...patch } : event)) };
319}
320
321// spawn 被拒或失敗:那個 agent 沒有真的開始,把它從紀錄與計數拿掉。
322export function dropEvent(state: ActivityState, id: string): ActivityState {
323 const dropped = state.events.find((event) => event.id === id);
324 if (dropped === undefined) return state;
325 return {
326 ...state,
327 agents: state.agents - (dropped.kind === "agent" ? 1 : 0),
328 events: state.events.filter((event) => event.id !== id),
329 };
330}
331
332export function startTurn(state: ActivityState): ActivityState {
333 return { ...state, turn: state.turn + 1 };
334}
335
336// ---------- 文字樹 ----------
337
338export function formatDuration(ms: number): string {
339 return ms < 1000 ? `${Math.max(0, Math.round(ms))}ms` : `${(ms / 1000).toFixed(1)}s`;
340}
341
342type TreeNode = { text: string; suffix: string; children: TreeNode[]; joint: string };
343
344function toolText(event: FlowEvent): string {
345 if (isMcpTool(event.name)) {
346 const [, server = "", ...tool] = event.name.split("__");
347 return `MCP ${mcpCommandName(server)} › ${tool.join("__")}`;
348 }
349 return event.target === "" ? event.name : `${event.name} ${event.target}`;
350}
351
352function toolSuffix(event: FlowEvent): string {
353 if (event.durationMs === undefined) return " …";
354 return ` ${formatDuration(event.durationMs)}${event.ok === false ? " ✘" : ""}`;
355}
356
357function agentText(event: FlowEvent, hasChildren: boolean): string {
358 const model = event.model === "" ? "" : `(${event.model})`;
359 const label = `Agent ${event.name}${model}`;
360 return !hasChildren && event.target !== "" ? `${label} ${event.target}` : label;
361}
362
363function renderNode(node: TreeNode, first: string, rest: string, columns: number): string[] {
364 if (node.children.length === 0) {
365 const room = Math.max(columns - textWidth(first) - textWidth(node.suffix), 6);
366 return [fit(`${first}${fit(node.text, room)}${node.suffix}`, columns)];
367 }
368 const head = `${node.text}${node.joint}`;
369 const pad = " ".repeat(textWidth(head));
370 const count = node.children.length;
371 return node.children.flatMap((child, index) => {
372 const isLast = index === count - 1;
373 if (index === 0) {
374 return renderNode(
375 child,
376 `${first}${head}${count > 1 ? "┬" : "─"}─ `,
377 `${rest}${pad}${count > 1 ? "│" : " "} `,
378 columns,
379 );
380 }
381 return renderNode(
382 child,
383 `${rest}${pad}${isLast ? "└" : "├"}─ `,
384 `${rest}${pad}${isLast ? " " : "│"} `,
385 columns,
386 );
387 });
388}
389
390// 最近 SHOWN_TURNS 輪(最新在下):每輪一棵樹;subagent 的工具掛在 agent 底下,
391// 對不到 agent 的(例如 spawn 還沒回覆)平放在該輪頂層。
392export function activityLines(events: FlowEvent[], columns: number): string[] {
393 const agentById = new Map<string, FlowEvent>();
394 for (const event of events) {
395 if (event.kind === "agent" && event.agentId !== undefined) agentById.set(event.agentId, event);
396 }
397 const childrenOf = new Map<FlowEvent, FlowEvent[]>();
398 const topOfTurn = new Map<number, FlowEvent[]>();
399 for (const event of events) {
400 const parentId = event.kind === "agent" ? event.parentAgentId : event.agentId;
401 const parent = parentId === undefined ? undefined : agentById.get(parentId);
402 const bucket =
403 parent !== undefined && parent !== event
404 ? (childrenOf.get(parent) ?? childrenOf.set(parent, []).get(parent))
405 : (topOfTurn.get(event.turn) ?? topOfTurn.set(event.turn, []).get(event.turn));
406 bucket?.push(event);
407 }
408
409 const nodeOf = (event: FlowEvent): TreeNode => {
410 const kids = (childrenOf.get(event) ?? []).map(nodeOf);
411 if (event.kind === "agent") {
412 return { text: agentText(event, kids.length > 0), suffix: "", children: kids, joint: "─" };
413 }
414 return { text: toolText(event), suffix: toolSuffix(event), children: [], joint: "" };
415 };
416
417 const turns = [...topOfTurn.keys()].sort((a, b) => a - b).slice(-SHOWN_TURNS);
418 return turns.flatMap((turn) => {
419 const label = turn === 0 ? "載入前起算的一輪" : `第 ${turn} 輪`;
420 const root: TreeNode = {
421 text: label,
422 suffix: "",
423 children: (topOfTurn.get(turn) ?? []).map(nodeOf),
424 joint: " ─",
425 };
426 return renderNode(root, "", "", columns);
427 });
428}
429hooks/quota-model.ts 360 lines1// 額度分頁的純函式:樣本分段、消耗速度、到 95%/100% 的預測、歷史合併與顯示字串。
2// 不碰 `$`(驗證器規定 `$` 不跨檔);時間一律用毫秒數計算,現在時間由呼叫端傳入。
3import { fit, textWidth } from "./flow-model";
4
5export type QuotaSource = "claude" | "codex";
6export type QuotaWindow = "five_hour" | "seven_day";
7
8// 一筆讀數:只有這五個欄位會寫進歷史檔(沒有任何對話內容或帳號)。
9export type Sample = {
10 source: QuotaSource;
11 window: QuotaWindow;
12 pct: number;
13 // 該視窗的重置時間(毫秒)。
14 resetsAt: number;
15 // 取得讀數的時間(毫秒)。
16 at: number;
17};
18
19const HOUR_MS = 3_600_000;
20const MINUTE_MS = 60_000;
21const DAY_MS = 24 * HOUR_MS;
22
23// 同一個週期:resetsAt 相差 ≤ 120 秒;pct 下降 ≥ 5 點視為新週期。
24const SAME_RESET_MS = 120_000;
25const DROP_POINTS = 5;
26// 速度的回看範圍、最短跨度與最小上升量。
27const LOOKBACK_MS: Record<QuotaWindow, number> = { seven_day: 6 * HOUR_MS, five_hour: 60 * MINUTE_MS };
28const MIN_SPAN_MS = 10 * MINUTE_MS;
29const MIN_RISE = 1;
30const WARN_PCT = 95;
31const FULL_PCT = 100;
32// 歷史上限:每個來源與視窗最近 7 天、最多 500 筆;相鄰且相隔不到 5 分鐘的同值讀數不重複存。
33const KEEP_MS = 7 * DAY_MS;
34const KEEP_COUNT = 500;
35const SAME_VALUE_GAP_MS = 5 * MINUTE_MS;
36
37function byTime(a: Sample, b: Sample): number {
38 return (
39 a.at - b.at ||
40 (a.source < b.source ? -1 : a.source > b.source ? 1 : 0) ||
41 (a.window < b.window ? -1 : a.window > b.window ? 1 : 0)
42 );
43}
44
45function keyOf(sample: Sample): string {
46 return `${sample.source}|${sample.window}|${sample.at}`;
47}
48
49// ---------- 分段與預測 ----------
50
51// 取某來源、某視窗「目前這個週期」的樣本(依時間排序):resetsAt 改變或 pct 下降 ≥ 5 點之前的舊資料不算。
52export function currentCycle(samples: Sample[], source: QuotaSource, window: QuotaWindow): Sample[] {
53 const own = samples.filter((sample) => sample.source === source && sample.window === window).sort(byTime);
54 let start = 0;
55 for (let index = 1; index < own.length; index++) {
56 const prev = own[index - 1]!;
57 const cur = own[index]!;
58 if (Math.abs(cur.resetsAt - prev.resetsAt) > SAME_RESET_MS || prev.pct - cur.pct >= DROP_POINTS) start = index;
59 }
60 return own.slice(start);
61}
62
63export type Forecast = {
64 pct: number;
65 resetsAt: number;
66 // 最後一筆讀數的時間。
67 at: number;
68 // 用來算速度的樣本筆數與跨度(回看範圍內)。
69 count: number;
70 spanMs: number;
71 // 每小時上升的百分點;資料不足時沒有。
72 ratePerHour?: number;
73 // 資料不足的原因:span=樣本太少或跨度不到 10 分鐘,flat=沒有上升到 1 點。
74 insufficient?: "span" | "flat";
75 // 目前已到的門檻(0=都沒到)。
76 reached: 0 | 95 | 100;
77 // 照目前速度還要幾小時到 95%/100%(已到、資料不足或已過重置時間時沒有)。
78 hours95?: number;
79 hours100?: number;
80 // 到 95%(100%)的時間點早於重置時間。
81 beforeReset?: boolean;
82 beforeReset100?: boolean;
83 // 最後一筆讀數的重置時間已過:讀數過期,不預測。
84 isExpired: boolean;
85};
86
87// 以目前週期的樣本(可未排序)算預測;沒有樣本回傳 undefined。
88export function forecastOf(cycle: Sample[], now: number): Forecast | undefined {
89 const sorted = [...cycle].sort(byTime);
90 const last = sorted[sorted.length - 1];
91 if (last === undefined) return undefined;
92 const used = sorted.filter((sample) => sample.at >= last.at - LOOKBACK_MS[last.window]);
93 const first = used[0]!;
94 const spanMs = last.at - first.at;
95 const rise = last.pct - first.pct;
96 const reached = last.pct >= FULL_PCT ? FULL_PCT : last.pct >= WARN_PCT ? WARN_PCT : 0;
97 const forecast: Forecast = {
98 pct: last.pct,
99 resetsAt: last.resetsAt,
100 at: last.at,
101 count: used.length,
102 spanMs,
103 reached,
104 isExpired: last.resetsAt <= now,
105 };
106 if (spanMs < MIN_SPAN_MS) forecast.insufficient = "span";
107 else if (rise < MIN_RISE) forecast.insufficient = "flat";
108 else forecast.ratePerHour = rise / (spanMs / HOUR_MS);
109
110 const rate = forecast.ratePerHour;
111 if (rate !== undefined && reached === 0 && !forecast.isExpired) {
112 forecast.hours95 = (WARN_PCT - last.pct) / rate;
113 forecast.hours100 = (FULL_PCT - last.pct) / rate;
114 forecast.beforeReset = now + forecast.hours95 * HOUR_MS < last.resetsAt;
115 forecast.beforeReset100 = now + forecast.hours100 * HOUR_MS < last.resetsAt;
116 }
117 return forecast;
118}
119
120// ---------- 歷史 ----------
121
122// 依 (source, window, at) 去重後依時間排序;同一鍵以後者(記憶體)為準。
123export function mergeHistory(fileSamples: Sample[], memSamples: Sample[]): Sample[] {
124 const byKey = new Map<string, Sample>();
125 for (const sample of [...fileSamples, ...memSamples]) byKey.set(keyOf(sample), sample);
126 return [...byKey.values()].sort(byTime);
127}
128
129// 每個來源與視窗只留最近 7 天、最多 500 筆(較舊的丟棄)。
130export function trimHistory(samples: Sample[], now: number): Sample[] {
131 const groups = new Map<string, Sample[]>();
132 for (const sample of [...samples].sort(byTime)) {
133 if (sample.at < now - KEEP_MS) continue;
134 const key = `${sample.source}|${sample.window}`;
135 const group = groups.get(key);
136 if (group === undefined) groups.set(key, [sample]);
137 else group.push(sample);
138 }
139 const kept: Sample[] = [];
140 for (const group of groups.values()) kept.push(...group.slice(-KEEP_COUNT));
141 return kept.sort(byTime);
142}
143
144// 加一筆樣本:與同來源、同視窗前一筆相比,pct 相同、同一重置週期、相隔不到 5 分鐘就不重複存(回傳原陣列)。
145export function addSample(history: Sample[], sample: Sample, now: number): Sample[] {
146 let prev: Sample | undefined;
147 for (const entry of history) {
148 if (entry.source !== sample.source || entry.window !== sample.window || entry.at > sample.at) continue;
149 if (prev === undefined || entry.at >= prev.at) prev = entry;
150 }
151 if (prev !== undefined) {
152 const isSameValue =
153 prev.pct === sample.pct &&
154 Math.abs(prev.resetsAt - sample.resetsAt) <= SAME_RESET_MS &&
155 sample.at - prev.at < SAME_VALUE_GAP_MS;
156 if (isSameValue) return history;
157 }
158 return trimHistory(mergeHistory(history, [sample]), now);
159}
160
161function isSource(value: unknown): value is QuotaSource {
162 return value === "claude" || value === "codex";
163}
164
165function isWindow(value: unknown): value is QuotaWindow {
166 return value === "five_hour" || value === "seven_day";
167}
168
169function isFiniteNumber(value: unknown): value is number {
170 return typeof value === "number" && Number.isFinite(value);
171}
172
173function sampleOf(value: unknown): Sample | undefined {
174 if (value === null || typeof value !== "object") return undefined;
175 const { source, window, pct, resetsAt, at } = value as Record<string, unknown>;
176 if (!isSource(source) || !isWindow(window) || !isFiniteNumber(pct) || !isFiniteNumber(resetsAt) || !isFiniteNumber(at)) {
177 return undefined;
178 }
179 return { source, window, pct, resetsAt, at };
180}
181
182// 歷史檔格式 `{samples:[...]}`;整個檔案無法解析或格式不符回傳 undefined(呼叫端用記憶體樣本重建),單筆壞掉的略過。
183export function parseHistory(text: string): Sample[] | undefined {
184 let parsed: unknown;
185 try {
186 parsed = JSON.parse(text);
187 } catch {
188 return undefined;
189 }
190 const list = (parsed as { samples?: unknown } | null)?.samples;
191 if (!Array.isArray(list)) return undefined;
192 return list.flatMap((item) => {
193 const sample = sampleOf(item);
194 return sample === undefined ? [] : [sample];
195 });
196}
197
198// 只輸出 Sample 的五個欄位,不會夾帶其他東西。
199export function serializeHistory(samples: Sample[]): string {
200 const rows = samples.map(({ source, window, pct, resetsAt, at }) => ({ source, window, pct, resetsAt, at }));
201 return `${JSON.stringify({ samples: rows })}\n`;
202}
203
204// ---------- 取樣:Claude 與 Codex ----------
205
206// `$.session.usage().rateLimits`:只取 five_hour、seven_day 且有重置時間的(spend_limit 等不算)。
207export function claudeSamples(limits: { kind: string; percentUsed: number; resetsAt?: string }[], now: number): Sample[] {
208 const out: Sample[] = [];
209 for (const limit of limits) {
210 if (!isWindow(limit.kind) || !isFiniteNumber(limit.percentUsed) || limit.resetsAt === undefined) continue;
211 const resetsAt = Date.parse(limit.resetsAt);
212 if (!Number.isFinite(resetsAt)) continue;
213 out.push({ source: "claude", window: limit.kind, pct: limit.percentUsed, resetsAt, at: now });
214 }
215 return out;
216}
217
218export type CodexWindow = { pct: number; windowMinutes: number; resetsAt: number };
219export type CodexReading = { primary?: CodexWindow; secondary?: CodexWindow; observedAt: number };
220export type AgyCounts = { today: number; week: number };
221// 讀取腳本 stdout 的全部欄位。
222export type ScriptResult = {
223 codex: CodexReading | null;
224 codexReason?: string;
225 agy: AgyCounts | null;
226 agyReason?: string;
227};
228
229// Codex 的 primary 是 5 小時、secondary 是週;observedAt(紀錄檔修改時間)當樣本的 at。
230export function codexSamples(reading: CodexReading): Sample[] {
231 const out: Sample[] = [];
232 const pairs: [CodexWindow | undefined, QuotaWindow][] = [
233 [reading.primary, "five_hour"],
234 [reading.secondary, "seven_day"],
235 ];
236 for (const [entry, window] of pairs) {
237 if (entry !== undefined) {
238 out.push({ source: "codex", window, pct: entry.pct, resetsAt: entry.resetsAt, at: reading.observedAt });
239 }
240 }
241 return out;
242}
243
244function codexWindowOf(value: unknown): CodexWindow | undefined {
245 if (value === null || typeof value !== "object") return undefined;
246 const { pct, windowMinutes, resetsAt } = value as Record<string, unknown>;
247 if (!isFiniteNumber(pct) || !isFiniteNumber(windowMinutes) || !isFiniteNumber(resetsAt)) return undefined;
248 return { pct, windowMinutes, resetsAt };
249}
250
251// 驗證並取出腳本輸出的欄位(多餘的欄位丟掉);格式不符回傳 undefined。
252export function parseScriptOutput(text: string): ScriptResult | undefined {
253 let parsed: unknown;
254 try {
255 parsed = JSON.parse(text);
256 } catch {
257 return undefined;
258 }
259 if (parsed === null || typeof parsed !== "object") return undefined;
260 const raw = parsed as Record<string, unknown>;
261 const result: ScriptResult = { codex: null, agy: null };
262
263 const codex = raw.codex;
264 if (codex !== null && codex !== undefined) {
265 if (typeof codex !== "object") return undefined;
266 const body = codex as Record<string, unknown>;
267 const primary = codexWindowOf(body.primary);
268 const secondary = codexWindowOf(body.secondary);
269 if (!isFiniteNumber(body.observedAt) || (primary === undefined && secondary === undefined)) return undefined;
270 result.codex = {
271 ...(primary === undefined ? {} : { primary }),
272 ...(secondary === undefined ? {} : { secondary }),
273 observedAt: body.observedAt,
274 };
275 }
276 if (typeof raw.codexReason === "string") result.codexReason = raw.codexReason;
277
278 const agy = raw.agy;
279 if (agy !== null && agy !== undefined) {
280 if (typeof agy !== "object") return undefined;
281 const { today, week } = agy as Record<string, unknown>;
282 if (!isFiniteNumber(today) || !isFiniteNumber(week)) return undefined;
283 result.agy = { today, week };
284 }
285 if (typeof raw.agyReason === "string") result.agyReason = raw.agyReason;
286 return result;
287}
288
289// ---------- 顯示 ----------
290
291function pad2(value: number): string {
292 return String(value).padStart(2, "0");
293}
294
295// 本地時間:同一天只顯示 `14:30`,不同天顯示 `10/23 08:00`。
296export function clockLabel(ms: number, now: number): string {
297 const date = new Date(ms);
298 const today = new Date(now);
299 const time = `${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
300 const isSameDay =
301 date.getFullYear() === today.getFullYear() && date.getMonth() === today.getMonth() && date.getDate() === today.getDate();
302 return isSameDay ? time : `${date.getMonth() + 1}/${date.getDate()} ${time}`;
303}
304
305export function agoLabel(at: number, now: number): string {
306 const seconds = Math.max(0, Math.floor((now - at) / 1000));
307 if (seconds < 60) return "剛剛";
308 const minutes = Math.floor(seconds / 60);
309 if (minutes < 60) return `${minutes} 分鐘前`;
310 const hours = Math.floor(minutes / 60);
311 if (hours < 24) return `${hours} 小時前`;
312 return `${Math.floor(hours / 24)} 天前`;
313}
314
315function oneDecimal(value: number): string {
316 return String(Math.round(value * 10) / 10);
317}
318
319function hoursLabel(hours: number): string {
320 if (hours < 1) return `${Math.max(1, Math.round(hours * 60))} 分`;
321 return hours < 10 ? `${oneDecimal(hours)} 時` : `${Math.round(hours)} 時`;
322}
323
324// 進度條:「█」已用、「░」未用,95% 的位置放一格「│」當門檻標記(取代該格)。條寬含標記;標記後面至少留一格。
325const BAR_MIN = 6;
326const BAR_MAX = 24;
327
328function barOf(pct: number, width: number): string {
329 const filled = Math.min(width, Math.max(0, Math.round((pct / 100) * width)));
330 const mark = Math.min(width - 2, Math.round((WARN_PCT / 100) * width) - 1);
331 let bar = "";
332 for (let index = 0; index < width; index++) bar += index === mark ? "│" : index < filled ? "█" : "░";
333 return bar;
334}
335
336// 行尾的簡短狀態,依序判斷。
337function statusOf(forecast: Forecast): string {
338 if (forecast.isExpired) return "等新讀數";
339 if (forecast.reached === FULL_PCT) return "已滿";
340 if (forecast.reached === WARN_PCT) return "已達95%";
341 if (forecast.ratePerHour === undefined) return "資料不足";
342 if (forecast.beforeReset === true && forecast.hours95 !== undefined) {
343 return `⚠${hoursLabel(forecast.hours95).replace(" ", "")}後95%`;
344 }
345 return "重置前安全";
346}
347
348// 一個視窗的一行:` 週用量 █████████░░░░│░ 62% ⚠15時後95%`;整行寬度不超過 columns,顏色等級依百分比(≥95 error、≥70 warning)。
349export function quotaBarLine(
350 label: string,
351 forecast: Forecast,
352 columns: number,
353): { text: string; level: "success" | "warning" | "error" } {
354 const head = ` ${label} `;
355 const tail = ` ${oneDecimal(forecast.pct)}% ${statusOf(forecast)}`;
356 const width = Math.min(BAR_MAX, Math.max(BAR_MIN, columns - textWidth(head) - textWidth(tail)));
357 const level = forecast.pct >= WARN_PCT ? "error" : forecast.pct >= 70 ? "warning" : "success";
358 return { text: fit(`${head}${barOf(forecast.pct, width)}${tail}`, columns), level };
359}
360types/index.d.ts 91 lines1declare module "claude-code" {
2 interface PluginState {
3 "session-picker": {
4 picker: {
5 groups: {
6 open: { id: string; title: string; mtimeMs: number }[];
7 paused: { id: string; title: string; mtimeMs: number }[];
8 closed: { id: string; title: string; mtimeMs: number }[];
9 };
10 error: string;
11 actionError: string;
12 loadedAt: number;
13 };
14 sessionGroup: "open" | "paused" | "closed";
15 tab: "session" | "tool" | "mcp" | "flow" | "memory" | "quota";
16 quota: {
17 samples: {
18 source: "claude" | "codex";
19 window: "five_hour" | "seven_day";
20 pct: number;
21 resetsAt: number;
22 at: number;
23 }[];
24 codexAt: number;
25 codexReason: string;
26 agy: { today: number; week: number } | null;
27 agyReason: string;
28 claudeEmpty: boolean;
29 historyError: string;
30 scriptError: string;
31 lastWriteAt: number;
32 };
33 tools: {
34 tools: { name: string; description: string; mcp: boolean }[];
35 error: string;
36 };
37 mcp: {
38 servers: { name: string; connected: boolean; tools: number }[];
39 pending: string[];
40 error: string;
41 actionError: string;
42 notice: string;
43 };
44 memory: {
45 files: { type: string; path: string; tokens: number }[];
46 hasAutoMem: boolean;
47 folder: string[];
48 folderMore: number;
49 folderError: string;
50 error: string;
51 viewing: { path: string; lines: string[]; omitted: number } | null;
52 status: string;
53 statusIsError: boolean;
54 };
55 plan: {
56 files: { name: string; mtimeMs: number }[];
57 selected: string;
58 nodes: {
59 id: string;
60 name: string;
61 agent: string;
62 status: "done" | "problem" | "todo";
63 skipped: boolean;
64 }[];
65 error: string;
66 };
67 copyBar: boolean;
68 copyStatus: { key: string; text: string; isError: boolean };
69 activity: {
70 events: {
71 id: string;
72 kind: "tool" | "agent";
73 turn: number;
74 name: string;
75 target: string;
76 agentId?: string;
77 parentAgentId?: string;
78 model: string;
79 startedAt: number;
80 durationMs?: number;
81 ok?: boolean;
82 }[];
83 turn: number;
84 tools: number;
85 mcp: number;
86 agents: number;
87 };
88 };
89 }
90}
91