在開發 CLI 工具時,除了處理 git status 這類單次執行的命令,我們經常需要打造更具互動性的介面——例如按鍵即時過濾的選單、滾動檢視日誌的 Viewport、REPL 提示字元,或是全螢幕的 TUI 工具。
這些互動式 CLI 介面背後,都依賴相同的四個底層機制:逐鍵讀取輸入、控制畫面重繪、換算座標與捲動範圍,以及管理記憶體中的文字狀態。為了徹底掌握這些 CLI 互動能力,接下來我們將用 Go 實作一個終端機文字編輯器來學習這幾項底層機制:
一般 CLI 以完整命令為一次操作。例如要查看 Git 工作目錄的狀態,你會先在 Shell 輸入:
$ git status
輸入 g、i、t 的過程中,Shell 只是在命令列上收集文字。按下 Enter 後,Shell 才啟動 git,把 status 當成參數交給它。git 不需要逐鍵處理這段輸入,只需要執行完整的 git status 命令。
終端機編輯器啟動後,游標、文字與畫面都要跟著單一按鍵改變。先看一個編輯器畫面,游標目前停在 world 的行尾:
┌──────────────────────────────────────┐
│ hello │
│ world▌ │
│ ~ │
│ ~ │
├──────────────────────────────────────┤
│ notes.txt 2:6 │
└──────────────────────────────────────┘
進入這個畫面後,每個按鍵都要在按下時生效:
←:游標移到 d 前面,畫面變成 worl▌d
!:在游標處插入字元,畫面變成 worl!▌d
notes.txt
這些操作都不能等待 Enter。編輯器必須逐鍵讀取文字、方向鍵與快捷鍵,更新內容與游標,再重畫畫面。互動式選單、REPL、即時 dashboard 與全螢幕 TUI 也使用相同的輸入方式。
接下來的四篇將依序完成這四個部分:
這篇先專注完成第一個部分:輸入路徑。要讓編輯器能即時回應每一次按鍵,我們必須處理兩個問題:
stdin。stdin 讀到的只是 byte 序列(例如按 a 收到 97,按 ↑ 則收到三個連續 bytes 27 91 65)。我們需要實作 readKey 函式,把這些 bytes 解析成統一的 Key 事件(如 KeyArrowUp),交給編輯器更新狀態。整個輸入流程如下:

