快轉到主要內容
  1. 教學文章/

Python prompt_toolkit 實戰:打造互動式 CLI、Auto-Completion 與 REPL 完全攻略

·10 分鐘· loading · loading · ·
Python Prompt_toolkit Cli REPL Developer-Tools
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 48: 本文

featured

一. 前言:當 CLI 不只是一行 --help
#

很多 Python 工具一開始都很單純:

  • 讀幾個參數
  • 跑一段邏輯
  • 把結果印到 terminal

這種情境下,用 argparseclickTyper 都很夠。拍拍君前面也寫過 argparse 教學click 教學,如果你的需求是「把命令列參數整理好」,那些工具已經很好用了。

但有些工具會慢慢長成另一種樣子:

  • 想要輸入時就看到自動完成
  • 想保留歷史紀錄,按上下鍵就能重用舊指令
  • 想在輸入過程中做驗證,而不是按 Enter 才報錯
  • 想做一個互動式 shell、REPL、問答介面,甚至聊天式 CLI

這時候,單純的 input() 就開始不太夠看了。

今天這篇要介紹的 prompt_toolkit,就是 Python 世界裡處理「互動式終端輸入體驗」的神兵利器。很多你熟悉的工具背後都跟它有關,例如 IPython、ptpython 這類互動 shell,就大量受惠於這個生態。

你可以把它理解成:

如果 argparse 負責「程式啟動前的參數」,那 prompt_toolkit 負責「程式執行中的對話」。

它不只是讓輸入框變漂亮而已,而是幫你把以下這些東西一次補齊:

  • line editing
  • history
  • completion
  • syntax highlighting
  • validation
  • key bindings
  • 多行輸入
  • 互動式 prompt flow

如果你正在做 CLI 工具、內部開發工具、LLM 對話殼、資料查詢小工具,這套真的很值得學。


二. 安裝:先把第一個 prompt 跑起來
#

安裝非常簡單:

pip install prompt_toolkit

# 或用 uv
uv add prompt_toolkit

安裝完成後,先跑一個最小範例:

from prompt_toolkit import prompt

name = prompt("你的名字是什麼? ")
print(f"哈囉,{name}!")

這段看起來很像 input(),但實際體驗差很多。你會立刻得到比較完整的行編輯能力,例如左右移動游標、比較好的貼上處理,之後也能很自然接上自動完成與驗證。

2.1 跟 input() 的第一個差別在哪裡?
#

先說結論:如果你的輸入永遠都只是「問一個字串」,那 input() 真的沒問題。但只要你想開始追加下面這些功能:

  • 命令提示顏色
  • 預設值
  • 輸入驗證
  • history 檔案
  • completer
  • 自訂快捷鍵

prompt_toolkit 的擴充性會漂亮很多。

例如,你可以直接把 prompt 做得更像一個工具:

from prompt_toolkit import prompt
from prompt_toolkit.formatted_text import HTML

user_input = prompt(
    HTML("<skyblue>chatPTT</skyblue> <b>&gt;</b> ")
)

print(user_input)

這裡的 HTML(...) 不是瀏覽器 HTML,而是 prompt_toolkit 提供的一種格式化文字寫法。用它來做提示字串樣式很方便。

2.2 設定預設值與輸入提示
#

from prompt_toolkit import prompt

language = prompt(
    "請輸入語言: ",
    default="python",
)

print(f"你選的是 {language}")

使用者如果直接按 Enter,就會拿到 python。這在互動式工具裡很常用,因為你可以把常見選項設成預設值,讓操作速度快很多。

拍拍君很喜歡這種細節:CLI 不一定要每次都從零開始輸入,幫使用者少敲幾個字,就是 UX。


三. 自動完成:讓 CLI 開始有「對話感」
#

很多人開始愛上 prompt_toolkit,通常就是因為 completion

想像你在做一個內部工具,支援這些命令:

  • deploy
  • logs
  • status
  • rollback

