
前四天都在「畫」——佈局、檔案樹、編輯器。今天要處理真正讓這個插件變成工作站的部分:側邊欄怎麼跟終端機說話,終端機又怎麼告訴我們它走到哪了。
這是雙向的兩條路,而且兩條都有坑。
需求很簡單:點檔案樹的資料夾,終端機自動 cd 過去。
第一個直覺是去抓 xterm 實例然後 term.write('cd ...\r')——錯的。term.write() 只是把字元「畫」在畫面上,shell 根本不知道有這回事。畫面上會出現一行假的指令,按 Enter 沒有任何反應。
要真的送進 shell,得走使用者敲鍵盤時走的那條路:renderer 把資料透過 IPC 丟給 main process,main 再寫進 node-pty。

// src/index.js — sendToTerminal()
function sendToTerminal(text) {
const state = hyperStore.getState();
const uid = state.sessions && state.sessions.activeUid;
if (!uid) return;
// PRIMARY: window.rpc.emit —— 跟 Hyper 自己的 xterm 鍵盤輸入走同一個 API
try {
if (window.rpc) {
window.rpc.emit('data', { uid, data: text });
return;
}
} catch { /* fall through */ }
// FALLBACK: 直接打底層 ipcRenderer
try {
const { ipcRenderer } = require('electron');
const channelId = window.__rpcId;
if (channelId) {
ipcRenderer.send(channelId, { ev: 'data', data: [{ uid, data: text }] });
return;
}
} catch { /* no method available */ }
}
兩件事值得說:
uid 是必要的。 一個 Hyper 視窗可以開很多分頁,每個分頁是獨立的 PTY。沒帶 uid 的訊息不知道要寫給誰,會被靜靜丟掉——這是最容易 debug 到懷疑人生的一種失敗:沒有錯誤、沒有例外,就是什麼都沒發生。
備援那條的 payload 形狀不一樣。 window.rpc.emit 收物件,底層 ipcRenderer.send 收的是 { ev, data: [...] }——data 是陣列。這層包裝是 Hyper 的 RPC 協定自己的格式,漏掉方括號一樣是靜默失敗。
會保留兩條路,是因為 window.rpc 是 Hyper 掛在 window 上的非公開物件,不同版本存在與否沒有保證;window.__rpcId 則是更底層、更穩定的東西。這種「在別人的 app 裡動手術」的專案,能多一條退路就多一條。
反方向更難。Sidebar 要顯示正確的檔案樹、Git 面板要對正確的 repo 下指令,都得知道 shell 現在的工作目錄。但 PTY 是個黑盒子——它只會吐出「畫面上該顯示什麼」的位元組流,沒有任何「我 cd 了」的結構化事件。
DevTerminal 用兩條管道解決,一準一糙:

OSC(Operating System Command)是終端機的一組控制序列,其中 OSC 7 專門用來回報當前目錄。格式是:
ESC ] 7 ; file:///C:/Users/foo/project BEL

