|
4 | 4 | "context" |
5 | 5 | "encoding/json" |
6 | 6 | "fmt" |
| 7 | + "log/slog" |
7 | 8 | "os" |
8 | 9 | "os/exec" |
9 | 10 | "path/filepath" |
@@ -743,7 +744,8 @@ func (p *Plugin) scheduleInteractivePoll(worktreeName string, delay time.Duratio |
743 | 744 | // AgentPollUnchangedMsg signals content unchanged, schedule next poll. |
744 | 745 | type AgentPollUnchangedMsg struct { |
745 | 746 | WorkspaceName string |
746 | | - CurrentStatus WorktreeStatus // For adaptive polling interval selection |
| 747 | + CurrentStatus WorktreeStatus // Status including session file re-check |
| 748 | + WaitingFor string // Prompt text if waiting |
747 | 749 | // Cursor position captured atomically (even when content unchanged) |
748 | 750 | CursorRow int |
749 | 751 | CursorCol int |
@@ -845,41 +847,58 @@ func (p *Plugin) handlePollAgent(worktreeName string) tea.Cmd { |
845 | 847 | output = trimCapturedOutput(output, maxBytes) |
846 | 848 |
|
847 | 849 | // Use hash-based change detection to skip processing if content unchanged |
848 | | - if outputBuf != nil && !outputBuf.Update(output) { |
849 | | - // Content unchanged - signal to schedule next poll with delay |
850 | | - // Still include cursor position since cursor can move without content changing |
851 | | - return AgentPollUnchangedMsg{ |
852 | | - WorkspaceName: worktreeName, |
853 | | - CurrentStatus: currentStatus, |
854 | | - CursorRow: cursorRow, |
855 | | - CursorCol: cursorCol, |
856 | | - CursorVisible: cursorVisible, |
857 | | - HasCursor: hasCursor, |
858 | | - PaneHeight: paneHeight, |
859 | | - PaneWidth: paneWidth, |
860 | | - } |
861 | | - } |
| 850 | + outputChanged := outputBuf == nil || outputBuf.Update(output) |
862 | 851 |
|
863 | | - // Content changed - detect status (skip during interactive mode to reduce I/O, td-f29f2d) |
| 852 | + // Detect status. Both detectors run; each is authoritative for what it's good at (td-2fca7d): |
| 853 | + // - tmux patterns: thinking, done, error (high-signal, session files can't detect these) |
| 854 | + // - session files: active vs waiting (reliable, tmux patterns are noisy for this) |
| 855 | + // Session file detection ALWAYS runs (even when output unchanged) because the agent |
| 856 | + // may finish while tmux output stays the same (td-2fca7d v8). |
864 | 857 | status := currentStatus |
865 | 858 | waitingFor := "" |
866 | 859 | if !interactiveCapture { |
867 | | - status = detectStatus(output) |
868 | | - if status == StatusWaiting { |
869 | | - waitingFor = extractPrompt(output) |
| 860 | + if outputChanged { |
| 861 | + // Tmux pattern detection only when output changes (same output = same patterns). |
| 862 | + status = detectStatus(output) |
| 863 | + if status == StatusWaiting { |
| 864 | + waitingFor = extractPrompt(output) |
| 865 | + } |
870 | 866 | } |
871 | | - // For supported agents: supplement tmux detection with session file analysis |
872 | | - // Session files are more reliable for detecting "waiting at prompt" state |
873 | | - if status == StatusActive { |
| 867 | + // Session file check runs every poll — mtime changes independently of tmux output. |
| 868 | + // Only override active/waiting; preserve tmux-detected thinking/done/error. |
| 869 | + if status == StatusActive || status == StatusWaiting { |
874 | 870 | if sessionStatus, ok := detectAgentSessionStatus(agentType, wtPath); ok { |
875 | | - if sessionStatus == StatusWaiting { |
876 | | - status = StatusWaiting |
877 | | - waitingFor = "Waiting for input" |
| 871 | + prevStatus := status |
| 872 | + status = sessionStatus |
| 873 | + if status == StatusWaiting { |
| 874 | + waitingFor = extractPrompt(output) |
| 875 | + if waitingFor == "" { |
| 876 | + waitingFor = "Waiting for input" |
| 877 | + } |
| 878 | + } else { |
| 879 | + waitingFor = "" |
878 | 880 | } |
| 881 | + slog.Debug("status: session file override", "worktree", worktreeName, "prev", prevStatus, "session", sessionStatus) |
| 882 | + } else { |
| 883 | + slog.Debug("status: no session file, using tmux", "worktree", worktreeName, "status", status, "agent", agentType) |
879 | 884 | } |
880 | 885 | } |
881 | 886 | } |
882 | 887 |
|
| 888 | + if !outputChanged { |
| 889 | + return AgentPollUnchangedMsg{ |
| 890 | + WorkspaceName: worktreeName, |
| 891 | + CurrentStatus: status, |
| 892 | + WaitingFor: waitingFor, |
| 893 | + CursorRow: cursorRow, |
| 894 | + CursorCol: cursorCol, |
| 895 | + CursorVisible: cursorVisible, |
| 896 | + HasCursor: hasCursor, |
| 897 | + PaneHeight: paneHeight, |
| 898 | + PaneWidth: paneWidth, |
| 899 | + } |
| 900 | + } |
| 901 | + |
883 | 902 | return AgentOutputMsg{ |
884 | 903 | WorkspaceName: worktreeName, |
885 | 904 | Output: output, |
@@ -1068,31 +1087,34 @@ func tailUTF8Safe(s string, n int) string { |
1068 | 1087 | } |
1069 | 1088 |
|
1070 | 1089 | // detectStatus determines agent status from captured output. |
1071 | | -// Optimized to avoid unnecessary string allocations. |
| 1090 | +// This is the tmux-based fallback for agents without session file support (td-2fca7d). |
| 1091 | +// For supported agents (Claude, Codex, Gemini, OpenCode), session file analysis runs |
| 1092 | +// first in handlePollAgent and is more reliable than tmux pattern matching. |
1072 | 1093 | func detectStatus(output string) WorktreeStatus { |
1073 | 1094 | // Check tail of output for status patterns (avoids splitting entire string) |
1074 | 1095 | checkText := tailUTF8Safe(output, statusCheckBytes) |
1075 | 1096 | textLower := strings.ToLower(checkText) |
1076 | 1097 |
|
1077 | | - // Waiting patterns (agent needs user input) - highest priority |
1078 | | - // Check before thinking since waiting blocks progress |
| 1098 | + // Waiting patterns — only check the last few lines of output (td-2fca7d). |
| 1099 | + // A prompt is only relevant if it's at the bottom of the screen (the agent is |
| 1100 | + // actually waiting right now). Checking 2048 bytes of scrollback history caused |
| 1101 | + // false positives from old prompts and shell prompt characters like "❯". |
1079 | 1102 | waitingPatterns := []string{ |
1080 | 1103 | "[y/n]", // Claude Code permission prompt |
1081 | 1104 | "(y/n)", // Aider style |
1082 | 1105 | "allow edit", // Claude Code file edit |
1083 | 1106 | "allow bash", // Claude Code bash command |
1084 | | - "waiting for", // Generic waiting |
1085 | 1107 | "press enter", // Continue prompt |
1086 | 1108 | "continue?", |
1087 | 1109 | "approve", |
1088 | 1110 | "confirm", |
1089 | 1111 | "do you want", // Common prompt |
1090 | | - "❯", // Claude Code input prompt (waiting for user) |
1091 | | - "╰─❯", // Claude Code prompt with tree line decoration |
1092 | 1112 | } |
1093 | 1113 |
|
| 1114 | + lastLines := extractLastNLines(checkText, 5) |
| 1115 | + lastLinesLower := strings.ToLower(lastLines) |
1094 | 1116 | for _, pattern := range waitingPatterns { |
1095 | | - if strings.Contains(textLower, pattern) { |
| 1117 | + if strings.Contains(lastLinesLower, pattern) { |
1096 | 1118 | return StatusWaiting |
1097 | 1119 | } |
1098 | 1120 | } |
@@ -1156,6 +1178,34 @@ func detectStatus(output string) WorktreeStatus { |
1156 | 1178 | return StatusActive |
1157 | 1179 | } |
1158 | 1180 |
|
| 1181 | +// extractLastNLines returns the last n non-empty lines of text. |
| 1182 | +// Used by detectStatus to restrict waiting pattern matching to the bottom of the terminal. |
| 1183 | +func extractLastNLines(text string, n int) string { |
| 1184 | + // Work backwards from the end to find the last n lines |
| 1185 | + end := len(text) |
| 1186 | + // Skip trailing whitespace/newlines |
| 1187 | + for end > 0 && (text[end-1] == '\n' || text[end-1] == '\r' || text[end-1] == ' ') { |
| 1188 | + end-- |
| 1189 | + } |
| 1190 | + if end == 0 { |
| 1191 | + return "" |
| 1192 | + } |
| 1193 | + |
| 1194 | + linesFound := 0 |
| 1195 | + pos := end |
| 1196 | + for pos > 0 && linesFound < n { |
| 1197 | + pos-- |
| 1198 | + if text[pos] == '\n' { |
| 1199 | + linesFound++ |
| 1200 | + } |
| 1201 | + } |
| 1202 | + // If we stopped at a newline, skip past it |
| 1203 | + if pos > 0 || (pos == 0 && text[0] == '\n') { |
| 1204 | + pos++ |
| 1205 | + } |
| 1206 | + return text[pos:end] |
| 1207 | +} |
| 1208 | + |
1159 | 1209 | // extractPrompt finds the prompt text from output. |
1160 | 1210 | // Optimized to search backwards without splitting the entire string. |
1161 | 1211 | func extractPrompt(output string) string { |
|
0 commit comments