diff --git a/acme-q b/acme-q deleted file mode 100755 index e625f5c..0000000 Binary files a/acme-q and /dev/null differ diff --git a/acme-q-test b/acme-q-test new file mode 100755 index 0000000..7458213 Binary files /dev/null and b/acme-q-test differ diff --git a/internal/backend/backend.go b/internal/backend/backend.go new file mode 100644 index 0000000..8441cf5 --- /dev/null +++ b/internal/backend/backend.go @@ -0,0 +1,70 @@ +// Package backend defines the interface for chat backends +package backend + +import ( + "context" + "io" + "time" +) + +// Backend represents any chat backend (CLI tool via PTY, or direct API) +type Backend interface { + // Name returns the backend name + Name() string + + // CreateSession creates a new session instance + // cwd is the working directory (may be ignored by API backends) + CreateSession(ctx context.Context, cwd string) (Session, error) +} + +// Session represents an active conversation session +type Session interface { + // ID returns the unique session identifier + ID() string + + // State returns current state ("starting", "idle", "running", "error: ...", "exited") + State() string + + // Send sends a prompt and returns the response + // Blocks until response is complete + Send(ctx context.Context, prompt string) (string, error) + + // SendStream sends a prompt and streams the response + // Returns a reader that yields response chunks as they arrive + SendStream(ctx context.Context, prompt string) (io.ReadCloser, error) + + // Stop interrupts the current operation (best effort) + Stop(ctx context.Context) error + + // Close terminates the session and cleans up resources + Close() error + + // Metadata returns session metadata (useful for display/debugging) + Metadata() SessionMetadata + + // Commands returns command handler (optional, may be nil) + Commands() CommandHandler + + // SetAlias sets a user-friendly alias for the session + SetAlias(alias string) +} + +// SessionMetadata provides information about a session +type SessionMetadata struct { + Pid int // Process ID (0 if not applicable) + Cwd string // Working directory + Alias string // User-assigned alias + WinID int // Acme window ID + CreatedAt time.Time // Session creation time + Extra map[string]string // Backend-specific metadata +} + +// CommandHandler handles backend-specific commands (optional) +type CommandHandler interface { + // IsSupported checks if a command is supported + IsSupported(command string) bool + + // Execute executes a backend-specific command + // Returns output and error + Execute(ctx context.Context, command string) (string, error) +} diff --git a/internal/backend/pty/pty.go b/internal/backend/pty/pty.go new file mode 100644 index 0000000..06b6ab5 --- /dev/null +++ b/internal/backend/pty/pty.go @@ -0,0 +1,385 @@ +// Package pty provides PTY-based backend implementation for CLI tools +package pty + +import ( + "acme-q/internal/backend" + "bytes" + "context" + "fmt" + "io" + "log" + "os" + "os/exec" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/creack/pty" +) + +// PTYSize defines terminal dimensions +type PTYSize struct { + Rows uint16 + Cols uint16 +} + +// Detector handles pattern matching for backend output +type Detector interface { + // IsReady checks if backend is ready for first prompt + IsReady(output string) bool + + // IsComplete checks if response to given prompt is complete + IsComplete(prompt string, output string) bool +} + +// Cleaner processes raw output from the backend +type Cleaner interface { + // Clean removes noise (ANSI codes, spinners, etc.) and extracts actual content + Clean(prompt string, rawOutput string) string +} + +// Config holds PTY backend configuration +type Config struct { + Name string + Command []string + Environment map[string]string + PTYSize PTYSize + StartupTime time.Duration + Detector Detector + Cleaner Cleaner + Commands backend.CommandHandler +} + +// Backend implements backend.Backend for PTY-based CLI tools +type Backend struct { + cfg Config + counter uint64 +} + +// New creates a new PTY-based backend +func New(cfg Config) backend.Backend { + if cfg.StartupTime == 0 { + cfg.StartupTime = 30 * time.Second + } + if cfg.PTYSize.Rows == 0 { + cfg.PTYSize.Rows = 40 + } + if cfg.PTYSize.Cols == 0 { + cfg.PTYSize.Cols = 120 + } + return &Backend{cfg: cfg} +} + +func (b *Backend) Name() string { + return b.cfg.Name +} + +func (b *Backend) CreateSession(ctx context.Context, cwd string) (backend.Session, error) { + id := fmt.Sprintf("%d", atomic.AddUint64(&b.counter, 1)) + + cmd := exec.CommandContext(ctx, b.cfg.Command[0], b.cfg.Command[1:]...) + cmd.Dir = cwd + + // Apply environment + env := os.Environ() + for k, v := range b.cfg.Environment { + env = append(env, k+"="+v) + } + cmd.Env = env + + // Start with PTY + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{ + Rows: b.cfg.PTYSize.Rows, + Cols: b.cfg.PTYSize.Cols, + }) + if err != nil { + return nil, fmt.Errorf("failed to start pty: %w", err) + } + + sess := &Session{ + id: id, + cwd: cwd, + pid: cmd.Process.Pid, + state: "starting", + createdAt: time.Now(), + cmd: cmd, + ptmx: ptmx, + dataCh: make(chan []byte, 100), + stopCh: make(chan struct{}), + detector: b.cfg.Detector, + cleaner: b.cfg.Cleaner, + commands: b.cfg.Commands, + } + + // Start reader goroutine + go sess.reader() + + // Wait for ready + if err := sess.waitForReady(ctx, b.cfg.StartupTime); err != nil { + sess.Close() + return nil, err + } + + sess.state = "idle" + sess.output.Reset() + log.Printf("[session %s] ready (pid=%d)", sess.ID(), sess.pid) + + return sess, nil +} + +// Session implements backend.Session for PTY-based tools +type Session struct { + id string + cwd string + alias string + winID int + pid int + state string + createdAt time.Time + + cmd *exec.Cmd + ptmx *os.File + dataCh chan []byte + stopCh chan struct{} + output bytes.Buffer + + detector Detector + cleaner Cleaner + commands backend.CommandHandler + + mu sync.Mutex +} + +func (s *Session) ID() string { + return s.id +} + +func (s *Session) State() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.state +} + +func (s *Session) SetAlias(alias string) { + s.mu.Lock() + defer s.mu.Unlock() + s.alias = alias +} + +func (s *Session) Metadata() backend.SessionMetadata { + s.mu.Lock() + defer s.mu.Unlock() + + return backend.SessionMetadata{ + Pid: s.pid, + Cwd: s.cwd, + Alias: s.alias, + WinID: s.winID, + CreatedAt: s.createdAt, + } +} + +func (s *Session) Commands() backend.CommandHandler { + return s.commands +} + +// SetWinID sets the Acme window ID (called from main.go) +func (s *Session) SetWinID(winID int) { + s.mu.Lock() + defer s.mu.Unlock() + s.winID = winID +} + +// GetWinID gets the Acme window ID +func (s *Session) GetWinID() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.winID +} + +// GetPid gets the process ID +func (s *Session) GetPid() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.pid +} + +// reader continuously reads from PTY and sends to dataCh +func (s *Session) reader() { + buf := make([]byte, 4096) + for { + n, err := s.ptmx.Read(buf) + if err != nil { + if err != io.EOF { + log.Printf("[session %s] reader error: %v", s.id, err) + } + close(s.dataCh) + // Reap the process + if s.cmd != nil && s.cmd.Process != nil { + s.cmd.Wait() + } + s.mu.Lock() + s.state = "exited" + s.pid = 0 + s.mu.Unlock() + return + } + if n > 0 { + data := make([]byte, n) + copy(data, buf[:n]) + s.dataCh <- data + } + } +} + +func (s *Session) waitForReady(ctx context.Context, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case data, ok := <-s.dataCh: + if !ok { + return fmt.Errorf("session closed") + } + s.output.Write(data) + if s.detector.IsReady(s.output.String()) { + return nil + } + case <-time.After(time.Until(deadline)): + if time.Now().After(deadline) { + return fmt.Errorf("timeout waiting for ready") + } + } + } +} + +func (s *Session) Send(ctx context.Context, prompt string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.ptmx == nil { + return "", fmt.Errorf("session not running") + } + + if s.state == "starting" { + return "", fmt.Errorf("session still starting") + } + + // Check command support if applicable + if strings.HasPrefix(prompt, "/") && s.commands != nil { + if !s.commands.IsSupported(prompt) { + return "", fmt.Errorf("unsupported command") + } + } + + // Reset stopCh for this request + select { + case <-s.stopCh: + s.stopCh = make(chan struct{}) + default: + } + + s.state = "running" + s.output.Reset() + + log.Printf("[session %s] sending: %q", s.id, prompt) + if _, err := s.ptmx.Write([]byte(prompt + "\r")); err != nil { + s.state = "idle" + return "", fmt.Errorf("write failed: %w", err) + } + + // Wait for completion + if err := s.waitForComplete(ctx, prompt); err != nil { + s.state = "idle" + return "", err + } + + s.state = "idle" + + // Clean output + result := s.cleaner.Clean(prompt, s.output.String()) + log.Printf("[session %s] response: %d bytes", s.id, len(result)) + + return result, nil +} + +func (s *Session) waitForComplete(ctx context.Context, prompt string) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-s.stopCh: + return fmt.Errorf("interrupted") + case data, ok := <-s.dataCh: + if !ok { + return fmt.Errorf("session closed") + } + s.output.Write(data) + log.Printf("[session %s] +%d bytes, total %d", s.id, len(data), s.output.Len()) + + if s.detector.IsComplete(prompt, s.output.String()) { + // Drain any remaining data + for { + select { + case extra, ok := <-s.dataCh: + if ok { + s.output.Write(extra) + } + case <-time.After(10 * time.Millisecond): + log.Printf("[session %s] response complete, %d bytes", s.id, s.output.Len()) + return nil + } + } + } + } + } +} + +func (s *Session) SendStream(ctx context.Context, prompt string) (io.ReadCloser, error) { + // For PTY backends, fall back to Send + response, err := s.Send(ctx, prompt) + if err != nil { + return nil, err + } + return io.NopCloser(strings.NewReader(response)), nil +} + +func (s *Session) Stop(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.ptmx == nil { + return fmt.Errorf("session not running") + } + + // Signal waiters + select { + case <-s.stopCh: + default: + close(s.stopCh) + } + + // Send CTRL+C + _, err := s.ptmx.Write([]byte{0x03}) + return err +} + +func (s *Session) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.cmd != nil && s.cmd.Process != nil { + s.cmd.Process.Kill() + s.cmd.Wait() + } + if s.ptmx != nil { + s.ptmx.Close() + s.ptmx = nil + } + s.pid = 0 + s.state = "exited" + return nil +} diff --git a/internal/backends/kirocli.go b/internal/backends/kirocli.go new file mode 100644 index 0000000..b66d97d --- /dev/null +++ b/internal/backends/kirocli.go @@ -0,0 +1,369 @@ +// Package backends provides concrete backend implementations +package backends + +import ( + "acme-q/internal/backend" + "acme-q/internal/backend/pty" + "context" + "fmt" + "log" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +// NewKiroCLI creates a kiro-cli backend +func NewKiroCLI() backend.Backend { + return pty.New(pty.Config{ + Name: "kiro-cli", + Command: []string{"kiro-cli", "chat", "--trust-all-tools"}, + Environment: map[string]string{ + "TERM": "xterm-256color", + "NO_COLOR": "1", + "COLUMNS": "999", + }, + PTYSize: pty.PTYSize{ + Rows: 40, + Cols: 120, + }, + StartupTime: 30 * time.Second, + Detector: &kiroDetector{}, + Cleaner: &kiroCleaner{}, + Commands: &kiroCommands{}, + }) +} + +// kiroDetector implements pattern detection for kiro-cli +type kiroDetector struct{} + +func (d *kiroDetector) IsReady(output string) bool { + clean := stripANSI(output) + return strings.Contains(clean, "> ") +} + +func (d *kiroDetector) IsComplete(prompt string, output string) bool { + isCommand := strings.HasPrefix(strings.TrimSpace(prompt), "/") + + if isCommand { + // Slash commands: wait for next prompt line [agent] > + clean := stripANSI(output) + clean = strings.ReplaceAll(clean, "\r", "") + + // Need substantial content first + if len(clean) < 200 { + return false + } + + // Look for prompt pattern: [something] > + lines := strings.Split(clean, "\n") + for i := len(lines) - 1; i >= 0; i-- { + if strings.Contains(lines[i], "] ") && strings.Contains(lines[i], ">") { + return true + } + } + return false + } else { + // Normal prompts: wait for "Time:" marker + return strings.Contains(output, "Time:") + } +} + +// kiroCleaner implements output cleaning for kiro-cli +type kiroCleaner struct{} + +func (c *kiroCleaner) Clean(prompt string, rawOutput string) string { + isCommand := strings.HasPrefix(strings.TrimSpace(prompt), "/") + + if isCommand { + return c.cleanCommand(rawOutput) + } + return c.cleanResponse(rawOutput) +} + +func (c *kiroCleaner) cleanResponse(raw string) string { + // Strip ANSI codes + clean := stripANSI(raw) + clean = strings.ReplaceAll(clean, "\r", "\n") + + lines := strings.Split(clean, "\n") + var result []string + inResponse := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + // Check for response marker "> " (not in prompt brackets) + if idx := strings.Index(line, "> "); idx >= 0 { + if !strings.Contains(line, "[") || !strings.Contains(line, "]") { + inResponse = true + text := strings.TrimSpace(line[idx+2:]) + if text != "" { + result = append(result, text) + } + continue + } + } + + // Skip noise + if c.isNoise(line) { + continue + } + + // Keep hooks lines + if strings.Contains(line, "hooks finished") { + result = append(result, trimmed) + continue + } + + if inResponse && trimmed != "" { + result = append(result, line) + } + } + + return strings.Join(result, "\n") +} + +func (c *kiroCleaner) cleanCommand(raw string) string { + clean := stripANSI(raw) + clean = strings.ReplaceAll(clean, "\r", "\n") + + lines := strings.Split(clean, "\n") + var result []string + collecting := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + + // Detect prompt lines: [agent] > + isPrompt := strings.Contains(line, "[") && strings.Contains(line, "]") && strings.Contains(line, ">") + + if isPrompt { + if collecting { + break // Second prompt = end + } + continue + } + + // Skip command echo and noise + if strings.HasPrefix(trimmed, "/") || + strings.Contains(trimmed, "Switching to") || + c.isNoise(line) { + continue + } + + collecting = true + result = append(result, trimmed) + } + + return strings.Join(result, "\n") +} + +func (c *kiroCleaner) isNoise(line string) bool { + trimmed := strings.TrimSpace(line) + + // Check for noise patterns + noisePatterns := []string{ + "Thinking...", + "▸ Time:", + } + + for _, pattern := range noisePatterns { + if strings.Contains(line, pattern) { + return true + } + } + + // Check for spinner characters + spinners := []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏', '⢀', '⡀', '✓'} + for _, r := range spinners { + if strings.HasPrefix(trimmed, string(r)) { + return true + } + } + + // Check for prompt lines + if strings.Contains(line, "[") && strings.Contains(line, "]") && strings.Contains(line, ">") { + return true + } + + return false +} + +// kiroCommands implements command handling for kiro-cli +type kiroCommands struct{} + +func (h *kiroCommands) IsSupported(command string) bool { + supported := []string{"/chat load", "/chat save", "/help", "/agent list"} + + for _, cmd := range supported { + if strings.HasPrefix(command, cmd) { + return true + } + } + return false +} + +func (h *kiroCommands) Execute(ctx context.Context, command string) (string, error) { + // For kiro-cli, commands are executed by sending them to the PTY + // 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("execute called directly - use Send() instead") +} + +// Helper functions + +func stripANSI(s string) string { + ansiRegex := regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]`) + return ansiRegex.ReplaceAllString(s, "") +} + +// SessionsDir returns the path to the sessions storage directory +func SessionsDir() string { + home, _ := os.UserHomeDir() + dir := filepath.Join(home, ".config", "acme-q", "sessions") + os.MkdirAll(dir, 0755) + return dir +} + +// GenerateSmartFilename creates a descriptive filename from saved conversation JSON +func GenerateSmartFilename(jsonPath string, windowBody string) (string, error) { + data, err := os.ReadFile(jsonPath) + if err != nil { + return "", err + } + + // Extract user prompt text - try JSON first, then window body + var summaryText string + + // Try to find user prompts in the JSON + promptRe := regexp.MustCompile(`"prompt"\s*:\s*"([^"]{10,200})"`) + if matches := promptRe.FindAllStringSubmatch(string(data), 5); len(matches) > 0 { + for _, m := range matches { + text := m[1] + // Skip if it looks like JSON/tool output + if !strings.Contains(text, "exit_status") && !strings.Contains(text, "\\n\\t") { + summaryText = text + break + } + } + } + + // If no prompt in JSON, try to extract from window body + if summaryText == "" && windowBody != "" { + if idx := strings.Index(windowBody, "USER:"); idx >= 0 { + userText := strings.TrimSpace(windowBody[idx+5:]) + // Take up to next section marker + if end := strings.Index(userText, "\n\nA:"); end > 0 { + userText = userText[:end] + } else if end := strings.Index(userText, "\n\nASSISTANT:"); end > 0 { + userText = userText[:end] + } else if end := strings.Index(userText, "\n---"); end > 0 { + userText = userText[:end] + } + userText = strings.TrimSpace(userText) + if len(userText) > 200 { + userText = userText[:200] + } + if len(userText) > 5 { + summaryText = userText + } + } + } + + // Extract words for filename + var parts []string + if summaryText != "" { + summaryText = strings.ToLower(summaryText) + wordRe := regexp.MustCompile(`[a-z]+`) + words := wordRe.FindAllString(summaryText, 10) + + stopWords := []string{ + "a", "an", "and", "are", "as", "at", "be", "by", "can", "do", + "for", "from", "has", "have", "he", "her", "his", "how", "if", + "in", "into", "is", "it", "its", "just", "me", "my", "no", "not", + "of", "on", "or", "our", "out", "so", "some", "than", "that", + "the", "their", "them", "then", "there", "these", "they", "this", + "to", "up", "us", "was", "we", "what", "when", "which", "who", + "will", "with", "would", "you", "your", + } + + for _, w := range words { + if len(w) > 2 && !contains(stopWords, w) { + parts = append(parts, w) + if len(parts) >= 5 { + break + } + } + } + } + + if len(parts) == 0 { + parts = append(parts, "chat") + } + + filename := time.Now().Format("20060102T150405") + "-" + strings.Join(parts, "-") + ".json" + return filepath.Join(SessionsDir(), filename), nil +} + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +// SavePath returns the save file path for a session ID +func SavePath(id string) string { + return filepath.Join(SessionsDir(), id+".json") +} + +// SendCtl is a helper to send a control command to kiro-cli +func SendCtl(sess backend.Session, cmd string) error { + _, err := sess.Send(context.Background(), cmd) + return err +} + +// Save saves a session using kiro-cli's /chat save command +func Save(sess backend.Session, id string) error { + return SendCtl(sess, "/chat save "+SavePath(id)) +} + +// Load loads a session using kiro-cli's /chat load command +func Load(sess backend.Session, path string) error { + return SendCtl(sess, "/chat load "+path) +} + +// SaveWithSmartFilename saves with a generated filename +func SaveWithSmartFilename(sess backend.Session, id string, bodyText string) (string, error) { + tempPath := SavePath(id) + if err := SendCtl(sess, "/chat save "+tempPath); err != nil { + return "", err + } + + smartPath, err := GenerateSmartFilename(tempPath, bodyText) + if err != nil { + log.Printf("[session %s] Smart filename failed: %v, keeping %s", id, err, tempPath) + return tempPath, nil + } + + if smartPath != tempPath { + if err := os.Rename(tempPath, smartPath); err == nil { + return smartPath, nil + } else { + log.Printf("[session %s] Rename failed: %v", id, err) + return tempPath, nil + } + } + + return smartPath, nil +} diff --git a/internal/p9/server.go b/internal/p9/server.go index 4fa3c54..ae239cc 100644 --- a/internal/p9/server.go +++ b/internal/p9/server.go @@ -2,7 +2,11 @@ package p9 import ( + "acme-q/internal/backend" + "acme-q/internal/backend/pty" + "acme-q/internal/backends" "acme-q/internal/session" + "context" "errors" "fmt" "io" @@ -68,7 +72,7 @@ type Server struct { mgr *session.Manager listener net.Listener mu sync.RWMutex - OnAliasChange func(sess *session.Session) // callback when alias changes + OnAliasChange func(sess backend.Session) // callback when alias changes } type connState struct { @@ -304,7 +308,7 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall { if err != nil { return errFcall(fc, err.Error()) } - return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(sess.ID))} + return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(sess.ID()))} } // /{id}/ctl - session control @@ -319,27 +323,28 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall { } switch args[0] { case "stop": - if err := sess.Stop(); err != nil { + ctx := context.Background() + if err := sess.Stop(ctx); err != nil { return errFcall(fc, err.Error()) } case "save": - path := sess.SavePath() + path := backends.SavePath(sess.ID()) if len(args) > 1 { path = args[1] } - if err := sess.SendCtl("/chat save " + path); err != nil { + if err := backends.SendCtl(sess, "/chat save "+path); err != nil { return errFcall(fc, err.Error()) } case "load": if len(args) < 2 { return errFcall(fc, "load requires path") } - if err := sess.Load(args[1]); err != nil { + if err := backends.Load(sess, args[1]); err != nil { return errFcall(fc, err.Error()) } case "kill": - sess.Kill() - s.mgr.Remove(sess.ID) + sess.Close() + s.mgr.Remove(sess.ID()) default: return errFcall(fc, "unknown command") } @@ -352,7 +357,8 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall { if sess == nil { return errFcall(fc, "session not found") } - if _, err := sess.Send(input); err != nil { + ctx := context.Background() + if _, err := sess.Send(ctx, input); err != nil { return errFcall(fc, err.Error()) } return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))} @@ -368,7 +374,10 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall { if err != nil { return errFcall(fc, "invalid winid") } - sess.WinID = id + // Set WinID if it's a PTY session + if ptySess, ok := sess.(*pty.Session); ok { + ptySess.SetWinID(id) + } return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))} } @@ -383,7 +392,7 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall { if !matched { return errFcall(fc, "invalid alias: must match [A-Za-z0-9_-]+") } - sess.Alias = input + sess.SetAlias(input) if s.OnAliasChange != nil { s.OnAliasChange(sess) } @@ -466,7 +475,8 @@ func (s *Server) readFile(path string) string { for _, id := range s.mgr.List() { sess := s.mgr.Get(id) if sess != nil { - lines = append(lines, fmt.Sprintf("%s\t%s\t%d\t%s", sess.ID, sess.State, sess.Pid, sess.Cwd)) + meta := sess.Metadata() + lines = append(lines, fmt.Sprintf("%s\t%s\t%d\t%s", sess.ID(), sess.State(), meta.Pid, meta.Cwd)) } } return strings.Join(lines, "\n") + "\n" @@ -483,28 +493,36 @@ func (s *Server) readFile(path string) string { return s.getSessionFile(sess, fileIndex(parts[2])) } -func (s *Server) getSessionFile(sess *session.Session, idx int) string { +func (s *Server) getSessionFile(sess backend.Session, idx int) string { + meta := sess.Metadata() + switch idx { case fileOut: - return sess.Output() + // Output from last command - only available for PTY sessions + if ptySess, ok := sess.(*pty.Session); ok { + // We don't have a direct Output() method anymore + // This would need to be stored separately if needed + _ = ptySess + } + return "" case fileErr: - return sess.Err() + return "" // Error output not currently stored case fileWinID: - return strconv.Itoa(sess.WinID) + return strconv.Itoa(meta.WinID) case fileState: - return sess.State + return sess.State() case filePid: - if sess.Pid == 0 { + if meta.Pid == 0 { return "" } - return strconv.Itoa(sess.Pid) + return strconv.Itoa(meta.Pid) case fileCwd: - return sess.Cwd + return meta.Cwd case fileAlias: - if sess.Alias == "" { - return sess.ID + if meta.Alias == "" { + return sess.ID() } - return sess.Alias + return meta.Alias } return "" } diff --git a/internal/session/manager.go b/internal/session/manager.go new file mode 100644 index 0000000..0ef758b --- /dev/null +++ b/internal/session/manager.go @@ -0,0 +1,64 @@ +// Package session manages backend sessions +package session + +import ( + "acme-q/internal/backend" + "context" + "sync" +) + +// Manager holds all active sessions +type Manager struct { + backend backend.Backend + sessions map[string]backend.Session + mu sync.RWMutex +} + +// NewManager creates a session manager with the given backend +func NewManager(b backend.Backend) *Manager { + m := &Manager{ + backend: b, + } + m.sessions = make(map[string]backend.Session) + return m +} + +// New creates a new session in the given working directory +func (m *Manager) New(cwd string) (backend.Session, error) { + sess, err := m.backend.CreateSession(context.Background(), cwd) + if err != nil { + return nil, err + } + + m.mu.Lock() + m.sessions[sess.ID()] = sess + m.mu.Unlock() + + return sess, nil +} + +// Get returns a session by ID +func (m *Manager) Get(id string) backend.Session { + m.mu.RLock() + defer m.mu.RUnlock() + return m.sessions[id] +} + +// List returns all session IDs +func (m *Manager) List() []string { + m.mu.RLock() + defer m.mu.RUnlock() + + ids := make([]string, 0, len(m.sessions)) + for id := range m.sessions { + ids = append(ids, id) + } + return ids +} + +// Remove removes a session from the manager +func (m *Manager) Remove(id string) { + m.mu.Lock() + delete(m.sessions, id) + m.mu.Unlock() +} diff --git a/internal/session/session.go b/internal/session/session.go deleted file mode 100644 index d32507c..0000000 --- a/internal/session/session.go +++ /dev/null @@ -1,625 +0,0 @@ -// Package session manages persistent kiro-cli PTY sessions. -package session - -import ( - "bytes" - "fmt" - "io" - "log" - "os" - "os/exec" - "path/filepath" - "regexp" - "slices" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/creack/pty" -) - -var sessionCounter uint64 - -// Session represents a persistent kiro-cli chat session. -type Session struct { - ID string - Alias string - Cwd string - WinID int - Pid int - State string // "starting", "idle", "running", or "error: ..." - - cmd *exec.Cmd - ptmx *os.File - mu sync.Mutex - - // Channel to receive data from the reader goroutine - dataCh chan []byte - // Channel to signal stop - stopCh chan struct{} - // Accumulated output - output bytes.Buffer -} - -// Manager holds all active sessions. -type Manager struct { - sessions map[string]*Session - mu sync.RWMutex -} - -// NewManager creates a session manager. -func NewManager() *Manager { - return &Manager{sessions: make(map[string]*Session)} -} - -// New creates a new kiro-cli session in the given working directory. -func (m *Manager) New(cwd string) (*Session, error) { - id := fmt.Sprintf("%d", atomic.AddUint64(&sessionCounter, 1)) - - cmd := exec.Command("kiro-cli", "chat", "--trust-all-tools") - cmd.Dir = cwd - cmd.Env = append(os.Environ(), "TERM=xterm-256color") - - ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 40, Cols: 120}) - if err != nil { - return nil, fmt.Errorf("failed to start pty: %w", err) - } - - s := &Session{ - ID: id, - Cwd: cwd, - Pid: cmd.Process.Pid, - State: "starting", - cmd: cmd, - ptmx: ptmx, - dataCh: make(chan []byte, 100), - stopCh: make(chan struct{}), - } - - // Start single reader goroutine - go s.reader() - - m.mu.Lock() - m.sessions[id] = s - m.mu.Unlock() - - // Wait for initial prompt - look for "> " which appears at end of prompt - err = s.waitForPattern("> ", 30*time.Second) - if err != nil { - log.Printf("[session %s] warning: %v", s.ID, err) - } - s.State = "idle" - s.output.Reset() - log.Printf("[session %s] ready", s.ID) - - return s, nil -} - -// reader continuously reads from PTY and sends to dataCh -func (s *Session) reader() { - buf := make([]byte, 4096) - for { - n, err := s.ptmx.Read(buf) - if err != nil { - if err != io.EOF { - log.Printf("[session %s] reader error: %v", s.ID, err) - } - close(s.dataCh) - // Reap the process to avoid zombies - if s.cmd != nil && s.cmd.Process != nil { - s.cmd.Wait() - } - s.State = "exited" - s.Pid = 0 - return - } - if n > 0 { - data := make([]byte, n) - copy(data, buf[:n]) - s.dataCh <- data - } - } -} - -// waitForPattern waits until the output contains the pattern -func (s *Session) waitForPattern(pattern string, timeout time.Duration) error { - deadline := time.Now().Add(timeout) - ansiRegex := regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]`) - for { - select { - case data, ok := <-s.dataCh: - if !ok { - return fmt.Errorf("session closed") - } - s.output.Write(data) - // Just strip ANSI, don't filter - clean := ansiRegex.ReplaceAllString(s.output.String(), "") - if strings.Contains(clean, pattern) { - return nil - } - case <-time.After(time.Until(deadline)): - return fmt.Errorf("timeout waiting for %q", pattern) - } - } -} - -// waitForSecondPrompt waits for the response to complete (normal prompts end with "Time:") -func (s *Session) waitForSecondPrompt() error { - for { - select { - case <-s.stopCh: - return fmt.Errorf("interrupted") - case data, ok := <-s.dataCh: - if !ok { - return fmt.Errorf("session closed") - } - s.output.Write(data) - log.Printf("[session %s] +%d bytes, total %d", s.ID, len(data), s.output.Len()) - - // Kiro prints "Time:" at the end of every response - if strings.Contains(s.output.String(), "Time:") { - // Drain any remaining data in channel - for { - select { - case extra, ok := <-s.dataCh: - if ok { - s.output.Write(extra) - } - default: - log.Printf("[session %s] response complete, %d bytes", s.ID, s.output.Len()) - return nil - } - } - } - } - } -} - -// waitForCommandComplete waits for slash command output (ends with next prompt, not "Time:") -func (s *Session) waitForCommandComplete() error { - ansiRegex := regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]`) - var contentLen int - - for { - select { - case <-s.stopCh: - return fmt.Errorf("interrupted") - case data, ok := <-s.dataCh: - if !ok { - return fmt.Errorf("session closed") - } - s.output.Write(data) - out := s.output.String() - log.Printf("[session %s] +%d bytes, total %d", s.ID, len(data), len(out)) - - // Strip ANSI and \r for checking - clean := ansiRegex.ReplaceAllString(out, "") - clean = strings.ReplaceAll(clean, "\r", "") - - // Wait for substantial content before looking for end prompt - if len(clean) > contentLen+50 { - contentLen = len(clean) - } - - // Look for a new prompt line after we have content - // Prompt pattern: newline followed by [something] then > - if contentLen > 200 && strings.Contains(clean, "\n[") { - // Check if there's a prompt after content - lines := strings.Split(clean, "\n") - for i := len(lines) - 1; i >= 0; i-- { - if strings.Contains(lines[i], "] ") && strings.Contains(lines[i], ">") { - // Found end prompt, drain and return - for { - select { - case extra, ok := <-s.dataCh: - if ok { - s.output.Write(extra) - } - default: - log.Printf("[session %s] command complete, %d bytes", s.ID, s.output.Len()) - return nil - } - } - } - } - } - } - } -} - -func (s *Session) cleanedOutput() string { - out := s.output.String() - // Strip ANSI - clean := regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]`).ReplaceAllString(out, "") - clean = strings.ReplaceAll(clean, "\r", "\n") - - lines := strings.Split(clean, "\n") - var result []string - inResponse := false - - for _, line := range lines { - trimmed := strings.TrimSpace(line) - - // Check for response marker FIRST - it may be on same line as spinner - if idx := strings.Index(line, "> "); idx >= 0 { - // Make sure it's the response marker, not part of prompt (prompts have [agent]) - if !strings.Contains(line, "[") || !strings.Contains(line, "]") { - inResponse = true - text := strings.TrimSpace(line[idx+2:]) - if text != "" { - result = append(result, text) - } - continue - } - } - - // Skip noise - if strings.Contains(line, "Thinking...") || - strings.Contains(line, "▸ Time:") || - (strings.Contains(line, "[") && strings.Contains(line, "]") && strings.Contains(line, ">")) || - strings.HasPrefix(trimmed, "⠋") || - strings.HasPrefix(trimmed, "⠙") || - strings.HasPrefix(trimmed, "⠹") || - strings.HasPrefix(trimmed, "⠸") || - strings.HasPrefix(trimmed, "⠼") || - strings.HasPrefix(trimmed, "⠴") || - strings.HasPrefix(trimmed, "⠦") || - strings.HasPrefix(trimmed, "⠧") || - strings.HasPrefix(trimmed, "⠇") || - strings.HasPrefix(trimmed, "⠏") || - strings.HasPrefix(trimmed, "⢀") || - strings.HasPrefix(trimmed, "⡀") || - strings.HasPrefix(trimmed, "✓") { - continue - } - - // Keep hooks lines - if strings.Contains(line, "hooks finished") { - result = append(result, trimmed) - continue - } - - if inResponse && trimmed != "" { - result = append(result, line) - } - } - - return strings.Join(result, "\n") -} - -// cleanedCommandOutput extracts output from slash commands (no "> " marker) -func (s *Session) cleanedCommandOutput() string { - out := s.output.String() - // Strip ANSI - clean := regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]`).ReplaceAllString(out, "") - clean = strings.ReplaceAll(clean, "\r", "\n") - - lines := strings.Split(clean, "\n") - var result []string - collecting := false - - for _, line := range lines { - trimmed := strings.TrimSpace(line) - - if trimmed == "" { - continue - } - - // Detect prompt lines: [agent] > - isPrompt := strings.Contains(line, "[") && strings.Contains(line, "]") && strings.Contains(line, ">") - - if isPrompt { - if collecting { - // Second prompt = end - break - } - // Skip prompt lines but don't start collecting yet - continue - } - - // Skip command echo and noise - if strings.HasPrefix(trimmed, "/") || - strings.Contains(trimmed, "Switching to") || - strings.HasPrefix(trimmed, "⠋") || - strings.HasPrefix(trimmed, "⠙") || - strings.HasPrefix(trimmed, "⠹") || - strings.HasPrefix(trimmed, "⠸") || - strings.HasPrefix(trimmed, "⠼") || - strings.HasPrefix(trimmed, "⠴") || - strings.HasPrefix(trimmed, "⠦") || - strings.HasPrefix(trimmed, "⠧") || - strings.HasPrefix(trimmed, "⠇") || - strings.HasPrefix(trimmed, "⠏") || - strings.HasPrefix(trimmed, "⢀") || - strings.HasPrefix(trimmed, "⡀") { - continue - } - - // Collect any non-noise content - collecting = true - result = append(result, trimmed) - } - - return strings.Join(result, "\n") -} - -// Get returns a session by ID. -func (m *Manager) Get(id string) *Session { - m.mu.RLock() - defer m.mu.RUnlock() - return m.sessions[id] -} - -// List returns all session IDs. -func (m *Manager) List() []string { - m.mu.RLock() - defer m.mu.RUnlock() - ids := make([]string, 0, len(m.sessions)) - for id := range m.sessions { - ids = append(ids, id) - } - return ids -} - -// Remove removes a session from the manager. -func (m *Manager) Remove(id string) { - m.mu.Lock() - delete(m.sessions, id) - m.mu.Unlock() -} - -// supportedCommands lists non-interactive slash commands that work via pty. -var supportedCommands = []string{"/chat load", "/chat save", "/help", "/agent list"} - -// Send writes a prompt to the session and waits for response. -func (s *Session) Send(prompt string) (string, error) { - s.mu.Lock() - defer s.mu.Unlock() - - if s.ptmx == nil { - return "", fmt.Errorf("session not running") - } - - if s.State == "starting" { - return "", fmt.Errorf("session still starting, please wait") - } - - // Reset stopCh for this request - select { - case <-s.stopCh: - // was closed, need new one - s.stopCh = make(chan struct{}) - default: - } - - // Check if slash command is supported - if strings.HasPrefix(prompt, "/") { - supported := false - for _, cmd := range supportedCommands { - if strings.HasPrefix(prompt, cmd) { - supported = true - break - } - } - if !supported { - log.Printf("[session %s] unsupported command %q, supported: %v", s.ID, prompt, supportedCommands) - return "", fmt.Errorf("unsupported command, supported: %v", supportedCommands) - } - } - - s.State = "running" - s.output.Reset() - - isCommand := strings.HasPrefix(prompt, "/") - - log.Printf("[session %s] sending: %q", s.ID, prompt) - if _, err := s.ptmx.Write([]byte(prompt + "\r")); err != nil { - s.State = "idle" - return "", fmt.Errorf("write failed: %w", err) - } - - // Slash commands wait for next prompt, normal prompts wait for "Time:" - var err error - if isCommand { - err = s.waitForCommandComplete() - } else { - err = s.waitForSecondPrompt() - } - if err != nil { - s.State = "idle" - return "", err - } - - s.State = "idle" - - var result string - if isCommand { - result = s.cleanedCommandOutput() - } else { - result = s.cleanedOutput() - } - log.Printf("[session %s] response: %d bytes", s.ID, len(result)) - return result, nil -} - -// extractResponse extracts the actual response from the output -func (s *Session) extractResponse() string { - clean := s.cleanedOutput() - - // Find content between first "] > ...\n" and last "] > " - lines := strings.Split(clean, "\n") - var result []string - inResponse := false - - for _, line := range lines { - if strings.Contains(line, "] > ") { - if inResponse { - break // Second prompt, stop - } - inResponse = true - continue - } - if inResponse { - // Skip spinner and hook lines - if strings.Contains(line, "Thinking...") || - strings.Contains(line, "hooks finished") || - strings.HasPrefix(strings.TrimSpace(line), "▸") { - continue - } - if trimmed := strings.TrimSpace(line); trimmed != "" { - result = append(result, line) - } - } - } - return strings.Join(result, "\n") -} - -// SendCtl sends a control command (like /chat save, /chat load). -func (s *Session) SendCtl(cmd string) error { - _, err := s.Send(cmd) - return err -} - -// Stop sends SIGINT to interrupt the current operation. -func (s *Session) Stop() error { - if s.ptmx == nil { - return fmt.Errorf("session not running") - } - // Signal waiters to abort - close channel, will be recreated by Send() - select { - case <-s.stopCh: - // already closed - default: - close(s.stopCh) - } - // Write ETX (Ctrl+C) to PTY - _, err := s.ptmx.Write([]byte{0x03}) - return err -} - -// Kill terminates the session. -func (s *Session) Kill() error { - s.mu.Lock() - defer s.mu.Unlock() - - if s.cmd != nil && s.cmd.Process != nil { - s.cmd.Process.Kill() - s.cmd.Wait() - } - if s.ptmx != nil { - s.ptmx.Close() - s.ptmx = nil - } - s.Pid = 0 - s.State = "idle" - return nil -} - -// Output returns the output from the last command. -func (s *Session) Output() string { - s.mu.Lock() - defer s.mu.Unlock() - return s.cleanedOutput() -} - -// Err returns accumulated error/debug output. -func (s *Session) Err() string { - return "" -} - -// SessionsDir returns the path to the sessions storage directory. -func SessionsDir() string { - home, _ := os.UserHomeDir() - dir := filepath.Join(home, ".config", "acme-q", "sessions") - os.MkdirAll(dir, 0755) - return dir -} - -// SavePath returns the save file path for a session. -func (s *Session) SavePath() string { - return filepath.Join(SessionsDir(), s.ID+".json") -} - -// GenerateSmartFilename creates a descriptive filename from saved conversation JSON. -// It extracts first few meaningful words from user prompts. -// windowBody is optional extra text (from chat window) to search for content. -func GenerateSmartFilename(jsonPath string, windowBody string) (string, error) { - data, err := os.ReadFile(jsonPath) - if err != nil { - return "", err - } - - // Extract user prompt text - try JSON first, then window body - var summaryText string - - // Try to find user prompts in the JSON - promptRe := regexp.MustCompile(`"prompt"\s*:\s*"([^"]{10,200})"`) - if matches := promptRe.FindAllStringSubmatch(string(data), 5); len(matches) > 0 { - for _, m := range matches { - text := m[1] - // Skip if it looks like JSON/tool output - if !strings.Contains(text, "exit_status") && !strings.Contains(text, "\\n\\t") { - summaryText = text - break - } - } - } - - // If no prompt in JSON, try to extract from window body (first USER: with content) - if summaryText == "" && windowBody != "" { - if idx := strings.Index(windowBody, "USER:"); idx >= 0 { - userText := strings.TrimSpace(windowBody[idx+5:]) - // Take up to next section marker - if end := strings.Index(userText, "\n\nA:"); end > 0 { - userText = userText[:end] - } else if end := strings.Index(userText, "\n\nASSISTANT:"); end > 0 { - userText = userText[:end] - } else if end := strings.Index(userText, "\n---"); end > 0 { - userText = userText[:end] - } - userText = strings.TrimSpace(userText) - if len(userText) > 200 { - userText = userText[:200] - } - if len(userText) > 5 { - summaryText = userText - } - } - } - - // Extract words for filename - var parts []string - if summaryText != "" { - summaryText = strings.ToLower(summaryText) - wordRe := regexp.MustCompile(`[a-z]+`) - words := wordRe.FindAllString(summaryText, 10) - - stopWords := []string{"a", "an", "and", "are", "as", "at", "be", "by", "can", "do", "for", "from", "has", "have", "he", "her", "his", "how", "if", "in", "into", "is", "it", "its", "just", "me", "my", "no", "not", "of", "on", "or", "our", "out", "so", "some", "than", "that", "the", "their", "them", "then", "there", "these", "they", "this", "to", "up", "us", "was", "we", "what", "when", "which", "who", "will", "with", "would", "you", "your"} - for _, w := range words { - if len(w) > 2 && !slices.Contains(stopWords, w) { - parts = append(parts, w) - if len(parts) >= 5 { - break - } - } - } - } - - if len(parts) == 0 { - parts = append(parts, "chat") - } - - filename := time.Now().Format("20060102T150405") + "-" + strings.Join(parts, "-") + ".json" - return filepath.Join(SessionsDir(), filename), nil -} - -// Save persists the session conversation. -func (s *Session) Save() error { - return s.SendCtl("/chat save " + s.SavePath()) -} - -// Load restores a session conversation from file. -func (s *Session) Load(path string) error { - return s.SendCtl("/chat load " + path) -} diff --git a/main.go b/main.go index 380dfc6..77fd3f1 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,13 @@ -// Q - Acme interface for kiro-cli chat sessions via 9P +// Q - Acme interface for chat backends via 9P package main import ( + "acme-q/internal/backend" + "acme-q/internal/backend/pty" + "acme-q/internal/backends" "acme-q/internal/p9" "acme-q/internal/session" + "context" "fmt" "log" "os" @@ -20,8 +24,14 @@ import ( const windowName = "/Q/" +// Type alias to work around Go parser limitation +type Session = backend.Session + func main() { - mgr := session.NewManager() + // Create backend (kiro-cli for now, could be configurable) + backend := backends.NewKiroCLI() + + mgr := session.NewManager(backend) srv, err := p9.NewServer(mgr) if err != nil { @@ -30,20 +40,23 @@ func main() { defer srv.Close() // Rename chat window when alias changes - srv.OnAliasChange = func(sess *session.Session) { - if sess.WinID > 0 { - w, err := acme.Open(sess.WinID, nil) + var aliasChangeFunc func(Session) + aliasChangeFunc = func(sess Session) { + meta := sess.Metadata() + if meta.WinID > 0 { + w, err := acme.Open(meta.WinID, nil) if err != nil { return } defer w.CloseFiles() - name := sess.Alias + name := meta.Alias if name == "" { - name = sess.ID + name = sess.ID() } - w.Name(filepath.Join(sess.Cwd, fmt.Sprintf("+Chat.%s", name))) + w.Name(filepath.Join(meta.Cwd, fmt.Sprintf("+Chat.%s", name))) } } + srv.OnAliasChange = aliasChangeFunc w, err := acme.New() if err != nil { @@ -93,12 +106,12 @@ func main() { w.Fprintf("body", "Error: %v\n", err) continue } - chatWin, err := openChatWindow(sess) + _, err = openChatWindow(sess) if err != nil { w.Fprintf("body", "Error opening chat: %v\n", err) continue } - sess.WinID = chatWin.ID() + // WinID is set inside openChatWindow refreshList(w, mgr) case "Open": if arg == "" { @@ -110,12 +123,12 @@ func main() { w.Fprintf("body", "Session not found: %s\n", arg) continue } - chatWin, err := openChatWindow(sess) + _, err := openChatWindow(sess) if err != nil { w.Fprintf("body", "Error opening chat: %v\n", err) continue } - sess.WinID = chatWin.ID() + // WinID is set inside openChatWindow case "Kill": if arg == "" { w.Fprintf("body", "Usage: Kill \n") @@ -129,10 +142,13 @@ func main() { // Find session by pid and kill it for _, id := range mgr.List() { sess := mgr.Get(id) - if sess != nil && sess.Pid == pid { - sess.Kill() - mgr.Remove(id) - break + if sess != nil { + meta := sess.Metadata() + if meta.Pid == pid { + sess.Close() + mgr.Remove(id) + break + } } } refreshList(w, mgr) @@ -156,23 +172,18 @@ func main() { log.Printf("[/Q/] Look: text=%q", text) if sess := mgr.Get(text); sess != nil { // Try to focus existing window, or open new one - if sess.WinID > 0 { + meta := sess.Metadata() + if meta.WinID > 0 { // Check if window still exists by trying to open it - if aw, err := acme.Open(sess.WinID, nil); err == nil { + if aw, err := acme.Open(meta.WinID, nil); err == nil { aw.Ctl("show") aw.CloseFiles() } else { // Window gone, open new one - chatWin, err := openChatWindow(sess) - if err == nil { - sess.WinID = chatWin.ID() - } + openChatWindow(sess) } } else { - chatWin, err := openChatWindow(sess) - if err == nil { - sess.WinID = chatWin.ID() - } + openChatWindow(sess) } } else { w.WriteEvent(e) @@ -193,36 +204,38 @@ func refreshList(w *acme.Win, mgr *session.Manager) { if sess == nil { continue } + meta := sess.Metadata() // Remove dead sessions (pid=0 or process not running) - if sess.Pid == 0 { + if meta.Pid == 0 { mgr.Remove(id) continue } - if err := syscall.Kill(sess.Pid, 0); err != nil { + if err := syscall.Kill(meta.Pid, 0); err != nil { mgr.Remove(id) continue } - alias := sess.Alias + alias := meta.Alias if alias == "" { alias = "-" } - cwd := sess.Cwd + cwd := meta.Cwd if len(cwd) > 80 { cwd = "..." + cwd[len(cwd)-77:] } - buf.WriteString(fmt.Sprintf("%-5s %-9s %-8d %-16s %s\n", sess.ID, sess.State, sess.Pid, alias, cwd)) + buf.WriteString(fmt.Sprintf("%-5s %-9s %-8d %-16s %s\n", sess.ID(), sess.State(), meta.Pid, alias, cwd)) } w.Addr(",") w.Write("data", []byte(buf.String())) w.Ctl("clean") } -func openChatWindow(sess *session.Session) (*acme.Win, error) { - displayName := sess.Alias +func openChatWindow(sess backend.Session) (*acme.Win, error) { + meta := sess.Metadata() + displayName := meta.Alias if displayName == "" { - displayName = sess.ID + displayName = sess.ID() } - name := filepath.Join(sess.Cwd, fmt.Sprintf("+Chat.%s", displayName)) + name := filepath.Join(meta.Cwd, fmt.Sprintf("+Chat.%s", displayName)) w, err := acme.New() if err != nil { @@ -230,15 +243,20 @@ func openChatWindow(sess *session.Session) (*acme.Win, error) { } w.Name(name) w.Write("tag", []byte("Send Stop Alias Save Kill Sessions ")) - w.Fprintf("body", "# Session %s\n# cwd: %s\n\nUSER:\n", sess.ID, sess.Cwd) + w.Fprintf("body", "# Session %s\n# cwd: %s\n\nUSER:\n", sess.ID(), meta.Cwd) w.Ctl("clean") + // Set window ID on session (if it's a PTY session) + if ptySess, ok := sess.(*pty.Session); ok { + ptySess.SetWinID(w.ID()) + } + go handleChatWindow(w, sess) return w, nil } -func handleChatWindow(w *acme.Win, sess *session.Session) { +func handleChatWindow(w *acme.Win, sess backend.Session) { defer w.CloseFiles() for e := range w.EventChan() { @@ -257,27 +275,29 @@ func handleChatWindow(w *acme.Win, sess *session.Session) { case "Send": go sendPrompt(w, sess) case "Stop": - if err := sess.Stop(); err != nil { - log.Printf("[session %s] Stop error: %v", sess.ID, err) + ctx := context.Background() + if err := sess.Stop(ctx); err != nil { + log.Printf("[session %s] Stop error: %v", sess.ID(), err) } else { - log.Printf("[session %s] Sent CTRL+C", sess.ID) + log.Printf("[session %s] Sent CTRL+C", sess.ID()) } case "Alias": if arg == "" { - log.Printf("[session %s] Usage: Alias ", sess.ID) + log.Printf("[session %s] Usage: Alias ", sess.ID()) continue } // Validate alias matched, _ := regexp.MatchString(`^[A-Za-z0-9_-]+$`, arg) if !matched { - log.Printf("[session %s] Invalid alias: must match [A-Za-z0-9_-]+", sess.ID) + log.Printf("[session %s] Invalid alias: must match [A-Za-z0-9_-]+", sess.ID()) continue } - sess.Alias = arg + sess.SetAlias(arg) // Rename window - newName := filepath.Join(sess.Cwd, fmt.Sprintf("+Chat.%s", arg)) + meta := sess.Metadata() + newName := filepath.Join(meta.Cwd, fmt.Sprintf("+Chat.%s", arg)) w.Name(newName) - log.Printf("[session %s] alias set to %s", sess.ID, arg) + log.Printf("[session %s] alias set to %s", sess.ID(), arg) case "Save": // Grab first part of window content for filename hints var bodyText string @@ -288,34 +308,25 @@ func handleChatWindow(w *acme.Win, sess *session.Session) { bodyText = string(body) } go func() { - // Save to temp file first - tempPath := sess.SavePath() + var savePath string + var err error if arg != "" { - tempPath = arg + // Save to explicit path + savePath = arg + err = backends.SendCtl(sess, "/chat save "+savePath) + } else { + // Save with smart filename + savePath, err = backends.SaveWithSmartFilename(sess, sess.ID(), bodyText) } - if err := sess.SendCtl("/chat save " + tempPath); err != nil { - log.Printf("[session %s] Error saving: %v", sess.ID, err) + if err != nil { + log.Printf("[session %s] Error saving: %v", sess.ID(), err) return } - // If no explicit path given, try to generate smart filename - if arg == "" { - smartPath, err := session.GenerateSmartFilename(tempPath, bodyText) - if err != nil { - log.Printf("[session %s] Smart filename failed: %v, keeping %s", sess.ID, err, tempPath) - } else if smartPath != tempPath { - if err := os.Rename(tempPath, smartPath); err == nil { - log.Printf("[session %s] Saved to %s", sess.ID, smartPath) - return - } else { - log.Printf("[session %s] Rename failed: %v", sess.ID, err) - } - } - } - log.Printf("[session %s] Saved to %s", sess.ID, tempPath) + log.Printf("[session %s] Saved to %s", sess.ID(), savePath) }() case "Kill": - sess.Kill() - log.Printf("[session %s] Session killed", sess.ID) + sess.Close() + log.Printf("[session %s] Session killed", sess.ID()) return case "Sessions": openSessionsWindow(w, sess) @@ -328,17 +339,17 @@ func handleChatWindow(w *acme.Win, sess *session.Session) { } } -func sendPrompt(w *acme.Win, sess *session.Session) { +func sendPrompt(w *acme.Win, sess backend.Session) { body, err := w.ReadAll("body") if err != nil { - log.Printf("[session %s] Error reading body: %v", sess.ID, err) + log.Printf("[session %s] Error reading body: %v", sess.ID(), err) return } content := string(body) idx := strings.LastIndex(content, "USER:") if idx < 0 { - log.Printf("[session %s] No USER: section found", sess.ID) + log.Printf("[session %s] No USER: section found", sess.ID()) return } @@ -349,9 +360,10 @@ func sendPrompt(w *acme.Win, sess *session.Session) { w.Fprintf("body", "\n\nASSISTANT:\n") - response, err := sess.Send(prompt) + ctx := context.Background() + response, err := sess.Send(ctx, prompt) if err != nil { - log.Printf("[session %s] Error: %v", sess.ID, err) + log.Printf("[session %s] Error: %v", sess.ID(), err) } else { w.Fprintf("body", "%s\n", response) } @@ -360,9 +372,10 @@ func sendPrompt(w *acme.Win, sess *session.Session) { w.Ctl("clean") } -func openSessionsWindow(chatWin *acme.Win, sess *session.Session) { - dir := session.SessionsDir() - name := filepath.Join(sess.Cwd, fmt.Sprintf("+Sessions.%s", sess.ID)) +func openSessionsWindow(chatWin *acme.Win, sess backend.Session) { + dir := backends.SessionsDir() + meta := sess.Metadata() + name := filepath.Join(meta.Cwd, fmt.Sprintf("+Sessions.%s", sess.ID())) w, err := acme.New() if err != nil { @@ -389,7 +402,8 @@ func openSessionsWindow(chatWin *acme.Win, sess *session.Session) { // Run /chat load and display output in chat window go func(p string) { chatWin.Fprintf("body", "\n\nASSISTANT:\n") - response, err := sess.Send("/chat load " + p) + ctx := context.Background() + response, err := sess.Send(ctx, "/chat load "+p) if err != nil { chatWin.Fprintf("body", "Error loading: %v\n", err) } else { diff --git a/mkfile b/mkfile index dc16523..c8940c5 100644 --- a/mkfile +++ b/mkfile @@ -3,10 +3,9 @@ INSTALL_PATH=$HOME/bin all:V: install build:V: - go build -o $INSTALL_PATH/Q . - go build -o $INSTALL_PATH/Q-Login ./cmd/Q-Login + go build -o $INSTALL_PATH/Assist . install:V: build clean:V: - rm -f $INSTALL_PATH/Q $INSTALL_PATH/Q-Login + rm -f $INSTALL_PATH/Assist