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
1 change: 1 addition & 0 deletions internal/tui/core/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ type appRuntimeState struct {
footerErrorLast string
footerErrorText string
footerErrorUntil time.Time
deferredFooterTick tea.Cmd
startupVisible bool
startupTick int
startupTypingIndex int
Expand Down
105 changes: 105 additions & 0 deletions internal/tui/core/app/command_menu_test.go
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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())
}
}
12 changes: 9 additions & 3 deletions internal/tui/core/app/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 != "" {
Expand Down
60 changes: 45 additions & 15 deletions internal/tui/core/app/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}

Expand Down Expand Up @@ -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}
Expand All @@ -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())
}
}
}

Expand Down Expand Up @@ -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")
}
}

Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
15 changes: 13 additions & 2 deletions internal/tui/core/app/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
12 changes: 12 additions & 0 deletions internal/tui/core/app/view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading