Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions cmd/ledger/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}

func (m model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if msg.String() == "ctrl+c" {
switch msg.String() {
case "ctrl+c", "ctrl+d":
return m, tea.Quit
}

Expand All @@ -172,8 +173,6 @@ func (m model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
}
case "enter":
return m, m.startSession(m.options[m.cursor])
case "q", "esc":
return m, tea.Quit
}
return m, nil

Expand All @@ -191,11 +190,21 @@ func (m model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.session = nil
}
m.state = stateSelect
// A single branch has no menu to show, so esc reconnects a fresh session.
if len(m.options) == 1 {
return m, m.startSession(m.options[0])
}
return m, nil
case "pgup", "pgdown", "ctrl+u", "ctrl+d":
case "up", "down", "pgup", "pgdown":
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
case "home":
m.viewport.GotoTop()
return m, nil
case "end":
m.viewport.GotoBottom()
return m, nil
case "enter":
if m.running {
return m, nil
Expand Down Expand Up @@ -444,7 +453,7 @@ func (m model) View() tea.View {
content = "Starting accounting TUI..."
case m.err != nil:
content = errorStyle.Render("error: "+m.err.Error()) + "\n\n" +
footerStyle.Render("ctrl+c quit")
footerStyle.Render("ctrl+c/ctrl+d quit")
case m.state == stateSelect && len(m.options) == 1:
content = "Connecting to ledger..."
case m.state == stateSelect:
Expand Down Expand Up @@ -482,7 +491,7 @@ func (m model) selectView() string {
b.WriteString(keyHints(
[2]string{"↑/↓", "move"},
[2]string{"enter", "start"},
[2]string{"q", "quit"},
[2]string{"ctrl+c/ctrl+d", "quit"},
))
return b.String()
}
Expand All @@ -500,12 +509,12 @@ func (m model) chatView() string {

footer := keyHints(
[2]string{"enter", "send"},
[2]string{"pgup/pgdn", "scroll"},
[2]string{"↑/↓ pgup/pgdn", "scroll"},
[2]string{"esc", "back"},
[2]string{"ctrl+c", "quit"},
[2]string{"ctrl+c/ctrl+d", "quit"},
)
if m.running {
footer = keyHints([2]string{"esc", "cancel turn"}, [2]string{"ctrl+c", "quit"})
footer = keyHints([2]string{"esc", "cancel turn"}, [2]string{"ctrl+c/ctrl+d", "quit"})
}

return strings.Join([]string{
Expand Down
85 changes: 82 additions & 3 deletions cmd/ledger/tui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,11 +206,22 @@ func TestModelCtrlCQuitsDuringTurn(t *testing.T) {
}
}

func TestModelEscClosesSessionAndReturns(t *testing.T) {
func TestModelEscReturnsToSelectWithMultipleOptions(t *testing.T) {
fake := &fakeSession{}
m := chatModel(t, fake)
m := newModel(context.Background(), []Option{
{Label: "a", Start: func(context.Context) (Session, error) { return fake, nil }},
{Label: "b", Start: func(context.Context) (Session, error) { return &fakeSession{}, nil }},
})
next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
m = next.(model)

next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
m = next.(model)
ready := cmd().(sessionReadyMsg)
next, _ = m.Update(ready)
m = next.(model)

next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
m = next.(model)
if m.state != stateSelect {
t.Fatalf("state = %v, want stateSelect after esc", m.state)
Expand All @@ -220,6 +231,74 @@ func TestModelEscClosesSessionAndReturns(t *testing.T) {
}
}

func TestModelEscReconnectsWithSingleOption(t *testing.T) {
fake := &fakeSession{}
m := chatModel(t, fake)

next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
m = next.(model)
if !fake.closed {
t.Error("esc should close the open session")
}
if cmd == nil {
t.Fatal("esc with a single option should reconnect a fresh session")
}
if _, ok := cmd().(tea.QuitMsg); ok {
t.Fatal("esc must never quit; only ctrl+c/ctrl+d quit")
}
if _, ok := cmd().(sessionReadyMsg); !ok {
t.Fatalf("esc with a single option should start a fresh session, got %T", cmd())
}
}

func TestModelArrowKeysScrollTranscript(t *testing.T) {
m := chatModel(t, &fakeSession{})
for range 40 {
m.appendLine(line{kind: lineSystem, text: "transcript line"})
}
if !m.viewport.AtBottom() {
t.Fatal("a fresh transcript should be pinned to the bottom")
}

next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyUp})
m = next.(model)
if m.viewport.AtBottom() {
t.Fatal("up should scroll the transcript up, away from the bottom")
}
if m.running {
t.Fatal("scrolling must not start a turn")
}

next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
m = next.(model)
if !m.viewport.AtBottom() {
t.Fatal("down should scroll one line back to the bottom")
}

next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyHome})
m = next.(model)
if !m.viewport.AtTop() {
t.Fatal("home should jump to the top of the transcript")
}

next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnd})
m = next.(model)
if !m.viewport.AtBottom() {
t.Fatal("end should jump to the bottom of the transcript")
}
}

func TestModelCtrlDQuits(t *testing.T) {
m := chatModel(t, &fakeSession{})
_, cmd := m.Update(tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl})
if cmd == nil {
t.Fatal("ctrl+d should produce a command")
}
if _, ok := cmd().(tea.QuitMsg); !ok {
t.Fatalf("ctrl+d should quit, got %T", cmd())
}
}

