warn unsupported actions, attach to tmux from acme, etc.
This commit is contained in:
parent
e7059b94fb
commit
d33bede0f1
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
21
main.go
|
|
@ -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())
|
||||
|
|
|
|||
Reference in New Issue