iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0

接續昨天的內容,我們繼續來完成做到一半的刪除圖片吧!

昨天有說到,按下 ctrl + O 後,會有幾個情況:

  1. 沒有圖片 -> 那當然就不用渲染出選單啦,直接 return。
  2. 只有一張圖片 -> 就只要把那張圖片刪掉就好。
  3. 多於一張 -> 那就要派出 questionary.select() 來做選單了,然後也加上 ctrl + O 預覽的功能。

前兩個已經完成了,今天首先來做比較麻煩的第三點。


選單

這裡迴圈的邏輯,直接設為 while attached_images,也就是說,只要還有圖片就會繼續跳出選單。
repeat 的部分是因為,在結束選擇的部分,我想做出區別,如果是首次跳出選單會顯示「取消」,如果已經是第二次或以上則會顯示「不繼續選擇」(此邏輯放在等等 questionary.select() 的參數部分)。
接下來,用 for 取出圖片路徑,然後又用到了 questionary.Choice() 來區別出顯示及賦值(把顯示部分只保留檔名及副檔名):

# src/meowgent/cli/input_prompt.py
...
async def _del_image():
	...
	if ...:
		...
	elif ...:
		...
	
	else:
        repeat = False # 標記是否為重複選擇了
        
        while attached_images: # 刪光時離開

            img_list = []
            for img in attached_images:
                img_list.append(questionary.Choice(
	                title=Path(img).name,
	                value=img
                ))

這邊看一下渲染出的選單效果大致如下圖:
https://ithelp.ithome.com.tw/upload/images/20261006/201836082DNjzYOWnq.png

用到了 questionary.select() 但有注意到嗎?怎麼沒有 ask() 呢?
這是因為我們還有 ctrl + O 的偵測要跑,所以不能馬上送出渲染,而是等邏輯偵測跑完才送。
最後,把 repeat 設為 True 標記為已經選擇至少一次,來為下一輪選單的 ["不繼續選擇"] if repeat else ["取消"] 選項切換做準備。

# src/meowgent/cli/input_prompt.py
...
async def _del_image():
	...
		while ...:
			...
			
			q = questionary.select(
                "選擇要刪除的圖片",
                choices=img_list + (["不繼續選擇"] if repeat else ["取消"]),
                instruction="(Enter: 刪除, Ctrl+O: 開啟圖片)",
                style=Style([
                    ("question", "dim"),         
                    ("instruction", "dim")
                ])
            )
            repeat = True

捕捉開啟圖片的按鍵

接下來開始,是我個人覺得整份專案進行到現在遇到最難的部分。
首先,我們要取得 Question 物件控制項之中的「選單控制器物件」:
解釋一下語法,q 是前面 questionary.select() 沒有 ask() 所得到的「Question 物件」,生成式遍歷 q.application.layout.find_all_controls()(所有的控制項),其中我們要的只有屬於 InquirerControl 物件的控制項(選單控制器物件),
而整個生成式用 next() 去取值。

說一下 next(),它的作用是獲取迭代器的「下一個」元素,像是函數的 yield 回傳、生成式的值。
如果覺得這樣寫不太懂,其實等同於,用串列生成式,然後取出第一個元素:

ic = [c for c in q.application.layout.find_all_controls() if isinstance(c, InquirerControl)][0]
# src/meowgent/cli/input_prompt.py
...
async def _del_image():
	...
		while ...:
			...
			ic = next(
				c for c in q.application.layout.find_all_controls() if isinstance(
					c,
					InquirerControl
				)
			)

得到選單控制器物件後,接著就可以來做按鍵捕捉到要執行的函數:
這裡套上 @q.application.key_bindings.add() 函數裝飾器,這裡是運用了 Question 物件裡的 application 屬性(得到 Application 物件)之中的 key_bindings 屬性來連結到 KeyBindings 物件,然後對 KeyBindings 物件做 add() 來註冊快捷鍵。

參數部分,c-o 指的就是 ctrl + O,eager 則是是否要在快捷鍵「匹配成功時立刻執行」,也就是不等其他更長的快捷鍵出現(例如 ctrl + O + 空格 等的組合,當沒有更複雜的快捷鍵時設為 True 可以減少等待時間)。

而 _open_preview() 就是當捕捉到快捷鍵要執行的函數,而傳入的 event 是 KeyPressEvent 物件,這邊可以暫時不用知道它是做什麼的,因為現在還不會用到。

雖然不會用到,但是一定要傳入,這是被 @key_bindings.add() 裝飾之函數的必選參數。
它代表當前的按鍵觸發事件,讓你可以取得終端當下的上下文資訊(輸入框裡已輸入的文字、游標所在位置,以及選單目前反白停留在哪一個項目),並直接控制輸入框內容或應用程式的狀態(例如強制退出)。

# src/meowgent/cli/input_prompt.py
...
async def _del_image():
	...
		while ...:
			...
			
			@q.application.key_bindings.add("c-o", eager=True)
            def _open_preview(event):

進到 _open_preview() 後,首先要知道當前游標所停留的圖片是哪張,這邊就用到了前面拿的「選單控制器物件 - ic」,對此物件 get_pointed_at() 後就得到當前的選擇,
但還記得嗎,前面有對選項做 questionary.Choice() 所以這邊需要特別去取出 value 才是所選圖片的路徑。

其實,就算沒有做 questionary.Choice() 也需要多一個步驟,只不過 value 也可以用 title 替代,
這是因為 get_pointed_at() 會自動將選項封裝為 Choice 物件,而沒經過 questionary.Choice() 時 value 和 title 是相同的。

# src/meowgent/cli/input_prompt.py
...
async def _del_image():
	...
		while ...:
			...
			
			@...
            def _open_preview(...):
	            current_img = ic.get_pointed_at().value

然後來做開啟圖片的部分,這邊要先確定選到的不是「不繼續選擇」或「取消」,然後用到非同步運行 open 路徑 的指令:

注意,這個指令是 macOS 專屬的。

# src/meowgent/cli/input_prompt.py
...
async def _del_image():
	...
		while ...:
			...
			
			@...
            def _open_preview(...):
	            ...

                if current_img not in ("不繼續選擇", "取消"):  
                    subprocess.Popen(["open", current_img])

接著就可以做選項的賦值了,透過 await 呼叫非同步的 ask_async():

表示這邊先以「非同步方式顯示選單並等待操作」,直到使用者按下 Enter 將選取的圖片結果存入 del_img。

# src/meowgent/cli/input_prompt.py
...
async def _del_image():
	...
		while ...:
			...
			
			@...
            def _open_preview(...):
	            ...
	            
            del_img = await q.ask_async()

得到選擇後,判斷是否有選(沒選則 return),有選的話就對所選做 remove(),然後用 _render_del_msg() 顯示刪除結果:

# src/meowgent/cli/input_prompt.py
...
async def _del_image():
	...
		while ...:
			...
			
			@...
            def _open_preview(...):
	            ...
	            
			if del_img in ("不繼續選擇", "取消", None):
                return

            attached_images.remove(del_img)
            _render_del_msg()

看到這裡,如果你還對整個流程一頭霧水的話,來看看下面這張流程圖:

1. 進入 _del_image() 的 while 迴圈
   │
2. 建立選單物件 (q = questionary.select(...))
   │
3. 動態掛載快捷鍵 (@q.application.key_bindings.add("c-o"))
   └─ 告訴 prompt_toolkit:等一下要是有人按 Ctrl+O,就去跑 _open_preview()
   │
4. 啟動互動選單 (await q.ask_async())
   ├─ 終端機畫面畫出清單,進入等待使用者操作的狀態
   │
   ├─>【分支 A:使用者按上下鍵】
   │     終端機即時移動高亮游標,選單繼續等待。
   │
   ├─>【分支 B:使用者按下 Ctrl + O】
   │  1. 觸發 _open_preview(event)
   │  2. 抓出目前游標反白停在哪一張圖片
   │  3. subprocess.Popen 背景打開圖片(Mac 預覽程式彈出)
   │  4. 函式瞬間結束,終端機完全沒被卡住,圖片與選單同時留在畫面上!
   │
   └─>【分支 C:使用者按下 Enter】
         1. await q.ask_async() 終於完成並回傳選擇的項目 (del_img)
         2. 從暫存清單中將這張圖片移除
         3. 迴圈檢查如果還有圖片,繼續下一輪選單;若無或選取消,退出迴圈。

捕捉刪除圖片的快捷鍵

剛剛已經完成了在圖片選單中的快捷鍵捕捉,接下來要做的是開啟選單(調用 _del_image())的 ctrl + O。
前面的 KeyBindings 物件是從 q.application 取得的,
但這邊,我們是要放在 PromptSession 物件上,而它沒有提供 KeyBindings 物件,所以我們要自行建立:

# src/meowgent/cli/input_prompt.py
def get_input() -> ...:
	...
	
    if _prompt_session is None:
	    kb = KeyBindings()

然後 add() 註冊快捷鍵,catch_del_img 被設為「非同步」,這是因為當前已經有一個元素佔據畫面(PromptSession 輸入框),而 ctrl + O 後會出現第二個 questionary.select(),所以必須用非同步方式。

然後要記得寫 event。

in_terminal() 是用於把目前正在運行的 PromptSession 或 Application(例如 questionary 問答)掛起當前介面,顯示第二個互動介面,
而由於它屬於「非同步上下文管理器物件」所以要搭配「非同步上下文管理器 async with」使用。

為什麼是非同步呢?
這是因為新視窗(questionary)要等待舊視窗(PromptSession)交出鍵盤控制權,以及結束時等待螢幕重繪任務。

為什麼是上下文管理器?
可以做到安全的進入以及離開新視窗。

而管理器裡的 await _del_image() 就是把控制權交給 _del_image(),接著其中的 del_img = await q.ask_async() 又交給 Questionary 的互動選單,而最後,控制權交到了「Event Loop」手上,也就是你的方向鍵去選擇圖片。

當選單操作結束後,控制權又回到了 PromptSession 手上,這時因為剛剛執行了刪除圖片的動作(但也可能沒刪),所以我們要用 event.app.invalidate() 刷新原先 PromptSession 視窗底下的提示(bottom_toolbar),把被刪除掉的圖片顯示移除。

# src/meowgent/cli/input_prompt.py
def get_input() -> ...:
	...
	
    if _prompt_session is None:
	    ...
	    
	    @kb.add("c-o", eager=True) # 捕捉 ctrl o
        async def catch_del_img(event):
            async with in_terminal():
                await _del_image()
                
            event.app.invalidate() # 重繪畫面(針對底下圖片提示)

掛上 KeyBindings

把建立的 key_bindings 物件傳入 PromptSession() 的參數,就大功告成啦!

# src/meowgent/cli/input_prompt.py
def get_input() -> ...:
	...
	
    if _prompt_session is None:
	    _prompt_session = PromptSession(..., key_bindings=kb)
	    
	...

這邊,一樣整理了流程:

[目前執行緒只有一個 Event Loop]

 1. 主輸入框正在等待按鍵...
         ↓
 2. 收到 Ctrl+O,排入 catch_del_img Task
         ↓
 3. async with in_terminal() 
    └─ await 等待終端機將游標與螢幕狀態回報並釋放
         ↓
 4. await _del_image()
    └─ 暫停當前協程,把 Event Loop 的處理權讓給 Questionary 渲染與監聽按鍵
         ↓ (使用者在選單中操作並按 Enter)
 5. _del_image 結束,喚醒原協程
         ↓
 6. 離開 with,終端機復原
         ↓
 7. event.app.invalidate()
    └─ 同步標記「畫面髒了(Dirty)」,告訴 Event Loop 在下一個 Tick 重繪主畫面

哇!今天的內容真的是有夠複雜,尤其是非同步真的搞得我頭很痛,但好在,還是順利搞定了!
明天,要開始做一個很有趣的功能 - subagent,也就是讓主模型可以呼叫子模型做事,像老闆分配工作給員工一樣。


上一篇
Day 22 - 圖片讀取的多模態能力 - 中
下一篇
Day 24 - 利用 subagent 做模型分工 - 上
系列文
手刻 AI Agent!大一新生的 Python 實戰筆記 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言