From 8a031a0173d46cdbe4d191a29b20d39509667e8a Mon Sep 17 00:00:00 2001 From: creatang Date: Fri, 17 Apr 2026 22:58:57 +0800 Subject: [PATCH 1/5] feat(tui): add demo todo panel and remove obsolete slash commands # Conflicts: # internal/tui/core/app/app.go # internal/tui/core/app/update.go # internal/tui/core/app/update_test.go --- internal/tui/core/app/app.go | 21 + internal/tui/core/app/commands.go | 18 +- internal/tui/core/app/commands_test.go | 14 +- .../tui/core/app/permission_prompt_test.go | 6 + internal/tui/core/app/todo.go | 385 +++++++++++ internal/tui/core/app/todo_test.go | 604 ++++++++++++++++++ internal/tui/core/app/update.go | 595 +++++++++++------ .../tui/core/app/update_permission_test.go | 3 + internal/tui/core/app/update_test.go | 289 +-------- internal/tui/core/app/view.go | 9 +- internal/tui/core/app/view_test.go | 2 + internal/tui/core/utils/view_helpers.go | 3 + internal/tui/core/utils/view_helpers_test.go | 3 +- internal/tui/state/state_test.go | 9 +- internal/tui/state/ui_state.go | 1 + 15 files changed, 1457 insertions(+), 505 deletions(-) create mode 100644 internal/tui/core/app/todo.go create mode 100644 internal/tui/core/app/todo_test.go diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index 37769e16..952ffc0e 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 ) @@ -83,6 +84,7 @@ type appComponents struct { progress progress.Model transcript viewport.Model activity viewport.Model + todo viewport.Model input textarea.Model markdownRenderer markdownContentRenderer } @@ -100,6 +102,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 @@ -110,6 +117,10 @@ type appRuntimeState struct { pendingPermission *permissionPromptState pendingImageAttachments []pendingImageAttachment providerAddForm *providerAddFormState + layoutCached bool + cachedWidth int + cachedHeight int + viewDirty bool } type pendingImageAttachment struct { @@ -261,6 +272,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, }, @@ -268,6 +280,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, @@ -295,11 +311,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..a8310976 100644 --- a/internal/tui/core/app/commands.go +++ b/internal/tui/core/app/commands.go @@ -21,30 +21,22 @@ const ( slashCommandHelp = "/help" slashCommandExit = "/exit" slashCommandClear = "/clear" - slashCommandCompact = "/compact" slashCommandStatus = "/status" slashCommandProvider = "/provider" slashCommandProviderAdd = "/provider add" slashCommandModelPick = "/model" slashCommandSession = "/session" slashCommandCWD = "/cwd" - slashCommandMemo = "/memo" - slashCommandRemember = "/remember" - slashCommandForget = "/forget" slashUsageHelp = "/help" slashUsageExit = "/exit" slashUsageClear = "/clear" - slashUsageCompact = "/compact" slashUsageStatus = "/status" slashUsageProvider = "/provider" slashUsageProviderAdd = "/provider add" slashUsageModel = "/model" slashUsageSession = "/session" slashUsageWorkdir = "/cwd" - slashUsageMemo = "/memo" - slashUsageRemember = "/remember " - slashUsageForget = "/forget " commandMenuTitle = "Suggestions" providerPickerTitle = "Select Provider" @@ -62,6 +54,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." @@ -81,10 +74,12 @@ const ( statusApplyingCommand = "Applying local command" statusRunningCommand = "Running command" statusCommandDone = "Command finished" - statusCompacting = "Compacting context" 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 +89,7 @@ const ( focusLabelSessions = "Sessions" focusLabelTranscript = "Transcript" focusLabelActivity = "Activity" + focusLabelTodo = "Todo" focusLabelComposer = "Composer" maxActivityEntries = 64 @@ -120,12 +116,8 @@ type commandSuggestion = tuicommands.CommandSuggestion var builtinSlashCommands = []slashCommand{ {Usage: slashUsageHelp, Description: "Show slash command help"}, {Usage: slashUsageClear, Description: "Clear the current draft transcript"}, - {Usage: slashUsageCompact, Description: "Compact the current session context"}, {Usage: slashUsageStatus, Description: "Show current session and agent status"}, {Usage: slashUsageWorkdir, Description: "Show or set current session workspace root (/cwd [path])"}, - {Usage: slashUsageMemo, Description: "Show persistent memo index"}, - {Usage: slashUsageRemember, Description: "Save a persistent memo (/remember )"}, - {Usage: slashUsageForget, Description: "Remove memos matching keyword (/forget )"}, {Usage: slashUsageProvider, Description: "Open the interactive provider picker"}, {Usage: slashUsageProviderAdd, Description: "Add a new custom provider"}, {Usage: slashUsageModel, Description: "Open the interactive model picker"}, diff --git a/internal/tui/core/app/commands_test.go b/internal/tui/core/app/commands_test.go index 34c5c2bc..a72a18fb 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) { @@ -72,9 +78,10 @@ func TestStatusConstants(t *testing.T) { {"statusApplyingCommand", statusApplyingCommand}, {"statusRunningCommand", statusRunningCommand}, {"statusCommandDone", statusCommandDone}, - {"statusCompacting", statusCompacting}, {"statusChooseProvider", statusChooseProvider}, {"statusChooseModel", statusChooseModel}, + {"statusTodoCollapsed", statusTodoCollapsed}, + {"statusTodoExpanded", statusTodoExpanded}, {"statusChooseHelp", statusChooseHelp}, {"statusBrowseFile", statusBrowseFile}, } @@ -98,6 +105,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 19e3e7be..c96f8f7b 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -17,7 +17,6 @@ import ( "github.com/charmbracelet/lipgloss" "neo-code/internal/config" - "neo-code/internal/memo" "neo-code/internal/provider" providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" @@ -63,6 +62,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 +112,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 { @@ -129,6 +130,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } return a, tea.Batch(cmds...) + case tickMsg: + cmds = append(cmds, a.handleTickMsg(typed)) + return a, tea.Batch(cmds...) case modelCatalogRefreshMsg: if strings.EqualFold(a.modelRefreshID, typed.ProviderID) { a.modelRefreshID = "" @@ -146,21 +150,6 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.syncConfigState(cfg) selectPickerItemByID(&a.modelPicker, cfg.CurrentModel) return a, tea.Batch(cmds...) - case compactFinishedMsg: - a.state.IsCompacting = false - if typed.Err != nil && strings.TrimSpace(a.state.ExecutionError) == "" { - a.state.ExecutionError = typed.Err.Error() - a.state.StatusText = typed.Err.Error() - } - if err := a.refreshMessages(); err != nil && strings.TrimSpace(a.state.ActiveSessionID) != "" { - a.state.ExecutionError = err.Error() - a.state.StatusText = err.Error() - a.appendInlineMessage(roleError, err.Error()) - } - a.syncActiveSessionTitle() - a.rebuildTranscript() - a.transcript.GotoBottom() - return a, tea.Batch(cmds...) case localCommandResultMsg: if typed.Err != nil { a.state.ExecutionError = typed.Err.Error() @@ -220,6 +209,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 +248,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 +285,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 +355,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 +381,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 +465,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te } // image capability precheck is intentionally disabled. - // 如果不是立即执行的命令,再执行常规的输入重置 + // 婵″倹鐏夋稉宥嗘Ц缁斿宓嗛幍褑顢戦惃鍕嚒娴犮倧绱濋崘宥嗗⒔鐞涘苯鐖剁憴鍕畱鏉堟挸鍙嗛柌宥囩枂 a.input.Reset() a.state.InputText = "" a.applyComponentLayout(true) @@ -488,7 +521,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 +552,7 @@ func (a *App) updatePendingPermissionInput(typed tea.KeyMsg) (tea.Cmd, bool) { return nil, true } -// submitPermissionDecision 触发一次权限审批提交命令。 +// submitPermissionDecision 闁荤喐鐟辩粻鎴c亹閸屾稓鈻旈柍褜鍓欓埢搴ㄥ焺閸愨晝绉梻鍌氬閸旀洟顢橀幖浣哥閻熸瑥瀚徊鐟懊瑰┃鍨偓鏇㈠箠閳╁啰顩烽柕鍫濆暊閸? func (a *App) submitPermissionDecision(decision agentruntime.PermissionResolutionDecision) tea.Cmd { if a.pendingPermission == nil { return nil @@ -747,6 +780,7 @@ func (a *App) refreshMessages() error { if strings.TrimSpace(a.state.ActiveSessionID) == "" { a.activeMessages = nil a.clearActivities() + a.clearTodos() return nil } @@ -757,13 +791,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 +811,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 +876,7 @@ func (a *App) activateSessionByID(sessionID string) error { return fmt.Errorf("session not found: %s", sessionID) } -// ensureSessionSwitchAllowed 统一阻止运行中切换到其他会话,避免 UI 脱离仍在执行的 run 上下文。 +// ensureSessionSwitchAllowed 缂佺喍绔撮梼缁橆剾鏉╂劘顢戞稉顓炲瀼閹广垹鍩岄崗鏈电铂娴兼俺鐦介敍宀勪缉閸?UI 閼磋京顬囨禒宥呮躬閹笛嗩攽閻?run 娑撳﹣绗呴弬鍥モ偓 func (a *App) ensureSessionSwitchAllowed(targetSessionID string) error { targetSessionID = strings.TrimSpace(targetSessionID) activeSessionID := strings.TrimSpace(a.state.ActiveSessionID) @@ -844,7 +910,7 @@ func (a *App) syncConfigState(cfg config.Config) { } } -// refreshRuntimeSourceSnapshot 从 runtime 查询 context/token/tool 快照,用于会话切换或恢复时回填 UI。 +// refreshRuntimeSourceSnapshot 婵?runtime 闂佸搫琚崕鎾敋?context/token/tool 闂婎偄娴傞崑鍡涘矗鎼淬劍鏅€光偓閳ь剟寮妶鍡欘洸閹煎瓨绻勭粣妤呮偣閸ャ劎绠撻柛銊ョ箻楠炴垿濮€閳ュ磭浠愰梺璇″幑閸ㄥ綊藝閺屻儱绫嶉悹铏瑰劋缁€鈧繝?UI闂? func (a *App) refreshRuntimeSourceSnapshot() { sessionID := strings.TrimSpace(a.state.ActiveSessionID) if sessionID != "" { @@ -895,17 +961,17 @@ func (a *App) refreshRuntimeSourceSnapshot() { } } -// runtimeSessionContextSource 约束可选的会话上下文查询能力。 +// runtimeSessionContextSource 缂備焦鎷濋梽鍕焽椤愶箑鐭楁い鏍亹閸嬫挻寰勭仦鍓ф殸婵炴潙鍚嬫穱娲儊閼恒儳鈻斿┑鐘辫兌閻熸捇鏌¢崒姘闁绘搫绱曢幏鐘诲閿濆懎骞嬮梺鍛婃⒐缁嬪繘鍩€ type runtimeSessionContextSource interface { GetSessionContext(ctx context.Context, sessionID string) (any, error) } -// runtimeSessionUsageSource 约束可选的会话 token 使用量查询能力。 +// runtimeSessionUsageSource 缂備焦鎷濋梽鍕焽椤愶箑鐭楁い鏍亹閸嬫挻寰勭仦鍓ф殸婵炴潙鍚嬫穱娲儊?token 婵炶揪缍€濞夋洟寮妶澶嬬厒闊洦姊婚崣鈧柣鐘叉储閸ㄥジ宕楀鈧畷婵嗏槈閺嶃倕浜? type runtimeSessionUsageSource interface { GetSessionUsage(ctx context.Context, sessionID string) (any, error) } -// runtimeRunSnapshotSource 约束可选的运行快照查询能力。 +// runtimeRunSnapshotSource 缂備焦鎷濋梽鍕焽椤愶箑鐭楁い鏍亹閸嬫挻寰勭仦鍓ф殸闁哄鏅滈崝姗€銆侀幋锝囩杸妞ゆ劑鍊曞楣冩煛鐏炶鍔ユい鏇燁殜閹虫鎸婃径濠庢綕闂? type runtimeRunSnapshotSource interface { GetRunSnapshot(ctx context.Context, runID string) (any, error) } @@ -931,9 +997,11 @@ 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 迁移并更新进度标签。 +// runtimeEventPhaseChangedHandler 婵犮垼娉涚€氼噣骞?phase 闁哄鏅欓懗鏈电昂濡ょ姷鍋犻崺鏍洪崸妤€妫橀柟宄扮灱缁犲骞栨潏楣冩闁绘牭绲跨划鍨┍閹典礁浜? func runtimeEventPhaseChangedHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := event.Payload.(agentruntime.PhaseChangedPayload) if !ok { @@ -950,7 +1018,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 +1053,89 @@ func runtimeEventStopReasonDecidedHandler(a *App, event agentruntime.RuntimeEven return false } -// handleRuntimeEvent 通过注册表分发 runtime 事件,避免巨型 switch 膨胀。 +// runtimeEventTodoUpdatedHandler 婵犮垼娉涚€氼噣骞?Todo 闂佸憡甯¢弨閬嶅蓟婵犲啰顩查悗锝傛櫆椤愪粙鏌ㄥ☉妯垮闁艰崵鍠栭弻鍛潩瀹曞洨鐣烘繛鏉戝悑娣囨椽鎯侀崐鐔虹杸妞ゆ劑鍊曞楣冩煛閸パ呮憼闁?Todo 闂傚倸鐗勯崹鍝勵熆濮椻偓婵? +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 +} + +// runtimeEventTodoConflictHandler 婵犮垼娉涚€氼噣骞?Todo 闂佸憡鍔樼亸娆撴偘婵犲啰顩查悗锝傛櫆椤愪粙鏌ㄥ☉妯侯殭缂侇喖娴锋禒锕傚即閻橆喕绮甸梺鍦帛閸旀濡靛鍓佸祦閻犲搫鎼悡鏇㈡煛閸屾稒绶茬紓宥咁儔瀹曟粌顓奸崨顔兼笎闂佺粯鎸嗘担鎻掍壕 +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 + } +} + +// handleRuntimeEvent 闂備緡鍋呮穱铏规崲閸愩劉鏋栭柕濞垮劚閺傗偓闁荤偞绋忛崝宀勫垂鎼淬劌鐭?runtime 婵炲瓨绮岄鍕枎閵忋倖鏅€光偓閸曨亞绱氶梺绋跨箰缁夋潙鈻旈弴銏犲瀭?switch 闂佽壈缈伴崝蹇涘磿婢舵劕违 func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) bool { if !a.shouldHandleRuntimeEvent(event) { return false @@ -997,7 +1147,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 +1162,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 +1181,7 @@ func runtimeEventInputNormalizedHandler(a *App, event agentruntime.RuntimeEvent) return false } -// runtimeEventAssetSavedHandler 处理附件保存成功事件并写入活动面板。 +// runtimeEventAssetSavedHandler 婢跺嫮鎮婇梽鍕娣囨繂鐡ㄩ幋鎰娴滃娆㈤獮璺哄晸閸忋儲妞块崝銊╂桨閺夎¥鈧? func runtimeEventAssetSavedHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := event.Payload.(agentruntime.AssetSavedPayload) if !ok { @@ -1051,7 +1198,7 @@ func runtimeEventAssetSavedHandler(a *App, event agentruntime.RuntimeEvent) bool return false } -// runtimeEventAssetSaveFailedHandler 处理附件保存失败事件并同步错误状态。 +// runtimeEventAssetSaveFailedHandler 婢跺嫮鎮婇梽鍕娣囨繂鐡ㄦ径杈Е娴滃娆㈤獮璺烘倱濮濄儵鏁婄拠顖滃Ц閹降鈧? func runtimeEventAssetSaveFailedHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := event.Payload.(agentruntime.AssetSaveFailedPayload) if !ok { @@ -1101,7 +1248,7 @@ func runtimeEventUserMessageHandler(a *App, event agentruntime.RuntimeEvent) boo return true } -// runtimeEventRunContextHandler 处理 runtime 上下文事件并回填界面状态。 +// runtimeEventRunContextHandler 婵犮垼娉涚€氼噣骞?runtime 婵炴垶鎸搁敃锝囩箔閸涙潙妫橀柛銉閻ㄦ垵霉閻樼儤纭鹃柤鑽ゅ枛瀹曞爼鎮欑拠褏绋忛梺浼欑到閻倸顭囨导瀛樺亹闁煎摜顣介崑鎾存媴绾版ê浜? func runtimeEventRunContextHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := tuiservices.ParseRunContextPayload(event.Payload) if !ok { @@ -1127,7 +1274,7 @@ func runtimeEventRunContextHandler(a *App, event agentruntime.RuntimeEvent) bool return false } -// runtimeEventToolStatusHandler 处理工具状态流转并更新当前工具展示。 +// runtimeEventToolStatusHandler 婵犮垼娉涚€氼噣骞冩繝鍌ゅ晠闁靛鍎卞鏃堟煟濡灝鐓愰柍褜鍏涢悞锔剧矈閿旇姤濮滄い鎺嗗亾闁艰崵鍠栧瀵糕偓娑櫳戦悡鈧悷婊呭閹稿憡鏅堕悩宸晠闁靛鍎卞鏃堟倶閻愬瓨绀堥柕鍥ㄥ哺婵? func runtimeEventToolStatusHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := tuiservices.ParseToolStatusPayload(event.Payload) if !ok { @@ -1146,7 +1293,7 @@ func runtimeEventToolStatusHandler(a *App, event agentruntime.RuntimeEvent) bool return false } -// runtimeEventUsageHandler 处理 token 使用量更新。 +// runtimeEventUsageHandler 婵犮垼娉涚€氼噣骞?token 婵炶揪缍€濞夋洟寮妶澶嬬厒闊洦姊圭痪顖炴煛閸屾繂鐒介柍 func runtimeEventUsageHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := tuiservices.ParseUsagePayload(event.Payload) if !ok { @@ -1156,7 +1303,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 +1313,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 +1325,7 @@ func runtimeEventToolStartHandler(a *App, event agentruntime.RuntimeEvent) bool return false } -// runtimeEventToolResultHandler 处理工具执行结果并决定是否刷新对话区。 +// runtimeEventToolResultHandler 婵犮垼娉涚€氼噣骞冩繝鍌ゅ晠闁靛鍎卞鏃堟煙缁楁稑妫弨鐣岀磽娴h灏伴柣蹇擃樀閻涱喚鎹勯崫鍕€柣搴ゎ潐绾板秴危閹间礁瑙﹂柨鏇楀亾闁糕晜鐩顒勫级濞嗘儳娈搁柣鐘叉处缁诲倻浜搁鐐参? func runtimeEventToolResultHandler(a *App, event agentruntime.RuntimeEvent) bool { a.state.StreamingReply = false a.state.CurrentTool = "" @@ -1203,7 +1350,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 +1363,7 @@ func runtimeEventAgentChunkHandler(a *App, event agentruntime.RuntimeEvent) bool return true } -// runtimeEventToolChunkHandler 处理工具流式输出片段。 +// 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 +1372,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 +1393,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 +1406,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 +1423,7 @@ func runtimeEventErrorHandler(a *App, event agentruntime.RuntimeEvent) bool { return false } -// runtimeEventProviderRetryHandler 处理 provider 重试提示事件。 +// 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 +1433,7 @@ func runtimeEventProviderRetryHandler(a *App, event agentruntime.RuntimeEvent) b return false } -// runtimeEventPermissionRequestHandler 处理 permission_requested 事件并激活审批面板。 +// runtimeEventPermissionRequestHandler 婵犮垼娉涚€氼噣骞?permission_requested 婵炲瓨绮岄鍕枎閵忋倗宓侀柤鍝ユ暩鐠愮喐绻涢懠棰濆殭妞ゆ挻鎮傞獮宥堛亹閹烘挻銆冮梺鍝勵槼閿熴儵鍩€ func runtimeEventPermissionRequestHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := parsePermissionRequestPayload(event.Payload) if !ok { @@ -1327,7 +1473,7 @@ func runtimeEventPermissionRequestHandler(a *App, event agentruntime.RuntimeEven return false } -// runtimeEventPermissionResolvedHandler 处理 permission_resolved 事件并清理审批面板状态。 +// runtimeEventPermissionResolvedHandler 婵犮垼娉涚€氼噣骞?permission_resolved 婵炲瓨绮岄鍕枎閵忋倗宓侀柤鍝ユ暩椤忔悂鏌i悙鍙夘棞妞ゆ挻鎮傞獮宥堛亹閹烘挻銆冮梺鍝勵槼濞夋洘鎱ㄩ幖浣哥畱濞达絿顣介崑 func runtimeEventPermissionResolvedHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := parsePermissionResolvedPayload(event.Payload) if !ok { @@ -1348,7 +1494,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 +1502,7 @@ func (a *App) refreshPermissionPromptLayout() { a.applyComponentLayout(false) } -// runtimeEventCompactDoneHandler 处理 compact_applied 事件。 +// runtimeEventCompactDoneHandler 婵犮垼娉涚€氼噣骞?compact_applied 婵炲瓨绮岄鍕枎閵忋倕违 func runtimeEventCompactDoneHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := event.Payload.(agentruntime.CompactResult) if !ok { @@ -1379,7 +1525,7 @@ func runtimeEventCompactDoneHandler(a *App, event agentruntime.RuntimeEvent) boo return true } -// runtimeEventCompactErrorHandler 处理 compact 异常事件。 +// runtimeEventCompactErrorHandler 婵犮垼娉涚€氼噣骞?compact 閻庢鍠栭崐鎼佹偉閼哥數顩查悗锝傛櫆椤愪粙鏌? func runtimeEventCompactErrorHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := event.Payload.(agentruntime.CompactErrorPayload) if !ok { @@ -1435,9 +1581,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 +1720,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 +1742,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 +1767,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 +1837,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 +1993,28 @@ func (a *App) applyFocus() { } func (a *App) applyComponentLayout(rebuildTranscript bool) { + if a.layoutCached && a.cachedWidth == a.width && a.cachedHeight == a.height { + if rebuildTranscript { + a.rebuildTranscript() + } + return + } + 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 +2032,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 +2065,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 { @@ -1872,7 +2168,7 @@ func (a *App) clearRunProgress() { } func (a *App) handleImmediateSlashCommand(input string) (bool, tea.Cmd) { - command, rest := splitFirstWord(strings.ToLower(strings.TrimSpace(input))) + command, _ := splitFirstWord(strings.ToLower(strings.TrimSpace(input))) switch command { case slashCommandExit: return true, tea.Quit @@ -1880,43 +2176,6 @@ func (a *App) handleImmediateSlashCommand(input string) (bool, tea.Cmd) { a.startDraftSession() a.state.StatusText = "[System] Cleared current draft/history." return true, nil - case slashCommandCompact: - if strings.TrimSpace(rest) != "" { - errText := fmt.Sprintf("usage: %s", slashUsageCompact) - a.state.ExecutionError = errText - a.state.StatusText = errText - a.appendInlineMessage(roleError, errText) - a.rebuildTranscript() - return true, nil - } - if strings.TrimSpace(a.state.ActiveSessionID) == "" { - errText := "compact requires an existing session" - a.state.ExecutionError = errText - a.state.StatusText = errText - a.appendInlineMessage(roleError, errText) - a.rebuildTranscript() - return true, nil - } - if a.isBusy() { - errText := "compact is already running, please wait" - a.state.ExecutionError = errText - a.state.StatusText = errText - a.appendInlineMessage(roleError, errText) - a.rebuildTranscript() - return true, nil - } - a.state.IsCompacting = true - a.state.StreamingReply = false - a.state.CurrentTool = "" - a.state.StatusText = statusCompacting - a.state.ExecutionError = "" - return true, runCompact(a.runtime, a.state.ActiveSessionID) - case slashCommandMemo: - return true, a.handleMemoCommand() - case slashCommandRemember: - return true, a.handleRememberCommand(rest) - case slashCommandForget: - return true, a.handleForgetCommand(rest) case slashCommandSession: if err := a.ensureSessionSwitchAllowed(""); err != nil { a.state.ExecutionError = err.Error() @@ -1940,7 +2199,7 @@ func (a *App) handleImmediateSlashCommand(input string) (bool, tea.Cmd) { } } -// runSlashCommandSelection 根据 /help 弹层选中的命令执行对应 slash 行为。 +// runSlashCommandSelection 闂佸搫绉烽~澶婄暤?/help 閻庢鍠氶幊鎾绘儑娴煎瓨鐒诲璺侯槼閸橆剟鏌i妸銉ヮ仼闁规枼鍓濈粋鎺楀Χ閸℃せ鎸冮柣鐐寸☉閼活垶顢氶鑺ュ劅?slash 闁荤偞绋戞總鏃傛嫻閻旂厧违 func (a *App) runSlashCommandSelection(command string) tea.Cmd { command = strings.ToLower(strings.TrimSpace(command)) if command == "" { @@ -1995,6 +2254,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 +2305,7 @@ func runAgent(runtime agentruntime.Runtime, input agentruntime.PrepareInput) tea ) } -// runResolvePermission 提交一次权限审批决定到 runtime。 +// runResolvePermission 闂佸湱绮崝鎺戭潩閿旂晫鈻旈柍褜鍓欓埢搴ㄥ焺閸愨晝绉梻鍌氬閸旀洟顢橀幖浣哥闁荤喐婢橀弸鈧柣搴ゎ潐閼归箖宕?runtime闂? func runResolvePermission( runtime agentruntime.Runtime, requestID string, @@ -2067,115 +2327,18 @@ func runResolvePermission( ) } -// runCompact 在独立命令中触发 runtime compact,并把结果回传给 TUI。 -func runCompact(runtime agentruntime.Runtime, sessionID string) tea.Cmd { - return tuiservices.RunCompactCmd( - runtime, - agentruntime.CompactInput{SessionID: sessionID}, - func(err error) tea.Msg { return compactFinishedMsg{Err: err} }, - ) -} - -// 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 { - if a.memoSvc == nil { - a.appendInlineMessage(roleError, "[System] Memo service is not enabled.") - a.rebuildTranscript() - return nil - } - entries, err := a.memoSvc.List(context.Background()) - if err != nil { - a.appendInlineMessage(roleError, fmt.Sprintf("[System] Failed to load memo: %s", err)) - a.rebuildTranscript() - return nil - } - if len(entries) == 0 { - a.appendInlineMessage(roleSystem, "[System] No memos stored yet. Use /remember to add one.") - a.rebuildTranscript() - return nil - } - var lines []string - lines = append(lines, fmt.Sprintf("[System] %d memo(s):", len(entries))) - for _, entry := range entries { - lines = append(lines, fmt.Sprintf(" [%s] %s", entry.Type, entry.Title)) - } - a.appendInlineMessage(roleSystem, strings.Join(lines, "\n")) - a.rebuildTranscript() - return nil -} - -// handleRememberCommand 处理 /remember 命令,创建新的记忆条目。 -func (a *App) handleRememberCommand(text string) tea.Cmd { - text = strings.TrimSpace(text) - if a.memoSvc == nil { - a.appendInlineMessage(roleError, "[System] Memo service is not enabled.") - a.rebuildTranscript() - return nil - } - if text == "" { - a.appendInlineMessage(roleError, fmt.Sprintf("[System] Usage: %s", slashUsageRemember)) - a.rebuildTranscript() - return nil - } - title := memo.NormalizeTitle(text) - entry := memo.Entry{ - Type: memo.TypeUser, - Title: title, - Content: text, - Source: memo.SourceUserManual, - } - if err := a.memoSvc.Add(context.Background(), entry); err != nil { - a.appendInlineMessage(roleError, fmt.Sprintf("[System] Failed to save memo: %s", err)) - a.rebuildTranscript() - return nil - } - a.appendInlineMessage(roleSystem, fmt.Sprintf("[System] Memo saved: %s", title)) - a.rebuildTranscript() - return nil -} - -// handleForgetCommand 处理 /forget 命令,删除匹配的记忆条目。 -func (a *App) handleForgetCommand(keyword string) tea.Cmd { - keyword = strings.TrimSpace(keyword) - if a.memoSvc == nil { - a.appendInlineMessage(roleError, "[System] Memo service is not enabled.") - a.rebuildTranscript() - return nil - } - if keyword == "" { - a.appendInlineMessage(roleError, fmt.Sprintf("[System] Usage: %s", slashUsageForget)) - a.rebuildTranscript() - return nil - } - removed, err := a.memoSvc.Remove(context.Background(), keyword) - if err != nil { - a.appendInlineMessage(roleError, fmt.Sprintf("[System] Failed to remove memo: %s", err)) - a.rebuildTranscript() - return nil - } - if removed == 0 { - a.appendInlineMessage(roleSystem, fmt.Sprintf("[System] No memos matching %q.", keyword)) - } else { - a.appendInlineMessage(roleSystem, fmt.Sprintf("[System] Removed %d memo(s) matching %q.", removed, keyword)) - } - a.rebuildTranscript() - return nil -} - -// 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 @@ -2286,6 +2449,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: @@ -2300,30 +2465,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: @@ -2331,6 +2500,8 @@ func (a *App) handleProviderAddFormInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch currentProviderAddField(a.providerAddForm) { case providerAddFieldName: a.providerAddForm.Name += string(typed.Runes) + case providerAddFieldDriver: + a.providerAddForm.Driver += string(typed.Runes) case providerAddFieldBaseURL: a.providerAddForm.BaseURL += string(typed.Runes) case providerAddFieldAPIStyle: @@ -2475,7 +2646,7 @@ func providerAddAPIKeyEnv(name string) string { return normalized + "_API_KEY" } -// trimLastRune 按 UTF-8 rune 删除字符串末尾一个字符,避免按字节截断导致乱码。 +// trimLastRune 閹?UTF-8 rune 閸掔娀娅庣€涙顑佹稉鍙夋汞鐏忓彞绔存稉顏勭摟缁楋讣绱濋柆鍨帳閹稿鐡ч懞鍌涘焻閺傤厼顕遍懛缈犺础閻降鈧? func trimLastRune(value string) string { if value == "" { return "" @@ -2510,7 +2681,7 @@ type fileSnapshot struct { Content []byte } -// loadFileSnapshot 读取目标文件当前快照,用于 provider add 失败时恢复原始状态。 +// loadFileSnapshot 鐠囪褰囬惄顔界垼閺傚洣娆㈣ぐ鎾冲韫囶偆鍙庨敍宀€鏁ゆ禍?provider add 婢惰精瑙﹂弮鑸典划婢跺秴甯慨瀣Ц閹降鈧? func loadFileSnapshot(path string) (fileSnapshot, error) { data, err := os.ReadFile(path) if err != nil { @@ -2525,7 +2696,7 @@ func loadFileSnapshot(path string) (fileSnapshot, error) { }, nil } -// restoreEnvFileSnapshot 将 .env 恢复到提交前快照,避免覆盖场景下丢失原有键值。 +// restoreEnvFileSnapshot 鐏?.env 閹垹顦查崚鐗堝絹娴溿倕澧犺箛顐ゅ弾閿涘矂浼╅崗宥堫洬閻╂牕婧€閺咁垯绗呮稉銏犮亼閸樼喐婀侀柨顔尖偓绗衡偓 func restoreEnvFileSnapshot(path string, snapshot fileSnapshot) error { if !snapshot.Exists { if err := os.Remove(path); err != nil && !os.IsNotExist(err) { @@ -2539,7 +2710,7 @@ func restoreEnvFileSnapshot(path string, snapshot fileSnapshot) error { return os.WriteFile(path, snapshot.Content, 0o600) } -// restoreProviderConfigSnapshot 恢复 provider.yaml 快照;若原先不存在则清理新建目录。 +// restoreProviderConfigSnapshot 閹垹顦?provider.yaml 韫囶偆鍙庨敍娑滃閸樼喎鍘涙稉宥呯摠閸︺劌鍨〒鍛倞閺傛澘缂撻惄顔肩秿閵? func restoreProviderConfigSnapshot(baseDir string, providerName string, snapshot fileSnapshot) error { providerDir := filepath.Join(baseDir, "providers", providerName) if !snapshot.Exists { @@ -2691,7 +2862,7 @@ func (a *App) runProviderAddFlow(request providerAddRequest) tea.Cmd { } } -// rollbackProviderAddSideEffects 回滚 provider add 过程中已落地的副作用,避免失败后残留配置与密钥。 +// rollbackProviderAddSideEffects 閸ョ偞绮?provider add 鏉╁洨鈻兼稉顓炲嚒閽€钘夋勾閻ㄥ嫬澹囨担婊呮暏閿涘矂浼╅崗宥呫亼鐠愩儱鎮楀▓瀣殌闁板秶鐤嗘稉搴$槕闁姐儯鈧? func rollbackProviderAddSideEffects( baseDir string, providerName string, @@ -2785,3 +2956,9 @@ func (a *App) handleProviderAddResultMsg(msg providerAddResultMsg) { a.appendActivity("system", "Failed to refresh models", err.Error(), true) } } + +func (a *App) handleTickMsg(msg tickMsg) tea.Cmd { + return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} diff --git a/internal/tui/core/app/update_permission_test.go b/internal/tui/core/app/update_permission_test.go index 5b24ef62..167dd91f 100644 --- a/internal/tui/core/app/update_permission_test.go +++ b/internal/tui/core/app/update_permission_test.go @@ -106,6 +106,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 7bbdbc2e..3e48c9de 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -14,7 +14,6 @@ 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" @@ -264,7 +263,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) { @@ -607,8 +610,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") } } @@ -1702,8 +1705,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 { @@ -1714,7 +1717,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 != "" { @@ -1908,7 +1911,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") } @@ -2119,8 +2122,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") @@ -2140,44 +2143,6 @@ func TestRunSlashCommandSelectionWorkspaceAndLocal(t *testing.T) { } } -func TestHandleImmediateSlashCommandCompactBranches(t *testing.T) { - app, runtime := newTestApp(t) - app.state.ActiveSessionID = "session-1" - - handled, cmd := app.handleImmediateSlashCommand(slashCommandCompact + " now") - if !handled || cmd != nil { - t.Fatalf("expected compact with args to be handled without cmd") - } - if !strings.Contains(app.state.StatusText, "usage:") { - t.Fatalf("expected usage error for compact with args") - } - - app.state.ExecutionError = "" - app.state.IsCompacting = true - handled, cmd = app.handleImmediateSlashCommand(slashCommandCompact) - if !handled || cmd != nil { - t.Fatalf("expected compact busy branch to return handled with nil cmd") - } - if !strings.Contains(app.state.StatusText, "already running") { - t.Fatalf("expected busy message") - } - - app.state.IsCompacting = false - app.state.IsAgentRunning = false - app.state.StatusText = "" - handled, cmd = app.handleImmediateSlashCommand(slashCommandCompact) - if !handled || cmd == nil { - t.Fatalf("expected compact success branch to return cmd") - } - msg := cmd() - if _, ok := msg.(compactFinishedMsg); !ok { - t.Fatalf("expected compactFinishedMsg, got %T", msg) - } - if len(runtime.resolveCalls) != 0 { - t.Fatalf("compact should not resolve permissions") - } -} - func TestHandleImmediateSlashCommandDefault(t *testing.T) { app, _ := newTestApp(t) handled, cmd := app.handleImmediateSlashCommand("/unknown") @@ -2261,212 +2226,6 @@ func TestSetCurrentWorkdir(t *testing.T) { }) } -// newTestAppWithMemo 创建一个注入了 memo 服务的测试 App。 -func newTestAppWithMemo(t *testing.T) (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(t.TempDir(), cfg.Workdir) - memoSvc := memo.NewService(memoStore, nil, cfg.Memo, nil) - - runtime := newStubRuntime() - 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.Error("expected nil cmd") - } - msgs := app.activeMessages - if len(msgs) == 0 { - t.Fatal("expected at least one inline message") - } - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "No memos stored yet") { - t.Errorf("expected 'no memos' message, got: %s", messageText(last)) - } - }) - - 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}) - - app.handleMemoCommand() - msgs := app.activeMessages - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "1 memo(s)") { - t.Errorf("expected memo count, got: %s", messageText(last)) - } - if !strings.Contains(messageText(last), "test entry") { - t.Errorf("expected entry title, got: %s", messageText(last)) - } - }) - - t.Run("nil memoSvc shows error", func(t *testing.T) { - app, _ := newTestApp(t) - cmd := app.handleMemoCommand() - if cmd != nil { - t.Error("expected nil cmd") - } - msgs := app.activeMessages - if len(msgs) == 0 { - t.Fatal("expected at least one inline message") - } - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "not enabled") { - t.Errorf("expected 'not enabled' message, got: %s", messageText(last)) - } - }) -} - -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.Error("expected nil cmd") - } - msgs := app.activeMessages - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "Memo saved") { - t.Errorf("expected saved confirmation, got: %s", messageText(last)) - } - // Verify the entry was actually saved - entries, _ := app.memoSvc.List(context.Background()) - 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("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("nil memoSvc shows error", func(t *testing.T) { - app, _ := newTestApp(t) - app.handleRememberCommand("something") - msgs := app.activeMessages - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "not enabled") { - t.Errorf("expected 'not enabled' message, got: %s", messageText(last)) - } - }) -} - -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}) - - app.handleForgetCommand("remove") - msgs := app.activeMessages - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "Removed 1 memo") { - t.Errorf("expected removal confirmation, got: %s", messageText(last)) - } - // Verify only one was removed - entries, _ := app.memoSvc.List(context.Background()) - 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) - app.handleForgetCommand("nonexistent") - msgs := app.activeMessages - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "No memos matching") { - t.Errorf("expected no match message, got: %s", messageText(last)) - } - }) - - 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("nil memoSvc shows error", func(t *testing.T) { - app, _ := newTestApp(t) - app.handleForgetCommand("something") - msgs := app.activeMessages - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "not enabled") { - t.Errorf("expected 'not enabled' message, got: %s", messageText(last)) - } - }) -} - func TestNoteInputEditTracksPasteHeuristics(t *testing.T) { app, _ := newTestApp(t) base := time.Now() @@ -2655,14 +2414,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) } @@ -2965,24 +2724,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")} diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index 83fa314f..2b42210d 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) } @@ -508,6 +512,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 ) From 87a259c16c9210ca510670381a94069f43ab1d75 Mon Sep 17 00:00:00 2001 From: creatang Date: Fri, 17 Apr 2026 22:58:57 +0800 Subject: [PATCH 2/5] feat(tui): add demo todo panel and remove obsolete slash commands --- README.md | 6 +- docs/context-compact.md | 4 +- docs/guides/configuration.md | 2 +- docs/tools-and-tui-integration.md | 2 +- internal/tui/core/app/app.go | 16 + internal/tui/core/app/commands.go | 18 +- internal/tui/core/app/commands_test.go | 14 +- .../tui/core/app/permission_prompt_test.go | 6 + internal/tui/core/app/todo.go | 385 +++++++++++ internal/tui/core/app/todo_test.go | 604 ++++++++++++++++++ internal/tui/core/app/update.go | 546 ++++++++++------ .../tui/core/app/update_permission_test.go | 3 + internal/tui/core/app/update_test.go | 289 +-------- internal/tui/core/app/view.go | 9 +- internal/tui/core/app/view_test.go | 2 + internal/tui/core/utils/view_helpers.go | 3 + internal/tui/core/utils/view_helpers_test.go | 3 +- internal/tui/state/state_test.go | 9 +- internal/tui/state/ui_state.go | 1 + 19 files changed, 1412 insertions(+), 510 deletions(-) create mode 100644 internal/tui/core/app/todo.go create mode 100644 internal/tui/core/app/todo_test.go diff --git a/README.md b/README.md index 1e6bd26c..e8533bb0 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ NeoCode 是一个在终端中运行的 AI 编码助手,采用 ReAct(Reason-A - 终端原生 TUI 交互体验(Bubble Tea) - Agent 可调用内置工具完成文件与命令相关任务 - 支持 Provider/Model 切换(内建 `openai`、`gemini`、`openll`、`qiniu`) -- 支持上下文压缩(`/compact`),帮助长会话保持可用 +- 支持上下文压缩能力(manual/auto/reactive),帮助长会话保持可用 - 支持工作区隔离(`--workdir`、`/cwd`) - 会话持久化与恢复,降低重复沟通成本 - 支持持久记忆查看、显式写入与后台自动提取,保留跨会话偏好与项目事实 @@ -112,12 +112,8 @@ go run ./cmd/neocode --workdir /path/to/workspace - `/help`:查看命令帮助 - `/provider`:打开 provider 选择器 - `/model`:打开 model 选择器 -- `/compact`:压缩当前会话上下文 - `/status`:查看当前会话与运行状态 - `/cwd [path]`:查看或设置当前会话工作区 -- `/memo`:查看记忆索引 -- `/remember `:保存记忆 -- `/forget `:按关键词删除记忆 - `& `:在当前工作区执行本地命令 示例输入: diff --git a/docs/context-compact.md b/docs/context-compact.md index 86c20a63..2bb36fb4 100644 --- a/docs/context-compact.md +++ b/docs/context-compact.md @@ -12,7 +12,7 @@ - runtime 已接入手动 compact、基于 token 阈值的自动 compact,以及 provider 上下文过长后的 `reactive` compact 自动恢复。 - `internal/context/compact` 支持 `manual`、`auto` 与 `reactive` 三种 mode。 -- 用户通过 `/compact` 对当前会话执行一次上下文压缩。 +- 当前 TUI 不提供 `/compact` 入口;`manual` compact 由 runtime 接口触发。 - compact 前会先写入完整 transcript,随后生成并校验新的 durable `TaskState` 与 display summary,再回写会话消息。 ## 配置 @@ -64,7 +64,7 @@ context: ## 执行链路 -1. TUI 识别 `/compact` 并调用 `runtime.Compact(...)`。 +1. 由 runtime 触发 `runtime.Compact(...)`(手动或自动模式)。 2. runtime 发出 `compact_start` 事件。 3. compact runner 将原始消息写入 transcript(JSONL)。 4. compact runner 根据策略构造归档消息与保留消息,并过滤旧的 `[compact_summary]` 展示摘要,避免“摘要的摘要”。 diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 4cf8e1e1..5a33a3c7 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -75,7 +75,7 @@ context: | 字段 | 说明 | |------|------| -| `context.compact.manual_strategy` | `/compact` 手动压缩策略,支持 `keep_recent` / `full_replace` | +| `context.compact.manual_strategy` | manual compact 策略,支持 `keep_recent` / `full_replace` | | `context.compact.manual_keep_recent_messages` | `keep_recent` 策略下保留的最近消息数 | | `context.compact.read_time_max_message_spans` | context 读时保留的 message span 上限,用于降低“继续”时较早文件读取结果被过早裁掉的风险 | | `context.compact.max_summary_chars` | compact summary 最大字符数 | diff --git a/docs/tools-and-tui-integration.md b/docs/tools-and-tui-integration.md index 79d72691..96938e76 100644 --- a/docs/tools-and-tui-integration.md +++ b/docs/tools-and-tui-integration.md @@ -25,7 +25,7 @@ ## Memo 能力集成 - `memo_remember` 与 `memo_recall` 作为标准工具暴露给模型,沿 `Runtime -> Tool Manager -> internal/tools/memo` 链路执行。 - 自动记忆提取不作为单独工具暴露给模型,也不由 TUI 直接触发;它在 runtime 完成最终回复后由 memo 子系统后台调度。 -- TUI 目前只通过 Slash Command 展示和管理 memo(如 `/memo`、`/remember`、`/forget`),不会展示后台自动提取的中间状态。 +- TUI 不直接提供 memo 管理型 Slash Command;memo 的写入与召回由 runtime 工具链路驱动,不展示后台自动提取的中间状态。 ## TUI 集成方式 - 本地配置操作统一通过 Slash Command 完成,例如 Base URL、API Key 和模型选择 diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index cc0ba48d..88a13bac 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, diff --git a/internal/tui/core/app/commands.go b/internal/tui/core/app/commands.go index 653c1ef6..a8310976 100644 --- a/internal/tui/core/app/commands.go +++ b/internal/tui/core/app/commands.go @@ -21,30 +21,22 @@ const ( slashCommandHelp = "/help" slashCommandExit = "/exit" slashCommandClear = "/clear" - slashCommandCompact = "/compact" slashCommandStatus = "/status" slashCommandProvider = "/provider" slashCommandProviderAdd = "/provider add" slashCommandModelPick = "/model" slashCommandSession = "/session" slashCommandCWD = "/cwd" - slashCommandMemo = "/memo" - slashCommandRemember = "/remember" - slashCommandForget = "/forget" slashUsageHelp = "/help" slashUsageExit = "/exit" slashUsageClear = "/clear" - slashUsageCompact = "/compact" slashUsageStatus = "/status" slashUsageProvider = "/provider" slashUsageProviderAdd = "/provider add" slashUsageModel = "/model" slashUsageSession = "/session" slashUsageWorkdir = "/cwd" - slashUsageMemo = "/memo" - slashUsageRemember = "/remember " - slashUsageForget = "/forget " commandMenuTitle = "Suggestions" providerPickerTitle = "Select Provider" @@ -62,6 +54,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." @@ -81,10 +74,12 @@ const ( statusApplyingCommand = "Applying local command" statusRunningCommand = "Running command" statusCommandDone = "Command finished" - statusCompacting = "Compacting context" 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 +89,7 @@ const ( focusLabelSessions = "Sessions" focusLabelTranscript = "Transcript" focusLabelActivity = "Activity" + focusLabelTodo = "Todo" focusLabelComposer = "Composer" maxActivityEntries = 64 @@ -120,12 +116,8 @@ type commandSuggestion = tuicommands.CommandSuggestion var builtinSlashCommands = []slashCommand{ {Usage: slashUsageHelp, Description: "Show slash command help"}, {Usage: slashUsageClear, Description: "Clear the current draft transcript"}, - {Usage: slashUsageCompact, Description: "Compact the current session context"}, {Usage: slashUsageStatus, Description: "Show current session and agent status"}, {Usage: slashUsageWorkdir, Description: "Show or set current session workspace root (/cwd [path])"}, - {Usage: slashUsageMemo, Description: "Show persistent memo index"}, - {Usage: slashUsageRemember, Description: "Save a persistent memo (/remember )"}, - {Usage: slashUsageForget, Description: "Remove memos matching keyword (/forget )"}, {Usage: slashUsageProvider, Description: "Open the interactive provider picker"}, {Usage: slashUsageProviderAdd, Description: "Add a new custom provider"}, {Usage: slashUsageModel, Description: "Open the interactive model picker"}, diff --git a/internal/tui/core/app/commands_test.go b/internal/tui/core/app/commands_test.go index ce0ccade..b1bcf776 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) { @@ -72,9 +78,10 @@ func TestStatusConstants(t *testing.T) { {"statusApplyingCommand", statusApplyingCommand}, {"statusRunningCommand", statusRunningCommand}, {"statusCommandDone", statusCommandDone}, - {"statusCompacting", statusCompacting}, {"statusChooseProvider", statusChooseProvider}, {"statusChooseModel", statusChooseModel}, + {"statusTodoCollapsed", statusTodoCollapsed}, + {"statusTodoExpanded", statusTodoExpanded}, {"statusChooseHelp", statusChooseHelp}, {"statusBrowseFile", statusBrowseFile}, } @@ -98,6 +105,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 a46549d4..afff0c43 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -18,7 +18,6 @@ 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" @@ -63,6 +62,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 +112,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 { @@ -146,21 +147,6 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.syncConfigState(cfg) selectPickerItemByID(&a.modelPicker, cfg.CurrentModel) return a, tea.Batch(cmds...) - case compactFinishedMsg: - a.state.IsCompacting = false - if typed.Err != nil && strings.TrimSpace(a.state.ExecutionError) == "" { - a.state.ExecutionError = typed.Err.Error() - a.state.StatusText = typed.Err.Error() - } - if err := a.refreshMessages(); err != nil && strings.TrimSpace(a.state.ActiveSessionID) != "" { - a.state.ExecutionError = err.Error() - a.state.StatusText = err.Error() - a.appendInlineMessage(roleError, err.Error()) - } - a.syncActiveSessionTitle() - a.rebuildTranscript() - a.transcript.GotoBottom() - return a, tea.Batch(cmds...) case localCommandResultMsg: if typed.Err != nil { a.state.ExecutionError = typed.Err.Error() @@ -220,6 +206,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 +245,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 +282,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 +352,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 +378,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 +462,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 +518,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 +549,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 +776,7 @@ func (a *App) refreshMessages() error { if strings.TrimSpace(a.state.ActiveSessionID) == "" { a.activeMessages = nil a.clearActivities() + a.clearTodos() return nil } @@ -757,13 +787,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 +807,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 +872,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 +905,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 +955,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 +989,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 +1009,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 +1044,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 +1135,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 +1150,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 +1169,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 +1185,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 +1234,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 +1259,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 +1277,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 +1286,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 +1296,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 +1308,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 +1332,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 +1345,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 +1353,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 +1374,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 +1387,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 +1404,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 +1413,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 +1452,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 +1472,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 +1480,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 +1502,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 +1557,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 +1696,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 +1718,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 +1743,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 +1813,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 +1969,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 +2002,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 +2035,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 { @@ -1872,7 +2138,7 @@ func (a *App) clearRunProgress() { } func (a *App) handleImmediateSlashCommand(input string) (bool, tea.Cmd) { - command, rest := splitFirstWord(strings.ToLower(strings.TrimSpace(input))) + command, _ := splitFirstWord(strings.ToLower(strings.TrimSpace(input))) switch command { case slashCommandExit: return true, tea.Quit @@ -1880,43 +2146,6 @@ func (a *App) handleImmediateSlashCommand(input string) (bool, tea.Cmd) { a.startDraftSession() a.state.StatusText = "[System] Cleared current draft/history." return true, nil - case slashCommandCompact: - if strings.TrimSpace(rest) != "" { - errText := fmt.Sprintf("usage: %s", slashUsageCompact) - a.state.ExecutionError = errText - a.state.StatusText = errText - a.appendInlineMessage(roleError, errText) - a.rebuildTranscript() - return true, nil - } - if strings.TrimSpace(a.state.ActiveSessionID) == "" { - errText := "compact requires an existing session" - a.state.ExecutionError = errText - a.state.StatusText = errText - a.appendInlineMessage(roleError, errText) - a.rebuildTranscript() - return true, nil - } - if a.isBusy() { - errText := "compact is already running, please wait" - a.state.ExecutionError = errText - a.state.StatusText = errText - a.appendInlineMessage(roleError, errText) - a.rebuildTranscript() - return true, nil - } - a.state.IsCompacting = true - a.state.StreamingReply = false - a.state.CurrentTool = "" - a.state.StatusText = statusCompacting - a.state.ExecutionError = "" - return true, runCompact(a.runtime, a.state.ActiveSessionID) - case slashCommandMemo: - return true, a.handleMemoCommand() - case slashCommandRemember: - return true, a.handleRememberCommand(rest) - case slashCommandForget: - return true, a.handleForgetCommand(rest) case slashCommandSession: if err := a.ensureSessionSwitchAllowed(""); err != nil { a.state.ExecutionError = err.Error() @@ -1940,7 +2169,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 +2223,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 +2274,6 @@ func runAgent(runtime agentruntime.Runtime, input agentruntime.PrepareInput) tea ) } -// runResolvePermission 提交一次权限审批决定到 runtime。 func runResolvePermission( runtime agentruntime.Runtime, requestID string, @@ -2067,115 +2295,18 @@ func runResolvePermission( ) } -// runCompact 在独立命令中触发 runtime compact,并把结果回传给 TUI。 -func runCompact(runtime agentruntime.Runtime, sessionID string) tea.Cmd { - return tuiservices.RunCompactCmd( - runtime, - agentruntime.CompactInput{SessionID: sessionID}, - func(err error) tea.Msg { return compactFinishedMsg{Err: err} }, - ) -} - -// 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 { - if a.memoSvc == nil { - a.appendInlineMessage(roleError, "[System] Memo service is not enabled.") - a.rebuildTranscript() - return nil - } - entries, err := a.memoSvc.List(context.Background()) - if err != nil { - a.appendInlineMessage(roleError, fmt.Sprintf("[System] Failed to load memo: %s", err)) - a.rebuildTranscript() - return nil - } - if len(entries) == 0 { - a.appendInlineMessage(roleSystem, "[System] No memos stored yet. Use /remember to add one.") - a.rebuildTranscript() - return nil - } - var lines []string - lines = append(lines, fmt.Sprintf("[System] %d memo(s):", len(entries))) - for _, entry := range entries { - lines = append(lines, fmt.Sprintf(" [%s] %s", entry.Type, entry.Title)) - } - a.appendInlineMessage(roleSystem, strings.Join(lines, "\n")) - a.rebuildTranscript() - return nil -} - -// handleRememberCommand 处理 /remember 命令,创建新的记忆条目。 -func (a *App) handleRememberCommand(text string) tea.Cmd { - text = strings.TrimSpace(text) - if a.memoSvc == nil { - a.appendInlineMessage(roleError, "[System] Memo service is not enabled.") - a.rebuildTranscript() - return nil - } - if text == "" { - a.appendInlineMessage(roleError, fmt.Sprintf("[System] Usage: %s", slashUsageRemember)) - a.rebuildTranscript() - return nil - } - title := memo.NormalizeTitle(text) - entry := memo.Entry{ - Type: memo.TypeUser, - Title: title, - Content: text, - Source: memo.SourceUserManual, - } - if err := a.memoSvc.Add(context.Background(), entry); err != nil { - a.appendInlineMessage(roleError, fmt.Sprintf("[System] Failed to save memo: %s", err)) - a.rebuildTranscript() - return nil - } - a.appendInlineMessage(roleSystem, fmt.Sprintf("[System] Memo saved: %s", title)) - a.rebuildTranscript() - return nil -} - -// handleForgetCommand 处理 /forget 命令,删除匹配的记忆条目。 -func (a *App) handleForgetCommand(keyword string) tea.Cmd { - keyword = strings.TrimSpace(keyword) - if a.memoSvc == nil { - a.appendInlineMessage(roleError, "[System] Memo service is not enabled.") - a.rebuildTranscript() - return nil - } - if keyword == "" { - a.appendInlineMessage(roleError, fmt.Sprintf("[System] Usage: %s", slashUsageForget)) - a.rebuildTranscript() - return nil - } - removed, err := a.memoSvc.Remove(context.Background(), keyword) - if err != nil { - a.appendInlineMessage(roleError, fmt.Sprintf("[System] Failed to remove memo: %s", err)) - a.rebuildTranscript() - return nil - } - if removed == 0 { - a.appendInlineMessage(roleSystem, fmt.Sprintf("[System] No memos matching %q.", keyword)) - } else { - a.appendInlineMessage(roleSystem, fmt.Sprintf("[System] Removed %d memo(s) matching %q.", removed, keyword)) - } - a.rebuildTranscript() - return nil -} - -// 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 @@ -2293,6 +2424,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: @@ -2313,30 +2446,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: @@ -2525,7 +2662,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 5b24ef62..167dd91f 100644 --- a/internal/tui/core/app/update_permission_test.go +++ b/internal/tui/core/app/update_permission_test.go @@ -106,6 +106,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 8152ad20..e16261d7 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -14,7 +14,6 @@ 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" @@ -283,7 +282,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) { @@ -452,8 +455,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") } } @@ -1452,8 +1455,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 { @@ -1464,7 +1467,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 != "" { @@ -1658,7 +1661,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") } @@ -1869,8 +1872,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") @@ -1890,44 +1893,6 @@ func TestRunSlashCommandSelectionWorkspaceAndLocal(t *testing.T) { } } -func TestHandleImmediateSlashCommandCompactBranches(t *testing.T) { - app, runtime := newTestApp(t) - app.state.ActiveSessionID = "session-1" - - handled, cmd := app.handleImmediateSlashCommand(slashCommandCompact + " now") - if !handled || cmd != nil { - t.Fatalf("expected compact with args to be handled without cmd") - } - if !strings.Contains(app.state.StatusText, "usage:") { - t.Fatalf("expected usage error for compact with args") - } - - app.state.ExecutionError = "" - app.state.IsCompacting = true - handled, cmd = app.handleImmediateSlashCommand(slashCommandCompact) - if !handled || cmd != nil { - t.Fatalf("expected compact busy branch to return handled with nil cmd") - } - if !strings.Contains(app.state.StatusText, "already running") { - t.Fatalf("expected busy message") - } - - app.state.IsCompacting = false - app.state.IsAgentRunning = false - app.state.StatusText = "" - handled, cmd = app.handleImmediateSlashCommand(slashCommandCompact) - if !handled || cmd == nil { - t.Fatalf("expected compact success branch to return cmd") - } - msg := cmd() - if _, ok := msg.(compactFinishedMsg); !ok { - t.Fatalf("expected compactFinishedMsg, got %T", msg) - } - if len(runtime.resolveCalls) != 0 { - t.Fatalf("compact should not resolve permissions") - } -} - func TestHandleImmediateSlashCommandDefault(t *testing.T) { app, _ := newTestApp(t) handled, cmd := app.handleImmediateSlashCommand("/unknown") @@ -2011,212 +1976,6 @@ func TestSetCurrentWorkdir(t *testing.T) { }) } -// newTestAppWithMemo 创建一个注入了 memo 服务的测试 App。 -func newTestAppWithMemo(t *testing.T) (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(t.TempDir(), cfg.Workdir) - memoSvc := memo.NewService(memoStore, nil, cfg.Memo, nil) - - runtime := newStubRuntime() - 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.Error("expected nil cmd") - } - msgs := app.activeMessages - if len(msgs) == 0 { - t.Fatal("expected at least one inline message") - } - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "No memos stored yet") { - t.Errorf("expected 'no memos' message, got: %s", messageText(last)) - } - }) - - 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}) - - app.handleMemoCommand() - msgs := app.activeMessages - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "1 memo(s)") { - t.Errorf("expected memo count, got: %s", messageText(last)) - } - if !strings.Contains(messageText(last), "test entry") { - t.Errorf("expected entry title, got: %s", messageText(last)) - } - }) - - t.Run("nil memoSvc shows error", func(t *testing.T) { - app, _ := newTestApp(t) - cmd := app.handleMemoCommand() - if cmd != nil { - t.Error("expected nil cmd") - } - msgs := app.activeMessages - if len(msgs) == 0 { - t.Fatal("expected at least one inline message") - } - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "not enabled") { - t.Errorf("expected 'not enabled' message, got: %s", messageText(last)) - } - }) -} - -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.Error("expected nil cmd") - } - msgs := app.activeMessages - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "Memo saved") { - t.Errorf("expected saved confirmation, got: %s", messageText(last)) - } - // Verify the entry was actually saved - entries, _ := app.memoSvc.List(context.Background()) - 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("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("nil memoSvc shows error", func(t *testing.T) { - app, _ := newTestApp(t) - app.handleRememberCommand("something") - msgs := app.activeMessages - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "not enabled") { - t.Errorf("expected 'not enabled' message, got: %s", messageText(last)) - } - }) -} - -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}) - - app.handleForgetCommand("remove") - msgs := app.activeMessages - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "Removed 1 memo") { - t.Errorf("expected removal confirmation, got: %s", messageText(last)) - } - // Verify only one was removed - entries, _ := app.memoSvc.List(context.Background()) - 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) - app.handleForgetCommand("nonexistent") - msgs := app.activeMessages - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "No memos matching") { - t.Errorf("expected no match message, got: %s", messageText(last)) - } - }) - - 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("nil memoSvc shows error", func(t *testing.T) { - app, _ := newTestApp(t) - app.handleForgetCommand("something") - msgs := app.activeMessages - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "not enabled") { - t.Errorf("expected 'not enabled' message, got: %s", messageText(last)) - } - }) -} - func TestNoteInputEditTracksPasteHeuristics(t *testing.T) { app, _ := newTestApp(t) base := time.Now() @@ -2419,14 +2178,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) } @@ -2797,24 +2556,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")} 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 ) From cd29732e1d6dfb596b4f9a86ead46e99131df3a8 Mon Sep 17 00:00:00 2001 From: creatang Date: Sat, 18 Apr 2026 17:29:14 +0800 Subject: [PATCH 3/5] feat(tui): add demo todo panel and remove obsolete slash commands --- README.md | 6 +- docs/context-compact.md | 4 +- docs/guides/configuration.md | 2 +- docs/tools-and-tui-integration.md | 2 +- internal/tui/core/app/app.go | 21 + internal/tui/core/app/commands.go | 18 +- internal/tui/core/app/commands_test.go | 14 +- .../tui/core/app/permission_prompt_test.go | 6 + internal/tui/core/app/todo.go | 385 +++++++++++ internal/tui/core/app/todo_test.go | 604 ++++++++++++++++++ internal/tui/core/app/update.go | 546 ++++++++++------ .../tui/core/app/update_permission_test.go | 3 + internal/tui/core/app/update_test.go | 289 +-------- internal/tui/core/app/view.go | 9 +- internal/tui/core/app/view_test.go | 2 + internal/tui/core/utils/view_helpers.go | 3 + internal/tui/core/utils/view_helpers_test.go | 3 +- internal/tui/state/state_test.go | 9 +- internal/tui/state/ui_state.go | 1 + 19 files changed, 1417 insertions(+), 510 deletions(-) create mode 100644 internal/tui/core/app/todo.go create mode 100644 internal/tui/core/app/todo_test.go diff --git a/README.md b/README.md index 1e6bd26c..e8533bb0 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ NeoCode 是一个在终端中运行的 AI 编码助手,采用 ReAct(Reason-A - 终端原生 TUI 交互体验(Bubble Tea) - Agent 可调用内置工具完成文件与命令相关任务 - 支持 Provider/Model 切换(内建 `openai`、`gemini`、`openll`、`qiniu`) -- 支持上下文压缩(`/compact`),帮助长会话保持可用 +- 支持上下文压缩能力(manual/auto/reactive),帮助长会话保持可用 - 支持工作区隔离(`--workdir`、`/cwd`) - 会话持久化与恢复,降低重复沟通成本 - 支持持久记忆查看、显式写入与后台自动提取,保留跨会话偏好与项目事实 @@ -112,12 +112,8 @@ go run ./cmd/neocode --workdir /path/to/workspace - `/help`:查看命令帮助 - `/provider`:打开 provider 选择器 - `/model`:打开 model 选择器 -- `/compact`:压缩当前会话上下文 - `/status`:查看当前会话与运行状态 - `/cwd [path]`:查看或设置当前会话工作区 -- `/memo`:查看记忆索引 -- `/remember `:保存记忆 -- `/forget `:按关键词删除记忆 - `& `:在当前工作区执行本地命令 示例输入: diff --git a/docs/context-compact.md b/docs/context-compact.md index 86c20a63..2bb36fb4 100644 --- a/docs/context-compact.md +++ b/docs/context-compact.md @@ -12,7 +12,7 @@ - runtime 已接入手动 compact、基于 token 阈值的自动 compact,以及 provider 上下文过长后的 `reactive` compact 自动恢复。 - `internal/context/compact` 支持 `manual`、`auto` 与 `reactive` 三种 mode。 -- 用户通过 `/compact` 对当前会话执行一次上下文压缩。 +- 当前 TUI 不提供 `/compact` 入口;`manual` compact 由 runtime 接口触发。 - compact 前会先写入完整 transcript,随后生成并校验新的 durable `TaskState` 与 display summary,再回写会话消息。 ## 配置 @@ -64,7 +64,7 @@ context: ## 执行链路 -1. TUI 识别 `/compact` 并调用 `runtime.Compact(...)`。 +1. 由 runtime 触发 `runtime.Compact(...)`(手动或自动模式)。 2. runtime 发出 `compact_start` 事件。 3. compact runner 将原始消息写入 transcript(JSONL)。 4. compact runner 根据策略构造归档消息与保留消息,并过滤旧的 `[compact_summary]` 展示摘要,避免“摘要的摘要”。 diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 4cf8e1e1..5a33a3c7 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -75,7 +75,7 @@ context: | 字段 | 说明 | |------|------| -| `context.compact.manual_strategy` | `/compact` 手动压缩策略,支持 `keep_recent` / `full_replace` | +| `context.compact.manual_strategy` | manual compact 策略,支持 `keep_recent` / `full_replace` | | `context.compact.manual_keep_recent_messages` | `keep_recent` 策略下保留的最近消息数 | | `context.compact.read_time_max_message_spans` | context 读时保留的 message span 上限,用于降低“继续”时较早文件读取结果被过早裁掉的风险 | | `context.compact.max_summary_chars` | compact summary 最大字符数 | diff --git a/docs/tools-and-tui-integration.md b/docs/tools-and-tui-integration.md index 79d72691..96938e76 100644 --- a/docs/tools-and-tui-integration.md +++ b/docs/tools-and-tui-integration.md @@ -25,7 +25,7 @@ ## Memo 能力集成 - `memo_remember` 与 `memo_recall` 作为标准工具暴露给模型,沿 `Runtime -> Tool Manager -> internal/tools/memo` 链路执行。 - 自动记忆提取不作为单独工具暴露给模型,也不由 TUI 直接触发;它在 runtime 完成最终回复后由 memo 子系统后台调度。 -- TUI 目前只通过 Slash Command 展示和管理 memo(如 `/memo`、`/remember`、`/forget`),不会展示后台自动提取的中间状态。 +- TUI 不直接提供 memo 管理型 Slash Command;memo 的写入与召回由 runtime 工具链路驱动,不展示后台自动提取的中间状态。 ## TUI 集成方式 - 本地配置操作统一通过 Slash Command 完成,例如 Base URL、API Key 和模型选择 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..a8310976 100644 --- a/internal/tui/core/app/commands.go +++ b/internal/tui/core/app/commands.go @@ -21,30 +21,22 @@ const ( slashCommandHelp = "/help" slashCommandExit = "/exit" slashCommandClear = "/clear" - slashCommandCompact = "/compact" slashCommandStatus = "/status" slashCommandProvider = "/provider" slashCommandProviderAdd = "/provider add" slashCommandModelPick = "/model" slashCommandSession = "/session" slashCommandCWD = "/cwd" - slashCommandMemo = "/memo" - slashCommandRemember = "/remember" - slashCommandForget = "/forget" slashUsageHelp = "/help" slashUsageExit = "/exit" slashUsageClear = "/clear" - slashUsageCompact = "/compact" slashUsageStatus = "/status" slashUsageProvider = "/provider" slashUsageProviderAdd = "/provider add" slashUsageModel = "/model" slashUsageSession = "/session" slashUsageWorkdir = "/cwd" - slashUsageMemo = "/memo" - slashUsageRemember = "/remember " - slashUsageForget = "/forget " commandMenuTitle = "Suggestions" providerPickerTitle = "Select Provider" @@ -62,6 +54,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." @@ -81,10 +74,12 @@ const ( statusApplyingCommand = "Applying local command" statusRunningCommand = "Running command" statusCommandDone = "Command finished" - statusCompacting = "Compacting context" 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 +89,7 @@ const ( focusLabelSessions = "Sessions" focusLabelTranscript = "Transcript" focusLabelActivity = "Activity" + focusLabelTodo = "Todo" focusLabelComposer = "Composer" maxActivityEntries = 64 @@ -120,12 +116,8 @@ type commandSuggestion = tuicommands.CommandSuggestion var builtinSlashCommands = []slashCommand{ {Usage: slashUsageHelp, Description: "Show slash command help"}, {Usage: slashUsageClear, Description: "Clear the current draft transcript"}, - {Usage: slashUsageCompact, Description: "Compact the current session context"}, {Usage: slashUsageStatus, Description: "Show current session and agent status"}, {Usage: slashUsageWorkdir, Description: "Show or set current session workspace root (/cwd [path])"}, - {Usage: slashUsageMemo, Description: "Show persistent memo index"}, - {Usage: slashUsageRemember, Description: "Save a persistent memo (/remember )"}, - {Usage: slashUsageForget, Description: "Remove memos matching keyword (/forget )"}, {Usage: slashUsageProvider, Description: "Open the interactive provider picker"}, {Usage: slashUsageProviderAdd, Description: "Add a new custom provider"}, {Usage: slashUsageModel, Description: "Open the interactive model picker"}, diff --git a/internal/tui/core/app/commands_test.go b/internal/tui/core/app/commands_test.go index ce0ccade..b1bcf776 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) { @@ -72,9 +78,10 @@ func TestStatusConstants(t *testing.T) { {"statusApplyingCommand", statusApplyingCommand}, {"statusRunningCommand", statusRunningCommand}, {"statusCommandDone", statusCommandDone}, - {"statusCompacting", statusCompacting}, {"statusChooseProvider", statusChooseProvider}, {"statusChooseModel", statusChooseModel}, + {"statusTodoCollapsed", statusTodoCollapsed}, + {"statusTodoExpanded", statusTodoExpanded}, {"statusChooseHelp", statusChooseHelp}, {"statusBrowseFile", statusBrowseFile}, } @@ -98,6 +105,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 a46549d4..afff0c43 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -18,7 +18,6 @@ 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" @@ -63,6 +62,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 +112,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 { @@ -146,21 +147,6 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.syncConfigState(cfg) selectPickerItemByID(&a.modelPicker, cfg.CurrentModel) return a, tea.Batch(cmds...) - case compactFinishedMsg: - a.state.IsCompacting = false - if typed.Err != nil && strings.TrimSpace(a.state.ExecutionError) == "" { - a.state.ExecutionError = typed.Err.Error() - a.state.StatusText = typed.Err.Error() - } - if err := a.refreshMessages(); err != nil && strings.TrimSpace(a.state.ActiveSessionID) != "" { - a.state.ExecutionError = err.Error() - a.state.StatusText = err.Error() - a.appendInlineMessage(roleError, err.Error()) - } - a.syncActiveSessionTitle() - a.rebuildTranscript() - a.transcript.GotoBottom() - return a, tea.Batch(cmds...) case localCommandResultMsg: if typed.Err != nil { a.state.ExecutionError = typed.Err.Error() @@ -220,6 +206,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 +245,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 +282,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 +352,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 +378,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 +462,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 +518,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 +549,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 +776,7 @@ func (a *App) refreshMessages() error { if strings.TrimSpace(a.state.ActiveSessionID) == "" { a.activeMessages = nil a.clearActivities() + a.clearTodos() return nil } @@ -757,13 +787,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 +807,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 +872,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 +905,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 +955,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 +989,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 +1009,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 +1044,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 +1135,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 +1150,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 +1169,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 +1185,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 +1234,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 +1259,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 +1277,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 +1286,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 +1296,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 +1308,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 +1332,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 +1345,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 +1353,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 +1374,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 +1387,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 +1404,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 +1413,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 +1452,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 +1472,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 +1480,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 +1502,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 +1557,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 +1696,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 +1718,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 +1743,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 +1813,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 +1969,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 +2002,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 +2035,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 { @@ -1872,7 +2138,7 @@ func (a *App) clearRunProgress() { } func (a *App) handleImmediateSlashCommand(input string) (bool, tea.Cmd) { - command, rest := splitFirstWord(strings.ToLower(strings.TrimSpace(input))) + command, _ := splitFirstWord(strings.ToLower(strings.TrimSpace(input))) switch command { case slashCommandExit: return true, tea.Quit @@ -1880,43 +2146,6 @@ func (a *App) handleImmediateSlashCommand(input string) (bool, tea.Cmd) { a.startDraftSession() a.state.StatusText = "[System] Cleared current draft/history." return true, nil - case slashCommandCompact: - if strings.TrimSpace(rest) != "" { - errText := fmt.Sprintf("usage: %s", slashUsageCompact) - a.state.ExecutionError = errText - a.state.StatusText = errText - a.appendInlineMessage(roleError, errText) - a.rebuildTranscript() - return true, nil - } - if strings.TrimSpace(a.state.ActiveSessionID) == "" { - errText := "compact requires an existing session" - a.state.ExecutionError = errText - a.state.StatusText = errText - a.appendInlineMessage(roleError, errText) - a.rebuildTranscript() - return true, nil - } - if a.isBusy() { - errText := "compact is already running, please wait" - a.state.ExecutionError = errText - a.state.StatusText = errText - a.appendInlineMessage(roleError, errText) - a.rebuildTranscript() - return true, nil - } - a.state.IsCompacting = true - a.state.StreamingReply = false - a.state.CurrentTool = "" - a.state.StatusText = statusCompacting - a.state.ExecutionError = "" - return true, runCompact(a.runtime, a.state.ActiveSessionID) - case slashCommandMemo: - return true, a.handleMemoCommand() - case slashCommandRemember: - return true, a.handleRememberCommand(rest) - case slashCommandForget: - return true, a.handleForgetCommand(rest) case slashCommandSession: if err := a.ensureSessionSwitchAllowed(""); err != nil { a.state.ExecutionError = err.Error() @@ -1940,7 +2169,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 +2223,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 +2274,6 @@ func runAgent(runtime agentruntime.Runtime, input agentruntime.PrepareInput) tea ) } -// runResolvePermission 提交一次权限审批决定到 runtime。 func runResolvePermission( runtime agentruntime.Runtime, requestID string, @@ -2067,115 +2295,18 @@ func runResolvePermission( ) } -// runCompact 在独立命令中触发 runtime compact,并把结果回传给 TUI。 -func runCompact(runtime agentruntime.Runtime, sessionID string) tea.Cmd { - return tuiservices.RunCompactCmd( - runtime, - agentruntime.CompactInput{SessionID: sessionID}, - func(err error) tea.Msg { return compactFinishedMsg{Err: err} }, - ) -} - -// 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 { - if a.memoSvc == nil { - a.appendInlineMessage(roleError, "[System] Memo service is not enabled.") - a.rebuildTranscript() - return nil - } - entries, err := a.memoSvc.List(context.Background()) - if err != nil { - a.appendInlineMessage(roleError, fmt.Sprintf("[System] Failed to load memo: %s", err)) - a.rebuildTranscript() - return nil - } - if len(entries) == 0 { - a.appendInlineMessage(roleSystem, "[System] No memos stored yet. Use /remember to add one.") - a.rebuildTranscript() - return nil - } - var lines []string - lines = append(lines, fmt.Sprintf("[System] %d memo(s):", len(entries))) - for _, entry := range entries { - lines = append(lines, fmt.Sprintf(" [%s] %s", entry.Type, entry.Title)) - } - a.appendInlineMessage(roleSystem, strings.Join(lines, "\n")) - a.rebuildTranscript() - return nil -} - -// handleRememberCommand 处理 /remember 命令,创建新的记忆条目。 -func (a *App) handleRememberCommand(text string) tea.Cmd { - text = strings.TrimSpace(text) - if a.memoSvc == nil { - a.appendInlineMessage(roleError, "[System] Memo service is not enabled.") - a.rebuildTranscript() - return nil - } - if text == "" { - a.appendInlineMessage(roleError, fmt.Sprintf("[System] Usage: %s", slashUsageRemember)) - a.rebuildTranscript() - return nil - } - title := memo.NormalizeTitle(text) - entry := memo.Entry{ - Type: memo.TypeUser, - Title: title, - Content: text, - Source: memo.SourceUserManual, - } - if err := a.memoSvc.Add(context.Background(), entry); err != nil { - a.appendInlineMessage(roleError, fmt.Sprintf("[System] Failed to save memo: %s", err)) - a.rebuildTranscript() - return nil - } - a.appendInlineMessage(roleSystem, fmt.Sprintf("[System] Memo saved: %s", title)) - a.rebuildTranscript() - return nil -} - -// handleForgetCommand 处理 /forget 命令,删除匹配的记忆条目。 -func (a *App) handleForgetCommand(keyword string) tea.Cmd { - keyword = strings.TrimSpace(keyword) - if a.memoSvc == nil { - a.appendInlineMessage(roleError, "[System] Memo service is not enabled.") - a.rebuildTranscript() - return nil - } - if keyword == "" { - a.appendInlineMessage(roleError, fmt.Sprintf("[System] Usage: %s", slashUsageForget)) - a.rebuildTranscript() - return nil - } - removed, err := a.memoSvc.Remove(context.Background(), keyword) - if err != nil { - a.appendInlineMessage(roleError, fmt.Sprintf("[System] Failed to remove memo: %s", err)) - a.rebuildTranscript() - return nil - } - if removed == 0 { - a.appendInlineMessage(roleSystem, fmt.Sprintf("[System] No memos matching %q.", keyword)) - } else { - a.appendInlineMessage(roleSystem, fmt.Sprintf("[System] Removed %d memo(s) matching %q.", removed, keyword)) - } - a.rebuildTranscript() - return nil -} - -// 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 @@ -2293,6 +2424,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: @@ -2313,30 +2446,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: @@ -2525,7 +2662,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 5b24ef62..167dd91f 100644 --- a/internal/tui/core/app/update_permission_test.go +++ b/internal/tui/core/app/update_permission_test.go @@ -106,6 +106,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 8152ad20..e16261d7 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -14,7 +14,6 @@ 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" @@ -283,7 +282,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) { @@ -452,8 +455,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") } } @@ -1452,8 +1455,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 { @@ -1464,7 +1467,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 != "" { @@ -1658,7 +1661,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") } @@ -1869,8 +1872,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") @@ -1890,44 +1893,6 @@ func TestRunSlashCommandSelectionWorkspaceAndLocal(t *testing.T) { } } -func TestHandleImmediateSlashCommandCompactBranches(t *testing.T) { - app, runtime := newTestApp(t) - app.state.ActiveSessionID = "session-1" - - handled, cmd := app.handleImmediateSlashCommand(slashCommandCompact + " now") - if !handled || cmd != nil { - t.Fatalf("expected compact with args to be handled without cmd") - } - if !strings.Contains(app.state.StatusText, "usage:") { - t.Fatalf("expected usage error for compact with args") - } - - app.state.ExecutionError = "" - app.state.IsCompacting = true - handled, cmd = app.handleImmediateSlashCommand(slashCommandCompact) - if !handled || cmd != nil { - t.Fatalf("expected compact busy branch to return handled with nil cmd") - } - if !strings.Contains(app.state.StatusText, "already running") { - t.Fatalf("expected busy message") - } - - app.state.IsCompacting = false - app.state.IsAgentRunning = false - app.state.StatusText = "" - handled, cmd = app.handleImmediateSlashCommand(slashCommandCompact) - if !handled || cmd == nil { - t.Fatalf("expected compact success branch to return cmd") - } - msg := cmd() - if _, ok := msg.(compactFinishedMsg); !ok { - t.Fatalf("expected compactFinishedMsg, got %T", msg) - } - if len(runtime.resolveCalls) != 0 { - t.Fatalf("compact should not resolve permissions") - } -} - func TestHandleImmediateSlashCommandDefault(t *testing.T) { app, _ := newTestApp(t) handled, cmd := app.handleImmediateSlashCommand("/unknown") @@ -2011,212 +1976,6 @@ func TestSetCurrentWorkdir(t *testing.T) { }) } -// newTestAppWithMemo 创建一个注入了 memo 服务的测试 App。 -func newTestAppWithMemo(t *testing.T) (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(t.TempDir(), cfg.Workdir) - memoSvc := memo.NewService(memoStore, nil, cfg.Memo, nil) - - runtime := newStubRuntime() - 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.Error("expected nil cmd") - } - msgs := app.activeMessages - if len(msgs) == 0 { - t.Fatal("expected at least one inline message") - } - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "No memos stored yet") { - t.Errorf("expected 'no memos' message, got: %s", messageText(last)) - } - }) - - 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}) - - app.handleMemoCommand() - msgs := app.activeMessages - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "1 memo(s)") { - t.Errorf("expected memo count, got: %s", messageText(last)) - } - if !strings.Contains(messageText(last), "test entry") { - t.Errorf("expected entry title, got: %s", messageText(last)) - } - }) - - t.Run("nil memoSvc shows error", func(t *testing.T) { - app, _ := newTestApp(t) - cmd := app.handleMemoCommand() - if cmd != nil { - t.Error("expected nil cmd") - } - msgs := app.activeMessages - if len(msgs) == 0 { - t.Fatal("expected at least one inline message") - } - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "not enabled") { - t.Errorf("expected 'not enabled' message, got: %s", messageText(last)) - } - }) -} - -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.Error("expected nil cmd") - } - msgs := app.activeMessages - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "Memo saved") { - t.Errorf("expected saved confirmation, got: %s", messageText(last)) - } - // Verify the entry was actually saved - entries, _ := app.memoSvc.List(context.Background()) - 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("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("nil memoSvc shows error", func(t *testing.T) { - app, _ := newTestApp(t) - app.handleRememberCommand("something") - msgs := app.activeMessages - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "not enabled") { - t.Errorf("expected 'not enabled' message, got: %s", messageText(last)) - } - }) -} - -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}) - - app.handleForgetCommand("remove") - msgs := app.activeMessages - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "Removed 1 memo") { - t.Errorf("expected removal confirmation, got: %s", messageText(last)) - } - // Verify only one was removed - entries, _ := app.memoSvc.List(context.Background()) - 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) - app.handleForgetCommand("nonexistent") - msgs := app.activeMessages - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "No memos matching") { - t.Errorf("expected no match message, got: %s", messageText(last)) - } - }) - - 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("nil memoSvc shows error", func(t *testing.T) { - app, _ := newTestApp(t) - app.handleForgetCommand("something") - msgs := app.activeMessages - last := msgs[len(msgs)-1] - if !strings.Contains(messageText(last), "not enabled") { - t.Errorf("expected 'not enabled' message, got: %s", messageText(last)) - } - }) -} - func TestNoteInputEditTracksPasteHeuristics(t *testing.T) { app, _ := newTestApp(t) base := time.Now() @@ -2419,14 +2178,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) } @@ -2797,24 +2556,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")} 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 ) From 96771f5966d47704b934fbd004bf67ed02318a1c Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sat, 18 Apr 2026 10:05:04 +0000 Subject: [PATCH 4/5] test(tui): improve coverage for app and update branches Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: creatang <165447160+creatang@users.noreply.github.com> --- internal/tui/core/app/update_test.go | 460 +++++++++++++++++++++++++++ 1 file changed, 460 insertions(+) diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index 2369e4d4..22a3a0d6 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -28,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 @@ -36,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 } @@ -68,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 } @@ -2702,3 +2710,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) + } +} From 2489afb4bce0af4dc2dbb9c3f5232dfc2ee40bc4 Mon Sep 17 00:00:00 2001 From: creatang Date: Sat, 18 Apr 2026 23:02:45 +0800 Subject: [PATCH 5/5] revert(tui): restore compact and memo slash commands --- README.md | 6 +- docs/context-compact.md | 4 +- docs/guides/configuration.md | 2 +- docs/tools-and-tui-integration.md | 7 +- internal/tui/core/app/commands.go | 13 +++ internal/tui/core/app/commands_test.go | 1 + internal/tui/core/app/update.go | 127 ++++++++++++++++++++++++- internal/tui/core/app/update_test.go | 120 +++++++++++++++++++++++ 8 files changed, 273 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e8533bb0..1e6bd26c 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ NeoCode 是一个在终端中运行的 AI 编码助手,采用 ReAct(Reason-A - 终端原生 TUI 交互体验(Bubble Tea) - Agent 可调用内置工具完成文件与命令相关任务 - 支持 Provider/Model 切换(内建 `openai`、`gemini`、`openll`、`qiniu`) -- 支持上下文压缩能力(manual/auto/reactive),帮助长会话保持可用 +- 支持上下文压缩(`/compact`),帮助长会话保持可用 - 支持工作区隔离(`--workdir`、`/cwd`) - 会话持久化与恢复,降低重复沟通成本 - 支持持久记忆查看、显式写入与后台自动提取,保留跨会话偏好与项目事实 @@ -112,8 +112,12 @@ go run ./cmd/neocode --workdir /path/to/workspace - `/help`:查看命令帮助 - `/provider`:打开 provider 选择器 - `/model`:打开 model 选择器 +- `/compact`:压缩当前会话上下文 - `/status`:查看当前会话与运行状态 - `/cwd [path]`:查看或设置当前会话工作区 +- `/memo`:查看记忆索引 +- `/remember `:保存记忆 +- `/forget `:按关键词删除记忆 - `& `:在当前工作区执行本地命令 示例输入: diff --git a/docs/context-compact.md b/docs/context-compact.md index 2bb36fb4..86c20a63 100644 --- a/docs/context-compact.md +++ b/docs/context-compact.md @@ -12,7 +12,7 @@ - runtime 已接入手动 compact、基于 token 阈值的自动 compact,以及 provider 上下文过长后的 `reactive` compact 自动恢复。 - `internal/context/compact` 支持 `manual`、`auto` 与 `reactive` 三种 mode。 -- 当前 TUI 不提供 `/compact` 入口;`manual` compact 由 runtime 接口触发。 +- 用户通过 `/compact` 对当前会话执行一次上下文压缩。 - compact 前会先写入完整 transcript,随后生成并校验新的 durable `TaskState` 与 display summary,再回写会话消息。 ## 配置 @@ -64,7 +64,7 @@ context: ## 执行链路 -1. 由 runtime 触发 `runtime.Compact(...)`(手动或自动模式)。 +1. TUI 识别 `/compact` 并调用 `runtime.Compact(...)`。 2. runtime 发出 `compact_start` 事件。 3. compact runner 将原始消息写入 transcript(JSONL)。 4. compact runner 根据策略构造归档消息与保留消息,并过滤旧的 `[compact_summary]` 展示摘要,避免“摘要的摘要”。 diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 5a33a3c7..4cf8e1e1 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -75,7 +75,7 @@ context: | 字段 | 说明 | |------|------| -| `context.compact.manual_strategy` | manual compact 策略,支持 `keep_recent` / `full_replace` | +| `context.compact.manual_strategy` | `/compact` 手动压缩策略,支持 `keep_recent` / `full_replace` | | `context.compact.manual_keep_recent_messages` | `keep_recent` 策略下保留的最近消息数 | | `context.compact.read_time_max_message_spans` | context 读时保留的 message span 上限,用于降低“继续”时较早文件读取结果被过早裁掉的风险 | | `context.compact.max_summary_chars` | compact summary 最大字符数 | diff --git a/docs/tools-and-tui-integration.md b/docs/tools-and-tui-integration.md index 96938e76..f00f7927 100644 --- a/docs/tools-and-tui-integration.md +++ b/docs/tools-and-tui-integration.md @@ -21,11 +21,14 @@ - `webfetch` - `memo_remember` - `memo_recall` +- `memo_list` +- `memo_remove` ## Memo 能力集成 -- `memo_remember` 与 `memo_recall` 作为标准工具暴露给模型,沿 `Runtime -> Tool Manager -> internal/tools/memo` 链路执行。 +- `memo_remember`、`memo_recall`、`memo_list`、`memo_remove` 作为标准工具暴露给模型,沿 `Runtime -> Tool Manager -> internal/tools/memo` 链路执行。 - 自动记忆提取不作为单独工具暴露给模型,也不由 TUI 直接触发;它在 runtime 完成最终回复后由 memo 子系统后台调度。 -- TUI 不直接提供 memo 管理型 Slash Command;memo 的写入与召回由 runtime 工具链路驱动,不展示后台自动提取的中间状态。 +- TUI 的 `/memo`、`/remember`、`/forget` 等 Slash Command 不再直接依赖 memo service,而是通过 `Runtime.ExecuteSystemTool` 统一入口触发系统工具执行,保证 UI 与 memo 逻辑解耦。 +- TUI 不会展示后台自动提取的中间状态。 ## TUI 集成方式 - 本地配置操作统一通过 Slash Command 完成,例如 Base URL、API Key 和模型选择 diff --git a/internal/tui/core/app/commands.go b/internal/tui/core/app/commands.go index a8310976..b10a657e 100644 --- a/internal/tui/core/app/commands.go +++ b/internal/tui/core/app/commands.go @@ -21,22 +21,30 @@ const ( slashCommandHelp = "/help" slashCommandExit = "/exit" slashCommandClear = "/clear" + slashCommandCompact = "/compact" slashCommandStatus = "/status" slashCommandProvider = "/provider" slashCommandProviderAdd = "/provider add" slashCommandModelPick = "/model" slashCommandSession = "/session" slashCommandCWD = "/cwd" + slashCommandMemo = "/memo" + slashCommandRemember = "/remember" + slashCommandForget = "/forget" slashUsageHelp = "/help" slashUsageExit = "/exit" slashUsageClear = "/clear" + slashUsageCompact = "/compact" slashUsageStatus = "/status" slashUsageProvider = "/provider" slashUsageProviderAdd = "/provider add" slashUsageModel = "/model" slashUsageSession = "/session" slashUsageWorkdir = "/cwd" + slashUsageMemo = "/memo" + slashUsageRemember = "/remember " + slashUsageForget = "/forget " commandMenuTitle = "Suggestions" providerPickerTitle = "Select Provider" @@ -74,6 +82,7 @@ const ( statusApplyingCommand = "Applying local command" statusRunningCommand = "Running command" statusCommandDone = "Command finished" + statusCompacting = "Compacting context" statusChooseProvider = "Choose a provider" statusChooseModel = "Choose a model" statusChooseSession = "Choose a session" @@ -116,8 +125,12 @@ type commandSuggestion = tuicommands.CommandSuggestion var builtinSlashCommands = []slashCommand{ {Usage: slashUsageHelp, Description: "Show slash command help"}, {Usage: slashUsageClear, Description: "Clear the current draft transcript"}, + {Usage: slashUsageCompact, Description: "Compact the current session context"}, {Usage: slashUsageStatus, Description: "Show current session and agent status"}, {Usage: slashUsageWorkdir, Description: "Show or set current session workspace root (/cwd [path])"}, + {Usage: slashUsageMemo, Description: "Show persistent memo index"}, + {Usage: slashUsageRemember, Description: "Save a persistent memo (/remember )"}, + {Usage: slashUsageForget, Description: "Remove memos matching keyword (/forget )"}, {Usage: slashUsageProvider, Description: "Open the interactive provider picker"}, {Usage: slashUsageProviderAdd, Description: "Add a new custom provider"}, {Usage: slashUsageModel, Description: "Open the interactive model picker"}, diff --git a/internal/tui/core/app/commands_test.go b/internal/tui/core/app/commands_test.go index b1bcf776..7f1be039 100644 --- a/internal/tui/core/app/commands_test.go +++ b/internal/tui/core/app/commands_test.go @@ -78,6 +78,7 @@ func TestStatusConstants(t *testing.T) { {"statusApplyingCommand", statusApplyingCommand}, {"statusRunningCommand", statusRunningCommand}, {"statusCommandDone", statusCommandDone}, + {"statusCompacting", statusCompacting}, {"statusChooseProvider", statusChooseProvider}, {"statusChooseModel", statusChooseModel}, {"statusTodoCollapsed", statusTodoCollapsed}, diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index afff0c43..e2ec882d 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -2,6 +2,7 @@ package tui import ( "context" + "encoding/json" "errors" "fmt" "path/filepath" @@ -147,6 +148,21 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.syncConfigState(cfg) selectPickerItemByID(&a.modelPicker, cfg.CurrentModel) return a, tea.Batch(cmds...) + case compactFinishedMsg: + a.state.IsCompacting = false + if typed.Err != nil && strings.TrimSpace(a.state.ExecutionError) == "" { + a.state.ExecutionError = typed.Err.Error() + a.state.StatusText = typed.Err.Error() + } + if err := a.refreshMessages(); err != nil && strings.TrimSpace(a.state.ActiveSessionID) != "" { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendInlineMessage(roleError, err.Error()) + } + a.syncActiveSessionTitle() + a.rebuildTranscript() + a.transcript.GotoBottom() + return a, tea.Batch(cmds...) case localCommandResultMsg: if typed.Err != nil { a.state.ExecutionError = typed.Err.Error() @@ -2138,7 +2154,7 @@ func (a *App) clearRunProgress() { } func (a *App) handleImmediateSlashCommand(input string) (bool, tea.Cmd) { - command, _ := splitFirstWord(strings.ToLower(strings.TrimSpace(input))) + command, rest := splitFirstWord(strings.ToLower(strings.TrimSpace(input))) switch command { case slashCommandExit: return true, tea.Quit @@ -2146,6 +2162,43 @@ func (a *App) handleImmediateSlashCommand(input string) (bool, tea.Cmd) { a.startDraftSession() a.state.StatusText = "[System] Cleared current draft/history." return true, nil + case slashCommandCompact: + if strings.TrimSpace(rest) != "" { + errText := fmt.Sprintf("usage: %s", slashUsageCompact) + a.state.ExecutionError = errText + a.state.StatusText = errText + a.appendInlineMessage(roleError, errText) + a.rebuildTranscript() + return true, nil + } + if strings.TrimSpace(a.state.ActiveSessionID) == "" { + errText := "compact requires an existing session" + a.state.ExecutionError = errText + a.state.StatusText = errText + a.appendInlineMessage(roleError, errText) + a.rebuildTranscript() + return true, nil + } + if a.isBusy() { + errText := "compact is already running, please wait" + a.state.ExecutionError = errText + a.state.StatusText = errText + a.appendInlineMessage(roleError, errText) + a.rebuildTranscript() + return true, nil + } + a.state.IsCompacting = true + a.state.StreamingReply = false + a.state.CurrentTool = "" + a.state.StatusText = statusCompacting + a.state.ExecutionError = "" + return true, runCompact(a.runtime, a.state.ActiveSessionID) + case slashCommandMemo: + return true, a.handleMemoCommand() + case slashCommandRemember: + return true, a.handleRememberCommand(rest) + case slashCommandForget: + return true, a.handleForgetCommand(rest) case slashCommandSession: if err := a.ensureSessionSwitchAllowed(""); err != nil { a.state.ExecutionError = err.Error() @@ -2295,11 +2348,83 @@ func runResolvePermission( ) } +func runCompact(runtime agentruntime.Runtime, sessionID string) tea.Cmd { + return tuiservices.RunCompactCmd( + runtime, + agentruntime.CompactInput{SessionID: sessionID}, + func(err error) tea.Msg { return compactFinishedMsg{Err: err} }, + ) +} + // 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) } +func (a *App) handleMemoCommand() tea.Cmd { + return a.runMemoSystemTool(tools.ToolNameMemoList, map[string]any{}) +} + +func (a *App) handleRememberCommand(text string) tea.Cmd { + text = strings.TrimSpace(text) + if text == "" { + a.appendInlineMessage(roleError, fmt.Sprintf("[System] Usage: %s", slashUsageRemember)) + a.rebuildTranscript() + return nil + } + return a.runMemoSystemTool(tools.ToolNameMemoRemember, map[string]any{ + "type": "user", + "title": text, + "content": text, + }) +} + +func (a *App) handleForgetCommand(keyword string) tea.Cmd { + keyword = strings.TrimSpace(keyword) + if keyword == "" { + a.appendInlineMessage(roleError, fmt.Sprintf("[System] Usage: %s", slashUsageForget)) + a.rebuildTranscript() + return nil + } + return a.runMemoSystemTool(tools.ToolNameMemoRemove, map[string]any{ + "keyword": keyword, + "scope": "all", + }) +} + +func (a *App) runMemoSystemTool(toolName string, arguments map[string]any) tea.Cmd { + payload, err := json.Marshal(arguments) + if err != nil { + a.appendInlineMessage(roleError, fmt.Sprintf("[System] Failed to encode memo command: %s", err)) + a.rebuildTranscript() + return nil + } + + return tuiservices.RunSystemToolCmd( + a.runtime, + agentruntime.SystemToolInput{ + SessionID: a.state.ActiveSessionID, + Workdir: a.state.CurrentWorkdir, + ToolName: toolName, + Arguments: payload, + }, + func(result tools.ToolResult, err error) tea.Msg { + if err != nil { + message := strings.TrimSpace(result.Content) + if message == "" { + message = err.Error() + } + return localCommandResultMsg{Err: errors.New(message)} + } + notice := strings.TrimSpace(result.Content) + if notice == "" { + notice = "Memo command completed." + } + return localCommandResultMsg{Notice: notice} + }, + ) +} + // setCurrentWorkdir updates the current workdir only when the value is non-empty and absolute. func (a *App) setCurrentWorkdir(workdir string) { trimmed := strings.TrimSpace(workdir) diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index 2369e4d4..e3b8fdc0 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -1912,6 +1912,126 @@ func TestHandleImmediateSlashCommandDefault(t *testing.T) { } } +func TestHandleImmediateSlashCommandCompactBranches(t *testing.T) { + app, runtime := newTestApp(t) + app.state.ActiveSessionID = "session-1" + + handled, cmd := app.handleImmediateSlashCommand(slashCommandCompact + " now") + if !handled || cmd != nil { + t.Fatalf("expected compact with args to be handled without cmd") + } + if !strings.Contains(app.state.StatusText, "usage:") { + t.Fatalf("expected usage error for compact with args") + } + + app.state.ExecutionError = "" + app.state.IsCompacting = true + handled, cmd = app.handleImmediateSlashCommand(slashCommandCompact) + if !handled || cmd != nil { + t.Fatalf("expected compact busy branch to return handled with nil cmd") + } + if !strings.Contains(app.state.StatusText, "already running") { + t.Fatalf("expected busy message") + } + + app.state.IsCompacting = false + app.state.IsAgentRunning = false + app.state.StatusText = "" + handled, cmd = app.handleImmediateSlashCommand(slashCommandCompact) + if !handled || cmd == nil { + t.Fatalf("expected compact success branch to return cmd") + } + msg := cmd() + if _, ok := msg.(compactFinishedMsg); !ok { + t.Fatalf("expected compactFinishedMsg, got %T", msg) + } + if len(runtime.resolveCalls) != 0 { + t.Fatalf("compact should not resolve permissions") + } +} + +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) + + 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") + } +} + func TestFormatPermissionPromptToolOnly(t *testing.T) { lines := formatPermissionPromptLines(permissionPromptState{ Request: agentruntime.PermissionRequestPayload{ToolName: "bash"},