如果每次都要手打完整字串,其實有點煩。加上自動完成後,CLI 的質感會立刻升級。

3.1 用 WordCompleter 做第一個補全
#

from prompt_toolkit import prompt
from prompt_toolkit.completion import WordCompleter

command_completer = WordCompleter(
    ["deploy", "logs", "status", "rollback"],
    ignore_case=True,
)

command = prompt(
    "指令 > ",
    completer=command_completer,
    complete_while_typing=True,
)

print(f"你輸入的是:{command}")

這時候使用者輸入 de,通常就能看到 deploy 的提示。complete_while_typing=True 表示不用特別按 Tab,輸入中就會出現候選結果。

如果你偏好比較傳統的 shell 手感,也可以拿掉這個參數,改成只在按 Tab 時補全。

3.2 補全不只能補一個字
#

例如你正在做 LLM CLI,可以把 model 名稱、指令前綴一起補:

from prompt_toolkit.completion import WordCompleter

shell_completer = WordCompleter(
    [
        "/help",
        "/exit",
        "/clear",
        "/model gpt-4.1",
        "/model gemini-2.5-pro",
        "/model claude-sonnet-4",
    ],
    sentence=True,
    ignore_case=True,
)

這裡 sentence=True 很實用,因為它允許補完整句,而不只是單一 token。對 slash command 類型的介面特別方便。

3.3 用 NestedCompleter 做分層命令
#

如果你的 CLI 有子命令,NestedCompleter 會比純字串清單更自然:

from prompt_toolkit import prompt
from prompt_toolkit.completion import NestedCompleter

completer = NestedCompleter.from_nested_dict(
    {
        "project": {
            "list": None,
            "sync": None,
            "open": None,
        },
        "model": {
            "set": {
                "gpt-4.1": None,
                "gemini-2.5-pro": None,
                "claude-sonnet-4": None,
            },
            "show": None,
        },
        "exit": None,
    }
)

text = prompt("chatPTT > ", completer=completer)
print(text)

這種結構拿來做小型管理工具超適合。你不一定要一開始就導入完整 CLI framework,有時候只是想做一個互動殼,prompt_toolkit 反而更順手。


四. History 與 Validation:好用的工具,通常都記得你剛剛做了什麼
#

互動式工具如果沒有 history,體驗通常會少一大截。尤其是你在 debug、查資料、反覆執行某些命令時,按上下鍵重用舊輸入真的差很多。

4.1 使用 FileHistory
#

from prompt_toolkit import prompt
from prompt_toolkit.history import FileHistory

history = FileHistory(".chatptt-history")

command = prompt(
    "chatPTT > ",
    history=history,
)

print(command)

這樣輸入過的內容就會被寫進 .chatptt-history。下次重新開工具,歷史紀錄還在。

你可以把這個 history 檔放在使用者家目錄或 app data 目錄,例如:

from pathlib import Path
from prompt_toolkit.history import FileHistory

history_path = Path.home() / ".config" / "chatptt" / "history.txt"
history_path.parent.mkdir(parents=True, exist_ok=True)

history = FileHistory(str(history_path))

這種寫法比較像正式工具,而不是 demo。

4.2 用 Validator 在按 Enter 前先擋錯
#

如果你輸入的是 port、email、或某種固定格式,最好不要等後面執行邏輯才發現錯。prompt_toolkit 可以在輸入階段就驗證:

from prompt_toolkit import prompt
from prompt_toolkit.validation import Validator, ValidationError

class PortValidator(Validator):
    def validate(self, document) -> None:
        text = document.text.strip()
        if not text.isdigit():
            raise ValidationError(message="請輸入數字 port")

        value = int(text)
        if not (1 <= value <= 65535):
            raise ValidationError(message="port 必須在 1 到 65535 之間")

port = prompt("Port: ", validator=PortValidator())
print(port)

這個體驗會比後面才印 ValueError 好很多,因為使用者立刻就知道哪裡有問題。

4.3 也可以用 from_callable() 快速定義
#

如果你不想每次都寫 class,也可以這樣:

from prompt_toolkit import prompt
from prompt_toolkit.validation import Validator

not_empty = Validator.from_callable(
    lambda text: bool(text.strip()),
    error_message="不能是空字串",
    move_cursor_to_end=True,
)

project_name = prompt("Project name: ", validator=not_empty)
print(project_name)

對一些簡單規則,這種寫法很省事。


五. PromptSession:當你不只問一次問題
#

如果你的程式只問一個問題,用 prompt() 就夠了。但只要你需要連續輸入、多輪互動、共享 history/completer/style,PromptSession 才是比較實戰的做法

5.1 最小 session 範例
#

from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory

session = PromptSession(
    history=FileHistory(".assistant-history"),
)

while True:
    text = session.prompt("chatPTT > ")

    if text.strip() in {"exit", "quit"}:
        print("bye")
        break

    print(f"你剛剛輸入:{text}")

這樣做的好處是,history、設定、補全器都能集中放在 session 裡,不用每次 prompt() 都重複帶一堆參數。

5.2 跟 completer 整合起來
#

from prompt_toolkit import PromptSession
from prompt_toolkit.completion import WordCompleter
from prompt_toolkit.history import FileHistory

completer = WordCompleter(
    ["/help", "/exit", "/clear", "/model", "/system"],
    ignore_case=True,
)

session = PromptSession(
    completer=completer,
    complete_while_typing=True,
    history=FileHistory(".assistant-history"),
)

while True:
    text = session.prompt("chatPTT > ")
    if text.strip() == "/exit":
        break
    print(f"收到:{text}")

這就是很多互動式 CLI 的骨架了。

5.3 做一個超小型 REPL
#

下面這個例子會稍微更像真的工具:

from prompt_toolkit import PromptSession
from prompt_toolkit.completion import WordCompleter
from prompt_toolkit.history import FileHistory

completer = WordCompleter(
    ["add", "list", "done", "remove", "help", "exit"],
    ignore_case=True,
)

session = PromptSession(
    completer=completer,
    complete_while_typing=True,
    history=FileHistory(".todo-history"),
)

todos: list[str] = []

while True:
    text = session.prompt("todo > ").strip()

    if not text:
        continue

    if text == "help":
        print("add <task> | list | remove <index> | exit")
        continue

    if text == "exit":
        break

    if text == "list":
        for i, item in enumerate(todos, start=1):
            print(f"{i}. {item}")
        continue

    if text.startswith("add "):
        todos.append(text[4:])
        print("已新增")
        continue

    if text.startswith("remove "):
        index_text = text.split(maxsplit=1)[1]
        if index_text.isdigit():
            index = int(index_text) - 1
            if 0 <= index < len(todos):
                removed = todos.pop(index)
                print(f"已刪除:{removed}")
                continue

    print("未知指令,輸入 help 查看用法")

這類程式如果只靠 input() 來寫,功能也不是做不到,但你會很快開始手補很多 UX 細節。prompt_toolkit 就像直接送你一套比較像樣的 terminal 互動地基。


六. 把體驗再往上拉一層:Style、Toolbar 與 Key Bindings
#

當你的 CLI 已經能輸入、補全、記 history,下一步通常就是「讓它更順手」。prompt_toolkit 在這方面很強,因為它本來就是拿來做完整互動體驗的。

6.1 自訂 prompt 樣式
#

from prompt_toolkit import PromptSession
from prompt_toolkit.styles import Style

style = Style.from_dict(
    {
        "prompt": "bold ansicyan",
        "tip": "ansigray",
    }
)

session = PromptSession(style=style)
text = session.prompt([
    ("class:prompt", "chatPTT "),
    ("class:tip", "(Ctrl-D 離開) "),
    ("class:prompt", "> "),
])

print(text)

這種 styled fragments 很適合做比較細緻的 prompt,例如前綴顯示目前環境、模型、專案名稱等等。

6.2 加一個 bottom toolbar
#

