diff --git a/cmd/late/main.go b/cmd/late/main.go index a7fc28c..bdfe3a6 100644 --- a/cmd/late/main.go +++ b/cmd/late/main.go @@ -254,6 +254,9 @@ func main() { mainTools[k] = v } mainTools["write_implementation_plan"] = true + mainTools["create_todos"] = true + mainTools["list_todos"] = true + mainTools["finish_todo"] = true mainTools["write_file"] = false mainTools["target_edit"] = false @@ -281,7 +284,7 @@ func main() { // Create root orchestrator // We'll add middlewares later once the program is started - rootAgent := orchestrator.NewBaseOrchestrator("main", sess, nil, 0) + rootAgent := orchestrator.NewBaseOrchestrator(common.MainAgentID, sess, nil, 0) model := tui.NewModel(rootAgent, renderer, appConfig) model.ApplyOrchestratorModel = func(setting appconfig.ModelSetting) tea.Cmd { diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 269aeb1..b57d5dc 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -68,7 +68,8 @@ func NewSubagentOrchestrator( for _, t := range parent.Registry().All() { // Skip spawn_subagent and write_implementation_plan to prevent recursion/confusion name := t.Name() - if name == "spawn_subagent" || name == "write_implementation_plan" { + if name == "spawn_subagent" || name == "write_implementation_plan" || + name == "create_todos" || name == "list_todos" || name == "finish_todo" { continue } sess.Registry.Register(t) @@ -82,6 +83,12 @@ func NewSubagentOrchestrator( subagentTools[t] = true } } + + // Todo tools are orchestrator-only: never register them for subagents, + // even if a subagent config lists them in allowed_tools. + for _, name := range []string{"create_todos", "list_todos", "finish_todo"} { + delete(subagentTools, name) + } executor.RegisterTools(sess.Registry, subagentTools) // 3. Construct Initial Context diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index b846378..b9bbb70 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -176,3 +176,56 @@ func TestNewSubagentOrchestratorID(t *testing.T) { t.Errorf("Expected child ID to contain 'coder', got %s", child.ID()) } } + +// TestSubagentRegistryHasNoTodoTools verifies that a spawned subagent NEVER +// receives todo tools in its registry, even when enabledTools includes them. +func TestSubagentRegistryHasNoTodoTools(t *testing.T) { + cfg := client.Config{BaseURL: "http://localhost:8080"} + c := client.NewClient(cfg) + + mockSession := session.New(c, "/tmp/mock-session.json", []client.ChatMessage{}, "mock system prompt", true) + parent := orchestrator.NewBaseOrchestrator("parent", mockSession, nil, 100) + + enabledTools := map[string]bool{ + "read_file": true, + "create_todos": true, + "list_todos": true, + "finish_todo": true, + } + + child, err := NewSubagentOrchestrator( + c, + "test goal", + []string{}, + "coder", + enabledTools, + false, + false, + 100, + parent, + nil, + ) + if err != nil { + t.Fatalf("Failed to create subagent orchestrator: %v", err) + } + + childBase, ok := child.(*orchestrator.BaseOrchestrator) + if !ok { + t.Fatalf("Expected BaseOrchestrator, got %T", child) + } + + sess := childBase.Session() + + if sess.Registry.Get("create_todos") != nil { + t.Fatalf("expected subagent registry to NOT contain create_todos") + } + if sess.Registry.Get("list_todos") != nil { + t.Fatalf("expected subagent registry to NOT contain list_todos") + } + if sess.Registry.Get("finish_todo") != nil { + t.Fatalf("expected subagent registry to NOT contain finish_todo") + } + if sess.Registry.Get("read_file") == nil { + t.Fatalf("expected subagent registry to contain read_file") + } +} diff --git a/internal/assets/prompts/instruction-orchestrator.md b/internal/assets/prompts/instruction-orchestrator.md index 0a77088..ca08aec 100644 --- a/internal/assets/prompts/instruction-orchestrator.md +++ b/internal/assets/prompts/instruction-orchestrator.md @@ -10,6 +10,7 @@ Your goal is to analyze complex user requests, explore the existing codebase to * **YOU MUST NOT**: Conduct broad, initial codebase exploration yourself. You must delegate this to the `researcher` subagent to conserve your context window. * **YOU MUST**: Use the `search_tool` instead of using the `bash_tool` with e.g. `grep`/`find`/`rg` to search for and match patterns and strings in the codebase. * **YOU MUST**: Use `write_implementation_plan` to record your design before any execution. +* **YOU MUST**: Use `create_todos`, `list_todos`, and `finish_todo` to track high-level execution progress, but ONLY AFTER writing the implementation plan. * **YOU MUST**: Use `spawn_subagent` (type `coder`) for **ALL** direct file modifications. **CRITICAL TOOL RULE: You MUST invoke the `spawn_subagent` tool MULTIPLE TIMES—exactly once for EVERY individual step in your Implementation Plan. You are strictly forbidden from passing multiple steps or the entire plan into a single `spawn_subagent` call.** * **YOU CANNOT**: Edit files, create files (other than the plan), or run destructive bash commands. * *Note: Direct file-editing tools (like `write_file` or `target_edit`) are physically removed from your toolset. You MUST delegate all coding to subagents.* @@ -46,8 +47,9 @@ Before generating the final output, you must internally simulate the execution o Output a structured **Implementation Plan** in Markdown. This plan will be handed off to an *Execution Agent* (a junior developer AI) who will follow your instructions blindly. Clarity and precision are paramount. -**You MUST use the `write_implementation_plan` tool to save your plan to `${{CWD}}/implementation_plan.md`.** -Your final response to the user should confirm the plan is written and ask for approval. +1. **Write the Plan**: You MUST use the `write_implementation_plan` tool to save your plan to `${{CWD}}/implementation_plan.md`. +2. **Initialize Todo Tracking**: Immediately AFTER saving the implementation plan, you MUST call `create_todos` with a high-level list of steps that track the major phases/milestones of your implementation plan. Do NOT call `create_todos` before the implementation plan is written. +3. **Request Approval**: Your final response to the user should confirm the plan is written, todos are created, and ask for approval. ### Phase 5: Skill Activation & Knowledge Transfer @@ -96,6 +98,8 @@ Clarity is key. Group steps logically. You must not edit any files yourself. You must use `coder` subagents to edit files. You must use `spawn_subagent` to spawn a subagent. You must use atomic steps in your plan. Each step should be a single, atomic action that can be performed independently of other steps. Each `coder` subagent being invoked by you must implement one single step only of your plan. +* **Progress Tracking**: Before or after spawning subagents, use `list_todos` to review progress. As each high-level step or milestone from your plan is completed by a `coder` subagent, use `finish_todo` to mark it complete. + ## Current working dir Your current working directory is `${{CWD}}` diff --git a/internal/common/interfaces.go b/internal/common/interfaces.go index 6aac680..7cbcac1 100644 --- a/internal/common/interfaces.go +++ b/internal/common/interfaces.go @@ -112,6 +112,10 @@ const ( ToolApprovalKey contextKey = "tool_approval" ) +// MainAgentID is the orchestrator ID of the root/main agent. +// Subagents use IDs of the form "-subagent-". +const MainAgentID = "main" + // GetInputProvider returns the InputProvider from the context. func GetInputProvider(ctx context.Context) InputProvider { if p, ok := ctx.Value(InputProviderKey).(InputProvider); ok { diff --git a/internal/common/tools.go b/internal/common/tools.go index 0165c19..a45ef2a 100644 --- a/internal/common/tools.go +++ b/internal/common/tools.go @@ -45,3 +45,20 @@ func (r *ToolRegistry) All() []Tool { }) return all } + +// TodoItem represents a single item in a todo list. +type TodoItem struct { + Text string + Done bool +} + +// TodoProvider provides a read-only snapshot of current todos. +type TodoProvider interface { + GetTodos() []TodoItem +} + +// ConversationResetter clears tool state that must not carry into a new +// conversation. +type ConversationResetter interface { + ResetConversationState() +} diff --git a/internal/config/config.go b/internal/config/config.go index 79eaf93..2dda026 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -76,6 +76,9 @@ func defaultConfig() Config { "spawn_subagent": true, "bash": true, "search_tool": true, + "create_todos": true, + "list_todos": true, + "finish_todo": true, }, } } diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 76193d8..bc26fb7 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + "sync" + "late/internal/client" "late/internal/common" "late/internal/pathutil" @@ -139,6 +141,21 @@ func RegisterTools(reg *tool.Registry, enabledTools map[string]bool) { reg.Register(tool.NewTargetEditTool()) } + // Register Todo planning tools (orchestrator-only, not inherited by subagents) + if enabledTools["create_todos"] || enabledTools["list_todos"] || enabledTools["finish_todo"] { + var todos []tool.Todo + var mu sync.Mutex + if enabledTools["create_todos"] { + reg.Register(tool.CreateTodosTool{Todos: &todos, Mu: &mu}) + } + if enabledTools["list_todos"] { + reg.Register(tool.ListTodosTool{Todos: &todos, Mu: &mu}) + } + if enabledTools["finish_todo"] { + reg.Register(tool.FinishTodoTool{Todos: &todos, Mu: &mu}) + } + } + // Register Skills skillDirs := []string{} if userSkillsDir, err := pathutil.LateSkillsDir(); err == nil { diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index 84e606c..d098f2d 100644 --- a/internal/executor/executor_test.go +++ b/internal/executor/executor_test.go @@ -345,3 +345,32 @@ func TestRegisterTools_Planning(t *testing.T) { t.Error("bash should be registered in planning mode") } } + +func TestRegisterTools_TodoTools(t *testing.T) { + c := client.NewClient(client.Config{BaseURL: "http://localhost:0"}) + histPath := filepath.Join(t.TempDir(), "history.json") + sess := session.New(c, histPath, nil, "", false) + + enabledTools := map[string]bool{ + "create_todos": true, + "list_todos": true, + "finish_todo": true, + } + RegisterTools(sess.Registry, enabledTools) + + for _, name := range []string{"create_todos", "list_todos", "finish_todo"} { + if sess.Registry.Get(name) == nil { + t.Errorf("expected tool '%s' to be registered", name) + } + } + + // Verify disabled tools are not registered + sessDisabled := session.New(c, histPath, nil, "", false) + RegisterTools(sessDisabled.Registry, map[string]bool{}) + for _, name := range []string{"create_todos", "list_todos", "finish_todo"} { + if sessDisabled.Registry.Get(name) != nil { + t.Errorf("expected tool '%s' not to be registered when disabled", name) + } + } +} + diff --git a/internal/orchestrator/base.go b/internal/orchestrator/base.go index 70e46ad..1735ec5 100644 --- a/internal/orchestrator/base.go +++ b/internal/orchestrator/base.go @@ -450,7 +450,15 @@ func (o *BaseOrchestrator) Parent() common.Orchestrator { func (o *BaseOrchestrator) Reset() error { o.mu.Lock() defer o.mu.Unlock() - return o.sess.StartNewConversation() + if err := o.sess.StartNewConversation(); err != nil { + return err + } + for _, registeredTool := range o.sess.Registry.All() { + if resetter, ok := registeredTool.(common.ConversationResetter); ok { + resetter.ResetConversationState() + } + } + return nil } func (o *BaseOrchestrator) Rewind(index int) error { diff --git a/internal/orchestrator/base_test.go b/internal/orchestrator/base_test.go index 59a76af..47a24ec 100644 --- a/internal/orchestrator/base_test.go +++ b/internal/orchestrator/base_test.go @@ -2,11 +2,14 @@ package orchestrator import ( "context" + "encoding/json" "late/internal/client" "late/internal/common" "late/internal/session" + "late/internal/tool" "os" "path/filepath" + "sync" "testing" ) @@ -93,6 +96,16 @@ func TestBaseOrchestrator_ResetStartsNewConversation(t *testing.T) { } sess := session.New(nil, originalPath, history, "", false) + var todos []tool.Todo + var todoMu sync.Mutex + createTodos := tool.CreateTodosTool{Todos: &todos, Mu: &todoMu} + listTodos := tool.ListTodosTool{Todos: &todos, Mu: &todoMu} + sess.Registry.Register(createTodos) + sess.Registry.Register(listTodos) + ctx := context.WithValue(context.Background(), common.OrchestratorIDKey, common.MainAgentID) + if _, err := createTodos.Execute(ctx, json.RawMessage(`{"todos":["old conversation task"]}`)); err != nil { + t.Fatalf("creating todo: %v", err) + } o := NewBaseOrchestrator("test-orch", sess, nil, 10) if err := o.Reset(); err != nil { t.Fatalf("Reset() error = %v", err) @@ -108,6 +121,9 @@ func TestBaseOrchestrator_ResetStartsNewConversation(t *testing.T) { if len(o.History()) != 0 { t.Fatalf("new conversation history length = %d, want 0", len(o.History())) } + if got := listTodos.GetTodos(); len(got) != 0 { + t.Fatalf("new conversation retained todos: %#v", got) + } if sess.HistoryPath == originalPath { t.Fatal("new conversation reused the original history path") } diff --git a/internal/tool/todo.go b/internal/tool/todo.go new file mode 100644 index 0000000..885f40e --- /dev/null +++ b/internal/tool/todo.go @@ -0,0 +1,280 @@ +package tool + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + + "late/internal/common" +) + +// Todo represents a single todo item with its completion status. +type Todo struct { + Text string `json:"text"` + Done bool `json:"done"` +} + +// CreateTodosTool creates and stores a list of todos for the session. +type CreateTodosTool struct { + Todos *[]Todo + Mu *sync.Mutex +} + +func resetTodos(todos *[]Todo, mu *sync.Mutex) { + if mu != nil { + mu.Lock() + defer mu.Unlock() + } + if todos != nil { + *todos = nil + } +} + +func (t CreateTodosTool) ResetConversationState() { + resetTodos(t.Todos, t.Mu) +} + +func (t CreateTodosTool) Name() string { return "create_todos" } +func (t CreateTodosTool) Description() string { + return `Create a list of todos to track your progress. + +Instructions: +1. Break down the work into clear, sequential milestones or steps. +2. Call this tool with ALL steps at once, in order. +3. Once created, use 'list_todos' to check your progress. +4. Use 'finish_todo' as you complete each step. + +Tips: +- Make steps atomic and verifiable. +- More granularity is better than less. +- The list stays in memory for the entire session.` +} +func (t CreateTodosTool) Parameters() json.RawMessage { + return json.RawMessage(`{ + "type": "object", + "properties": { + "todos": { + "type": "array", + "items": { "type": "string" }, + "description": "List of todo items in order (first item = first to do)" + } + }, + "required": ["todos"] + }`) +} +func (t CreateTodosTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { + // Only the main agent may manage todos; subagents must never modify them. + if id := common.GetOrchestratorID(ctx); id != "" && id != common.MainAgentID { + return "Error: todo tools are restricted to the main agent; subagents cannot modify the todo list.", nil + } + var params struct { + Todos []string `json:"todos"` + } + if err := json.Unmarshal(args, ¶ms); err != nil { + return "", fmt.Errorf("invalid parameters: %w", err) + } + if len(params.Todos) == 0 { + return "Error: todos array cannot be empty. Please provide at least one todo item.", nil + } + + newTodos := make([]Todo, len(params.Todos)) + for i, text := range params.Todos { + newTodos[i] = Todo{Text: text, Done: false} + } + + if t.Mu != nil { + t.Mu.Lock() + if t.Todos != nil { + *t.Todos = newTodos + } + t.Mu.Unlock() + } else if t.Todos != nil { + *t.Todos = newTodos + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Created %d todo(s):\n\n", len(params.Todos))) + for i, todoText := range params.Todos { + sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, todoText)) + } + sb.WriteString("\nUse 'list_todos' to see your plan, or 'finish_todo' to mark items complete.\n") + return sb.String(), nil +} +func (t CreateTodosTool) RequiresConfirmation(args json.RawMessage) bool { return false } +func (t CreateTodosTool) CallString(args json.RawMessage) string { + return "Creating todos..." +} + +// ListTodosTool displays the current list of todos. +type ListTodosTool struct { + Todos *[]Todo + Mu *sync.Mutex +} + +func (t ListTodosTool) ResetConversationState() { + resetTodos(t.Todos, t.Mu) +} + +func (t ListTodosTool) Name() string { return "list_todos" } +func (t ListTodosTool) Description() string { + return `List all todos and their completion status. + +Shows the full plan with numbered items and checkboxes: +- [ ] = not done +- [✓] = completed + +Use this tool frequently to stay organized and track progress.` +} +func (t ListTodosTool) Parameters() json.RawMessage { + return json.RawMessage(`{ + "type": "object", + "properties": {}, + "required": [] + }`) +} +func (t ListTodosTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { + // Only the main agent may manage todos; subagents must never modify them. + if id := common.GetOrchestratorID(ctx); id != "" && id != common.MainAgentID { + return "Error: todo tools are restricted to the main agent; subagents cannot modify the todo list.", nil + } + var snapshot []Todo + if t.Mu != nil { + t.Mu.Lock() + if t.Todos != nil { + snapshot = make([]Todo, len(*t.Todos)) + copy(snapshot, *t.Todos) + } + t.Mu.Unlock() + } else if t.Todos != nil { + snapshot = *t.Todos + } + + if len(snapshot) == 0 { + return "No todos have been created yet. Use 'create_todos' to set up your plan.", nil + } + var sb strings.Builder + sb.WriteString("# Todo List\n\n") + for i, todo := range snapshot { + status := " " + if todo.Done { + status = "✓" + } + sb.WriteString(fmt.Sprintf("%d. [%s] %s\n", i+1, status, todo.Text)) + } + return sb.String(), nil +} +func (t ListTodosTool) RequiresConfirmation(args json.RawMessage) bool { return false } +func (t ListTodosTool) CallString(args json.RawMessage) string { + return "Listing todos..." +} + +func (t ListTodosTool) GetTodos() []common.TodoItem { + var snapshot []Todo + if t.Mu != nil { + t.Mu.Lock() + if t.Todos != nil { + snapshot = make([]Todo, len(*t.Todos)) + copy(snapshot, *t.Todos) + } + t.Mu.Unlock() + } else if t.Todos != nil { + snapshot = *t.Todos + } + + if len(snapshot) == 0 { + return nil + } + result := make([]common.TodoItem, len(snapshot)) + for i, td := range snapshot { + result[i] = common.TodoItem{ + Text: td.Text, + Done: td.Done, + } + } + return result +} + +// FinishTodoTool marks a todo as complete by exact string match. +type FinishTodoTool struct { + Todos *[]Todo + Mu *sync.Mutex +} + +func (t FinishTodoTool) ResetConversationState() { + resetTodos(t.Todos, t.Mu) +} + +func (t FinishTodoTool) Name() string { return "finish_todo" } +func (t FinishTodoTool) Description() string { + return `Mark a todo item as complete by typing its EXACT text. + +Instructions: +1. Use 'list_todos' to see your current todo list. +2. Copy the EXACT text of the todo you want to mark complete. +3. Pass it to this tool. The match is EXACT — whitespace matters. +4. If your string does not match, you will get an error telling you to use 'list_todos'. + +Important: The string must match character-for-character with the item in your todo list. +Do not modify capitalization, punctuation, or whitespace.` +} +func (t FinishTodoTool) Parameters() json.RawMessage { + return json.RawMessage(`{ + "type": "object", + "properties": { + "todo_text": { + "type": "string", + "description": "The exact text of the todo to mark as complete. Must match exactly." + } + }, + "required": ["todo_text"] + }`) +} +func (t FinishTodoTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { + // Only the main agent may manage todos; subagents must never modify them. + if id := common.GetOrchestratorID(ctx); id != "" && id != common.MainAgentID { + return "Error: todo tools are restricted to the main agent; subagents cannot modify the todo list.", nil + } + var params struct { + TodoText string `json:"todo_text"` + } + if err := json.Unmarshal(args, ¶ms); err != nil { + return "", fmt.Errorf("invalid parameters: %w", err) + } + todoText := strings.TrimSpace(params.TodoText) + if todoText == "" { + return "Error: todo_text cannot be empty. Use 'list_todos' to see your current todos.", nil + } + + if t.Mu != nil { + t.Mu.Lock() + defer t.Mu.Unlock() + } + + if t.Todos == nil || len(*t.Todos) == 0 { + return "Error: No todos have been created yet. Use 'create_todos' to set up your plan.", nil + } + + todos := *t.Todos + foundCompleted := false + for i, todo := range todos { + if todo.Text == todoText { + if todo.Done { + foundCompleted = true + continue + } + todos[i].Done = true + return fmt.Sprintf("Completed: %s", todo.Text), nil + } + } + if foundCompleted { + return fmt.Sprintf("Todo '%s' is already completed.", todoText), nil + } + return fmt.Sprintf("Error: No todo found matching '%s'. The text must match EXACTLY (including capitalization, punctuation, and whitespace). Use the 'list_todos' tool to see your current todo list and get the correct text.", todoText), nil +} +func (t FinishTodoTool) RequiresConfirmation(args json.RawMessage) bool { return false } +func (t FinishTodoTool) CallString(args json.RawMessage) string { + todoText := getToolParam(args, "todo_text") + return fmt.Sprintf("Finishing todo: %s", truncate(todoText, 50)) +} diff --git a/internal/tool/todo_test.go b/internal/tool/todo_test.go new file mode 100644 index 0000000..1750ec5 --- /dev/null +++ b/internal/tool/todo_test.go @@ -0,0 +1,370 @@ +package tool + +import ( + "context" + "encoding/json" + "strings" + "sync" + "testing" + + "late/internal/common" +) + +func TestCreateTodos(t *testing.T) { + var todos []Todo + var mu sync.Mutex + tool := CreateTodosTool{Todos: &todos, Mu: &mu} + + // Create a list of todos + args := json.RawMessage(`{"todos": ["Step 1", "Step 2", "Step 3"]}`) + result, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "Created 3 todo(s)") { + t.Fatalf("expected success message with count, got: %s", result) + } + if len(todos) != 3 || todos[0].Text != "Step 1" || todos[2].Text != "Step 3" { + t.Fatalf("todos not stored correctly: %v", todos) + } + t.Logf("result: %s", result) +} + +func TestCreateTodosEmpty(t *testing.T) { + var todos []Todo + tool := CreateTodosTool{Todos: &todos} + + args := json.RawMessage(`{"todos": []}`) + result, _ := tool.Execute(context.Background(), args) + if !strings.Contains(result, "cannot be empty") { + t.Fatalf("expected empty error, got: %s", result) + } +} + +func TestListTodos(t *testing.T) { + todos := []Todo{ + {Text: "Setup", Done: false}, + {Text: "Code", Done: false}, + {Text: "Test", Done: false}, + } + tool := ListTodosTool{Todos: &todos} + + result, err := tool.Execute(context.Background(), json.RawMessage(`{}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "# Todo List") { + t.Fatalf("expected header, got: %s", result) + } + if !strings.Contains(result, "1. [ ] Setup") { + t.Fatalf("expected checkbox format, got: %s", result) + } + if !strings.Contains(result, "2. [ ] Code") { + t.Fatalf("expected checkbox format, got: %s", result) + } + if !strings.Contains(result, "3. [ ] Test") { + t.Fatalf("expected checkbox format, got: %s", result) + } + t.Logf("result: %s", result) +} + +func TestListTodosEmpty(t *testing.T) { + var todos []Todo + tool := ListTodosTool{Todos: &todos} + + result, _ := tool.Execute(context.Background(), json.RawMessage(`{}`)) + if !strings.Contains(result, "No todos") { + t.Fatalf("expected empty message, got: %s", result) + } +} + +func TestFinishTodoSuccess(t *testing.T) { + todos := []Todo{ + {Text: "Step 1", Done: false}, + {Text: "Step 2", Done: false}, + {Text: "Step 3", Done: false}, + } + tool := FinishTodoTool{Todos: &todos} + + // Finish the second todo + args := json.RawMessage(`{"todo_text": "Step 2"}`) + result, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "Completed: Step 2") { + t.Fatalf("expected completion message, got: %s", result) + } + if !todos[1].Done { + t.Fatalf("todo not marked complete: %v", todos[1]) + } + t.Logf("result: %s", result) +} + +func TestFinishTodoAlreadyCompleted(t *testing.T) { + todos := []Todo{ + {Text: "Step 1", Done: true}, + } + tool := FinishTodoTool{Todos: &todos} + + args := json.RawMessage(`{"todo_text": "Step 1"}`) + result, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "already completed") { + t.Fatalf("expected already completed message, got: %s", result) + } +} + +func TestFinishTodoNotFound(t *testing.T) { + todos := []Todo{ + {Text: "Step 1", Done: false}, + {Text: "Step 2", Done: false}, + } + tool := FinishTodoTool{Todos: &todos} + + // Try with wrong text + args := json.RawMessage(`{"todo_text": "Step 4"}`) + result, _ := tool.Execute(context.Background(), args) + if !strings.Contains(result, "No todo found") { + t.Fatalf("expected not found error, got: %s", result) + } + if !strings.Contains(result, "list_todos") { + t.Fatalf("expected error to mention list_todos, got: %s", result) + } + // Todos should be unchanged + if len(todos) != 2 { + t.Fatalf("todos should be unchanged, got: %v", todos) + } +} + +func TestFinishTodoExactMatch(t *testing.T) { + todos := []Todo{{Text: "Do The Thing", Done: false}} + tool := FinishTodoTool{Todos: &todos} + + // Case-sensitive: "do the thing" should NOT match "Do The Thing" + args := json.RawMessage(`{"todo_text": "do the thing"}`) + result, _ := tool.Execute(context.Background(), args) + if !strings.Contains(result, "No todo found") { + t.Fatalf("expected case-sensitive mismatch, got: %s", result) + } + + // Exact match should work + args = json.RawMessage(`{"todo_text": "Do The Thing"}`) + result, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "Completed") { + t.Fatalf("expected success, got: %s", result) + } +} + +func TestCreateFinishListFlow(t *testing.T) { + var todos []Todo + var mu sync.Mutex + + createTool := CreateTodosTool{Todos: &todos, Mu: &mu} + listTool := ListTodosTool{Todos: &todos, Mu: &mu} + finishTool := FinishTodoTool{Todos: &todos, Mu: &mu} + + // 1. Create todos + _, err := createTool.Execute(context.Background(), json.RawMessage(`{"todos": ["Write code", "Write test"]}`)) + if err != nil { + t.Fatalf("create failed: %v", err) + } + + // 2. Finish first todo + _, err = finishTool.Execute(context.Background(), json.RawMessage(`{"todo_text": "Write code"}`)) + if err != nil { + t.Fatalf("finish failed: %v", err) + } + + // 3. List todos and check checkbox output + listResult, err := listTool.Execute(context.Background(), json.RawMessage(`{}`)) + if err != nil { + t.Fatalf("list failed: %v", err) + } + + if !strings.Contains(listResult, "1. [✓] Write code") { + t.Errorf("expected completed item formatted as '1. [✓] Write code', got:\n%s", listResult) + } + if !strings.Contains(listResult, "2. [ ] Write test") { + t.Errorf("expected uncompleted item formatted as '2. [ ] Write test', got:\n%s", listResult) + } +} + +func TestCreateTodosNilPointerSafe(t *testing.T) { + var mu sync.Mutex + tool := CreateTodosTool{Todos: nil, Mu: &mu} + + args := json.RawMessage(`{"todos": ["Step 1", "Step 2"]}`) + // Should not panic when Todos is nil + _, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestFinishTodoDuplicates(t *testing.T) { + todos := []Todo{ + {Text: "Run tests", Done: false}, + {Text: "Update docs", Done: false}, + {Text: "Run tests", Done: false}, + } + tool := FinishTodoTool{Todos: &todos} + + // First finish should complete the first "Run tests" + res1, err := tool.Execute(context.Background(), json.RawMessage(`{"todo_text": "Run tests"}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(res1, "Completed: Run tests") { + t.Fatalf("expected completion of first item, got: %s", res1) + } + if !todos[0].Done || todos[2].Done { + t.Fatalf("expected only first 'Run tests' to be done, got: %v", todos) + } + + // Second finish should complete the second "Run tests" + res2, err := tool.Execute(context.Background(), json.RawMessage(`{"todo_text": "Run tests"}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(res2, "Completed: Run tests") { + t.Fatalf("expected completion of second item, got: %s", res2) + } + if !todos[0].Done || !todos[2].Done { + t.Fatalf("expected both 'Run tests' to be done, got: %v", todos) + } + + // Third finish should report already completed + res3, _ := tool.Execute(context.Background(), json.RawMessage(`{"todo_text": "Run tests"}`)) + if !strings.Contains(res3, "already completed") { + t.Fatalf("expected already completed message, got: %s", res3) + } +} + +func TestFinishTodoTrimSpace(t *testing.T) { + todos := []Todo{ + {Text: "Do Something", Done: false}, + } + tool := FinishTodoTool{Todos: &todos} + + // Leading/trailing whitespace should be trimmed and match + args := json.RawMessage(`{"todo_text": " Do Something \n"}`) + result, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "Completed: Do Something") { + t.Fatalf("expected success with trimmed text, got: %s", result) + } + if !todos[0].Done { + t.Fatalf("expected todo to be marked done") + } +} + +func TestListTodosGetTodos(t *testing.T) { + var mu sync.Mutex + todos := []Todo{ + {Text: "Task A", Done: true}, + {Text: "Task B", Done: false}, + } + tool := ListTodosTool{Todos: &todos, Mu: &mu} + + items := tool.GetTodos() + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + if items[0].Text != "Task A" || !items[0].Done { + t.Fatalf("unexpected item[0]: %+v", items[0]) + } + if items[1].Text != "Task B" || items[1].Done { + t.Fatalf("unexpected item[1]: %+v", items[1]) + } +} + +func TestTodoToolsRejectSubagentOrchestrator(t *testing.T) { + ctx := context.WithValue(context.Background(), common.OrchestratorIDKey, "coder-subagent-0") + + // create must be rejected and not modify the todo list + var createTodos []Todo + var createMu sync.Mutex + createResult, createErr := CreateTodosTool{Todos: &createTodos, Mu: &createMu}.Execute(ctx, json.RawMessage(`{"todos": ["A", "B"]}`)) + if createErr != nil { + t.Fatalf("expected nil error from subagent guard, got: %v", createErr) + } + if !strings.Contains(createResult, "restricted to the main agent") { + t.Fatalf("expected subagent restriction message, got: %s", createResult) + } + if len(createTodos) != 0 { + t.Fatalf("expected todos to be unchanged, got: %v", createTodos) + } + + // list must be rejected + var listTodos []Todo + var listMu sync.Mutex + listResult, listErr := ListTodosTool{Todos: &listTodos, Mu: &listMu}.Execute(ctx, json.RawMessage(`{}`)) + if listErr != nil { + t.Fatalf("expected nil error from subagent guard, got: %v", listErr) + } + if !strings.Contains(listResult, "restricted to the main agent") { + t.Fatalf("expected subagent restriction message, got: %s", listResult) + } + + // finish must be rejected and not modify the todo list + var finishTodos []Todo + var finishMu sync.Mutex + finishResult, finishErr := FinishTodoTool{Todos: &finishTodos, Mu: &finishMu}.Execute(ctx, json.RawMessage(`{"todo_text": "A"}`)) + if finishErr != nil { + t.Fatalf("expected nil error from subagent guard, got: %v", finishErr) + } + if !strings.Contains(finishResult, "restricted to the main agent") { + t.Fatalf("expected subagent restriction message, got: %s", finishResult) + } + if len(finishTodos) != 0 { + t.Fatalf("expected todos to be unchanged, got: %v", finishTodos) + } +} + +func TestTodoToolsAllowMainOrchestrator(t *testing.T) { + ctx := context.WithValue(context.Background(), common.OrchestratorIDKey, common.MainAgentID) + + var todos []Todo + var mu sync.Mutex + + // create should work for the main agent + createResult, err := CreateTodosTool{Todos: &todos, Mu: &mu}.Execute(ctx, json.RawMessage(`{"todos": ["Step 1", "Step 2"]}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(createResult, "Created 2 todo(s)") { + t.Fatalf("expected success message with count, got: %s", createResult) + } + if len(todos) != 2 { + t.Fatalf("expected 2 todos, got: %v", todos) + } + + // finish should work for the main agent + finishResult, err := FinishTodoTool{Todos: &todos, Mu: &mu}.Execute(ctx, json.RawMessage(`{"todo_text": "Step 1"}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(finishResult, "Completed: Step 1") { + t.Fatalf("expected completion message, got: %s", finishResult) + } + if !todos[0].Done { + t.Fatalf("expected first todo to be marked done, got: %v", todos[0]) + } + + // list should work for the main agent + listResult, err := ListTodosTool{Todos: &todos, Mu: &mu}.Execute(ctx, json.RawMessage(`{}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(listResult, "# Todo List") { + t.Fatalf("expected header, got: %s", listResult) + } +} diff --git a/internal/tui/state.go b/internal/tui/state.go index d7f9be2..bd41552 100644 --- a/internal/tui/state.go +++ b/internal/tui/state.go @@ -62,6 +62,7 @@ var AvailableCommands = []CommandDef{ {Name: "/model", Description: "Select AI model for agents"}, {Name: "/quit", Description: "Exit the application"}, {Name: "/rewind", Description: "Rewind conversation history"}, + {Name: "/todos", Description: "Toggle live todo progress pane"}, } // RenderBlock represents the line bounds of a rendered block in the viewport. @@ -142,9 +143,12 @@ type Model struct { Spinner spinner.Model // File Picker - FilePicker filepicker.Model - AttachedFiles []string - ShowFilePicker bool + FilePicker filepicker.Model + AttachedFiles []string + ShowFilePicker bool + ShowTodoPane bool + TodoPaneFocused bool + TodoScrollOffset int // Double-click copy & Toast tracking LastClickX int diff --git a/internal/tui/todo_pane_test.go b/internal/tui/todo_pane_test.go new file mode 100644 index 0000000..ec4c354 --- /dev/null +++ b/internal/tui/todo_pane_test.go @@ -0,0 +1,113 @@ +package tui + +import ( + "reflect" + "strings" + "testing" + + "late/internal/common" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +func TestWrapTodoText(t *testing.T) { + tests := []struct { + name string + text string + maxLen int + want []string + }{ + { + name: "short text fits on single line", + text: "Setup Reviewer", + maxLen: 28, + want: []string{"Setup Reviewer"}, + }, + { + name: "long text wraps to multiple lines", + text: "Setup Implementation Reviewer Subagent Configuration", + maxLen: 28, + want: []string{ + "Setup Implementation", + "Reviewer Subagent", + "Configuration", + }, + }, + { + name: "empty text", + text: "", + maxLen: 28, + want: []string{""}, + }, + { + name: "wraps by terminal cell width", + text: "界界界 next", + maxLen: 6, + want: []string{"界界界", "next"}, + }, + { + name: "does not split emoji grapheme clusters", + text: "👩‍💻👩‍💻 done", + maxLen: 4, + want: []string{"👩‍💻👩‍💻", "done"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := wrapTodoText(tt.text, tt.maxLen) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("wrapTodoText(%q, %d) = %q, want %q", tt.text, tt.maxLen, got, tt.want) + } + }) + } +} + +func TestTodoContentLinesAlignWrappedText(t *testing.T) { + todos := []common.TodoItem{ + {Text: "A task whose description needs to wrap cleanly", Done: false}, + } + + lines := todoContentLines(todos, 24) + if len(lines) < 2 { + t.Fatalf("expected wrapped content, got %q", lines) + } + + firstLine := ansi.Strip(lines[0]) + if !strings.Contains(firstLine, "A task") { + t.Fatalf("first line does not contain task text: %q", lines[0]) + } + firstIndent := lipgloss.Width(firstLine) - lipgloss.Width(strings.TrimLeft(firstLine, " 1○✓")) + continuation := ansi.Strip(lines[1]) + if got := lipgloss.Width(continuation) - lipgloss.Width(strings.TrimLeft(continuation, " ")); got != firstIndent { + t.Fatalf("continuation indent = %d, want %d; lines: %q", got, firstIndent, lines) + } +} + +func TestTodoContentLinesUseCompactCompletedStyle(t *testing.T) { + lines := todoContentLines([]common.TodoItem{{Text: "Finished", Done: true}}, 30) + rendered := strings.Join(lines, "\n") + if !strings.Contains(rendered, "✓") { + t.Fatalf("completed item has no check mark: %q", rendered) + } + if strings.Contains(rendered, "\x1b[9m") { + t.Fatalf("completed item should not use strikethrough: %q", rendered) + } +} + +func TestTodoMaxScrollOffset(t *testing.T) { + todos := make([]common.TodoItem, 10) + for i := range todos { + todos[i] = common.TodoItem{Text: "A short task"} + } + content := todoContentLines(todos, todoPaneWidth-1) + height := 7 + want := len(content) - (height - 2) + if want < 0 { + want = 0 + } + if got := todoMaxScrollOffsetFor(todos, height); got != want { + t.Fatalf("max scroll offset = %d, want %d", got, want) + } +} diff --git a/internal/tui/update.go b/internal/tui/update.go index 7fb9322..8063d00 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -101,6 +101,10 @@ func (m Model) updateInternal(msg tea.Msg) (Model, tea.Cmd) { m.updateLayout() return m, nil } + if msg.String() == "ctrl+t" && m.ShowTodoPane && m.Mode == ViewChat { + m.TodoPaneFocused = !m.TodoPaneFocused + return m, nil + } } // Window Sizing @@ -113,6 +117,61 @@ func (m Model) updateInternal(msg tea.Msg) (Model, tea.Cmd) { m.updateLayout() } + // The todo pane has its own focus so its navigation keys never interfere + // with typing in the chat input. + if m.ShowTodoPane && m.Mode == ViewChat { + if keyMsg, ok := msg.(tea.KeyMsg); ok && m.TodoPaneFocused { + pageSize := max(1, m.Viewport.Height()-3) + maxOffset := m.todoMaxScrollOffset(m.Viewport.Height()) + switch keyMsg.String() { + case "esc", "ctrl+t": + m.TodoPaneFocused = false + return m, nil + case "up", "k": + m.TodoScrollOffset = max(0, m.TodoScrollOffset-1) + return m, nil + case "down", "j": + m.TodoScrollOffset = min(maxOffset, m.TodoScrollOffset+1) + return m, nil + case "pgup": + m.TodoScrollOffset = max(0, m.TodoScrollOffset-pageSize) + return m, nil + case "pgdown": + m.TodoScrollOffset = min(maxOffset, m.TodoScrollOffset+pageSize) + return m, nil + case "home", "g": + m.TodoScrollOffset = 0 + return m, nil + case "end", "G": + m.TodoScrollOffset = maxOffset + return m, nil + } + } + + if wheelMsg, ok := msg.(tea.MouseWheelMsg); ok { + mouse := wheelMsg.Mouse() + if mouse.X >= m.Width-todoPaneWidth && mouse.Y >= 0 && mouse.Y < m.Viewport.Height() { + maxOffset := m.todoMaxScrollOffset(m.Viewport.Height()) + if mouse.Button == tea.MouseWheelUp { + m.TodoScrollOffset = max(0, m.TodoScrollOffset-3) + } else if mouse.Button == tea.MouseWheelDown { + m.TodoScrollOffset = min(maxOffset, m.TodoScrollOffset+3) + } + return m, nil + } + } + + if clickMsg, ok := msg.(tea.MouseClickMsg); ok { + mouse := clickMsg.Mouse() + if mouse.Button == tea.MouseLeft && + mouse.X >= m.Width-todoPaneWidth && + mouse.Y >= 0 && mouse.Y < m.Viewport.Height() { + m.TodoPaneFocused = true + return m, nil + } + } + } + // Internal Messages if msg, ok := msg.(SetMessengerMsg); ok { m.Messenger = msg.Messenger @@ -788,6 +847,28 @@ func (m Model) updateChat(msg tea.Msg) (Model, tea.Cmd) { m.updateLayout() return m, nil } + if cmd == "/todos" { + m.Input.Reset() + m.Input.SetValue("> ") + if m.Width < 85 { + m.ToastMessage = "Terminal too narrow for side pane (need >= 85 cols)" + m.ToastWarning = true + m.ToastExpireTime = time.Now().UnixMilli() + 3000 + clearCmd := tea.Tick(3*time.Second, func(t time.Time) tea.Msg { + return clearToastMsg{} + }) + m.updateViewport() + return m, clearCmd + } + m.ShowTodoPane = !m.ShowTodoPane + m.TodoPaneFocused = false + m.TodoScrollOffset = 0 + for _, s := range m.AgentStates { + s.RenderedHistory = nil + } + m.updateLayout() + return m, nil + } if cmd == "/model" { m.Input.Reset() m.Input.SetValue("> ") @@ -1188,9 +1269,21 @@ func (m *Model) updateLayout() { } availableWidth := m.Width - m.Input.SetWidth(availableWidth - 2) + if m.ShowTodoPane && m.Width >= 85 { + availableWidth = m.Width - todoPaneWidth + } else if m.ShowTodoPane && m.Width < 85 { + m.ShowTodoPane = false + m.TodoPaneFocused = false + } + m.Input.SetWidth(m.Width - 2) + oldWidth := m.Viewport.Width() m.Viewport.SetWidth(availableWidth) + if oldWidth != availableWidth { + for _, s := range m.AgentStates { + s.RenderedHistory = nil + } + } vHeight := m.Height - (m.Input.Height() + 1) - StatusBarHeight - AppPadding if m.Mode == ViewModelPicker { vHeight = m.Height - 3 - StatusBarHeight - AppPadding @@ -1206,6 +1299,7 @@ func (m *Model) updateLayout() { vHeight = 1 } m.Viewport.SetHeight(vHeight) + m.TodoScrollOffset = min(m.TodoScrollOffset, m.todoMaxScrollOffset(vHeight)) // Ensure file picker also respects the layout height to prevent pushing the status bar off-screen // We subtract StatusBarHeight. If we have a 2-line picker status bar, we subtract 3. diff --git a/internal/tui/view.go b/internal/tui/view.go index feda12a..6b1ff66 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -10,25 +10,36 @@ import ( "runtime" "strings" "time" + "unicode/utf8" "late/internal/client" + "late/internal/common" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" + "github.com/rivo/uniseg" ) +const todoPaneWidth = 44 + func (m Model) View() tea.View { if m.Width == 0 || m.Height == 0 { return tea.NewView("") } // Force each component to its strict allocated height to prevent layout shifts + vpView := m.Viewport.View() + if m.ShowTodoPane && !m.ShowFilePicker && m.Mode == ViewChat && m.Width >= 85 { + todoView := m.todoPaneView(m.Viewport.Height()) + vpView = lipgloss.JoinHorizontal(lipgloss.Top, vpView, todoView) + } + vStr := lipgloss.NewStyle(). Height(m.Viewport.Height()). Width(m.Width). Background(appBgColor). - Render(m.Viewport.View()) + Render(vpView) iStr := m.inputView() @@ -1726,3 +1737,174 @@ func (m *Model) renderModelPickerView() { Render(strings.Join(lines, "\n")) m.Viewport.SetContent(paddedContent) } + +func wrapTodoText(text string, maxLen int) []string { + if maxLen <= 0 { + return []string{""} + } + var lines []string + words := strings.Fields(text) + if len(words) == 0 { + return []string{""} + } + current := "" + for _, word := range words { + for lipgloss.Width(word) > maxLen { + if current != "" { + lines = append(lines, current) + current = "" + } + chunk, rest := splitTodoWord(word, maxLen) + lines = append(lines, chunk) + word = rest + } + if current == "" { + current = word + } else if lipgloss.Width(current)+1+lipgloss.Width(word) <= maxLen { + current += " " + word + } else { + lines = append(lines, current) + current = word + } + } + if current != "" { + lines = append(lines, current) + } + return lines +} + +func splitTodoWord(word string, maxWidth int) (string, string) { + graphemes := uniseg.NewGraphemes(word) + width := 0 + byteEnd := 0 + for graphemes.Next() { + cluster := graphemes.Str() + clusterWidth := lipgloss.Width(cluster) + if width > 0 && width+clusterWidth > maxWidth { + break + } + width += clusterWidth + _, byteEnd = graphemes.Positions() + if width >= maxWidth { + break + } + } + if byteEnd == 0 { + _, size := utf8.DecodeRuneInString(word) + byteEnd = size + } + return word[:byteEnd], word[byteEnd:] +} + +func (m Model) todoItems() []common.TodoItem { + // Always show the MAIN agent's todos. Subagents have no todo tools in + // their registries, so reading from the focused agent makes the list + // appear to reset whenever focus moves to a subagent. + if m.Root != nil { + if reg := m.Root.Registry(); reg != nil { + if provider, ok := reg.Get("list_todos").(common.TodoProvider); ok { + return provider.GetTodos() + } + } + } + return nil +} + +func todoContentLines(todos []common.TodoItem, innerWidth int) []string { + var lines []string + for i, td := range todos { + icon := "○" + itemStyle := lipgloss.NewStyle().Foreground(textColor) + if td.Done { + icon = "✓" + itemStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#549B68")) + } + + prefix := fmt.Sprintf(" %2d %s ", i+1, icon) + textWidth := max(5, innerWidth-lipgloss.Width(prefix)-1) + wrapped := wrapTodoText(td.Text, textWidth) + for lineIndex, line := range wrapped { + if lineIndex == 0 { + lines = append(lines, prefix+itemStyle.Render(line)) + } else { + lines = append(lines, strings.Repeat(" ", lipgloss.Width(prefix))+itemStyle.Render(line)) + } + } + } + return lines +} + +func (m Model) todoMaxScrollOffset(height int) int { + return todoMaxScrollOffsetFor(m.todoItems(), height) +} + +func todoMaxScrollOffsetFor(todos []common.TodoItem, height int) int { + visibleHeight := max(1, height-2) + return max(0, len(todoContentLines(todos, todoPaneWidth-1))-visibleHeight) +} + +func (m Model) todoPaneView(height int) string { + innerWidth := todoPaneWidth - 1 // 1 left border + innerHeight := height // No top or bottom border + if innerHeight < 1 { + innerHeight = 1 + } + + todos := m.todoItems() + + var lines []string + focusMarker := "" + if m.TodoPaneFocused { + focusMarker = " • focused" + } + title := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7D56F4")). + Render(" Todos" + focusMarker) + lines = append(lines, title) + + if len(todos) == 0 { + emptyMsg := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#4A4B50")). + Italic(true). + Render(" No todos created yet.\n Waiting for plan…") + lines = append(lines, strings.Split(emptyMsg, "\n")...) + } else { + content := todoContentLines(todos, innerWidth) + visibleHeight := max(1, innerHeight-2) + maxOffset := max(0, len(content)-visibleHeight) + offset := min(max(0, m.TodoScrollOffset), maxOffset) + end := min(len(content), offset+visibleHeight) + lines = append(lines, content[offset:end]...) + + help := "click or Ctrl+T to scroll" + if m.TodoPaneFocused { + help = "↑/↓ · PgUp/PgDn · Esc" + } + if len(content) > visibleHeight { + help = fmt.Sprintf("%d–%d/%d · %s", offset+1, end, len(content), help) + } + lines = append(lines, lipgloss.NewStyle(). + Foreground(lipgloss.Color("#67686E")). + Render(" "+help)) + } + + // Pad or truncate lines to the exact pane height. + for len(lines) < innerHeight { + lines = append(lines, "") + } + if len(lines) > innerHeight { + lines = lines[:innerHeight] + } + + content := strings.Join(lines, "\n") + boxStyle := lipgloss.NewStyle(). + Width(innerWidth). + Height(innerHeight). + MaxHeight(innerHeight). + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(lipgloss.Color("#232329")). + Background(appBgColor) + + return boxStyle.Render(content) +} diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go new file mode 100644 index 0000000..42befdb --- /dev/null +++ b/internal/tui/view_test.go @@ -0,0 +1,87 @@ +package tui + +import ( + "context" + "encoding/json" + "sync" + "testing" + + "late/internal/common" + "late/internal/orchestrator" + "late/internal/session" + "late/internal/tool" +) + +// TestTodoItemsAlwaysShowsMainAgentTodos proves the Todo pane always shows the +// MAIN agent's todos, even when a subagent is focused. The pane previously read +// from the focused agent's registry, which has no todo tools (they are +// orchestrator-only, not inherited by subagents), making the list appear to +// reset whenever focus moved to a subagent. +func TestTodoItemsAlwaysShowsMainAgentTodos(t *testing.T) { + // 1. Build the root (main) agent with todo tools registered on its session + // registry as values, matching how executor.RegisterTools wires them up. + rootSess := session.New(nil, "", nil, "", false) + var todos []tool.Todo + var mu sync.Mutex + rootSess.Registry.Register(tool.CreateTodosTool{Todos: &todos, Mu: &mu}) + rootSess.Registry.Register(tool.ListTodosTool{Todos: &todos, Mu: &mu}) + rootSess.Registry.Register(tool.FinishTodoTool{Todos: &todos, Mu: &mu}) + root := orchestrator.NewBaseOrchestrator(common.MainAgentID, rootSess, nil, 0) + + // 2. Seed todos through the root's registry using the main-agent context. + ctx := context.WithValue(context.Background(), common.OrchestratorIDKey, common.MainAgentID) + if _, err := rootSess.Registry.Get("create_todos").Execute(ctx, json.RawMessage(`{"todos": ["Implement fix", "Write tests"]}`)); err != nil { + t.Fatalf("create_todos failed: %v", err) + } + + // 3. Build a focused subagent with an EMPTY registry (no todo tools). + subSess := session.New(nil, "", nil, "", false) + sub := orchestrator.NewBaseOrchestrator("coder-subagent-0", subSess, nil, 0) + + // 4. Construct a minimal Model with the subagent focused. Only Root and + // Focused are set; the rest are zero values, which is fine because + // todoItems() only touches Root. + m := Model{Root: root, Focused: sub} + + // 5. The todo pane must show the MAIN agent's todos, not the focused + // subagent's (empty) list. + items := m.todoItems() + if len(items) != 2 { + t.Fatalf("expected 2 todo items from the main agent, got %d: %v", len(items), items) + } + if items[0].Text != "Implement fix" { + t.Errorf("items[0].Text = %q, want %q", items[0].Text, "Implement fix") + } + if items[0].Done { + t.Errorf("items[0].Done = true, want false") + } + if items[1].Text != "Write tests" { + t.Errorf("items[1].Text = %q, want %q", items[1].Text, "Write tests") + } + if items[1].Done { + t.Errorf("items[1].Done = true, want false") + } + + // 6. Document the OLD behavior: the focused subagent's registry has no + // todo tools at all, which is why reading the pane from it emptied the + // list. This assertion pins down the root cause of the bug. + if got := sub.Registry().Get("list_todos"); got != nil { + t.Errorf("subagent registry should not expose list_todos, got %v", got) + } +} + +// TestTodoItemsNilRootReturnsNil verifies todoItems() degrades gracefully when +// no root agent is set, returning nil instead of panicking. +func TestTodoItemsNilRootReturnsNil(t *testing.T) { + subSess := session.New(nil, "", nil, "", false) + sub := orchestrator.NewBaseOrchestrator("coder-subagent-0", subSess, nil, 0) + + m := Model{Root: nil, Focused: sub} + + if items := m.todoItems(); items != nil { + t.Fatalf("expected nil todo items, got %v", items) + } + if items := m.todoItems(); len(items) != 0 { + t.Fatalf("expected 0 todo items, got %d", len(items)) + } +}