func TestModelViewDoesNotPanic(t *testing.T) {
m := newTestModel(&fakeSession{})
if m.View().Content == "" {
Expand Down
16 changes: 8 additions & 8 deletions docs/tui-manual-test.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ ledger tui
|------|----------|
| 啟動後看到 **bookkeeper** 選項 | 選擇畫面顯示 `Accounting - choose an agent + scenario` |
| `↑` / `↓` 或 `k` / `j` | 游標移動 |
| `q` 或 `ESC` | 程式結束 |
| `ctrl+c` / `ctrl+d` | 程式結束(`q` / `ESC` 在此不結束程式)|
| `Enter` | 進入 chat 畫面,spinner 短暫出現後 input 可用 |

---
Expand Down Expand Up @@ -231,12 +231,12 @@ JE-XXXX 金額打錯了,幫我沖掉,再用正確的含稅 94,500 重新過

| 操作 | 時機 | 預期行為 |
|------|------|----------|
| `ESC`(turn 進行中) | spinner 轉動時 | 中止該 turn,顯示「turn cancelled」,保持 chat 畫面 |
| `ESC`(turn 完成後) | 無 spinner 時 | 返回選擇畫面,session 關閉 |
| `ctrl+c`(chat 畫面) | 任何時候 | 程式結束(不再用來中止 turn)|
| `pgup` / `pgdn` | 有多輪對話時 | viewport 捲動,不觸發新 turn |
| `ctrl+u` / `ctrl+d` | 同上 | 半頁捲動 |
| `ctrl+c` / `q`(選擇畫面) | 未選任何選項 | 程式結束 |
| `ESC`(turn 進行中) | spinner 轉動時 | 中止該 turn,顯示「turn cancelled」,保持 chat 畫面(不結束程式)|
| `ESC`(turn 完成後) | 無 spinner 時 | 退回選擇畫面,session 關閉;單一分公司時改為重連一個全新 session(清空對話)|
| `ctrl+c` / `ctrl+d` | 任何時候、任何畫面 | 程式結束(唯一的結束方式)|
| `` / `` | chat 畫面、有多輪對話時 | viewport 單行捲動,不觸發新 turn(chat 的輸入是單行,方向鍵改作捲動)|
| `pgup` / `pgdn` | 有多輪對話時 | viewport 整頁捲動,不觸發新 turn |
| `home` / `end` | chat 畫面 | 捲到 transcript 最頂 / 最底(輸入框跳游標改用 `ctrl+a` / `ctrl+e`)|
| 送出空白輸入 | 直接按 Enter | 不觸發 turn,input 維持 focus |

---
Expand All @@ -251,5 +251,5 @@ JE-XXXX 金額打錯了,幫我沖掉,再用正確的含稅 94,500 重新過
- [ ] T-10 指定關帳期間時出現 `reject`,不過帳、不替換期間
- [ ] T-12 停用科目出現 `reject`,LLM 不自行選替代科目
- [ ] 所有 TWD 金額以整數輸入,無小數點
- [ ] `ESC` 中止進行中的 turn 後程式不崩潰、仍可繼續輸入(`ctrl+c` 則是直接結束程式)
- [ ] `ESC` 中止進行中的 turn 後程式不崩潰、仍可繼續輸入;`ESC` 永不結束程式,結束只能按 `ctrl+c` / `ctrl+d`
- [ ] 返回選擇畫面後可重新進入 session