From 3bba1d109c76c566dd629bf1571800bb12d96ca3 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Wed, 22 Apr 2026 15:58:37 +0000 Subject: [PATCH] fix(tui): resolve startup and footer tick regressions Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: creatang <165447160+creatang@users.noreply.github.com> --- internal/tui/core/app/app.go | 1 + internal/tui/core/app/command_menu_test.go | 105 +++++++++++++++++++++ internal/tui/core/app/update.go | 12 ++- internal/tui/core/app/update_test.go | 60 +++++++++--- internal/tui/core/app/view.go | 15 ++- internal/tui/core/app/view_test.go | 12 +++ 6 files changed, 185 insertions(+), 20 deletions(-) diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index c0f5990a..b0e0b9c3 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -153,6 +153,7 @@ type appRuntimeState struct { footerErrorLast string footerErrorText string footerErrorUntil time.Time + deferredFooterTick tea.Cmd startupVisible bool startupTick int startupTypingIndex int diff --git a/internal/tui/core/app/command_menu_test.go b/internal/tui/core/app/command_menu_test.go index 1aae7bac..ab6f8354 100644 --- a/internal/tui/core/app/command_menu_test.go +++ b/internal/tui/core/app/command_menu_test.go @@ -1,11 +1,17 @@ package tui import ( + "bytes" + "io" "path/filepath" "strings" "testing" + "time" + "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + + agentsession "neo-code/internal/session" ) func TestCommandMenuItem(t *testing.T) { @@ -213,3 +219,102 @@ func TestOpenFileBrowserUsesAbsoluteWorkdir(t *testing.T) { t.Fatalf("expected file picker to be active") } } + +func TestSessionItemAccessors(t *testing.T) { + updatedAt := time.Date(2026, 4, 22, 8, 30, 0, 0, time.UTC) + item := sessionItem{Summary: agentsession.Summary{Title: "Session A", UpdatedAt: updatedAt}} + if item.Title() != "Session A" { + t.Fatalf("Title() = %q, want Session A", item.Title()) + } + if item.Description() != "04-22 08:30" { + t.Fatalf("Description() = %q, want 04-22 08:30", item.Description()) + } + if item.FilterValue() != "session a" { + t.Fatalf("FilterValue() = %q, want session a", item.FilterValue()) + } +} + +type titledOnlyItem struct{ title string } + +func (i titledOnlyItem) Title() string { return i.title } +func (i titledOnlyItem) FilterValue() string { + return i.title +} + +type describedOnlyItem struct{ description string } + +func (i describedOnlyItem) Description() string { return i.description } +func (i describedOnlyItem) FilterValue() string { + return i.description +} + +type invalidListItem struct{} + +func (invalidListItem) FilterValue() string { return "invalid" } + +func TestPickerItemTextFallbackBranches(t *testing.T) { + title, subtitle := pickerItemText(titledOnlyItem{title: " only-title "}) + if title != "only-title" || subtitle != "" { + t.Fatalf("unexpected title-only result: title=%q subtitle=%q", title, subtitle) + } + title, subtitle = pickerItemText(describedOnlyItem{description: " only-desc "}) + if title != "" || subtitle != "only-desc" { + t.Fatalf("unexpected desc-only result: title=%q subtitle=%q", title, subtitle) + } +} + +func TestPickerSelectionDelegateMethods(t *testing.T) { + delegate := pickerSelectionDelegate{} + if delegate.Height() != 2 { + t.Fatalf("Height() = %d, want 2", delegate.Height()) + } + if delegate.Spacing() != 0 { + t.Fatalf("Spacing() = %d, want 0", delegate.Spacing()) + } + if cmd := delegate.Update(tea.KeyMsg{Type: tea.KeyDown}, nil); cmd != nil { + t.Fatalf("expected nil cmd from delegate update, got %T", cmd) + } +} + +func TestPickerSelectionDelegateRenderBranches(t *testing.T) { + delegate := pickerSelectionDelegate{} + model := list.New([]list.Item{ + selectionItem{id: "m1", name: "Model A", description: "desc"}, + }, delegate, 24, 2) + model.Select(0) + + var out bytes.Buffer + delegate.Render(&out, model, 0, selectionItem{id: "m1", name: "Model A", description: "desc"}) + if !strings.Contains(out.String(), "|") { + t.Fatalf("expected selected row indicator, got %q", out.String()) + } + + out.Reset() + delegate.Render(&out, model, 1, selectionItem{id: "m2", name: "Model B", description: ""}) + if strings.TrimSpace(out.String()) == "" { + t.Fatalf("expected non-selected row to render") + } + + out.Reset() + delegate.Render(io.Discard, model, 0, invalidListItem{}) +} + +func TestSessionDelegateMethodsAndRenderGuard(t *testing.T) { + delegate := sessionDelegate{styles: newStyles()} + if delegate.Height() != 3 { + t.Fatalf("Height() = %d, want 3", delegate.Height()) + } + if delegate.Spacing() != 1 { + t.Fatalf("Spacing() = %d, want 1", delegate.Spacing()) + } + if cmd := delegate.Update(tea.KeyMsg{Type: tea.KeyUp}, nil); cmd != nil { + t.Fatalf("expected nil cmd from session delegate update, got %T", cmd) + } + + model := list.New(nil, delegate, 24, 3) + var out bytes.Buffer + delegate.Render(&out, model, 0, invalidListItem{}) + if out.Len() != 0 { + t.Fatalf("expected guard branch to skip invalid item render, got %q", out.String()) + } +} diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index f6cf17ee..f196c631 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -81,6 +81,10 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, a.deferredLogPersistCmd) a.deferredLogPersistCmd = nil } + if a.deferredFooterTick != nil { + cmds = append(cmds, a.deferredFooterTick) + a.deferredFooterTick = nil + } switch typed := msg.(type) { case tea.WindowSizeMsg: @@ -758,7 +762,8 @@ func (a App) handleStartupKey(typed tea.KeyMsg, cmds []tea.Cmd) (tea.Model, tea. a.applyComponentLayout(false) return a, tea.Batch(cmds...), true case key.Matches(typed, a.keys.FocusInput): - return a, tea.Quit, true + a.dismissStartup() + return a, tea.Batch(cmds...), true case key.Matches(typed, a.keys.Quit): return a, tea.Quit, true case isStartupRegularInput(typed): @@ -1890,6 +1895,8 @@ func (a *App) showFooterError(message string) { } a.footerErrorText = message a.footerErrorUntil = a.now().Add(footerErrorFlashDuration) + // 新错误出现时主动补发一次 tick,确保空闲状态下也能驱动自动消失。 + a.deferredFooterTick = appTickCmd() } func (a *App) clearActivities() { @@ -2782,7 +2789,7 @@ func (a App) currentStatusSnapshot() tuistatus.Snapshot { func (a *App) startDraftSession() { a.dismissStartup() a.setActiveSessionID("") - a.startupScreenLocked = true + a.startupScreenLocked = false a.startupIntroActive = false a.startupIntroFrame = 0 a.startupLoopFrame = 0 @@ -2873,7 +2880,6 @@ func (a *App) setActiveSessionID(sessionID string) { next := strings.TrimSpace(sessionID) current := strings.TrimSpace(a.state.ActiveSessionID) if next == "" { - a.startupScreenLocked = true a.startupIntroActive = false a.startupIntroFrame = 0 if current != "" { diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index aa6d700b..12e77330 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -374,20 +374,22 @@ func newTestApp(t *testing.T) (App, *stubRuntime) { return app, runtime } -func TestStartupKeyEscQuits(t *testing.T) { +func TestStartupKeyEscFocusesInput(t *testing.T) { app, _ := newTestApp(t) app.startupVisible = true model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEsc}) next := model.(App) - if !next.startupVisible { - t.Fatalf("expected startup to stay visible before quit command is consumed") + if next.startupVisible { + t.Fatalf("expected startup to be dismissed by Esc") } - if cmd == nil { - t.Fatalf("expected quit command") + if next.focus != panelInput { + t.Fatalf("expected Esc to focus input panel from startup") } - if _, ok := cmd().(tea.QuitMsg); !ok { - t.Fatalf("expected tea.QuitMsg from quit command") + if cmd != nil { + if _, ok := cmd().(tea.QuitMsg); ok { + t.Fatalf("expected Esc not to quit from startup") + } } } @@ -720,7 +722,9 @@ func TestAppUpdateBasic(t *testing.T) { } app = model.(App) if cmd != nil { - t.Error("Update returned non-nil cmd for runFinishedMsg with error") + if _, ok := cmd().(tickMsg); !ok { + t.Errorf("expected optional tick cmd for footer toast, got %T", cmd()) + } } canceledMsg := runFinishedMsg{Err: context.Canceled} @@ -730,7 +734,9 @@ func TestAppUpdateBasic(t *testing.T) { } app = model.(App) if cmd != nil { - t.Error("Update returned non-nil cmd for runFinishedMsg with canceled error") + if _, ok := cmd().(tickMsg); !ok { + t.Errorf("expected optional tick cmd for footer toast, got %T", cmd()) + } } } @@ -3909,9 +3915,10 @@ func TestSetActiveSessionIDTogglesStartupScreenLock(t *testing.T) { t.Fatalf("expected switching to session to unlock startup screen") } + app.startupScreenLocked = false app.setActiveSessionID("") - if !app.startupScreenLocked { - t.Fatalf("expected returning to draft to relock startup screen") + if app.startupScreenLocked { + t.Fatalf("expected returning to draft to keep startup unlocked") } } @@ -4027,6 +4034,9 @@ func TestFooterErrorToastSyncBranches(t *testing.T) { if !app.footerErrorUntil.Equal(base.Add(footerErrorFlashDuration)) { t.Fatalf("unexpected footer toast expiration: %v", app.footerErrorUntil) } + if app.deferredFooterTick == nil { + t.Fatalf("expected footer toast to schedule tick command") + } app.state.ExecutionError = "Runtime failed" app.syncFooterErrorToast() @@ -4049,6 +4059,26 @@ func TestFooterErrorToastSyncBranches(t *testing.T) { } } +func TestDeferredFooterTickDispatchedOnce(t *testing.T) { + app, _ := newTestApp(t) + app.showFooterError("permission denied") + if app.deferredFooterTick == nil { + t.Fatal("expected deferred footer tick to be prepared") + } + + model, cmd := app.Update(tea.WindowSizeMsg{Width: 100, Height: 24}) + app = model.(App) + if app.deferredFooterTick != nil { + t.Fatal("expected deferred footer tick to be cleared after dispatch") + } + if cmd == nil { + t.Fatal("expected update to include deferred footer tick command") + } + if _, ok := cmd().(tickMsg); !ok { + t.Fatalf("expected tick command, got %T", cmd()) + } +} + func TestHandleLogViewerKeyAndScrollBranches(t *testing.T) { app, _ := newTestApp(t) app.width = 100 @@ -4357,14 +4387,14 @@ func TestUpdateFocusInputNewSessionAndTodoScroll(t *testing.T) { if len(runtime.listSessions) != 0 && strings.TrimSpace(app.state.ActiveSessionID) == "" { t.Fatalf("expected Ctrl+N to create or activate draft session") } - if !app.startupScreenLocked { - t.Fatalf("expected Ctrl+N to relock startup screen") + if app.startupScreenLocked { + t.Fatalf("expected Ctrl+N to keep startup unlocked") } if len(app.activeMessages) != 0 { t.Fatalf("expected Ctrl+N to clear transcript messages") } - if view := app.renderWaterfall(100, 24); !strings.Contains(view, "AI-POWERED CLI WORKSPACE") { - t.Fatalf("expected startup screen after Ctrl+N, got %q", view) + if view := app.renderWaterfall(100, 24); strings.Contains(view, "AI-POWERED CLI WORKSPACE") { + t.Fatalf("expected main view after Ctrl+N, got startup content %q", view) } app.focus = panelTodo diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index a1f81956..83c7c232 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -160,9 +160,20 @@ func composeHeaderLine(left string, right string, width int) string { } leftText := tuiutils.TrimMiddle(left, leftMax) - spaceCount := width - lipgloss.Width(leftText) - rightWidth + leftWidth := lipgloss.Width(leftText) + spaceCount := width - leftWidth - rightWidth if spaceCount < gap { - spaceCount = gap + // 终端过窄时继续收缩左侧,优先保证右侧信息与最小间隔不溢出。 + targetLeft := max(0, width-rightWidth-gap) + leftText = tuiutils.TrimMiddle(left, targetLeft) + leftWidth = lipgloss.Width(leftText) + spaceCount = width - leftWidth - rightWidth + } + if spaceCount < 1 { + spaceCount = 1 + } + if leftWidth+spaceCount+rightWidth > width { + return tuiutils.TrimMiddle(right, max(8, width)) } return leftText + strings.Repeat(" ", spaceCount) + right } diff --git a/internal/tui/core/app/view_test.go b/internal/tui/core/app/view_test.go index 207b2b20..4ddd939f 100644 --- a/internal/tui/core/app/view_test.go +++ b/internal/tui/core/app/view_test.go @@ -589,6 +589,18 @@ func TestComposeHeaderLineKeepsRightSectionVisible(t *testing.T) { } } +func TestComposeHeaderLineDoesNotOverflowTightWidth(t *testing.T) { + right := "cwd: /tmp/workdir" + width := lipgloss.Width(right) + line := composeHeaderLine("NeoCode / model / status", right, width) + if got := lipgloss.Width(line); got > width { + t.Fatalf("expected composed header width <= %d, got %d (%q)", width, got, line) + } + if !strings.Contains(line, right) { + t.Fatalf("expected right section preserved, got %q", line) + } +} + func TestRenderPanelAndActivityPreview(t *testing.T) { app, _ := newTestApp(t) panel := app.renderPanel("Title", "Sub", "Body", 60, 8, true)