diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index cc0ba48d..c267cc66 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -28,6 +28,7 @@ type panel = tuistate.Panel const ( panelTranscript panel = tuistate.PanelTranscript panelActivity panel = tuistate.PanelActivity + panelTodo panel = tuistate.PanelTodo panelInput panel = tuistate.PanelInput ) @@ -84,6 +85,7 @@ type appComponents struct { progress progress.Model transcript viewport.Model activity viewport.Model + todo viewport.Model input textarea.Model markdownRenderer markdownContentRenderer } @@ -101,6 +103,11 @@ type appRuntimeState struct { pasteMode bool activeMessages []providertypes.Message activities []tuistate.ActivityEntry + todoItems []todoViewItem + todoFilter todoFilter + todoSelectedIndex int + todoPanelVisible bool + todoCollapsed bool fileCandidates []string modelRefreshID string focus panel @@ -111,6 +118,10 @@ type appRuntimeState struct { pendingPermission *permissionPromptState pendingImageAttachments []pendingImageAttachment providerAddForm *providerAddFormState + layoutCached bool + cachedWidth int + cachedHeight int + viewDirty bool } type pendingImageAttachment struct { @@ -265,6 +276,7 @@ func newApp(container tuibootstrap.Container) (App, error) { progress: progressBar, transcript: viewport.New(0, 0), activity: viewport.New(0, 0), + todo: viewport.New(0, 0), input: input, markdownRenderer: markdownRenderer, }, @@ -272,6 +284,10 @@ func newApp(container tuibootstrap.Container) (App, error) { codeCopyBlocks: make(map[int]string), nowFn: time.Now, focus: panelInput, + todoFilter: todoFilterAll, + layoutCached: true, + cachedWidth: 128, + cachedHeight: 40, }, width: 128, height: 40, @@ -299,11 +315,16 @@ func newApp(container tuibootstrap.Container) (App, error) { return app, nil } +type tickMsg time.Time + func (a App) Init() tea.Cmd { cmds := []tea.Cmd{ ListenForRuntimeEvent(a.runtime.Events()), textarea.Blink, a.spinner.Tick, + tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { + return tickMsg(t) + }), } if cmd := runModelCatalogRefresh(a.providerSvc, a.modelRefreshID); cmd != nil { cmds = append(cmds, cmd) diff --git a/internal/tui/core/app/commands.go b/internal/tui/core/app/commands.go index 653c1ef6..b10a657e 100644 --- a/internal/tui/core/app/commands.go +++ b/internal/tui/core/app/commands.go @@ -62,6 +62,7 @@ const ( activityTitle = "Activity" activitySubtitle = "Latest execution events" + todoTitle = "Todos" draftSessionTitle = "Draft" emptyConversationText = "No conversation yet.\nAsk NeoCode to inspect or change code, or type /help to browse local commands." @@ -85,6 +86,9 @@ const ( statusChooseProvider = "Choose a provider" statusChooseModel = "Choose a model" statusChooseSession = "Choose a session" + statusTodoFilterChanged = "Todo filter updated" + statusTodoCollapsed = "Todo list collapsed" + statusTodoExpanded = "Todo list expanded" statusChooseHelp = "Choose a slash command" statusBrowseFile = "Browse workspace files" statusPermissionRequired = "Permission required: choose a decision and press Enter" @@ -94,6 +98,7 @@ const ( focusLabelSessions = "Sessions" focusLabelTranscript = "Transcript" focusLabelActivity = "Activity" + focusLabelTodo = "Todo" focusLabelComposer = "Composer" maxActivityEntries = 64 diff --git a/internal/tui/core/app/commands_test.go b/internal/tui/core/app/commands_test.go index ce0ccade..7f1be039 100644 --- a/internal/tui/core/app/commands_test.go +++ b/internal/tui/core/app/commands_test.go @@ -19,15 +19,21 @@ func TestBuiltinSlashCommands(t *testing.T) { } found := false + foundTodo := false for _, cmd := range builtinSlashCommands { if cmd.Usage == slashUsageHelp { found = true - break + } + if strings.HasPrefix(cmd.Usage, "/todo") { + foundTodo = true } } if !found { t.Error("expected to find /help command") } + if foundTodo { + t.Error("did not expect /todo command in builtin slash commands") + } } func TestNewSelectionPicker(t *testing.T) { @@ -75,6 +81,8 @@ func TestStatusConstants(t *testing.T) { {"statusCompacting", statusCompacting}, {"statusChooseProvider", statusChooseProvider}, {"statusChooseModel", statusChooseModel}, + {"statusTodoCollapsed", statusTodoCollapsed}, + {"statusTodoExpanded", statusTodoExpanded}, {"statusChooseHelp", statusChooseHelp}, {"statusBrowseFile", statusBrowseFile}, } @@ -98,6 +106,9 @@ func TestFocusLabels(t *testing.T) { if focusLabelActivity == "" { t.Error("focusLabelActivity should not be empty") } + if focusLabelTodo == "" { + t.Error("focusLabelTodo should not be empty") + } if focusLabelComposer == "" { t.Error("focusLabelComposer should not be empty") } diff --git a/internal/tui/core/app/permission_prompt_test.go b/internal/tui/core/app/permission_prompt_test.go index 0faceb81..42c0521c 100644 --- a/internal/tui/core/app/permission_prompt_test.go +++ b/internal/tui/core/app/permission_prompt_test.go @@ -89,6 +89,9 @@ func TestRenderPermissionPrompt(t *testing.T) { }, Selected: 0, }, + layoutCached: true, + cachedWidth: 128, + cachedHeight: 40, }, } rendered := app.renderPermissionPrompt() @@ -163,6 +166,9 @@ func TestRenderPromptWithPendingPermission(t *testing.T) { Request: agentruntime.PermissionRequestPayload{ToolName: "bash", Target: "git status"}, Selected: 0, }, + layoutCached: true, + cachedWidth: 128, + cachedHeight: 40, }, } rendered := app.renderPrompt(80) diff --git a/internal/tui/core/app/todo.go b/internal/tui/core/app/todo.go new file mode 100644 index 00000000..7f912d22 --- /dev/null +++ b/internal/tui/core/app/todo.go @@ -0,0 +1,385 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + "time" + + agentsession "neo-code/internal/session" +) + +type todoFilter string + +const ( + todoFilterAll todoFilter = "all" + todoFilterPending todoFilter = "pending" + todoFilterInProgress todoFilter = "in_progress" + todoFilterBlocked todoFilter = "blocked" + todoFilterCompleted todoFilter = "completed" + todoFilterFailed todoFilter = "failed" + todoFilterCanceled todoFilter = "canceled" +) + +var orderedTodoStatuses = []todoFilter{ + todoFilterPending, + todoFilterInProgress, + todoFilterBlocked, + todoFilterCompleted, + todoFilterFailed, + todoFilterCanceled, +} + +var todoStatusRank = map[string]int{ + string(todoFilterPending): 0, + string(todoFilterInProgress): 1, + string(todoFilterBlocked): 2, + string(todoFilterCompleted): 3, + string(todoFilterFailed): 4, + string(todoFilterCanceled): 5, +} + +const ( + todoCollapsedHeight = 4 + todoMinExpandedHeight = 8 + todoDefaultExpandedLimit = 14 + todoMaxExpandedLimit = 24 + todoHeaderLines = 1 +) + +type todoViewItem struct { + ID string + Title string + Status string + Priority int + Owner string + UpdatedAt time.Time +} + +func parseTodoFilter(input string) (todoFilter, bool) { + filter := todoFilter(strings.ToLower(strings.TrimSpace(input))) + switch filter { + case todoFilterAll, + todoFilterPending, + todoFilterInProgress, + todoFilterBlocked, + todoFilterCompleted, + todoFilterFailed, + todoFilterCanceled: + return filter, true + default: + return "", false + } +} + +func formatTodoOwner(ownerType string, ownerID string) string { + ownerType = strings.TrimSpace(ownerType) + ownerID = strings.TrimSpace(ownerID) + if ownerType == "" && ownerID == "" { + return "-" + } + if ownerType == "" { + return ownerID + } + if ownerID == "" { + return ownerType + } + return ownerType + "/" + ownerID +} + +func mapTodoViewItems(items []agentsession.TodoItem) []todoViewItem { + if len(items) == 0 { + return nil + } + + mapped := make([]todoViewItem, 0, len(items)) + for _, item := range items { + mapped = append(mapped, todoViewItem{ + ID: strings.TrimSpace(item.ID), + Title: strings.TrimSpace(item.Content), + Status: strings.TrimSpace(string(item.Status)), + Priority: item.Priority, + Owner: formatTodoOwner(item.OwnerType, item.OwnerID), + UpdatedAt: item.UpdatedAt, + }) + } + + sort.SliceStable(mapped, func(i, j int) bool { + left := mapped[i] + right := mapped[j] + + leftRank := todoStatusRank[strings.ToLower(left.Status)] + rightRank := todoStatusRank[strings.ToLower(right.Status)] + if leftRank != rightRank { + return leftRank < rightRank + } + if left.Priority != right.Priority { + return left.Priority > right.Priority + } + if !left.UpdatedAt.Equal(right.UpdatedAt) { + return left.UpdatedAt.After(right.UpdatedAt) + } + return left.ID < right.ID + }) + + return mapped +} + +func filterTodoItems(items []todoViewItem, filter todoFilter) []todoViewItem { + if len(items) == 0 { + return nil + } + if filter == todoFilterAll { + out := make([]todoViewItem, len(items)) + copy(out, items) + return out + } + + expected := string(filter) + out := make([]todoViewItem, 0, len(items)) + for _, item := range items { + if strings.EqualFold(strings.TrimSpace(item.Status), expected) { + out = append(out, item) + } + } + return out +} + +func formatTodoUpdatedAt(ts time.Time) string { + if ts.IsZero() { + return "-" + } + return ts.Format("2006-01-02 15:04:05") +} + +func clampTodoSelection(index int, length int) int { + if length <= 0 { + return 0 + } + if index < 0 { + return 0 + } + if index >= length { + return length - 1 + } + return index +} + +func (a *App) visibleTodoItems() []todoViewItem { + return filterTodoItems(a.todoItems, a.todoFilter) +} + +func (a *App) setTodoFilter(filter todoFilter) { + a.todoFilter = filter + a.todoSelectedIndex = 0 + a.todoCollapsed = false + if len(a.todoItems) > 0 { + a.todoPanelVisible = true + } + a.rebuildTodo() +} + +func (a *App) syncTodos(items []agentsession.TodoItem) { + a.todoItems = mapTodoViewItems(items) + if len(a.todoItems) > 0 { + a.todoPanelVisible = true + } + visible := a.visibleTodoItems() + a.todoSelectedIndex = clampTodoSelection(a.todoSelectedIndex, len(visible)) + a.rebuildTodo() +} + +func (a *App) clearTodos() { + a.todoItems = nil + a.todoSelectedIndex = 0 + a.todoPanelVisible = false + a.todoCollapsed = false + a.rebuildTodo() +} + +func (a *App) setTodoCollapsed(collapsed bool) { + a.todoCollapsed = collapsed + if len(a.todoItems) > 0 { + a.todoPanelVisible = true + } + if collapsed { + a.todo.SetYOffset(0) + } + a.rebuildTodo() +} + +func (a *App) toggleTodoCollapsed() bool { + next := !a.todoCollapsed + a.setTodoCollapsed(next) + return next +} + +func (a App) todoPreviewHeight() int { + if !a.todoPanelVisible { + return 0 + } + if a.todoCollapsed { + return todoCollapsedHeight + } + visible := len(a.visibleTodoItems()) + desired := todoMinExpandedHeight + if visible > 0 { + // one table header line + one hint line + desired = visible + 4 + } + + maxHeight := todoDefaultExpandedLimit + if a.height > 0 { + dynamicLimit := (a.height - headerBarHeight) / 2 + if dynamicLimit > maxHeight { + maxHeight = dynamicLimit + } + } + maxHeight = min(todoMaxExpandedLimit, maxHeight) + + return max(todoMinExpandedHeight, min(maxHeight, desired)) +} + +func (a App) renderTodoPreview(width int) string { + if !a.todoPanelVisible { + return "" + } + + mode := "expanded" + if a.todoCollapsed { + mode = "collapsed" + } + visible := a.visibleTodoItems() + subtitle := fmt.Sprintf("%s | Filter: %s | Showing: %d/%d", mode, a.todoFilter, len(visible), len(a.todoItems)) + if len(visible) > 0 { + current := clampTodoSelection(a.todoSelectedIndex, len(visible)) + 1 + subtitle = fmt.Sprintf("%s | Selected: %d", subtitle, current) + } + body := a.todo.View() + if a.todoCollapsed { + body = fmt.Sprintf( + "Collapsed (%d visible / %d total)\nUse Enter or c to expand.", + len(visible), + len(a.todoItems), + ) + } + return a.renderPanel( + todoTitle, + subtitle, + body, + width, + a.todoPreviewHeight(), + a.focus == panelTodo, + ) +} + +func (a *App) rebuildTodo() { + if !a.todoPanelVisible || a.todo.Height <= 0 { + a.todo.SetContent("") + a.todo.GotoTop() + return + } + if a.todoCollapsed { + a.todo.SetContent("") + a.todo.GotoTop() + return + } + + visible := a.visibleTodoItems() + a.todoSelectedIndex = clampTodoSelection(a.todoSelectedIndex, len(visible)) + + lines := []string{ + fmt.Sprintf("ID Title Status Priority Owner Updated At"), + } + if len(visible) == 0 { + lines = append(lines, fmt.Sprintf("No todos for filter %q.", a.todoFilter)) + } else { + for i, item := range visible { + prefix := " " + if i == a.todoSelectedIndex { + prefix = ">" + } + title := item.Title + if title == "" { + title = "(empty)" + } + lines = append(lines, fmt.Sprintf( + "%s %s | %s | %s | P%d | %s | %s", + prefix, + item.ID, + title, + item.Status, + item.Priority, + item.Owner, + formatTodoUpdatedAt(item.UpdatedAt), + )) + } + lines = append( + lines, + fmt.Sprintf( + "Selected %d/%d | Up/Down move | Enter detail | c collapse", + a.todoSelectedIndex+1, + len(visible), + ), + ) + } + + content := strings.Join(lines, "\n") + a.todo.SetContent(content) + a.ensureTodoSelectionVisible(len(visible)) +} + +func (a *App) moveTodoSelection(delta int) { + if a.todoCollapsed { + return + } + visible := a.visibleTodoItems() + if len(visible) == 0 { + return + } + a.todoSelectedIndex = clampTodoSelection(a.todoSelectedIndex+delta, len(visible)) + a.rebuildTodo() +} + +func (a *App) ensureTodoSelectionVisible(visibleCount int) { + if visibleCount <= 0 || a.todo.Height <= 0 { + a.todo.SetYOffset(0) + return + } + + // Row 0 is header, todo rows start at line 1. + selectedLine := todoHeaderLines + clampTodoSelection(a.todoSelectedIndex, visibleCount) + top := max(0, a.todo.YOffset) + bottom := top + max(1, a.todo.Height) - 1 + + switch { + case selectedLine < top: + a.todo.SetYOffset(selectedLine) + case selectedLine > bottom: + a.todo.SetYOffset(selectedLine - max(1, a.todo.Height) + 1) + } +} + +func (a *App) openSelectedTodoDetail() { + if a.todoCollapsed { + a.state.StatusText = "Todo list is collapsed" + return + } + visible := a.visibleTodoItems() + if len(visible) == 0 { + a.state.StatusText = "No todo selected" + return + } + current := visible[clampTodoSelection(a.todoSelectedIndex, len(visible))] + lines := []string{ + fmt.Sprintf("[Todo] %s", current.ID), + fmt.Sprintf("title: %s", current.Title), + fmt.Sprintf("status: %s", current.Status), + fmt.Sprintf("priority: %d", current.Priority), + fmt.Sprintf("owner: %s", current.Owner), + fmt.Sprintf("updated_at: %s", formatTodoUpdatedAt(current.UpdatedAt)), + } + a.appendInlineMessage(roleSystem, strings.Join(lines, "\n")) + a.rebuildTranscript() + a.state.StatusText = fmt.Sprintf("Opened todo %s", current.ID) +} diff --git a/internal/tui/core/app/todo_test.go b/internal/tui/core/app/todo_test.go new file mode 100644 index 00000000..881346c8 --- /dev/null +++ b/internal/tui/core/app/todo_test.go @@ -0,0 +1,604 @@ +package tui + +import ( + "fmt" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + agentruntime "neo-code/internal/runtime" + agentsession "neo-code/internal/session" +) + +func TestParseTodoFilter(t *testing.T) { + cases := []struct { + input string + want todoFilter + ok bool + }{ + {input: "", ok: false}, + {input: "all", want: todoFilterAll, ok: true}, + {input: "pending", want: todoFilterPending, ok: true}, + {input: "in_progress", want: todoFilterInProgress, ok: true}, + {input: "blocked", want: todoFilterBlocked, ok: true}, + {input: "completed", want: todoFilterCompleted, ok: true}, + {input: "failed", want: todoFilterFailed, ok: true}, + {input: "canceled", want: todoFilterCanceled, ok: true}, + {input: "unknown", ok: false}, + } + for _, tc := range cases { + got, ok := parseTodoFilter(tc.input) + if ok != tc.ok { + t.Fatalf("parseTodoFilter(%q) ok=%v, want %v", tc.input, ok, tc.ok) + } + if got != tc.want { + t.Fatalf("parseTodoFilter(%q) got=%q, want %q", tc.input, got, tc.want) + } + } +} + +func TestMapTodoViewItemsSortOrder(t *testing.T) { + now := time.Now() + input := []agentsession.TodoItem{ + {ID: "todo-c", Content: "C", Status: agentsession.TodoStatusCompleted, Priority: 1, UpdatedAt: now.Add(-1 * time.Minute)}, + {ID: "todo-a", Content: "A", Status: agentsession.TodoStatusPending, Priority: 2, UpdatedAt: now}, + {ID: "todo-b", Content: "B", Status: agentsession.TodoStatusPending, Priority: 3, UpdatedAt: now.Add(-2 * time.Minute)}, + {ID: "todo-d", Content: "D", Status: agentsession.TodoStatusCompleted, Priority: 5, UpdatedAt: now}, + } + + got := mapTodoViewItems(input) + if len(got) != len(input) { + t.Fatalf("expected %d items, got %d", len(input), len(got)) + } + + // status -> priority -> updated_at -> id + wantOrder := []string{"todo-b", "todo-a", "todo-d", "todo-c"} + for i, id := range wantOrder { + if got[i].ID != id { + t.Fatalf("order[%d] expected %s, got %s", i, id, got[i].ID) + } + } +} + +func TestFilterTodoItems(t *testing.T) { + items := []todoViewItem{ + {ID: "a", Status: "pending"}, + {ID: "b", Status: "completed"}, + } + all := filterTodoItems(items, todoFilterAll) + if len(all) != 2 { + t.Fatalf("expected all size 2, got %d", len(all)) + } + pending := filterTodoItems(items, todoFilterPending) + if len(pending) != 1 || pending[0].ID != "a" { + t.Fatalf("expected only pending item a, got %#v", pending) + } +} + +func TestFormatTodoOwner(t *testing.T) { + if got := formatTodoOwner("", ""); got != "-" { + t.Fatalf("expected -, got %q", got) + } + if got := formatTodoOwner("", "neo"); got != "neo" { + t.Fatalf("expected owner id, got %q", got) + } + if got := formatTodoOwner("agent", ""); got != "agent" { + t.Fatalf("expected owner type, got %q", got) + } + if got := formatTodoOwner("agent", "neo"); got != "agent/neo" { + t.Fatalf("expected owner composite, got %q", got) + } +} + +func TestMapTodoViewItemsTieBreakByID(t *testing.T) { + now := time.Now() + items := []agentsession.TodoItem{ + {ID: "b", Content: "B", Status: agentsession.TodoStatusPending, Priority: 1, UpdatedAt: now}, + {ID: "a", Content: "A", Status: agentsession.TodoStatusPending, Priority: 1, UpdatedAt: now}, + } + got := mapTodoViewItems(items) + if len(got) != 2 { + t.Fatalf("expected 2 items, got %d", len(got)) + } + if got[0].ID != "a" || got[1].ID != "b" { + t.Fatalf("expected id tie-break sort, got %#v", got) + } +} + +func TestMapTodoViewItemsSortByUpdatedAt(t *testing.T) { + now := time.Now() + items := []agentsession.TodoItem{ + {ID: "older", Content: "Older", Status: agentsession.TodoStatusPending, Priority: 1, UpdatedAt: now.Add(-1 * time.Minute)}, + {ID: "newer", Content: "Newer", Status: agentsession.TodoStatusPending, Priority: 1, UpdatedAt: now}, + } + got := mapTodoViewItems(items) + if len(got) != 2 { + t.Fatalf("expected 2 items, got %d", len(got)) + } + if got[0].ID != "newer" || got[1].ID != "older" { + t.Fatalf("expected updated_at desc order, got %#v", got) + } +} + +func TestClampTodoSelection(t *testing.T) { + if got := clampTodoSelection(-1, 3); got != 0 { + t.Fatalf("expected clamp to 0, got %d", got) + } + if got := clampTodoSelection(5, 3); got != 2 { + t.Fatalf("expected clamp to 2, got %d", got) + } + if got := clampTodoSelection(1, 3); got != 1 { + t.Fatalf("expected unchanged index 1, got %d", got) + } + if got := clampTodoSelection(1, 0); got != 0 { + t.Fatalf("expected empty length to clamp 0, got %d", got) + } +} + +func TestTodoPreviewHeight(t *testing.T) { + app, _ := newTestApp(t) + if got := app.todoPreviewHeight(); got != 0 { + t.Fatalf("expected hidden todo panel height 0, got %d", got) + } + + app.todoPanelVisible = true + if got := app.todoPreviewHeight(); got != 8 { + t.Fatalf("expected empty visible todo panel height 8, got %d", got) + } + + app.todoItems = make([]todoViewItem, 30) + dynamicLimit := (app.height - headerBarHeight) / 2 + if dynamicLimit < todoDefaultExpandedLimit { + dynamicLimit = todoDefaultExpandedLimit + } + if dynamicLimit > todoMaxExpandedLimit { + dynamicLimit = todoMaxExpandedLimit + } + if got := app.todoPreviewHeight(); got != dynamicLimit { + t.Fatalf("expected clamped todo panel height %d, got %d", dynamicLimit, got) + } + + app.todoCollapsed = true + if got := app.todoPreviewHeight(); got != todoCollapsedHeight { + t.Fatalf("expected collapsed todo panel height %d, got %d", todoCollapsedHeight, got) + } +} + +func TestRenderTodoPreviewAndEmptyRebuild(t *testing.T) { + app, _ := newTestApp(t) + app.todoPanelVisible = true + app.todoFilter = todoFilterPending + app.todo.Width = 100 + app.todo.Height = 10 + + app.rebuildTodo() + if !strings.Contains(app.todo.View(), "No todos for filter") { + t.Fatalf("expected empty todo text, got %q", app.todo.View()) + } + rendered := app.renderTodoPreview(100) + if !strings.Contains(rendered, todoTitle) { + t.Fatalf("expected todo title in panel render") + } + + app.todoCollapsed = true + rendered = app.renderTodoPreview(100) + if !strings.Contains(rendered, "Collapsed") { + t.Fatalf("expected collapsed summary in panel render") + } +} + +func TestSetTodoFilterAndRebuild(t *testing.T) { + app, _ := newTestApp(t) + app.todo.Width = 100 + app.todo.Height = 10 + app.todoItems = []todoViewItem{ + {ID: "todo-1", Title: "first", Status: "pending"}, + {ID: "todo-2", Title: "second", Status: "completed"}, + } + + app.setTodoFilter(todoFilterPending) + if app.todoFilter != todoFilterPending { + t.Fatalf("expected pending filter, got %q", app.todoFilter) + } + if !app.todoPanelVisible { + t.Fatalf("expected todo panel visible") + } + if !strings.Contains(app.todo.View(), "todo-1") || strings.Contains(app.todo.View(), "todo-2") { + t.Fatalf("expected rendered todo content to respect filter, got %q", app.todo.View()) + } +} + +func TestSetAndToggleTodoCollapsed(t *testing.T) { + app, _ := newTestApp(t) + app.todoPanelVisible = false + app.todoItems = []todoViewItem{ + {ID: "todo-1", Title: "Plan", Status: "pending", Priority: 2, Owner: "agent/neo", UpdatedAt: time.Now()}, + } + app.setTodoCollapsed(true) + if !app.todoPanelVisible || !app.todoCollapsed { + t.Fatalf("expected setTodoCollapsed to show panel and collapse it") + } + if collapsed := app.toggleTodoCollapsed(); collapsed { + t.Fatalf("expected toggle to expand panel") + } + if app.todoCollapsed { + t.Fatalf("expected expanded after toggle") + } +} + +func TestOpenSelectedTodoDetail(t *testing.T) { + app, _ := newTestApp(t) + app.todoItems = []todoViewItem{ + {ID: "todo-1", Title: "Plan", Status: "pending", Priority: 2, Owner: "agent/neo", UpdatedAt: time.Now()}, + } + app.todoPanelVisible = true + app.todoSelectedIndex = 0 + app.openSelectedTodoDetail() + + if len(app.activeMessages) == 0 { + t.Fatalf("expected detail message appended") + } + last := app.activeMessages[len(app.activeMessages)-1] + if !strings.Contains(messageText(last), "[Todo] todo-1") { + t.Fatalf("expected todo detail in transcript, got %q", messageText(last)) + } +} + +func TestHandleImmediateSlashCommandTodoIsNotHandled(t *testing.T) { + app, _ := newTestApp(t) + handled, cmd := app.handleImmediateSlashCommand("/todo") + if handled || cmd != nil { + t.Fatalf("expected /todo to not be treated as immediate command") + } +} + +func TestTodoPanelKeyInteractionsInUpdate(t *testing.T) { + app, _ := newTestApp(t) + app.todoItems = []todoViewItem{ + {ID: "todo-1", Title: "A", Status: "pending"}, + {ID: "todo-2", Title: "B", Status: "pending"}, + } + app.todoPanelVisible = true + app.todo.Width = 100 + app.todo.Height = 10 + app.rebuildTodo() + app.focus = panelTodo + + model, _ := app.Update(tea.KeyMsg{Type: tea.KeyDown}) + app = model.(App) + if app.todoSelectedIndex != 1 { + t.Fatalf("expected selection move to index 1, got %d", app.todoSelectedIndex) + } + + model, _ = app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + if len(app.activeMessages) == 0 { + t.Fatalf("expected enter to open todo detail message") + } + + model, _ = app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) + app = model.(App) + if !app.todoCollapsed { + t.Fatalf("expected c key to collapse panel") + } + + model, _ = app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + if app.todoCollapsed { + t.Fatalf("expected enter to expand collapsed todo panel") + } +} + +func TestHandleTodoMouseHeaderTogglesCollapse(t *testing.T) { + app, _ := newTestApp(t) + app.todoPanelVisible = true + app.todoItems = []todoViewItem{{ID: "todo-1", Title: "A", Status: "pending"}} + app.todoFilter = todoFilterAll + app.applyComponentLayout(true) + app.rebuildTodo() + + x, y, _, _ := app.todoBounds() + handled := app.handleTodoMouse(tea.MouseMsg{ + X: x + 1, + Y: y + 1, + Button: tea.MouseButtonLeft, + Action: tea.MouseActionPress, + }) + if !handled { + t.Fatalf("expected header click to be handled") + } + if !app.todoCollapsed { + t.Fatalf("expected header click to collapse todo panel") + } + + x, y, _, _ = app.todoBounds() + handled = app.handleTodoMouse(tea.MouseMsg{ + X: x + 1, + Y: y + 1, + Button: tea.MouseButtonLeft, + Action: tea.MouseActionPress, + }) + if !handled { + t.Fatalf("expected second header click to be handled") + } + if app.todoCollapsed { + t.Fatalf("expected second header click to expand todo panel") + } +} + +func TestHandleTodoMouseSelectsItem(t *testing.T) { + app, _ := newTestApp(t) + app.todoPanelVisible = true + app.todoFilter = todoFilterAll + app.todoItems = []todoViewItem{ + {ID: "todo-1", Title: "A", Status: "pending"}, + {ID: "todo-2", Title: "B", Status: "pending"}, + {ID: "todo-3", Title: "C", Status: "pending"}, + } + app.layoutCached = false + app.applyComponentLayout(true) + app.rebuildTodo() + + x, y, _, _ := app.todoBounds() + handled := app.handleTodoMouse(tea.MouseMsg{ + X: x + 1, + Y: y + 4, + Button: tea.MouseButtonLeft, + Action: tea.MouseActionPress, + }) + if !handled { + t.Fatalf("expected body click to be handled") + } + if app.todoSelectedIndex != 1 { + t.Fatalf("expected click to select second todo item, got %d", app.todoSelectedIndex) + } +} + +func TestHandleTodoMouseWheelMovesByStep(t *testing.T) { + app, _ := newTestApp(t) + app.todoPanelVisible = true + app.todoFilter = todoFilterAll + for i := 0; i < 20; i++ { + app.todoItems = append(app.todoItems, todoViewItem{ + ID: fmt.Sprintf("todo-%02d", i), + Title: "task", + Status: "pending", + Priority: 1, + }) + } + app.applyComponentLayout(true) + app.rebuildTodo() + + x, y, _, _ := app.todoBounds() + handled := app.handleTodoMouse(tea.MouseMsg{ + X: x + 1, + Y: y + 4, + Button: tea.MouseButtonWheelDown, + Action: tea.MouseActionPress, + }) + if !handled { + t.Fatalf("expected wheel-down in todo panel to be handled") + } + want := mouseWheelStepLines + if want > len(app.visibleTodoItems())-1 { + want = len(app.visibleTodoItems()) - 1 + } + if app.todoSelectedIndex != want { + t.Fatalf("expected wheel-down to move by %d, got %d", want, app.todoSelectedIndex) + } +} + +func TestUpdateInputTodoCommandFallsBackToLocalCommand(t *testing.T) { + app, _ := newTestApp(t) + app.focus = panelInput + app.input.SetValue("/todo collapse") + app.state.InputText = "/todo collapse" + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + if cmd == nil { + t.Fatalf("expected /todo collapse to fall back to local slash command execution") + } + if app.todoCollapsed { + t.Fatalf("did not expect /todo collapse to toggle todo panel via slash command") + } + if app.state.StatusText != statusApplyingCommand { + t.Fatalf("expected applying command status, got %q", app.state.StatusText) + } +} + +func TestMoveTodoSelectionNoVisibleItems(t *testing.T) { + app, _ := newTestApp(t) + app.todoPanelVisible = true + app.todo.Width = 100 + app.todo.Height = 10 + app.todoItems = nil + app.todoSelectedIndex = 5 + + app.moveTodoSelection(1) + if app.todoSelectedIndex != 5 { + t.Fatalf("expected no selection change when no visible items, got %d", app.todoSelectedIndex) + } +} + +func TestMoveTodoSelectionWhenCollapsed(t *testing.T) { + app, _ := newTestApp(t) + app.todoItems = []todoViewItem{{ID: "todo-1", Status: "pending"}} + app.todoPanelVisible = true + app.todoCollapsed = true + app.todoSelectedIndex = 0 + + app.moveTodoSelection(1) + if app.todoSelectedIndex != 0 { + t.Fatalf("expected collapsed panel to ignore selection movement") + } +} + +func TestMoveTodoSelectionScrollsViewportForLongList(t *testing.T) { + app, _ := newTestApp(t) + app.todoPanelVisible = true + app.todoCollapsed = false + app.todo.Width = 120 + app.todo.Height = 3 + app.todoFilter = todoFilterAll + for i := 0; i < 12; i++ { + app.todoItems = append(app.todoItems, todoViewItem{ + ID: fmt.Sprintf("todo-%02d", i), + Title: "task", + Status: "pending", + Priority: 1, + }) + } + app.rebuildTodo() + if app.todo.YOffset != 0 { + t.Fatalf("expected initial offset 0, got %d", app.todo.YOffset) + } + + for i := 0; i < 8; i++ { + app.moveTodoSelection(1) + } + if app.todoSelectedIndex < 8 { + t.Fatalf("expected selection moved down, got %d", app.todoSelectedIndex) + } + if app.todo.YOffset == 0 { + t.Fatalf("expected viewport offset to advance for long list") + } +} + +func TestEnsureTodoSelectionVisibleScrollsUpBranch(t *testing.T) { + app, _ := newTestApp(t) + app.todo.Height = 3 + app.todoSelectedIndex = 0 + app.todo.SetContent(strings.Repeat("line\n", 30)) + app.todo.SetYOffset(6) + if app.todo.YOffset != 6 { + t.Fatalf("expected precondition y offset 6, got %d", app.todo.YOffset) + } + + app.ensureTodoSelectionVisible(10) + if app.todo.YOffset >= 6 { + t.Fatalf("expected y offset to move up, got %d", app.todo.YOffset) + } +} + +func TestRefreshTodosFromSession(t *testing.T) { + app, runtime := newTestApp(t) + app.state.ActiveSessionID = "session-1" + runtime.loadSessions = map[string]agentsession.Session{ + "session-1": { + ID: "session-1", + Title: "S1", + Todos: []agentsession.TodoItem{ + {ID: "todo-1", Content: "Todo 1", Status: agentsession.TodoStatusPending, Priority: 1, UpdatedAt: time.Now()}, + }, + }, + } + + if err := app.refreshTodosFromSession("session-1"); err != nil { + t.Fatalf("refreshTodosFromSession error = %v", err) + } + if len(app.todoItems) != 1 || app.todoItems[0].ID != "todo-1" { + t.Fatalf("expected todo items synced, got %#v", app.todoItems) + } +} + +func TestRuntimeEventTodoHandlers(t *testing.T) { + app, runtime := newTestApp(t) + app.state.ActiveSessionID = "session-1" + runtime.loadSessions = map[string]agentsession.Session{ + "session-1": { + ID: "session-1", + Title: "S1", + Todos: []agentsession.TodoItem{ + {ID: "todo-1", Content: "Todo 1", Status: agentsession.TodoStatusPending, Priority: 1, UpdatedAt: time.Now()}, + }, + }, + } + + handled := runtimeEventTodoUpdatedHandler(&app, agentruntime.RuntimeEvent{ + SessionID: "session-1", + Payload: agentruntime.TodoEventPayload{Action: "set_status"}, + }) + if handled { + t.Fatalf("expected todo updated handler to return false") + } + if len(app.todoItems) != 1 { + t.Fatalf("expected todo refresh on updated event") + } + + handled = runtimeEventTodoConflictHandler(&app, agentruntime.RuntimeEvent{ + SessionID: "session-1", + Payload: map[string]any{"reason": "conflict"}, + }) + if handled { + t.Fatalf("expected todo conflict handler to return false") + } + if len(app.activities) == 0 { + t.Fatalf("expected conflict activity entry") + } + + before := len(app.activities) + handled = runtimeEventTodoUpdatedHandler(&app, agentruntime.RuntimeEvent{ + SessionID: "session-2", + Payload: agentruntime.TodoEventPayload{Action: "ignored"}, + }) + if handled { + t.Fatalf("expected ignored session event to return false") + } + if len(app.activities) != before { + t.Fatalf("expected no activity for foreign session") + } +} + +func TestParseTodoEventPayload(t *testing.T) { + got, ok := parseTodoEventPayload(agentruntime.TodoEventPayload{Action: "a", Reason: "b"}) + if !ok || got.Action != "a" || got.Reason != "b" { + t.Fatalf("unexpected struct parse result: %#v ok=%v", got, ok) + } + + payload := &agentruntime.TodoEventPayload{Action: "x", Reason: "y"} + got, ok = parseTodoEventPayload(payload) + if !ok || got.Action != "x" || got.Reason != "y" { + t.Fatalf("unexpected pointer parse result: %#v ok=%v", got, ok) + } + + got, ok = parseTodoEventPayload(map[string]any{"action": "plan", "reason": "conflict"}) + if !ok || got.Action != "plan" || got.Reason != "conflict" { + t.Fatalf("unexpected map parse result: %#v ok=%v", got, ok) + } + + got, ok = parseTodoEventPayload(fmt.Errorf("invalid")) + if ok || got != (agentruntime.TodoEventPayload{}) { + t.Fatalf("expected invalid payload to fail parse, got %#v ok=%v", got, ok) + } +} + +func TestOpenSelectedTodoDetailWithoutSelection(t *testing.T) { + app, _ := newTestApp(t) + app.todoItems = nil + app.openSelectedTodoDetail() + if app.state.StatusText != "No todo selected" { + t.Fatalf("expected no-selection status, got %q", app.state.StatusText) + } +} + +func TestOpenSelectedTodoDetailWhenCollapsed(t *testing.T) { + app, _ := newTestApp(t) + app.todoItems = []todoViewItem{{ID: "todo-1", Status: "pending"}} + app.todoCollapsed = true + app.openSelectedTodoDetail() + if app.state.StatusText != "Todo list is collapsed" { + t.Fatalf("expected collapsed status, got %q", app.state.StatusText) + } +} + +func TestTodoRuntimeEventsRegistered(t *testing.T) { + if _, ok := runtimeEventHandlerRegistry[agentruntime.EventTodoUpdated]; !ok { + t.Fatalf("expected todo_updated handler to be registered") + } + if _, ok := runtimeEventHandlerRegistry[agentruntime.EventTodoConflict]; !ok { + t.Fatalf("expected todo_conflict handler to be registered") + } +} diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index fd7b4b4c..e2ec882d 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -63,6 +63,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: a.width = typed.Width a.height = typed.Height + a.layoutCached = false a.applyComponentLayout(true) return a, tea.Batch(cmds...) case providerAddResultMsg: @@ -112,6 +113,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.clearRunProgress() } a.syncActiveSessionTitle() + a.syncTodosFromRun() return a, tea.Batch(cmds...) case permissionResolutionFinishedMsg: if a.pendingPermission != nil && a.pendingPermission.Request.RequestID == typed.RequestID { @@ -220,6 +222,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.handleActivityMouse(typed) { return a, tea.Batch(cmds...) } + if a.handleTodoMouse(typed) { + return a, tea.Batch(cmds...) + } if a.handleInputMouse(typed) { return a, tea.Batch(cmds...) } @@ -256,7 +261,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Batch(cmds...) } if a.shouldHandleTabAsInput(typed) { - tabMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'\t'}, Paste: typed.Paste} + tabMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}, Paste: typed.Paste} return a.updateInputPanel(tabMsg, tabMsg, cmds) } } @@ -293,6 +298,43 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case panelActivity: a.handleViewportKeys(&a.activity, typed) return a, tea.Batch(cmds...) + case panelTodo: + switch { + case key.Matches(typed, a.keys.ScrollUp): + a.moveTodoSelection(-1) + case key.Matches(typed, a.keys.ScrollDown): + a.moveTodoSelection(1) + case key.Matches(typed, a.keys.PageUp): + a.moveTodoSelection(-5) + case key.Matches(typed, a.keys.PageDown): + a.moveTodoSelection(5) + case key.Matches(typed, a.keys.Top): + if !a.todoCollapsed { + a.todoSelectedIndex = 0 + a.rebuildTodo() + } + case key.Matches(typed, a.keys.Bottom): + if !a.todoCollapsed { + a.todoSelectedIndex = len(a.visibleTodoItems()) - 1 + a.rebuildTodo() + } + case key.Matches(typed, a.keys.Send): + if a.todoCollapsed { + a.setTodoCollapsed(false) + a.state.StatusText = statusTodoExpanded + a.applyComponentLayout(false) + } else { + a.openSelectedTodoDetail() + } + case typed.Type == tea.KeyRunes && len(typed.Runes) == 1 && (typed.Runes[0] == 'c' || typed.Runes[0] == 'C'): + if a.toggleTodoCollapsed() { + a.state.StatusText = statusTodoCollapsed + } else { + a.state.StatusText = statusTodoExpanded + } + a.applyComponentLayout(false) + } + return a, tea.Batch(cmds...) case panelInput: return a.updateInputPanel(msg, typed, cmds) } @@ -326,9 +368,8 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te return a, tea.Batch(cmds...) } - // 先检查是否是立即执行的命令,如果处理了,就直接返回 if handled, cmd := a.handleImmediateSlashCommand(input); handled { - a.input.Reset() // 只有在命令被处理后才清空输入 + a.input.Reset() a.state.InputText = "" a.applyComponentLayout(true) a.refreshCommandMenu() @@ -353,6 +394,11 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te return a, tea.Batch(cmds...) } + a.input.Reset() + a.state.InputText = "" + a.applyComponentLayout(true) + a.refreshCommandMenu() + a.resetPasteHeuristics() switch strings.ToLower(input) { case slashCommandHelp: a.refreshHelpPicker() @@ -432,7 +478,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te } // image capability precheck is intentionally disabled. - // 如果不是立即执行的命令,再执行常规的输入重置 + // 保持与 CLI 一致,先允许输入提交流转,再由后续链路统一处理能力兜底。 a.input.Reset() a.state.InputText = "" a.applyComponentLayout(true) @@ -488,7 +534,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te return a, tea.Batch(cmds...) } -// updatePendingPermissionInput 处理权限审批面板上的键盘交互(上下选择与回车确认)。 +// updatePendingPermissionInput handles keyboard interaction in the permission prompt. func (a *App) updatePendingPermissionInput(typed tea.KeyMsg) (tea.Cmd, bool) { if a.pendingPermission == nil { return nil, false @@ -519,7 +565,6 @@ func (a *App) updatePendingPermissionInput(typed tea.KeyMsg) (tea.Cmd, bool) { return nil, true } -// submitPermissionDecision 触发一次权限审批提交命令。 func (a *App) submitPermissionDecision(decision agentruntime.PermissionResolutionDecision) tea.Cmd { if a.pendingPermission == nil { return nil @@ -747,6 +792,7 @@ func (a *App) refreshMessages() error { if strings.TrimSpace(a.state.ActiveSessionID) == "" { a.activeMessages = nil a.clearActivities() + a.clearTodos() return nil } @@ -757,13 +803,13 @@ func (a *App) refreshMessages() error { a.activeMessages = session.Messages a.clearActivities() + a.syncTodos(session.Todos) a.state.ActiveSessionTitle = session.Title a.setCurrentWorkdir(agentsession.EffectiveWorkdir(session.Workdir, a.configManager.Get().Workdir)) a.refreshRuntimeSourceSnapshot() return nil } -// resetSessionRuntimeState 在切换/刷新会话前清理运行态缓存,避免跨会话残留工具与用量展示。 func (a *App) resetSessionRuntimeState() { a.state.IsAgentRunning = false a.state.StreamingReply = false @@ -777,6 +823,38 @@ func (a *App) resetSessionRuntimeState() { a.clearRunProgress() } +func (a *App) refreshTodosFromSession(sessionID string) error { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return fmt.Errorf("session id is empty") + } + session, err := a.runtime.LoadSession(context.Background(), sessionID) + if err != nil { + return err + } + a.syncTodos(session.Todos) + a.applyComponentLayout(false) + return nil +} + +func (a *App) syncTodosFromRun() { + sessionID := a.state.ActiveSessionID + if sessionID == "" { + return + } + session, err := a.runtime.LoadSession(context.Background(), sessionID) + if err != nil { + return + } + a.todoItems = nil + a.todoPanelVisible = false + a.todoSelectedIndex = 0 + if len(session.Todos) > 0 { + a.syncTodos(session.Todos) + } + a.rebuildTodo() +} + func (a *App) activateSelectedSession() error { item, ok := a.sessionPicker.SelectedItem().(sessionItem) if !ok { @@ -810,7 +888,6 @@ func (a *App) activateSessionByID(sessionID string) error { return fmt.Errorf("session not found: %s", sessionID) } -// ensureSessionSwitchAllowed 统一阻止运行中切换到其他会话,避免 UI 脱离仍在执行的 run 上下文。 func (a *App) ensureSessionSwitchAllowed(targetSessionID string) error { targetSessionID = strings.TrimSpace(targetSessionID) activeSessionID := strings.TrimSpace(a.state.ActiveSessionID) @@ -844,7 +921,6 @@ func (a *App) syncConfigState(cfg config.Config) { } } -// refreshRuntimeSourceSnapshot 从 runtime 查询 context/token/tool 快照,用于会话切换或恢复时回填 UI。 func (a *App) refreshRuntimeSourceSnapshot() { sessionID := strings.TrimSpace(a.state.ActiveSessionID) if sessionID != "" { @@ -895,17 +971,15 @@ func (a *App) refreshRuntimeSourceSnapshot() { } } -// runtimeSessionContextSource 约束可选的会话上下文查询能力。 +// runtimeSessionContextSource 缂備焦鎷濋梽鍕焽椤愶箑鐭楁い鏍亹閸嬫挻寰勭仦鍓ф殸婵炴潙鍚嬫穱娲儊閼恒儳鈻斿┑鐘辫兌閻熸捇鏌¢崒姘闁绘搫绱曢幏鐘诲閿濆懎骞嬮梺鍛婃⒐缁嬪繘鍩€ type runtimeSessionContextSource interface { GetSessionContext(ctx context.Context, sessionID string) (any, error) } -// runtimeSessionUsageSource 约束可选的会话 token 使用量查询能力。 type runtimeSessionUsageSource interface { GetSessionUsage(ctx context.Context, sessionID string) (any, error) } -// runtimeRunSnapshotSource 约束可选的运行快照查询能力。 type runtimeRunSnapshotSource interface { GetRunSnapshot(ctx context.Context, runID string) (any, error) } @@ -931,9 +1005,10 @@ var runtimeEventHandlerRegistry = map[agentruntime.EventType]func(*App, agentrun agentruntime.EventCompactError: runtimeEventCompactErrorHandler, agentruntime.EventPhaseChanged: runtimeEventPhaseChangedHandler, agentruntime.EventStopReasonDecided: runtimeEventStopReasonDecidedHandler, + agentruntime.EventTodoUpdated: runtimeEventTodoUpdatedHandler, + agentruntime.EventTodoConflict: runtimeEventTodoConflictHandler, } -// runtimeEventPhaseChangedHandler 处理 phase 迁移并更新进度标签。 func runtimeEventPhaseChangedHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := event.Payload.(agentruntime.PhaseChangedPayload) if !ok { @@ -950,7 +1025,7 @@ func runtimeEventPhaseChangedHandler(a *App, event agentruntime.RuntimeEvent) bo return false } -// runtimeEventStopReasonDecidedHandler 处理唯一终止事实事件。 +// runtimeEventStopReasonDecidedHandler 婵犮垼娉涚€氼噣骞冩繝鍥ц埞妞ゆ牗鐟ч杈╃磽娴e摜澧涙い鎺撶⊕缁傚秶鈧綆浜為弶钘壝瑰鍐惧剮婵炲棎鍨芥俊 func runtimeEventStopReasonDecidedHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := event.Payload.(agentruntime.StopReasonDecidedPayload) if !ok { @@ -985,7 +1060,86 @@ func runtimeEventStopReasonDecidedHandler(a *App, event agentruntime.RuntimeEven return false } -// handleRuntimeEvent 通过注册表分发 runtime 事件,避免巨型 switch 膨胀。 +func runtimeEventTodoUpdatedHandler(a *App, event agentruntime.RuntimeEvent) bool { + sessionID := strings.TrimSpace(event.SessionID) + if sessionID == "" { + sessionID = strings.TrimSpace(a.state.ActiveSessionID) + } + if strings.TrimSpace(sessionID) == "" || !strings.EqualFold(sessionID, strings.TrimSpace(a.state.ActiveSessionID)) { + return false + } + + if err := a.refreshTodosFromSession(sessionID); err != nil { + a.appendActivity("todo", "Failed to refresh todo panel", err.Error(), true) + return false + } + + payload, _ := parseTodoEventPayload(event.Payload) + action := strings.TrimSpace(payload.Action) + if action == "" { + action = "update" + } + a.appendActivity("todo", "Todo updated", action, false) + return false +} + +func runtimeEventTodoConflictHandler(a *App, event agentruntime.RuntimeEvent) bool { + sessionID := strings.TrimSpace(event.SessionID) + if sessionID == "" { + sessionID = strings.TrimSpace(a.state.ActiveSessionID) + } + if strings.TrimSpace(sessionID) == "" || !strings.EqualFold(sessionID, strings.TrimSpace(a.state.ActiveSessionID)) { + return false + } + + if err := a.refreshTodosFromSession(sessionID); err != nil { + a.appendActivity("todo", "Failed to refresh todo panel", err.Error(), true) + return false + } + + payload, _ := parseTodoEventPayload(event.Payload) + reason := strings.TrimSpace(payload.Reason) + if reason == "" { + reason = "todo conflict" + } + a.appendActivity("todo", "Todo conflict", reason, true) + return false +} + +func parseTodoEventPayload(payload any) (agentruntime.TodoEventPayload, bool) { + switch typed := payload.(type) { + case agentruntime.TodoEventPayload: + return typed, true + case *agentruntime.TodoEventPayload: + if typed == nil { + return agentruntime.TodoEventPayload{}, false + } + return *typed, true + case map[string]any: + action := "" + reason := "" + if raw, ok := typed["Action"]; ok && raw != nil { + action = strings.TrimSpace(fmt.Sprintf("%v", raw)) + } + if raw, ok := typed["Reason"]; ok && raw != nil { + reason = strings.TrimSpace(fmt.Sprintf("%v", raw)) + } + if action == "" { + if raw, ok := typed["action"]; ok && raw != nil { + action = strings.TrimSpace(fmt.Sprintf("%v", raw)) + } + } + if reason == "" { + if raw, ok := typed["reason"]; ok && raw != nil { + reason = strings.TrimSpace(fmt.Sprintf("%v", raw)) + } + } + return agentruntime.TodoEventPayload{Action: action, Reason: reason}, true + default: + return agentruntime.TodoEventPayload{}, false + } +} + func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) bool { if !a.shouldHandleRuntimeEvent(event) { return false @@ -997,7 +1151,6 @@ func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) bool { return handler(a, event) } -// shouldHandleRuntimeEvent 校验事件与当前活跃会话/运行上下文的关联,避免跨会话污染 UI 状态。 func (a *App) shouldHandleRuntimeEvent(event agentruntime.RuntimeEvent) bool { activeSessionID := strings.TrimSpace(a.state.ActiveSessionID) eventSessionID := strings.TrimSpace(event.SessionID) @@ -1013,8 +1166,6 @@ func (a *App) shouldHandleRuntimeEvent(event agentruntime.RuntimeEvent) bool { return true } -// runtimeEventUserMessageHandler 处理用户消息进入运行队列后的状态同步。 -// runtimeEventInputNormalizedHandler 处理输入归一化完成事件并更新运行态提示。 func runtimeEventInputNormalizedHandler(a *App, event agentruntime.RuntimeEvent) bool { if strings.TrimSpace(event.RunID) != "" { a.state.ActiveRunID = strings.TrimSpace(event.RunID) @@ -1034,7 +1185,6 @@ func runtimeEventInputNormalizedHandler(a *App, event agentruntime.RuntimeEvent) return false } -// runtimeEventAssetSavedHandler 处理附件保存成功事件并写入活动面板。 func runtimeEventAssetSavedHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := event.Payload.(agentruntime.AssetSavedPayload) if !ok { @@ -1051,7 +1201,6 @@ func runtimeEventAssetSavedHandler(a *App, event agentruntime.RuntimeEvent) bool return false } -// runtimeEventAssetSaveFailedHandler 处理附件保存失败事件并同步错误状态。 func runtimeEventAssetSaveFailedHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := event.Payload.(agentruntime.AssetSaveFailedPayload) if !ok { @@ -1101,7 +1250,6 @@ func runtimeEventUserMessageHandler(a *App, event agentruntime.RuntimeEvent) boo return true } -// runtimeEventRunContextHandler 处理 runtime 上下文事件并回填界面状态。 func runtimeEventRunContextHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := tuiservices.ParseRunContextPayload(event.Payload) if !ok { @@ -1127,7 +1275,6 @@ func runtimeEventRunContextHandler(a *App, event agentruntime.RuntimeEvent) bool return false } -// runtimeEventToolStatusHandler 处理工具状态流转并更新当前工具展示。 func runtimeEventToolStatusHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := tuiservices.ParseToolStatusPayload(event.Payload) if !ok { @@ -1146,7 +1293,6 @@ func runtimeEventToolStatusHandler(a *App, event agentruntime.RuntimeEvent) bool return false } -// runtimeEventUsageHandler 处理 token 使用量更新。 func runtimeEventUsageHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := tuiservices.ParseUsagePayload(event.Payload) if !ok { @@ -1156,7 +1302,7 @@ func runtimeEventUsageHandler(a *App, event agentruntime.RuntimeEvent) bool { return false } -// runtimeEventToolCallThinkingHandler 处理工具规划阶段事件。 +// runtimeEventToolCallThinkingHandler 婵犮垼娉涚€氼噣骞冩繝鍌ゅ晠闁靛鍎卞鏃堟偡濞嗗繐顏╅柛銊︾箞濮婂ジ鎳滃▓鍨杸婵炲瓨绮岄鍕枎閵忋倕违 func runtimeEventToolCallThinkingHandler(a *App, event agentruntime.RuntimeEvent) bool { if payload, ok := event.Payload.(string); ok && strings.TrimSpace(payload) != "" { a.state.CurrentTool = payload @@ -1166,7 +1312,7 @@ func runtimeEventToolCallThinkingHandler(a *App, event agentruntime.RuntimeEvent return false } -// runtimeEventToolStartHandler 处理工具开始执行事件。 +// runtimeEventToolStartHandler 婵犮垼娉涚€氼噣骞冩繝鍌ゅ晠闁靛鍎卞鏃傗偓娈垮枓閸嬫挸鈹戦纰卞剱濠⒀呮櫕閹壆浠﹂懖鈺冩婵炲濮剧紙浼村焵 func runtimeEventToolStartHandler(a *App, event agentruntime.RuntimeEvent) bool { a.state.StatusText = statusRunningTool a.state.StreamingReply = false @@ -1178,7 +1324,6 @@ func runtimeEventToolStartHandler(a *App, event agentruntime.RuntimeEvent) bool return false } -// runtimeEventToolResultHandler 处理工具执行结果并决定是否刷新对话区。 func runtimeEventToolResultHandler(a *App, event agentruntime.RuntimeEvent) bool { a.state.StreamingReply = false a.state.CurrentTool = "" @@ -1203,7 +1348,7 @@ func runtimeEventToolResultHandler(a *App, event agentruntime.RuntimeEvent) bool return true } -// runtimeEventAgentChunkHandler 处理模型流式增量输出。 +// runtimeEventAgentChunkHandler 婵犮垼娉涚€氼噣骞冩繝鍋界喖鍨惧畷鍥e亾瀹勬噴瑙勬媴閸濄儳顢呮繝鈷€鍛槐闁革絿鍎ゅ蹇涘箻閸愬弶鐦旈梺 func runtimeEventAgentChunkHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := event.Payload.(string) if !ok { @@ -1216,7 +1361,6 @@ func runtimeEventAgentChunkHandler(a *App, event agentruntime.RuntimeEvent) bool return true } -// runtimeEventToolChunkHandler 处理工具流式输出片段。 func runtimeEventToolChunkHandler(a *App, event agentruntime.RuntimeEvent) bool { if payload, ok := event.Payload.(string); ok && strings.TrimSpace(payload) != "" { a.state.StatusText = statusRunningTool @@ -1225,7 +1369,7 @@ func runtimeEventToolChunkHandler(a *App, event agentruntime.RuntimeEvent) bool return false } -// runtimeEventAgentDoneHandler 处理运行完成事件。 +// runtimeEventAgentDoneHandler 婵犮垼娉涚€氼噣骞冩繝鍐╀氦闁归偊鍨奸弨浠嬫倵閻熺増婀伴柛銊︾缁傚秶鈧絺鏅滈浠嬫煏 func runtimeEventAgentDoneHandler(a *App, event agentruntime.RuntimeEvent) bool { a.state.IsAgentRunning = false a.state.StreamingReply = false @@ -1246,8 +1390,7 @@ func runtimeEventAgentDoneHandler(a *App, event agentruntime.RuntimeEvent) bool return false } -// runtimeEventRunCanceledHandler 处理运行取消事件。 -func runtimeEventRunCanceledHandler(a *App) bool { +func runtimeEventRunCanceledHandler(a *App, event agentruntime.RuntimeEvent) bool { a.state.IsAgentRunning = false a.state.StreamingReply = false a.state.CurrentTool = "" @@ -1260,7 +1403,7 @@ func runtimeEventRunCanceledHandler(a *App) bool { return false } -// runtimeEventErrorHandler 处理运行时错误事件。 +// runtimeEventErrorHandler 婵犮垼娉涚€氼噣骞冩繝鍐╀氦闁归偊鍨奸弨浠嬫煛閸愩劎鍩i柡浣革功閹风娀顢涘▎鎴犳婵炲濮剧紙浼村焵 func runtimeEventErrorHandler(a *App, event agentruntime.RuntimeEvent) bool { a.state.StatusText = statusError a.state.IsAgentRunning = false @@ -1277,7 +1420,6 @@ func runtimeEventErrorHandler(a *App, event agentruntime.RuntimeEvent) bool { return false } -// runtimeEventProviderRetryHandler 处理 provider 重试提示事件。 func runtimeEventProviderRetryHandler(a *App, event agentruntime.RuntimeEvent) bool { if payload, ok := event.Payload.(string); ok && strings.TrimSpace(payload) != "" { a.state.StatusText = statusThinking @@ -1287,7 +1429,6 @@ func runtimeEventProviderRetryHandler(a *App, event agentruntime.RuntimeEvent) b return false } -// runtimeEventPermissionRequestHandler 处理 permission_requested 事件并激活审批面板。 func runtimeEventPermissionRequestHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := parsePermissionRequestPayload(event.Payload) if !ok { @@ -1327,7 +1468,6 @@ func runtimeEventPermissionRequestHandler(a *App, event agentruntime.RuntimeEven return false } -// runtimeEventPermissionResolvedHandler 处理 permission_resolved 事件并清理审批面板状态。 func runtimeEventPermissionResolvedHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := parsePermissionResolvedPayload(event.Payload) if !ok { @@ -1348,7 +1488,7 @@ func runtimeEventPermissionResolvedHandler(a *App, event agentruntime.RuntimeEve return false } -// refreshPermissionPromptLayout 在布局已初始化时刷新权限面板相关排版。 +// refreshPermissionPromptLayout 闂侀潻璐熼崝宀€绮╂搴濇勃闁逞屽墮椤斿繘骞撻幒鎴犱淮婵犳鍠栭鍛偓鍨叀瀵喚鎹勯崫鍕幈闂佸搫鍊绘晶妤€顭囬崼銉︹挃闁规壆澧楀銊╂煛婢跺孩纭舵繛鏉戭樀瀹曟鎼归銏㈢懇闂佺粯顨呴悧鍕焵 func (a *App) refreshPermissionPromptLayout() { if a.width <= 0 || a.height <= 0 { return @@ -1356,7 +1496,6 @@ func (a *App) refreshPermissionPromptLayout() { a.applyComponentLayout(false) } -// runtimeEventCompactDoneHandler 处理 compact_applied 事件。 func runtimeEventCompactDoneHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := event.Payload.(agentruntime.CompactResult) if !ok { @@ -1379,7 +1518,6 @@ func runtimeEventCompactDoneHandler(a *App, event agentruntime.RuntimeEvent) boo return true } -// runtimeEventCompactErrorHandler 处理 compact 异常事件。 func runtimeEventCompactErrorHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := event.Payload.(agentruntime.CompactErrorPayload) if !ok { @@ -1435,9 +1573,10 @@ func (a *App) appendActivity(kind string, title string, detail string, isError b IsError: isError, }) if len(a.activities) > maxActivityEntries { - a.activities = append([]tuistate.ActivityEntry(nil), a.activities[len(a.activities)-maxActivityEntries:]...) + a.activities = a.activities[len(a.activities)-maxActivityEntries:] } a.syncActivityViewport(previousCount) + a.viewDirty = true } func (a *App) clearActivities() { @@ -1573,7 +1712,7 @@ func (a App) inputBounds() (int, int, int, int) { streamX := contentX streamY := bodyY - inputY := streamY + a.transcript.Height + a.activityPreviewHeight() + a.commandMenuHeight(lay.contentWidth) + inputY := streamY + a.transcript.Height + a.activityPreviewHeight() + a.todoPreviewHeight() + a.commandMenuHeight(lay.contentWidth) inputHeight := lipgloss.Height(a.renderPrompt(lay.contentWidth)) return streamX, inputY, lay.contentWidth, inputHeight } @@ -1595,6 +1734,23 @@ func (a App) activityBounds() (int, int, int, int) { return streamX, streamY + a.transcript.Height, lay.contentWidth, activityHeight } +func (a App) todoBounds() (int, int, int, int) { + lay := a.computeLayout() + contentX := a.styles.doc.GetPaddingLeft() + contentY := a.styles.doc.GetPaddingTop() + headerHeight := headerBarHeight + bodyY := contentY + headerHeight + + streamX := contentX + streamY := bodyY + + todoHeight := a.todoPreviewHeight() + if todoHeight <= 0 { + return streamX, streamY + a.transcript.Height + a.activityPreviewHeight(), lay.contentWidth, 0 + } + return streamX, streamY + a.transcript.Height + a.activityPreviewHeight(), lay.contentWidth, todoHeight +} + func (a App) isMouseWithinActivity(msg tea.MouseMsg) bool { x, y, width, height := a.activityBounds() if width <= 0 || height <= 0 { @@ -1603,6 +1759,48 @@ func (a App) isMouseWithinActivity(msg tea.MouseMsg) bool { return msg.X >= x && msg.X < x+width && msg.Y >= y && msg.Y < y+height } +func (a App) isMouseWithinTodo(msg tea.MouseMsg) bool { + x, y, width, height := a.todoBounds() + if width <= 0 || height <= 0 { + return false + } + return msg.X >= x && msg.X < x+width && msg.Y >= y && msg.Y < y+height +} + +func (a App) isMouseWithinTodoHeader(msg tea.MouseMsg) bool { + if !a.isMouseWithinTodo(msg) { + return false + } + _, y, _, _ := a.todoBounds() + // top border + one-line panel header + return msg.Y <= y+1 +} + +func (a App) todoItemIndexAtMouse(msg tea.MouseMsg) (int, bool) { + if a.todoCollapsed || a.todo.Height <= 0 { + return 0, false + } + if !a.isMouseWithinTodo(msg) { + return 0, false + } + + _, y, _, _ := a.todoBounds() + // one top border row + one panel header row + bodyRow := msg.Y - (y + 2) + if bodyRow < 0 || bodyRow >= a.todo.Height { + return 0, false + } + + contentLine := a.todo.YOffset + bodyRow + // line 0 is table header + index := contentLine - 1 + visibleCount := len(a.visibleTodoItems()) + if index < 0 || index >= visibleCount { + return 0, false + } + return index, true +} + func (a *App) handleActivityMouse(msg tea.MouseMsg) bool { if len(a.activities) == 0 || !a.isMouseWithinActivity(msg) { return false @@ -1631,6 +1829,66 @@ func (a *App) handleActivityMouse(msg tea.MouseMsg) bool { } } +func (a *App) handleTodoMouse(msg tea.MouseMsg) bool { + if !a.todoPanelVisible || !a.isMouseWithinTodo(msg) { + return false + } + if a.state.ActivePicker != pickerNone { + return false + } + + switch { + case msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionPress: + if a.focus != panelTodo { + a.focus = panelTodo + a.applyFocus() + } + if a.isMouseWithinTodoHeader(msg) { + if a.toggleTodoCollapsed() { + a.state.StatusText = statusTodoCollapsed + } else { + a.state.StatusText = statusTodoExpanded + } + a.applyComponentLayout(false) + return true + } + if a.todoCollapsed { + a.setTodoCollapsed(false) + a.state.StatusText = statusTodoExpanded + a.applyComponentLayout(false) + return true + } + if index, ok := a.todoItemIndexAtMouse(msg); ok { + a.todoSelectedIndex = index + a.rebuildTodo() + return true + } + return false + case msg.Button == tea.MouseButtonWheelUp && (msg.Action == tea.MouseActionPress || msg.Type == tea.MouseWheelUp): + if a.focus != panelTodo { + a.focus = panelTodo + a.applyFocus() + } + if a.todoCollapsed { + return true + } + a.moveTodoSelection(-mouseWheelStepLines) + return true + case msg.Button == tea.MouseButtonWheelDown && (msg.Action == tea.MouseActionPress || msg.Type == tea.MouseWheelDown): + if a.focus != panelTodo { + a.focus = panelTodo + a.applyFocus() + } + if a.todoCollapsed { + return true + } + a.moveTodoSelection(mouseWheelStepLines) + return true + default: + return false + } +} + func (a *App) handleInputMouse(msg tea.MouseMsg) bool { if !a.isMouseWithinInput(msg) { return false @@ -1727,16 +1985,22 @@ func (a *App) applyFocus() { } func (a *App) applyComponentLayout(rebuildTranscript bool) { + a.layoutCached = true + a.cachedWidth = a.width + a.cachedHeight = a.height + lay := a.computeLayout() prevTranscriptWidth := a.transcript.Width prevActivityWidth := a.activity.Width prevActivityHeight := a.activity.Height + prevTodoWidth := a.todo.Width + prevTodoHeight := a.todo.Height a.help.ShowAll = a.state.ShowHelp a.transcript.Width = lay.contentWidth a.resizeCommandMenu() a.input.SetWidth(a.composerInnerWidth(lay.contentWidth)) a.input.SetHeight(a.composerHeight()) - transcriptHeight, activityHeight, _, _ := a.waterfallMetrics(a.transcript.Width, lay.contentHeight) + transcriptHeight, activityHeight, _, todoHeight := a.waterfallMetrics(a.transcript.Width, lay.contentHeight) a.transcript.Height = transcriptHeight if activityHeight > 0 { @@ -1754,6 +2018,21 @@ func (a *App) applyComponentLayout(rebuildTranscript bool) { a.activity.Height = 0 } + if todoHeight > 0 { + panelStyle := a.styles.panelFocused + frameHeight := panelStyle.GetVerticalFrameSize() + borderWidth := 2 + paddingWidth := panelStyle.GetHorizontalFrameSize() - borderWidth + panelWidth := max(1, lay.contentWidth-borderWidth) + bodyWidth := max(10, panelWidth-paddingWidth) + bodyHeight := max(1, todoHeight-frameHeight-1) + a.todo.Width = bodyWidth + a.todo.Height = bodyHeight + } else { + a.todo.Width = max(10, lay.contentWidth-4) + a.todo.Height = 0 + } + pickerLayout := a.buildPickerLayout(lay.contentWidth, lay.contentHeight) a.providerPicker.SetSize(pickerLayout.listWidth, pickerLayout.listHeight) a.modelPicker.SetSize(pickerLayout.listWidth, pickerLayout.listHeight) @@ -1772,6 +2051,9 @@ func (a *App) applyComponentLayout(rebuildTranscript bool) { if prevActivityWidth != a.activity.Width || prevActivityHeight != a.activity.Height { a.rebuildActivity() } + if prevTodoWidth != a.todo.Width || prevTodoHeight != a.todo.Height { + a.rebuildTodo() + } } func (a App) composerBoxWidth(totalWidth int) int { @@ -1940,7 +2222,6 @@ func (a *App) handleImmediateSlashCommand(input string) (bool, tea.Cmd) { } } -// runSlashCommandSelection 根据 /help 弹层选中的命令执行对应 slash 行为。 func (a *App) runSlashCommandSelection(command string) tea.Cmd { command = strings.ToLower(strings.TrimSpace(command)) if command == "" { @@ -1995,6 +2276,7 @@ func (a *App) startDraftSession() { a.state.ActiveSessionTitle = draftSessionTitle a.activeMessages = nil a.clearActivities() + a.clearTodos() a.state.IsCompacting = false a.state.StatusText = statusDraft a.state.ExecutionError = "" @@ -2045,7 +2327,6 @@ func runAgent(runtime agentruntime.Runtime, input agentruntime.PrepareInput) tea ) } -// runResolvePermission 提交一次权限审批决定到 runtime。 func runResolvePermission( runtime agentruntime.Runtime, requestID string, @@ -2067,7 +2348,6 @@ func runResolvePermission( ) } -// runCompact 在独立命令中触发 runtime compact,并把结果回传给 TUI。 func runCompact(runtime agentruntime.Runtime, sessionID string) tea.Cmd { return tuiservices.RunCompactCmd( runtime, @@ -2076,17 +2356,15 @@ func runCompact(runtime agentruntime.Runtime, sessionID string) tea.Cmd { ) } -// isBusy 统一判断当前界面是否存在进行中的 agent 或 compact 操作。 +// isBusy reports whether an agent run or compact operation is in progress. func (a App) isBusy() bool { return tuiutils.IsBusy(a.state.IsAgentRunning, a.state.IsCompacting) } -// handleMemoCommand 处理 /memo 命令,显示记忆索引内容。 func (a *App) handleMemoCommand() tea.Cmd { return a.runMemoSystemTool(tools.ToolNameMemoList, map[string]any{}) } -// handleRememberCommand 处理 /remember 命令,创建新的记忆条目。 func (a *App) handleRememberCommand(text string) tea.Cmd { text = strings.TrimSpace(text) if text == "" { @@ -2101,7 +2379,6 @@ func (a *App) handleRememberCommand(text string) tea.Cmd { }) } -// handleForgetCommand 处理 /forget 命令,删除匹配的记忆条目。 func (a *App) handleForgetCommand(keyword string) tea.Cmd { keyword = strings.TrimSpace(keyword) if keyword == "" { @@ -2115,7 +2392,6 @@ func (a *App) handleForgetCommand(keyword string) tea.Cmd { }) } -// runMemoSystemTool 通过 runtime 的系统工具入口执行 memo 相关 slash 命令。 func (a *App) runMemoSystemTool(toolName string, arguments map[string]any) tea.Cmd { payload, err := json.Marshal(arguments) if err != nil { @@ -2149,15 +2425,13 @@ func (a *App) runMemoSystemTool(toolName string, arguments map[string]any) tea.C ) } -// setCurrentWorkdir 统一设置当前工作目录,仅接受非空白且为绝对路径的值。 -// 非法值会被静默忽略,防止 runtime 事件或异常输入污染 UI 状态。 +// setCurrentWorkdir updates the current workdir only when the value is non-empty and absolute. func (a *App) setCurrentWorkdir(workdir string) { trimmed := strings.TrimSpace(workdir) if trimmed == "" || !filepath.IsAbs(trimmed) { return } a.state.CurrentWorkdir = trimmed - } type providerAddFieldID int @@ -2275,6 +2549,8 @@ func (a *App) handleProviderAddFormInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch currentProviderAddField(a.providerAddForm) { case providerAddFieldName: a.providerAddForm.Name = trimLastRune(a.providerAddForm.Name) + case providerAddFieldDriver: + a.providerAddForm.Driver = trimLastRune(a.providerAddForm.Driver) case providerAddFieldBaseURL: a.providerAddForm.BaseURL = trimLastRune(a.providerAddForm.BaseURL) case providerAddFieldAPIStyle: @@ -2295,30 +2571,34 @@ func (a *App) handleProviderAddFormInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a, nil case typed.Type == tea.KeyUp: if currentProviderAddField(a.providerAddForm) == providerAddFieldDriver { - currentIdx := 0 + currentIdx := -1 for i, d := range a.providerAddForm.Drivers { if d == a.providerAddForm.Driver { currentIdx = i break } } - currentIdx = (currentIdx - 1 + len(a.providerAddForm.Drivers)) % len(a.providerAddForm.Drivers) - a.providerAddForm.Driver = a.providerAddForm.Drivers[currentIdx] - clampProviderAddStep(a.providerAddForm) + if currentIdx >= 0 { + currentIdx = (currentIdx - 1 + len(a.providerAddForm.Drivers)) % len(a.providerAddForm.Drivers) + a.providerAddForm.Driver = a.providerAddForm.Drivers[currentIdx] + clampProviderAddStep(a.providerAddForm) + } } return a, nil case typed.Type == tea.KeyDown: if currentProviderAddField(a.providerAddForm) == providerAddFieldDriver { - currentIdx := 0 + currentIdx := -1 for i, d := range a.providerAddForm.Drivers { if d == a.providerAddForm.Driver { currentIdx = i break } } - currentIdx = (currentIdx + 1) % len(a.providerAddForm.Drivers) - a.providerAddForm.Driver = a.providerAddForm.Drivers[currentIdx] - clampProviderAddStep(a.providerAddForm) + if currentIdx >= 0 { + currentIdx = (currentIdx + 1) % len(a.providerAddForm.Drivers) + a.providerAddForm.Driver = a.providerAddForm.Drivers[currentIdx] + clampProviderAddStep(a.providerAddForm) + } } return a, nil default: @@ -2507,7 +2787,6 @@ func normalizeProviderAddFieldValue(value string) string { return strings.TrimSpace(sanitizeProviderAddInputRunes([]rune(value))) } -// trimLastRune 按 UTF-8 rune 删除字符串末尾一个字符,避免按字节截断导致乱码。 func trimLastRune(value string) string { if value == "" { return "" diff --git a/internal/tui/core/app/update_permission_test.go b/internal/tui/core/app/update_permission_test.go index 12d28987..ef4e133d 100644 --- a/internal/tui/core/app/update_permission_test.go +++ b/internal/tui/core/app/update_permission_test.go @@ -111,6 +111,9 @@ func newPermissionTestApp(runtime agentruntime.Runtime) *App { activities: []tuistate.ActivityEntry{ {Kind: "test", Title: "seed"}, }, + layoutCached: true, + cachedWidth: 128, + cachedHeight: 40, }, } return app diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index 73a6b2f7..b068334a 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -14,14 +14,12 @@ import ( "neo-code/internal/config" configstate "neo-code/internal/config/state" - "neo-code/internal/memo" "neo-code/internal/provider" providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" approvalflow "neo-code/internal/runtime/approval" agentsession "neo-code/internal/session" "neo-code/internal/tools" - memotool "neo-code/internal/tools/memo" tuibootstrap "neo-code/internal/tui/bootstrap" tuiservices "neo-code/internal/tui/services" tuistate "neo-code/internal/tui/state" @@ -30,6 +28,8 @@ import ( type stubProviderService struct { providers []configstate.ProviderOption models []providertypes.ModelDescriptor + listErr error + listModelsErr error selectErr error selectDelay time.Duration selectResponse configstate.Selection @@ -38,6 +38,9 @@ type stubProviderService struct { } func (s stubProviderService) ListProviderOptions(ctx context.Context) ([]configstate.ProviderOption, error) { + if s.listErr != nil { + return nil, s.listErr + } return s.providers, nil } @@ -70,6 +73,9 @@ func (s stubProviderService) ListModels(ctx context.Context) ([]providertypes.Mo } func (s stubProviderService) ListModelsSnapshot(ctx context.Context) ([]providertypes.ModelDescriptor, error) { + if s.listModelsErr != nil { + return nil, s.listModelsErr + } return s.models, nil } @@ -105,7 +111,8 @@ type stubRuntime struct { preparedOutput agentruntime.UserInput runInputs []agentruntime.UserInput systemToolCalls []agentruntime.SystemToolInput - systemToolFn func(ctx context.Context, input agentruntime.SystemToolInput) (tools.ToolResult, error) + systemToolRes tools.ToolResult + systemToolErr error resolveCalls []agentruntime.PermissionResolutionInput resolveErr error cancelInvoked bool @@ -169,10 +176,10 @@ func (s *stubRuntime) Compact(ctx context.Context, input agentruntime.CompactInp func (s *stubRuntime) ExecuteSystemTool(ctx context.Context, input agentruntime.SystemToolInput) (tools.ToolResult, error) { s.systemToolCalls = append(s.systemToolCalls, input) - if s.systemToolFn != nil { - return s.systemToolFn(ctx, input) + if s.systemToolErr != nil { + return tools.ToolResult{}, s.systemToolErr } - return tools.ToolResult{}, nil + return s.systemToolRes, nil } func (s *stubRuntime) ResolvePermission(ctx context.Context, input agentruntime.PermissionResolutionInput) error { @@ -294,7 +301,11 @@ func newTestApp(t *testing.T) (App, *stubRuntime) { models = []providertypes.ModelDescriptor{{ID: providerCfg.Model, Name: providerCfg.Model}} } - return newTestAppWithProviderService(t, stubProviderService{providers: providers, models: models}) + app, runtime := newTestAppWithProviderService(t, stubProviderService{providers: providers, models: models}) + app.layoutCached = true + app.cachedWidth = app.width + app.cachedHeight = app.height + return app, runtime } func TestSubmitProviderAddFormRequiresAnthropicBaseURL(t *testing.T) { @@ -463,8 +474,8 @@ func TestTrimLastRune(t *testing.T) { if got := trimLastRune("ab"); got != "a" { t.Fatalf("trimLastRune(ascii) = %q, want a", got) } - if got := trimLastRune("你好"); got != "你" { - t.Fatalf("trimLastRune(utf8) = %q, want 你", got) + if got := trimLastRune("\u4f60\u597d"); got != "\u4f60" { + t.Fatalf("trimLastRune(utf8) = %q, want %q", got, "\u4f60") } } @@ -1463,8 +1474,8 @@ func TestUpdateSendWithInlineImageReferenceUsesPreparePipeline(t *testing.T) { }}, } - app.input.SetValue("请分析 @image:burn.png") - app.state.InputText = "请分析 @image:burn.png" + app.input.SetValue("analyze @image:burn.png") + app.state.InputText = "analyze @image:burn.png" model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) if cmd != nil { @@ -1475,7 +1486,7 @@ func TestUpdateSendWithInlineImageReferenceUsesPreparePipeline(t *testing.T) { if len(runtime.prepareInputs) != 1 { t.Fatalf("expected one prepare input, got %+v", runtime.prepareInputs) } - if runtime.prepareInputs[0].Text != "请分析" { + if runtime.prepareInputs[0].Text != "analyze" { t.Fatalf("expected inline image token removed from text, got %q", runtime.prepareInputs[0].Text) } if len(runtime.prepareInputs[0].Images) != 1 || runtime.prepareInputs[0].Images[0].MimeType != "" { @@ -1669,7 +1680,7 @@ func TestRuntimeEventAgentChunkHandler(t *testing.T) { func TestRuntimeEventRunCanceledHandler(t *testing.T) { app, _ := newTestApp(t) app.state.ActiveRunID = "run-3" - runtimeEventRunCanceledHandler(&app) + runtimeEventRunCanceledHandler(&app, agentruntime.RuntimeEvent{}) if app.state.StatusText != statusCanceled { t.Fatalf("expected canceled status") } @@ -1880,8 +1891,8 @@ func TestRunSlashCommandSelectionWorkspaceAndLocal(t *testing.T) { app.state.ActiveSessionID = "" app.state.CurrentWorkdir = t.TempDir() - // /cwd 不是 handleImmediateSlashCommand 处理的命令,也不是 switch 中的已知命令, - // 所以走 default 分支返回 runLocalCommand -> localCommandResultMsg + // /cwd is not handled by handleImmediateSlashCommand and is not in the direct switch cases. + // It should therefore execute through runLocalCommand and return a localCommandResultMsg. localCmd := app.runSlashCommandSelection("/cwd") if localCmd == nil { t.Fatalf("expected local slash cmd for /cwd") @@ -1901,6 +1912,14 @@ func TestRunSlashCommandSelectionWorkspaceAndLocal(t *testing.T) { } } +func TestHandleImmediateSlashCommandDefault(t *testing.T) { + app, _ := newTestApp(t) + handled, cmd := app.handleImmediateSlashCommand("/unknown") + if handled || cmd != nil { + t.Fatalf("expected unknown slash command to be ignored") + } +} + func TestHandleImmediateSlashCommandCompactBranches(t *testing.T) { app, runtime := newTestApp(t) app.state.ActiveSessionID = "session-1" @@ -1939,11 +1958,85 @@ func TestHandleImmediateSlashCommandCompactBranches(t *testing.T) { } } -func TestHandleImmediateSlashCommandDefault(t *testing.T) { +func TestHandleMemoCommandsRouteToSystemTools(t *testing.T) { + app, runtime := newTestApp(t) + + runtime.systemToolRes = tools.ToolResult{Content: "ok"} + cmd := app.handleMemoCommand() + if cmd == nil { + t.Fatalf("expected /memo command") + } + msg := cmd() + model, _ := app.Update(msg) + app = model.(App) + if len(runtime.systemToolCalls) != 1 { + t.Fatalf("expected one system tool call for /memo") + } + if runtime.systemToolCalls[0].ToolName != tools.ToolNameMemoList { + t.Fatalf("unexpected tool for /memo: %s", runtime.systemToolCalls[0].ToolName) + } + if app.state.StatusText != "ok" { + t.Fatalf("expected status from tool result, got %q", app.state.StatusText) + } + + cmd = app.handleRememberCommand("persist this") + if cmd == nil { + t.Fatalf("expected /remember command") + } + _ = cmd() + if len(runtime.systemToolCalls) != 2 { + t.Fatalf("expected one additional system tool call for /remember") + } + if runtime.systemToolCalls[1].ToolName != tools.ToolNameMemoRemember { + t.Fatalf("unexpected tool for /remember: %s", runtime.systemToolCalls[1].ToolName) + } + + cmd = app.handleForgetCommand("keyword") + if cmd == nil { + t.Fatalf("expected /forget command") + } + _ = cmd() + if len(runtime.systemToolCalls) != 3 { + t.Fatalf("expected one additional system tool call for /forget") + } + if runtime.systemToolCalls[2].ToolName != tools.ToolNameMemoRemove { + t.Fatalf("unexpected tool for /forget: %s", runtime.systemToolCalls[2].ToolName) + } +} + +func TestHandleRememberAndForgetValidation(t *testing.T) { app, _ := newTestApp(t) - handled, cmd := app.handleImmediateSlashCommand("/unknown") - if handled || cmd != nil { - t.Fatalf("expected unknown slash command to be ignored") + + if cmd := app.handleRememberCommand(" "); cmd != nil { + t.Fatalf("expected nil cmd for empty /remember") + } + if len(app.activeMessages) == 0 || !strings.Contains(messageText(app.activeMessages[len(app.activeMessages)-1]), "Usage") { + t.Fatalf("expected usage message for empty /remember") + } + + if cmd := app.handleForgetCommand(" "); cmd != nil { + t.Fatalf("expected nil cmd for empty /forget") + } + if len(app.activeMessages) == 0 || !strings.Contains(messageText(app.activeMessages[len(app.activeMessages)-1]), "Usage") { + t.Fatalf("expected usage message for empty /forget") + } +} + +func TestUpdateCompactFinishedAndRefreshMessagesError(t *testing.T) { + app, runtime := newTestApp(t) + app.state.ActiveSessionID = "session-error" + runtime.loadSessionErr = errors.New("load session failed") + + model, _ := app.Update(compactFinishedMsg{Err: errors.New("compact failed")}) + app = model.(App) + if app.state.IsCompacting { + t.Fatalf("expected compacting state to be cleared") + } + if app.state.ExecutionError != "load session failed" { + t.Fatalf("expected refresh message error to win, got %q", app.state.ExecutionError) + } + if len(app.activeMessages) == 0 || app.activeMessages[len(app.activeMessages)-1].Role != roleError { + t.Fatalf("expected inline error message appended") } } @@ -2022,255 +2115,6 @@ func TestSetCurrentWorkdir(t *testing.T) { }) } -// newTestAppWithMemo 创建一个注入了 memo 服务的测试 App。 -func newTestAppWithMemo(t *testing.T) (App, *stubRuntime) { - t.Helper() - return newTestAppWithMemoBaseDir(t, t.TempDir()) -} - -func newTestAppWithMemoBaseDir(t *testing.T, memoBaseDir string) (App, *stubRuntime) { - t.Helper() - - cfg := newDefaultAppConfig() - cfg.Workdir = t.TempDir() - cfg.Memo.Enabled = true - if len(cfg.Providers) > 0 { - cfg.SelectedProvider = cfg.Providers[0].Name - cfg.CurrentModel = cfg.Providers[0].Model - } - - manager := config.NewManager(config.NewLoader(cfg.Workdir, cfg)) - if _, err := manager.Load(context.Background()); err != nil { - t.Fatalf("Load() error = %v", err) - } - - var providers []configstate.ProviderOption - var models []providertypes.ModelDescriptor - if len(cfg.Providers) > 0 { - provider := cfg.Providers[0] - providers = []configstate.ProviderOption{ - {ID: provider.Name, Name: provider.Name, Models: []providertypes.ModelDescriptor{{ID: provider.Model, Name: provider.Model}}}, - } - models = []providertypes.ModelDescriptor{{ID: provider.Model, Name: provider.Model}} - } - - // 创建真实的 memo 服务 - memoStore := memo.NewFileStore(memoBaseDir, cfg.Workdir) - memoSvc := memo.NewService(memoStore, cfg.Memo, nil) - - runtime := newStubRuntime() - runtime.systemToolFn = func(ctx context.Context, input agentruntime.SystemToolInput) (tools.ToolResult, error) { - switch input.ToolName { - case tools.ToolNameMemoList: - return memotool.NewListTool(memoSvc).Execute(ctx, tools.ToolCallInput{Arguments: input.Arguments}) - case tools.ToolNameMemoRemember: - return memotool.NewRememberTool(memoSvc).Execute(ctx, tools.ToolCallInput{Arguments: input.Arguments}) - case tools.ToolNameMemoRemove: - return memotool.NewRemoveTool(memoSvc).Execute(ctx, tools.ToolCallInput{Arguments: input.Arguments}) - default: - return tools.ToolResult{}, errors.New("unsupported system tool") - } - } - app, err := newApp(tuibootstrap.Container{ - Config: *cfg, - ConfigManager: manager, - Runtime: runtime, - ProviderService: stubProviderService{providers: providers, models: models}, - MemoSvc: memoSvc, - }) - if err != nil { - t.Fatalf("newApp() error = %v", err) - } - return app, runtime -} - -func TestHandleMemoCommand(t *testing.T) { - t.Parallel() - - t.Run("shows no memos message when empty", func(t *testing.T) { - app, _ := newTestAppWithMemo(t) - cmd := app.handleMemoCommand() - if cmd == nil { - t.Fatal("expected async cmd") - } - model, _ := app.Update(cmd()) - app = model.(App) - if !strings.Contains(app.state.StatusText, "No memos stored yet") { - t.Errorf("expected status to mention no memos, got: %s", app.state.StatusText) - } - }) - - t.Run("lists entries when memos exist", func(t *testing.T) { - app, _ := newTestAppWithMemo(t) - app.memoSvc.Add(context.Background(), memo.Entry{Type: memo.TypeUser, Title: "test entry", Content: "test", Source: memo.SourceUserManual}) - - cmd := app.handleMemoCommand() - model, _ := app.Update(cmd()) - app = model.(App) - if !strings.Contains(app.state.StatusText, "test entry") { - t.Errorf("expected status to include entry title, got: %s", app.state.StatusText) - } - }) - - t.Run("routes through runtime system tool", func(t *testing.T) { - app, runtime := newTestApp(t) - cmd := app.handleMemoCommand() - if cmd == nil { - t.Fatal("expected async cmd") - } - _ = cmd() - if len(runtime.systemToolCalls) != 1 { - t.Fatalf("system tool calls = %d, want 1", len(runtime.systemToolCalls)) - } - if runtime.systemToolCalls[0].ToolName != tools.ToolNameMemoList { - t.Fatalf("ToolName = %q, want %q", runtime.systemToolCalls[0].ToolName, tools.ToolNameMemoList) - } - }) -} - -func TestHandleRememberCommand(t *testing.T) { - t.Parallel() - - t.Run("saves memo and shows confirmation", func(t *testing.T) { - app, _ := newTestAppWithMemo(t) - cmd := app.handleRememberCommand("my preference") - if cmd == nil { - t.Fatal("expected async cmd") - } - model, _ := app.Update(cmd()) - app = model.(App) - if !strings.Contains(app.state.StatusText, "Memory saved") { - t.Errorf("expected saved confirmation, got: %s", app.state.StatusText) - } - // Verify the entry was actually saved - entries, _ := app.memoSvc.List(context.Background(), memo.ScopeUser) - if len(entries) != 1 { - t.Fatalf("expected 1 entry, got %d", len(entries)) - } - if entries[0].Title != "my preference" { - t.Errorf("Title = %q, want %q", entries[0].Title, "my preference") - } - }) - - t.Run("user memo is visible from another workspace", func(t *testing.T) { - baseDir := t.TempDir() - app, _ := newTestAppWithMemoBaseDir(t, baseDir) - cmd := app.handleRememberCommand("global preference") - if cmd == nil { - t.Fatal("expected async cmd") - } - model, _ := app.Update(cmd()) - app = model.(App) - - otherSvc := memo.NewService(memo.NewFileStore(baseDir, t.TempDir()), newDefaultAppConfig().Memo, nil) - entries, err := otherSvc.List(context.Background(), memo.ScopeUser) - if err != nil { - t.Fatalf("List() error = %v", err) - } - if len(entries) != 1 { - t.Fatalf("expected 1 shared user memo, got %d", len(entries)) - } - if entries[0].Title != "global preference" { - t.Fatalf("shared entry title = %q, want %q", entries[0].Title, "global preference") - } - }) - - t.Run("empty text shows usage", func(t *testing.T) { - app, _ := newTestAppWithMemo(t) - app.handleRememberCommand("") - msgs := app.activeMessages - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "Usage") { - t.Errorf("expected usage message, got: %s", messageText(last)) - } - }) - - t.Run("whitespace only text shows usage", func(t *testing.T) { - app, _ := newTestAppWithMemo(t) - app.handleRememberCommand(" ") - msgs := app.activeMessages - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "Usage") { - t.Errorf("expected usage message, got: %s", messageText(last)) - } - }) - - t.Run("routes through runtime system tool", func(t *testing.T) { - app, runtime := newTestApp(t) - cmd := app.handleRememberCommand("something") - if cmd == nil { - t.Fatal("expected async cmd") - } - _ = cmd() - if len(runtime.systemToolCalls) != 1 { - t.Fatalf("system tool calls = %d, want 1", len(runtime.systemToolCalls)) - } - if runtime.systemToolCalls[0].ToolName != tools.ToolNameMemoRemember { - t.Fatalf("ToolName = %q, want %q", runtime.systemToolCalls[0].ToolName, tools.ToolNameMemoRemember) - } - }) -} - -func TestHandleForgetCommand(t *testing.T) { - t.Parallel() - - t.Run("removes matching memos", func(t *testing.T) { - app, _ := newTestAppWithMemo(t) - app.memoSvc.Add(context.Background(), memo.Entry{Type: memo.TypeUser, Title: "remove me", Content: "test", Source: memo.SourceUserManual}) - app.memoSvc.Add(context.Background(), memo.Entry{Type: memo.TypeFeedback, Title: "keep this", Content: "test2", Source: memo.SourceUserManual}) - - cmd := app.handleForgetCommand("remove") - model, _ := app.Update(cmd()) - app = model.(App) - if !strings.Contains(app.state.StatusText, "Removed 1 memo") { - t.Errorf("expected removal confirmation, got: %s", app.state.StatusText) - } - // Verify only one was removed - entries, _ := app.memoSvc.List(context.Background(), memo.ScopeAll) - if len(entries) != 1 { - t.Fatalf("expected 1 remaining entry, got %d", len(entries)) - } - if entries[0].Title != "keep this" { - t.Errorf("remaining entry Title = %q, want %q", entries[0].Title, "keep this") - } - }) - - t.Run("no match shows message", func(t *testing.T) { - app, _ := newTestAppWithMemo(t) - cmd := app.handleForgetCommand("nonexistent") - model, _ := app.Update(cmd()) - app = model.(App) - if !strings.Contains(app.state.StatusText, "No memos matching") { - t.Errorf("expected no match message, got: %s", app.state.StatusText) - } - }) - - t.Run("empty keyword shows usage", func(t *testing.T) { - app, _ := newTestAppWithMemo(t) - app.handleForgetCommand("") - msgs := app.activeMessages - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "Usage") { - t.Errorf("expected usage message, got: %s", messageText(last)) - } - }) - - t.Run("routes through runtime system tool", func(t *testing.T) { - app, runtime := newTestApp(t) - cmd := app.handleForgetCommand("something") - if cmd == nil { - t.Fatal("expected async cmd") - } - _ = cmd() - if len(runtime.systemToolCalls) != 1 { - t.Fatalf("system tool calls = %d, want 1", len(runtime.systemToolCalls)) - } - if runtime.systemToolCalls[0].ToolName != tools.ToolNameMemoRemove { - t.Fatalf("ToolName = %q, want %q", runtime.systemToolCalls[0].ToolName, tools.ToolNameMemoRemove) - } - }) -} - func TestNoteInputEditTracksPasteHeuristics(t *testing.T) { app, _ := newTestApp(t) base := time.Now() @@ -2473,14 +2317,14 @@ func TestCurrentProviderAddFieldAndInputHandling(t *testing.T) { t.Fatalf("expected backspace to remove name content") } - app.providerAddForm.Name = "你好" + app.providerAddForm.Name = "\u4f60\u597d" model, _ = app.handleProviderAddFormInput(tea.KeyMsg{Type: tea.KeyBackspace}) ptr, ok = model.(*App) if !ok { t.Fatalf("expected *App model, got %T", model) } app = *ptr - if app.providerAddForm.Name != "你" { + if app.providerAddForm.Name != "\u4f60" { t.Fatalf("expected UTF-8 safe backspace result, got %q", app.providerAddForm.Name) } @@ -2851,24 +2695,6 @@ func TestUpdateLocalAndWorkspaceCommandResultBranches(t *testing.T) { } } -func TestUpdateCompactFinishedAndRefreshMessagesError(t *testing.T) { - app, runtime := newTestApp(t) - app.state.ActiveSessionID = "session-error" - runtime.loadSessionErr = errors.New("load session failed") - - model, _ := app.Update(compactFinishedMsg{Err: errors.New("compact failed")}) - app = model.(App) - if app.state.IsCompacting { - t.Fatalf("expected compacting state to be cleared") - } - if app.state.ExecutionError != "load session failed" { - t.Fatalf("expected refresh message error to win, got %q", app.state.ExecutionError) - } - if len(app.activeMessages) == 0 || app.activeMessages[len(app.activeMessages)-1].Role != roleError { - t.Fatalf("expected inline error message appended") - } -} - func TestUpdateLocalCommandProviderChangedRefreshErrors(t *testing.T) { app, _ := newTestApp(t) app.providerSvc = errorProviderService{err: errors.New("refresh providers failed")} @@ -3004,3 +2830,455 @@ func TestUpdateInputPanelSlashAndWorkspaceBranches(t *testing.T) { t.Fatalf("expected invalid workspace command error") } } + +func TestNewWithMemoAndNewAppErrorBranches(t *testing.T) { + baseCfg := newDefaultAppConfig() + baseCfg.Workdir = t.TempDir() + + manager := config.NewManager(config.NewLoader(baseCfg.Workdir, baseCfg)) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + runtime := newStubRuntime() + providerSvc := stubProviderService{ + providers: []configstate.ProviderOption{{ID: "openai", Name: "openai"}}, + models: []providertypes.ModelDescriptor{{ID: "gpt-5", Name: "gpt-5"}}, + } + + app, err := NewWithMemo(baseCfg, manager, runtime, providerSvc, nil) + if err != nil { + t.Fatalf("NewWithMemo() error = %v", err) + } + if app.memoSvc != nil { + t.Fatalf("expected nil memo service") + } + + errorCases := []struct { + name string + cfg func() config.Config + providerSvc ProviderController + }{ + { + name: "provider list error", + cfg: func() config.Config { return *baseCfg }, + providerSvc: stubProviderService{ + listErr: errors.New("list providers failed"), + }, + }, + { + name: "model list error", + cfg: func() config.Config { return *baseCfg }, + providerSvc: stubProviderService{ + providers: []configstate.ProviderOption{{ID: "openai", Name: "openai"}}, + listModelsErr: errors.New("list models failed"), + }, + }, + { + name: "workspace scan error", + cfg: func() config.Config { + cfg := *baseCfg + cfg.Workdir = filepath.Join(baseCfg.Workdir, "missing", "workspace") + return cfg + }, + providerSvc: stubProviderService{ + providers: []configstate.ProviderOption{{ID: "openai", Name: "openai"}}, + models: []providertypes.ModelDescriptor{{ID: "gpt-5", Name: "gpt-5"}}, + }, + }, + } + + for _, tc := range errorCases { + t.Run(tc.name, func(t *testing.T) { + cfg := tc.cfg() + _, err := newApp(tuibootstrap.Container{ + Config: cfg, + ConfigManager: manager, + Runtime: runtime, + ProviderService: tc.providerSvc, + }) + if err == nil { + t.Fatalf("expected error") + } + }) + } +} + +func TestNowFallbackToSystemClock(t *testing.T) { + app, _ := newTestApp(t) + app.nowFn = nil + if got := app.now(); got.IsZero() { + t.Fatalf("expected non-zero time") + } +} + +func TestSyncTodosFromRunAndActivateSessionByIDFound(t *testing.T) { + app, runtime := newTestApp(t) + now := time.Now() + runtime.loadSessions = map[string]agentsession.Session{ + "s1": { + ID: "s1", + Title: "Session One", + Todos: nil, + }, + "s2": { + ID: "s2", + Title: "Session Two", + Todos: []agentsession.TodoItem{ + { + ID: "todo-1", + Content: "task", + Status: agentsession.TodoStatusPending, + Priority: 1, + CreatedAt: now, + UpdatedAt: now, + }, + }, + }, + } + + app.state.ActiveSessionID = "s1" + app.todoItems = []todoViewItem{{ID: "legacy"}} + app.todoPanelVisible = true + app.syncTodosFromRun() + if len(app.todoItems) != 0 { + t.Fatalf("expected todo items cleared when session has no todos") + } + if app.todoPanelVisible { + t.Fatalf("expected todo panel hidden when session has no todos") + } + + app.state.Sessions = []agentsession.Summary{ + {ID: "s2", Title: "Session Two"}, + } + if err := app.activateSessionByID("s2"); err != nil { + t.Fatalf("activateSessionByID() error = %v", err) + } + if app.state.ActiveSessionID != "s2" { + t.Fatalf("expected active session switched to s2, got %q", app.state.ActiveSessionID) + } +} + +func TestUpdateInputPanelTypingPathAndProviderAddFormExtraBranches(t *testing.T) { + app, _ := newTestApp(t) + model, _ := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + app = model.(App) + if app.input.Value() != "x" { + t.Fatalf("expected composer value to be updated, got %q", app.input.Value()) + } + + app.startProviderAddForm() + app.providerAddForm.Step = 1 + app.providerAddForm.Driver = "unknown-driver" + modelPtr, cmd := app.handleProviderAddFormInput(tea.KeyMsg{Type: tea.KeyUp}) + if cmd != nil { + t.Fatalf("expected nil cmd for key up") + } + ptr, ok := modelPtr.(*App) + if !ok { + t.Fatalf("expected *App, got %T", modelPtr) + } + app = *ptr + if app.providerAddForm.Driver != "unknown-driver" { + t.Fatalf("expected driver unchanged when current driver not in options") + } + + app.startProviderAddForm() + app.providerAddForm.Driver = provider.DriverAnthropic + app.providerAddForm.Step = 3 // api version + modelPtr, _ = app.handleProviderAddFormInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2024-10-01")}) + app = *modelPtr.(*App) + if app.providerAddForm.APIVersion == "" { + t.Fatalf("expected api version to accept rune input") + } +} + +func TestHandleImmediateSlashCommandSessionRefreshError(t *testing.T) { + app, runtime := newTestApp(t) + app.state.IsAgentRunning = false + app.state.ActiveRunID = "" + runtime.listSessionsErr = errors.New("list sessions failed") + + handled, cmd := app.handleImmediateSlashCommand("/session") + if !handled { + t.Fatalf("expected /session to be handled") + } + if cmd != nil { + t.Fatalf("expected nil cmd for failed /session handling") + } + if !strings.Contains(app.state.ExecutionError, "list sessions failed") { + t.Fatalf("expected execution error to capture refresh failure, got %q", app.state.ExecutionError) + } +} + +func TestHandleProviderAddResultMsgRefreshPickerErrors(t *testing.T) { + cfg := newDefaultAppConfig() + cfg.Workdir = t.TempDir() + manager := config.NewManager(config.NewLoader(cfg.Workdir, cfg)) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + runtime := newStubRuntime() + app, err := newApp(tuibootstrap.Container{ + Config: *cfg, + ConfigManager: manager, + Runtime: runtime, + ProviderService: stubProviderService{ + providers: []configstate.ProviderOption{{ID: "p0", Name: "p0"}}, + models: []providertypes.ModelDescriptor{{ID: "m0", Name: "m0"}}, + }, + }) + if err != nil { + t.Fatalf("newApp() error = %v", err) + } + + app.providerSvc = stubProviderService{ + listErr: errors.New("refresh providers failed"), + listModelsErr: errors.New("refresh models failed"), + } + app.startProviderAddForm() + app.handleProviderAddResultMsg(providerAddResultMsg{Name: "new-provider", Model: "new-model"}) + + if !strings.Contains(app.state.StatusText, "Provider added") { + t.Fatalf("expected success status even when picker refresh fails, got %q", app.state.StatusText) + } + if len(app.activities) < 3 { + t.Fatalf("expected activity entries for add success and refresh failures") + } +} + +func TestUpdateInputPanelSlashAndInlineImageErrorBranches(t *testing.T) { + app, _ := newTestApp(t) + app.providerSvc = stubProviderService{ + listErr: errors.New("providers unavailable"), + listModelsErr: errors.New("models unavailable"), + } + + app.input.SetValue("/provider") + app.state.InputText = "/provider" + model, _ := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + if !strings.Contains(app.state.ExecutionError, "providers unavailable") { + t.Fatalf("expected provider refresh error, got %q", app.state.ExecutionError) + } + + app.input.SetValue("/model") + app.state.InputText = "/model" + model, _ = app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + if !strings.Contains(app.state.ExecutionError, "models unavailable") { + t.Fatalf("expected model refresh error, got %q", app.state.ExecutionError) + } + + app.pendingImageAttachments = make([]pendingImageAttachment, maxImageAttachments) + app.input.SetValue("please inspect @image:/tmp/neo-code-inline.png") + app.state.InputText = "please inspect @image:/tmp/neo-code-inline.png" + model, _ = app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + if !strings.Contains(strings.ToLower(app.state.ExecutionError), "maximum") { + t.Fatalf("expected inline image absorb error, got %q", app.state.ExecutionError) + } +} + +func TestUpdatePanelRoutingAndSessionRefreshBranches(t *testing.T) { + app, runtime := newTestApp(t) + + app.focus = panelTranscript + model, _ := app.Update(tea.KeyMsg{Type: tea.KeyDown}) + app = model.(App) + + app.focus = panelActivity + model, _ = app.Update(tea.KeyMsg{Type: tea.KeyUp}) + app = model.(App) + + now := time.Now() + app.syncTodos([]agentsession.TodoItem{ + { + ID: "todo-1", + Content: "first", + Status: agentsession.TodoStatusPending, + Priority: 1, + CreatedAt: now, + UpdatedAt: now, + }, + { + ID: "todo-2", + Content: "second", + Status: agentsession.TodoStatusInProgress, + Priority: 2, + CreatedAt: now, + UpdatedAt: now, + }, + }) + app.todoPanelVisible = true + app.focus = panelTodo + model, _ = app.Update(tea.KeyMsg{Type: tea.KeyDown}) + app = model.(App) + model, _ = app.Update(tea.KeyMsg{Type: tea.KeyPgDown}) + app = model.(App) + model, _ = app.Update(tea.KeyMsg{Type: tea.KeyHome}) + app = model.(App) + model, _ = app.Update(tea.KeyMsg{Type: tea.KeyEnd}) + app = model.(App) + model, _ = app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}) + app = model.(App) + model, _ = app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + + app.focus = panelInput + app.input.SetValue("abc") + app.state.InputText = "abc" + model, _ = app.Update(tea.KeyMsg{Type: tea.KeyTab}) + app = model.(App) + + app.state.ActiveSessionID = "" + app.activities = []tuistate.ActivityEntry{{Title: "legacy"}} + app.todoItems = []todoViewItem{{ID: "legacy"}} + if err := app.refreshMessages(); err != nil { + t.Fatalf("refreshMessages() with draft session error = %v", err) + } + if len(app.activities) != 0 || len(app.todoItems) != 0 { + t.Fatalf("expected refreshMessages to clear draft runtime state") + } + + app.state.ActiveSessionID = "s1" + runtime.loadSessionErr = errors.New("load failed") + if err := app.refreshMessages(); err == nil { + t.Fatalf("expected refreshMessages load error") + } +} + +func TestMouseHandlersAdditionalBranches(t *testing.T) { + app, _ := newTestApp(t) + app.width = 120 + app.height = 40 + app.todoPanelVisible = true + now := time.Now() + app.syncTodos([]agentsession.TodoItem{ + { + ID: "todo-1", + Content: "first", + Status: agentsession.TodoStatusPending, + Priority: 1, + CreatedAt: now, + UpdatedAt: now, + }, + }) + app.applyComponentLayout(true) + + todoLeft, todoTop, _, _ := app.todoBounds() + collapsedClick := tea.MouseMsg{ + X: todoLeft + 1, + Y: todoTop + 1, + Button: tea.MouseButtonLeft, + Action: tea.MouseActionPress, + } + app.todoCollapsed = true + if !app.handleTodoMouse(collapsedClick) { + t.Fatalf("expected collapsed todo body click handled") + } + if app.todoCollapsed { + t.Fatalf("expected collapsed todo body click to expand panel") + } + + app.todoCollapsed = true + wheelDown := tea.MouseMsg{ + X: todoLeft + 1, + Y: todoTop + 1, + Button: tea.MouseButtonWheelDown, + Action: tea.MouseActionPress, + Type: tea.MouseWheelDown, + } + if !app.handleTodoMouse(wheelDown) { + t.Fatalf("expected todo wheel down handled when collapsed") + } + + inputLeft, inputTop, _, _ := app.inputBounds() + if !app.handleInputMouse(tea.MouseMsg{ + X: inputLeft + 1, + Y: inputTop + 1, + Button: tea.MouseButtonWheelDown, + Action: tea.MouseActionPress, + Type: tea.MouseWheelDown, + }) { + t.Fatalf("expected input wheel down handled") + } + + transcriptLeft, transcriptTop, _, _ := app.transcriptBounds() + if !app.handleTranscriptMouse(tea.MouseMsg{ + X: transcriptLeft + 1, + Y: transcriptTop + 1, + Button: tea.MouseButtonWheelDown, + Action: tea.MouseActionPress, + Type: tea.MouseWheelDown, + }) { + t.Fatalf("expected transcript wheel down handled") + } +} + +func TestSlashSelectionAndProviderAddUtilityBranches(t *testing.T) { + app, _ := newTestApp(t) + app.state.ActivePicker = pickerHelp + + app.runSlashCommandSelection("/help") + if app.state.ActivePicker != pickerHelp { + t.Fatalf("expected /help to keep help picker active") + } + + app.runSlashCommandSelection("/clear") + if !strings.Contains(app.state.StatusText, "Cleared") { + t.Fatalf("expected /clear branch to update status") + } + + fields := providerAddVisibleFields(provider.DriverOpenAICompat) + if len(fields) == 0 || fields[0] != providerAddFieldName { + t.Fatalf("expected provider add visible fields to start from name field") + } + clampProviderAddStep(nil) + + if _, err := buildProviderAddRequest(providerAddFormState{ + Name: "custom-provider", + Driver: "custom-driver", + BaseURL: "https://example.com", + DiscoveryEndpointPath: "/models", + APIKeyEnv: "CUSTOM_PROVIDER_API_KEY", + APIKey: "test-key", + }); err != "" { + t.Fatalf("expected custom driver request to pass with base url, got %q", err) + } + + prevSupports := supportsUserEnvPersistence + supportsUserEnvPersistence = func() bool { return true } + if got := providerAddPersistenceWarning(); got != "" { + t.Fatalf("expected empty persistence warning when env persistence is supported") + } + supportsUserEnvPersistence = prevSupports + + app.providerAddForm = nil + app.handleProviderAddResultMsg(providerAddResultMsg{Name: "unused"}) +} + +func TestRunProviderAddFlowDeadlineExceededBranch(t *testing.T) { + service := stubProviderService{ + createErr: context.DeadlineExceeded, + } + app, _ := newTestAppWithProviderService(t, service) + cmd := app.runProviderAddFlow(providerAddRequest{ + Name: "demo", + Driver: provider.DriverOpenAICompat, + BaseURL: "https://example.com", + APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, + DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, + APIKeyEnv: "DEMO_API_KEY", + APIKey: "secret", + }) + msg := cmd() + result, ok := msg.(providerAddResultMsg) + if !ok { + t.Fatalf("expected providerAddResultMsg, got %T", msg) + } + if !strings.Contains(strings.ToLower(result.Error), "timed out") { + t.Fatalf("expected timeout error message, got %q", result.Error) + } +} diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index 18cae3c4..5f376604 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -89,9 +89,10 @@ func (a App) renderBody(lay layout) string { // waterfallMetrics 统一计算瀑布区各组件高度,确保渲染、布局与命中区域使用同一组尺寸。 func (a App) waterfallMetrics(width int, height int) (int, int, int, int) { activityHeight := a.activityPreviewHeight() + todoHeight := a.todoPreviewHeight() menuHeight := a.commandMenuHeight(width) - transcriptHeight := max(6, height-activityHeight-menuHeight) - return transcriptHeight, activityHeight, menuHeight, 0 + transcriptHeight := max(6, height-activityHeight-todoHeight-menuHeight) + return transcriptHeight, activityHeight, menuHeight, todoHeight } func (a App) renderWaterfall(width int, height int) string { @@ -120,6 +121,9 @@ func (a App) renderWaterfall(width int, height int) string { if activity := a.renderActivityPreview(width); activity != "" { parts = append(parts, activity) } + if todo := a.renderTodoPreview(width); todo != "" { + parts = append(parts, todo) + } if menu := a.renderCommandMenu(width); menu != "" { parts = append(parts, menu) } @@ -530,6 +534,7 @@ func (a App) focusLabel() string { focusLabelSessions, focusLabelTranscript, focusLabelActivity, + focusLabelTodo, focusLabelComposer, ) } diff --git a/internal/tui/core/app/view_test.go b/internal/tui/core/app/view_test.go index 0c965689..41c1cbed 100644 --- a/internal/tui/core/app/view_test.go +++ b/internal/tui/core/app/view_test.go @@ -40,6 +40,7 @@ func TestRenderPickerHelpMode(t *testing.T) { func TestRenderPickerSessionMode(t *testing.T) { app, _ := newTestApp(t) app.state.ActivePicker = pickerSession + app.layoutCached = false app.sessionPicker.SetItems([]list.Item{ sessionItem{Summary: agentsession.Summary{ ID: "session-1", @@ -47,6 +48,7 @@ func TestRenderPickerSessionMode(t *testing.T) { UpdatedAt: time.Now(), }}, }) + app.applyComponentLayout(false) view := app.renderPicker(48, 14) if !strings.Contains(view, sessionPickerTitle) { diff --git a/internal/tui/core/utils/view_helpers.go b/internal/tui/core/utils/view_helpers.go index 93688565..16dd7ca3 100644 --- a/internal/tui/core/utils/view_helpers.go +++ b/internal/tui/core/utils/view_helpers.go @@ -41,6 +41,7 @@ func FocusLabelFromPanel( sessionsLabel string, transcriptLabel string, activityLabel string, + todoLabel string, composerLabel string, ) string { switch focus { @@ -50,6 +51,8 @@ func FocusLabelFromPanel( return transcriptLabel case tuistate.PanelActivity: return activityLabel + case tuistate.PanelTodo: + return todoLabel default: return composerLabel } diff --git a/internal/tui/core/utils/view_helpers_test.go b/internal/tui/core/utils/view_helpers_test.go index a5bdf75a..0cf9bbaa 100644 --- a/internal/tui/core/utils/view_helpers_test.go +++ b/internal/tui/core/utils/view_helpers_test.go @@ -79,13 +79,14 @@ func TestFocusLabelFromPanel(t *testing.T) { {"sessions", tuistate.PanelSessions, "sessions"}, {"transcript", tuistate.PanelTranscript, "transcript"}, {"activity", tuistate.PanelActivity, "activity"}, + {"todo", tuistate.PanelTodo, "todo"}, {"input falls to default", tuistate.PanelInput, "composer"}, {"unknown", tuistate.Panel(999), "composer"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := FocusLabelFromPanel(tt.focus, "sessions", "transcript", "activity", "composer"); got != tt.want { + if got := FocusLabelFromPanel(tt.focus, "sessions", "transcript", "activity", "todo", "composer"); got != tt.want { t.Errorf("FocusLabelFromPanel() = %v, want %v", got, tt.want) } }) diff --git a/internal/tui/state/state_test.go b/internal/tui/state/state_test.go index e4038614..39a54cee 100644 --- a/internal/tui/state/state_test.go +++ b/internal/tui/state/state_test.go @@ -3,18 +3,19 @@ package state import "testing" func TestPanelAndPickerConstants(t *testing.T) { - if PanelSessions != 0 || PanelTranscript != 1 || PanelActivity != 2 || PanelInput != 3 { - t.Fatalf("unexpected panel constants: %d %d %d %d", PanelSessions, PanelTranscript, PanelActivity, PanelInput) + if PanelSessions != 0 || PanelTranscript != 1 || PanelActivity != 2 || PanelTodo != 3 || PanelInput != 4 { + t.Fatalf("unexpected panel constants: %d %d %d %d %d", PanelSessions, PanelTranscript, PanelActivity, PanelTodo, PanelInput) } - if PickerNone != 0 || PickerProvider != 1 || PickerModel != 2 || PickerSession != 3 || PickerFile != 4 || PickerHelp != 5 { + if PickerNone != 0 || PickerProvider != 1 || PickerModel != 2 || PickerSession != 3 || PickerFile != 4 || PickerHelp != 5 || PickerProviderAdd != 6 { t.Fatalf( - "unexpected picker constants: %d %d %d %d %d %d", + "unexpected picker constants: %d %d %d %d %d %d %d", PickerNone, PickerProvider, PickerModel, PickerSession, PickerFile, PickerHelp, + PickerProviderAdd, ) } } diff --git a/internal/tui/state/ui_state.go b/internal/tui/state/ui_state.go index be32b211..426fb563 100644 --- a/internal/tui/state/ui_state.go +++ b/internal/tui/state/ui_state.go @@ -9,6 +9,7 @@ const ( PanelSessions Panel = iota PanelTranscript PanelActivity + PanelTodo PanelInput )