refactor in progress
This commit is contained in:
parent
d8dccd94b3
commit
7ca987e6eb
Binary file not shown.
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
166
main.go
166
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 <pid>\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 <name>", sess.ID)
|
||||
log.Printf("[session %s] Usage: Alias <name>", 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 {
|
||||
|
|
|
|||
Reference in New Issue