warn unsupported actions, attach to tmux from acme, etc.

This commit is contained in:
Levi Neely 2026-02-10 20:01:18 +01:00
parent e7059b94fb
commit d33bede0f1
4 changed files with 334 additions and 49 deletions

BIN
acme-q

Binary file not shown.

View File

@ -181,7 +181,8 @@ func (s *Session) Send(ctx context.Context, prompt string) (string, error) {
// Check command support if applicable
if strings.HasPrefix(prompt, "/") && s.commands != nil {
if !s.commands.IsSupported(prompt) {
return "", fmt.Errorf("unsupported command")
cmd := strings.Fields(prompt)[0]
return "", fmt.Errorf("slash command not supported: %s (attach to tmux session to run: tmux attach -t %s)", cmd, s.sessionName)
}
}
@ -219,10 +220,41 @@ func (s *Session) Send(ctx context.Context, prompt string) (string, error) {
// Clean output
rawLen := s.output.Len()
result := s.cleaner.Clean(prompt, s.output.String())
rawOutput := s.output.String()
result := s.cleaner.Clean(prompt, rawOutput)
log.Printf("[session %s] raw: %d bytes, cleaned: %d bytes", s.id, rawLen, len(result))
if len(result) == 0 && rawLen > 0 {
log.Printf("[session %s] WARNING: cleaner returned 0 bytes", s.id)
// Debug: show first and last 500 chars of raw output
debugFirst := rawOutput
if len(debugFirst) > 500 {
debugFirst = debugFirst[:500]
}
debugLast := rawOutput
if len(debugLast) > 500 {
debugLast = debugLast[len(debugLast)-500:]
}
log.Printf("[session %s] DEBUG raw output (first 500 chars): %q", s.id, debugFirst)
log.Printf("[session %s] DEBUG raw output (last 500 chars): %q", s.id, debugLast)
// Check for key markers (simple check, no ANSI stripping)
hasBullet := strings.Contains(rawOutput, "●")
hasThinking := strings.Contains(rawOutput, "(esc to interrupt)") || strings.Contains(rawOutput, "∴")
log.Printf("[session %s] DEBUG markers: hasBullet=%v, hasThinking=%v", s.id, hasBullet, hasThinking)
// Find where the bullet is
if hasBullet {
idx := strings.Index(rawOutput, "●")
start := idx - 50
if start < 0 {
start = 0
}
end := idx + 100
if end > len(rawOutput) {
end = len(rawOutput)
}
log.Printf("[session %s] DEBUG bullet context: %q", s.id, rawOutput[start:end])
}
}
return result, nil

View File

@ -4,9 +4,13 @@ package backends
import (
"acme-q/internal/backend"
"acme-q/internal/backend/tmux"
"bufio"
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
@ -15,10 +19,26 @@ import (
//
// Uses tmux backend to handle interactive TUI dialogs programmatically.
// The startup handler automatically accepts the --dangerously-skip-permissions dialog.
//
// Sessions are automatically saved by Claude to ~/.claude/projects/<dir-path>/<session-id>.jsonl
func NewClaude() backend.Backend {
return newClaudeWithCommand([]string{"claude", "--dangerously-skip-permissions"})
}
// NewClaudeWithContinue creates a backend that continues the most recent conversation
func NewClaudeWithContinue() backend.Backend {
return newClaudeWithCommand([]string{"claude", "--dangerously-skip-permissions", "--continue"})
}
// NewClaudeWithResume creates a backend that resumes a specific session by ID
func NewClaudeWithResume(sessionID string) backend.Backend {
return newClaudeWithCommand([]string{"claude", "--dangerously-skip-permissions", "--resume", sessionID})
}
func newClaudeWithCommand(command []string) backend.Backend {
return tmux.New(tmux.Config{
Name: "claude",
Command: []string{"claude", "--dangerously-skip-permissions"},
Command: command,
Environment: map[string]string{
"TERM": "xterm-256color",
"NO_COLOR": "1",
@ -68,6 +88,30 @@ func (d *claudeDetector) IsComplete(prompt string, output string) bool {
clean := stripANSI(output)
clean = strings.ReplaceAll(clean, "\r", "")
isCommand := strings.HasPrefix(strings.TrimSpace(prompt), "/")
if isCommand {
// For slash commands, completion is simpler:
// Just wait for the prompt to return (bottom chrome appears)
if len(clean) < 500 {
return false
}
// Get the tail to check for bottom chrome
tailStart := len(clean) - 500
if tailStart < 0 {
tailStart = 0
}
tail := clean[tailStart:]
// Command complete when bottom chrome appears
hasChromeInTail := strings.Contains(tail, "bypass permissions on") ||
strings.Contains(tail, "Thinking on (tab to toggle)")
return hasChromeInTail
}
// For normal prompts (non-slash commands):
// Need substantial output first
if len(clean) < 1000 {
return false
@ -129,6 +173,68 @@ func (c *claudeCleaner) Clean(prompt string, rawOutput string) string {
titleRegex := regexp.MustCompile(`\]0;[^\n]*`)
clean = titleRegex.ReplaceAllString(clean, "")
isCommand := strings.HasPrefix(strings.TrimSpace(prompt), "/")
if isCommand {
// For slash commands, extract content between separator lines
return c.cleanCommand(clean, prompt)
}
// For normal prompts, extract ● responses
return c.cleanResponse(clean, prompt)
}
func (c *claudeCleaner) cleanCommand(clean string, prompt string) string {
lines := strings.Split(clean, "\n")
var result []string
inContent := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// Skip prompt echo
if strings.HasPrefix(trimmed, ">") && strings.Contains(line, prompt) {
continue
}
// Separator line marks start of content
// Pattern: long line of ─ characters (at least 50)
isSeparator := len(trimmed) > 50 && regexp.MustCompile(`^─+$`).MatchString(trimmed)
if isSeparator && !inContent {
// First separator = start of content
inContent = true
continue
}
// Once we're collecting content, look for end conditions
if inContent {
// Another separator = clean end
if isSeparator {
break
}
// Bottom chrome = end of content area
if c.isBottomChrome(line) {
break
}
// Skip "dismissed" messages
if strings.Contains(line, "dismissed") {
continue
}
// Keep all other content
result = append(result, line)
}
}
// Clean up trailing empty lines
for len(result) > 0 && strings.TrimSpace(result[len(result)-1]) == "" {
result = result[:len(result)-1]
}
return strings.Join(result, "\n")
}
func (c *claudeCleaner) cleanResponse(clean string, prompt string) string {
lines := strings.Split(clean, "\n")
var result []string
inResponse := false
@ -141,15 +247,34 @@ func (c *claudeCleaner) Clean(prompt string, rawOutput string) string {
continue
}
// Skip noise (includes thinking indicators, startup chrome, etc.)
if c.isNoise(line) {
// If we hit bottom chrome after response started, stop
if inResponse && c.isBottomChrome(line) {
break
// IMPORTANT: Check for response marker BEFORE noise filtering
// Otherwise lines like "● Hello! I'm Claude Code..." get filtered as noise
if strings.HasPrefix(trimmed, "●") {
inResponse = true
// Remove the ● prefix and keep the content
content := strings.TrimPrefix(trimmed, "●")
content = strings.TrimSpace(content)
if content != "" {
result = append(result, content)
}
continue
}
// Always filter thinking indicators and spinners (even during response)
if c.isThinkingIndicator(line) {
continue
}
// If in response mode and hit bottom chrome, stop collecting
if inResponse && c.isBottomChrome(line) {
break
}
// Only filter generic UI chrome before response starts
if !inResponse && c.isGenericNoise(line) {
continue
}
// Skip lines that are just ">" with optional whitespace
// These are cursor/prompt redraws
if trimmed == ">" || trimmed == "" {
@ -161,20 +286,6 @@ func (c *claudeCleaner) Clean(prompt string, rawOutput string) string {
continue
}
// Look for actual response content
// Claude responses start with ● (bullet)
// Tool usage also uses ●
if strings.HasPrefix(trimmed, "●") {
inResponse = true
// Remove the ● prefix and keep the content
content := strings.TrimPrefix(trimmed, "●")
content = strings.TrimSpace(content)
if content != "" {
result = append(result, content)
}
continue
}
// If we're in response mode, keep non-chrome content
// Continuation lines (indented content after ●) should be kept
if inResponse {
@ -199,7 +310,8 @@ func (c *claudeCleaner) Clean(prompt string, rawOutput string) string {
return strings.Join(result, "\n")
}
func (c *claudeCleaner) isNoise(line string) bool {
// isThinkingIndicator checks for thinking/status indicators (always filter these)
func (c *claudeCleaner) isThinkingIndicator(line string) bool {
trimmed := strings.TrimSpace(line)
// Empty or whitespace-only lines
@ -239,6 +351,21 @@ func (c *claudeCleaner) isNoise(line string) bool {
return true
}
// Check for progress spinner characters at start of line
spinners := []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏', '⢀', '⡀', '✓', '⚠'}
for _, r := range spinners {
if strings.HasPrefix(trimmed, string(r)) {
return true
}
}
return false
}
// isGenericNoise checks for startup/UI chrome (only filter before response starts)
func (c *claudeCleaner) isGenericNoise(line string) bool {
trimmed := strings.TrimSpace(line)
// Startup/welcome chrome and UI elements
noisePatterns := []string{
"https://code.claude.com",
@ -273,16 +400,8 @@ func (c *claudeCleaner) isNoise(line string) bool {
}
}
// Check for progress spinner characters at start of line
spinners := []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏', '⢀', '⡀', '✓', '⚠'}
for _, r := range spinners {
if strings.HasPrefix(trimmed, string(r)) {
return true
}
}
// Skip lines that are just box drawing characters or separators
if regexp.MustCompile(`^[─│┌┐└┘├┤┬┴┼═║╔╗╚╝╠╣╦╩╬╭╮╰╯\s]+$`).MatchString(trimmed) {
if regexp.MustCompile(`^[─│┌┐└┘├┤┬┴┼═║╔╗╚╝╠╣╦╩╬╭╮╰╯\\s]+$`).MatchString(trimmed) {
return true
}
@ -318,30 +437,145 @@ func (c *claudeCleaner) isBottomChrome(line string) bool {
return false
}
// claudeCommands implements command handling for claude CLI
// claudeCommands blocks all slash commands with a helpful error
type claudeCommands struct{}
func (h *claudeCommands) IsSupported(command string) bool {
// Claude CLI supports various slash commands
// This is a placeholder - adjust based on actual claude CLI capabilities
supported := []string{"/help", "/clear", "/model", "/settings"}
for _, cmd := range supported {
if strings.HasPrefix(command, cmd) {
return true
}
}
// Block all slash commands - many open interactive dialogs
// Users can attach to tmux session if needed: tmux attach -t <session-name>
return false
}
func (h *claudeCommands) Execute(ctx context.Context, command string) (string, error) {
// For claude CLI, commands are executed by sending them to the session
// This is handled by the Session.Send() method
// We just validate the command here
if !h.IsSupported(command) {
return "", fmt.Errorf("unsupported command")
return "", fmt.Errorf("slash commands not supported (many open interactive dialogs). To run slash commands, attach to tmux session.")
}
// Session Management Helpers
//
// NOTE: Save/Load operations are NOT supported for Claude backend.
// - Claude auto-saves all conversations to ~/.claude/projects/<dir>/<session-id>.jsonl
// - No practical way to "load" context from one session into another
// - Context sharing would require either:
// 1. Expensive message replay (burns tokens, slow)
// 2. Lossy summarization (defeats purpose of context sharing)
// 3. File manipulation (risky, undefined behavior)
//
// For session management:
// - Use NewClaudeWithResume(sessionID) to continue a specific session
// - Use NewClaudeWithContinue() to continue most recent session
// - Use ListSessions(cwd) to browse available sessions
// Claude automatically saves conversations to ~/.claude/projects/<dir-path>/<session-id>.jsonl
// GetSessionDir returns the directory where Claude stores sessions for the given working directory
func GetSessionDir(cwd string) string {
homeDir, err := os.UserHomeDir()
if err != nil {
return ""
}
return "", fmt.Errorf("execute called directly - use Send() instead")
// Convert cwd to Claude's project path format (replace / with -)
dirPath := strings.ReplaceAll(cwd, "/", "-")
return filepath.Join(homeDir, ".claude", "projects", dirPath)
}
// ListSessions returns a list of session IDs for the given directory, sorted by modification time (newest first)
func ListSessions(cwd string) ([]SessionInfo, error) {
sessionDir := GetSessionDir(cwd)
files, err := os.ReadDir(sessionDir)
if err != nil {
return nil, fmt.Errorf("failed to read session directory: %w", err)
}
var sessions []SessionInfo
for _, file := range files {
if strings.HasSuffix(file.Name(), ".jsonl") {
sessionID := strings.TrimSuffix(file.Name(), ".jsonl")
info, _ := file.Info()
// Try to get summary from file
summary := getSessionSummary(filepath.Join(sessionDir, file.Name()))
sessions = append(sessions, SessionInfo{
ID: sessionID,
Summary: summary,
ModTime: info.ModTime(),
})
}
}
// Sort by modification time (newest first)
sort.Slice(sessions, func(i, j int) bool {
return sessions[i].ModTime.After(sessions[j].ModTime)
})
return sessions, nil
}
// SessionInfo contains information about a Claude session
type SessionInfo struct {
ID string
Summary string
ModTime time.Time
}
// getSessionSummary extracts a summary from the session JSONL file
func getSessionSummary(path string) string {
file, err := os.Open(path)
if err != nil {
return "conversation"
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
// Look for summary field
if strings.Contains(line, `"summary"`) {
if idx := strings.Index(line, `"summary":"`); idx != -1 {
start := idx + len(`"summary":"`)
if end := strings.Index(line[start:], `"`); end != -1 {
extracted := line[start : start+end]
if extracted != "" {
return extracted
}
}
}
}
// Fallback: first user message
if strings.Contains(line, `"role":"user"`) && strings.Contains(line, `"content"`) {
if idx := strings.Index(line, `"content":"`); idx != -1 {
start := idx + len(`"content":"`)
if end := strings.Index(line[start:], `"`); end != -1 {
content := line[start : start+end]
if len(content) > 50 {
content = content[:50] + "..."
}
if content != "" {
return content
}
}
}
}
}
return "conversation"
}
// GetMostRecentSessionID returns the ID of the most recently modified session for the given directory
func GetMostRecentSessionID(cwd string) (string, error) {
sessions, err := ListSessions(cwd)
if err != nil {
return "", err
}
if len(sessions) == 0 {
return "", fmt.Errorf("no sessions found")
}
return sessions[0].ID, nil
}
// claudeStartupHandler handles the --dangerously-skip-permissions dialog

21
main.go
View File

@ -25,6 +25,10 @@ import (
const windowName = "/Q/"
// Terminal to use for tmux attach (configurable)
// Common options: "foot", "kitty", "xterm", "st", "alacritty"
const terminalCommand = "foot"
// Type alias to work around Go parser limitation
type Session = backend.Session
@ -262,7 +266,7 @@ func openChatWindow(sess backend.Session) (*acme.Win, error) {
return nil, err
}
w.Name(name)
w.Write("tag", []byte("Send Stop Alias Save Kill Sessions "))
w.Write("tag", []byte("Send Stop Attach Alias Save Kill Sessions "))
w.Fprintf("body", "# Session %s\n# cwd: %s\n\nUSER:\n", sess.ID(), meta.Cwd)
w.Ctl("clean")
@ -301,6 +305,21 @@ func handleChatWindow(w *acme.Win, sess backend.Session) {
} else {
log.Printf("[session %s] Sent CTRL+C", sess.ID())
}
case "Attach":
// Attach to tmux session (only works for tmux-based backends)
meta := sess.Metadata()
if tmuxSession, ok := meta.Extra["tmux_session"]; ok {
go func() {
cmd := exec.Command(terminalCommand, "-e", "tmux", "attach", "-t", tmuxSession)
if err := cmd.Start(); err != nil {
log.Printf("[session %s] Failed to launch terminal: %v", sess.ID(), err)
} else {
log.Printf("[session %s] Launched terminal attached to tmux session: %s", sess.ID(), tmuxSession)
}
}()
} else {
log.Printf("[session %s] Attach not supported: not a tmux-based backend", sess.ID())
}
case "Alias":
if arg == "" {
log.Printf("[session %s] Usage: Alias <name>", sess.ID())