Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions internal/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
75 changes: 75 additions & 0 deletions internal/ui/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading