diff --git a/internal/executor/executor.go b/internal/executor/executor.go index a15c9772..2e01495f 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -1688,6 +1688,28 @@ func SendKeyToPane(taskID int64, keys ...string) error { return exec.Command("tmux", args...).Run() } +// SendLiteralTextToPane sends literal text (using tmux -l flag) followed by Enter +// to a task's executor tmux pane. Unlike SendKeyToPane, this ensures the text is +// never interpreted as a tmux key name (e.g. "Enter", "Escape", "Space"). +func SendLiteralTextToPane(taskID int64, text string) error { + sessionName := TmuxSessionName(taskID) + + // Check if session exists first + if err := exec.Command("tmux", "has-session", "-t", sessionName).Run(); err != nil { + return fmt.Errorf("session not found: %w", err) + } + + target := sessionName + ".0" + + // Send text literally (won't interpret key names) + if err := exec.Command("tmux", "send-keys", "-t", target, "-l", text).Run(); err != nil { + return err + } + + // Send Enter as a key press + return exec.Command("tmux", "send-keys", "-t", target, "Enter").Run() +} + // KillAllWindowsByNameAllSessions kills ALL windows with a given name across all daemon sessions. // Also kills any -shell variant windows. func KillAllWindowsByNameAllSessions(windowName string) { diff --git a/internal/ui/app.go b/internal/ui/app.go index 6edc2658..d1d58e8d 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -4216,7 +4216,7 @@ func (m *AppModel) denyExecutorPrompt(taskID int64) tea.Cmd { func (m *AppModel) sendTextToExecutor(taskID int64, text string) tea.Cmd { database := m.db return func() tea.Msg { - err := executor.SendKeyToPane(taskID, text, "Enter") + err := executor.SendLiteralTextToPane(taskID, text) if err == nil { database.AppendTaskLog(taskID, "user", fmt.Sprintf("Replied from kanban: %s", text)) } diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 34ed2efb..4e4c2601 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -1866,6 +1866,81 @@ func TestQuickInput_EmptyEnterUnfocuses(t *testing.T) { } } +// TestQuickInput_EnterWithTextSendsAndUnfocuses verifies pressing Enter with text +// unfocuses quick input and produces a command (the sendTextToExecutor tea.Cmd). +func TestQuickInput_EnterWithTextSendsAndUnfocuses(t *testing.T) { + replyInput := textinput.New() + replyInput.CharLimit = 200 + replyInput.Focus() + replyInput.SetValue("hello world") + + kanban := NewKanbanBoard(100, 50) + task := &db.Task{ID: 42, Title: "Test task", Status: db.StatusBlocked} + kanban.SetTasks([]*db.Task{task}) + kanban.SelectTask(42) + + m := &AppModel{ + width: 100, + height: 50, + currentView: ViewDashboard, + keys: DefaultKeyMap(), + quickInputFocused: true, + replyInput: replyInput, + tasksNeedingInput: map[int64]bool{42: true}, + questionPrompts: make(map[int64]bool), + executorPrompts: make(map[int64]string), + kanban: kanban, + } + + result, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model := result.(*AppModel) + + if model.quickInputFocused { + t.Error("quickInputFocused should be false after pressing Enter with text") + } + if model.replyInput.Value() != "" { + t.Error("replyInput should be cleared after sending") + } + if cmd == nil { + t.Error("expected a command to be returned for sending text to executor") + } +} + +// TestQuickInput_EnterWithTextNoSelectedTask verifies pressing Enter with text +// but no selected task gracefully unfocuses without crashing. +func TestQuickInput_EnterWithTextNoSelectedTask(t *testing.T) { + replyInput := textinput.New() + replyInput.CharLimit = 200 + replyInput.Focus() + replyInput.SetValue("some text") + + kanban := NewKanbanBoard(100, 50) + // No tasks set - SelectedTask() returns nil + + m := &AppModel{ + width: 100, + height: 50, + currentView: ViewDashboard, + keys: DefaultKeyMap(), + quickInputFocused: true, + replyInput: replyInput, + tasksNeedingInput: make(map[int64]bool), + questionPrompts: make(map[int64]bool), + executorPrompts: make(map[int64]string), + kanban: kanban, + } + + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model := result.(*AppModel) + + if model.quickInputFocused { + t.Error("quickInputFocused should be false even without a selected task") + } + if model.replyInput.Value() != "" { + t.Error("replyInput should be cleared") + } +} + // TestQuickInput_TabIgnoredWithoutBlockedTask verifies Tab does nothing // when the selected task doesn't need input. func TestQuickInput_TabIgnoredWithoutBlockedTask(t *testing.T) {