from prompt_toolkit import PromptSession

session = PromptSession(
    bottom_toolbar=lambda: "Enter 送出  |  Tab 補全  |  Ctrl-R 搜尋 history"
)

text = session.prompt("chatPTT > ")
print(text)

對新手使用者來說,這種底部提示很有幫助。你不用另外寫 help 文件,操作提示直接黏在輸入框下方。

6.3 自訂快捷鍵
#

from prompt_toolkit import PromptSession
from prompt_toolkit.key_binding import KeyBindings

kb = KeyBindings()

@kb.add("c-l")
def _(event) -> None:
    event.app.current_buffer.reset()

session = PromptSession(key_bindings=kb)
text = session.prompt("chatPTT > ")
print(text)

這裡把 Ctrl-L 綁成清空目前輸入框。你也可以把它改成切換模式、插入模板文字、或觸發某些內建命令。

6.4 多行輸入也沒問題
#

如果你的工具要輸入比較長的內容,例如 system prompt、commit message、SQL 查詢或 LLM 指令,多行輸入就很重要:

from prompt_toolkit import PromptSession

session = PromptSession(multiline=True)

text = session.prompt("message > ")
print(text)

你還可以自己設計 Enter 的語意,例如:

  • Enter 換行
  • Meta+Enter 送出
  • 或某個快捷鍵才結束輸入

這種設計在聊天型工具特別常見。


七. 實戰案例:做一個像樣的 chatPTT 互動殼
#

前面都在拆功能,現在把它們組回來,看一個比較完整的例子。

需求很簡單:

  • 支援 /help/exit/model <name>
  • 有 history
  • 有自動完成
  • 空字串不送出
  • 底部顯示目前模型
from pathlib import Path

from prompt_toolkit import PromptSession
from prompt_toolkit.completion import NestedCompleter
from prompt_toolkit.history import FileHistory
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.styles import Style
from prompt_toolkit.validation import Validator


history_path = Path.home() / ".config" / "chatptt" / "history.txt"
history_path.parent.mkdir(parents=True, exist_ok=True)

current_model = {"name": "gpt-4.1-mini"}

completer = NestedCompleter.from_nested_dict(
    {
        "/help": None,
        "/exit": None,
        "/clear": None,
        "/model": {
            "gpt-4.1-mini": None,
            "gemini-2.5-pro": None,
            "claude-sonnet-4": None,
        },
    }
)

validator = Validator.from_callable(
    lambda text: bool(text.strip()),
    error_message="請輸入內容,不要空白送出",
    move_cursor_to_end=True,
)

style = Style.from_dict(
    {
        "prompt": "bold ansigreen",
        "model": "ansiblue",
        "toolbar": "bg:#202020 #bbbbbb",
    }
)

kb = KeyBindings()

@kb.add("c-k")
def _(event) -> None:
    event.app.current_buffer.reset()

session = PromptSession(
    completer=completer,
    complete_while_typing=True,
    history=FileHistory(str(history_path)),
    validator=validator,
    key_bindings=kb,
    style=style,
    bottom_toolbar=lambda: [
        ("class:toolbar", f" model: {current_model['name']} | Ctrl-K 清空輸入 ")
    ],
)

while True:
    text = session.prompt(
        [
            ("class:prompt", "chatPTT "),
            ("class:model", f"[{current_model['name']}] "),
            ("class:prompt", "> "),
        ]
    ).strip()

    if text == "/exit":
        print("bye")
        break

    if text == "/help":
        print("/help | /exit | /clear | /model <name>")
        continue

    if text == "/clear":
        print("\033[2J\033[H", end="")
        continue

    if text.startswith("/model "):
        _, model_name = text.split(maxsplit=1)
        current_model["name"] = model_name
        print(f"模型切換為:{model_name}")
        continue

    print(f"送到模型:{text}")

這個版本已經很接近真正能拿來包 API 的互動殼了。後面你只要把 print(f"送到模型:{text}") 換成真正的 LLM 呼叫,整個工具就會活起來。

7.1 為什麼這種結構好維護?
#

因為你把 CLI 的互動層拆成幾個清楚的元件:

  • completer 管命令發現性
  • history 管重用輸入
  • validator 管前置檢查
  • key_bindings 管手感
  • bottom_toolbar 管狀態提示

之後就算你想再加:

  • 目前工作目錄
  • API key 狀態
  • streaming 開關
  • system prompt 模板
  • slash command router

也都很容易掛進去。

7.2 跟 click / Typer / Textual 的差異
#

這裡很值得講清楚,因為很多人第一次看到 prompt_toolkit 會問:它跟 click、Typer、Textual 到底怎麼分工?

拍拍君的粗暴版本是這樣:

  • argparse / click / Typer:啟動程式時解析參數
  • prompt_toolkit:程式跑起來後,負責互動輸入體驗
  • Textual:當你需要更完整的 TUI 元件與版面系統

也就是說,它們通常不是互斥關係,反而很常串在一起。

例如你完全可以這樣設計:

  • 用 click 做 mytool chatmytool syncmytool doctor
  • mytool chat 這個子命令裡,用 prompt_toolkit 啟動互動 shell
  • 如果未來想把 chat 模式升級成完整 TUI,再考慮 Textual

這種路徑很務實,不會一開始就把工具做太重。


八. 什麼情況特別適合 prompt_toolkit?
#

如果你還在猶豫要不要用,拍拍君會說下面幾種情況最適合:

  • 你要做 REPL 或聊天式 CLI
  • 你想提供 command completion
  • 你需要輸入 history
  • 你想在輸入階段做 validation
  • 你需要多行輸入與快捷鍵
  • 你的工具會長時間停留在 terminal 裡互動

反過來說,如果你的工具只是:

  • tool --input a.json --output b.json
  • 執行一次就結束
  • 幾乎沒有互動流程

那用 argparse、click、Typer 會比較簡單。不要為了炫技把每個小腳本都做成 pseudo-shell,這樣只會把 UX 弄得比原本更怪。


結語:它不是炫砲,是 CLI 的「手感工程」
#

拍拍君很喜歡 prompt_toolkit 的地方,在於它解的是一個很實際的問題:

很多工具功能明明不差,但互動體驗太粗糙,所以大家不想天天用。

你加一點 history,使用者會更願意重複操作。 你加一點 completion,探索成本就會下降。 你加一點 validation,錯誤就不會拖到執行期才爆。

這些看起來都不是「核心演算法」,但它們會直接決定工具到底像 demo,還是像產品。

如果你最近正好在做:

  • 內部小工具
  • LLM shell
  • 資料查詢 CLI
  • DevOps 指令殼
  • 需要多輪輸入的 terminal app

真的很推薦挑一個小功能,先把 input() 換成 PromptSession 試試看。很多時候你一旦把 auto-completion 和 history 接上去,就回不去了。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 48: 本文

相關文章

Python hypothesis 實戰:Property-Based Testing 與自動化找 bug 完全攻略
·7 分鐘· loading · loading
Python Hypothesis Testing Pytest Developer-Tools
Python watchdog 實戰:檔案變更監控與自動化完全攻略
·8 分鐘· loading · loading
Python Watchdog Automation Filesystem Developer-Tools
Python Textual 實戰:終端機 TUI 應用開發完全攻略
·9 分鐘· loading · loading
Python Textual TUI Cli Terminal
Python tenacity 實戰:重試、退避與容錯機制完全攻略
·9 分鐘· loading · loading
Python Tenacity Retry Backoff 容錯
Python loguru 實戰:告別複雜的 logging 設定,寫出漂亮的日誌
·6 分鐘· loading · loading
Python Logging Loguru 除錯 工具
Python argparse 實戰:CLI 參數解析、旗標設計與 subcommands 完全攻略
·9 分鐘· loading · loading
Python Argparse Cli Command-Line Automation Developer-Tools