也就是 \x1b]7;<file URI>\x07。Hyper 認得這個序列,收到後會 dispatch 一個 SESSION_SET_CWD action——對插件來說,這就變成一個乾淨的 Redux 事件了。
讓 shell 吐這個序列,只要改寫 prompt 函式:
# %APPDATA%/devterm-plugin/psreadline-init.ps1
function prompt {
$p = $PWD.ProviderPath
Write-Host -NoNewline "$([char]27)]7;file:///$($p.Replace('\','/'))$([char]7)"
return "PS $p> "
}
[char]27 是 ESC、[char]7 是 BEL,-NoNewline 讓它不換行,反斜線換成正斜線才符合 file URI 規格。因為在 prompt 裡,每次指令跑完重畫提示字元時都會自動送一次,cd 也好、腳本裡切目錄也好,一律涵蓋。
早期版本是把 dot-source 寫進使用者的 PowerShell profile。後來拿掉了——理由寫在程式碼註解裡:
我們不再碰全域 profile:那會影響這台機器上每一個 PowerShell 工作階段(Windows Terminal、VS Code、純主控台),而且會蓋掉使用者自己的
prompt/ oh-my-posh / starship。
改成只在 Hyper 自己啟動 shell 時注入,靠 Day 3 提過的 decorateConfig:
if (process.platform === 'win32' && !config.shell) {
try {
execSync('where pwsh.exe', { stdio: 'ignore' });
result.shell = 'pwsh.exe'; // PS7:PSReadLine 2.3.6 才有預測式自動完成
} catch {
result.shell = 'powershell.exe'; // 退回 PS5.1
}
result.shellArgs = ['-NoLogo', '-NoExit', '-Command',
`. "${path.join(process.env.APPDATA, 'devterm-plugin', 'psreadline-init.ps1')}"`,
];
}
同一份設定順手解決兩件事:OSC 7 回報,以及 PSReadLine 的預測式 IntelliSense。
更值得一提的是 setupPowerShellProfile() 現在做的事——它不再寫入 profile,而是去把舊版本寫進去的那段刪掉:
const cleaned = original.split(/\r?\n/)
.filter((line) =>
!line.includes(PROFILE_MARKER) &&
!/Test-Path.*psreadline-init\.ps1/i.test(line))
.join('\n')
.replace(/\n{3,}/g, '\n\n');
工具改變主意時,要順手把自己以前留下的東西收乾淨,而不是留給使用者自己去發現「為什麼我的 profile 裡有一行不認識的 dot-source」。這是我覺得任何會動使用者系統設定的軟體都該有的自覺。
OSC 7 需要 shell 配合。使用者用的是 cmd、bash,或是自己的 profile 蓋掉了我們的 prompt,就沒戲唱了。所以還有一條備援:直接從 PTY 輸出裡認提示字元。
const PS_PROMPT_RE = /^PS\s+([A-Z]:\\[^>]*?)>\s*$/im; // PS C:\path>
const CMD_PROMPT_RE = /^([A-Z]:\\[^>]*?)>\s*$/im; // C:\path>
function detectCwdFromPtyData(rawData, sessionUid) {
const clean = stripAnsi(rawData);
const lines = clean.split(/\r?\n/).slice(-5); // 提示字元在輸出尾端
// ...比對出 detected
}
stripAnsi 不能省——PTY 流裡到處是顏色控制碼,不洗掉的話 regex 永遠對不上。只看最後 5 行則是因為提示字元一定在尾巴,而且順便避開「使用者 cat 了一個內容長得像路徑的檔案」這種誤判。
後面接三層把關:
// ① 黑名單:太廣的目錄不接受
if (/^[a-z]:\/?\s*$/.test(norm)) return; // 磁碟根目錄
if (/^[a-z]:\/users\/[^/]+\/?$/i.test(norm)) return; // 家目錄
if (/^[a-z]:\/(windows|program files)/i.test(norm)) return; // 系統目錄
// ② 500ms debounce + 真的存在才算數
clearTimeout(cwdDebounceTimer);
cwdDebounceTimer = setTimeout(() => {
if (!fs.existsSync(detected)) return;
const { projectRoot } = resolveProjectRoot(detected);
// ...
}, 500);
resolveProjectRoot() 是最後一層加工:從偵測到的目錄一路往上找 .git,找到就用 git root 當專案根目錄。使用者 cd src/components 之後,檔案樹和 Git 面板依然對著整個專案,不會突然只剩一個子目錄。
這一段是後來補的,理由很實際:
// 提示字元每按一次 Enter 就會重印,少了這道守門,
// 使用者每下一個指令就會觸發完整刷新管線(recent.json 同步寫檔 +
// 4 個 git 子行程 + 一次 re-render),打字愈快、磁碟和 CPU 燒得愈兇。
if (projectRoot === prevCwd && (!isActive || projectRoot === pluginState.cwd)) {
return;
}
「偵測到路徑」和「路徑真的變了」是兩件事。前者一分鐘可以發生幾十次,後者可能一小時才一次。任何從資料流裡推導狀態的設計,都要在最後補一道「值沒變就什麼都別做」,否則成本會跟使用者的打字速度成正比。
上面兩條管道最後都收斂到 Redux middleware。整個插件跟 Hyper 之間的狀態協定,就這五個 action:
exports.middleware = (store) => (next) => (action) => {
if (!hyperStore) hyperStore = store; // 順手偷走 store 給 sendToTerminal 用
switch (action.type) {
case 'SESSION_ADD': // 新分頁:建立 sessions[uid],繼承目前 cwd
case 'SESSION_SET_ACTIVE': // 切分頁:把該 session 記住的 cwd 換回全域
case 'SESSION_SET_CWD': // OSC 7 來的,最準
case 'SESSION_PTY_DATA': // 原始輸出:提示字元偵測 + AI token 統計
case 'SESSION_EXIT': // 關分頁:delete sessions[uid]
}
return next(action);
};
注意 pluginState.sessions 是「每個分頁各自記住自己的 cwd」。切回舊分頁時 SESSION_SET_ACTIVE 會把它的 cwd 換回全域,Sidebar 跟著切——這是分頁式終端機該有的行為,但沒有這張表就做不到。
還有 SESSION_PTY_DATA 那行 tracker.processOutput(action.data):同一份輸出流順便餵給 AI 用量統計模組,從 Claude / OpenAI CLI 的 stdout 裡撈 token 數。同一條資料流上掛兩個消費者,零額外成本。
終端機輸出裡的路徑會被做成可點連結,用的是 xterm 的 registerLinkProvider。要判斷「這串字是不是真的路徑」,自然是 fs.existsSync。然後就中獎了:
// 略過 UNC 路徑(\\server\share)。link provider 在 hover 時同步執行,
// 對無法連線的主機做 fs.existsSync 會卡住整個 renderer 直到 SMB 逾時
// (好幾秒)——而且滑鼠每掃過那一行就再卡一次。
if (/^\\\\/.test(candidate)) continue;
fs.existsSync 對本機路徑是微秒級,對 \\不存在的主機\share 卻要等完整的 SMB 逾時,而且它是同步的——整個 renderer 連同終端機一起凍住。滑鼠只要掃過含有 UNC 路徑的那一行就重來一次。
教訓:在 UI 執行緒上呼叫同步 I/O 之前,先想清楚這個 I/O 最壞情況會等多久。「檔案系統很快」這個假設,只在路徑真的指向本機檔案系統時成立。
window.rpc.emit / ipcRenderer.send),term.write() 只是畫在畫面上uid,漏了是靜默失敗shellArgs,別動使用者的全域 profile;改變主意時要把舊的清乾淨明天(Day 6)是第一部的收尾,也是我踩過最詭異的一個坑:明明綁好的 onClick 完全沒反應。追下去發現是 Hyper 內嵌的 xterm.js 把 React 合成事件吃掉了,解法是回頭用 document 層級的 capture-phase 原生監聽——這也是為什麼前幾天的程式碼裡到處都是 addEventListener(..., true)。