一般 CLI 啟動時,連接作業系統與程式的終端機通道(tty)預設使用 canonical mode。在這個模式下,tty 驅動程式會先幫你收集輸入並自動把字元顯示在畫面上,直到按下 Enter 才會把整行資料一次交給程式。按下 Ctrl+C 時,tty 驅動程式也會直接中斷並結束程式,不會把 Ctrl+C 當成一般的快捷鍵傳給程式。
CLI 編輯器不能讓 tty 先做這些處理。方向鍵與文字要在按下時立刻交給編輯器,畫面上的文字要由編輯器重畫,Ctrl+C、Ctrl+S 等組合鍵也要保留給編輯器當成快捷鍵。
Raw mode 是 tty 的設定,可以在終端機執行 stty raw 手動切換,再用 stty sane 還原。我們在程式啟動時直接透過代碼進入 raw mode,並在程式結束時自動還原原本的終端機設定。
開啟 cli-sample/go-editor-tutorial/raw-mode/main.go。我們使用 golang.org/x/term 套件的 term.MakeRaw 函式將 stdin 所連接的終端機切換至 Raw Mode。呼叫 defer term.Restore(fd, oldState) 在 main 返回時把終端機還原為原本的設定,避免離開程式後的 Shell 停在 Raw Mode 而無法正常操作。
func main() {
// x/term 透過 file descriptor 修改 stdin 所連接的 tty。
fd := int(os.Stdin.Fd())
// 進入 raw mode,並保存切換前的 termios 設定。
oldState, err := term.MakeRaw(fd)
if err != nil {
panic(err)
}
// main 返回時離開 raw mode,還原原本的終端機設定。
defer term.Restore(fd, oldState)
// 接下來才進入讀取按鍵的迴圈。
}
切換至 Raw Mode 後,程式就能直接讀到鍵盤送出的原始 byte 數值。在開始實作編輯器的 readKey 之前,我們先用一段測試迴圈來觀察輸入行為,看看按下字母、Ctrl 組合鍵與方向鍵時,終端機到底會傳進哪些數字。按 q 可以結束測試:
buf := make([]byte, 1)
for {
os.Stdin.Read(buf)
fmt.Printf("%d\r\n", buf[0])
if buf[0] == 'q' {
break
}
}
在範例的raw-mode 觀察 byte 迴圈:
go run ./raw-mode
執行後:
a,印出 97——立刻印,不用等 Enter,畫面上也沒有多出一個 a
3——程式沒有離開,它只是一個普通的 byte 了27、91、65——同一個方向鍵會產生三個 bytes這段測試程式展示了 Raw Mode 的本質:它只負責把原始 bytes 傳給程式,不會自動告訴程式 27 91 65 代表方向鍵。在正式的編輯器中,我們不會直接印出這些數字,而是透過接下來要寫的 readKey 函式,把這些數值解析成主迴圈能處理的按鍵事件。
終端機傳進來的按鍵主要分為兩類:
a 數值為 97)與 Ctrl 組合鍵(如 Ctrl+C 數值為 3 的控制碼)。27)開頭的多位元序列。為了讓主迴圈能用同一個型別處理所有按鍵,我們自訂 Key 型別。一般字元與 Ctrl 碼直接保存原始數值;方向鍵等特殊功能鍵則從 Unicode 範圍之外(0x110000)開始定義常數,避免與可輸入的字元重疊:
type Key int32
const (
KeyArrowUp Key = 0x110000 + iota // Unicode 上限之後起跳
KeyArrowDown
KeyArrowLeft
KeyArrowRight
KeyPageUp
KeyPageDown
KeyHome
KeyEnd
KeyDelete
KeyEsc
)
有了 Key 型別後,readKey 的解析邏輯以第一個 byte 是否為 27 作為分流邊界:
第一個 byte 不是 27(一般字元與 Ctrl 鍵):直接轉換為 Key。ASCII 可列印字元(32–126)與 Ctrl 鍵(1–26)都是單一 byte,readKey 讀到後不需要再讀下一個 byte:
if c != 27 {
return Key(c)
}
第一個 byte 是 27(Esc 控制序列):按下 ↑ 時,stdin 會連續送出 27、91、65。如果直接分開處理,主迴圈會誤認成按了 Esc、[、A 三次輸入。readKey 必須繼續讀齊後續 bytes 辨認出 KeyArrowUp;如果 27 後面沒有其他資料,才代表使用者單純按了 Esc 鍵(KeyEsc)。
readKey 依照 stdin 收到的序列,還原為對應的按鍵事件:
ESC [ A、ESC [ B、ESC [ C、ESC [ D 時,分別回傳 KeyArrowUp、KeyArrowDown、KeyArrowRight、KeyArrowLeft。ESC [ 5 ~ 或 ESC [ 6 ~ 時,回傳 KeyPageUp 或 KeyPageDown。ESC [ H、ESC [ 1 ~ 或 ESC O H 時回傳 KeyHome;收到 ESC [ F、ESC [ 4 ~ 或 ESC O F 時回傳 KeyEnd。ESC [ 3 ~ 時,回傳 KeyDelete。Esc 鍵本身也是 27,所以 readKey 讀到 27 時會等待 50ms。期限內沒有其他資料,表示使用者只按了 Esc;收到後續 bytes,則繼續解析特殊鍵。不同終端機可能使用不同序列表示 Home 與 End,解析後都會回傳相同的 KeyHome 或 KeyEnd。
readKey 的處理方式如下:
func readKey() Key {
c, err := readByte()
if err != nil {
return ctrl('q') // stdin 關閉時讓主迴圈離開。
}
if c != 27 {
return Key(c) // 一般字元與 Ctrl 組合鍵
}
// 讀到 27:逾時表示單獨的 Esc,否則繼續解析特殊鍵。
seq0, ok := readByteTimeout()
if !ok {
return KeyEsc
}
if seq0 == '[' {
seq1, _ := readByteTimeout()
switch seq1 {
case 'A':
return KeyArrowUp
// ... B C D、H F、數字+'~' 的分支
}
}
// ESC O H/ESC O F 等序列。
return KeyEsc
}
readByteTimeout 只在解析 ESC 開頭的序列時設定 50ms 期限,讀完後會清除期限,因此下一次 readKey 仍會持續等待使用者按鍵。
在範例 go-editor-tutorial/read-keys 執行
go run ./read-keys
執行後按下字母、Ctrl 組合鍵或方向鍵,程式會印出解析後的按鍵名稱(如 a、Ctrl+A、ArrowUp),按 Ctrl+Q 即可離開。完成這一步後,我們成功把終端機的 Raw Mode 原始 bytes 還原為統一的按鍵事件,接通了編輯器與互動式 CLI 的輸入路徑。
處理完輸入後,下一步是畫面輸出。我們將學習用 Escape Sequences 清除終端機、控制游標位置與繪製狀態列,並透過 Buffer 一次性寫入消除重繪時的畫面閃爍。