視力正常的開發者之間存在一個持續存在的誤解:如果一個應用程式在終端機中運行,它就天生具備無障礙性。這種邏輯假設,因為沒有圖形、沒有複雜的 DOM、也沒有 WebGL 畫布,所以內容只是螢幕閱讀器可以輕鬆解析的原始 ASCII 文字。
現實情況卻是另一回事。大多數現代的文字使用者介面(TUI)對無障礙性的敵意,往往比程式碼寫得不好的圖形介面還要嚴重。那些旨在改善終端機開發者體驗(DX)的工具——例如 Ink (JS/React)、Bubble Tea (Go) 或 tcell 等框架——實際上正在摧毀盲眼使用者的體驗。
要理解這種失敗,我們必須區分「終端機應用程式」這個詞下常常被混淆的兩個不同概念:CLI(命令列介面)和 TUI。
CLI(串流):它基於標準輸入/輸出模型(stdin / stdout)。你輸入一個指令,系統會在下方附加結果,游標向下移動。這是線性和按時間順序的。對於螢幕閱讀器,特別是像 Speakup 這樣的核心層級閱讀器,這是理想的。
TUI(網格):它將終端機視窗視為一個 2D 像素網格,而不是文字串流,其中每個字元儲存格都是一個像素。它放棄了時間流動,轉而採用空間佈局。
讓我們來看一個具體的例子:gemini-cli,一個使用 Ink 框架編寫的 Node.js 工具。表面上看,它就像一個簡單的聊天介面。但實際上,Ink 正在嘗試將一個 React 元件樹協調到一個終端機網格中。
當你使用 Speakup(Linux)或 NVDA(Windows)來使用這個工具時,應用程式不僅僅是失敗;它會主動地向你發送垃圾訊息。
因為該框架將螢幕視為一個響應式畫布,每一次更新都會觸發重繪。當 AI 在「思考」時,該工具會更新一個計時器或一個旋轉指示器。為此,它會將硬體游標移動到計時器位置,寫入新時間,然後移回。
對於視力正常的用戶來說,這瞬間就完成了。對於螢幕閱讀器用戶來說,你會聽到:「回應中... 已耗時 1 秒... 回應中... 已耗時 2 秒... [聊天記錄片段]... 回應中...」
這會讓螢幕閱讀器發瘋。游標在螢幕上到處跳躍,以更新狀態指示器、旋轉指示器和歷史記錄。Speakup 試圖讀取在那一毫秒下游標所在位置的任何內容。最終,你會聽到隨機的對話片段與計時器更新混合在一起,讓你無法專注於你實際正在輸入的內容。
更糟的是,假設你到目前為止用 Speakup 應付得還不錯,但你想用 NVDA 做些工作。也許你想貼上你在 Windows 上遇到的錯誤訊息。於是你打開終端機,SSH 到你的 Linux 主機,連接到你的 screen 會話,然後貼上你的文字。
結果是螢幕閱讀器(NVDA)立即崩潰,或導致系統嚴重不穩定。為什麼?每次你輸入一個字元或貼上文字時,應用程式都會觸發狀態變更。該框架決定需要重新渲染介面。因為對話歷史是該狀態的一部分,應用程式會嘗試立即重繪或重新計算數千行文字的佈局。對話越多,這種情況就越頻繁。而且,你不能僅僅通過使用 insert+5 來避免這種情況,這個組合鍵本應避免宣布內容的動態變更。
此外,像 Ink 這樣運行在單線程環境(如 Node.js)中的框架,在歷史記錄增長時會遭受嚴重的效能下降。如果你貼上大塊文字,系統必須計算數千行的差異。
這會導致輸入延遲。你按下一個按鍵,然後等待。你可能要等長達 10 秒才能看到一個字元回顯。系統忙於計算如何重繪螢幕,而無法實際處理你的輸入。
視力正常的開發者經常問:「如果 TUI 這麼糟糕,為什麼還要使用 nano、vim 或 menuconfig?」
答案並不是因為這些工具預設就能完美處理游標。答案是因為它們允許你完全隱藏游標。
在 nano 或 vim 等工具中,可用性取決於關閉追蹤游標位置的功能。如果你運行 nano 並啟用顯示游標位置的選項(例如 --constantshow ),或者如果你在沒有特定配置的情況下使用 vim,那麼體驗就會被破壞。
當游標可見且追蹤啟動時,Speakup 會優先處理游標位置的更新,而不是字元的回顯。當你輸入字母「a」時,你聽到的不是「a」,而是「第 2 列」。你輸入「b」,聽到的是「第 3 列」。
這些較舊的工具之所以成功,是因為它們允許你禁用這種噪音。你可以配置它們來抑制視覺游標或狀態欄更新,迫使螢幕閱讀器依賴字元輸入串流,而不是嘈雜的座標更新。現代框架很少提供「無游標」或「無頭」模式;它們假設視覺游標是必不可少的。
像 Linux 核心的 menuconfig 這樣的工具之所以有效,是因為它們強制執行了嚴格的單欄式焦點。即使有邊框和標題,活動區域也是一個垂直列表。游標會固定在這個列表上。它不會跳到右下角更新時鐘,然後跳到左上角更新標題。空間複雜度保持足夠低,以至於螢幕閱讀器永遠不會「迷失」。
Irssi 是無障礙聊天的黃金標準,但這並非偶然。Irssi 是在 20 多年裡通過一個利用 VT100 Scrolling Regions 的自訂渲染引擎建立的。
當 Irssi 中收到新訊息時:
它會告訴終端機驅動程式:「定義一個從第 1 行到第 23 行的滾動區域。」
它會發送一個指令:「向上滾動。」終端機會將內容向上移動。
它會在該區域的底部繪製新文字。
關鍵在於,它以盡量減少對輸入行的干擾的方式來處理。它依賴終端機的硬體功能,而不是手動重繪螢幕上的每個字元。現代框架為了「差異化」螢幕狀態和重寫字元而忽略了這些硬體功能,這在計算上更重,並且對無障礙性不利。
Google 和 gemini-cli 的維護者假裝關心無障礙性。「假裝」是這裡的關鍵詞。如果你查看儲存庫,關鍵的無障礙性回歸,如 Issue #3435 和 Issue #11305,已經被擱置。沒有討論,沒有路線圖,也沒有修復。更糟糕的是 Issue #1553 的命運,它本應追蹤這些無障礙性失敗。它沒有被解決;它被靜默了。它被一個機器人自動關閉,並附帶了這個通用的駁回理由:
「您好!作為我們努力保持待辦事項清單可管理並專注於最活躍問題的一部分,我們正在整理較舊的報告。看起來這個 > 問題已經有一段時間沒有活躍了,所以我們暫時關閉它。」
這是不可接受的。因為維護者幾個月沒有處理無障礙性報告就將其關閉,這不是「整理」;這是隱藏證據。它有效地表明,如果一個錯誤被忽略足夠長的時間,它就不復存在了。這提高了專案的「已關閉問題」指標,同時卻讓實際軟體對盲眼使用者來說無法使用。
如果你正在為終端機開發,並且關心無障礙性,請停止使用將終端機視為畫布的聲明式 UI 框架。
「現代」TUI 技術棧為了優化開發者編寫類似 React 的程式碼的能力,而犧牲了機器有效渲染文字的能力。
如果你無法保證你的應用程式允許使用者隱藏游標,或者如果你依賴於積極的重繪來顯示旋轉指示器和計時器,那麼你正在構建一個不具備無障礙性的工具。
對於盲眼使用者來說,一個笨拙、線性的 CLI 串流,遠遠優於一個「聰明」的 TUI,後者會延遲、發送垃圾訊息,並將游標散佈在螢幕各處。