diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 92ebcaf..938de49 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -23,7 +23,9 @@ var Commands = []struct { {"changes", "List file changes made by the session (interactive on TTY)"}, {"images", "List image paths from the session (interactive on TTY)"}, {"conversation", "List conversation turns from the Claude session (interactive on TTY)"}, + {"info", "Show the matched Claude session metadata"}, {"sessions", "List session IDs with metadata (use --pick for TUI JSON picker)"}, + {"config", "View/edit ccx config and get/set dot-path values"}, {"help", "Show available commands and usage"}, } @@ -45,6 +47,9 @@ func Run(command, claudeDir string, plain bool) (*RunResult, error) { if command == "sessions" { return nil, RunSessions(claudeDir, false) } + if command == "info" { + return nil, RunInfo(claudeDir) + } filePath, sessID, err := findSessionFile(claudeDir) if err != nil { @@ -125,7 +130,8 @@ func printHelp() { fmt.Fprintf(os.Stderr, "ccx — Claude Code Explorer\n\n") fmt.Fprintf(os.Stderr, "Usage:\n") fmt.Fprintf(os.Stderr, " ccx Launch the TUI\n") - fmt.Fprintf(os.Stderr, " ccx Run a subcommand\n\n") + fmt.Fprintf(os.Stderr, " ccx Run a subcommand\n") + fmt.Fprintf(os.Stderr, " ccx config ...\n\n") fmt.Fprintf(os.Stderr, "Commands:\n") for _, c := range Commands { fmt.Fprintf(os.Stderr, " %-10s %s\n", c.Name, c.Desc) @@ -140,7 +146,11 @@ func printHelp() { fmt.Fprintf(os.Stderr, " ccx files Interactive file picker\n") fmt.Fprintf(os.Stderr, " ccx changes Interactive changed-files picker\n") fmt.Fprintf(os.Stderr, " ccx images Interactive image picker\n") - fmt.Fprintf(os.Stderr, " ccx conversation Interactive conversation picker\n\n") + fmt.Fprintf(os.Stderr, " ccx conversation Interactive conversation picker\n") + fmt.Fprintf(os.Stderr, " ccx info Show current matched session metadata\n") + fmt.Fprintf(os.Stderr, " ccx config view Print ~/.config/ccx/config.yaml\n") + fmt.Fprintf(os.Stderr, " ccx config edit Open config in $EDITOR\n") + fmt.Fprintf(os.Stderr, " ccx config set remote.pod_name ccx-worker\n\n") fmt.Fprintf(os.Stderr, "Picker keys:\n") fmt.Fprintf(os.Stderr, " ↵ enter Jump to message in full ccx TUI\n") fmt.Fprintf(os.Stderr, " o Open URL in browser\n") @@ -311,6 +321,32 @@ func RunSessions(claudeDir string, all bool) error { return nil } +func RunInfo(claudeDir string) error { + _, sessID, err := findSessionFile(claudeDir) + if err != nil { + return err + } + sess, ok := session.FindSessionByID(claudeDir, sessID) + if !ok { + return fmt.Errorf("session %s not found", sessID) + } + fmt.Fprintf(os.Stdout, "id\t%s\n", sess.ID) + fmt.Fprintf(os.Stdout, "short_id\t%s\n", sess.ShortID) + fmt.Fprintf(os.Stdout, "project\t%s\n", sess.ProjectName) + fmt.Fprintf(os.Stdout, "project_path\t%s\n", sess.ProjectPath) + fmt.Fprintf(os.Stdout, "transcript\t%s\n", sess.FilePath) + fmt.Fprintf(os.Stdout, "modified\t%s\n", sess.ModTime.Format("2006-01-02 15:04:05")) + fmt.Fprintf(os.Stdout, "messages\t%d\n", sess.MsgCount) + if sess.GitBranch != "" { + fmt.Fprintf(os.Stdout, "git_branch\t%s\n", sess.GitBranch) + } + if sess.FirstPrompt != "" { + prompt := strings.ReplaceAll(sess.FirstPrompt, "\n", " ") + fmt.Fprintf(os.Stdout, "first_prompt\t%s\n", prompt) + } + return nil +} + // findSessionFile detects Claude sessions in the same tmux window. // If multiple sessions are found, prompts the user to choose one. func findSessionFile(claudeDir string) (string, string, error) { diff --git a/internal/remote/config.go b/internal/remote/config.go index 1f41187..ee32dda 100644 --- a/internal/remote/config.go +++ b/internal/remote/config.go @@ -4,6 +4,7 @@ import ( "crypto/rand" "fmt" "os/exec" + "runtime" "strings" ) @@ -18,24 +19,34 @@ func CurrentContext() (string, error) { // Config holds settings for a remote Claude execution. type Config struct { - Context string `yaml:"context"` // kubectl --context (required) - Namespace string `yaml:"namespace"` // target namespace - Image string `yaml:"image"` // container image - LocalDir string `yaml:"local_dir"` // local workdir to sync - GitRepo string `yaml:"git_repo"` // repo URL to clone (fallback if no local_dir) - GitBranch string `yaml:"git_branch"` // branch to checkout - WorkDir string `yaml:"work_dir"` // remote working directory - Prompt string `yaml:"-"` // initial prompt (not persisted) - CPULimit string `yaml:"cpu_limit"` // e.g. "2" - MemoryLimit string `yaml:"memory_limit"` // e.g. "4Gi" - Arch string `yaml:"arch"` // "amd64" or "arm64" - EnvVars map[string]string `yaml:"env_vars"` // extra env vars to inject into pod - MirrorEnv []string `yaml:"mirror_env"` // local env var names to mirror to pod - Labels map[string]string `yaml:"labels"` // extra pod labels - Tolerations []string `yaml:"tolerations"` // toleration keys - ClaudeArgs []string `yaml:"claude_args"` // extra args for claude CLI (e.g. --model, --allowedTools) - SessionID string `yaml:"-"` // session ID to resume - SessionFile string `yaml:"-"` // local path to session JSONL + Context string `yaml:"context"` // kubectl --context (required) + Namespace string `yaml:"namespace"` // target namespace + PodName string `yaml:"pod_name"` // fixed pod name to reuse (optional) + Container string `yaml:"container"` // target container name (optional) + RemoteUser string `yaml:"remote_user"` // user to run Claude as + RemoteHome string `yaml:"remote_home"` // remote user's home directory + Image string `yaml:"image"` // container image + LocalDir string `yaml:"local_dir"` // local workdir to sync + RemoteProjectPath string `yaml:"remote_project_path"` // project path key to use for Claude session JSONL + GitRepo string `yaml:"git_repo"` // repo URL to clone (fallback if no local_dir) + GitBranch string `yaml:"git_branch"` // branch to checkout + WorkDir string `yaml:"work_dir"` // remote working directory + WorkDirTemplate string `yaml:"work_dir_template"` // optional template for per-session workdirs + Prompt string `yaml:"-"` // initial prompt (not persisted) + CPULimit string `yaml:"cpu_limit"` // e.g. "2" + MemoryLimit string `yaml:"memory_limit"` // e.g. "4Gi" + Arch string `yaml:"arch"` // "amd64" or "arm64" + EnvVars map[string]string `yaml:"env_vars"` // extra env vars to inject into pod + MirrorEnv []string `yaml:"mirror_env"` // local env var names to mirror to pod + Labels map[string]string `yaml:"labels"` // extra pod labels + Tolerations []string `yaml:"tolerations"` // toleration keys + ClaudeArgs []string `yaml:"claude_args"` // extra args for claude CLI (e.g. --model, --allowedTools) + SessionID string `yaml:"-"` // session ID to resume + SessionFile string `yaml:"-"` // local path to session JSONL + // WorkdirTarball, when non-nil, is uploaded verbatim into WorkDir on the pod + // instead of re-tarring LocalDir. Used by snapshot restore / fork to avoid + // host-side changes leaking into the resumed pod. + WorkdirTarball []byte `yaml:"-"` } // Defaults returns a Config with sensible defaults filled in. @@ -49,6 +60,15 @@ func (c Config) Defaults() Config { if c.Image == "" { c.Image = "ubuntu:24.04" } + if c.Container == "" { + c.Container = "main" + } + if c.RemoteUser == "" { + c.RemoteUser = "claude" + } + if c.RemoteHome == "" { + c.RemoteHome = "/home/" + c.RemoteUser + } if c.GitBranch == "" { c.GitBranch = "main" } @@ -61,9 +81,6 @@ func (c Config) Defaults() Config { if c.MemoryLimit == "" { c.MemoryLimit = "4Gi" } - if c.Arch == "" { - c.Arch = "amd64" - } return c } @@ -81,3 +98,18 @@ func GeneratePodName() string { rand.Read(b) return fmt.Sprintf("ccx-remote-%x", b) } + +// HostArch returns the local machine's GOARCH normalized to the k8s +// kubernetes.io/arch convention (amd64, arm64). +func HostArch() string { + return runtime.GOARCH +} + +// ArchMismatch reports whether cfg.Arch is set and differs from the host arch. +// Comparison is case-insensitive; "" never mismatches. +func (c Config) ArchMismatch() bool { + if c.Arch == "" { + return false + } + return !strings.EqualFold(c.Arch, HostArch()) +} diff --git a/internal/remote/pod.go b/internal/remote/pod.go index d13221e..beafcd8 100644 --- a/internal/remote/pod.go +++ b/internal/remote/pod.go @@ -70,6 +70,9 @@ func podSpec(cfg Config, podName, oauthToken string) ([]byte, error) { }, }, } + if cfg.Arch != "" { + podSpecMap["nodeSelector"] = map[string]string{"kubernetes.io/arch": cfg.Arch} + } if len(tolerations) > 0 { podSpecMap["tolerations"] = tolerations } @@ -127,8 +130,12 @@ func ExecInPod(ctx context.Context, cfg Config, podName string, cmd ...string) ( args := []string{ "--context", cfg.Context, "-n", cfg.Namespace, - "exec", podName, "--", + "exec", podName, + } + if cfg.Container != "" { + args = append(args, "-c", cfg.Container) } + args = append(args, "--") args = append(args, cmd...) c := exec.CommandContext(ctx, "kubectl", args...) return c.CombinedOutput() @@ -154,8 +161,12 @@ func ExecInteractive(cfg Config, podName string, cmd ...string) *exec.Cmd { args := []string{ "--context", cfg.Context, "-n", cfg.Namespace, - "exec", "-it", podName, "--", + "exec", "-it", podName, + } + if cfg.Container != "" { + args = append(args, "-c", cfg.Container) } + args = append(args, "--") args = append(args, cmd...) return exec.Command("kubectl", args...) } diff --git a/internal/remote/session.go b/internal/remote/session.go index e65de2e..11673a2 100644 --- a/internal/remote/session.go +++ b/internal/remote/session.go @@ -3,6 +3,7 @@ package remote import ( "context" "fmt" + "os" "os/exec" "strings" "time" @@ -15,7 +16,7 @@ type Session struct { Config Config PodName string Stream <-chan StreamLine // live output stream (nil until Claude starts) - Status string // current status for display + Status string // current status for display ctx context.Context cancel context.CancelFunc } @@ -34,9 +35,13 @@ func Start(cfg Config, claudeDir, projectPath string) (*Session, <-chan SetupSte steps := make(chan SetupStep, 16) ctx, cancel := context.WithCancel(context.Background()) + podName := cfg.PodName + if podName == "" { + podName = GeneratePodName() + } sess := &Session{ Config: cfg, - PodName: GeneratePodName(), + PodName: podName, Status: "starting", ctx: ctx, cancel: cancel, @@ -57,6 +62,25 @@ func Start(cfg Config, claudeDir, projectPath string) (*Session, <-chan SetupSte return sess, steps } +// Adopt attaches ccx state to an already-running remote pod. It does not sync +// config or workdir; callers should only use it for pods previously created by +// ccx with matching metadata. +func Adopt(cfg Config, podName string) (*Session, <-chan SetupStep) { + cfg = cfg.Defaults() + steps := make(chan SetupStep, 1) + ctx, cancel := context.WithCancel(context.Background()) + sess := &Session{ + Config: cfg, + PodName: podName, + Status: "running", + ctx: ctx, + cancel: cancel, + } + steps <- SetupStep{Done: true, Message: "Reusing existing pod"} + close(steps) + return sess, steps +} + func (s *Session) setup(cfg Config, claudeDir, projectPath string, steps chan<- SetupStep) error { ctx := s.ctx @@ -72,56 +96,76 @@ func (s *Session) setup(cfg Config, claudeDir, projectPath string, steps chan<- return fmt.Errorf("auth: %w", err) } - // Create pod - steps <- SetupStep{Message: fmt.Sprintf("Creating pod %s...", s.PodName)} - if err := CreatePod(ctx, cfg, s.PodName, token); err != nil { - return fmt.Errorf("create pod: %w", err) - } - - // Wait ready - steps <- SetupStep{Message: "Waiting for pod ready..."} - if err := WaitForPod(ctx, cfg, s.PodName, 3*time.Minute); err != nil { - DeletePod(context.Background(), cfg, s.PodName) - return fmt.Errorf("pod not ready: %w", err) - } - - // Create non-root user (--dangerously-skip-permissions blocks root) - steps <- SetupStep{Message: "Creating user..."} - ExecInPod(ctx, cfg, s.PodName, "sh", "-c", - "useradd -m -s /bin/bash claude 2>/dev/null; "+ - "mkdir -p /home/claude/.claude "+cfg.WorkDir+" && "+ - "chown -R claude:claude /home/claude "+cfg.WorkDir+" && "+ - // Write token to file so claude user can source it - "echo \"export CLAUDE_CODE_OAUTH_TOKEN=$CLAUDE_CODE_OAUTH_TOKEN\" > /home/claude/.claude_env && "+ - "chown claude:claude /home/claude/.claude_env && chmod 600 /home/claude/.claude_env") - - // Install prerequisites + Claude Code CLI (as root) - steps <- SetupStep{Message: "Installing Node.js and Claude Code CLI..."} - installCmd := "apt-get update -qq && apt-get install -y -qq curl git > /dev/null 2>&1 && " + - "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - > /dev/null 2>&1 && " + - "apt-get install -y -qq nodejs > /dev/null 2>&1 && " + - "npm install -g @anthropic-ai/claude-code 2>&1 | tail -3" - out, err := ExecInPod(ctx, cfg, s.PodName, "sh", "-c", installCmd) - if err != nil { - steps <- SetupStep{Message: fmt.Sprintf("Install issue: %s", string(out))} + // Create pod or reuse fixed existing pod. + if phase, err := PodPhase(ctx, cfg, s.PodName); err == nil && (phase == "Running" || phase == "Pending") { + steps <- SetupStep{Message: fmt.Sprintf("Reusing pod %s (%s)...", s.PodName, phase)} + } else { + steps <- SetupStep{Message: fmt.Sprintf("Creating pod %s...", s.PodName)} + if err := CreatePod(ctx, cfg, s.PodName, token); err != nil { + return fmt.Errorf("create pod: %w", err) + } + + // Wait ready + steps <- SetupStep{Message: "Waiting for pod ready..."} + if err := WaitForPod(ctx, cfg, s.PodName, 3*time.Minute); err != nil { + DeletePod(context.Background(), cfg, s.PodName) + return fmt.Errorf("pod not ready: %w", err) + } } - // Sync config to claude user's home + // Create/update remote user and auth env. + steps <- SetupStep{Message: "Preparing remote user..."} + tokenExport := "export CLAUDE_CODE_OAUTH_TOKEN=" + token + "\n" + setupUserCmd := fmt.Sprintf( + "id -u %s >/dev/null 2>&1 || useradd -m -s /bin/bash %s 2>/dev/null; "+ + "mkdir -p %s/.claude %s && "+ + "chown -R %s:%s %s %s 2>/dev/null || true && "+ + "printf %%s %s > %s/.claude_env && "+ + "chown %s:%s %s/.claude_env 2>/dev/null || true && chmod 600 %s/.claude_env", + cfg.RemoteUser, cfg.RemoteUser, + cfg.RemoteHome, cfg.WorkDir, + cfg.RemoteUser, cfg.RemoteUser, cfg.RemoteHome, cfg.WorkDir, + shellQuote(tokenExport), cfg.RemoteHome, + cfg.RemoteUser, cfg.RemoteUser, cfg.RemoteHome, cfg.RemoteHome) + ExecInPod(ctx, cfg, s.PodName, "sh", "-c", setupUserCmd) + + // Install prerequisites + Claude Code CLI (as root), but skip on warm worker pods. + steps <- SetupStep{Message: "Checking Claude Code CLI..."} + if _, err := ExecInPod(ctx, cfg, s.PodName, "sh", "-c", "command -v claude >/dev/null 2>&1"); err != nil { + steps <- SetupStep{Message: "Installing Node.js and Claude Code CLI..."} + installCmd := "apt-get update -qq && apt-get install -y -qq curl git > /dev/null 2>&1 && " + + "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - > /dev/null 2>&1 && " + + "apt-get install -y -qq nodejs > /dev/null 2>&1 && " + + "npm install -g @anthropic-ai/claude-code 2>&1 | tail -3" + out, err := ExecInPod(ctx, cfg, s.PodName, "sh", "-c", installCmd) + if err != nil { + steps <- SetupStep{Message: fmt.Sprintf("Install issue: %s", string(out))} + } + } + + // Sync config to remote user's home. steps <- SetupStep{Message: "Syncing config..."} - configTar, err := CreateConfigTarball(claudeDir, projectPath, cfg.WorkDir, cfg.SessionFile) + remoteProjectPath := cfg.RemoteProjectPath + if remoteProjectPath == "" { + remoteProjectPath = cfg.WorkDir + } + configTar, err := CreateConfigTarball(claudeDir, projectPath, remoteProjectPath, cfg.SessionFile) if err == nil && len(configTar) > 0 { - UploadTarball(ctx, cfg, s.PodName, "main", "/home/claude", configTar) - // Fix ownership - ExecInPod(ctx, cfg, s.PodName, "chown", "-R", "claude:claude", "/home/claude/.claude", "/home/claude/.claude.json") + UploadTarball(ctx, cfg, s.PodName, cfg.Container, cfg.RemoteHome, configTar) + ExecInPod(ctx, cfg, s.PodName, "sh", "-c", fmt.Sprintf("chown -R %s:%s %s/.claude %s/.claude.json 2>/dev/null || true", cfg.RemoteUser, cfg.RemoteUser, cfg.RemoteHome, cfg.RemoteHome)) } - // Sync workdir - if cfg.LocalDir != "" { + // Sync workdir — prefer prebuilt tarball (snapshot/fork), else tar LocalDir. + if len(cfg.WorkdirTarball) > 0 { + steps <- SetupStep{Message: "Restoring workdir from snapshot..."} + UploadTarball(ctx, cfg, s.PodName, cfg.Container, cfg.WorkDir, cfg.WorkdirTarball) + ExecInPod(ctx, cfg, s.PodName, "sh", "-c", fmt.Sprintf("chown -R %s:%s %s 2>/dev/null || true", cfg.RemoteUser, cfg.RemoteUser, cfg.WorkDir)) + } else if cfg.LocalDir != "" { steps <- SetupStep{Message: "Syncing workdir..."} workdirTar, err := CreateWorkdirTarball(cfg.LocalDir) if err == nil && len(workdirTar) > 0 { - UploadTarball(ctx, cfg, s.PodName, "main", cfg.WorkDir, workdirTar) - ExecInPod(ctx, cfg, s.PodName, "chown", "-R", "claude:claude", cfg.WorkDir) + UploadTarball(ctx, cfg, s.PodName, cfg.Container, cfg.WorkDir, workdirTar) + ExecInPod(ctx, cfg, s.PodName, "sh", "-c", fmt.Sprintf("chown -R %s:%s %s 2>/dev/null || true", cfg.RemoteUser, cfg.RemoteUser, cfg.WorkDir)) } } @@ -140,8 +184,8 @@ func (s *Session) AttachCmd() *exec.Cmd { func BuildAttachCmd(cfg Config, podName string) *exec.Cmd { claudeCmd := BuildClaudeCmd(cfg, false) shellCmd := fmt.Sprintf( - "su - claude -c 'export PATH=/usr/local/bin:/usr/bin:/bin:$PATH; . ~/.claude_env; cd %s 2>/dev/null; %s'", - cfg.WorkDir, claudeCmd) + "su - %s -c 'export PATH=$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin:$PATH; . ~/.claude_env; cd %s 2>/dev/null; %s'", + cfg.RemoteUser, cfg.WorkDir, claudeCmd) return ExecInteractive(cfg, podName, "sh", "-c", shellCmd) } @@ -163,9 +207,13 @@ func BuildClaudeCmd(cfg Config, streaming bool) string { // FetchSessionJSONL downloads the latest session JSONL from the pod. // It finds the most recent .jsonl file under the remote workdir's project path. func FetchSessionJSONL(cfg Config, podName string) ([]byte, error) { - encoded := encodeProjectPath(cfg.WorkDir) + projectPath := cfg.RemoteProjectPath + if projectPath == "" { + projectPath = cfg.WorkDir + } + encoded := encodeProjectPath(projectPath) // Find the latest .jsonl file - findCmd := fmt.Sprintf("ls -t /home/claude/.claude/projects/%s/*.jsonl 2>/dev/null | head -1", encoded) + findCmd := fmt.Sprintf("ls -t %s/.claude/projects/%s/*.jsonl 2>/dev/null | head -1", cfg.RemoteHome, encoded) out, err := ExecInPod(context.Background(), cfg, podName, "sh", "-c", findCmd) if err != nil || len(out) == 0 { return nil, fmt.Errorf("no session file found on pod") @@ -192,3 +240,49 @@ func (s *Session) Stop() error { func (s *Session) IsRunning() bool { return s.ctx.Err() == nil } + +// StartFromSnapshot resolves a snapshot by name and starts a new pod whose +// workdir and (optionally) session file come from the snapshot rather than +// from the local host. +// +// overrides lets the caller change the target Context/Namespace/Image without +// editing the snapshot meta on disk — anything zero in overrides falls back to +// the snapshot's recorded value. +func StartFromSnapshot(name string, overrides Config, claudeDir string) (*Session, <-chan SetupStep, error) { + meta, sessionPath, workdirPath, err := LoadSnapshot(name) + if err != nil { + return nil, nil, fmt.Errorf("load snapshot %q: %w", name, err) + } + + cfg := overrides + if cfg.Context == "" { + cfg.Context = meta.Context + } + if cfg.Namespace == "" { + cfg.Namespace = meta.Namespace + } + if cfg.Image == "" { + cfg.Image = meta.Image + } + if cfg.WorkDir == "" { + cfg.WorkDir = meta.WorkDir + } + if cfg.LocalDir == "" { + cfg.LocalDir = meta.LocalDir + } + if cfg.SessionID == "" { + cfg.SessionID = meta.SessionID + } + if cfg.SessionFile == "" && sessionPath != "" { + cfg.SessionFile = sessionPath + } + if workdirPath != "" { + data, rerr := os.ReadFile(workdirPath) + if rerr == nil { + cfg.WorkdirTarball = data + } + } + + sess, steps := Start(cfg, claudeDir, cfg.LocalDir) + return sess, steps, nil +} diff --git a/internal/remote/snapshot.go b/internal/remote/snapshot.go new file mode 100644 index 0000000..e9d3eba --- /dev/null +++ b/internal/remote/snapshot.go @@ -0,0 +1,359 @@ +package remote + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +// SnapshotMeta describes a persisted remote session snapshot. The matching +// payload files (session.jsonl, workdir.tgz) live next to meta.yaml under +// snapshotDir//. +type SnapshotMeta struct { + Name string `yaml:"name"` + CreatedAt time.Time `yaml:"created_at"` + SourcePod string `yaml:"source_pod"` + Context string `yaml:"context"` + Namespace string `yaml:"namespace"` + Image string `yaml:"image"` + WorkDir string `yaml:"work_dir"` + LocalDir string `yaml:"local_dir,omitempty"` + SessionID string `yaml:"session_id,omitempty"` + HasSession bool `yaml:"has_session"` + HasWorkdir bool `yaml:"has_workdir"` + WorkdirSize int64 `yaml:"workdir_size,omitempty"` +} + +func snapshotsRoot() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "ccx", "snapshots") +} + +func snapshotDir(name string) string { + return filepath.Join(snapshotsRoot(), name) +} + +// ListSnapshots returns metadata for every snapshot on disk, newest first. +func ListSnapshots() []SnapshotMeta { + root := snapshotsRoot() + entries, err := os.ReadDir(root) + if err != nil { + return nil + } + var out []SnapshotMeta + for _, e := range entries { + if !e.IsDir() { + continue + } + meta, err := loadMeta(snapshotDir(e.Name())) + if err != nil { + continue + } + out = append(out, meta) + } + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) + return out +} + +// LoadSnapshot returns the metadata and on-disk payload paths for a named snapshot. +func LoadSnapshot(name string) (SnapshotMeta, string, string, error) { + dir := snapshotDir(name) + meta, err := loadMeta(dir) + if err != nil { + return SnapshotMeta{}, "", "", err + } + sessionPath := "" + if meta.HasSession { + sessionPath = filepath.Join(dir, "session.jsonl") + } + workdirPath := "" + if meta.HasWorkdir { + workdirPath = filepath.Join(dir, "workdir.tgz") + } + return meta, sessionPath, workdirPath, nil +} + +// DeleteSnapshot removes a snapshot directory. +func DeleteSnapshot(name string) error { + if name == "" || strings.ContainsAny(name, "/\\") { + return fmt.Errorf("invalid snapshot name") + } + return os.RemoveAll(snapshotDir(name)) +} + +// ExportSnapshot writes a portable tar.gz bundle for a snapshot directory. +// The archive contains files under a single top-level / prefix. +func ExportSnapshot(name, outputPath string) error { + if name == "" || strings.ContainsAny(name, "/\\") { + return fmt.Errorf("invalid snapshot name") + } + if outputPath == "" { + return fmt.Errorf("output path required") + } + dir := snapshotDir(name) + if _, err := loadMeta(dir); err != nil { + return fmt.Errorf("load snapshot: %w", err) + } + + out, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("create export: %w", err) + } + defer out.Close() + gw := gzip.NewWriter(out) + defer gw.Close() + tw := tar.NewWriter(gw) + defer tw.Close() + + return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return err + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return err + } + return addFileToTar(tw, path, filepath.Join(name, rel)) + }) +} + +// ImportSnapshot extracts an exported tar.gz bundle into the snapshots root. +// If overrideName is non-empty, the imported directory and meta name are renamed. +func ImportSnapshot(inputPath, overrideName string) (SnapshotMeta, error) { + if inputPath == "" { + return SnapshotMeta{}, fmt.Errorf("input path required") + } + f, err := os.Open(inputPath) + if err != nil { + return SnapshotMeta{}, fmt.Errorf("open import: %w", err) + } + defer f.Close() + gr, err := gzip.NewReader(f) + if err != nil { + return SnapshotMeta{}, fmt.Errorf("read gzip: %w", err) + } + defer gr.Close() + + if err := os.MkdirAll(snapshotsRoot(), 0755); err != nil { + return SnapshotMeta{}, fmt.Errorf("mkdir snapshots root: %w", err) + } + tmp, err := os.MkdirTemp(snapshotsRoot(), ".import-*") + if err != nil { + return SnapshotMeta{}, fmt.Errorf("mktemp import: %w", err) + } + defer os.RemoveAll(tmp) + + tr := tar.NewReader(gr) + top := "" + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return SnapshotMeta{}, fmt.Errorf("read tar: %w", err) + } + if header.Typeflag != tar.TypeReg { + continue + } + parts := strings.Split(filepath.Clean(header.Name), string(os.PathSeparator)) + if len(parts) < 2 || parts[0] == "." || parts[0] == ".." { + return SnapshotMeta{}, fmt.Errorf("invalid archive path: %s", header.Name) + } + if top == "" { + top = parts[0] + } else if top != parts[0] { + return SnapshotMeta{}, fmt.Errorf("archive has multiple top-level dirs") + } + rel := filepath.Join(parts[1:]...) + if strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { + return SnapshotMeta{}, fmt.Errorf("unsafe archive path: %s", header.Name) + } + dest := filepath.Join(tmp, rel) + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + return SnapshotMeta{}, err + } + out, err := os.OpenFile(dest, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(header.Mode)) + if err != nil { + return SnapshotMeta{}, err + } + _, copyErr := io.Copy(out, tr) + closeErr := out.Close() + if copyErr != nil { + return SnapshotMeta{}, copyErr + } + if closeErr != nil { + return SnapshotMeta{}, closeErr + } + } + + meta, err := loadMeta(tmp) + if err != nil { + return SnapshotMeta{}, fmt.Errorf("import meta: %w", err) + } + name := meta.Name + if overrideName != "" { + if strings.ContainsAny(overrideName, "/\\") { + return SnapshotMeta{}, fmt.Errorf("invalid snapshot name") + } + name = overrideName + meta.Name = overrideName + } + if name == "" || strings.ContainsAny(name, "/\\") { + return SnapshotMeta{}, fmt.Errorf("invalid snapshot name") + } + + dest := snapshotDir(name) + if err := os.RemoveAll(dest); err != nil { + return SnapshotMeta{}, err + } + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + return SnapshotMeta{}, err + } + if err := os.Rename(tmp, dest); err != nil { + return SnapshotMeta{}, err + } + if overrideName != "" { + if err := writeMeta(dest, meta); err != nil { + return SnapshotMeta{}, err + } + } + return meta, nil +} + +// SaveSnapshot captures the JSONL transcript and workdir tarball from a live +// remote pod into snapshotsRoot//. Returns the resolved metadata. +func SaveSnapshot(ctx context.Context, cfg Config, podName, name string, src SavedSession) (SnapshotMeta, error) { + if name == "" { + name = fmt.Sprintf("%s-%s", podName, time.Now().Format("20060102-150405")) + } + if strings.ContainsAny(name, "/\\") { + return SnapshotMeta{}, fmt.Errorf("invalid snapshot name: %q", name) + } + + dir := snapshotDir(name) + if err := os.MkdirAll(dir, 0755); err != nil { + return SnapshotMeta{}, fmt.Errorf("mkdir snapshot: %w", err) + } + + meta := SnapshotMeta{ + Name: name, + CreatedAt: time.Now(), + SourcePod: podName, + Context: cfg.Context, + Namespace: cfg.Namespace, + Image: cfg.Image, + WorkDir: cfg.WorkDir, + LocalDir: src.LocalDir, + SessionID: src.SessionID, + } + + // Session JSONL — best effort, missing pod state shouldn't fail the snapshot. + if data, err := FetchSessionJSONL(cfg, podName); err == nil && len(data) > 0 { + if err := os.WriteFile(filepath.Join(dir, "session.jsonl"), data, 0644); err == nil { + meta.HasSession = true + } + } + + // Workdir tarball. + if tarball, err := fetchRemoteWorkdir(ctx, cfg, podName); err == nil && len(tarball) > 0 { + if err := os.WriteFile(filepath.Join(dir, "workdir.tgz"), tarball, 0644); err == nil { + meta.HasWorkdir = true + meta.WorkdirSize = int64(len(tarball)) + } + } + + if !meta.HasSession && !meta.HasWorkdir { + os.RemoveAll(dir) + return SnapshotMeta{}, fmt.Errorf("snapshot %q is empty (no session, no workdir)", name) + } + + if err := writeMeta(dir, meta); err != nil { + return SnapshotMeta{}, fmt.Errorf("write meta: %w", err) + } + return meta, nil +} + +// FetchWorkdirToDir extracts the pod's workdir tarball into destDir on disk. +// Used by `remote:pull` to bring guest changes back to the host LocalDir. +func FetchWorkdirToDir(ctx context.Context, cfg Config, podName, destDir string) error { + if destDir == "" { + return fmt.Errorf("destination directory required") + } + if err := ValidateWorkdir(destDir); err != nil { + return err + } + tarball, err := fetchRemoteWorkdir(ctx, cfg, podName) + if err != nil { + return err + } + if len(tarball) == 0 { + return fmt.Errorf("empty workdir tarball") + } + cmd := exec.CommandContext(ctx, "tar", "xzf", "-", "-C", destDir) + cmd.Stdin = bytes.NewReader(tarball) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("tar extract: %s: %w", strings.TrimSpace(string(out)), err) + } + return nil +} + +// fetchRemoteWorkdir tars+gzips cfg.WorkDir on the pod and streams it back. +func fetchRemoteWorkdir(ctx context.Context, cfg Config, podName string) ([]byte, error) { + if cfg.WorkDir == "" { + return nil, fmt.Errorf("work_dir unset") + } + // Tar the workdir contents (not the parent), excluding noisy dirs. + script := fmt.Sprintf( + "cd %s 2>/dev/null && tar czf - --exclude=node_modules --exclude=.git --exclude=vendor --exclude=tmp --exclude=__pycache__ --exclude=dist --exclude=build . 2>/dev/null", + shellQuote(cfg.WorkDir)) + args := []string{ + "--context", cfg.Context, + "-n", cfg.Namespace, + "exec", podName, + } + if cfg.Container != "" { + args = append(args, "-c", cfg.Container) + } + args = append(args, "--", "sh", "-c", script) + cmd := exec.CommandContext(ctx, "kubectl", args...) + var stdout bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("fetch workdir: %w", err) + } + return stdout.Bytes(), nil +} + +func loadMeta(dir string) (SnapshotMeta, error) { + data, err := os.ReadFile(filepath.Join(dir, "meta.yaml")) + if err != nil { + return SnapshotMeta{}, err + } + var meta SnapshotMeta + if err := yaml.Unmarshal(data, &meta); err != nil { + return SnapshotMeta{}, err + } + return meta, nil +} + +func writeMeta(dir string, meta SnapshotMeta) error { + data, err := yaml.Marshal(meta) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, "meta.yaml"), data, 0644) +} diff --git a/internal/remote/stream.go b/internal/remote/stream.go index 811b1a2..670df99 100644 --- a/internal/remote/stream.go +++ b/internal/remote/stream.go @@ -18,8 +18,12 @@ func StreamExec(ctx context.Context, cfg Config, podName string, cmd ...string) args := []string{ "--context", cfg.Context, "-n", cfg.Namespace, - "exec", podName, "--", + "exec", podName, } + if cfg.Container != "" { + args = append(args, "-c", cfg.Container) + } + args = append(args, "--") args = append(args, cmd...) c := exec.CommandContext(ctx, "kubectl", args...) diff --git a/internal/session/filter.go b/internal/session/filter.go index 69c3788..41259bb 100644 --- a/internal/session/filter.go +++ b/internal/session/filter.go @@ -72,6 +72,9 @@ func FilterValueFor(s Session, cwdProjectPaths []string) string { if s.HasMCP { parts = append(parts, "has:mcp") } + if s.HasMonitorJobs { + parts = append(parts, "has:monitor", "is:mon") + } if s.TeamName != "" { parts = append(parts, "is:team", "team:"+s.TeamName) } diff --git a/internal/session/filter_test.go b/internal/session/filter_test.go index 19ce76b..d6d1a10 100644 --- a/internal/session/filter_test.go +++ b/internal/session/filter_test.go @@ -37,6 +37,7 @@ func TestFilterValueFor_AllFlags(t *testing.T) { HasCompaction: true, HasSkills: true, HasMCP: true, + HasMonitorJobs: true, TeamName: "squad", TeammateName: "bob", ParentSessionID: "parent", @@ -50,6 +51,7 @@ func TestFilterValueFor_AllFlags(t *testing.T) { "win:work", "is:live", "is:busy", "is:wt", "has:mem", "has:todo", "has:task", "has:plan", "has:agent", "has:compact", "has:skill", "has:mcp", + "has:monitor", "is:mon", "is:team", "team:squad", "bob", "is:fork", "tag:tag1", "tag1", "is:remote", "pod-x", "running", ) diff --git a/internal/session/models.go b/internal/session/models.go index 931c5d7..ee73a2a 100644 --- a/internal/session/models.go +++ b/internal/session/models.go @@ -74,12 +74,13 @@ type Session struct { ParentSessionID string // UUID of parent session (empty if not a fork) - HasAgents bool - HasCompaction bool - HasSkills bool - HasMCP bool - HasShellJobs bool // background Bash or Monitor invocations present - ShellJobs []ShellJob // populated lazily when HasShellJobs is true + HasAgents bool + HasCompaction bool + HasSkills bool + HasMCP bool + HasShellJobs bool // background Bash or Monitor invocations present + HasMonitorJobs bool // Monitor tool invocations present (subset of HasShellJobs) + ShellJobs []ShellJob // populated lazily when HasShellJobs is true CustomBadges []string // user-created badge tags diff --git a/internal/session/scanner_stream.go b/internal/session/scanner_stream.go index f0fe67f..d75315b 100644 --- a/internal/session/scanner_stream.go +++ b/internal/session/scanner_stream.go @@ -137,9 +137,14 @@ func scanSessionStream(path string, modTime time.Time, home string, badgeStore * if !sess.HasShellJobs { if bytes.Contains(line, bMonitorTool) || bytes.Contains(line, bMonitorToolS) { sess.HasShellJobs = true + sess.HasMonitorJobs = true } else if bytes.Contains(line, bRunInBackground) || bytes.Contains(line, bRunInBackgroundS) { sess.HasShellJobs = true } + } else if !sess.HasMonitorJobs { + if bytes.Contains(line, bMonitorTool) || bytes.Contains(line, bMonitorToolS) { + sess.HasMonitorJobs = true + } } // Team detection (check any line for teamName/agentName) @@ -183,6 +188,7 @@ func scanSessionStream(path string, modTime time.Time, home string, badgeStore * if len(sess.Todos) == 0 { sess.Todos = loadFileTodos(sess.ID, home) } + sess.HasTodos = false for _, t := range sess.Todos { if t.Status == "pending" || t.Status == "in_progress" { sess.HasTodos = true @@ -190,8 +196,17 @@ func scanSessionStream(path string, modTime time.Time, home string, badgeStore * } } - // Load tasks from ~/.claude/tasks/{sessionID}/ + // Load tasks from ~/.claude/tasks/{sessionID}/. If there is no persisted task + // file but task activity was detected during the fast stream scan, fall back + // to reconstructing tasks from the JSONL itself so lifecycle/filter logic can + // still recognize completed sessions in the browser. sess.Tasks = loadFileTasks(sess.ID, home) + if len(sess.Tasks) == 0 && sess.HasTasks { + if entries, err := LoadMessages(path); err == nil { + sess.Tasks = LoadTasksFromEntries(entries) + } + } + sess.HasTasks = false for _, t := range sess.Tasks { if t.Status == "pending" || t.Status == "in_progress" { sess.HasTasks = true diff --git a/internal/session/scanner_stream_test.go b/internal/session/scanner_stream_test.go new file mode 100644 index 0000000..cc7528c --- /dev/null +++ b/internal/session/scanner_stream_test.go @@ -0,0 +1,44 @@ +package session + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestScanSessionStream_FallsBackToJSONLTasksForCompletedSessions(t *testing.T) { + home := t.TempDir() + sessionID := "session-1" + sessDir := filepath.Join(home, "project-dir") + if err := os.MkdirAll(sessDir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(sessDir, sessionID+".jsonl") + jsonl := + `{"type":"assistant","timestamp":"2026-05-14T00:00:01Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"task-1","name":"TaskCreate","input":{"id":"1","subject":"Check context","status":"pending"}}]}}` + "\n" + + `{"type":"assistant","timestamp":"2026-05-14T00:01:00Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"task-2","name":"TaskUpdate","input":{"taskId":"1","status":"completed"}}]}}` + "\n" + if err := os.WriteFile(path, []byte(jsonl), 0o644); err != nil { + t.Fatal(err) + } + + sess := scanSessionStream(path, time.Now(), home, nil) + if len(sess.Tasks) != 1 { + t.Fatalf("expected 1 task reconstructed from JSONL, got %d", len(sess.Tasks)) + } + if sess.Tasks[0].ID != "1" { + t.Fatalf("expected task ID 1, got %q", sess.Tasks[0].ID) + } + if sess.Tasks[0].Status != "completed" { + t.Fatalf("expected completed status, got %q", sess.Tasks[0].Status) + } + if sess.HasTasks { + t.Fatalf("expected HasTasks=false because there is no unfinished task") + } + if !sess.allWorkCompleted() { + t.Fatalf("expected allWorkCompleted=true for reconstructed completed task") + } + if got := sess.Lifecycle(); got != LifecycleDone { + t.Fatalf("expected LifecycleDone, got %v", got) + } +} diff --git a/internal/session/shells.go b/internal/session/shells.go index d8e3c8d..642b24f 100644 --- a/internal/session/shells.go +++ b/internal/session/shells.go @@ -24,6 +24,50 @@ type shellInputResult struct { ToolUseID string `json:"tool_use_id"` } +// ActiveShellJobs returns the subset of the session's recorded shell jobs +// that look like they are still alive: a `Monitor` invocation that hasn't +// been killed, or a background `Bash` whose latest poll did not return +// completion. We can't observe child process state from a JSONL alone, so +// this is a heuristic — but it matches what we render as `[BG]`. +func (s Session) ActiveShellJobs() []ShellJob { + if !s.IsLive || !s.HasShellJobs { + return nil + } + var out []ShellJob + for _, j := range s.ShellJobs { + switch j.Status { + case "killed", "stopped": + continue + } + out = append(out, j) + } + return out +} + +// ActiveMonitorCount returns how many `Monitor` tool invocations look +// active for this session — useful for showing a count next to the [BG] +// badge in the session list. +func (s Session) ActiveMonitorCount() int { + n := 0 + for _, j := range s.ActiveShellJobs() { + if j.ToolName == "Monitor" { + n++ + } + } + return n +} + +// ActiveBashJobCount returns how many backgrounded Bash calls look active. +func (s Session) ActiveBashJobCount() int { + n := 0 + for _, j := range s.ActiveShellJobs() { + if j.ToolName == "Bash" { + n++ + } + } + return n +} + // LoadShellJobsFromEntries scans parsed entries for background Bash and Monitor // tool invocations. It correlates BashOutput/KillShell calls (which carry a // tool_use_id) back to the originating shell so we can show how many polls diff --git a/internal/tmux/pane.go b/internal/tmux/pane.go index 8c5401b..2f888f7 100644 --- a/internal/tmux/pane.go +++ b/internal/tmux/pane.go @@ -240,6 +240,14 @@ func SendSingleKey(p Pane, key string) error { return exec.Command("tmux", "send-keys", "-t", target, tmuxKey).Run() } +// SendLiteralKeys sends raw text literally to a tmux pane without appending +// Enter. Useful for bracketed paste / multi-rune input forwarded through a +// proxied preview pane. +func SendLiteralKeys(p Pane, text string) error { + target := p.Session + ":" + p.Window + "." + p.Pane + return exec.Command("tmux", "send-keys", "-l", "-t", target, text).Run() +} + // SendKeys sends text input to a tmux pane followed by Enter to submit. // Uses -l for the text (literal, no key-name interpretation) then a separate // send-keys for Enter so it's treated as a keypress. diff --git a/internal/tui/app.go b/internal/tui/app.go index b2f8c06..be49497 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -138,6 +138,33 @@ func captureAfterKeyCmd(p tmux.Pane, key string) tea.Cmd { } } +// captureAfterLiteralCmd sends a block of literal text (for paste / multi-rune +// input) to the tmux pane, then captures the pane content. +func captureAfterLiteralCmd(p tmux.Pane, text string) tea.Cmd { + return func() tea.Msg { + tmux.SendLiteralKeys(p, text) + time.Sleep(30 * time.Millisecond) + content, err := tmux.CapturePane(p) + if err != nil || !tmux.HasClaude(p.PID) { + return liveCaptureMsg{failed: true} + } + return liveCaptureMsg{content: content} + } +} + +// paneProxyLiteralInput extracts literal text that should be forwarded as raw +// bytes to a proxied tmux pane instead of as a named key. This is primarily +// for bracketed paste and IME/multi-rune commits. +func paneProxyLiteralInput(msg tea.KeyMsg) (string, bool) { + if msg.Type != tea.KeyRunes || len(msg.Runes) == 0 { + return "", false + } + if msg.Paste || len(msg.Runes) > 1 { + return string(msg.Runes), true + } + return "", false +} + // capturePaneCmd returns a Cmd that captures tmux pane content asynchronously. func capturePaneCmd(p tmux.Pane) tea.Cmd { return func() tea.Msg { @@ -207,7 +234,9 @@ type App struct { pickResult PickResult // List models - sessionList list.Model + sessionList list.Model + sessionRowCache *sessionRowCache + convPreviewRowCache *sessionRowCache // Split panes sessSplit SplitPane @@ -306,7 +335,12 @@ type App struct { sessConvFilterTerm string // applied filter term // Group mode: groupFlat=0, groupProject=1, groupTree=2 - sessGroupMode int + sessGroupMode int + // sessFolded tracks which session groups are collapsed. + // Keys are group identities produced by the builders (e.g. project path, + // team name, base repo path). When a key is present and true, the + // group's children are hidden in the list. + sessFolded map[string]bool sessionsLoading bool // true while initial async scan is in progress liveUpdate bool // auto-refresh disabled by default @@ -386,6 +420,7 @@ type App struct { remoteDefaults remote.Config // defaults from config.yaml remoteJSONLFile *os.File // temp file accumulating streamed JSONL remoteStreaming bool // true once Claude output is streaming + remoteLastPoll time.Time // last time saved-pod phases were polled // Generic confirm modal confirmMsg string // message to show (empty = no modal) @@ -526,12 +561,96 @@ type App struct { } // selectedSession returns the currently selected session from the session list. +// In the project-centric view, the cursor can land on a `projectItem` +// (a folder-style row that is not itself a session). To keep preview/ +// actions sensible we fall back to that project's most-recent session. func (a *App) selectedSession() (session.Session, bool) { - item, ok := a.sessionList.SelectedItem().(sessionItem) - if !ok { - return session.Session{}, false + sel := a.sessionList.SelectedItem() + if item, ok := sel.(sessionItem); ok { + return item.sess, true + } + if pi, ok := sel.(projectItem); ok && len(pi.sessions) > 0 { + return pi.sessions[0], true + } + return session.Session{}, false +} + +// selectedProject returns the project row at the cursor, if any. +func (a *App) selectedProject() (projectItem, bool) { + pi, ok := a.sessionList.SelectedItem().(projectItem) + return pi, ok +} + +func (a *App) selectedSessionListItemKey() string { + if pi, ok := a.selectedProject(); ok { + return "project:" + pi.basePath + } + if sess, ok := a.selectedSession(); ok { + return "session:" + sess.ID + } + return "" +} + +func (a *App) restoreSessionListSelection(key string) { + if key == "" { + a.bumpPastHeader(0, +1) + return + } + for i, item := range a.sessionList.VisibleItems() { + switch v := item.(type) { + case projectItem: + if key == "project:"+v.basePath { + a.sessionList.Select(i) + return + } + case sessionItem: + if key == "session:"+v.sess.ID { + a.sessionList.Select(i) + return + } + } + } + a.bumpPastHeader(0, +1) +} + +func (a *App) setSessionListFilter(query string) { + selectionKey := a.selectedSessionListItemKey() + a.sessionList.ResetFilter() + a.config.SearchQuery = "" + if strings.TrimSpace(query) != "" { + applyListFilter(&a.sessionList, query) + a.config.SearchQuery = query + } + a.restoreSessionListSelection(selectionKey) +} + +func (a *App) visibleProjectBrowserItems() int { + n := 0 + for _, item := range a.sessionList.VisibleItems() { + switch item.(type) { + case projectItem, sessionItem: + n++ + } + } + return n +} + +func (a *App) toggleCompletedProjectsFilter() { + current := strings.TrimSpace(a.activeFilterValue()) + if current == "is:done" { + a.setSessionListFilter("") + a.copiedMsg = "Completed filter cleared" + return + } + a.setSessionListFilter("is:done") + if a.visibleProjectBrowserItems() == 0 { + // Do not strand the user on a confusing blank browser; fall back to the + // normal projects view and explain what happened. + a.setSessionListFilter("") + a.copiedMsg = "No completed projects found" + return } - return item.sess, true + a.copiedMsg = "Showing completed projects" } func (a *App) hasMultiSelection() bool { @@ -604,15 +723,23 @@ func NewApp(sessions []session.Session, cfg Config) *App { } a := &App{ - state: viewSessions, - sessions: sessions, - sessionsLoading: true, // always true — full scan happens async - config: cfg, - keymap: km, - splitRatio: 35, - selectedSet: make(map[string]bool), - hiddenBadges: make(map[string]bool), - termFocused: true, + state: viewSessions, + sessions: sessions, + sessionsLoading: true, // always true — full scan happens async + config: cfg, + keymap: km, + splitRatio: 35, + selectedSet: make(map[string]bool), + hiddenBadges: make(map[string]bool), + sessionRowCache: newSessionRowCache(1024), + convPreviewRowCache: newSessionRowCache(4096), + termFocused: true, + // Default to a true project-centric browser: ccx now opens with one + // row per project (folder-like), and sessions of the same repo (and + // its worktrees) appear as expandable children beneath the project + // header. CLI flags or persisted preferences below can still + // override this. + sessGroupMode: groupProjectCentric, } // Restore persisted view state (CLI flags override in the apply block below) @@ -644,11 +771,16 @@ func NewApp(sessions []session.Session, cfg Config) *App { // Apply group/preview/view mode from CLI flags or restored preferences if a.config.GroupMode != "" { - modeMap := map[string]int{"flat": groupFlat, "proj": groupProject, "tree": groupTree, "chain": groupChain, "fork": groupFork, "repo": groupBaseProject} + modeMap := map[string]int{"flat": groupFlat, "proj": groupProject, "tree": groupTree, "chain": groupChain, "fork": groupFork, "repo": groupBaseProject, "projects": groupProjectCentric} if m, ok := modeMap[a.config.GroupMode]; ok { a.sessGroupMode = m } } + // The main browser is canonically project-centric now. Preserve legacy + // group-mode parsing for explicit in-session commands/tests, but do not + // let persisted historical values (flat/repo/...) drag startup back out + // of the projects view. + a.sessGroupMode = groupProjectCentric if a.config.PreviewMode != "" { modeMap := map[string]sessPreview{"conv": sessPreviewConversation, "stats": sessPreviewStats, "mem": sessPreviewMemory, "tasks": sessPreviewTasksPlan, "agents": sessPreviewAgents, "shells": sessPreviewShells, "contexts": sessPreviewContexts, "ctx": sessPreviewContexts, "live": sessPreviewLive} if m, ok := modeMap[a.config.PreviewMode]; ok { @@ -658,7 +790,7 @@ func NewApp(sessions []session.Session, cfg Config) *App { } if a.config.ViewMode != "" { modeMap := map[string]viewState{ - "sessions": viewSessions, "config": viewConfig, + "sessions": viewSessions, "projects": viewSessions, "config": viewConfig, "plugins": viewPlugins, "stats": viewGlobalStats, } if m, ok := modeMap[a.config.ViewMode]; ok { @@ -765,6 +897,21 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case remoteExecDoneMsg: return a.handleRemoteExecDone(msg) + case remotePhaseMsg: + return a.handleRemotePhase(msg) + + case remoteExecOutputMsg: + return a.handleRemoteExecOutput(msg) + + case remoteSnapshotMsg: + return a.handleRemoteSnapshot(msg) + + case remotePullMsg: + return a.handleRemotePull(msg) + + case remoteForkReadyMsg: + return a.handleRemoteForkReady(msg) + case delayedRefreshMsg: // Auto-refresh after spawning a new session; retry if session not found yet oldCount := len(a.sessions) @@ -915,7 +1062,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.width > 0 && a.height > 0 { contentH := a.height - 3 sessW := a.sessSplit.ListWidth(a.width, a.splitRatio) - a.sessionList = newSessionList(a.sessions, sessW, contentH, a.sessGroupMode, a.selectedSet, a.hiddenBadges, a.config.WorktreeDir) + a.sessionList = newSessionList(a.sessions, sessW, contentH, a.sessGroupMode, a.selectedSet, a.hiddenBadges, a.sessFolded, a.sessionRowCache, a.config.WorktreeDir) a.sessSplit.CacheKey = "" if a.config.SearchQuery != "" { applyListFilter(&a.sessionList, a.config.SearchQuery) @@ -1172,11 +1319,11 @@ func (a *App) View() string { help = " " + a.cmdInput.View() + helpStyle.Render(" tab:complete ↵:run esc:cancel") } - // URL menu hint box + // URL menu centered modal if a.urlMenu { hintBox := a.renderURLMenu() if hintBox != "" { - content = placeHintBox(content, hintBox, a.activeDividerCol()) + content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-8, 24), maxHeight: max(ContentHeight(a.height)-4, 8)}) } if a.urlSearching { help = " " + a.urlSearchInput.View() + helpStyle.Render(" enter:apply esc:cancel") @@ -1188,94 +1335,96 @@ func (a *App) View() string { // Conversation/message-full actions menu hint box if a.convActionsMenu && (a.state == viewConversation || a.state == viewMessageFull) { hintBox := a.renderConvActionsHintBox() - content = placeHintBox(content, hintBox, a.activeDividerCol()) + content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-8, 28), maxHeight: max(ContentHeight(a.height)-4, 8)}) help = formatHelp(fmtKey(a.keymap.Conversation.Actions, "actions") + " — pick an action") } // Actions menu hint box floating above help line if a.actionsMenu && a.state == viewSessions { hintBox := a.renderActionsHintBox() - content = placeHintBox(content, hintBox, a.activeDividerCol()) + content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-8, 28), maxHeight: max(ContentHeight(a.height)-4, 8)}) help = formatHelp("x:actions — pick an action") } if a.sessPageMenu && a.state == viewSessions { hintBox := a.renderSessPageHintBox() - content = placeHintBox(content, hintBox, a.activeDividerCol()) + content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-8, 20), maxHeight: max(ContentHeight(a.height)-4, 8)}) help = formatHelp("p:page — pick a preview") } - // Tag menu floating modal + // Tag menu centered modal if a.tagMenu { modal := a.renderTagMenu() - content = placeHintBox(content, modal, a.activeDividerCol()) + if modal != "" { + content = overlayCenteredModal(content, modal, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-8, 24), maxHeight: max(ContentHeight(a.height)-4, 8)}) + } } // Config actions menu hint box if a.cfgActionsMenu && a.state == viewConfig { hintBox := a.renderCfgActionsHintBox() - content = placeHintBox(content, hintBox, a.activeDividerCol()) + content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-8, 28), maxHeight: max(ContentHeight(a.height)-4, 8)}) help = formatHelp("x:actions — pick an action") } // Plugin actions menu hint box if a.plgActionsMenu && a.state == viewPlugins { hintBox := a.renderPlgActionsHintBox() - content = placeHintBox(content, hintBox, a.activeDividerCol()) + content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-8, 28), maxHeight: max(ContentHeight(a.height)-4, 8)}) help = formatHelp("x:actions — pick an action") } // Plugin detail actions menu hint box if a.plgCompActionsMenu && a.state == viewPlugins && a.plgDetailActive { hintBox := a.renderPlgCompActionsHintBox() - content = placeHintBox(content, hintBox, a.activeDividerCol()) + content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-8, 28), maxHeight: max(ContentHeight(a.height)-4, 8)}) help = formatHelp("x:actions — pick an action") } - // Views menu hint box floating above help line + // Views menu centered modal if a.viewsMenu { hintBox := a.renderViewsHintBox() - content = placeHintBox(content, hintBox, a.activeDividerCol()) + content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-8, 20), maxHeight: max(ContentHeight(a.height)-4, 8)}) help = formatHelp("v:views — pick a view") } - // Edit menu hint box floating above help line + // Edit menu centered modal if a.editMenu { hintBox := a.renderEditHintBox() - content = placeHintBox(content, hintBox, a.activeDividerCol()) + content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-8, 20), maxHeight: max(ContentHeight(a.height)-4, 8)}) help = formatHelp("e:edit — pick a file") } - // Stats page jump hint box + // Stats page jump centered modal if a.statsPageMenu && a.state == viewGlobalStats { hintBox := a.renderStatsPageHintBox() - content = placeHintBox(content, hintBox, a.activeDividerCol()) + content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-8, 20), maxHeight: max(ContentHeight(a.height)-4, 8)}) help = formatHelp("p:page — pick a page") } - // Config page jump hint box + // Config page jump centered modal if a.cfgPageMenu && a.state == viewConfig { hintBox := a.renderCfgPageHintBox() - content = placeHintBox(content, hintBox, a.activeDividerCol()) + content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-8, 20), maxHeight: max(ContentHeight(a.height)-4, 8)}) help = formatHelp("p:page — pick a section") } - // Conversation page jump hint box + // Conversation page jump centered modal if a.convPageMenu && a.state == viewConversation { // Keep browser visible as background when menu opens from browser if a.convPageActive { content = a.renderConvPageBrowser() } hintBox := a.renderConvPageHintBox() - content = placeHintBox(content, hintBox, a.activeDividerCol()) + content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-8, 20), maxHeight: max(ContentHeight(a.height)-4, 8)}) help = formatHelp("p:page — pick a page") } - // Conversation artifact browser actions menu + // Conversation artifact browser actions centered modal if a.convPageActionsMenu && a.state == viewConversation && a.convPageActive { content = a.renderConvPageBrowser() hintBox := a.renderConvPageActionsHintBox() - content = placeHintBox(content, hintBox, a.activeDividerCol()) + content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-8, 20), maxHeight: max(ContentHeight(a.height)-4, 8)}) help = formatHelp("x:actions — pick an action") } @@ -1305,26 +1454,26 @@ func (a *App) View() string { } } - // Block filter hint box floating above help line (conversation preview and full-screen message) + // Filter/search hint boxes as constrained centered modals if a.conv.blockFiltering && a.state == viewConversation { hintBox := renderBlockFilterHintBox() - content = placeHintBox(content, hintBox, a.activeDividerCol()) + content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-10, 28), maxHeight: max(ContentHeight(a.height)-6, 8)}) } if a.state == viewMessageFull { if a.msgFull.blockFiltering { hintBox := renderBlockFilterHintBox() - content = placeHintBox(content, hintBox, a.activeDividerCol()) + content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-10, 28), maxHeight: max(ContentHeight(a.height)-6, 8)}) } else if a.msgFull.searching { hintBox := a.renderMsgFullSearchHintBox() - content = placeHintBox(content, hintBox, a.activeDividerCol()) + content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-10, 28), maxHeight: max(ContentHeight(a.height)-6, 8)}) } } - // Command mode hint box floating above help line + // Command mode hint box as constrained centered modal if a.cmdMode { hintBox := a.renderCmdHintBox() if hintBox != "" { - content = placeHintBox(content, hintBox, a.activeDividerCol()) + content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-10, 40), maxHeight: max(ContentHeight(a.height)-6, 10)}) } } @@ -1472,6 +1621,9 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Pane proxy focused: keys forwarded to tmux pane if a.isPaneProxyFocused() { + if literal, ok := paneProxyLiteralInput(msg); ok { + return a, captureAfterLiteralCmd(a.paneProxy.pane, literal) + } switch key { case "ctrl+q": sp.Focus = false @@ -1497,6 +1649,8 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { var items []session.Session if a.hasMultiSelection() { items = a.selectedSessions() + } else if pi, ok := a.selectedProject(); ok { + items = append(items, pi.sessions...) } else if sess, ok := a.selectedSession(); ok { items = []session.Session{sess} } @@ -1533,6 +1687,11 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return a, nil case km.Session.Open: + // Project rows: Enter toggles expansion instead of opening anything. + if pi, ok := a.sessionList.SelectedItem().(projectItem); ok { + a.toggleProjectFold(pi) + return a, nil + } // Remote sessions: attach interactively if sess, ok := a.selectedSession(); ok && sess.IsRemote { return a.attachToRemoteSession(sess) @@ -1556,6 +1715,16 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if sp.Focus && sp.Show { return a, nil } + if pi, ok := a.selectedProject(); ok { + for _, s := range pi.sessions { + if a.selectedSet[s.ID] { + delete(a.selectedSet, s.ID) + } else { + a.selectedSet[s.ID] = true + } + } + return a, nil + } sess, ok := a.selectedSession() if !ok { return a, nil @@ -1576,6 +1745,13 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { a.actionsMenu = true return a, nil } + if pi, ok := a.selectedProject(); ok { + for _, s := range pi.sessions { + a.selectedSet[s.ID] = true + } + a.actionsMenu = true + return a, nil + } sess, ok := a.selectedSession() if !ok { return a, nil @@ -1615,24 +1791,46 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case km.Session.Help: a.showHelp = true return a, nil - // Tab/shift+tab: context-aware cycling - // List focused → cycle group mode; Preview focused → cycle preview mode + // Tab/shift+tab now only control preview behavior. The main browser is + // project-centric by default, so users should not need Tab to rotate + // through alternate grouping models just to get the right mental model. case km.Session.Preview: - if sp.Focus && sp.Show { - a.cycleSessionPreviewMode() - } else { - a.sessGroupMode = (a.sessGroupMode + 1) % numGroupModes - a.rebuildSessionList() + if !sp.Show { + sp.Show = true + sp.Focus = false + return a, a.updateSessionPreview() } - return a, nil + a.cycleSessionPreviewMode() + return a, a.updateSessionPreview() case km.Session.PreviewBack: - if sp.Focus && sp.Show { - a.cycleSessionPreviewModeReverse() - } else { - a.sessGroupMode = (a.sessGroupMode - 1 + numGroupModes) % numGroupModes - a.rebuildSessionList() + if !sp.Show { + sp.Show = true + sp.Focus = false + return a, a.updateSessionPreview() + } + a.cycleSessionPreviewModeReverse() + return a, a.updateSessionPreview() + } + + // Fold/unfold groups: only available when the list (not the preview) + // is focused, so the same keys can still be used as text input + // elsewhere. `o` toggles the group at the cursor; `f`/`F` fold/expand + // everything. + if !sp.Focus { + switch key { + case "o": + a.toggleSessGroupFoldAtCursor() + return a, nil + case "f": + a.setAllSessGroupsFolded(true) + return a, nil + case "F": + a.setAllSessGroupsFolded(false) + return a, nil + case "D": + a.toggleCompletedProjectsFilter() + return a, a.updateSessionPreview() } - return a, nil } // Sessions list vim-style jumps: gg = top, G = end. @@ -1744,13 +1942,18 @@ func (a *App) skipHeaderInDirection(oldIdx, newIdx int) { if _, ok := visible[cur].(sessionItem); ok { return } + // projectItem rows are selectable (cursor can land on a project header). + if _, ok := visible[cur].(projectItem); ok { + return + } dir := 1 if newIdx < oldIdx { dir = -1 } idx := cur + dir for idx >= 0 && idx < len(visible) { - if _, ok := visible[idx].(sessionItem); ok { + switch visible[idx].(type) { + case sessionItem, projectItem: a.sessionList.Select(idx) return } @@ -1759,7 +1962,8 @@ func (a *App) skipHeaderInDirection(oldIdx, newIdx int) { // Reverse direction if we hit a boundary on a header. idx = cur - dir for idx >= 0 && idx < len(visible) { - if _, ok := visible[idx].(sessionItem); ok { + switch visible[idx].(type) { + case sessionItem, projectItem: a.sessionList.Select(idx) return } @@ -2322,7 +2526,7 @@ func (a *App) isPaneProxyFocused() bool { return a.paneProxy != nil && a.sessSplit.Focus && a.sessPreviewMode == sessPreviewLive } -// paneProxyIndicator returns a styled [LIVE ●]/[SHELL ●] badge for the help line. +// paneProxyIndicator returns a styled [LIVE ]/[SHELL ] badge for the help line. func (a *App) paneProxyIndicator() string { if a.paneProxy == nil { return "" @@ -2331,10 +2535,10 @@ func (a *App) paneProxyIndicator() string { if a.paneProxy.isShell { label = "SHELL" } - dot := "○" + dot := iconIdle style := dimStyle if a.sessSplit.Focus { - dot = "●" + dot = iconFocused style = liveBadge } return style.Render("[" + label + " " + dot + "]") @@ -2687,7 +2891,7 @@ func (a *App) renderViewsHintBox() string { } return h.Render(displayKey(k)) + d.Render(":"+label) } - parts = append(parts, viewLabel("↵", "sessions", a.state == viewSessions)) + parts = append(parts, viewLabel("↵", "projects", a.state == viewSessions)) parts = append(parts, viewLabel(km.Stats, "stats", a.state == viewGlobalStats)) parts = append(parts, viewLabel(km.Config, "config", a.state == viewConfig)) parts = append(parts, viewLabel(km.Plugins, "plugins", a.state == viewPlugins)) @@ -3004,7 +3208,7 @@ func (a *App) bulkDelete(selected []session.Session) (tea.Model, tea.Cmd) { a.sessionList.ResetFilter() a.config.SearchQuery = "" } - items := buildGroupedItems(remaining, a.sessGroupMode) + items := buildGroupedItems(remaining, a.sessGroupMode, a.sessFolded) a.sessionList.SetItems(items) idx := a.sessionList.Index() if idx >= len(items) { @@ -3230,7 +3434,7 @@ func (a *App) deleteSession(sess session.Session) (tea.Model, tea.Cmd) { a.sessionList.ResetFilter() } - items := buildGroupedItems(remaining, a.sessGroupMode) + items := buildGroupedItems(remaining, a.sessGroupMode, a.sessFolded) a.sessionList.SetItems(items) if idx >= len(items) { idx = len(items) - 1 @@ -3841,8 +4045,18 @@ func (a *App) handleTick() tea.Cmd { a.refreshRespondingState() } + // Poll remote pod phases at most every 30s. Off the main goroutine. + var pollCmd tea.Cmd + if time.Since(a.remoteLastPoll) >= 30*time.Second { + a.remoteLastPoll = time.Now() + pollCmd = pollRemotePhasesCmd() + } + if !a.liveUpdate { - return nil + return pollCmd + } + if pollCmd != nil { + return tea.Batch(a.doRefresh(), pollCmd) } return a.doRefresh() } @@ -4152,6 +4366,16 @@ func (a *App) updateSessionPreview() tea.Cmd { if !a.sessSplit.Show { return nil } + if pi, ok := a.selectedProject(); ok { + cacheKey := fmt.Sprintf("project:%d:%s", a.sessPreviewMode, pi.basePath) + if cacheKey == a.sessSplit.CacheKey { + return nil + } + a.sessSplit.CacheKey = cacheKey + a.sessPreviewPinned = false + a.updateProjectPreview(pi) + return nil + } sess, ok := a.selectedSession() if !ok { return nil @@ -4305,7 +4529,7 @@ func (a *App) updateSessionConvPreview(sess session.Session) { previewW := max(a.width-a.sessSplit.ListWidth(a.width, a.splitRatio)-1, 1) contentH := max(a.height-3, 1) - rendered := renderConversationPreview(visible, previewW, a.sessConvCursor, a.sessConvExpanded, a.sessConvFilterTerm, sess.IsLive) + rendered, _, _, _, _ := renderConversationPreviewWindowed(visible, previewW, a.sessConvCursor, a.sessConvExpanded, a.sessConvFilterTerm, a.convPreviewRowCache, sess.IsLive) content := a.prependConvHeaders(sess, rendered, previewW) @@ -4342,15 +4566,21 @@ func (a *App) refreshConvPreview() { return } previewW := max(a.width-a.sessSplit.ListWidth(a.width, a.splitRatio)-1, 1) - content := renderConversationPreview(visible, previewW, a.sessConvCursor, a.sessConvExpanded, a.sessConvFilterTerm, isLive) + content, renderedVisible, localCursor, localExpanded, windowed := renderConversationPreviewWindowed(visible, previewW, a.sessConvCursor, a.sessConvExpanded, a.sessConvFilterTerm, a.convPreviewRowCache, isLive) if sess, ok2 := a.selectedSession(); ok2 { content = a.prependConvHeaders(sess, content, previewW) } oldOffset := a.sessSplit.Preview.YOffset a.sessSplit.Preview.SetContent(content) - // Scroll to keep cursor visible: estimate cursor line position - cursorLine := convCursorLine(visible, a.sessConvCursor, a.sessConvExpanded, previewW) + // Scroll to keep cursor visible: estimate cursor line position. In + // windowed mode the content is already centered around the cursor, so + // line math should use the rendered window's local cursor instead of the + // full conversation index. + cursorLine := convCursorLine(renderedVisible, localCursor, localExpanded, previewW) + if windowed { + cursorLine += 1 // top "earlier messages" marker + } vpH := a.sessSplit.Preview.Height totalLines := strings.Count(content, "\n") + 1 maxOffset := max(totalLines-vpH, 0) @@ -4429,9 +4659,21 @@ func (a *App) applyConvFilter(term string) { // clearConvFilter removes the conversation preview filter. func (a *App) clearConvFilter() { + // Preserve the currently-highlighted entry: when filter is active the + // cursor indexes into the filtered (subset) slice, but after clearing + // it must index into the full sessConvEntries. Map back via the + // stored sessConvFiltered indices. + targetIdx := -1 + if a.sessConvFilterTerm != "" && a.sessConvCursor >= 0 && a.sessConvCursor < len(a.sessConvFiltered) { + targetIdx = a.sessConvFiltered[a.sessConvCursor] + } a.sessConvFilterTerm = "" a.sessConvFiltered = nil - a.sessConvCursor = 0 + if targetIdx >= 0 && targetIdx < len(a.sessConvEntries) { + a.sessConvCursor = targetIdx + } else { + a.sessConvCursor = 0 + } a.sessConvExpanded = make(map[int]bool) for i := range a.sessConvEntries { a.sessConvExpanded[i] = true @@ -4666,6 +4908,122 @@ func (a *App) updateSessionStatsPreview(sess session.Session) { a.sessSplit.Preview.SetContent(content) } +func (a *App) updateProjectPreview(pi projectItem) { + previewW := max(a.width-a.sessSplit.ListWidth(a.width, a.splitRatio)-1, 1) + contentH := max(a.height-3, 1) + var sb strings.Builder + + title := lipgloss.NewStyle().Bold(true).Foreground(colorPrimary) + section := lipgloss.NewStyle().Bold(true).Foreground(colorAccent) + muted := dimStyle + okStyle := doneBadgeStyle + warnStyle := waitBadgeStyle + errStyle := stuckBadgeStyle + liveStyle := liveBadge + bgStyle := bgBadgeStyle + monStyle := monBadgeStyle + + sb.WriteString(title.Render(pi.displayName)) + if pi.branch != "" { + sb.WriteString(muted.Render(" (" + pi.branch + ")")) + } + sb.WriteString("\n") + sb.WriteString(muted.Render(pi.basePath)) + sb.WriteString("\n\n") + + sb.WriteString(section.Render("Summary") + "\n") + sb.WriteString(fmt.Sprintf("Sessions: %d", len(pi.sessions))) + if pi.worktrees > 0 { + sb.WriteString(muted.Render(fmt.Sprintf(" • Worktrees: %d", pi.worktrees))) + } + sb.WriteString(muted.Render(fmt.Sprintf(" • Messages: %d", pi.totalMsgs))) + sb.WriteString("\n") + + statusParts := []string{} + if pi.liveSessions > 0 { + statusParts = append(statusParts, liveStyle.Render(fmt.Sprintf("LIVE×%d", pi.liveSessions))) + } + if pi.busyCount > 0 { + statusParts = append(statusParts, busyBadge.Render(fmt.Sprintf("BUSY×%d", pi.busyCount))) + } + if pi.bgSessions > 0 { + statusParts = append(statusParts, bgStyle.Render(fmt.Sprintf("BG×%d", pi.bgSessions))) + } + if pi.monSessions > 0 { + statusParts = append(statusParts, monStyle.Render(fmt.Sprintf("MON×%d", pi.monSessions))) + } + if pi.waitCount > 0 { + statusParts = append(statusParts, warnStyle.Render(fmt.Sprintf("WAIT×%d", pi.waitCount))) + } + if pi.stuckCount > 0 { + statusParts = append(statusParts, errStyle.Render(fmt.Sprintf("STUCK×%d", pi.stuckCount))) + } + if pi.doneCount > 0 { + statusParts = append(statusParts, okStyle.Render(fmt.Sprintf("DONE×%d", pi.doneCount))) + } + if len(statusParts) > 0 { + sb.WriteString(strings.Join(statusParts, " ")) + sb.WriteString("\n") + } + if pi.hereCount > 0 { + sb.WriteString(hereBadge.Render(fmt.Sprintf("HERE×%d", pi.hereCount))) + sb.WriteString("\n") + } + + sb.WriteString("\n") + sb.WriteString(section.Render("Sessions in project") + "\n") + for i, s := range pi.sessions { + badgeParts := []string{} + if s.IsCurrentWindow { + badgeParts = append(badgeParts, hereBadge.Render("HERE")) + } + if s.IsLive { + badgeParts = append(badgeParts, liveStyle.Render("LIVE")) + } + if s.HasMonitorJobs && s.IsLive { + badgeParts = append(badgeParts, monStyle.Render("MON")) + } + switch s.Lifecycle() { + case session.LifecycleBusy: + badgeParts = append(badgeParts, busyBadge.Render("BUSY")) + case session.LifecycleBG: + badgeParts = append(badgeParts, bgStyle.Render("BG")) + case session.LifecycleWait: + badgeParts = append(badgeParts, warnStyle.Render("WAIT")) + case session.LifecycleStuck: + badgeParts = append(badgeParts, errStyle.Render("STUCK")) + case session.LifecycleDone: + badgeParts = append(badgeParts, okStyle.Render("DONE")) + } + name := s.ProjectName + if s.IsWorktree { + name = "wt: " + name + } + line := fmt.Sprintf("%2d. %s %s %dm %s", i+1, timeAgo(s.ModTime), s.ShortID, s.MsgCount, name) + sb.WriteString(line) + if len(badgeParts) > 0 { + sb.WriteString(" ") + sb.WriteString(strings.Join(badgeParts, " ")) + } + sb.WriteString("\n") + if s.FirstPrompt != "" { + prompt := s.FirstPrompt + maxPromptW := max(previewW-6, 10) + if len(prompt) > maxPromptW { + prompt = prompt[:maxPromptW-3] + "..." + } + sb.WriteString(muted.Render(" " + prompt)) + sb.WriteString("\n") + } + } + + sb.WriteString("\n") + sb.WriteString(muted.Render("Enter/o: expand or collapse project • move to a child session to open detailed previews")) + + a.sessSplit.Preview = viewport.New(previewW, contentH) + a.sessSplit.Preview.SetContent(sb.String()) +} + func (a *App) updateSessionMemoryPreview(sess session.Session) { if a.sessMemoryCacheKey != sess.ID { a.sessMemoryCache = a.buildMemoryContent(sess) @@ -4855,13 +5213,13 @@ func (a *App) buildShellsPreviewContent(sess session.Session) string { monStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#22D3EE")).Bold(true) for _, j := range jobs { - icon, statusColor := "◉", colorAssistant + icon, statusColor := iconActive, colorAssistant switch j.Status { case "polled": - icon = "◑" + icon = iconProgress statusColor = colorAccent case "killed", "stopped": - icon = "⏹" + icon = iconStopped statusColor = colorDim } statusStyle := lipgloss.NewStyle().Foreground(statusColor).Bold(true) @@ -4945,14 +5303,14 @@ func (a *App) buildTasksPlanContent(sess session.Session) string { } sb.WriteString(dimStyle.Render(label) + "\n\n") for _, t := range tasks { - icon := "○" + icon := iconIdle style := dimStyle switch t.Status { case "completed": - icon = "✓" + icon = iconDone style = lipgloss.NewStyle().Foreground(colorAccent) case "in_progress": - icon = "◉" + icon = iconActive style = lipgloss.NewStyle().Foreground(colorAssistant) } sb.WriteString(style.Render(fmt.Sprintf(" %s %s", icon, t.Subject)) + "\n") @@ -4983,11 +5341,11 @@ func (a *App) buildTasksPlanContent(sess session.Session) string { } sb.WriteString(dimStyle.Render(label) + "\n\n") for _, c := range crons { - icon := "◉" + icon := iconActive style := lipgloss.NewStyle().Foreground(colorAssistant) status := "active" if c.Status == "deleted" { - icon = "⏹" + icon = iconStopped style = dimStyle status = "deleted" } @@ -5070,13 +5428,13 @@ func (a *App) buildAgentsPreviewContent(sess session.Session) string { sb.WriteString(dimStyle.Render(label) + "\n\n") sel := lipgloss.NewStyle().Foreground(lipgloss.Color("#38BDF8")).Bold(true) for i, ag := range agents { - icon := "⊕" + icon := iconAgent style := dimStyle if ag.MsgCount > 0 && ag.MsgCount%2 == 1 { - icon = "◉" + icon = iconActive style = lipgloss.NewStyle().Foreground(colorAssistant) } else if ag.MsgCount > 0 { - icon = "✓" + icon = iconDone style = lipgloss.NewStyle().Foreground(colorAccent) } typeBadge := ag.AgentType @@ -5127,14 +5485,14 @@ func (a *App) buildMemoryContent(sess session.Session) string { } sb.WriteString(dimStyle.Render(fmt.Sprintf("── Todos [%d/%d] ──", completed, len(sess.Todos))) + "\n\n") for _, t := range sess.Todos { - icon := "○" + icon := iconIdle style := dimStyle switch t.Status { case "completed": - icon = "✓" + icon = iconDone style = lipgloss.NewStyle().Foreground(colorAccent) case "in_progress": - icon = "◉" + icon = iconActive style = lipgloss.NewStyle().Foreground(colorAssistant) } sb.WriteString(style.Render(fmt.Sprintf(" %s %s", icon, t.Content)) + "\n") @@ -5275,7 +5633,7 @@ func (a *App) refreshSessionPreviewLive() { } previewW := max(a.width-a.sessSplit.ListWidth(a.width, a.splitRatio)-1, 1) - content := renderConversationPreview(visible, previewW, a.sessConvCursor, a.sessConvExpanded, a.sessConvFilterTerm, true) + content, _, _, _, _ := renderConversationPreviewWindowed(visible, previewW, a.sessConvCursor, a.sessConvExpanded, a.sessConvFilterTerm, a.convPreviewRowCache, true) a.sessSplit.Preview.SetContent(content) if !a.sessPreviewPinned { a.sessSplit.Preview.GotoBottom() @@ -5473,9 +5831,23 @@ func (a *App) resetActiveFilter() { } } case viewConversation: + // Capture stable identity of the selected item before reset so we + // can re-select the same logical entry once the filter is cleared. + // Falling back to the (filtered) index would land on an unrelated + // item because the index space shifts when ResetFilter expands the + // visible items back to the full set. + selID := selectedConvItemID(&a.convList) idx := a.convList.Index() a.convList.ResetFilter() - // Re-select same index (clamped) + if selID != "" { + for i, item := range a.convList.Items() { + if ci, ok := item.(convItem); ok && convItemID(ci) == selID { + a.convList.Select(i) + return + } + } + } + // Fallback: clamp the previous index into the now-larger list. total := len(a.convList.Items()) if idx >= total { idx = total - 1 @@ -5689,7 +6061,8 @@ func (a *App) bumpPastHeader(start, dir int) { } idx := start for idx >= 0 && idx < len(visible) { - if _, ok := visible[idx].(sessionItem); ok { + switch visible[idx].(type) { + case sessionItem, projectItem: a.sessionList.Select(idx) return } @@ -5704,7 +6077,7 @@ func (a *App) resizeAll() tea.Cmd { sessW := a.sessSplit.ListWidth(a.width, a.splitRatio) if a.sessionList.Width() == 0 { if len(a.sessions) > 0 { - a.sessionList = newSessionList(a.sessions, sessW, contentH, a.sessGroupMode, a.selectedSet, a.hiddenBadges, a.config.WorktreeDir) + a.sessionList = newSessionList(a.sessions, sessW, contentH, a.sessGroupMode, a.selectedSet, a.hiddenBadges, a.sessFolded, a.sessionRowCache, a.config.WorktreeDir) a.sessSplit.CacheKey = "" if a.config.SearchQuery != "" { applyListFilter(&a.sessionList, a.config.SearchQuery) @@ -5807,7 +6180,7 @@ func (a *App) rebuildSessionList() { contentH := max(a.height-3, 1) sessW := a.sessSplit.ListWidth(a.width, a.splitRatio) - a.sessionList = newSessionList(a.sessions, sessW, contentH, a.sessGroupMode, a.selectedSet, a.hiddenBadges, a.config.WorktreeDir) + a.sessionList = newSessionList(a.sessions, sessW, contentH, a.sessGroupMode, a.selectedSet, a.hiddenBadges, a.sessFolded, a.sessionRowCache, a.config.WorktreeDir) a.sessSplit.CacheKey = "" // Reapply filter @@ -5834,6 +6207,120 @@ func (a *App) rebuildSessionList() { a.bumpPastHeader(0, +1) } +// toggleSessGroupFoldAtCursor flips the fold state of the group at the +// current cursor row. Works for any row in the group (header or child). +// When called on a non-group row it's a no-op so the key is safe to press. +func (a *App) toggleSessGroupFoldAtCursor() { + // Project rows (project-centric view) have their own toggle key. + if pi, ok := a.sessionList.SelectedItem().(projectItem); ok { + a.toggleProjectFold(pi) + return + } + si, ok := a.sessionList.SelectedItem().(sessionItem) + if !ok { + return + } + key := si.groupKey + if key == "" { + // Child rows don't carry the groupKey; walk back to the header + // that contains them. + idx := a.sessionList.Index() + items := a.sessionList.VisibleItems() + for i := idx - 1; i >= 0; i-- { + parent, ok := items[i].(sessionItem) + if !ok { + continue + } + if parent.groupKey != "" { + key = parent.groupKey + break + } + if parent.treeDepth == 0 { + // Hit a non-group top-level row before finding a header. + break + } + } + } + if key == "" { + return + } + if a.sessFolded == nil { + a.sessFolded = make(map[string]bool) + } + a.sessFolded[key] = !a.sessFolded[key] + a.rebuildSessionList() + // After rebuild, try to land the cursor on the now-toggled header so + // the user can immediately re-expand or move on without searching for + // their row again. + for i, item := range a.sessionList.VisibleItems() { + if s, ok := item.(sessionItem); ok && s.groupKey == key { + a.sessionList.Select(i) + break + } + } +} + +// setAllSessGroupsFolded sets the fold flag for every visible group head. +// Used by `f` (fold all) and `F` (expand all). +func (a *App) setAllSessGroupsFolded(folded bool) { + if a.sessFolded == nil { + a.sessFolded = make(map[string]bool) + } + // Capture currently selected session ID so cursor stays anchored. + var selID string + if sess, ok := a.selectedSession(); ok { + selID = sess.ID + } + for _, item := range a.sessionList.Items() { + switch v := item.(type) { + case sessionItem: + if v.groupKey == "" { + continue + } + if folded { + a.sessFolded[v.groupKey] = true + } else { + delete(a.sessFolded, v.groupKey) + } + case projectItem: + key := "repo:" + v.basePath + if folded { + a.sessFolded[key] = true + } else { + delete(a.sessFolded, key) + } + } + } + a.rebuildSessionList() + if selID == "" { + return + } + for i, item := range a.sessionList.VisibleItems() { + if si, ok := item.(sessionItem); ok && si.sess.ID == selID { + a.sessionList.Select(i) + return + } + } +} + +// toggleProjectFold flips the fold state of a project row. The cursor +// stays on the same project row after rebuild so the user can immediately +// fold/unfold again or move on. +func (a *App) toggleProjectFold(pi projectItem) { + if a.sessFolded == nil { + a.sessFolded = make(map[string]bool) + } + key := "repo:" + pi.basePath + a.sessFolded[key] = !a.sessFolded[key] + a.rebuildSessionList() + for i, item := range a.sessionList.VisibleItems() { + if p, ok := item.(projectItem); ok && p.basePath == pi.basePath { + a.sessionList.Select(i) + break + } + } +} + func (a *App) listReady(l *list.Model) bool { return l.Width() > 0 } @@ -5866,7 +6353,7 @@ func (a *App) renderBreadcrumb() string { switch a.state { case viewSessions: - crumbs = []crumb{{" Sessions", viewSessions}} + crumbs = []crumb{{" Projects", viewSessions}} // Show selected project name in breadcrumb if sess, ok := a.selectedSession(); ok && a.sessionList.Width() > 0 { proj := sess.ProjectName @@ -5884,7 +6371,7 @@ func (a *App) renderBreadcrumb() string { } case viewConversation: crumbs = []crumb{ - {" Sessions", viewSessions}, + {" Projects", viewSessions}, {a.currentSess.ShortID, viewConversation}, } if a.conv.agent.ShortID != "" { @@ -5926,7 +6413,7 @@ func (a *App) renderBreadcrumb() string { } case viewMessageFull: crumbs = []crumb{ - {" Sessions", viewSessions}, + {" Projects", viewSessions}, {a.currentSess.ShortID, viewConversation}, } // Add nav stack context @@ -6049,14 +6536,15 @@ func (a *App) renderBreadcrumb() string { func (a *App) breadcrumbRightStatus() string { var parts []string - // Session group mode badge (styled) + // Main browser badge: always present it as PROJECTS in the UI even if + // alternate legacy grouping modes still exist internally. if a.state == viewSessions { - modeLabels := []string{"FLAT", "PROJ", "TREE", "CHAIN", "FORK", "REPO"} - modeColors := []lipgloss.Color{"#9CA3AF", "#3B82F6", "#10B981", "#F59E0B", "#EC4899", "#7C3AED"} - ml := modeLabels[a.sessGroupMode] - mc := modeColors[a.sessGroupMode] - modeStyle := lipgloss.NewStyle().Foreground(mc).Bold(true) - parts = append(parts, modeStyle.Render(ml)) + modeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#A78BFA")).Bold(true) + parts = append(parts, modeStyle.Render("PROJECTS")) + if strings.TrimSpace(a.activeFilterValue()) == "is:done" { + doneMode := lipgloss.NewStyle().Foreground(lipgloss.Color("#10B981")).Bold(true) + parts = append(parts, doneMode.Render("DONE-ONLY")) + } if a.hasMultiSelection() { parts = append(parts, fmt.Sprintf("%d selected", len(a.selectedSet))) } @@ -6229,7 +6717,7 @@ func scrollPreview(vp *viewport.Model, key string) { func roleLabel(e session.Entry) string { if e.Role == "user" { - return "User" + return roleChip("user") } - return "Assistant" + return roleChip("assistant") } diff --git a/internal/tui/cmdmode.go b/internal/tui/cmdmode.go index 614cff3..67b9558 100644 --- a/internal/tui/cmdmode.go +++ b/internal/tui/cmdmode.go @@ -64,6 +64,12 @@ func buildCmdRegistry() []cmdEntry { a.rebuildSessionList() return a, nil }}, + {name: "group:projects", aliases: []string{"g:projects", "projects"}, desc: "project-centric view", views: cmdSessions, + action: func(a *App) (tea.Model, tea.Cmd) { + a.sessGroupMode = groupProjectCentric + a.rebuildSessionList() + return a, nil + }}, // Conversation detail levels {name: "detail:compact", aliases: []string{"d:compact"}, desc: "text only", views: cmdConv, @@ -104,8 +110,8 @@ func buildCmdRegistry() []cmdEntry { // Views { - name: "view:sessions", aliases: []string{"v:sessions", "v:sess"}, - desc: "session browser", + name: "view:projects", aliases: []string{"v:projects", "v:proj", "view:sessions", "v:sessions", "v:sess"}, + desc: "project browser", action: func(a *App) (tea.Model, tea.Cmd) { a.state = viewSessions return a, nil @@ -283,6 +289,10 @@ func buildCmdRegistry() []cmdEntry { }}, {name: "refresh", aliases: []string{"R"}, desc: "refresh sessions", views: cmdSessions, action: func(a *App) (tea.Model, tea.Cmd) { cmd := a.doRefresh(); a.copiedMsg = "Refreshed"; return a, cmd }}, + {name: "filter:done", aliases: []string{"done", "completed"}, desc: "show completed-only projects", views: cmdSessions, + action: func(a *App) (tea.Model, tea.Cmd) { a.setSessionListFilter("is:done"); return a, a.updateSessionPreview() }}, + {name: "filter:all", aliases: []string{"all"}, desc: "clear project filter", views: cmdSessions, + action: func(a *App) (tea.Model, tea.Cmd) { a.setSessionListFilter(""); return a, a.updateSessionPreview() }}, // Global {name: "config:edit", aliases: []string{"cfg:edit", "keymap:edit", "km:edit"}, desc: "edit config", @@ -332,10 +342,51 @@ func buildCmdRegistry() []cmdEntry { // Remote execution {name: "remote:start", aliases: []string{"r:start"}, desc: "resume session remotely", action: func(a *App) (tea.Model, tea.Cmd) { return a.executeCmdRemoteStart("remote:start") }}, - {name: "remote:stop", aliases: []string{"r:stop"}, desc: "stop remote + delete pod", + {name: "remote:stop", aliases: []string{"r:stop", "remote:rm"}, desc: "stop remote + delete pod", action: func(a *App) (tea.Model, tea.Cmd) { return a.stopRemoteSession() }}, + {name: "remote:stop-pull", aliases: []string{"r:stop-pull"}, desc: "pull workdir then stop pod", + action: func(a *App) (tea.Model, tea.Cmd) { return a.stopRemoteSessionWithPull() }}, {name: "remote:attach", aliases: []string{"r:attach"}, desc: "reattach to remote Claude", action: func(a *App) (tea.Model, tea.Cmd) { return a.reconnectRemoteSession() }}, + {name: "remote:ls", aliases: []string{"r:ls"}, desc: "jump to first remote session", + action: func(a *App) (tea.Model, tea.Cmd) { return a.executeCmdRemoteLs() }}, + {name: "remote:phase", aliases: []string{"r:phase"}, desc: "show pod phase for selected remote", + action: func(a *App) (tea.Model, tea.Cmd) { return a.executeCmdRemotePhase() }}, + {name: "remote:exec", aliases: []string{"r:exec"}, desc: "kubectl exec in selected remote pod", + action: func(a *App) (tea.Model, tea.Cmd) { + a.copiedMsg = "Usage: remote:exec " + return a, nil + }}, + {name: "remote:snapshot", aliases: []string{"r:snap"}, desc: "save remote workdir + session", + action: func(a *App) (tea.Model, tea.Cmd) { return a.executeCmdRemoteSnapshot("remote:snapshot") }}, + {name: "remote:snapshots", aliases: []string{"r:snaps"}, desc: "list saved snapshots", + action: func(a *App) (tea.Model, tea.Cmd) { return a.executeCmdRemoteSnapshots() }}, + {name: "remote:restore", aliases: []string{"r:restore"}, desc: "boot new pod from snapshot", + action: func(a *App) (tea.Model, tea.Cmd) { + a.copiedMsg = "Usage: remote:restore [context] [namespace]" + return a, nil + }}, + {name: "remote:fork", aliases: []string{"r:fork"}, desc: "clone selected pod into a fresh one", + action: func(a *App) (tea.Model, tea.Cmd) { return a.executeCmdRemoteFork("") }}, + {name: "remote:pull", aliases: []string{"r:pull", "remote:sync-down", "r:sync-down"}, desc: "fetch pod workdir back to host", + action: func(a *App) (tea.Model, tea.Cmd) { return a.executeCmdRemotePull("remote:pull") }}, + {name: "remote:sync-up", aliases: []string{"r:sync-up"}, desc: "sync local session/workdir to remote pod", + action: func(a *App) (tea.Model, tea.Cmd) { return a.executeCmdRemoteStart("remote:sync-up") }}, + {name: "remote:rm-snap", aliases: []string{"r:rm-snap"}, desc: "delete a saved snapshot", + action: func(a *App) (tea.Model, tea.Cmd) { + a.copiedMsg = "Usage: remote:rm-snap " + return a, nil + }}, + {name: "remote:export-snap", aliases: []string{"r:export-snap"}, desc: "export snapshot as tar.gz", + action: func(a *App) (tea.Model, tea.Cmd) { + a.copiedMsg = "Usage: remote:export-snap " + return a, nil + }}, + {name: "remote:import-snap", aliases: []string{"r:import-snap"}, desc: "import snapshot tar.gz", + action: func(a *App) (tea.Model, tea.Cmd) { + a.copiedMsg = "Usage: remote:import-snap [snapshot-name]" + return a, nil + }}, } } @@ -584,11 +635,48 @@ func (a *App) executeCommand(input string) (tea.Model, tea.Cmd) { return a.executeCmdWorktreeNew(input) } - // Check remote:start [branch] [prompt] - if strings.HasPrefix(lower, "remote:start") || strings.HasPrefix(lower, "r:start") { + // Check remote:start / remote:sync-up [key=value...] [prompt...] + if strings.HasPrefix(lower, "remote:start") || strings.HasPrefix(lower, "r:start") || + strings.HasPrefix(lower, "remote:sync-up") || strings.HasPrefix(lower, "r:sync-up") { return a.executeCmdRemoteStart(input) } + // Check remote:exec + if strings.HasPrefix(lower, "remote:exec") || strings.HasPrefix(lower, "r:exec") { + return a.executeCmdRemoteExec(input) + } + + // Check remote:snapshot [name] + if strings.HasPrefix(lower, "remote:snapshot") || strings.HasPrefix(lower, "r:snap") { + return a.executeCmdRemoteSnapshot(input) + } + + // Check remote:restore + if strings.HasPrefix(lower, "remote:restore") || strings.HasPrefix(lower, "r:restore") { + return a.executeCmdRemoteRestore(input) + } + + // Check remote:pull / remote:sync-down [target-dir] + if strings.HasPrefix(lower, "remote:pull") || strings.HasPrefix(lower, "r:pull") || + strings.HasPrefix(lower, "remote:sync-down") || strings.HasPrefix(lower, "r:sync-down") { + return a.executeCmdRemotePull(input) + } + + // Check remote:rm-snap + if strings.HasPrefix(lower, "remote:rm-snap") || strings.HasPrefix(lower, "r:rm-snap") { + return a.executeCmdRemoteRmSnap(input) + } + + // Check remote:export-snap + if strings.HasPrefix(lower, "remote:export-snap") || strings.HasPrefix(lower, "r:export-snap") { + return a.executeCmdRemoteExportSnap(input) + } + + // Check remote:import-snap [name] + if strings.HasPrefix(lower, "remote:import-snap") || strings.HasPrefix(lower, "r:import-snap") { + return a.executeCmdRemoteImportSnap(input) + } + // Split into parts for multi-command support parts := strings.Fields(lower) var cmds []tea.Cmd diff --git a/internal/tui/cmdmode_test.go b/internal/tui/cmdmode_test.go index bb3c95f..4b6db25 100644 --- a/internal/tui/cmdmode_test.go +++ b/internal/tui/cmdmode_test.go @@ -136,7 +136,7 @@ func TestRegistryCompleteness(t *testing.T) { "pane:flat": false, "pane:tree": false, "preview:conv": false, "preview:stats": false, "preview:mem": false, "preview:tasks": false, "preview:agents": false, "preview:contexts": false, "preview:live": false, - "view:sessions": false, "view:stats": false, "view:config": false, + "view:projects": false, "view:stats": false, "view:config": false, "view:config:hooks": false, "view:plugins": false, "view:stats:tools": false, "view:stats:mcp": false, "view:stats:agents": false, "view:stats:skills": false, diff --git a/internal/tui/config.go b/internal/tui/config.go index f156029..c0ac263 100644 --- a/internal/tui/config.go +++ b/internal/tui/config.go @@ -58,11 +58,11 @@ func (d cfgDelegate) Render(w io.Writer, m list.Model, index int, item list.Item isMultiSelected := !ci.isHeader && d.selectedSet != nil && d.selectedSet[ci.item.Path] cursor := " " if selected && isMultiSelected { - cursor = lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render("✓ ") + cursor = lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render(iconSelect + " ") } else if isMultiSelected { - cursor = lipgloss.NewStyle().Foreground(colorPrimary).Render("✓ ") + cursor = lipgloss.NewStyle().Foreground(colorPrimary).Render(iconSelect + " ") } else if selected { - cursor = lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render("▸ ") + cursor = lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render(iconFoldClosed + " ") } cursorW := 2 @@ -2046,7 +2046,7 @@ func (a *App) renderProjectPickerOverlay(bg string) string { display = "…" + display[len(display)-boxW+7:] } if i == a.cfgProjectCursor { - lines = append(lines, cursorStyle.Render("▸ ")+selStyle.Render(display)) + lines = append(lines, cursorStyle.Render(iconFoldClosed+" ")+selStyle.Render(display)) } else { lines = append(lines, " "+nameStyle.Render(display)) } diff --git a/internal/tui/conversation.go b/internal/tui/conversation.go index b63dc03..97f0905 100644 --- a/internal/tui/conversation.go +++ b/internal/tui/conversation.go @@ -665,7 +665,12 @@ func (a *App) handleConversationKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { result = sp.HandleFocusedKeys(key) switch result { case splitKeySearchFromPreview: - if a.conv.rightPaneMode != previewText { + // Always run the in-pane block filter when the preview pane is + // focused — it filters the blocks of the current message, which + // is the user's actual mental model of "search inside the + // preview". Previously text mode dropped back into the convList + // filter, which felt like `/` was broken inside the preview. + if sp.Folds != nil { a.startBlockFilter() return a, nil } @@ -1615,7 +1620,7 @@ func captureConvPreviewAnchor(sp *SplitPane, baseKey string) convPreviewAnchor { // previewHeaderRE matches a role-header line like "USER" or "ASSISTANT 12:00:03" // produced by compactPreviewMessageText / previewMessageText. -var previewHeaderRE = regexp.MustCompile(`^[A-Z][A-Z_]*(\s+\d{2}:\d{2}:\d{2})?$`) +var previewHeaderRE = regexp.MustCompile(`^[^\s]+(\s+[^\s]+)?(\s+\d{2}:\d{2}:\d{2})?$`) // previewToolSummaryRE extracts tool names from a previewMessageText summary // such as "[TaskUpdate]", "[[TaskUpdate]]", or "[Read×3, Edit]". @@ -1906,11 +1911,10 @@ func summarizeToolResult(b session.ContentBlock) session.ContentBlock { } func compactPreviewMessageText(e session.Entry) string { - role := strings.ToUpper(e.Role) - if role == "" { - role = "ENTRY" + header := roleChip(e.Role) + if header == "" { + header = "entry" } - header := role if !e.Timestamp.IsZero() { header += " " + e.Timestamp.Format("15:04:05") } @@ -1929,11 +1933,10 @@ func compactPreviewMessageText(e session.Entry) string { } func previewMessageText(e session.Entry) string { - role := strings.ToUpper(e.Role) - if role == "" { - role = "ENTRY" + header := roleChip(e.Role) + if header == "" { + header = "entry" } - header := role if !e.Timestamp.IsZero() { header += " " + e.Timestamp.Format("15:04:05") } @@ -2103,9 +2106,9 @@ func (a *App) buildCronPreviewEntry(cron session.CronItem) session.Entry { func renderCronSummary(cron session.CronItem, width int) string { var sb strings.Builder - status := "◉ active" + status := iconActive + " active" if cron.Status == "deleted" { - status = "⏹ deleted" + status = iconStopped + " deleted" } name := cron.ID if name == "" { @@ -2515,14 +2518,14 @@ func (a *App) renderConvTaskBoard(width int) string { var sb strings.Builder sb.WriteString(dimStyle.Render(fmt.Sprintf("── Tasks [%d/%d] ──", completed, len(tasks))) + "\n\n") for _, t := range tasks { - icon := "○" + icon := iconIdle style := dimStyle switch t.Status { case "completed": - icon = "✓" + icon = iconDone style = lipgloss.NewStyle().Foreground(colorAccent) case "in_progress": - icon = "◉" + icon = iconActive style = lipgloss.NewStyle().Foreground(colorAssistant) } idTag := "" @@ -2574,7 +2577,7 @@ func (a *App) renderAgentsSummary(width int) string { if isSystemAgent(ag) { continue } - icon := "●" + icon := iconFocused status := statuses[ag.ID] if status == "" { status = statuses[ag.ShortID] @@ -2582,13 +2585,13 @@ func (a *App) renderAgentsSummary(width int) string { style := dimStyle switch status { case "completed": - icon = "✓" + icon = iconDone style = lipgloss.NewStyle().Foreground(colorAccent) case "running": - icon = "◉" + icon = iconActive style = lipgloss.NewStyle().Foreground(colorAssistant) case "stopped": - icon = "⏹" + icon = iconStopped } typeBadge := "" if ag.AgentType != "" { @@ -2632,14 +2635,14 @@ func (a *App) renderBgJobsSummary(width int) string { } } } - icon := "⏳" + icon := iconWaiting style := dimStyle switch status { case "completed": - icon = "✓" + icon = iconDone style = lipgloss.NewStyle().Foreground(colorAccent) case "stopped": - icon = "⏹" + icon = iconStopped } label := desc if len(label) > width-10 { @@ -2697,7 +2700,7 @@ func (a *App) scrollConvPreviewToTail() { lastBlock := len(sp.Folds.Entry.Content) - 1 if sp.Folds.BlockCursor != lastBlock { sp.Folds.BlockCursor = lastBlock - // Re-render so the ▸ cursor marker reflects the new position + // Re-render so the cursor marker reflects the new position sp.RefreshFoldCursor(a.width, a.splitRatio) } } diff --git a/internal/tui/conversation_models.go b/internal/tui/conversation_models.go index 2a638e7..72fa50e 100644 --- a/internal/tui/conversation_models.go +++ b/internal/tui/conversation_models.go @@ -38,6 +38,49 @@ type convItem struct { label string // optional compact label for tree items } +// convItemID returns a stable identity string for a convItem. Used to +// re-select the same logical row after operations that shift list indices +// (e.g. clearing a filter). +func convItemID(c convItem) string { + switch c.kind { + case convMsg: + if c.merged.entry.UUID != "" { + return "msg:" + c.merged.entry.UUID + } + case convTask: + if c.task.ID != "" { + return "task:" + c.task.ID + } + if c.bgTaskID != "" { + return "bgtask:" + c.bgTaskID + } + if c.cron.ID != "" { + return "cron:" + c.cron.ID + } + case convAgent: + if c.agent.ID != "" { + return "agent:" + c.agent.ID + } + if c.agent.ShortID != "" { + return "agent:" + c.agent.ShortID + } + case convSessionMeta: + return "meta:" + c.sessionMeta + } + // Fallback: groupTag headers and unidentified items use label+groupTag. + return "lbl:" + c.groupTag + ":" + c.label +} + +// selectedConvItemID returns convItemID for the currently selected item in +// the list, or "" when nothing is selected. +func selectedConvItemID(l *list.Model) string { + ci, ok := l.SelectedItem().(convItem) + if !ok { + return "" + } + return convItemID(ci) +} + func (c convItem) FilterValue() string { var parts []string if c.label != "" { diff --git a/internal/tui/conversation_preview_cache_test.go b/internal/tui/conversation_preview_cache_test.go new file mode 100644 index 0000000..fa46f58 --- /dev/null +++ b/internal/tui/conversation_preview_cache_test.go @@ -0,0 +1,55 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "github.com/sendbird/ccx/internal/session" +) + +func TestRenderConversationPreviewCacheMatchesColdOutput(t *testing.T) { + msgs := []mergedMsg{ + { + entry: session.Entry{ + UUID: "u1", + Role: "user", + Timestamp: time.Date(2026, 5, 22, 10, 0, 0, 0, time.UTC), + Content: []session.ContentBlock{{ + Type: "text", + Text: strings.Repeat("hello world ", 8), + }}, + }, + startIdx: 0, + endIdx: 0, + }, + { + entry: session.Entry{ + UUID: "u2", + Role: "assistant", + Timestamp: time.Date(2026, 5, 22, 10, 1, 0, 0, time.UTC), + Content: []session.ContentBlock{ + {Type: "text", Text: "assistant reply"}, + {Type: "tool_use", ToolName: "Bash", ToolInput: `{"command":"go test ./..."}`}, + }, + }, + startIdx: 1, + endIdx: 1, + }, + } + expanded := map[int]bool{1: true} + + cold := renderConversationPreview(msgs, 80, 1, expanded, "reply", nil) + cache := newSessionRowCache(16) + cachedCold := renderConversationPreview(msgs, 80, 1, expanded, "reply", cache) + if cachedCold != cold { + t.Fatalf("cached cold output mismatch\nwant:\n%s\ngot:\n%s", cold, cachedCold) + } + if len(cache.items) != len(msgs) { + t.Fatalf("expected %d cached rows, got %d", len(msgs), len(cache.items)) + } + cachedWarm := renderConversationPreview(msgs, 80, 1, expanded, "reply", cache) + if cachedWarm != cold { + t.Fatalf("cached warm output mismatch\nwant:\n%s\ngot:\n%s", cold, cachedWarm) + } +} diff --git a/internal/tui/conversation_preview_window_test.go b/internal/tui/conversation_preview_window_test.go new file mode 100644 index 0000000..f0d65ec --- /dev/null +++ b/internal/tui/conversation_preview_window_test.go @@ -0,0 +1,82 @@ +package tui + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/sendbird/ccx/internal/session" +) + +func makeWindowedPreviewMsgs(n int) []mergedMsg { + now := time.Now() + msgs := make([]mergedMsg, 0, n) + for i := 0; i < n; i++ { + msgs = append(msgs, mergedMsg{ + entry: session.Entry{ + UUID: fmt.Sprintf("windowed-%03d", i), + Role: "assistant", + Timestamp: now.Add(time.Duration(i) * time.Minute), + Content: []session.ContentBlock{{ + Type: "text", + Text: fmt.Sprintf("message %03d %s", i, strings.Repeat("payload ", 6)), + }}, + }, + startIdx: i, + endIdx: i, + }) + } + return msgs +} + +func TestRenderConversationPreviewWindowedFallsBackForSmallInputs(t *testing.T) { + msgs := makeWindowedPreviewMsgs(10) + cache := newSessionRowCache(64) + full := renderConversationPreview(msgs, 80, 3, nil, "", cache) + windowed, rendered, localCursor, localExpanded, usedWindow := renderConversationPreviewWindowed(msgs, 80, 3, nil, "", cache) + if usedWindow { + t.Fatal("expected small input to skip windowing") + } + if windowed != full { + t.Fatalf("expected fallback output to match full render") + } + if len(rendered) != len(msgs) { + t.Fatalf("expected rendered slice len=%d, got %d", len(msgs), len(rendered)) + } + if localCursor != 3 { + t.Fatalf("expected localCursor=3, got %d", localCursor) + } + if localExpanded != nil { + t.Fatalf("expected localExpanded=nil for fallback path") + } +} + +func TestRenderConversationPreviewWindowedSlicesLargeInputs(t *testing.T) { + msgs := makeWindowedPreviewMsgs(convPreviewWindowThreshold + 40) + cursor := convPreviewWindowThreshold / 2 + expanded := map[int]bool{cursor: true, cursor + 1: true} + cache := newSessionRowCache(1024) + content, rendered, localCursor, localExpanded, usedWindow := renderConversationPreviewWindowed(msgs, 90, cursor, expanded, "", cache) + if !usedWindow { + t.Fatal("expected large input to use windowing") + } + if len(rendered) >= len(msgs) { + t.Fatalf("expected rendered window to be smaller than full message set (%d >= %d)", len(rendered), len(msgs)) + } + if localCursor < 0 || localCursor >= len(rendered) { + t.Fatalf("localCursor out of range: %d (window size %d)", localCursor, len(rendered)) + } + if !strings.Contains(content, "earlier messages hidden") { + t.Fatalf("expected top elision marker in content, got %q", content) + } + if !strings.Contains(content, "later messages hidden") { + t.Fatalf("expected bottom elision marker in content, got %q", content) + } + if localExpanded == nil { + t.Fatal("expected localExpanded to be non-nil in windowed mode") + } + if len(localExpanded) == 0 { + t.Fatal("expected at least one expanded message to be preserved in the local window") + } +} diff --git a/internal/tui/conversation_render.go b/internal/tui/conversation_render.go index 4b445da..d4908d4 100644 --- a/internal/tui/conversation_render.go +++ b/internal/tui/conversation_render.go @@ -21,11 +21,11 @@ func renderConvMsg(w io.Writer, ci convItem, selected bool, width int, clamp lip isCompacted := isAutoCompacted(e) - role := userLabelStyle.Render("USER") + role := userLabelStyle.Render(roleChip("user")) if isCompacted { - role = compactBadgeStyle.Render("CMPX") + role = compactBadgeStyle.Render(roleChip("compact")) } else if e.Role == "assistant" { - role = assistantLabelStyle.Render("ASST") + role = assistantLabelStyle.Render(roleChip("assistant")) } ts := " " @@ -60,7 +60,7 @@ func renderConvMsg(w io.Writer, ci convItem, selected bool, width int, clamp lip imgBadge := "" for _, block := range e.Content { if block.Type == "image" { - imgBadge = " " + lipgloss.NewStyle().Foreground(lipgloss.Color("#87CEEB")).Render("▣") + imgBadge = " " + lipgloss.NewStyle().Foreground(lipgloss.Color("#87CEEB")).Render(iconImage) break } } @@ -82,35 +82,35 @@ func renderConvTaskOrAgent(w io.Writer, ci convItem, selected bool, width int, c style = selectedStyle } if ci.groupTag != "" { - fold := "▾" + fold := iconFoldOpen if ci.folded { - fold = "▸" + fold = iconFoldClosed } line := fmt.Sprintf("%s%s %s", indent, cursor, style.Render(fmt.Sprintf("%s %s [%d]", fold, ci.label, ci.count))) fmt.Fprint(w, clamp.Render(line)) return } - status := "○" + status := iconIdle switch ci.kind { case convAgent: - status = agentBadgeStyle.Render("⊕") + status = agentBadgeStyle.Render(iconAgent) switch ci.agentStatus { case "completed": - status = taskDoneStyle.Render("✓") + status = taskDoneStyle.Render(iconDone) case "stopped": - status = dimStyle.Render("⏹") + status = dimStyle.Render(iconStopped) case "running": - status = lipgloss.NewStyle().Foreground(lipgloss.Color("#22C55E")).Render("◉") + status = lipgloss.NewStyle().Foreground(lipgloss.Color("#22C55E")).Render(iconActive) } case convTask: switch ci.task.Status { case "completed": - status = taskDoneStyle.Render("✓") + status = taskDoneStyle.Render(iconDone) case "in_progress": - status = taskInProgressStyle.Render("◉") + status = taskInProgressStyle.Render(iconActive) case "stopped": - status = dimStyle.Render("⏹") + status = dimStyle.Render(iconStopped) } } @@ -146,14 +146,14 @@ func renderConvTaskOrAgent(w io.Writer, ci convItem, selected bool, width int, c var label string if ci.count > 0 { // Expandable header (last task-touching message) - fold := "▸" + fold := iconFoldClosed if !ci.folded { - fold = "▾" + fold = iconFoldOpen } if selected { - label = fmt.Sprintf("%s Tasks [%s]", fold, counter+" ✓") + label = fmt.Sprintf("%s Tasks [%s]", fold, counter+" "+iconDone) } else { - label = fmt.Sprintf("%s Tasks [%s]", fold, counterStyle.Render(counter+" ✓")) + label = fmt.Sprintf("%s Tasks [%s]", fold, counterStyle.Render(counter+" "+iconDone)) } } else { // Marker header — show per-message operation summary @@ -170,9 +170,9 @@ func renderConvTaskOrAgent(w io.Writer, ci convItem, selected bool, width int, c label = "· " + style.Render(opDesc) } else { if selected { - label = fmt.Sprintf("· Tasks [%s]", counter+" ✓") + label = fmt.Sprintf("· Tasks [%s]", counter+" "+iconDone) } else { - label = fmt.Sprintf("· Tasks [%s]", counterStyle.Render(counter+" ✓")) + label = fmt.Sprintf("· Tasks [%s]", counterStyle.Render(counter+" "+iconDone)) } } } @@ -181,12 +181,12 @@ func renderConvTaskOrAgent(w io.Writer, ci convItem, selected bool, width int, c return } - status := "○" + status := iconIdle switch ci.task.Status { case "completed": - status = taskDoneStyle.Render("✓") + status = taskDoneStyle.Render(iconDone) case "in_progress": - status = taskInProgressStyle.Render("◉") + status = taskInProgressStyle.Render(iconActive) } idLabel := "" if ci.task.ID != "" { @@ -210,9 +210,9 @@ func renderConvTaskOrAgent(w io.Writer, ci convItem, selected bool, width int, c case convAgent: // Group header for unattached agents if ci.groupTag != "" { - fold := "▸" + fold := iconFoldClosed if !ci.folded { - fold = "▾" + fold = iconFoldOpen } label := fmt.Sprintf("%s Agents [%d]", fold, ci.count) style := dimStyle @@ -223,14 +223,14 @@ func renderConvTaskOrAgent(w io.Writer, ci convItem, selected bool, width int, c break } a := ci.agent - badge := agentBadgeStyle.Render("⊕") + badge := agentBadgeStyle.Render(iconAgent) switch ci.agentStatus { case "completed": - badge = taskDoneStyle.Render("✓") + badge = taskDoneStyle.Render(iconDone) case "stopped": - badge = dimStyle.Render("⏹") + badge = dimStyle.Render(iconStopped) case "running": - badge = lipgloss.NewStyle().Foreground(lipgloss.Color("#22C55E")).Render("◉") + badge = lipgloss.NewStyle().Foreground(lipgloss.Color("#22C55E")).Render(iconActive) } typeStr := "" if a.AgentType == "aside_question" { @@ -638,20 +638,20 @@ func buildConvItems(sess session.Session, merged []mergedMsg, agents []session.S } json.Unmarshal([]byte(b.ToolInput), &input) if input.Subject != "" { - icon, verb, subject = "📋", "Create", input.Subject + icon, verb, subject = iconTask, "Create", input.Subject } case "TaskOutput": var input struct { TaskID string `json:"task_id"` } json.Unmarshal([]byte(b.ToolInput), &input) - taskID, icon, verb = input.TaskID, "⏳", "Waiting" + taskID, icon, verb = input.TaskID, iconWaiting, "Waiting" case "TaskStop": var input struct { TaskID string `json:"task_id"` } json.Unmarshal([]byte(b.ToolInput), &input) - taskID, icon, verb = input.TaskID, "⏹", "Stop" + taskID, icon, verb = input.TaskID, iconStopped, "Stop" } if taskID == "" && subject == "" { continue @@ -733,9 +733,9 @@ func buildConvItems(sess session.Session, merged []mergedMsg, agents []session.S continue } cron = session.CronItem{Cron: input.Cron, Prompt: input.Prompt, Recurring: input.Recurring, Status: "active"} - label = "◉ Create " + strings.TrimSpace(input.Cron) - if label == "◉ Create" { - label = "◉ Create cron" + label = iconActive + " Create " + strings.TrimSpace(input.Cron) + if label == iconActive+" Create" { + label = iconActive + " Create cron" } case "CronDelete": var input struct { @@ -749,16 +749,16 @@ func buildConvItems(sess session.Session, merged []mergedMsg, agents []session.S cron.ID = input.ID cron.Status = "deleted" } - label = "⏹ Delete #" + input.ID + label = iconStopped + " Delete #" + input.ID if cron.Cron != "" { label += " " + cron.Cron } case "CronGet": - label = "📋 Read cron" + label = iconTask + " Read cron" case "CronList": - label = "📋 List crons" + label = iconTask + " List crons" case "CronUpdate": - label = "◉ Update cron" + label = iconActive + " Update cron" } items = append(items, convItem{ kind: convTask, @@ -1178,12 +1178,12 @@ func taskOpSummaryResult(entry session.Entry, tasksByID map[string]session.TaskI if input.Status == "" { continue } - icon := "○" + icon := iconIdle switch input.Status { case "completed": - icon = "✓" + icon = iconDone case "in_progress": - icon = "◉" + icon = iconActive } compactLabel := icon + " #" + input.TaskID detailLabel := icon + " #" + input.TaskID @@ -1202,7 +1202,7 @@ func taskOpSummaryResult(entry session.Entry, tasksByID map[string]session.TaskI TaskID string `json:"task_id"` } json.Unmarshal([]byte(b.ToolInput), &input) - label, detail := resolveTaskLabel("⏳", "Waiting", input.TaskID, agentsByID, bgTasks, 25) + label, detail := resolveTaskLabel(iconWaiting, "Waiting", input.TaskID, agentsByID, bgTasks, 25) compactParts = append(compactParts, label) detailLines = append(detailLines, detail) case "TaskGet": @@ -1210,7 +1210,7 @@ func taskOpSummaryResult(entry session.Entry, tasksByID map[string]session.TaskI TaskID string `json:"taskId"` } json.Unmarshal([]byte(b.ToolInput), &input) - label, detail := resolveTaskLabel("📋", "Read", input.TaskID, agentsByID, bgTasks, 25) + label, detail := resolveTaskLabel(iconTask, "Read", input.TaskID, agentsByID, bgTasks, 25) compactParts = append(compactParts, label) detailLines = append(detailLines, detail) case "TaskStop": @@ -1218,12 +1218,12 @@ func taskOpSummaryResult(entry session.Entry, tasksByID map[string]session.TaskI TaskID string `json:"task_id"` } json.Unmarshal([]byte(b.ToolInput), &input) - label, detail := resolveTaskLabel("⏹", "Stop", input.TaskID, agentsByID, bgTasks, 25) + label, detail := resolveTaskLabel(iconStopped, "Stop", input.TaskID, agentsByID, bgTasks, 25) compactParts = append(compactParts, label) detailLines = append(detailLines, detail) case "TaskList": compactParts = append(compactParts, "list") - detailLines = append(detailLines, "📋 Listed tasks") + detailLines = append(detailLines, iconTask+" Listed tasks") case "TodoWrite": compactParts = append(compactParts, "todo updated") detailLines = append(detailLines, "Todo list updated") diff --git a/internal/tui/current_window_test.go b/internal/tui/current_window_test.go index b720e99..f2a3993 100644 --- a/internal/tui/current_window_test.go +++ b/internal/tui/current_window_test.go @@ -17,7 +17,7 @@ func TestBuildGroupedItems_PinsCurrentWindowSessions(t *testing.T) { {ID: "s4", ShortID: "s4", ProjectPath: "/tmp/d", ProjectName: "d", ModTime: now.Add(-30 * time.Minute), IsCurrentWindow: true}, } - items := buildGroupedItems(sessions, groupFlat) + items := buildGroupedItems(sessions, groupFlat, nil) if len(items) < 5 { t.Fatalf("expected at least 5 items (2 headers + 4 sessions), got %d", len(items)) } @@ -52,12 +52,42 @@ func TestBuildGroupedItems_PinsCurrentWindowSessions(t *testing.T) { } } +func TestBuildGroupedItems_ProjectCentric_CurrentWindowIsGroupedByProject(t *testing.T) { + now := time.Now() + sessions := []session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: now.Add(-1 * time.Minute), IsCurrentWindow: true}, + {ID: "a2", ShortID: "a2", ProjectPath: "/tmp/repo-a/.worktree/feat", ProjectName: "feat", ModTime: now.Add(-2 * time.Minute), IsCurrentWindow: true, IsWorktree: true}, + {ID: "b1", ShortID: "b1", ProjectPath: "/tmp/repo-b", ProjectName: "repo-b", ModTime: now.Add(-3 * time.Minute)}, + } + items := buildGroupedItems(sessions, groupProjectCentric, nil, ".worktree") + if len(items) < 4 { + t.Fatalf("expected grouped items, got %d", len(items)) + } + h0, ok := items[0].(headerItem) + if !ok || h0.label != "Current Window Projects" { + t.Fatalf("expected first header 'Current Window Projects', got %T %#v", items[0], items[0]) + } + if _, ok := items[1].(projectItem); !ok { + t.Fatalf("expected current-window section to start with a projectItem, got %T", items[1]) + } + foundProjectsHeader := false + for _, item := range items { + if h, ok := item.(headerItem); ok && h.label == "Projects" { + foundProjectsHeader = true + break + } + } + if !foundProjectsHeader { + t.Fatalf("expected a trailing 'Projects' header, got %#v", items) + } +} + func TestBuildGroupedItems_NoCurrentWindow_NoHeader(t *testing.T) { sessions := []session.Session{ {ID: "s1", ShortID: "s1", ProjectPath: "/tmp/a", ProjectName: "a"}, {ID: "s2", ShortID: "s2", ProjectPath: "/tmp/b", ProjectName: "b"}, } - items := buildGroupedItems(sessions, groupFlat) + items := buildGroupedItems(sessions, groupFlat, nil) for _, it := range items { if _, ok := it.(headerItem); ok { t.Fatalf("did not expect a header when no current-window sessions present") @@ -73,7 +103,7 @@ func TestPinCurrentWindowFilter_AlwaysIncludesCurrent(t *testing.T) { {ID: "s1", ProjectPath: "/tmp/match", ProjectName: "match"}, {ID: "s2", ProjectPath: "/tmp/here", ProjectName: "here", IsCurrentWindow: true}, } - items := buildGroupedItems(sessions, groupFlat) + items := buildGroupedItems(sessions, groupFlat, nil) targets := make([]string, len(items)) for i, it := range items { targets[i] = it.FilterValue() @@ -115,7 +145,7 @@ func TestPinCurrentWindowFilter_KeepsMatchedRest(t *testing.T) { {ID: "s1", ProjectPath: "/tmp/match", ProjectName: "alpha-match"}, {ID: "s2", ProjectPath: "/tmp/here", ProjectName: "here-proj", IsCurrentWindow: true}, } - items := buildGroupedItems(sessions, groupFlat) + items := buildGroupedItems(sessions, groupFlat, nil) targets := make([]string, len(items)) for i, it := range items { targets[i] = it.FilterValue() diff --git a/internal/tui/fold_test.go b/internal/tui/fold_test.go new file mode 100644 index 0000000..67b9224 --- /dev/null +++ b/internal/tui/fold_test.go @@ -0,0 +1,120 @@ +package tui + +import ( + "testing" + "time" + + "github.com/sendbird/ccx/internal/session" +) + +func TestBuildGroupedItems_ProjectGroupFold(t *testing.T) { + now := time.Now() + sessions := []session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/a", ProjectName: "a", ModTime: now.Add(-1 * time.Minute)}, + {ID: "a2", ShortID: "a2", ProjectPath: "/tmp/a", ProjectName: "a", ModTime: now.Add(-5 * time.Minute)}, + {ID: "a3", ShortID: "a3", ProjectPath: "/tmp/a", ProjectName: "a", ModTime: now.Add(-7 * time.Minute)}, + {ID: "b1", ShortID: "b1", ProjectPath: "/tmp/b", ProjectName: "b", ModTime: now.Add(-2 * time.Minute)}, + } + + expanded := buildGroupedItems(sessions, groupProject, nil) + // 3 items in group "a" + 1 standalone "b" = 4 rows + if got := len(expanded); got != 4 { + t.Fatalf("expanded: expected 4 items, got %d", got) + } + header, ok := expanded[0].(sessionItem) + if !ok { + t.Fatalf("first item is not a sessionItem: %T", expanded[0]) + } + if header.groupKey != "proj:/tmp/a" { + t.Fatalf("expected groupKey 'proj:/tmp/a', got %q", header.groupKey) + } + if header.groupChildren != 2 { + t.Fatalf("expected 2 children, got %d", header.groupChildren) + } + if header.groupFolded { + t.Fatal("expected expanded by default") + } + + folded := buildGroupedItems(sessions, groupProject, map[string]bool{"proj:/tmp/a": true}) + // only header for "a" (no children) + standalone "b" = 2 rows + if got := len(folded); got != 2 { + t.Fatalf("folded: expected 2 items, got %d", got) + } + foldedHeader, ok := folded[0].(sessionItem) + if !ok { + t.Fatalf("first item is not a sessionItem: %T", folded[0]) + } + if !foldedHeader.groupFolded { + t.Fatal("expected groupFolded=true on the folded header") + } + if foldedHeader.groupChildren != 2 { + t.Fatalf("expected child count to survive folding, got %d", foldedHeader.groupChildren) + } +} + +func TestBuildGroupedItems_ProjectCentric(t *testing.T) { + now := time.Now() + sessions := []session.Session{ + {ID: "m1", ShortID: "m1", ProjectPath: "/repo-a", ProjectName: "repo-a", ModTime: now.Add(-1 * time.Minute)}, + {ID: "w1", ShortID: "w1", ProjectPath: "/repo-a/.worktree/b1", ProjectName: "b1", ModTime: now.Add(-2 * time.Minute), IsWorktree: true}, + {ID: "n1", ShortID: "n1", ProjectPath: "/repo-b", ProjectName: "repo-b", ModTime: now.Add(-3 * time.Minute)}, + } + + expanded := buildGroupedItems(sessions, groupProjectCentric, nil, ".worktree") + // repo-a row + 2 children + repo-b row + 1 child = 5 + if got := len(expanded); got != 5 { + t.Fatalf("expanded: expected 5 items, got %d", got) + } + first, ok := expanded[0].(projectItem) + if !ok { + t.Fatalf("first item should be projectItem, got %T", expanded[0]) + } + if first.basePath != "/repo-a" { + t.Fatalf("expected first project basePath /repo-a, got %q", first.basePath) + } + if !first.expanded { + t.Fatal("expected first project to start expanded") + } + if len(first.sessions) != 2 { + t.Fatalf("expected 2 sessions under repo-a, got %d", len(first.sessions)) + } + if first.worktrees != 1 { + t.Fatalf("expected 1 worktree under repo-a, got %d", first.worktrees) + } + + folded := buildGroupedItems(sessions, groupProjectCentric, map[string]bool{"repo:/repo-a": true}, ".worktree") + // folded repo-a (1) + repo-b (1) + repo-b child (1) = 3 + if got := len(folded); got != 3 { + t.Fatalf("folded: expected 3 items, got %d", got) + } + foldedPI := folded[0].(projectItem) + if foldedPI.expanded { + t.Fatal("expected repo-a to be folded") + } +} + +func TestBuildGroupedItems_BaseProjectGroupFold(t *testing.T) { + now := time.Now() + sessions := []session.Session{ + {ID: "m1", ShortID: "m1", ProjectPath: "/repo", ProjectName: "repo", ModTime: now.Add(-1 * time.Minute)}, + {ID: "w1", ShortID: "w1", ProjectPath: "/repo/.worktree/branch1", ProjectName: "branch1", ModTime: now.Add(-2 * time.Minute), IsWorktree: true}, + {ID: "w2", ShortID: "w2", ProjectPath: "/repo/.worktree/branch2", ProjectName: "branch2", ModTime: now.Add(-3 * time.Minute), IsWorktree: true}, + } + + expanded := buildGroupedItems(sessions, groupBaseProject, nil, ".worktree") + if got := len(expanded); got != 3 { + t.Fatalf("expanded: expected 3 items, got %d", got) + } + + folded := buildGroupedItems(sessions, groupBaseProject, map[string]bool{"repo:/repo": true}, ".worktree") + if got := len(folded); got != 1 { + t.Fatalf("folded: expected only the header (1 item), got %d", got) + } + header := folded[0].(sessionItem) + if !header.groupFolded { + t.Fatal("expected groupFolded=true") + } + if header.groupChildren != 2 { + t.Fatalf("expected groupChildren=2, got %d", header.groupChildren) + } +} diff --git a/internal/tui/help.go b/internal/tui/help.go index 6b02557..c211383 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -73,7 +73,7 @@ func (a *App) sessHelpLine() string { h = fmtKey(sk.Pick, "pick") + " " + h } if !a.sessSplit.Show { - h += " g/G:top/end →:preview tab/S-tab:group" + h += " g/G:top/end →:preview tab/S-tab:preview" } else if a.sessSplit.Focus { switch a.sessPreviewMode { case sessPreviewConversation: @@ -85,7 +85,7 @@ func (a *App) sessHelpLine() string { } h += " " + displayKey(sk.ResizeShrink) + displayKey(sk.ResizeGrow) + ":resize" } else { - h += " g/G:top/end tab/S-tab:group →:focus ←:close " + displayKey(sk.ResizeShrink) + displayKey(sk.ResizeGrow) + ":resize" + h += " g/G:top/end tab/S-tab:preview →:focus ←:close " + displayKey(sk.ResizeShrink) + displayKey(sk.ResizeGrow) + ":resize" } if a.config.TmuxEnabled && tmux.InTmux() { h += " " + fmtKey(sk.Live, "live") @@ -93,7 +93,7 @@ func (a *App) sessHelpLine() string { if sc := a.shortcutHint(); sc != "" { h += " " + dimStyle.Render(sc) } - h += " " + fmtKey(sk.Search, "search") + " " + fmtKey(sk.Help, "help") + " " + fmtKey(sk.Quit, "quit") + h += " D:completed " + fmtKey(sk.Search, "search") + " " + fmtKey(sk.Help, "help") + " " + fmtKey(sk.Quit, "quit") return formatHelp(h) } diff --git a/internal/tui/live_preview_test.go b/internal/tui/live_preview_test.go index 39ef251..38c0b8d 100644 --- a/internal/tui/live_preview_test.go +++ b/internal/tui/live_preview_test.go @@ -22,6 +22,16 @@ func newTestApp(sessions []session.Session) *App { // tests that assume the sessions view is active. a.state = viewSessions a.sessPreviewMode = sessPreviewConversation + a.sessionsLoading = false + // Tests should be hermetic: clear any persisted startup filter from the + // user's local config so visible-item assertions don't depend on the + // developer's current browser state. + a.config.SearchQuery = "" + a.sessionList.ResetFilter() + // Default to flat grouping for tests so existing index/visible-item + // assertions keep holding regardless of the production default. + a.sessGroupMode = groupFlat + a.rebuildSessionList() return a } @@ -33,6 +43,25 @@ func fakeSessions() []session.Session { } } +func TestPaneProxyLiteralInput_PasteAndMultiRune(t *testing.T) { + cases := []struct { + msg tea.KeyMsg + want string + ok bool + }{ + {tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h', 'i'}, Paste: true}, "hi", true}, + {tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'한', '글'}}, "한글", true}, + {tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}, "", false}, + {tea.KeyMsg{Type: tea.KeyEnter}, "", false}, + } + for _, tc := range cases { + got, ok := paneProxyLiteralInput(tc.msg) + if ok != tc.ok || got != tc.want { + t.Fatalf("paneProxyLiteralInput(%v) = (%q,%v), want (%q,%v)", tc.msg, got, ok, tc.want, tc.ok) + } + } +} + // TestCyclePreviewModeSkipsLive verifies tab cycling skips sessPreviewLive. func TestCyclePreviewModeSkipsLive(t *testing.T) { app := newTestApp(fakeSessions()) @@ -257,19 +286,19 @@ func TestPaneProxyIndicator(t *testing.T) { t.Errorf("expected empty indicator with no proxy, got %q", got) } - // Live proxy, unfocused → contains LIVE and ○ + // Live proxy, unfocused → contains LIVE and idle icon app.paneProxy = &paneProxyState{sessID: "aaa"} app.sessSplit.Focus = false got := app.paneProxyIndicator() - if got == "" || !contains(got, "LIVE") || !contains(got, "○") { - t.Errorf("unfocused live indicator should contain LIVE and ○, got %q", got) + if got == "" || !contains(got, "LIVE") || !contains(got, iconIdle) { + t.Errorf("unfocused live indicator should contain LIVE and idle icon, got %q", got) } - // Live proxy, focused → contains LIVE and ● + // Live proxy, focused → contains LIVE and focused icon app.sessSplit.Focus = true got = app.paneProxyIndicator() - if got == "" || !contains(got, "LIVE") || !contains(got, "●") { - t.Errorf("focused live indicator should contain LIVE and ●, got %q", got) + if got == "" || !contains(got, "LIVE") || !contains(got, iconFocused) { + t.Errorf("focused live indicator should contain LIVE and focused icon, got %q", got) } // Shell proxy → contains SHELL diff --git a/internal/tui/messages.go b/internal/tui/messages.go index bea8645..50e0213 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -40,9 +40,69 @@ func renderPlainMessage(e session.Entry) string { return sb.String() } +const ( + convPreviewWindowThreshold = 200 + convPreviewWindowRadius = 80 +) + +func convPreviewWindowBounds(total, cursor int) (start, end, localCursor int) { + if total <= 0 { + return 0, 0, 0 + } + if cursor < 0 { + cursor = 0 + } + if cursor >= total { + cursor = total - 1 + } + start = cursor - convPreviewWindowRadius + if start < 0 { + start = 0 + } + end = cursor + convPreviewWindowRadius + 1 + if end > total { + end = total + } + return start, end, cursor - start +} + +func renderConversationPreviewWindowed(msgs []mergedMsg, width, cursor int, expanded map[int]bool, filterTerm string, rowCache *sessionRowCache, isLive ...bool) (string, []mergedMsg, int, map[int]bool, bool) { + if len(msgs) <= convPreviewWindowThreshold { + return renderConversationPreview(msgs, width, cursor, expanded, filterTerm, rowCache, isLive...), msgs, cursor, expanded, false + } + start, end, localCursor := convPreviewWindowBounds(len(msgs), cursor) + window := msgs[start:end] + localExpanded := make(map[int]bool, len(expanded)) + for idx, v := range expanded { + if idx >= start && idx < end { + localExpanded[idx-start] = v + } + } + content := renderConversationPreview(window, width, localCursor, localExpanded, filterTerm, rowCache, isLive...) + var sb strings.Builder + if start > 0 { + sb.WriteString(dimStyle.Render(fmt.Sprintf("⋯ %d earlier messages hidden ⋯", start))) + sb.WriteByte('\n') + } + sb.WriteString(content) + if end < len(msgs) { + sb.WriteString(dimStyle.Render(fmt.Sprintf("⋯ %d later messages hidden ⋯", len(msgs)-end))) + sb.WriteByte('\n') + } + return sb.String(), window, localCursor, localExpanded, true +} + +// conversationPreviewRowCacheKey returns a cache key for one rendered preview +// row, including the selected/expanded state and text-affecting dimensions. +func conversationPreviewRowCacheKey(m mergedMsg, width, idxW int, selected, expanded bool, filterTerm string) string { + return fmt.Sprintf("convprev|%d|%d|%t|%t|%s|%s|%d|%d|%d", + width, idxW, selected, expanded, filterTerm, m.entry.UUID, + m.startIdx, m.endIdx, len(m.entry.Content)) +} + // renderConversationPreview renders merged messages in the same one-line style // as the message list delegate. When expanded, the full text is shown below. -func renderConversationPreview(msgs []mergedMsg, width, cursor int, expanded map[int]bool, filterTerm string, _ ...bool) string { +func renderConversationPreview(msgs []mergedMsg, width, cursor int, expanded map[int]bool, filterTerm string, rowCache *sessionRowCache, _ ...bool) string { var sb strings.Builder idxW := 2 for _, m := range msgs { @@ -56,10 +116,23 @@ func renderConversationPreview(msgs []mergedMsg, width, cursor int, expanded map idxW = len(s) } } + // Most rows are one terminal line. Growing up-front avoids repeated + // buffer copies on warm-cache rerenders where we mostly concatenate + // already-rendered row strings. + if width > 0 && len(msgs) > 0 { + sb.Grow(len(msgs) * (width + 1)) + } for i, m := range msgs { e := m.entry selected := i == cursor + expandedRow := expanded != nil && expanded[i] + cacheKey := conversationPreviewRowCacheKey(m, width, idxW, selected, expandedRow, filterTerm) + if cached, ok := rowCache.Get(cacheKey); ok { + sb.WriteString(cached) + continue + } + var row strings.Builder // Classify message (same logic as messageDelegate) hasText := false @@ -98,9 +171,9 @@ func renderConversationPreview(msgs []mergedMsg, width, cursor int, expanded map cursorStr = convCursorStyle.Render("> ") } } - role = userLabelStyle.Render("USER") + role = userLabelStyle.Render(roleChip("user")) } else { - role = assistantLabelStyle.Render("ASST") + role = assistantLabelStyle.Render(roleChip("assistant")) } // Time @@ -171,7 +244,7 @@ func renderConversationPreview(msgs []mergedMsg, width, cursor int, expanded map styledPreview = pStyle.Render(preview) } - sb.WriteString(prefix + styledPreview + suffix + "\n") + row.WriteString(prefix + styledPreview + suffix + "\n") // Expanded: show full text below if expanded != nil && expanded[i] { @@ -180,10 +253,13 @@ func renderConversationPreview(msgs []mergedMsg, width, cursor int, expanded map if text != "" { wrapped := wrapText(text, textW) for _, line := range strings.Split(wrapped, "\n") { - sb.WriteString(" " + line + "\n") + row.WriteString(" " + line + "\n") } } } + rendered := row.String() + rowCache.Set(cacheKey, rendered) + sb.WriteString(rendered) } return sb.String() @@ -308,9 +384,9 @@ func renderCompactMessage(e session.Entry, width, maxLines int) string { var sb strings.Builder w := max(width, 10) - role := userLabelStyle.Render("USER") + role := userLabelStyle.Render(roleChip("user")) if e.Role == "assistant" { - role = assistantLabelStyle.Render("ASST") + role = assistantLabelStyle.Render(roleChip("assistant")) } ts := " " @@ -453,9 +529,9 @@ func renderFullMessageImpl(e session.Entry, width int, folds foldSet, formats fo if isAutoCompacted(e) { label = compactBadgeStyle.Render("COMPACTION SUMMARY") } else if e.Role == "user" { - label = userLabelStyle.Render("USER") + label = userLabelStyle.Render(roleChip("user")) } else { - label = assistantLabelStyle.Render("ASSISTANT") + label = assistantLabelStyle.Render(roleChip("assistant")) } ts := "" @@ -489,21 +565,21 @@ func renderFullMessageImpl(e session.Entry, width int, folds foldSet, formats fo if blockCursor >= 0 { indicator := " " if isMarked { - // Selected block: ✓ replaces the arrow/indicator + // Selected block: the check icon replaces the arrow/indicator if isCursor { - indicator = blockCursorStyle.Render("✓") + indicator = blockCursorStyle.Render(iconSelect) } else { - indicator = lipgloss.NewStyle().Foreground(colorAccent).Render("✓") + indicator = lipgloss.NewStyle().Foreground(colorAccent).Render(iconSelect) } } else if isCursor { // Cursor block: bright indicator if formatted { - indicator = blockCursorStyle.Render("✦") + indicator = blockCursorStyle.Render(iconBlockMarker) } else if isFoldable { if folded { - indicator = blockCursorStyle.Render("▸") + indicator = blockCursorStyle.Render(iconFoldClosed) } else { - indicator = blockCursorStyle.Render("▾") + indicator = blockCursorStyle.Render(iconFoldOpen) } } else { indicator = blockCursorStyle.Render("›") @@ -511,12 +587,12 @@ func renderFullMessageImpl(e session.Entry, width int, folds foldSet, formats fo } else { // Non-cursor block: dim indicator if formatted { - indicator = dimStyle.Render("✦") + indicator = dimStyle.Render(iconBlockMarker) } else if isFoldable { if folded { - indicator = dimStyle.Render("▸") + indicator = dimStyle.Render(iconFoldClosed) } else { - indicator = dimStyle.Render("▾") + indicator = dimStyle.Render(iconFoldOpen) } } } @@ -699,7 +775,7 @@ func renderFullMessageImpl(e session.Entry, width int, folds foldSet, formats fo } label := block.Text if block.ImagePasteID > 0 { - label = fmt.Sprintf("▣ %s (paste #%d — Enter to open)", block.Text, block.ImagePasteID) + label = fmt.Sprintf(iconImage+" %s (paste #%d — Enter to open)", block.Text, block.ImagePasteID) } buf.WriteString(dimStyle.Render(label) + "\n\n") } @@ -951,7 +1027,7 @@ func extractSkillFromInput(input string) string { } // renderHookBadges returns inline hook summary for tool_use blocks. -// Shows short script names e.g. "⚡ go_vet.py, ts_lint.py" +// Shows short script names with a compact hook marker. func renderHookBadges(hooks []session.HookInfo) string { if len(hooks) == 0 { return "" @@ -960,7 +1036,7 @@ func renderHookBadges(hooks []session.HookInfo) string { for _, h := range hooks { names = append(names, hookScriptName(h.Command)) } - return " " + hookBadgeStyle.Render("⚡"+strings.Join(names, ", ")) + return " " + hookBadgeStyle.Render(iconHook+" "+strings.Join(names, ", ")) } // renderHookDetails returns expanded hook info lines for unfolded tool_use blocks. @@ -969,7 +1045,7 @@ func renderHookDetails(hooks []session.HookInfo) string { for _, h := range hooks { event := h.Event script := hookScriptName(h.Command) - sb.WriteString(hookDetailStyle.Render(fmt.Sprintf(" ⚡ %s %s", event, script)) + "\n") + sb.WriteString(hookDetailStyle.Render(fmt.Sprintf(" "+iconHook+" %s %s", event, script)) + "\n") // Show full command if different from script name if h.Command != script { sb.WriteString(hookDetailStyle.Render(" "+h.Command) + "\n") diff --git a/internal/tui/plugins.go b/internal/tui/plugins.go index bdbb4fc..573096e 100644 --- a/internal/tui/plugins.go +++ b/internal/tui/plugins.go @@ -57,9 +57,9 @@ func (d plgDelegate) Render(w io.Writer, m list.Model, index int, item list.Item isMarked := !pi.isHeader && d.selectedSet[pi.plugin.ID] cursor := " " if isMarked { - cursor = lipgloss.NewStyle().Foreground(lipgloss.Color("#22C55E")).Bold(true).Render("✓ ") + cursor = lipgloss.NewStyle().Foreground(lipgloss.Color("#22C55E")).Bold(true).Render(iconSelect + " ") } else if selected { - cursor = lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render("▸ ") + cursor = lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render(iconFoldClosed + " ") } cursorW := 2 @@ -706,9 +706,9 @@ func (d plgCompDelegate) Render(w io.Writer, m list.Model, index int, item list. isMarked := !ci.isHeader && ci.comp.Path != "" && d.selectedSet[ci.comp.Path] cursor := " " if isMarked { - cursor = lipgloss.NewStyle().Foreground(lipgloss.Color("#22C55E")).Bold(true).Render("✓ ") + cursor = lipgloss.NewStyle().Foreground(lipgloss.Color("#22C55E")).Bold(true).Render(iconSelect + " ") } else if selected { - cursor = lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render("▸ ") + cursor = lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render(iconFoldClosed + " ") } cursorW := 2 diff --git a/internal/tui/project_centric_test.go b/internal/tui/project_centric_test.go new file mode 100644 index 0000000..386b30b --- /dev/null +++ b/internal/tui/project_centric_test.go @@ -0,0 +1,150 @@ +package tui + +import ( + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/sendbird/ccx/internal/session" +) + +func TestProjectCentricPreviewShowsProjectSummary(t *testing.T) { + sessions := []session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", GitBranch: "main", ModTime: time.Now(), MsgCount: 10, FirstPrompt: "first prompt", IsLive: true}, + {ID: "a2", ShortID: "a2", ProjectPath: "/tmp/repo-a/.worktree/feat", ProjectName: "feat", ModTime: time.Now().Add(-time.Hour), MsgCount: 3, FirstPrompt: "second prompt", IsWorktree: true, HasMonitorJobs: true, HasShellJobs: true, IsLive: true}, + } + app := newTestApp(sessions) + app.sessGroupMode = groupProjectCentric + app.rebuildSessionList() + items := app.sessionList.VisibleItems() + if len(items) == 0 { + t.Fatal("expected visible items") + } + if _, ok := items[0].(projectItem); !ok { + t.Fatalf("expected first item to be projectItem, got %T", items[0]) + } + app.sessionList.Select(0) + app.sessSplit.Show = true + if cmd := app.updateSessionPreview(); cmd != nil { + t.Fatal("expected project preview update to be synchronous") + } + content := app.sessSplit.Preview.View() + for _, want := range []string{"repo-a", "/tmp/repo-a", "Sessions: 2", "Worktrees: 1", "Sessions in project", "a1", "a2"} { + if !strings.Contains(content, want) { + t.Fatalf("expected project preview to contain %q, got:\n%s", want, content) + } + } +} + +func TestProjectCentricEnterTogglesProjectFold(t *testing.T) { + sessions := []session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: time.Now(), MsgCount: 10}, + {ID: "a2", ShortID: "a2", ProjectPath: "/tmp/repo-a/.worktree/feat", ProjectName: "feat", ModTime: time.Now().Add(-time.Hour), MsgCount: 3, IsWorktree: true}, + } + app := newTestApp(sessions) + app.sessGroupMode = groupProjectCentric + app.rebuildSessionList() + app.sessionList.Select(0) + before := len(app.sessionList.VisibleItems()) + m, _ := app.handleSessionKeys(tea.KeyMsg{Type: tea.KeyEnter}) + app = m.(*App) + after := len(app.sessionList.VisibleItems()) + if after >= before { + t.Fatalf("expected enter on project row to fold children (before=%d after=%d)", before, after) + } +} + +func TestProjectCentricSelectTogglesAllChildSessions(t *testing.T) { + sessions := []session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: time.Now(), MsgCount: 10}, + {ID: "a2", ShortID: "a2", ProjectPath: "/tmp/repo-a/.worktree/feat", ProjectName: "feat", ModTime: time.Now().Add(-time.Hour), MsgCount: 3, IsWorktree: true}, + } + app := newTestApp(sessions) + app.sessGroupMode = groupProjectCentric + app.rebuildSessionList() + app.sessionList.Select(0) + m, _ := app.handleSessionKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) + app = m.(*App) + if len(app.selectedSet) != 2 { + t.Fatalf("expected selecting a project row to toggle all 2 child sessions, got %d", len(app.selectedSet)) + } +} + +func TestProjectCentricPickReturnsAllChildSessions(t *testing.T) { + sessions := []session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: time.Now(), MsgCount: 10}, + {ID: "a2", ShortID: "a2", ProjectPath: "/tmp/repo-a/.worktree/feat", ProjectName: "feat", ModTime: time.Now().Add(-time.Hour), MsgCount: 3, IsWorktree: true}, + } + app := NewApp(sessions, Config{TmuxEnabled: true, PickMode: true}) + app.width = 160 + app.height = 50 + app.sessSplit = SplitPane{List: &app.sessionList, ItemHeight: 2} + app.sessGroupMode = groupProjectCentric + app.rebuildSessionList() + app.state = viewSessions + app.sessionList.Select(0) + m, _ := app.handleSessionKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'P'}}) + app = m.(*App) + res, ok := app.pickResult.(SessionsResult) + if !ok { + t.Fatalf("expected SessionsResult, got %T", app.pickResult) + } + if len(res.Items) != 2 { + t.Fatalf("expected pick on project row to include all child sessions, got %d", len(res.Items)) + } +} + +func TestCompletedProjectsToggleKey(t *testing.T) { + sessions := []session.Session{ + {ID: "done1", ShortID: "done1", ProjectPath: "/tmp/repo-done", ProjectName: "repo-done", ModTime: time.Now(), Tasks: []session.TaskItem{{ID: "1", Status: "completed"}}, HasTasks: true}, + {ID: "wait1", ShortID: "wait1", ProjectPath: "/tmp/repo-wait", ProjectName: "repo-wait", ModTime: time.Now().Add(-time.Hour), Tasks: []session.TaskItem{{ID: "1", Status: "in_progress"}}, HasTasks: true}, + } + app := newTestApp(sessions) + app.sessGroupMode = groupProjectCentric + app.rebuildSessionList() + m, _ := app.handleSessionKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'D'}}) + app = m.(*App) + if got := strings.TrimSpace(app.activeFilterValue()); got != "is:done" { + t.Fatalf("expected D to apply is:done filter, got %q", got) + } + if app.copiedMsg != "Showing completed projects" { + t.Fatalf("expected completed filter status message, got %q", app.copiedMsg) + } + m, _ = app.handleSessionKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'D'}}) + app = m.(*App) + if got := strings.TrimSpace(app.activeFilterValue()); got != "" { + t.Fatalf("expected second D to clear filter, got %q", got) + } + if app.copiedMsg != "Completed filter cleared" { + t.Fatalf("expected clear status message, got %q", app.copiedMsg) + } +} + +func TestCompletedProjectsToggleFallsBackWhenNoDoneMatches(t *testing.T) { + sessions := []session.Session{ + {ID: "wait1", ShortID: "wait1", ProjectPath: "/tmp/repo-wait", ProjectName: "repo-wait", ModTime: time.Now(), Tasks: []session.TaskItem{{ID: "1", Status: "in_progress"}}, HasTasks: true}, + } + app := newTestApp(sessions) + app.sessGroupMode = groupProjectCentric + app.rebuildSessionList() + m, _ := app.handleSessionKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'D'}}) + app = m.(*App) + if got := strings.TrimSpace(app.activeFilterValue()); got != "" { + t.Fatalf("expected D to clear back out when there are no done matches, got %q", got) + } + if app.copiedMsg != "No completed projects found" { + t.Fatalf("expected no-match status message, got %q", app.copiedMsg) + } +} + +func TestNewAppForcesProjectsBrowserModeOnStartup(t *testing.T) { + app := NewApp(fakeSessions(), Config{TmuxEnabled: true, GroupMode: "repo"}) + if app.sessGroupMode != groupProjectCentric { + t.Fatalf("expected startup group mode to be project-centric, got %d", app.sessGroupMode) + } + prefs := app.capturePreferences() + if prefs.GroupMode != "projects" { + t.Fatalf("expected persisted group mode to be projects, got %q", prefs.GroupMode) + } +} diff --git a/internal/tui/remote.go b/internal/tui/remote.go index e38c225..22bdcb3 100644 --- a/internal/tui/remote.go +++ b/internal/tui/remote.go @@ -14,6 +14,10 @@ import ( "github.com/sendbird/ccx/internal/tmux" ) +func shellArg(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} + // injectRemoteSessions prepends virtual remote sessions into a session list. func (a *App) injectRemoteSessions(sessions []session.Session) []session.Session { remoteMap := make(map[string]session.Session) @@ -89,7 +93,10 @@ func (a *App) buildRemoteProgressView(sess *remote.Session, currentStep string) sb.WriteString(labelStyle.Render(" Pod: ") + valStyle.Render(sess.PodName) + "\n") sb.WriteString(labelStyle.Render(" Image: ") + valStyle.Render(sess.Config.Image) + "\n") if sess.Config.LocalDir != "" { - sb.WriteString(labelStyle.Render(" Workdir: ") + valStyle.Render(sess.Config.LocalDir) + "\n") + sb.WriteString(labelStyle.Render(" Local dir: ") + valStyle.Render(sess.Config.LocalDir) + "\n") + } + if sess.Config.WorkDir != "" { + sb.WriteString(labelStyle.Render(" Remote dir:") + " " + valStyle.Render(sess.Config.WorkDir) + "\n") } if sess.Config.SessionID != "" { sid := sess.Config.SessionID @@ -100,10 +107,10 @@ func (a *App) buildRemoteProgressView(sess *remote.Session, currentStep string) } sb.WriteString("\n" + titleStyle.Render("Progress") + "\n\n") for _, step := range a.remoteProgressSteps { - sb.WriteString(" " + lipgloss.NewStyle().Foreground(colorAccent).Render("✓") + " " + step + "\n") + sb.WriteString(" " + lipgloss.NewStyle().Foreground(colorAccent).Render(iconDone) + " " + step + "\n") } if currentStep != "" { - sb.WriteString(" " + lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B")).Render("◉") + " " + currentStep + "\n") + sb.WriteString(" " + lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B")).Render(iconActive) + " " + currentStep + "\n") } return sb.String() } @@ -120,32 +127,163 @@ type remoteExecDoneMsg struct { err error } +// remotePhaseMsg carries refreshed pod phases for saved remote sessions. +type remotePhaseMsg struct { + phases map[string]string // pod name -> phase (Running, Pending, Failed, NotFound) +} + +// remoteExecOutputMsg carries combined output of an ad-hoc kubectl exec. +type remoteExecOutputMsg struct { + podName string + out []byte + err error +} + +// remoteSnapshotMsg signals snapshot completion. +type remoteSnapshotMsg struct { + name string + meta remote.SnapshotMeta + err error +} + +// remotePullMsg signals workdir write-back completion. +type remotePullMsg struct { + podName string + dest string + err error +} + // mergeRemoteConfig applies defaults from config.yaml onto a runtime config. // Runtime values take precedence over defaults. func mergeRemoteConfig(defaults, cfg remote.Config) remote.Config { - if cfg.Context == "" { cfg.Context = defaults.Context } - if cfg.Namespace == "" { cfg.Namespace = defaults.Namespace } - if cfg.Image == "" { cfg.Image = defaults.Image } - if cfg.WorkDir == "" { cfg.WorkDir = defaults.WorkDir } - if cfg.CPULimit == "" { cfg.CPULimit = defaults.CPULimit } - if cfg.MemoryLimit == "" { cfg.MemoryLimit = defaults.MemoryLimit } - if len(cfg.EnvVars) == 0 { cfg.EnvVars = defaults.EnvVars } - if len(cfg.MirrorEnv) == 0 { cfg.MirrorEnv = defaults.MirrorEnv } - if len(cfg.Labels) == 0 { cfg.Labels = defaults.Labels } - if len(cfg.Tolerations) == 0 { cfg.Tolerations = defaults.Tolerations } - if len(cfg.ClaudeArgs) == 0 { cfg.ClaudeArgs = defaults.ClaudeArgs } + if cfg.Context == "" { + cfg.Context = defaults.Context + } + if cfg.Namespace == "" { + cfg.Namespace = defaults.Namespace + } + if cfg.PodName == "" { + cfg.PodName = defaults.PodName + } + if cfg.Container == "" { + cfg.Container = defaults.Container + } + if cfg.RemoteUser == "" { + cfg.RemoteUser = defaults.RemoteUser + } + if cfg.RemoteHome == "" { + cfg.RemoteHome = defaults.RemoteHome + } + if cfg.RemoteProjectPath == "" { + cfg.RemoteProjectPath = defaults.RemoteProjectPath + } + if cfg.Image == "" { + cfg.Image = defaults.Image + } + if cfg.WorkDir == "" { + cfg.WorkDir = defaults.WorkDir + } + if cfg.WorkDirTemplate == "" { + cfg.WorkDirTemplate = defaults.WorkDirTemplate + } + if cfg.CPULimit == "" { + cfg.CPULimit = defaults.CPULimit + } + if cfg.MemoryLimit == "" { + cfg.MemoryLimit = defaults.MemoryLimit + } + if cfg.Arch == "" { + cfg.Arch = defaults.Arch + } + if len(cfg.EnvVars) == 0 { + cfg.EnvVars = defaults.EnvVars + } + if len(cfg.MirrorEnv) == 0 { + cfg.MirrorEnv = defaults.MirrorEnv + } + if len(cfg.Labels) == 0 { + cfg.Labels = defaults.Labels + } + if len(cfg.Tolerations) == 0 { + cfg.Tolerations = defaults.Tolerations + } + if len(cfg.ClaudeArgs) == 0 { + cfg.ClaudeArgs = defaults.ClaudeArgs + } return cfg } +func expandRemoteWorkDirTemplate(tmpl string, sess session.Session, remoteHome string) string { + if tmpl == "" { + return "" + } + project := sess.ProjectName + if project == "" { + project = "project" + } + shortSession := sess.ShortID + if shortSession == "" { + shortSession = sess.ID + if len(shortSession) > 12 { + shortSession = shortSession[:12] + } + } + repls := map[string]string{ + "{{project}}": safePathPart(project), + "{{session}}": safePathPart(sess.ID), + "{{short_session}}": safePathPart(shortSession), + "{{home_rel}}": safeHomeRel(sess.ProjectPath), + "{{remote_home}}": strings.TrimRight(remoteHome, "/"), + } + out := tmpl + for k, v := range repls { + out = strings.ReplaceAll(out, k, v) + } + return out +} + +func safePathPart(s string) string { + if s == "" { + return "unknown" + } + var b strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { + b.WriteRune(r) + } else { + b.WriteByte('-') + } + } + trimmed := strings.Trim(b.String(), "-.") + if trimmed == "" { + return "unknown" + } + return trimmed +} + +func safeHomeRel(path string) string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return safePathPart(path) + } + if path == home { + return "" + } + prefix := strings.TrimRight(home, "/") + "/" + if strings.HasPrefix(path, prefix) { + parts := strings.Split(strings.TrimPrefix(path, prefix), "/") + for i, part := range parts { + parts[i] = safePathPart(part) + } + return strings.Join(parts, "/") + } + return safePathPart(path) +} + // --- Actions --- // startRemoteSession shows confirmation with context info. func (a *App) startRemoteSession(cfg remote.Config) (tea.Model, tea.Cmd) { - if a.remoteSession != nil { - a.copiedMsg = "Remote session already active — :remote:stop first" - return a, nil - } - // Merge config.yaml remote defaults into the config cfg = mergeRemoteConfig(a.remoteDefaults, cfg) cfg = cfg.Defaults() @@ -159,10 +297,26 @@ func (a *App) startRemoteSession(cfg remote.Config) (tea.Model, tea.Cmd) { cfg.SessionID = sess.ID cfg.SessionFile = sess.FilePath } + if cfg.WorkDirTemplate != "" { + cfg.WorkDir = expandRemoteWorkDirTemplate(cfg.WorkDirTemplate, sess, cfg.RemoteHome) + } + } + + if a.remoteSession != nil && cfg.PodName == "" { + a.copiedMsg = "Remote session already active — use remote.pod_name to reuse a fixed worker pod" + return a, nil } cfgCopy := cfg - a.confirmMsg = fmt.Sprintf("Start remote on %s/%s?", cfg.Context, cfg.Namespace) + prompt := fmt.Sprintf("Start remote on %s/%s?", cfg.Context, cfg.Namespace) + if cfg.PodName != "" { + prompt = fmt.Sprintf("Sync session to remote pod %s/%s/%s?", cfg.Context, cfg.Namespace, cfg.PodName) + } + if cfg.ArchMismatch() { + prompt = fmt.Sprintf("Start remote on %s/%s? [arch %s ≠ host %s]", + cfg.Context, cfg.Namespace, cfg.Arch, remote.HostArch()) + } + a.confirmMsg = prompt a.confirmAction = func() (tea.Model, tea.Cmd) { a.remoteConfirmCfg = &cfgCopy return a.confirmRemoteStart() @@ -179,61 +333,7 @@ func (a *App) confirmRemoteStart() (tea.Model, tea.Cmd) { projectPath := cfg.LocalDir sess, steps := remote.Start(cfg, claudeDir, projectPath) - a.remoteSession = sess - a.remoteSetupSteps = steps - - // Persist to disk - remote.AddSavedSession(remote.SavedSession{ - PodName: sess.PodName, - Context: sess.Config.Context, - Namespace: sess.Config.Namespace, - Image: sess.Config.Image, - LocalDir: cfg.LocalDir, - SessionID: cfg.SessionID, - WorkDir: sess.Config.WorkDir, - Status: "starting", - }) - - // Insert virtual session - virtualID := "remote-" + sess.PodName - virtualSess := session.Session{ - ID: virtualID, - ShortID: sess.PodName, - ProjectPath: cfg.LocalDir, - ProjectName: "remote:" + sess.PodName, - ModTime: time.Now(), - IsRemote: true, - RemotePodName: sess.PodName, - RemoteContext: sess.Config.Context, - RemoteNamespace: sess.Config.Namespace, - RemoteStatus: "starting...", - FirstPrompt: fmt.Sprintf("%s/%s/%s", sess.Config.Context, sess.Config.Namespace, sess.PodName), - } - a.sessions = append([]session.Session{virtualSess}, a.sessions...) - a.rebuildSessionList() - - // Select it - for i, item := range a.sessionList.Items() { - if si, ok := item.(sessionItem); ok && si.sess.ID == virtualID { - a.sessionList.Select(i) - break - } - } - - // Show progress in preview - a.remoteProgressSteps = nil - a.remoteContent = a.buildRemoteProgressView(sess, "Initializing...") - a.copiedMsg = fmt.Sprintf("Remote → %s/%s/%s", sess.Config.Context, cfg.Namespace, sess.PodName) - - if !a.sessSplit.Show { - a.sessSplit.Show = true - contentH := max(a.height-3, 1) - a.sessionList.SetSize(a.sessSplit.ListWidth(a.width, a.splitRatio), contentH) - } - a.sessSplit.CacheKey = "remote:" + virtualID - a.sessSplit.Preview.SetContent(a.remoteContent) - - return a, readSetupStep(sess.PodName, steps) + return a.installRemoteSession(sess, steps) } func readSetupStep(podName string, steps <-chan remote.SetupStep) tea.Cmd { @@ -279,7 +379,7 @@ func (a *App) handleRemoteSetup(msg remoteSetupMsg) (tea.Model, tea.Cmd) { a.remoteProgressSteps = append(a.remoteProgressSteps, msg.step.Message) if a.remoteSession != nil { a.remoteContent = a.buildRemoteProgressView(a.remoteSession, msg.step.Message) - // Remove last (it's the "current" one, shown with ◉) + // Remove last (it's the "current" one, shown with the active icon) a.remoteProgressSteps = a.remoteProgressSteps[:len(a.remoteProgressSteps)-1] } a.updateRemotePreview(msg.podName) @@ -325,11 +425,15 @@ func (a *App) openRemoteLivePreview(sess session.Session) (tea.Model, tea.Cmd) { // Close existing pane proxy a.closePaneProxy() - // Build the shell command for the hidden tmux window (runs as non-root claude user) + // Build the shell command for the hidden tmux window. claudeCmd := remote.BuildClaudeCmd(cfg, false) + containerArg := "" + if cfg.Container != "" { + containerArg = " -c " + shellArg(cfg.Container) + } kubectlCmd := fmt.Sprintf( - "kubectl --context=%s -n %s exec -it %s -- su - claude -c 'export PATH=/usr/local/bin:/usr/bin:/bin:$PATH; . ~/.claude_env; cd %s 2>/dev/null; %s'", - cfg.Context, cfg.Namespace, sess.RemotePodName, cfg.WorkDir, claudeCmd) + "kubectl --context=%s -n %s exec -it %s%s -- su - %s -c 'export PATH=$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin:$PATH; . ~/.claude_env; cd %s 2>/dev/null; %s'", + shellArg(cfg.Context), shellArg(cfg.Namespace), shellArg(sess.RemotePodName), containerArg, shellArg(cfg.RemoteUser), cfg.WorkDir, claudeCmd) windowName := "ccx-remote-" + sess.RemotePodName[:min(8, len(sess.RemotePodName))] a.copiedMsg = fmt.Sprintf("Spawning live → %s/%s...", cfg.Context, sess.RemotePodName) @@ -456,10 +560,35 @@ func (a *App) updateRemoteSessionStatus(podName, status string) { // --- Stop / Attach --- func (a *App) stopRemoteSession() (tea.Model, tea.Cmd) { + return a.stopRemoteSessionInternal(false) +} + +// stopRemoteSessionWithPull pulls the workdir back to LocalDir before deleting +// the pod. The pull is best-effort — stop proceeds even if it fails. +func (a *App) stopRemoteSessionWithPull() (tea.Model, tea.Cmd) { + return a.stopRemoteSessionInternal(true) +} + +func (a *App) stopRemoteSessionInternal(pull bool) (tea.Model, tea.Cmd) { var podName string + var pullCfg remote.Config + var pullDest string if a.remoteSession != nil { podName = a.remoteSession.PodName + if pull { + pullCfg = a.remoteSession.Config + pullDest = a.remoteSession.Config.LocalDir + } + if pull && pullDest != "" && pullCfg.Context != "" { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + if err := remote.FetchWorkdirToDir(ctx, pullCfg, podName, pullDest); err != nil { + a.copiedMsg = "Pull failed, stopping anyway: " + err.Error() + } else { + a.copiedMsg = fmt.Sprintf("Pulled workdir → %s, stopped %s", pullDest, podName) + } + cancel() + } a.remoteSession.Stop() a.remoteSession = nil a.remoteContent = "" @@ -475,7 +604,20 @@ func (a *App) stopRemoteSession() (tea.Model, tea.Cmd) { podName = sess.RemotePodName for _, saved := range remote.LoadSavedSessions() { if saved.PodName == podName { - cfg := remote.Config{Context: saved.Context, Namespace: saved.Namespace} + cfg := remote.Config{Context: saved.Context, Namespace: saved.Namespace, WorkDir: saved.WorkDir} + if pull { + pullCfg = cfg + pullDest = saved.LocalDir + if pullDest != "" { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + if err := remote.FetchWorkdirToDir(ctx, pullCfg, podName, pullDest); err != nil { + a.copiedMsg = "Pull failed, stopping anyway: " + err.Error() + } else { + a.copiedMsg = fmt.Sprintf("Pulled workdir → %s, stopped %s", pullDest, podName) + } + cancel() + } + } remote.DeletePod(context.Background(), cfg, podName) break } @@ -495,7 +637,9 @@ func (a *App) stopRemoteSession() (tea.Model, tea.Cmd) { } a.sessions = filtered a.rebuildSessionList() - a.copiedMsg = fmt.Sprintf("Stopped pod %s", podName) + if a.copiedMsg == "" || !strings.HasPrefix(a.copiedMsg, "Pulled") { + a.copiedMsg = fmt.Sprintf("Stopped pod %s", podName) + } return a, nil } @@ -573,9 +717,574 @@ func (a *App) executeCmdRemoteStart(input string) (tea.Model, tea.Cmd) { cfg.SessionFile = sess.FilePath } - if len(parts) >= 2 { - cfg.Prompt = strings.Join(parts[1:], " ") + var promptParts []string + for _, part := range parts[1:] { + key, val, ok := strings.Cut(part, "=") + if !ok { + promptParts = append(promptParts, part) + continue + } + switch strings.ToLower(key) { + case "context", "ctx": + cfg.Context = val + case "namespace", "ns": + cfg.Namespace = val + case "pod", "pod_name", "pod-name": + cfg.PodName = val + case "container": + cfg.Container = val + case "user", "remote_user", "remote-user": + cfg.RemoteUser = val + case "home", "remote_home", "remote-home": + cfg.RemoteHome = val + case "remote_project_path", "remote-project-path", "project_path", "project-path": + cfg.RemoteProjectPath = val + case "workdir", "work_dir", "work-dir": + cfg.WorkDir = val + case "workdir_template", "work_dir_template", "work-dir-template": + cfg.WorkDirTemplate = val + default: + promptParts = append(promptParts, part) + } + } + if len(promptParts) > 0 { + cfg.Prompt = strings.Join(promptParts, " ") } return a.startRemoteSession(cfg) } + +// pollRemotePhasesCmd returns a Cmd that queries pod phase for every saved +// remote session. The returned remotePhaseMsg drives a single batched UI +// refresh, avoiding one tea.Cmd per pod. +func pollRemotePhasesCmd() tea.Cmd { + saved := remote.LoadSavedSessions() + if len(saved) == 0 { + return nil + } + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + phases := make(map[string]string, len(saved)) + for _, s := range saved { + cfg := remote.Config{Context: s.Context, Namespace: s.Namespace} + phase, err := remote.PodPhase(ctx, cfg, s.PodName) + if err != nil { + phases[s.PodName] = "NotFound" + continue + } + if phase == "" { + phase = "Unknown" + } + phases[s.PodName] = phase + } + return remotePhaseMsg{phases: phases} + } +} + +// handleRemotePhase merges polled pod phases into in-memory and on-disk state. +// NotFound pods are dropped from saved sessions. +func (a *App) handleRemotePhase(msg remotePhaseMsg) (tea.Model, tea.Cmd) { + if len(msg.phases) == 0 { + return a, nil + } + changed := false + for pod, phase := range msg.phases { + status := strings.ToLower(phase) + for i := range a.sessions { + s := &a.sessions[i] + if !(s.IsRemote && s.RemotePodName == pod) { + continue + } + // Preserve in-progress setup state (e.g. "syncing config..."). + if a.remoteSession != nil && a.remoteSession.PodName == pod && a.remoteSetupSteps != nil { + break + } + if s.RemoteStatus != status { + s.RemoteStatus = status + s.FirstPrompt = fmt.Sprintf("%s/%s/%s [%s]", s.RemoteContext, s.RemoteNamespace, pod, status) + changed = true + } + break + } + if phase == "NotFound" { + remote.RemoveSavedSession(pod) + var filtered []session.Session + for _, s := range a.sessions { + if !(s.IsRemote && s.RemotePodName == pod) { + filtered = append(filtered, s) + } + } + if len(filtered) != len(a.sessions) { + a.sessions = filtered + changed = true + } + } else { + remote.UpdateSavedSessionStatus(pod, status) + } + } + if changed { + a.rebuildSessionList() + } + return a, nil +} + +// executeCmdRemoteExec runs an ad-hoc command in the selected remote pod and +// reports the output via copiedMsg. Long output is truncated. +func (a *App) executeCmdRemoteExec(input string) (tea.Model, tea.Cmd) { + parts := strings.Fields(input) + if len(parts) < 2 { + a.copiedMsg = "Usage: remote:exec " + return a, nil + } + cmdParts := parts[1:] + + sess, ok := a.selectedSession() + if !ok || !sess.IsRemote { + a.copiedMsg = "Select a remote session first" + return a, nil + } + + cfg, ok := a.resolveRemoteConfig(sess.RemotePodName) + if !ok { + a.copiedMsg = "No config for remote session" + return a, nil + } + + a.copiedMsg = fmt.Sprintf("Exec on %s: %s", sess.RemotePodName, strings.Join(cmdParts, " ")) + pod := sess.RemotePodName + return a, func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + out, err := remote.ExecInPod(ctx, cfg, pod, cmdParts...) + return remoteExecOutputMsg{podName: pod, out: out, err: err} + } +} + +// handleRemoteExecOutput renders ad-hoc exec output as a transient status line. +func (a *App) handleRemoteExecOutput(msg remoteExecOutputMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + out := strings.TrimSpace(string(msg.out)) + if out != "" { + a.copiedMsg = fmt.Sprintf("Exec failed: %s: %s", msg.err.Error(), trimLine(out, 80)) + } else { + a.copiedMsg = "Exec failed: " + msg.err.Error() + } + return a, nil + } + out := strings.TrimSpace(string(msg.out)) + if out == "" { + a.copiedMsg = "Exec ok (no output)" + return a, nil + } + a.copiedMsg = trimLine(strings.ReplaceAll(out, "\n", " | "), 160) + return a, nil +} + +// resolveRemoteConfig returns the kubectl-target config for a saved or active pod. +func (a *App) resolveRemoteConfig(podName string) (remote.Config, bool) { + if a.remoteSession != nil && a.remoteSession.PodName == podName { + return a.remoteSession.Config, true + } + for _, saved := range remote.LoadSavedSessions() { + if saved.PodName == podName { + cfg := remote.Config{ + Context: saved.Context, + Namespace: saved.Namespace, + WorkDir: saved.WorkDir, + SessionID: saved.SessionID, + } + cfg = mergeRemoteConfig(a.remoteDefaults, cfg) + cfg = cfg.Defaults() + return cfg, true + } + } + return remote.Config{}, false +} + +// executeCmdRemotePhase queries the current pod phase for the selected remote +// session and surfaces it as a status line. +func (a *App) executeCmdRemotePhase() (tea.Model, tea.Cmd) { + sess, ok := a.selectedSession() + if !ok || !sess.IsRemote { + a.copiedMsg = "Select a remote session first" + return a, nil + } + cfg, ok := a.resolveRemoteConfig(sess.RemotePodName) + if !ok { + a.copiedMsg = "No config for remote session" + return a, nil + } + pod := sess.RemotePodName + a.copiedMsg = fmt.Sprintf("Querying %s/%s/%s...", cfg.Context, cfg.Namespace, pod) + return a, func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + phase, err := remote.PodPhase(ctx, cfg, pod) + if err != nil { + phase = "NotFound" + } + return remotePhaseMsg{phases: map[string]string{pod: phase}} + } +} + +// executeCmdRemoteLs jumps to the first remote session in the list. If none +// exist, surfaces a hint. +func (a *App) executeCmdRemoteLs() (tea.Model, tea.Cmd) { + items := a.sessionList.Items() + for i, item := range items { + if si, ok := item.(sessionItem); ok && si.sess.IsRemote { + a.sessionList.Select(i) + a.copiedMsg = fmt.Sprintf("Remote: %s", si.sess.RemotePodName) + return a, nil + } + } + a.copiedMsg = "No remote sessions" + return a, nil +} + +func trimLine(s string, n int) string { + if len(s) <= n { + return s + } + if n <= 1 { + return s[:n] + } + return s[:n-1] + "…" +} + +// executeCmdRemoteSnapshot captures the selected pod's transcript + workdir +// into ~/.config/ccx/snapshots//. +func (a *App) executeCmdRemoteSnapshot(input string) (tea.Model, tea.Cmd) { + parts := strings.Fields(input) + name := "" + if len(parts) >= 2 { + name = parts[1] + } + + sess, ok := a.selectedSession() + if !ok || !sess.IsRemote { + a.copiedMsg = "Select a remote session first" + return a, nil + } + cfg, ok := a.resolveRemoteConfig(sess.RemotePodName) + if !ok { + a.copiedMsg = "No config for remote session" + return a, nil + } + + src := remote.SavedSession{LocalDir: sess.ProjectPath} + for _, s := range remote.LoadSavedSessions() { + if s.PodName == sess.RemotePodName { + src = s + break + } + } + + pod := sess.RemotePodName + a.copiedMsg = fmt.Sprintf("Snapshotting %s...", pod) + return a, func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + meta, err := remote.SaveSnapshot(ctx, cfg, pod, name, src) + return remoteSnapshotMsg{name: meta.Name, meta: meta, err: err} + } +} + +func (a *App) handleRemoteSnapshot(msg remoteSnapshotMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + a.copiedMsg = "Snapshot failed: " + msg.err.Error() + return a, nil + } + parts := []string{"snapshot " + msg.name} + if msg.meta.HasSession { + parts = append(parts, "session") + } + if msg.meta.HasWorkdir { + parts = append(parts, fmt.Sprintf("workdir %s", formatBytes(msg.meta.WorkdirSize))) + } + a.copiedMsg = strings.Join(parts, " · ") + return a, nil +} + +// executeCmdRemoteRestore boots a fresh pod from a saved snapshot. +// Usage: remote:restore [context] [namespace] +func (a *App) executeCmdRemoteRestore(input string) (tea.Model, tea.Cmd) { + parts := strings.Fields(input) + if len(parts) < 2 { + a.copiedMsg = "Usage: remote:restore [context] [namespace]" + return a, nil + } + name := parts[1] + + overrides := mergeRemoteConfig(a.remoteDefaults, remote.Config{}) + if len(parts) >= 3 { + overrides.Context = parts[2] + } + if len(parts) >= 4 { + overrides.Namespace = parts[3] + } + sess, steps, err := remote.StartFromSnapshot(name, overrides, a.config.ClaudeDir) + if err != nil { + a.copiedMsg = "Restore failed: " + err.Error() + return a, nil + } + return a.installRemoteSession(sess, steps) +} + +// executeCmdRemoteFork clones the selected pod's config + workdir snapshot +// into a fresh sibling pod. Captures from the live pod first (no on-disk +// snapshot is left behind). +func (a *App) executeCmdRemoteFork(input string) (tea.Model, tea.Cmd) { + sess, ok := a.selectedSession() + if !ok || !sess.IsRemote { + a.copiedMsg = "Select a remote session first to fork" + return a, nil + } + cfg, ok := a.resolveRemoteConfig(sess.RemotePodName) + if !ok { + a.copiedMsg = "No config for remote session" + return a, nil + } + pod := sess.RemotePodName + + a.copiedMsg = fmt.Sprintf("Forking %s...", pod) + return a, func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + src := remote.SavedSession{LocalDir: sess.ProjectPath} + for _, s := range remote.LoadSavedSessions() { + if s.PodName == pod { + src = s + break + } + } + name := fmt.Sprintf("fork-%s-%d", pod, time.Now().Unix()) + meta, err := remote.SaveSnapshot(ctx, cfg, pod, name, src) + if err != nil { + return remoteSnapshotMsg{err: fmt.Errorf("fork: %w", err)} + } + // Tag this snapshot as a pending fork-restore; handled in handler. + return remoteForkReadyMsg{snapshot: meta.Name, cleanup: true} + } +} + +type remoteForkReadyMsg struct { + snapshot string + cleanup bool +} + +func (a *App) handleRemoteForkReady(msg remoteForkReadyMsg) (tea.Model, tea.Cmd) { + overrides := mergeRemoteConfig(a.remoteDefaults, remote.Config{}) + sess, steps, err := remote.StartFromSnapshot(msg.snapshot, overrides, a.config.ClaudeDir) + if err != nil { + a.copiedMsg = "Fork restore failed: " + err.Error() + return a, nil + } + if msg.cleanup { + _ = remote.DeleteSnapshot(msg.snapshot) + } + return a.installRemoteSession(sess, steps) +} + +// executeCmdRemotePull tars the pod's workdir and extracts it over the +// selected session's LocalDir on the host. Equivalent of machinen `mount-live` +// write-back, condensed to a single explicit pull instead of streaming. +func (a *App) executeCmdRemotePull(input string) (tea.Model, tea.Cmd) { + parts := strings.Fields(input) + sess, ok := a.selectedSession() + if !ok || !sess.IsRemote { + a.copiedMsg = "Select a remote session first" + return a, nil + } + cfg, ok := a.resolveRemoteConfig(sess.RemotePodName) + if !ok { + a.copiedMsg = "No config for remote session" + return a, nil + } + + dest := sess.ProjectPath + if len(parts) >= 2 { + dest = parts[1] + } + if dest == "" { + a.copiedMsg = "Usage: remote:pull [target-dir]" + return a, nil + } + + pod := sess.RemotePodName + a.copiedMsg = fmt.Sprintf("Pulling workdir %s → %s...", pod, dest) + return a, func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + err := remote.FetchWorkdirToDir(ctx, cfg, pod, dest) + return remotePullMsg{podName: pod, dest: dest, err: err} + } +} + +func (a *App) handleRemotePull(msg remotePullMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + a.copiedMsg = "Pull failed: " + msg.err.Error() + return a, nil + } + a.copiedMsg = fmt.Sprintf("Pulled workdir → %s", msg.dest) + return a, nil +} + +// installRemoteSession wires a freshly-Started remote.Session into the App +// (virtual session, persistence, preview). Shared by restore + fork paths. +func (a *App) installRemoteSession(sess *remote.Session, steps <-chan remote.SetupStep) (tea.Model, tea.Cmd) { + if a.remoteSession != nil { + a.copiedMsg = "Remote session already active — :remote:stop first" + return a, nil + } + a.remoteSession = sess + a.remoteSetupSteps = steps + + remote.AddSavedSession(remote.SavedSession{ + PodName: sess.PodName, + Context: sess.Config.Context, + Namespace: sess.Config.Namespace, + Image: sess.Config.Image, + LocalDir: sess.Config.LocalDir, + SessionID: sess.Config.SessionID, + WorkDir: sess.Config.WorkDir, + Status: "starting", + }) + + virtualID := "remote-" + sess.PodName + virtualSess := session.Session{ + ID: virtualID, + ShortID: sess.PodName, + ProjectPath: sess.Config.LocalDir, + ProjectName: "remote:" + sess.PodName, + ModTime: time.Now(), + IsRemote: true, + RemotePodName: sess.PodName, + RemoteContext: sess.Config.Context, + RemoteNamespace: sess.Config.Namespace, + RemoteStatus: "starting...", + FirstPrompt: fmt.Sprintf("%s/%s/%s", sess.Config.Context, sess.Config.Namespace, sess.PodName), + } + inserted := false + for i := range a.sessions { + if a.sessions[i].IsRemote && a.sessions[i].RemotePodName == sess.PodName { + a.sessions[i] = virtualSess + inserted = true + break + } + } + if !inserted { + a.sessions = append([]session.Session{virtualSess}, a.sessions...) + } + a.rebuildSessionList() + + for i, item := range a.sessionList.Items() { + if si, ok := item.(sessionItem); ok && si.sess.ID == virtualID { + a.sessionList.Select(i) + break + } + } + + a.remoteProgressSteps = nil + a.remoteContent = a.buildRemoteProgressView(sess, "Initializing...") + a.copiedMsg = fmt.Sprintf("Remote → %s/%s/%s", sess.Config.Context, sess.Config.Namespace, sess.PodName) + if sess.Config.ArchMismatch() { + a.copiedMsg += fmt.Sprintf(" [arch %s ≠ host %s]", sess.Config.Arch, remote.HostArch()) + } + + if !a.sessSplit.Show { + a.sessSplit.Show = true + contentH := max(a.height-3, 1) + a.sessionList.SetSize(a.sessSplit.ListWidth(a.width, a.splitRatio), contentH) + } + a.sessSplit.CacheKey = "remote:" + virtualID + a.sessSplit.Preview.SetContent(a.remoteContent) + + return a, readSetupStep(sess.PodName, steps) +} + +// executeCmdRemoteSnapshots lists saved snapshots as a status line. +func (a *App) executeCmdRemoteSnapshots() (tea.Model, tea.Cmd) { + snaps := remote.ListSnapshots() + if len(snaps) == 0 { + a.copiedMsg = "No snapshots" + return a, nil + } + var names []string + for _, s := range snaps { + names = append(names, s.Name) + if len(names) >= 5 { + break + } + } + more := "" + if len(snaps) > len(names) { + more = fmt.Sprintf(" (+%d more)", len(snaps)-len(names)) + } + a.copiedMsg = "Snapshots: " + strings.Join(names, ", ") + more + return a, nil +} + +// executeCmdRemoteRmSnap deletes a snapshot directory on disk. +func (a *App) executeCmdRemoteRmSnap(input string) (tea.Model, tea.Cmd) { + parts := strings.Fields(input) + if len(parts) < 2 { + a.copiedMsg = "Usage: remote:rm-snap " + return a, nil + } + name := parts[1] + if err := remote.DeleteSnapshot(name); err != nil { + a.copiedMsg = "Delete failed: " + err.Error() + return a, nil + } + a.copiedMsg = "Deleted snapshot " + name + return a, nil +} + +func (a *App) executeCmdRemoteExportSnap(input string) (tea.Model, tea.Cmd) { + parts := strings.Fields(input) + if len(parts) < 3 { + a.copiedMsg = "Usage: remote:export-snap " + return a, nil + } + name, out := parts[1], parts[2] + if err := remote.ExportSnapshot(name, out); err != nil { + a.copiedMsg = "Export failed: " + err.Error() + return a, nil + } + a.copiedMsg = fmt.Sprintf("Exported %s → %s", name, out) + return a, nil +} + +func (a *App) executeCmdRemoteImportSnap(input string) (tea.Model, tea.Cmd) { + parts := strings.Fields(input) + if len(parts) < 2 { + a.copiedMsg = "Usage: remote:import-snap [snapshot-name]" + return a, nil + } + name := "" + if len(parts) >= 3 { + name = parts[2] + } + meta, err := remote.ImportSnapshot(parts[1], name) + if err != nil { + a.copiedMsg = "Import failed: " + err.Error() + return a, nil + } + a.copiedMsg = "Imported snapshot " + meta.Name + return a, nil +} + +func formatBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for x := n / unit; x >= unit; x /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) +} diff --git a/internal/tui/render_test.go b/internal/tui/render_test.go index 2399b35..4f493f6 100644 --- a/internal/tui/render_test.go +++ b/internal/tui/render_test.go @@ -257,7 +257,7 @@ func TestRender_SeparatorWrappedTextCursorFirstLineOnly(t *testing.T) { lines := strings.Split(stripANSIForGolden(rp.content), "\n") markerLines := 0 for _, line := range lines { - if strings.HasPrefix(line, "› ") || strings.HasPrefix(line, "▾ ") || strings.HasPrefix(line, "▸ ") || strings.HasPrefix(line, "✦ ") { + if strings.HasPrefix(line, "› ") || strings.HasPrefix(line, iconFoldOpen+" ") || strings.HasPrefix(line, iconFoldClosed+" ") || strings.HasPrefix(line, iconBlockMarker+" ") { markerLines++ } } diff --git a/internal/tui/session_keybindings_test.go b/internal/tui/session_keybindings_test.go index 31f0d43..37d1cee 100644 --- a/internal/tui/session_keybindings_test.go +++ b/internal/tui/session_keybindings_test.go @@ -13,7 +13,7 @@ func newSessionKeybindingApp() *App { app.sessSplit.Show = false app.sessSplit.Focus = false contentH := ContentHeight(app.height) - app.sessionList = newSessionList(app.sessions, app.sessSplit.ListWidth(app.width, app.splitRatio), contentH, app.sessGroupMode, app.selectedSet, app.hiddenBadges, app.config.WorktreeDir) + app.sessionList = newSessionList(app.sessions, app.sessSplit.ListWidth(app.width, app.splitRatio), contentH, app.sessGroupMode, app.selectedSet, app.hiddenBadges, app.sessFolded, app.sessionRowCache, app.config.WorktreeDir) app.sessionList.ResetFilter() app.sessSplit.List = &app.sessionList return app @@ -60,14 +60,18 @@ func TestSessionsGJumpsToEnd(t *testing.T) { } } -func TestSessionsTabStillCyclesGroupMode(t *testing.T) { +func TestSessionsTabCyclesPreviewModeInsteadOfGrouping(t *testing.T) { app := newSessionKeybindingApp() - start := app.sessGroupMode + app.sessPreviewMode = sessPreviewConversation + startGroup := app.sessGroupMode m, _ := app.handleSessionKeys(tea.KeyMsg{Type: tea.KeyTab}) app = m.(*App) - if app.sessGroupMode == start { - t.Fatalf("tab should still cycle group mode, stayed at %d", start) + if app.sessGroupMode != startGroup { + t.Fatalf("tab should no longer cycle group mode, changed from %d to %d", startGroup, app.sessGroupMode) + } + if !app.sessSplit.Show { + t.Fatalf("tab should open the preview when hidden") } } @@ -84,12 +88,12 @@ func TestSessionsSpaceDoesNotSelectWhenPreviewFocused(t *testing.T) { } } -func TestSessionsHelpShowsNavigationAndTabGrouping(t *testing.T) { +func TestSessionsHelpShowsNavigationAndPreviewTab(t *testing.T) { app := newSessionKeybindingApp() app.sessSplit.Show = false help := stripANSI(app.sessHelpLine()) - for _, want := range []string{"g/G:top/end", "tab/S-tab:group"} { + for _, want := range []string{"g/G:top/end", "tab/S-tab:preview"} { if !strings.Contains(help, want) { t.Fatalf("expected sessions help to contain %q, got %q", want, help) } diff --git a/internal/tui/session_render_bench_test.go b/internal/tui/session_render_bench_test.go new file mode 100644 index 0000000..bc44741 --- /dev/null +++ b/internal/tui/session_render_bench_test.go @@ -0,0 +1,185 @@ +package tui + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/sendbird/ccx/internal/session" +) + +func benchmarkSessions(n int) []session.Session { + now := time.Now() + sessions := make([]session.Session, 0, n) + for i := 0; i < n; i++ { + project := i % 40 + isWorktree := i%3 == 0 + base := fmt.Sprintf("/tmp/repo-%02d", project) + projectPath := base + projectName := fmt.Sprintf("repo-%02d", project) + if isWorktree { + projectPath = fmt.Sprintf("%s/.worktree/branch-%03d", base, i) + projectName = fmt.Sprintf("branch-%03d", i) + } + sessions = append(sessions, session.Session{ + ID: fmt.Sprintf("session-%04d", i), + ShortID: fmt.Sprintf("%08d", i), + ProjectPath: projectPath, + ProjectName: projectName, + GitBranch: fmt.Sprintf("branch-%02d", i%9), + ModTime: now.Add(-time.Duration(i) * time.Minute), + MsgCount: 10 + i%300, + FirstPrompt: fmt.Sprintf("Investigate project %02d issue %03d and summarize the findings", project, i), + IsWorktree: isWorktree, + IsLive: i%7 == 0, + HasShellJobs: i%11 == 0, + HasMonitorJobs: i%17 == 0, + HasTasks: i%5 == 0, + HasAgents: i%4 == 0, + }) + } + return sessions +} + +func BenchmarkProjectCentricBuildGroupedItems(b *testing.B) { + sessions := benchmarkSessions(1000) + folded := map[string]bool{ + "repo:/tmp/repo-01": true, + "repo:/tmp/repo-02": true, + "repo:/tmp/repo-03": true, + } + b.ReportAllocs() + for i := 0; i < b.N; i++ { + items := buildGroupedItems(sessions, groupProjectCentric, folded, ".worktree") + if len(items) == 0 { + b.Fatal("expected items") + } + } +} + +func BenchmarkProjectCentricListViewColdCache(b *testing.B) { + sessions := benchmarkSessions(1000) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + cache := newSessionRowCache(2048) + l := newSessionList(sessions, 120, 40, groupProjectCentric, nil, nil, nil, cache, ".worktree") + if v := l.View(); v == "" { + b.Fatal("empty view") + } + } +} + +func BenchmarkProjectCentricListViewWarmCache(b *testing.B) { + sessions := benchmarkSessions(1000) + cache := newSessionRowCache(2048) + l := newSessionList(sessions, 120, 40, groupProjectCentric, nil, nil, nil, cache, ".worktree") + _ = l.View() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if v := l.View(); v == "" { + b.Fatal("empty view") + } + } +} + +func benchmarkMergedMessages(n int) []mergedMsg { + now := time.Now() + msgs := make([]mergedMsg, 0, n) + for i := 0; i < n; i++ { + role := "assistant" + if i%3 == 0 { + role = "user" + } + text := strings.Repeat(fmt.Sprintf("message-%04d project investigation detail ", i), 4) + blocks := []session.ContentBlock{{Type: "text", Text: text}} + if i%5 == 0 { + blocks = append(blocks, session.ContentBlock{Type: "tool_use", ToolName: "Bash", ToolInput: `{"command":"go test ./..."}`}) + } + msgs = append(msgs, mergedMsg{ + entry: session.Entry{ + UUID: fmt.Sprintf("uuid-%04d", i), + Role: role, + Timestamp: now.Add(time.Duration(i) * time.Minute), + Content: blocks, + }, + startIdx: i, + endIdx: i, + }) + } + return msgs +} + +func BenchmarkConversationPreviewColdCache(b *testing.B) { + msgs := benchmarkMergedMessages(1000) + expanded := make(map[int]bool) + for i := 0; i < len(msgs); i += 20 { + expanded[i] = true + } + b.ReportAllocs() + for i := 0; i < b.N; i++ { + cache := newSessionRowCache(4096) + if v := renderConversationPreview(msgs, 120, 10, expanded, "", cache); v == "" { + b.Fatal("empty preview") + } + } +} + +func BenchmarkConversationPreviewWarmCache(b *testing.B) { + msgs := benchmarkMergedMessages(1000) + expanded := make(map[int]bool) + for i := 0; i < len(msgs); i += 20 { + expanded[i] = true + } + cache := newSessionRowCache(4096) + _ = renderConversationPreview(msgs, 120, 10, expanded, "", cache) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if v := renderConversationPreview(msgs, 120, 10, expanded, "", cache); v == "" { + b.Fatal("empty preview") + } + } +} + +func BenchmarkConversationPreviewWindowedWarmCache(b *testing.B) { + msgs := benchmarkMergedMessages(1000) + cursor := 500 + expanded := map[int]bool{cursor: true, cursor + 1: true} + cache := newSessionRowCache(4096) + _, _, _, _, _ = renderConversationPreviewWindowed(msgs, 120, cursor, expanded, "", cache) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if v, _, _, _, _ := renderConversationPreviewWindowed(msgs, 120, cursor, expanded, "", cache); v == "" { + b.Fatal("empty preview") + } + } +} + +func BenchmarkProjectSummaryPreviewRender(b *testing.B) { + sessions := benchmarkSessions(500) + app := NewApp(sessions, Config{TmuxEnabled: true}) + app.width = 160 + app.height = 50 + app.sessSplit = SplitPane{List: &app.sessionList, ItemHeight: 2, Show: true} + items := buildGroupedItems(sessions, groupProjectCentric, nil, ".worktree") + var pi projectItem + for _, item := range items { + if p, ok := item.(projectItem); ok { + pi = p + break + } + } + if pi.basePath == "" { + b.Fatal("expected a project item") + } + b.ReportAllocs() + for i := 0; i < b.N; i++ { + app.updateProjectPreview(pi) + if app.sessSplit.Preview.View() == "" { + b.Fatal("empty project preview") + } + } +} diff --git a/internal/tui/session_row_cache.go b/internal/tui/session_row_cache.go new file mode 100644 index 0000000..ffbac22 --- /dev/null +++ b/internal/tui/session_row_cache.go @@ -0,0 +1,58 @@ +package tui + +// sessionRowCache is a tiny capped render cache for session/project rows. +// Bubble Tea asks delegates to render visible rows often; caching avoids +// repeating Lip Gloss styling and truncation work when the row identity, +// width, selection state, and active filter have not changed. +type sessionRowCache struct { + items map[string]string + order []string + cap int +} + +func newSessionRowCache(capacity int) *sessionRowCache { + if capacity <= 0 { + capacity = 512 + } + return &sessionRowCache{items: make(map[string]string, capacity), cap: capacity} +} + +func (c *sessionRowCache) Get(key string) (string, bool) { + if c == nil || c.items == nil { + return "", false + } + v, ok := c.items[key] + return v, ok +} + +func (c *sessionRowCache) Set(key, value string) { + if c == nil { + return + } + if c.items == nil { + c.items = make(map[string]string, c.cap) + } + if _, exists := c.items[key]; exists { + c.items[key] = value + return + } + if c.cap <= 0 { + c.cap = 512 + } + if len(c.order) >= c.cap { + oldest := c.order[0] + copy(c.order, c.order[1:]) + c.order = c.order[:len(c.order)-1] + delete(c.items, oldest) + } + c.items[key] = value + c.order = append(c.order, key) +} + +func (c *sessionRowCache) Clear() { + if c == nil { + return + } + clear(c.items) + c.order = c.order[:0] +} diff --git a/internal/tui/sessions.go b/internal/tui/sessions.go index 16be2fc..4e8469a 100644 --- a/internal/tui/sessions.go +++ b/internal/tui/sessions.go @@ -17,49 +17,65 @@ import ( // Group mode constants const ( - groupFlat = 0 - groupProject = 1 - groupTree = 2 - groupChain = 3 - groupFork = 4 - groupBaseProject = 5 - numGroupModes = 6 + groupFlat = 0 + groupProject = 1 + groupTree = 2 + groupChain = 3 + groupFork = 4 + groupBaseProject = 5 + groupProjectCentric = 6 // project rows are first-class, sessions are children + numGroupModes = 7 ) // buildGroupedItems returns list items for the given group mode. -func buildGroupedItems(sessions []session.Session, groupMode int, worktreeDir ...string) []list.Item { +// +// folded contains group keys whose children should be hidden. Builders set +// sessionItem.groupKey/groupChildren on parent rows so the renderer can show +// a fold chevron and so handlers can find which key to toggle. +func buildGroupedItems(sessions []session.Session, groupMode int, folded map[string]bool, worktreeDir ...string) []list.Item { currentSessions, rest := splitCurrentWindow(sessions) - var restItems []list.Item - switch groupMode { - case groupProject: - restItems = buildProjectGroupItems(rest) - case groupTree: - restItems = buildTreeItems(rest) - case groupChain: - restItems = buildChainGroupItems(rest) - case groupFork: - restItems = buildForkGroupItems(rest) - case groupBaseProject: - restItems = buildBaseProjectGroupItems(rest, worktreeDir...) - default: - restItems = make([]list.Item, len(rest)) - for i, s := range rest { - restItems[i] = sessionItem{sess: s} + buildForMode := func(ss []session.Session) []list.Item { + switch groupMode { + case groupProject: + return buildProjectGroupItems(ss, folded) + case groupTree: + return buildTreeItems(ss, folded) + case groupChain: + return buildChainGroupItems(ss, folded) + case groupFork: + return buildForkGroupItems(ss, folded) + case groupBaseProject: + return buildBaseProjectGroupItems(ss, folded, worktreeDir...) + case groupProjectCentric: + return buildProjectCentricItems(ss, folded, worktreeDir...) + default: + items := make([]list.Item, len(ss)) + for i, s := range ss { + items[i] = sessionItem{sess: s} + } + return items } } - if len(currentSessions) == 0 { + currentItems := buildForMode(currentSessions) + restItems := buildForMode(rest) + + if len(currentItems) == 0 { return restItems } - items := make([]list.Item, 0, len(currentSessions)+len(restItems)+2) - items = append(items, headerItem{label: "Current Window"}) - for _, s := range currentSessions { - items = append(items, sessionItem{sess: s}) + items := make([]list.Item, 0, len(currentItems)+len(restItems)+2) + currentLabel := "Current Window" + restLabel := "Sessions" + if groupMode == groupProjectCentric { + currentLabel = "Current Window Projects" + restLabel = "Projects" } + items = append(items, headerItem{label: currentLabel}) + items = append(items, currentItems...) if len(restItems) > 0 { - items = append(items, headerItem{label: "Sessions"}) + items = append(items, headerItem{label: restLabel}) items = append(items, restItems...) } return items @@ -112,15 +128,53 @@ func substringFilter(term string, targets []string) []list.Rank { } type sessionItem struct { - sess session.Session - treeDepth int // 0=root, 1=teammate child - treeLast bool // last child in group (└─ vs ├─) + sess session.Session + treeDepth int // 0=root, 1=teammate child + treeLast bool // last child in group (└─ vs ├─) + groupKey string // stable identity of the group this row heads (depth=0 only when group has children) + groupChildren int // number of children in this group (0 when not a group head) + groupFolded bool // current fold state at build time, used by the renderer for chevron + count } func (s sessionItem) FilterValue() string { return session.FilterValueFor(s.sess, nil) } +// projectItem represents a project (or base repo) row in the +// project-centric view. It is not a session — it is a folder-like row that +// can be expanded to reveal its child sessions. Selecting it does not open +// a conversation; pressing Enter/Open toggles its fold state instead. +type projectItem struct { + basePath string // canonical key (resolved base repo path) + displayName string // shown name (project name or base repo basename) + branch string // main repo's git branch (if any) + sessions []session.Session // all sessions under this project (main + worktrees), sorted recent-first + worktrees int // count of worktree-only sessions + totalMsgs int // sum of MsgCount across sessions + liveSessions int // number of live sessions + bgSessions int // sessions whose lifecycle is BG + monSessions int // sessions with active monitor jobs + stuckCount int // STUCK lifecycle sessions + waitCount int // WAIT lifecycle sessions + doneCount int // DONE lifecycle sessions + busyCount int // BUSY lifecycle sessions + hereCount int // sessions in the current tmux window + bestTime time.Time // most-recent ModTime in this project + expanded bool // current fold state at build time + lifecycle session.LifecycleState +} + +func (p projectItem) FilterValue() string { + // Concatenate every child session's filter value so the project row + // becomes visible whenever any session beneath it matches the filter. + parts := make([]string, 0, len(p.sessions)+3) + parts = append(parts, p.displayName, p.basePath, p.branch, "is:project") + for _, s := range p.sessions { + parts = append(parts, session.FilterValueFor(s, nil)) + } + return strings.Join(parts, " ") +} + // headerSentinel is returned by headerItem.FilterValue so headers never match a // user filter via substring search. The filter wrapper still injects them back // in to keep section titles visible. @@ -140,16 +194,167 @@ func isSeparator(item list.Item) bool { } type sessionDelegate struct { - timeW int // max width of time-ago column - msgW int // max width of message count column - selectedSet map[string]bool // shared reference to App.selectedSet - hiddenBadges map[string]bool // shared reference to App.hiddenBadges + timeW int // max width of time-ago column + msgW int // max width of message count column + selectedSet map[string]bool // shared reference to App.selectedSet + hiddenBadges map[string]bool // shared reference to App.hiddenBadges + rowCache *sessionRowCache // shared render cache for visible project/session rows } func (d sessionDelegate) Height() int { return 2 } func (d sessionDelegate) Spacing() int { return 0 } func (d sessionDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } +func (d sessionDelegate) hiddenBadgeKey() string { + if len(d.hiddenBadges) == 0 { + return "" + } + keys := []string{"HERE", "LIVE", "BUSY", "BG", "MON", "WAIT", "DONE", "STUCK"} + var b strings.Builder + for _, k := range keys { + if d.hiddenBadges[k] { + b.WriteString(k) + b.WriteByte(',') + } + } + return b.String() +} + +func (d sessionDelegate) sessionCacheKey(m list.Model, index int, si sessionItem, selected bool) string { + filterTerm := listFilterTerm(m) + multi := d.selectedSet != nil && d.selectedSet[si.sess.ID] + return fmt.Sprintf("s|%d|%d|%t|%t|%s|%s|%s|%d|%t|%d|%d|%d|%s", + m.Width(), index, selected, multi, filterTerm, d.hiddenBadgeKey(), si.sess.ID, + si.groupChildren, si.groupFolded, int(si.sess.Lifecycle()), si.sess.MsgCount, + si.sess.ModTime.Unix(), si.sess.FirstPrompt) +} + +func (d sessionDelegate) projectCacheKey(m list.Model, index int, pi projectItem, selected bool) string { + return fmt.Sprintf("p|%d|%d|%t|%s|%s|%t|%d|%d|%d|%d|%d|%d|%d|%d|%s", + m.Width(), index, selected, listFilterTerm(m), d.hiddenBadgeKey(), pi.expanded, + len(pi.sessions), pi.totalMsgs, pi.liveSessions, pi.bgSessions, pi.monSessions, + pi.stuckCount, pi.waitCount, pi.doneCount, pi.basePath) +} + +// renderProject draws a folder-style row for a project: chevron + name + +// branch + session count + lifecycle badges. Always 2 rows tall to match +// sessionItem rendering. When selected, the row is highlighted full-width. +func (d sessionDelegate) renderProject(w io.Writer, m list.Model, index int, pi projectItem) { + selected := index == m.Index() + cacheKey := d.projectCacheKey(m, index, pi, selected) + if cached, ok := d.rowCache.Get(cacheKey); ok { + fmt.Fprint(w, cached) + return + } + width := m.Width() + clamp := lipgloss.NewStyle().MaxWidth(width) + + cursor := " " + if selected { + cursor = "> " + } + chevron := iconFoldOpen + if !pi.expanded { + chevron = iconFoldClosed + } + + nameStyle := lipgloss.NewStyle().Foreground(colorPrimary).Bold(true) + branchStyle := dimStyle + countStyle := lipgloss.NewStyle().Foreground(colorAccent) + timeStyle := dimStyle + if selected { + nameStyle = nameStyle.Foreground(lipgloss.Color("#A78BFA")) + branchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#9CA3AF")) + countStyle = countStyle.Bold(true) + timeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#9CA3AF")) + } + + // Badges roll up project-wide lifecycle counts. + hide := d.hiddenBadges + badges := "" + badgesW := 0 + if pi.hereCount > 0 && !hide["HERE"] { + badges = appendBadge(badges, &badgesW, hereBadge, badgeLabel(iconBadgeHere, fmt.Sprintf("HERE×%d", pi.hereCount))) + } + if pi.liveSessions > 0 && !hide["LIVE"] { + badges = appendBadge(badges, &badgesW, liveBadge, badgeLabel(iconBadgeLive, fmt.Sprintf("LIVE×%d", pi.liveSessions))) + } + if pi.busyCount > 0 && !hide["BUSY"] { + badges = appendBadge(badges, &badgesW, busyBadge, badgeLabel(iconBadgeBusy, fmt.Sprintf("BUSY×%d", pi.busyCount))) + } + if pi.bgSessions > 0 && !hide["BG"] { + badges = appendBadge(badges, &badgesW, bgBadgeStyle, badgeLabel(iconBadgeBg, fmt.Sprintf("BG×%d", pi.bgSessions))) + } + if pi.monSessions > 0 && !hide["MON"] { + badges = appendBadge(badges, &badgesW, monBadgeStyle, badgeLabel(iconBadgeMon, fmt.Sprintf("MON×%d", pi.monSessions))) + } + if pi.stuckCount > 0 && !hide["STUCK"] { + badges = appendBadge(badges, &badgesW, stuckBadgeStyle, badgeLabel(iconBadgeStuck, fmt.Sprintf("STUCK×%d", pi.stuckCount))) + } + if pi.waitCount > 0 && !hide["WAIT"] { + badges = appendBadge(badges, &badgesW, waitBadgeStyle, badgeLabel(iconBadgeWait, fmt.Sprintf("WAIT×%d", pi.waitCount))) + } + if pi.doneCount > 0 && !hide["DONE"] { + badges = appendBadge(badges, &badgesW, doneBadgeStyle, badgeLabel(iconBadgeDone, fmt.Sprintf("DONE×%d", pi.doneCount))) + } + + // Header text: line-art folder, name, branch, time, and badges. + branch := "" + if pi.branch != "" { + branch = " (" + pi.branch + ")" + } + icon := iconFolder + if pi.expanded { + icon = iconFolderOpen + } + chev := dimStyle.Render(chevron) + name := nameStyle.Render(pi.displayName) + br := branchStyle.Render(branch) + timeStr := timeStyle.Render(timeAgo(pi.bestTime)) + + summary := fmt.Sprintf(" %d session", len(pi.sessions)) + if len(pi.sessions) != 1 { + summary += "s" + } + if pi.worktrees > 0 { + summary += fmt.Sprintf(", %d wt", pi.worktrees) + } + if pi.totalMsgs > 0 { + summary += fmt.Sprintf(", %dm", pi.totalMsgs) + } + summaryStyled := branchStyle.Render(summary) + + filterTerm := listFilterTerm(m) + if filterTerm != "" { + highlighted := highlightSnippet(pi.displayName, filterTerm, max(width/2, 10), nameStyle) + name = highlighted + } + + line1 := fmt.Sprintf("%s%s %s %s%s %s%s", cursor, chev, icon, name, br, timeStr, badges) + // Pad/clamp. + if selected { + bare := lipgloss.Width(line1) + if bare < width { + line1 += strings.Repeat(" ", width-bare) + } + line1 = selectedRowStyle.Render(line1) + } + + line2 := " " + summaryStyled + if selected { + bare := lipgloss.Width(line2) + if bare < width { + line2 += strings.Repeat(" ", width-bare) + } + line2 = selectedRowStyle.Render(line2) + } + + _ = badgesW // currently unused in layout math; columns are loose for project rows + rendered := fmt.Sprintf("%s\n%s", clamp.Render(line1), clamp.Render(line2)) + d.rowCache.Set(cacheKey, rendered) + fmt.Fprint(w, rendered) +} + // renderHeader draws a section divider like "── Current Window ──". // It always occupies 2 rows (label + blank) so cursor math stays consistent // with sessionItem rows. @@ -177,6 +382,10 @@ func (d sessionDelegate) Render(w io.Writer, m list.Model, index int, item list. d.renderHeader(w, m, h) return } + if pi, ok := item.(projectItem); ok { + d.renderProject(w, m, index, pi) + return + } si, ok := item.(sessionItem) if !ok { return @@ -184,6 +393,11 @@ func (d sessionDelegate) Render(w io.Writer, m list.Model, index int, item list. s := si.sess selected := index == m.Index() + cacheKey := d.sessionCacheKey(m, index, si, selected) + if cached, ok := d.rowCache.Get(cacheKey); ok { + fmt.Fprint(w, cached) + return + } width := m.Width() // Tree connector prefix for depth>0 teammates @@ -198,12 +412,24 @@ func (d sessionDelegate) Render(w io.Writer, m list.Model, index int, item list. treePrefixW = 3 // "├─ " is 3 cells wide } + // Fold chevron for group-head rows: open when expanded, closed when collapsed. + // Non-group rows get a single space so columns stay aligned across rows. + foldPrefix := " " + foldPrefixW := 1 + if si.groupChildren > 0 && si.treeDepth == 0 { + if si.groupFolded { + foldPrefix = dimStyle.Render(iconFoldClosed) + } else { + foldPrefix = dimStyle.Render(iconFoldOpen) + } + } + isMultiSelected := d.selectedSet != nil && d.selectedSet[s.ID] cursor := " " if selected && isMultiSelected { - cursor = selectMarkStyle.Render("✓") + " " + cursor = selectMarkStyle.Render(iconSelect) + " " } else if isMultiSelected { - cursor = selectMarkStyle.Render("✓") + " " + cursor = selectMarkStyle.Render(iconSelect) + " " } else if selected { cursor = "> " } @@ -239,40 +465,42 @@ func (d sessionDelegate) Render(w io.Writer, m list.Model, index int, item list. badgesW := 0 hide := d.hiddenBadges if s.IsCurrentWindow && !hide["HERE"] { - badges += " " + hereBadge.Render("[HERE]") - badgesW += 7 + badges = appendBadge(badges, &badgesW, hereBadge, badgeLabel(iconBadgeHere, "HERE")) } if s.IsLive && !hide["LIVE"] { - badges += " " + liveBadge.Render("[LIVE]") - badgesW += 7 + badges = appendBadge(badges, &badgesW, liveBadge, badgeLabel(iconBadgeLive, "LIVE")) } switch s.Lifecycle() { case session.LifecycleBusy: if !hide["BUSY"] { - badges += " " + busyBadge.Render("[BUSY]") - badgesW += 7 + badges = appendBadge(badges, &badgesW, busyBadge, badgeLabel(iconBadgeBusy, "BUSY")) } case session.LifecycleBG: if !hide["BG"] { - badges += " " + bgBadgeStyle.Render("[BG]") - badgesW += 5 + badges = appendBadge(badges, &badgesW, bgBadgeStyle, badgeLabel(iconBadgeBg, "BG")) } case session.LifecycleStuck: if !hide["STUCK"] { - badges += " " + stuckBadgeStyle.Render("[STUCK]") - badgesW += 8 + badges = appendBadge(badges, &badgesW, stuckBadgeStyle, badgeLabel(iconBadgeStuck, "STUCK")) } case session.LifecycleWait: if !hide["WAIT"] { - badges += " " + waitBadgeStyle.Render("[WAIT]") - badgesW += 7 + badges = appendBadge(badges, &badgesW, waitBadgeStyle, badgeLabel(iconBadgeWait, "WAIT")) } case session.LifecycleDone: if !hide["DONE"] { - badges += " " + doneBadgeStyle.Render("[DONE]") - badgesW += 7 + badges = appendBadge(badges, &badgesW, doneBadgeStyle, badgeLabel(iconBadgeDone, "DONE")) } } + // Monitor badge: surfaces sessions that have at least one Monitor tool + // invocation while the Claude process is still live. The [BG] badge + // already signals "background work running", but doesn't distinguish a + // Monitor (long-running watcher) from a one-off background Bash, and + // users explicitly want to see which sessions are currently watching + // something. + if s.IsLive && s.HasMonitorJobs && !hide["MON"] { + badges = appendBadge(badges, &badgesW, monBadgeStyle, badgeLabel(iconBadgeMon, "MON")) + } // Custom user badges for _, badge := range s.CustomBadges { badgeText := "[" + badge + "]" @@ -281,13 +509,21 @@ func (d sessionDelegate) Render(w io.Writer, m list.Model, index int, item list. } if s.IsRemote { remoteBadge := lipgloss.NewStyle().Foreground(lipgloss.Color("#7C3AED")).Bold(true) - badges += " " + remoteBadge.Render("[R·exp]") - badgesW += 8 + badges = appendBadge(badges, &badgesW, remoteBadge, badgeLabel(iconBadgeRemote, "REMOTE")) + } + + // Child-count badge for group head rows. When folded we always show it + // (so the user knows what's hidden); when expanded we still surface it + // because the children may scroll out of view in long lists. + if si.groupChildren > 0 && si.treeDepth == 0 { + countText := fmt.Sprintf("[+%d]", si.groupChildren) + badges += " " + dimStyle.Bold(true).Render(countText) + badgesW += len(countText) + 1 } // Calculate available width for project column - // cursor(2) + tree(treePrefixW) + id(8) + 2 + time + 2 + msg + 2 + project + badges - fixedW := 2 + treePrefixW + 8 + 2 + d.timeW + 2 + d.msgW + 2 + badgesW + // cursor(2) + fold(foldPrefixW) + tree(treePrefixW) + id(8) + 2 + time + 2 + msg + 2 + project + badges + fixedW := 2 + foldPrefixW + treePrefixW + 8 + 2 + d.timeW + 2 + d.msgW + 2 + badgesW maxProjW := width - fixedW if maxProjW < 4 { maxProjW = 4 @@ -338,11 +574,11 @@ func (d sessionDelegate) Render(w io.Writer, m list.Model, index int, item list. } } - line1 := fmt.Sprintf("%s%s%s %s %s %s%s", cursor, treePrefix, idStr, timeStr, msgStr, project, badges) + line1 := fmt.Sprintf("%s%s%s%s %s %s %s%s", cursor, foldPrefix, treePrefix, idStr, timeStr, msgStr, project, badges) prompt := s.FirstPrompt - maxW := width - 6 - treePrefixW - promptIndent := " " + strings.Repeat(" ", treePrefixW) + maxW := width - 6 - treePrefixW - foldPrefixW + promptIndent := " " + strings.Repeat(" ", treePrefixW+foldPrefixW) var line2 string if filterTerm != "" && maxW > 0 { line2 = promptIndent + highlightSnippet(prompt, filterTerm, maxW, promptStyle) @@ -368,7 +604,9 @@ func (d sessionDelegate) Render(w io.Writer, m list.Model, index int, item list. } clamp := lipgloss.NewStyle().MaxWidth(width) - fmt.Fprintf(w, "%s\n%s", clamp.Render(line1), clamp.Render(line2)) + rendered := fmt.Sprintf("%s\n%s", clamp.Render(line1), clamp.Render(line2)) + d.rowCache.Set(cacheKey, rendered) + fmt.Fprint(w, rendered) } func computeSessionColWidths(sessions []session.Session) (timeW, msgW int) { @@ -383,19 +621,19 @@ func computeSessionColWidths(sessions []session.Session) (timeW, msgW int) { return } -func newSessionList(sessions []session.Session, width, height int, groupMode int, selectedSet map[string]bool, hiddenBadges map[string]bool, worktreeDir ...string) list.Model { - items := buildGroupedItems(sessions, groupMode, worktreeDir...) +func newSessionList(sessions []session.Session, width, height int, groupMode int, selectedSet map[string]bool, hiddenBadges map[string]bool, folded map[string]bool, rowCache *sessionRowCache, worktreeDir ...string) list.Model { + items := buildGroupedItems(sessions, groupMode, folded, worktreeDir...) timeW, msgW := computeSessionColWidths(sessions) - l := list.New(items, sessionDelegate{timeW: timeW, msgW: msgW, selectedSet: selectedSet, hiddenBadges: hiddenBadges}, width, height) + l := list.New(items, sessionDelegate{timeW: timeW, msgW: msgW, selectedSet: selectedSet, hiddenBadges: hiddenBadges, rowCache: rowCache}, width, height) initListBase(&l) l.SetFilteringEnabled(true) // Use chain-aware filter for grouped modes so children stay visible // when their parent matches (and vice versa). var base list.FilterFunc - if groupMode == groupChain || groupMode == groupFork || groupMode == groupTree || groupMode == groupBaseProject { + if groupMode == groupChain || groupMode == groupFork || groupMode == groupTree || groupMode == groupBaseProject || groupMode == groupProjectCentric { base = buildChainAwareFilter(items) } else { base = substringFilter @@ -485,15 +723,16 @@ func buildChainAwareFilter(items []list.Item) list.FilterFunc { childrenOf := make(map[int][]int) // parent index → child indices lastParent := -1 for i, item := range items { - si, ok := item.(sessionItem) - if !ok { - continue - } - if si.treeDepth == 0 { + switch v := item.(type) { + case projectItem: lastParent = i - } else if lastParent >= 0 { - parentOf[i] = lastParent - childrenOf[lastParent] = append(childrenOf[lastParent], i) + case sessionItem: + if v.treeDepth == 0 { + lastParent = i + } else if lastParent >= 0 { + parentOf[i] = lastParent + childrenOf[lastParent] = append(childrenOf[lastParent], i) + } } } @@ -542,7 +781,7 @@ func buildChainAwareFilter(items []list.Item) list.FilterFunc { // buildTreeItems groups sessions by team, placing leaders at depth=0 and // teammates at depth=1, interleaved with standalone sessions by recency. -func buildTreeItems(sessions []session.Session) []list.Item { +func buildTreeItems(sessions []session.Session, folded map[string]bool) []list.Item { type teamGroup struct { projectPath string teamName string @@ -612,19 +851,32 @@ func buildTreeItems(sessions []session.Session) []list.Item { if useGroup { g := groupList[gi] gi++ + groupKey := "team:" + g.projectPath + "\x00" + g.teamName // Leader (or first teammate as header if no leader) + var header session.Session if g.leader != nil { - items = append(items, sessionItem{sess: *g.leader, treeDepth: 0}) + header = *g.leader } else if len(g.teammates) > 0 { - items = append(items, sessionItem{sess: g.teammates[0], treeDepth: 0}) + header = g.teammates[0] g.teammates = g.teammates[1:] } - for ti, tm := range g.teammates { - items = append(items, sessionItem{ - sess: tm, - treeDepth: 1, - treeLast: ti == len(g.teammates)-1, - }) + children := g.teammates + isFolded := folded[groupKey] && len(children) > 0 + items = append(items, sessionItem{ + sess: header, + treeDepth: 0, + groupKey: groupKey, + groupChildren: len(children), + groupFolded: isFolded, + }) + if !isFolded { + for ti, tm := range children { + items = append(items, sessionItem{ + sess: tm, + treeDepth: 1, + treeLast: ti == len(children)-1, + }) + } } } else { items = append(items, sessionItem{sess: standalone[si], treeDepth: 0}) @@ -638,7 +890,7 @@ func buildTreeItems(sessions []session.Session) []list.Item { // buildChainGroupItems groups sessions that share the same PlanSlug (continuation // chain). The earliest session in each chain becomes the depth=0 header; later // sessions are depth=1 children sorted by Created time. -func buildChainGroupItems(sessions []session.Session) []list.Item { +func buildChainGroupItems(sessions []session.Session, folded map[string]bool) []list.Item { type chainGroup struct { slug string sessions []session.Session @@ -705,9 +957,20 @@ func buildChainGroupItems(sessions []session.Session) []list.Item { if useChain { g := chainList[ci] ci++ - // Earliest session is the header - items = append(items, sessionItem{sess: g.sessions[0], treeDepth: 0}) + groupKey := "chain:" + g.slug children := g.sessions[1:] + isFolded := folded[groupKey] && len(children) > 0 + // Earliest session is the header + items = append(items, sessionItem{ + sess: g.sessions[0], + treeDepth: 0, + groupKey: groupKey, + groupChildren: len(children), + groupFolded: isFolded, + }) + if isFolded { + continue + } for idx, ch := range children { items = append(items, sessionItem{ sess: ch, @@ -726,7 +989,7 @@ func buildChainGroupItems(sessions []session.Session) []list.Item { // buildForkGroupItems groups forked sessions under their parent session. // Only ParentSessionID relationships are used — sessions without fork // relationships appear standalone (flat). -func buildForkGroupItems(sessions []session.Session) []list.Item { +func buildForkGroupItems(sessions []session.Session, folded map[string]bool) []list.Item { type forkGroup struct { sessions []session.Session bestTime time.Time @@ -825,8 +1088,23 @@ func buildForkGroupItems(sessions []session.Session) []list.Item { if useFork { g := forkList[fi] fi++ - items = append(items, sessionItem{sess: g.sessions[0], treeDepth: 0}) + rootID := "" + if len(g.sessions) > 0 { + rootID = g.sessions[0].ID + } + groupKey := "fork:" + rootID children := g.sessions[1:] + isFolded := folded[groupKey] && len(children) > 0 + items = append(items, sessionItem{ + sess: g.sessions[0], + treeDepth: 0, + groupKey: groupKey, + groupChildren: len(children), + groupFolded: isFolded, + }) + if isFolded { + continue + } for idx, ch := range children { items = append(items, sessionItem{ sess: ch, @@ -845,7 +1123,7 @@ func buildForkGroupItems(sessions []session.Session) []list.Item { // buildProjectGroupItems groups sessions by ProjectPath. The most recent // session in each project becomes the depth=0 header; the rest are depth=1 // children showing branch name instead of project. -func buildProjectGroupItems(sessions []session.Session) []list.Item { +func buildProjectGroupItems(sessions []session.Session, folded map[string]bool) []list.Item { type projGroup struct { projectPath string sessions []session.Session @@ -890,9 +1168,20 @@ func buildProjectGroupItems(sessions []session.Session) []list.Item { items = append(items, sessionItem{sess: g.sessions[0], treeDepth: 0}) continue } - // First (most recent) session is the header - items = append(items, sessionItem{sess: g.sessions[0], treeDepth: 0}) + groupKey := "proj:" + g.projectPath children := g.sessions[1:] + isFolded := folded[groupKey] + // First (most recent) session is the header + items = append(items, sessionItem{ + sess: g.sessions[0], + treeDepth: 0, + groupKey: groupKey, + groupChildren: len(children), + groupFolded: isFolded, + }) + if isFolded { + continue + } for ci, ch := range children { items = append(items, sessionItem{ sess: ch, @@ -908,7 +1197,7 @@ func buildProjectGroupItems(sessions []session.Session) []list.Item { // buildBaseProjectGroupItems groups sessions by base repository, resolving // worktrees to their main repo using git info (.git file) or path patterns. // Sessions from the same base repo (main + worktrees) appear under one group. -func buildBaseProjectGroupItems(sessions []session.Session, worktreeDirs ...string) []list.Item { +func buildBaseProjectGroupItems(sessions []session.Session, folded map[string]bool, worktreeDirs ...string) []list.Item { type baseGroup struct { basePath string sessions []session.Session @@ -957,8 +1246,19 @@ func buildBaseProjectGroupItems(sessions []session.Session, worktreeDirs ...stri items = append(items, sessionItem{sess: g.sessions[0], treeDepth: 0}) continue } - items = append(items, sessionItem{sess: g.sessions[0], treeDepth: 0}) + groupKey := "repo:" + g.basePath children := g.sessions[1:] + isFolded := folded[groupKey] + items = append(items, sessionItem{ + sess: g.sessions[0], + treeDepth: 0, + groupKey: groupKey, + groupChildren: len(children), + groupFolded: isFolded, + }) + if isFolded { + continue + } for ci, ch := range children { items = append(items, sessionItem{ sess: ch, @@ -971,6 +1271,167 @@ func buildBaseProjectGroupItems(sessions []session.Session, worktreeDirs ...stri return items } +// buildProjectCentricItems creates a project-centric view where each base +// repository becomes a first-class row (`projectItem`). Its child sessions +// (`sessionItem` with treeDepth=1) are shown beneath it only when the +// project is expanded — controlled by the same `folded` map used by other +// group modes, keyed by `repo:`. +func buildProjectCentricItems(sessions []session.Session, folded map[string]bool, worktreeDirs ...string) []list.Item { + type proj struct { + basePath string + name string + branch string + sessions []session.Session + bestTime time.Time + } + + groups := make(map[string]*proj) + for i := range sessions { + s := &sessions[i] + basePath := session.ResolveBaseRepo(s.ProjectPath, worktreeDirs...) + g, ok := groups[basePath] + if !ok { + g = &proj{basePath: basePath} + groups[basePath] = g + } + g.sessions = append(g.sessions, *s) + if s.ModTime.After(g.bestTime) { + g.bestTime = s.ModTime + } + // First non-worktree session contributes the display name and branch. + if g.name == "" && !s.IsWorktree && s.ProjectPath == basePath { + g.name = s.ProjectName + g.branch = s.GitBranch + } + } + // Fallback display name: any session's ProjectName, else basename of basePath. + for _, g := range groups { + if g.name != "" { + continue + } + for _, s := range g.sessions { + if s.ProjectName != "" { + g.name = s.ProjectName + break + } + } + if g.name == "" { + g.name = filepathBase(g.basePath) + } + } + + // Sort each project's sessions: main-repo first (by ModTime desc), then + // worktrees (by ModTime desc). + for _, g := range groups { + sort.Slice(g.sessions, func(i, j int) bool { + iIsWT := g.sessions[i].IsWorktree || g.sessions[i].ProjectPath != g.basePath + jIsWT := g.sessions[j].IsWorktree || g.sessions[j].ProjectPath != g.basePath + if iIsWT != jIsWT { + return !iIsWT + } + return g.sessions[i].ModTime.After(g.sessions[j].ModTime) + }) + } + + // Sort projects by recency. + projList := make([]*proj, 0, len(groups)) + for _, g := range groups { + projList = append(projList, g) + } + sort.Slice(projList, func(i, j int) bool { + return projList[i].bestTime.After(projList[j].bestTime) + }) + + items := make([]list.Item, 0, len(projList)*2) + for _, g := range projList { + key := "repo:" + g.basePath + expanded := !folded[key] + pi := projectItem{ + basePath: g.basePath, + displayName: g.name, + branch: g.branch, + sessions: g.sessions, + bestTime: g.bestTime, + expanded: expanded, + totalMsgs: 0, + } + for _, s := range g.sessions { + pi.totalMsgs += s.MsgCount + if s.IsWorktree || s.ProjectPath != g.basePath { + pi.worktrees++ + } + if s.IsLive { + pi.liveSessions++ + } + if s.IsCurrentWindow { + pi.hereCount++ + } + if s.HasMonitorJobs && s.IsLive { + pi.monSessions++ + } + switch s.Lifecycle() { + case session.LifecycleBusy: + pi.busyCount++ + case session.LifecycleBG: + pi.bgSessions++ + case session.LifecycleStuck: + pi.stuckCount++ + case session.LifecycleWait: + pi.waitCount++ + case session.LifecycleDone: + pi.doneCount++ + } + } + // Compute a representative lifecycle for the row's badge. + switch { + case pi.busyCount > 0: + pi.lifecycle = session.LifecycleBusy + case pi.bgSessions > 0: + pi.lifecycle = session.LifecycleBG + case pi.stuckCount > 0: + pi.lifecycle = session.LifecycleStuck + case pi.waitCount > 0: + pi.lifecycle = session.LifecycleWait + case pi.doneCount > 0: + pi.lifecycle = session.LifecycleDone + default: + pi.lifecycle = session.LifecycleNone + } + items = append(items, pi) + if !expanded { + continue + } + for ci, ch := range g.sessions { + items = append(items, sessionItem{ + sess: ch, + treeDepth: 1, + treeLast: ci == len(g.sessions)-1, + }) + } + } + return items +} + +// filepathBase returns the trailing path element. We avoid importing +// filepath here for this single use to keep the package's imports tidy. +func filepathBase(p string) string { + if p == "" { + return "" + } + for i := len(p) - 1; i >= 0; i-- { + if p[i] == '/' { + return p[i+1:] + } + } + return p +} + +func appendBadge(badges string, badgesW *int, style lipgloss.Style, text string) string { + badges += " " + style.Render(text) + *badgesW += lipgloss.Width(text) + 1 + return badges +} + func timeAgo(t time.Time) string { if t.IsZero() { return "unknown" @@ -990,6 +1451,60 @@ func timeAgo(t time.Time) string { } } +type modalOptions struct { + maxWidth int + maxHeight int + paddingX int + paddingY int +} + +func overlayCenteredModal(bg, fg string, screenW, screenH int, opts modalOptions) string { + if fg == "" { + return bg + } + bgLines := strings.Split(bg, "\n") + fgLines := strings.Split(fg, "\n") + + for len(bgLines) < screenH { + bgLines = append(bgLines, "") + } + + fgH := len(fgLines) + fgW := 0 + for _, l := range fgLines { + if w := lipgloss.Width(l); w > fgW { + fgW = w + } + } + + padX := max(opts.paddingX, 0) + padY := max(opts.paddingY, 0) + outerW := fgW + padX*2 + outerH := fgH + padY*2 + if opts.maxWidth > 0 { + outerW = min(outerW, opts.maxWidth) + } + if opts.maxHeight > 0 { + outerH = min(outerH, opts.maxHeight) + } + + startY := max((screenH-outerH)/2, 0) + padY + startX := max((screenW-outerW)/2, 0) + padX + + for i, fgLine := range fgLines { + bgIdx := startY + i + if bgIdx >= len(bgLines) { + break + } + bgLines[bgIdx] = overlayLine(bgLines[bgIdx], fgLine, startX, screenW) + } + + if len(bgLines) > screenH { + bgLines = bgLines[:screenH] + } + return strings.Join(bgLines, "\n") +} + // renderHelpModal renders a centered bordered modal with help content overlaid on bg. func renderHelpModal(bg string, screenW, screenH int, km Keymap, shortcutHint string) string { titleStyle := lipgloss.NewStyle().Bold(true).Foreground(colorPrimary) @@ -1007,14 +1522,15 @@ func renderHelpModal(bg string, screenW, screenH int, km Keymap, shortcutHint st desc string } allBadges := []badge{ - {hereBadge, "[HERE]", "In current tmux window"}, - {liveBadge, "[LIVE]", "Running Claude"}, - {busyBadge, "[BUSY]", "Responding now"}, - {bgBadgeStyle, "[BG]", "Background shell/monitor/cron"}, - {waitBadgeStyle, "[WAIT]", "Idle, waiting for user"}, - {doneBadgeStyle, "[DONE]", "All work completed"}, - {stuckBadgeStyle, "[STUCK]", "Live but stale with unfinished work"}, - {lipgloss.NewStyle().Foreground(lipgloss.Color("#7C3AED")).Bold(true), "[R·exp]", "Remote (experimental)"}, + {hereBadge, badgeLabel(iconBadgeHere, "HERE"), "In current tmux window"}, + {liveBadge, badgeLabel(iconBadgeLive, "LIVE"), "Running Claude"}, + {busyBadge, badgeLabel(iconBadgeBusy, "BUSY"), "Responding now"}, + {bgBadgeStyle, badgeLabel(iconBadgeBg, "BG"), "Background shell/monitor/cron"}, + {monBadgeStyle, badgeLabel(iconBadgeMon, "MON"), "Monitor tool currently in flight"}, + {waitBadgeStyle, badgeLabel(iconBadgeWait, "WAIT"), "Idle, waiting for user"}, + {doneBadgeStyle, badgeLabel(iconBadgeDone, "DONE"), "All work completed"}, + {stuckBadgeStyle, badgeLabel(iconBadgeStuck, "STUCK"), "Live but stale with unfinished work"}, + {lipgloss.NewStyle().Foreground(lipgloss.Color("#7C3AED")).Bold(true), badgeLabel(iconBadgeRemote, "REMOTE"), "Remote (experimental)"}, } // Render badges in pairs (two per line) for i := 0; i < len(allBadges); i += 2 { @@ -1039,6 +1555,7 @@ func renderHelpModal(bg string, screenW, screenH int, km Keymap, shortcutHint st {"is:bg", "Background work in flight"}, {"is:wait", "Idle, waiting for user"}, {"is:done", "All work completed"}, + {"D", "Toggle completed-only"}, {"is:stuck", "Stale, unfinished"}, {"is:wt", "Worktree sessions"}, {"is:team", "Team sessions"}, @@ -1050,6 +1567,7 @@ func renderHelpModal(bg string, screenW, screenH int, km Keymap, shortcutHint st {"has:compact", "With compaction"}, {"has:skill", "With skills"}, {"has:mcp", "With MCP tools"}, + {"is:mon", "Monitor in flight"}, {"proj:", "By project name"}, {"team:", "By team name"}, {"is:fork", "Forked sessions"}, @@ -1076,12 +1594,14 @@ func renderHelpModal(bg string, screenW, screenH int, km Keymap, shortcutHint st {displayKey(sk.Edit), "Edit session files"}, {displayKey(sk.Actions), "Actions (" + displayKey(km.Actions.Delete) + "/" + displayKey(km.Actions.Move) + "/" + displayKey(km.Actions.Resume) + "/" + displayKey(km.Actions.CopyPath) + "/" + displayKey(km.Actions.Worktree) + "/" + displayKey(km.Actions.Kill) + "/" + displayKey(km.Actions.Input) + "/" + displayKey(km.Actions.Jump) + ")"}, {displayKey(sk.Search), "Search / filter"}, - {displayKey(sk.Group), "Group (flat→proj→tree→chain)"}, {displayKey(km.Views.Stats), "Global stats"}, {displayKey(sk.Refresh), "Refresh list"}, {displayKey(sk.Preview), "Cycle preview mode (conv→stats→mem→tasks/plan)"}, {displayKey(sk.Live), "Live preview (^Q:unfocus)"}, {displayKey(sk.Select), "Toggle multi-select"}, + {"o", "Fold/expand project group"}, + {"f / F", "Fold all / expand all groups"}, + {"D", "Toggle completed-only filter"}, {displayKey(sk.Help), "This help"}, {displayKey(sk.Quit), "Quit"}, } diff --git a/internal/tui/splitpane.go b/internal/tui/splitpane.go index 86e9eb4..bd2c4e3 100644 --- a/internal/tui/splitpane.go +++ b/internal/tui/splitpane.go @@ -76,7 +76,7 @@ func ContentHeight(totalH int) int { // truncateExact truncates s to at most targetW display cells. // Uses the Wc (wide-char) variants which treat East Asian Ambiguous-width -// characters (○, ●, ■, ✓, etc.) as 2 cells, matching CJK terminal rendering. +// characters as 2 cells, matching CJK terminal rendering. func truncateExact(s string, targetW int) (string, int) { if targetW <= 0 { return "", 0 diff --git a/internal/tui/state.go b/internal/tui/state.go index c02bd75..282dc85 100644 --- a/internal/tui/state.go +++ b/internal/tui/state.go @@ -22,6 +22,7 @@ type Preferences struct { HiddenBadges []string `yaml:"hidden_badges,omitempty"` // badge keys to hide: M,W,T,K,P,A,C,S,X,F FilterTerm string `yaml:"filter_term,omitempty"` // last applied session filter EditorInput bool `yaml:"editor_input,omitempty"` // true = open $EDITOR for live input + FoldedGroups []string `yaml:"folded_groups,omitempty"` // session group keys collapsed in the list } // KeymapsConfig groups all keybinding sections under one key. @@ -251,6 +252,8 @@ func groupModeString(mode int) string { return "fork" case groupBaseProject: return "repo" + case groupProjectCentric: + return "projects" } return "" } @@ -282,7 +285,7 @@ func sessPreviewString(mode sessPreview) string { func viewStateString(state viewState) string { switch state { case viewSessions: - return "sessions" + return "projects" case viewGlobalStats: return "stats" case viewConfig: @@ -315,8 +318,15 @@ func (a *App) capturePreferences() Preferences { filterTerm = a.sessionList.FilterInput.Value() } + var foldedGroups []string + for k, v := range a.sessFolded { + if v { + foldedGroups = append(foldedGroups, k) + } + } + return Preferences{ - GroupMode: groupModeString(a.sessGroupMode), + GroupMode: "projects", PreviewMode: sessPreviewString(a.sessPreviewMode), ViewMode: viewStateString(a.state), ConvDetailLevel: int(a.conv.rightPaneMode), @@ -325,6 +335,7 @@ func (a *App) capturePreferences() Preferences { HiddenBadges: hidden, FilterTerm: filterTerm, EditorInput: a.editorInput, + FoldedGroups: foldedGroups, } } @@ -356,6 +367,14 @@ func (a *App) applyPreferences(p Preferences) { a.hiddenBadges[b] = true } } + if len(p.FoldedGroups) > 0 { + if a.sessFolded == nil { + a.sessFolded = make(map[string]bool) + } + for _, k := range p.FoldedGroups { + a.sessFolded[k] = true + } + } if p.FilterTerm != "" { a.config.SearchQuery = p.FilterTerm } diff --git a/internal/tui/stats.go b/internal/tui/stats.go index 2522ef5..e1df0f5 100644 --- a/internal/tui/stats.go +++ b/internal/tui/stats.go @@ -16,279 +16,152 @@ func renderSessionStats(stats session.SessionStats, width int) string { titleStyle := statTitleStyle numStyle := statNumStyle labelStyle := dimStyle - ruler := dimStyle.Render(strings.Repeat("─", min(width, 40))) - - // ── TIMELINE ── - sb.WriteString(titleStyle.Render("TIMELINE") + "\n") - sb.WriteString(ruler + "\n") + ruler := dimStyle.Render(strings.Repeat("─", min(width, 52))) dur := stats.LastTimestamp.Sub(stats.FirstTimestamp) - sb.WriteString(fmt.Sprintf(" Duration %s\n", numStyle.Render(fmtDuration(dur)))) - - if !stats.FirstTimestamp.IsZero() { - sb.WriteString(fmt.Sprintf(" Period %s\n", - labelStyle.Render(stats.FirstTimestamp.Format("15:04")+" → "+stats.LastTimestamp.Format("15:04")))) + totalInput := stats.TotalInputTokens + stats.TotalCacheReadTokens + stats.TotalCacheCreationTokens + cost := session.EstimateCost(stats.ModelTokens) + cacheRatio := float64(0) + if totalInput > 0 { + cacheRatio = float64(stats.TotalCacheReadTokens) * 100 / float64(totalInput) } - sb.WriteString(fmt.Sprintf(" Messages %s", - numStyle.Render(fmt.Sprintf("%d", stats.MessageCount)))) - sb.WriteString(labelStyle.Render(fmt.Sprintf(" (%d user, %d assistant)", - stats.UserMsgCount, stats.AsstMsgCount))) - sb.WriteString("\n") - - if dur > 0 { - rate := float64(stats.MessageCount) / dur.Minutes() - rateLine := fmt.Sprintf("%.1f msg/min", rate) - // Inline rate sparkline - if len(stats.MsgTimestamps) > 2 { - rateSparkW := min(width-24, 20) - if rateSparkW > 5 { - rateBuckets := timelineBuckets(stats.MsgTimestamps, stats.FirstTimestamp, stats.LastTimestamp, rateSparkW) - rateLine += " " + sparkline(rateBuckets, rateSparkW) - } - } - sb.WriteString(fmt.Sprintf(" Rate %s\n", labelStyle.Render(rateLine))) - } + sb.WriteString(titleStyle.Render(sectionTitle(iconFolderOpen, "SESSION OVERVIEW")) + "\n") + sb.WriteString(ruler + "\n") + sb.WriteString(fmt.Sprintf(" %s %s msgs %s %s tokens\n", + statInputStyle.Render(roleChip("user"))+labelStyle.Render(fmt.Sprintf(" %d", stats.UserMsgCount)), + statOutputStyle.Render(fmt.Sprintf("%d", stats.MessageCount)), + numStyle.Render(fmtDuration(dur)), + statAccentStyle.Render(fmtNum(stats.TotalOutputTokens)))) + sb.WriteString(fmt.Sprintf(" %s %s msgs %s %s cost\n", + assistantLabelStyle.Render(roleChip("assistant"))+labelStyle.Render(fmt.Sprintf(" %d", stats.AsstMsgCount)), + labelStyle.Render(fmt.Sprintf("%d asst", stats.AsstMsgCount)), + labelStyle.Render(stats.FirstTimestamp.Format("15:04")+" → "+stats.LastTimestamp.Format("15:04")), + statCostStyle.Render(fmtCost(cost)))) if stats.CompactionCount > 0 { - warnStyle := errorStyle - sb.WriteString(fmt.Sprintf(" Compacted %s\n", - warnStyle.Render(fmt.Sprintf("%d×", stats.CompactionCount)))) + sb.WriteString(fmt.Sprintf(" %s compacted %d×\n", errorStyle.Render(iconBadgeStuck), stats.CompactionCount)) } - if len(stats.TurnsPerRequest) > 0 { - avg, maxT := turnsStats(stats.TurnsPerRequest) - sb.WriteString(fmt.Sprintf(" Turns/Req %s", - numStyle.Render(fmt.Sprintf("%.1f avg", avg)))) - sb.WriteString(labelStyle.Render(fmt.Sprintf(" (max %d, %d reqs)", maxT, len(stats.TurnsPerRequest)))) - sb.WriteString("\n") - // Sparkline of turns per request - if len(stats.TurnsPerRequest) > 2 { - sparkW := min(width-12, 40) - if sparkW > 5 { - spark := sparkline(stats.TurnsPerRequest, sparkW) - sb.WriteString(fmt.Sprintf(" Per Req %s\n", - labelStyle.Render(spark))) - } - } + if stats.ModelSwitches > 0 || stats.ToolCounts["Agent"] > 0 { + sb.WriteString(fmt.Sprintf(" %s model switches %d %s agents %d\n", + busyBadge.Render(iconBadgeBusy), stats.ModelSwitches, + agentBadgeStyle.Render(iconAgent), stats.ToolCounts["Agent"])) } - // Activity timeline sparkline (message density over session duration) + sb.WriteString("\n") + + sb.WriteString(titleStyle.Render(sectionTitle(iconBadgeMon, "ACTIVITY")) + "\n") + sb.WriteString(ruler + "\n") if len(stats.MsgTimestamps) > 2 && dur > 0 { - sparkW := min(width-12, 40) - if sparkW > 5 { - buckets := timelineBuckets(stats.MsgTimestamps, stats.FirstTimestamp, stats.LastTimestamp, sparkW) - spark := sparkline(buckets, sparkW) - userStyle := statInputStyle - sb.WriteString(fmt.Sprintf(" Activity %s\n", userStyle.Render(spark))) - // Error timeline (same time scale, red) - if len(stats.ErrorTimestamps) > 0 { - errBuckets := timelineBuckets(stats.ErrorTimestamps, stats.FirstTimestamp, stats.LastTimestamp, sparkW) - if hasNonZero(errBuckets) { - errSpark := sparkline(errBuckets, sparkW) - errStyle := errorStyle - sb.WriteString(fmt.Sprintf(" Errors %s\n", errStyle.Render(errSpark))) - } + sparkW := min(width-18, 42) + if sparkW > 8 { + msgBuckets := timelineBuckets(stats.MsgTimestamps, stats.FirstTimestamp, stats.LastTimestamp, sparkW) + errBuckets := timelineBuckets(stats.ErrorTimestamps, stats.FirstTimestamp, stats.LastTimestamp, sparkW) + sb.WriteString(fmt.Sprintf(" Msgs %s\n", statInputStyle.Render(sparkline(msgBuckets, sparkW)))) + if hasNonZero(errBuckets) { + sb.WriteString(fmt.Sprintf(" Errors %s\n", errorStyle.Render(sparkline(errBuckets, sparkW)))) } - // Time axis labels - sb.WriteString(fmt.Sprintf(" %s%s%s\n", + rate := float64(stats.MessageCount) / max(dur.Minutes(), 1) + sb.WriteString(fmt.Sprintf(" Rate %s %s%s%s\n", + labelStyle.Render(fmt.Sprintf("%.1f msg/min", rate)), labelStyle.Render(stats.FirstTimestamp.Format("15:04")), labelStyle.Render(strings.Repeat(" ", max(sparkW-10, 0))), labelStyle.Render(stats.LastTimestamp.Format("15:04")))) } } - sb.WriteString("\n") - - // ── TOKENS ── - sb.WriteString(titleStyle.Render("TOKENS") + "\n") - sb.WriteString(ruler + "\n") - - totalInput := stats.TotalInputTokens + stats.TotalCacheReadTokens + stats.TotalCacheCreationTokens - cacheRatio := float64(0) - if totalInput > 0 { - cacheRatio = float64(stats.TotalCacheReadTokens) * 100 / float64(totalInput) - } - - inputStyle := statInputStyle - outputStyle := statOutputStyle - - sb.WriteString(fmt.Sprintf(" Input %s", inputStyle.Render(fmtNum(totalInput)))) - if cacheRatio > 0 { - sb.WriteString(labelStyle.Render(fmt.Sprintf(" (cache hit %.0f%%)", cacheRatio))) + if len(stats.TurnsPerRequest) > 0 { + avg, maxT := turnsStats(stats.TurnsPerRequest) + sparkW := min(width-18, 30) + sb.WriteString(fmt.Sprintf(" Turns %s max %d\n", numStyle.Render(fmt.Sprintf("%.1f avg", avg)), maxT)) + if sparkW > 8 { + sb.WriteString(fmt.Sprintf(" Flow %s\n", dimStyle.Render(sparkline(stats.TurnsPerRequest, sparkW)))) + } } sb.WriteString("\n") - sb.WriteString(fmt.Sprintf(" Output %s\n", outputStyle.Render(fmtNum(stats.TotalOutputTokens)))) - sb.WriteString(fmt.Sprintf(" Cache Read %s\n", labelStyle.Render(fmtNum(stats.TotalCacheReadTokens)))) - sb.WriteString(fmt.Sprintf(" Cache Write %s\n", labelStyle.Render(fmtNum(stats.TotalCacheCreationTokens)))) - // Cost estimate - cost := session.EstimateCost(stats.ModelTokens) - if cost > 0 { - costStyle := statCostStyle - sb.WriteString(fmt.Sprintf(" Cost %s\n", costStyle.Render(fmtCost(cost)))) - } - - // Sparkline for output tokens + sb.WriteString(titleStyle.Render(sectionTitle(iconTask, "TOKENS")) + "\n") + sb.WriteString(ruler + "\n") + inputBarW := min(width-22, 34) + if inputBarW < 8 { + inputBarW = 8 + } + maxToken := int(max(max(totalInput, stats.TotalOutputTokens), 1)) + sb.WriteString(fmt.Sprintf(" In %s %s\n", + statInputStyle.Render(histogramBar(int(totalInput), maxToken, inputBarW)), + statInputStyle.Render(fmtNum(totalInput)))) + sb.WriteString(fmt.Sprintf(" Out %s %s\n", + statOutputStyle.Render(histogramBar(int(stats.TotalOutputTokens), maxToken, inputBarW)), + statOutputStyle.Render(fmtNum(stats.TotalOutputTokens)))) + sb.WriteString(fmt.Sprintf(" Cache %s hit %.0f%% write %s\n", + labelStyle.Render(fmtNum(stats.TotalCacheReadTokens)), cacheRatio, + labelStyle.Render(fmtNum(stats.TotalCacheCreationTokens)))) if len(stats.OutputTokenSeries) > 1 { - sparkW := min(width-12, 60) - if sparkW > 5 { - spark := sparkline(stats.OutputTokenSeries, sparkW) - sb.WriteString(fmt.Sprintf(" Output %s\n", - outputStyle.Render(spark))) - } + sb.WriteString(fmt.Sprintf(" Trend %s\n", statOutputStyle.Render(sparkline(stats.OutputTokenSeries, min(width-18, 40))))) } - - // Tokens per turn if stats.AsstMsgCount > 0 { - tokPerTurn := stats.TotalOutputTokens / int64(stats.AsstMsgCount) - sb.WriteString(fmt.Sprintf(" Out/Turn %s\n", labelStyle.Render(fmtNum(tokPerTurn)))) + sb.WriteString(fmt.Sprintf(" AvgOut %s / turn\n", labelStyle.Render(fmtNum(stats.TotalOutputTokens/int64(stats.AsstMsgCount))))) } sb.WriteString("\n") - // ── EFFICIENCY ── - hasEfficiency := stats.ModelSwitches > 0 || stats.AvgMsgGap > 0 || stats.ToolCounts["Agent"] > 0 - if hasEfficiency { - sb.WriteString(titleStyle.Render("EFFICIENCY") + "\n") - sb.WriteString(ruler + "\n") - if stats.ModelSwitches > 0 { - sb.WriteString(fmt.Sprintf(" Model Switches %s\n", - numStyle.Render(fmt.Sprintf("%d", stats.ModelSwitches)))) - } - if agentCount := stats.ToolCounts["Agent"]; agentCount > 0 { - sb.WriteString(fmt.Sprintf(" Agent Spawns %s\n", - numStyle.Render(fmt.Sprintf("%d", agentCount)))) - } - if stats.AvgMsgGap > 0 { - sb.WriteString(fmt.Sprintf(" Avg Msg Gap %s", - labelStyle.Render(fmtDuration(stats.AvgMsgGap)))) - if stats.MaxMsgGap > 0 { - sb.WriteString(labelStyle.Render(fmt.Sprintf(" (max %s)", fmtDuration(stats.MaxMsgGap)))) - } - sb.WriteString("\n") - } - sb.WriteString("\n") - } - - // ── TOOLS ── if len(stats.ToolCounts) > 0 { totalCalls := 0 for _, c := range stats.ToolCounts { totalCalls += c } - header := fmt.Sprintf("TOOLS (%d calls)", totalCalls) + header := fmt.Sprintf(sectionTitle(iconTask, "TOOLS")+" %s", labelStyle.Render(fmt.Sprintf("(%d calls)", totalCalls))) if stats.ToolErrorCount > 0 { - errRate := float64(stats.ToolErrorCount) * 100 / float64(max(stats.ToolResultCount, 1)) - eStyle := errorStyle - header += eStyle.Render(fmt.Sprintf(" %d errors (%.1f%%)", stats.ToolErrorCount, errRate)) + header += " " + errorStyle.Render(fmt.Sprintf("%d err", stats.ToolErrorCount)) } sb.WriteString(titleStyle.Render(header) + "\n") sb.WriteString(ruler + "\n") builtinCounts := make(map[string]int) + builtinErrors := make(map[string]int) for k, v := range stats.ToolCounts { if len(k) <= 5 || k[:5] != "mcp__" { builtinCounts[k] = v } } - builtinErrors := make(map[string]int) for k, v := range stats.ToolErrors { if len(k) <= 5 || k[:5] != "mcp__" { builtinErrors[k] = v } } - renderToolBarWithErrors(&sb, builtinCounts, builtinErrors, width, 10) + renderToolBarWithErrors(&sb, builtinCounts, builtinErrors, width, 8) sb.WriteString("\n") } - // ── MCP TOOLS ── if len(stats.MCPToolCounts) > 0 { totalMCP := 0 for _, c := range stats.MCPToolCounts { totalMCP += c } mcpErrors := make(map[string]int) - totalMCPErrors := 0 for k, v := range stats.ToolErrors { if len(k) > 5 && k[:5] == "mcp__" { mcpErrors[k] = v - totalMCPErrors += v } } - header := fmt.Sprintf("MCP TOOLS (%d calls)", totalMCP) - if totalMCPErrors > 0 { - errRate := float64(totalMCPErrors) * 100 / float64(max(totalMCP, 1)) - eStyle := errorStyle - header += eStyle.Render(fmt.Sprintf(" %d errors (%.1f%%)", totalMCPErrors, errRate)) - } - sb.WriteString(titleStyle.Render(header) + "\n") - sb.WriteString(ruler + "\n") - renderToolBarWithErrors(&sb, stats.MCPToolCounts, mcpErrors, width, 10) - sb.WriteString("\n") - } - - // ── TOOL TIMELINES ── - if len(stats.ToolCallTimestamps) > 0 && dur > 0 { - renderToolTimelines(&sb, stats.ToolCallTimestamps, stats.ToolErrorTimestamps, stats.ToolCounts, stats.FirstTimestamp, stats.LastTimestamp, width, 10) - } - - // ── CODE ── - if stats.WriteCount > 0 || stats.EditCount > 0 { - sb.WriteString(titleStyle.Render("CODE") + "\n") - sb.WriteString(ruler + "\n") - sb.WriteString(fmt.Sprintf(" Write %s Edit %s Files %s\n", - numStyle.Render(fmt.Sprintf("%d", stats.WriteCount)), - numStyle.Render(fmt.Sprintf("%d", stats.EditCount)), - numStyle.Render(fmt.Sprintf("%d", len(stats.FilesTouched))), - )) - sb.WriteString("\n") - } - - // ── COMMANDS ── - if len(stats.CommandCounts) > 0 { - totalCmds := 0 - for _, c := range stats.CommandCounts { - totalCmds += c - } - sb.WriteString(titleStyle.Render(fmt.Sprintf("COMMANDS (%d)", totalCmds)) + "\n") - sb.WriteString(ruler + "\n") - renderToolBarWithErrors(&sb, stats.CommandCounts, stats.CommandErrors, width, 10) - sb.WriteString("\n") - } - - // ── SKILLS ── - if len(stats.SkillCounts) > 0 { - totalSkills := 0 - for _, c := range stats.SkillCounts { - totalSkills += c - } - sb.WriteString(titleStyle.Render(fmt.Sprintf("SKILLS (%d)", totalSkills)) + "\n") + sb.WriteString(titleStyle.Render(fmt.Sprintf(sectionTitle(iconAgent, "MCP")+" %s", labelStyle.Render(fmt.Sprintf("(%d calls)", totalMCP)))) + "\n") sb.WriteString(ruler + "\n") - renderToolBarWithErrors(&sb, stats.SkillCounts, stats.SkillErrors, width, 10) + renderToolBarWithErrors(&sb, stats.MCPToolCounts, mcpErrors, width, 6) sb.WriteString("\n") } - // ── HOOKS ── - if len(stats.HookCounts) > 0 { - totalHooks := 0 - for _, c := range stats.HookCounts { - totalHooks += c - } - sb.WriteString(titleStyle.Render(fmt.Sprintf("HOOKS (%d)", totalHooks)) + "\n") + if stats.WriteCount > 0 || stats.EditCount > 0 || len(stats.Models) > 0 { + sb.WriteString(titleStyle.Render(sectionTitle(iconRoleCompact, "CODE & MODELS")) + "\n") sb.WriteString(ruler + "\n") - renderToolBarN(&sb, stats.HookCounts, width, 10) - sb.WriteString("\n") - } - - // ── MODELS ── - if len(stats.Models) > 0 { - sb.WriteString(titleStyle.Render("MODELS") + "\n") - sb.WriteString(ruler + "\n") - for name, count := range stats.Models { - // Shorten model name - short := shortenModel(name) - sb.WriteString(fmt.Sprintf(" %-20s %s\n", short, - numStyle.Render(fmt.Sprintf("%d", count)))) + sb.WriteString(fmt.Sprintf(" %s %d %s %d %s %d\n", + taskBadgeStyle.Render(iconTask), stats.WriteCount, + busyBadge.Render(iconActive), stats.EditCount, + dimStyle.Render(iconFolder), len(stats.FilesTouched))) + if len(stats.Models) > 0 { + shortModels := make(map[string]int, len(stats.Models)) + for name, count := range stats.Models { + shortModels[shortenModel(name)] += count + } + renderToolBarN(&sb, shortModels, width, 6) } sb.WriteString("\n") } - // ── ERRORS ── if stats.ToolErrorCount > 0 { renderErrorBreakdown(&sb, stats.ToolErrors, stats.ToolCounts, stats.SkillErrors, stats.SkillCounts, stats.CommandErrors, stats.CommandCounts, stats.ToolErrorCount, width, ruler, titleStyle) } @@ -307,41 +180,7 @@ func renderGlobalStats(stats session.GlobalStats, width int) string { titleStyle := statTitleStyle numStyle := statNumStyle labelStyle := dimStyle - ruler := dimStyle.Render(strings.Repeat("─", min(width, 40))) - - // ── OVERVIEW ── - sb.WriteString(titleStyle.Render("OVERVIEW") + "\n") - sb.WriteString(ruler + "\n") - sb.WriteString(fmt.Sprintf(" Sessions %s\n", numStyle.Render(fmt.Sprintf("%d", stats.SessionCount)))) - sb.WriteString(fmt.Sprintf(" Messages %s", numStyle.Render(fmt.Sprintf("%d", stats.TotalMessages)))) - sb.WriteString(labelStyle.Render(fmt.Sprintf(" (%d user, %d assistant)", stats.TotalUserMsgs, stats.TotalAsstMsgs))) - sb.WriteString("\n") - sb.WriteString(fmt.Sprintf(" Total Time %s\n", numStyle.Render(fmtDuration(stats.TotalDuration)))) - sb.WriteString(fmt.Sprintf(" Avg Duration %s\n", numStyle.Render(fmtDuration(stats.AvgDuration)))) - if stats.SessionCount > 0 { - avgMsgs := stats.TotalMessages / stats.SessionCount - sb.WriteString(fmt.Sprintf(" Avg Msgs/Sess %s\n", labelStyle.Render(fmt.Sprintf("%d", avgMsgs)))) - } - if stats.TotalCompactions > 0 { - warnStyle := errorStyle - sb.WriteString(fmt.Sprintf(" Compactions %s", - warnStyle.Render(fmt.Sprintf("%d×", stats.TotalCompactions)))) - sb.WriteString(labelStyle.Render(fmt.Sprintf(" (%d/%d sessions)", - stats.SessionsWithCompaction, stats.SessionCount))) - sb.WriteString("\n") - } - if len(stats.AllTurnsPerRequest) > 0 { - avg, maxT := turnsStats(stats.AllTurnsPerRequest) - sb.WriteString(fmt.Sprintf(" Turns/Req %s", - numStyle.Render(fmt.Sprintf("%.1f avg", avg)))) - sb.WriteString(labelStyle.Render(fmt.Sprintf(" (max %d, %d reqs)", maxT, len(stats.AllTurnsPerRequest)))) - sb.WriteString("\n") - } - sb.WriteString("\n") - - // ── TOKENS ── - sb.WriteString(titleStyle.Render("TOKENS") + "\n") - sb.WriteString(ruler + "\n") + ruler := dimStyle.Render(strings.Repeat("─", min(width, 52))) totalInput := stats.TotalInputTokens + stats.TotalCacheReadTokens + stats.TotalCacheCreationTokens cacheRatio := float64(0) @@ -349,266 +188,197 @@ func renderGlobalStats(stats session.GlobalStats, width int) string { cacheRatio = float64(stats.TotalCacheReadTokens) * 100 / float64(totalInput) } - inputStyle := statInputStyle - outputStyle := statOutputStyle - - sb.WriteString(fmt.Sprintf(" Input %s", inputStyle.Render(fmtNum(totalInput)))) - if cacheRatio > 0 { - sb.WriteString(labelStyle.Render(fmt.Sprintf(" (cache hit %.0f%%)", cacheRatio))) + sb.WriteString(titleStyle.Render(sectionTitle(iconFolderOpen, "GLOBAL OVERVIEW")) + "\n") + sb.WriteString(ruler + "\n") + sb.WriteString(fmt.Sprintf(" %s %s sessions %s duration %s cost\n", + liveBadge.Render(iconBadgeLive)+labelStyle.Render(fmt.Sprintf(" %d", stats.SessionCount)), + numStyle.Render(fmt.Sprintf("%d", stats.TotalMessages)), + numStyle.Render(fmtDuration(stats.TotalDuration)), + statCostStyle.Render(fmtCost(stats.TotalCostUSD)))) + sb.WriteString(fmt.Sprintf(" %s %d user %s %d asst avg %s msgs/sess\n", + userLabelStyle.Render(roleChip("user")), stats.TotalUserMsgs, + assistantLabelStyle.Render(roleChip("assistant")), stats.TotalAsstMsgs, + labelStyle.Render(fmt.Sprintf("%d", max(stats.TotalMessages/max(stats.SessionCount, 1), 0))))) + if stats.TotalCompactions > 0 { + sb.WriteString(fmt.Sprintf(" %s compacted %d× across %d sessions\n", + errorStyle.Render(iconBadgeStuck), stats.TotalCompactions, stats.SessionsWithCompaction)) } - sb.WriteString("\n") - sb.WriteString(fmt.Sprintf(" Output %s\n", outputStyle.Render(fmtNum(stats.TotalOutputTokens)))) - sb.WriteString(fmt.Sprintf(" Cache Read %s\n", labelStyle.Render(fmtNum(stats.TotalCacheReadTokens)))) - sb.WriteString(fmt.Sprintf(" Cache Write %s\n", labelStyle.Render(fmtNum(stats.TotalCacheCreationTokens)))) - if stats.TotalCostUSD > 0 { - costStyle := statCostStyle - sb.WriteString(fmt.Sprintf(" Cost %s", costStyle.Render(fmtCost(stats.TotalCostUSD)))) - if stats.SessionCount > 0 { - avgCost := stats.TotalCostUSD / float64(stats.SessionCount) - sb.WriteString(labelStyle.Render(fmt.Sprintf(" (avg %s/sess)", fmtCost(avgCost)))) - } - sb.WriteString("\n") + if stats.TotalModelSwitches > 0 || stats.ToolCounts["Agent"] > 0 { + sb.WriteString(fmt.Sprintf(" %s switches %d %s agents %d\n", + busyBadge.Render(iconBadgeBusy), stats.TotalModelSwitches, + agentBadgeStyle.Render(iconAgent), stats.ToolCounts["Agent"])) } + sb.WriteString("\n") + + sb.WriteString(titleStyle.Render(sectionTitle(iconTask, "TOKEN MIX")) + "\n") + sb.WriteString(ruler + "\n") + barW := min(width-22, 36) + if barW < 8 { + barW = 8 + } + maxToken := int(max(max(totalInput, stats.TotalOutputTokens), 1)) + sb.WriteString(fmt.Sprintf(" In %s %s\n", + statInputStyle.Render(histogramBar(int(totalInput), maxToken, barW)), + statInputStyle.Render(fmtNum(totalInput)))) + sb.WriteString(fmt.Sprintf(" Out %s %s\n", + statOutputStyle.Render(histogramBar(int(stats.TotalOutputTokens), maxToken, barW)), + statOutputStyle.Render(fmtNum(stats.TotalOutputTokens)))) + sb.WriteString(fmt.Sprintf(" Cache %s hit %.0f%% write %s\n", + labelStyle.Render(fmtNum(stats.TotalCacheReadTokens)), cacheRatio, + labelStyle.Render(fmtNum(stats.TotalCacheCreationTokens)))) if stats.SessionCount > 0 { - avgOut := stats.TotalOutputTokens / int64(stats.SessionCount) - sb.WriteString(fmt.Sprintf(" Avg Out/Sess %s\n", labelStyle.Render(fmtNum(avgOut)))) + sb.WriteString(fmt.Sprintf(" AvgOut %s / sess\n", labelStyle.Render(fmtNum(stats.TotalOutputTokens/int64(stats.SessionCount))))) } sb.WriteString("\n") - // ── EFFICIENCY ── - hasEff := stats.TotalModelSwitches > 0 || stats.ToolCounts["Agent"] > 0 - if hasEff { - sb.WriteString(titleStyle.Render("EFFICIENCY") + "\n") - sb.WriteString(ruler + "\n") - if stats.TotalModelSwitches > 0 { - sb.WriteString(fmt.Sprintf(" Model Switches %s", - numStyle.Render(fmt.Sprintf("%d", stats.TotalModelSwitches)))) - sb.WriteString(labelStyle.Render(fmt.Sprintf(" (%d sessions)", stats.SessionsWithSwitches))) - sb.WriteString("\n") - } - if agentCount := stats.ToolCounts["Agent"]; agentCount > 0 { - sb.WriteString(fmt.Sprintf(" Agent Spawns %s\n", - numStyle.Render(fmt.Sprintf("%d", agentCount)))) + if len(stats.SessionStarts) > 1 { + sparkW := min(width-14, 42) + if sparkW > 8 { + buckets, firstDay, lastDay := dailyBuckets(stats.SessionStarts, sparkW) + if len(buckets) > 1 { + sb.WriteString(titleStyle.Render(sectionTitle(iconBadgeMon, "DAILY ACTIVITY")) + "\n") + sb.WriteString(ruler + "\n") + sb.WriteString(fmt.Sprintf(" Sess %s\n", statInputStyle.Render(sparkline(buckets, sparkW)))) + if len(stats.AllMsgTimestamps) > 0 { + msgBuckets, _, _ := dailyBuckets(stats.AllMsgTimestamps, sparkW) + if hasNonZero(msgBuckets) { + sb.WriteString(fmt.Sprintf(" Msgs %s\n", statAccentStyle.Render(sparkline(msgBuckets, sparkW)))) + } + } + if len(stats.AllErrorTimestamps) > 0 { + errBuckets, _, _ := dailyBuckets(stats.AllErrorTimestamps, sparkW) + if hasNonZero(errBuckets) { + sb.WriteString(fmt.Sprintf(" Errs %s\n", errorStyle.Render(sparkline(errBuckets, sparkW)))) + } + } + sb.WriteString(fmt.Sprintf(" %s%s%s\n", labelStyle.Render(firstDay), labelStyle.Render(strings.Repeat(" ", max(sparkW-len(firstDay)-len(lastDay), 0))), labelStyle.Render(lastDay))) + sb.WriteString("\n") + } } - sb.WriteString("\n") } - // ── TOOLS ── if len(stats.ToolCounts) > 0 { totalCalls := 0 for _, c := range stats.ToolCounts { totalCalls += c } - header := fmt.Sprintf("TOOLS (%d calls)", totalCalls) + header := fmt.Sprintf(sectionTitle(iconTask, "TOOLS")+" %s", labelStyle.Render(fmt.Sprintf("(%d calls)", totalCalls))) if stats.TotalToolErrors > 0 { - errRate := float64(stats.TotalToolErrors) * 100 / float64(max(stats.TotalToolResults, 1)) - eStyle := errorStyle - header += eStyle.Render(fmt.Sprintf(" %d errors (%.1f%%)", stats.TotalToolErrors, errRate)) + header += " " + errorStyle.Render(fmt.Sprintf("%d err", stats.TotalToolErrors)) } sb.WriteString(titleStyle.Render(header) + "\n") sb.WriteString(ruler + "\n") - // Split: built-in tools vs MCP tools builtinCounts := make(map[string]int) + builtinErrors := make(map[string]int) for k, v := range stats.ToolCounts { if len(k) <= 5 || k[:5] != "mcp__" { builtinCounts[k] = v } } - builtinErrors := make(map[string]int) for k, v := range stats.ToolErrors { if len(k) <= 5 || k[:5] != "mcp__" { builtinErrors[k] = v } } - renderToolBarWithErrors(&sb, builtinCounts, builtinErrors, width, 15) + renderToolBarWithErrors(&sb, builtinCounts, builtinErrors, width, 10) sb.WriteString("\n") } - // ── MCP TOOLS ── if len(stats.MCPToolCounts) > 0 { + mcpErrors := make(map[string]int) totalMCP := 0 + totalMCPErrors := 0 for _, c := range stats.MCPToolCounts { totalMCP += c } - // Build MCP-only errors - mcpErrors := make(map[string]int) - totalMCPErrors := 0 for k, v := range stats.ToolErrors { if len(k) > 5 && k[:5] == "mcp__" { mcpErrors[k] = v totalMCPErrors += v } } - header := fmt.Sprintf("MCP TOOLS (%d calls)", totalMCP) + header := fmt.Sprintf(sectionTitle(iconAgent, "MCP")+" %s", labelStyle.Render(fmt.Sprintf("(%d calls)", totalMCP))) if totalMCPErrors > 0 { - errRate := float64(totalMCPErrors) * 100 / float64(max(totalMCP, 1)) - eStyle := errorStyle - header += eStyle.Render(fmt.Sprintf(" %d errors (%.1f%%)", totalMCPErrors, errRate)) + header += " " + errorStyle.Render(fmt.Sprintf("%d err", totalMCPErrors)) } sb.WriteString(titleStyle.Render(header) + "\n") sb.WriteString(ruler + "\n") - renderToolBarWithErrors(&sb, stats.MCPToolCounts, mcpErrors, width, 15) + renderToolBarWithErrors(&sb, stats.MCPToolCounts, mcpErrors, width, 8) sb.WriteString("\n") } - // ── TOOL TIMELINES (daily) ── - if len(stats.AllToolCallTimestamps) > 0 { - renderToolDailyTimelines(&sb, stats.AllToolCallTimestamps, stats.AllToolErrorTimestamps, stats.ToolCounts, width, 10) - } - - // ── AGENTS ── if len(stats.AgentCounts) > 0 { totalAgents := 0 for _, c := range stats.AgentCounts { totalAgents += c } - sb.WriteString(titleStyle.Render(fmt.Sprintf("AGENTS (%d spawns)", totalAgents)) + "\n") + sb.WriteString(titleStyle.Render(fmt.Sprintf(sectionTitle(iconAgent, "AGENTS")+" %s", labelStyle.Render(fmt.Sprintf("(%d spawns)", totalAgents)))) + "\n") sb.WriteString(ruler + "\n") - renderToolBarN(&sb, stats.AgentCounts, width, 10) + renderToolBarN(&sb, stats.AgentCounts, width, 8) sb.WriteString("\n") } - // ── SKILLS ── if len(stats.SkillCounts) > 0 { totalSkills := 0 for _, c := range stats.SkillCounts { totalSkills += c } - sb.WriteString(titleStyle.Render(fmt.Sprintf("SKILLS (%d)", totalSkills)) + "\n") + sb.WriteString(titleStyle.Render(fmt.Sprintf(sectionTitle(iconRoleCompact, "SKILLS")+" %s", labelStyle.Render(fmt.Sprintf("(%d)", totalSkills)))) + "\n") sb.WriteString(ruler + "\n") - renderToolBarWithErrors(&sb, stats.SkillCounts, stats.SkillErrors, width, 15) + renderToolBarWithErrors(&sb, stats.SkillCounts, stats.SkillErrors, width, 8) sb.WriteString("\n") } - // ── COMMANDS ── if len(stats.CommandCounts) > 0 { totalCmds := 0 for _, c := range stats.CommandCounts { totalCmds += c } - sb.WriteString(titleStyle.Render(fmt.Sprintf("COMMANDS (%d)", totalCmds)) + "\n") + sb.WriteString(titleStyle.Render(fmt.Sprintf(sectionTitle(iconHook, "COMMANDS")+" %s", labelStyle.Render(fmt.Sprintf("(%d)", totalCmds)))) + "\n") sb.WriteString(ruler + "\n") - renderToolBarWithErrors(&sb, stats.CommandCounts, stats.CommandErrors, width, 15) + renderToolBarWithErrors(&sb, stats.CommandCounts, stats.CommandErrors, width, 8) sb.WriteString("\n") } - // ── HOOKS ── if len(stats.HookCounts) > 0 { totalHooks := 0 for _, c := range stats.HookCounts { totalHooks += c } - sb.WriteString(titleStyle.Render(fmt.Sprintf("HOOKS (%d)", totalHooks)) + "\n") + sb.WriteString(titleStyle.Render(fmt.Sprintf(sectionTitle(iconHook, "HOOKS")+" %s", labelStyle.Render(fmt.Sprintf("(%d)", totalHooks)))) + "\n") sb.WriteString(ruler + "\n") - renderToolBarN(&sb, stats.HookCounts, width, 15) + renderToolBarN(&sb, stats.HookCounts, width, 8) sb.WriteString("\n") } - // ── MODELS ── - if len(stats.Models) > 0 { - sb.WriteString(titleStyle.Render("MODELS") + "\n") + if len(stats.Models) > 0 || stats.TotalWrites > 0 || stats.TotalEdits > 0 { + sb.WriteString(titleStyle.Render(sectionTitle(iconRoleCompact, "MODELS & CODE")) + "\n") sb.WriteString(ruler + "\n") - shortModels := make(map[string]int, len(stats.Models)) - for name, count := range stats.Models { - shortModels[shortenModel(name)] += count + if len(stats.Models) > 0 { + shortModels := make(map[string]int, len(stats.Models)) + for name, count := range stats.Models { + shortModels[shortenModel(name)] += count + } + renderToolBarN(&sb, shortModels, width, 6) } - renderToolBarN(&sb, shortModels, width, 10) + sb.WriteString(fmt.Sprintf(" %s %d %s %d %s %d\n", + taskBadgeStyle.Render(iconTask), stats.TotalWrites, + busyBadge.Render(iconActive), stats.TotalEdits, + dimStyle.Render(iconFolder), stats.TotalFiles)) sb.WriteString("\n") } - // ── CODE ── - if stats.TotalWrites > 0 || stats.TotalEdits > 0 { - sb.WriteString(titleStyle.Render("CODE") + "\n") + if len(stats.ProjectStats) > 0 { + sb.WriteString(titleStyle.Render(sectionTitle(iconFolder, "TOP PROJECTS")) + "\n") sb.WriteString(ruler + "\n") - sb.WriteString(fmt.Sprintf(" Write %s Edit %s Files %s\n", - numStyle.Render(fmt.Sprintf("%d", stats.TotalWrites)), - numStyle.Render(fmt.Sprintf("%d", stats.TotalEdits)), - numStyle.Render(fmt.Sprintf("%d", stats.TotalFiles)), - )) + renderProjectStats(&sb, stats.ProjectStats, width) sb.WriteString("\n") } - // ── ERRORS ── if stats.TotalToolErrors > 0 { renderErrorBreakdown(&sb, stats.ToolErrors, stats.ToolCounts, stats.SkillErrors, stats.SkillCounts, stats.CommandErrors, stats.CommandCounts, stats.TotalToolErrors, width, ruler, titleStyle) } - // ── SESSION DURATIONS (sparkline) ── - if len(stats.SessionDurations) > 1 { - sb.WriteString(titleStyle.Render("SESSION DURATIONS") + "\n") - sb.WriteString(ruler + "\n") - durVals := make([]int, len(stats.SessionDurations)) - for i, d := range stats.SessionDurations { - durVals[i] = int(d.Seconds()) - } - sparkW := min(width-4, 60) - if sparkW > 5 { - spark := sparkline(durVals, sparkW) - sb.WriteString(fmt.Sprintf(" %s\n", outputStyle.Render(spark))) - } - sb.WriteString("\n") - } - - // ── SESSION TOKENS (sparkline) ── - if len(stats.SessionTokens) > 1 { - sb.WriteString(titleStyle.Render("SESSION OUTPUT TOKENS") + "\n") - sb.WriteString(ruler + "\n") - tokVals := make([]int, len(stats.SessionTokens)) - for i, t := range stats.SessionTokens { - tokVals[i] = int(t) - } - sparkW := min(width-4, 60) - if sparkW > 5 { - spark := sparkline(tokVals, sparkW) - sb.WriteString(fmt.Sprintf(" %s\n", outputStyle.Render(spark))) - } - sb.WriteString("\n") - } - - // ── DAILY ACTIVITY ── - if len(stats.SessionStarts) > 1 { - sparkW := min(width-4, 60) - if sparkW > 5 { - buckets, firstDay, lastDay := dailyBuckets(stats.SessionStarts, sparkW) - if len(buckets) > 1 { - sb.WriteString(titleStyle.Render("DAILY ACTIVITY") + "\n") - sb.WriteString(ruler + "\n") - spark := sparkline(buckets, sparkW) - userStyle := statInputStyle - sb.WriteString(fmt.Sprintf(" Sessions %s\n", userStyle.Render(spark))) - // Daily message timeline - if len(stats.AllMsgTimestamps) > 0 { - msgBuckets, _, _ := dailyBuckets(stats.AllMsgTimestamps, sparkW) - for len(msgBuckets) < len(buckets) { - msgBuckets = append(msgBuckets, 0) - } - if hasNonZero(msgBuckets) { - msgSpark := sparkline(msgBuckets, sparkW) - msgStyle := statAccentStyle - sb.WriteString(fmt.Sprintf(" Messages %s\n", msgStyle.Render(msgSpark))) - } - } - // Daily error timeline (same date scale, red) - if len(stats.AllErrorTimestamps) > 0 { - errBuckets, _, _ := dailyBuckets(stats.AllErrorTimestamps, sparkW) - // Pad to same length as session buckets - for len(errBuckets) < len(buckets) { - errBuckets = append(errBuckets, 0) - } - if hasNonZero(errBuckets) { - errSpark := sparkline(errBuckets, sparkW) - errStyle := errorStyle - sb.WriteString(fmt.Sprintf(" Errors %s\n", errStyle.Render(errSpark))) - } - } - sb.WriteString(fmt.Sprintf(" %s%s%s\n", - labelStyle.Render(firstDay), - labelStyle.Render(strings.Repeat(" ", max(sparkW-len(firstDay)-len(lastDay), 0))), - labelStyle.Render(lastDay))) - } - } - } - return sb.String() } diff --git a/internal/tui/stats_detail.go b/internal/tui/stats_detail.go index 624cd79..c9f0b2f 100644 --- a/internal/tui/stats_detail.go +++ b/internal/tui/stats_detail.go @@ -128,7 +128,7 @@ func renderToolDetail(stats session.GlobalStats, width int, mcpOnly bool) string label := "TOOLS" if mcpOnly { - label = "MCP TOOLS" + label = "MCP" } return renderCategoryDetail(label, counts, errors, callTS, errTS, width) } @@ -153,14 +153,13 @@ func renderCategoryDetail(label string, counts, errors map[string]int, callTS, e var sb strings.Builder - // Build entries with capped error rates var entries []detailEntry totalCalls := 0 totalErrors := 0 for name, count := range counts { e := detailEntry{name: name, count: count} if errors != nil { - e.errors = min(errors[name], count) // cap errors to calls + e.errors = min(errors[name], count) } if count > 0 { e.errRate = float64(e.errors) * 100 / float64(count) @@ -176,20 +175,19 @@ func renderCategoryDetail(label string, counts, errors map[string]int, callTS, e totalCalls += count totalErrors += e.errors } + if len(entries) == 0 { + return dimStyle.Render("(no data)") + } sort.Slice(entries, func(i, j int) bool { return entries[i].count > entries[j].count }) - // Header - header := fmt.Sprintf("%s DETAIL (%d total", label, totalCalls) + header := fmt.Sprintf("%s DETAIL %s", label, labelStyle.Render(fmt.Sprintf("%d calls", totalCalls))) if totalErrors > 0 { - rate := float64(totalErrors) * 100 / float64(max(totalCalls, 1)) - header += errStyle.Render(fmt.Sprintf(", %d errors %.0f%%", totalErrors, rate)) + header += " " + errStyle.Render(fmt.Sprintf("%d err", totalErrors)) } - header += ")" sb.WriteString(titleStyle.Render(header) + "\n") sb.WriteString(ruler + "\n\n") - // Bar chart of all items shortCounts := make(map[string]int, len(entries)) shortErrors := make(map[string]int, len(entries)) for _, e := range entries { @@ -199,32 +197,31 @@ func renderCategoryDetail(label string, counts, errors map[string]int, callTS, e shortErrors[name] += e.errors } } - renderToolBarWithErrors(&sb, shortCounts, shortErrors, width, 30) + renderToolBarWithErrors(&sb, shortCounts, shortErrors, width, 18) sb.WriteString("\n") - // Top error rates (only if errors exist) if totalErrors > 0 { - sb.WriteString(titleStyle.Render("HIGHEST ERROR RATES") + "\n") + sb.WriteString(titleStyle.Render(sectionTitle(iconBadgeStuck, "ERROR RATES")) + "\n") sb.WriteString(ruler + "\n") errSorted := make([]detailEntry, len(entries)) copy(errSorted, entries) sort.Slice(errSorted, func(i, j int) bool { return errSorted[i].errRate > errSorted[j].errRate }) shown := 0 for _, e := range errSorted { - if e.errors == 0 || shown >= 5 { + if e.errors == 0 || shown >= 6 { break } - name := shortenToolName(e.name) - sb.WriteString(fmt.Sprintf(" %-30s %s %s\n", - truncName(name, 30), + name := truncName(shortenToolName(e.name), 24) + sb.WriteString(fmt.Sprintf(" %s %-24s %s %s\n", + errStyle.Render(iconBadgeStuck), + name, errStyle.Render(fmt.Sprintf("%.0f%%", e.errRate)), - labelStyle.Render(fmt.Sprintf("(%d err / %d calls)", e.errors, e.count)))) + labelStyle.Render(fmt.Sprintf("%d/%d", e.errors, e.count)))) shown++ } sb.WriteString("\n") } - // Week-over-week trends var trending []detailEntry for _, e := range entries { if len(e.callTS) >= 3 && math.Abs(e.weekDelta) > 0.1 { @@ -232,33 +229,29 @@ func renderCategoryDetail(label string, counts, errors map[string]int, callTS, e } } if len(trending) > 0 { - sort.Slice(trending, func(i, j int) bool { - return math.Abs(trending[i].weekDelta) > math.Abs(trending[j].weekDelta) - }) - sb.WriteString(titleStyle.Render("WEEK-OVER-WEEK TRENDS") + "\n") + sort.Slice(trending, func(i, j int) bool { return math.Abs(trending[i].weekDelta) > math.Abs(trending[j].weekDelta) }) + sb.WriteString(titleStyle.Render(sectionTitle(iconTrendUp, "TRENDS")) + "\n") sb.WriteString(ruler + "\n") - shown := 0 - for _, e := range trending { - if shown >= 8 { + for i, e := range trending { + if i >= 8 { break } - name := shortenToolName(e.name) - arrow := "↑" - deltaStyle := accentStyle + name := truncName(shortenToolName(e.name), 24) + icon := iconTrendUp + style := accentStyle if e.weekDelta < 0 { - arrow = "↓" - deltaStyle = labelStyle + icon = iconTrendDown + style = labelStyle } - sb.WriteString(fmt.Sprintf(" %-30s %s %s\n", - truncName(name, 30), - deltaStyle.Render(fmt.Sprintf("%s%.0f%%", arrow, math.Abs(e.weekDelta))), - labelStyle.Render(fmt.Sprintf("(%d calls)", e.count)))) - shown++ + sb.WriteString(fmt.Sprintf(" %s %-24s %s %s\n", + style.Render(icon), + name, + style.Render(fmt.Sprintf("%.0f%%", math.Abs(e.weekDelta))), + labelStyle.Render(fmt.Sprintf("%d calls", e.count)))) } sb.WriteString("\n") } - // Per-item timelines (only for items with timestamps) hasTimelines := false for _, e := range entries { if len(e.callTS) >= 2 { @@ -266,11 +259,9 @@ func renderCategoryDetail(label string, counts, errors map[string]int, callTS, e break } } - if hasTimelines { - sb.WriteString(titleStyle.Render("TIMELINES") + "\n") + sb.WriteString(titleStyle.Render(sectionTitle(iconBadgeMon, "TIMELINES")) + "\n") sb.WriteString(ruler + "\n") - maxNameW := 0 for _, e := range entries { n := len(shortenToolName(e.name)) @@ -282,20 +273,16 @@ func renderCategoryDetail(label string, counts, errors map[string]int, callTS, e if maxNameW > maxLabelW { maxNameW = maxLabelW } - sparkW := width - maxNameW - 20 - if sparkW < 8 { - sparkW = 8 + sparkW := width - maxNameW - 24 + if sparkW < 10 { + sparkW = 10 } - var firstDay, lastDay string shown := 0 for _, e := range entries { - if len(e.callTS) < 2 { + if len(e.callTS) < 2 || shown >= 12 { continue } - if shown >= 15 { - break - } name := shortenToolName(e.name) if len(name) > maxNameW { name = name[:maxNameW-1] + "…" @@ -307,24 +294,18 @@ func renderCategoryDetail(label string, counts, errors map[string]int, callTS, e if firstDay == "" { firstDay, lastDay = fd, ld } - spark := sparkline(buckets, sparkW) - line := fmt.Sprintf(" %-*s %s %d", maxNameW, name, accentStyle.Render(spark), e.count) - + line := fmt.Sprintf(" %-*s %s", maxNameW, name, accentStyle.Render(sparkline(buckets, sparkW))) if len(e.errTS) > 0 { errBuckets, _, _ := dailyBuckets(e.errTS, min(sparkW/3, 10)) if hasNonZero(errBuckets) { - errSpark := sparkline(errBuckets, min(sparkW/3, 10)) - line += " " + errStyle.Render(errSpark) + errStyle.Render(fmt.Sprintf(" %d err", len(e.errTS))) + line += " " + errStyle.Render(sparkline(errBuckets, min(sparkW/3, 10))) } } sb.WriteString(line + "\n") shown++ } if firstDay != "" { - sb.WriteString(fmt.Sprintf(" %-*s%s%s\n", - maxNameW+1, "", - labelStyle.Render(firstDay), - labelStyle.Render(fmt.Sprintf("%*s", max(sparkW-len(firstDay)-len(lastDay), 0), lastDay)))) + sb.WriteString(fmt.Sprintf(" %-*s%s%s\n", maxNameW+1, "", labelStyle.Render(firstDay), labelStyle.Render(fmt.Sprintf("%*s", max(sparkW-len(firstDay)-len(lastDay), 0), lastDay)))) } sb.WriteString("\n") } @@ -397,7 +378,7 @@ func renderErrorDetail(stats session.GlobalStats, width int) string { } if len(stats.AllErrorTimestamps) > 0 { - sb.WriteString(titleStyle.Render("ERROR TIMELINE") + "\n") + sb.WriteString(titleStyle.Render(sectionTitle(iconBadgeStuck, "ERROR TIMELINE")) + "\n") sb.WriteString(ruler + "\n") buckets, firstDay, lastDay := dailyBuckets(stats.AllErrorTimestamps, sparkW) if len(buckets) >= 2 { @@ -410,7 +391,7 @@ func renderErrorDetail(stats session.GlobalStats, width int) string { } // Error bar chart - sb.WriteString(titleStyle.Render("BY SOURCE") + "\n") + sb.WriteString(titleStyle.Render(sectionTitle(iconTask, "BY SOURCE")) + "\n") sb.WriteString(ruler + "\n") errCounts := make(map[string]int, len(items)) @@ -422,7 +403,7 @@ func renderErrorDetail(stats session.GlobalStats, width int) string { // Overall error rate trend if len(stats.AllErrorTimestamps) > 0 && len(stats.AllMsgTimestamps) > 7 { - sb.WriteString(titleStyle.Render("ERROR RATE TREND") + "\n") + sb.WriteString(titleStyle.Render(sectionTitle(iconTrendUp, "ERROR RATE TREND")) + "\n") sb.WriteString(ruler + "\n") rollingRates := rollingErrorRate(stats.AllMsgTimestamps, stats.AllErrorTimestamps, sparkW) if hasNonZero(rollingRates) { diff --git a/internal/tui/stats_helpers.go b/internal/tui/stats_helpers.go index d1f6912..106900a 100644 --- a/internal/tui/stats_helpers.go +++ b/internal/tui/stats_helpers.go @@ -125,6 +125,9 @@ func renderToolBarWithErrors(sb *strings.Builder, counts map[string]int, errors if len(entries) > limit { entries = entries[:limit] } + if maxCount <= 0 { + return + } // Allow label column up to 40% of width, minimum 14 maxLabelW := max(width*2/5, 14) @@ -132,13 +135,14 @@ func renderToolBarWithErrors(sb *strings.Builder, counts map[string]int, errors maxNameW = maxLabelW } countW := len(fmt.Sprintf("%d", maxCount)) - barMaxW := width - maxNameW - countW - 6 // " name ██ N" - if barMaxW < 3 { - barMaxW = 3 + barMaxW := width - maxNameW - countW - 12 // icon + name + bar + count + if barMaxW < 6 { + barMaxW = 6 } barStyle := statAccentStyle errBarStyle := errorStyle + countStyle := statNumStyle for _, e := range entries { name := e.name @@ -150,29 +154,36 @@ func renderToolBarWithErrors(sb *strings.Builder, counts map[string]int, errors barLen = 1 } - var bar string + okLen := barLen + errBarLen := 0 if e.errors > 0 && e.count > 0 { - errBarLen := e.errors * barLen / e.count + errBarLen = e.errors * barLen / e.count if errBarLen < 1 { errBarLen = 1 } if errBarLen > barLen { errBarLen = barLen } - okLen := barLen - errBarLen - if okLen > 0 { - bar = barStyle.Render(strings.Repeat("█", okLen)) - } - bar += errBarStyle.Render(strings.Repeat("█", errBarLen)) - } else { - bar = barStyle.Render(strings.Repeat("█", barLen)) + okLen = barLen - errBarLen + } + + bar := "" + if okLen > 0 { + bar = barStyle.Render(strings.Repeat(iconBarFull, okLen)) + } + if errBarLen > 0 { + bar += errBarStyle.Render(strings.Repeat(iconBarLight, errBarLen)) + } + if fill := barMaxW - barLen; fill > 0 { + bar += dimStyle.Render(strings.Repeat(iconBarEmpty, fill)) } - countLabel := fmt.Sprintf("%*d", countW, e.count) + countLabel := countStyle.Render(fmt.Sprintf("%*d", countW, e.count)) + meta := "" if e.errors > 0 { - countLabel += errBarStyle.Render(fmt.Sprintf(" (%d err)", e.errors)) + meta = errBarStyle.Render(fmt.Sprintf(" %d err", e.errors)) } - sb.WriteString(fmt.Sprintf(" %-*s %s %s\n", maxNameW, name, bar, countLabel)) + sb.WriteString(fmt.Sprintf(" %s %-*s %s %s%s\n", iconTask, maxNameW, name, bar, countLabel, meta)) } } @@ -301,11 +312,7 @@ func renderProjectStats(sb *strings.Builder, projects []session.ProjectStats, wi sb.WriteString(fmt.Sprintf(" %s\n", path)) // Line 2: cost + bar + stats - barLen := int(float64(barW) * ps.CostUSD / maxCost) - if barLen < 1 && ps.CostUSD > 0 { - barLen = 1 - } - bar := strings.Repeat("█", barLen) + bar := histogramBar(int(ps.CostUSD*10000), int(maxCost*10000), barW) sb.WriteString(fmt.Sprintf(" %s %s %s out %s sess %s msgs\n", costStyle.Render(fmt.Sprintf("%7s", fmtCost(ps.CostUSD))), @@ -366,11 +373,7 @@ func renderProjectDetail(stats session.GlobalStats, width int) string { } sb.WriteString(fmt.Sprintf(" %s%s\n", labelStyle.Render(rank), path)) - barLen := int(float64(barW) * ps.CostUSD / maxCost) - if barLen < 1 && ps.CostUSD > 0 { - barLen = 1 - } - bar := strings.Repeat("█", barLen) + bar := histogramBar(int(ps.CostUSD*10000), int(maxCost*10000), barW) ratio := ps.CostUSD * 100 / totalCost sb.WriteString(fmt.Sprintf(" %s %s %s\n", @@ -389,7 +392,6 @@ func renderProjectDetail(stats session.GlobalStats, width int) string { return sb.String() } - func renderProjectPathDetail(stats session.GlobalStats, width int) string { if len(stats.ProjectStats) == 0 { return dimStyle.Render("(no project data)") @@ -436,11 +438,7 @@ func renderProjectPathDetail(stats session.GlobalStats, width int) string { } sb.WriteString(fmt.Sprintf(" %s%s\n", labelStyle.Render(rank), path)) - barLen := int(float64(barW) * ps.CostUSD / maxCost) - if barLen < 1 && ps.CostUSD > 0 { - barLen = 1 - } - bar := strings.Repeat("█", barLen) + bar := histogramBar(int(ps.CostUSD*10000), int(maxCost*10000), barW) ratio := ps.CostUSD * 100 / totalCost sb.WriteString(fmt.Sprintf(" %s %s %s\n", @@ -458,4 +456,3 @@ func renderProjectPathDetail(stats session.GlobalStats, width int) string { return sb.String() } - diff --git a/internal/tui/stats_timeline.go b/internal/tui/stats_timeline.go index 853b339..8051e91 100644 --- a/internal/tui/stats_timeline.go +++ b/internal/tui/stats_timeline.go @@ -5,11 +5,42 @@ import ( "sort" "strings" "time" - ) var sparkChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} +func histogramBar(value, maxVal, width int) string { + if width <= 0 { + return "" + } + if maxVal <= 0 || value <= 0 { + return strings.Repeat(iconBarEmpty, width) + } + filled := value * width / maxVal + if filled < 1 { + filled = 1 + } + if filled > width { + filled = width + } + return strings.Repeat(iconBarFull, filled) + strings.Repeat(iconBarEmpty, width-filled) +} + +func dualSparkline(a, b []int, maxWidth int) string { + left := sparkline(a, maxWidth) + right := sparkline(b, maxWidth) + if left == "" { + left = strings.Repeat(string(sparkChars[0]), min(len(b), maxWidth)) + } + if right == "" { + right = strings.Repeat(string(sparkChars[0]), min(len(a), maxWidth)) + } + if len(left) == 0 { + return "" + } + return left + " · " + right +} + func sparkline(values []int, maxWidth int) string { if len(values) == 0 { return "" @@ -53,6 +84,7 @@ func hasNonZero(vals []int) bool { } return false } + // timelineBuckets distributes timestamps into N buckets over a time range. func timelineBuckets(timestamps []time.Time, start, end time.Time, n int) []int { dur := end.Sub(start) @@ -73,6 +105,7 @@ func timelineBuckets(timestamps []time.Time, start, end time.Time, n int) []int } return buckets } + // dailyBuckets distributes timestamps into per-day counts, returning buckets and day labels. func dailyBuckets(timestamps []time.Time, n int) ([]int, string, string) { if len(timestamps) == 0 { @@ -269,4 +302,3 @@ func renderToolDailyTimelines(sb *strings.Builder, toolCallTS, toolErrTS map[str } sb.WriteString("\n") } - diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 3240069..360da49 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -2,6 +2,68 @@ package tui import "github.com/charmbracelet/lipgloss" +const ( + // Line-art icon palette. Prefer outline Nerd Font / Font Awesome + // glyphs over emoji so the TUI stays monochrome and abstract. + iconIdle = "" // circle-o + iconActive = "" // dot-circle-o + iconFocused = "" // circle + iconProgress = "" // spinner + iconDone = "" // check-circle-o + iconStopped = "" // stop-circle-o + iconWaiting = "" // hourglass-o + iconTask = "" // list-ul + iconFolder = "" // folder-o + iconFolderOpen = "" // folder-open-o + iconAgent = "" // code + iconImage = "" // file-image-o + iconHook = "" // bolt + iconBlockMarker = "" // dot-circle-o + iconFoldClosed = "▸" + iconFoldOpen = "▾" + iconSelect = iconDone + + iconRoleUser = "" + iconRoleAssistant = "" + iconRoleCompact = "" + iconBadgeLive = "" + iconBadgeBusy = "" + iconBadgeBg = "" + iconBadgeMon = "" + iconBadgeHere = "" + iconBadgeDone = "" + iconBadgeWait = "" + iconBadgeStuck = "" + iconBadgeRemote = "" + iconTrendUp = "▴" + iconTrendDown = "▾" + iconBarFull = "█" + iconBarLight = "▓" + iconBarMid = "▒" + iconBarEmpty = "░" +) + +func roleChip(role string) string { + switch role { + case "user": + return iconRoleUser + " usr" + case "assistant": + return iconRoleAssistant + " ast" + case "compact": + return iconRoleCompact + " ctx" + default: + return role + } +} + +func sectionTitle(icon, text string) string { + return icon + " " + text +} + +func badgeLabel(icon, text string) string { + return "[" + icon + " " + text + "]" +} + var ( colorPrimary = lipgloss.Color("#7C3AED") colorTitleBg = lipgloss.Color("#1E293B") // subtle dark bg for title bar @@ -37,6 +99,7 @@ var ( forkBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B")).Bold(true) hereBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#F472B6")).Bold(true) bgBadgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#22D3EE")).Bold(true) + monBadgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#A78BFA")).Bold(true) waitBadgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FBBF24")).Bold(true) doneBadgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#10B981")).Bold(true) stuckBadgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#EF4444")).Bold(true) diff --git a/internal/tui/tag_menu.go b/internal/tui/tag_menu.go index 30f0cfa..036155c 100644 --- a/internal/tui/tag_menu.go +++ b/internal/tui/tag_menu.go @@ -88,7 +88,7 @@ func (a *App) renderTagMenu() string { check := "[ ]" if item.hasTag { - check = "[✓]" + check = "[" + iconSelect + "]" } usageInfo := "" @@ -122,13 +122,7 @@ func (a *App) renderTagMenu() string { Width(width). Height(height) - return lipgloss.Place( - a.width, - a.height, - lipgloss.Center, - lipgloss.Center, - box.Render(content), - ) + return box.Render(content) } func (a *App) handleTagMenuKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { diff --git a/internal/tui/testdata/block_cursor_system_tag.golden b/internal/tui/testdata/block_cursor_system_tag.golden index cdda79d..1e80333 100644 --- a/internal/tui/testdata/block_cursor_system_tag.golden +++ b/internal/tui/testdata/block_cursor_system_tag.golden @@ -1,4 +1,4 @@ -USER 2025-06-01 12:00:00 + usr 2025-06-01 12:00:00 ──────────────────────────────────────────────────────────────────────────────── ▸ Long system reminder content diff --git a/internal/tui/testdata/context_summary.golden b/internal/tui/testdata/context_summary.golden index 3a252bc..1b7e754 100644 --- a/internal/tui/testdata/context_summary.golden +++ b/internal/tui/testdata/context_summary.golden @@ -1,4 +1,4 @@ -ASSISTANT 2025-06-01 12:00:00 + ast 2025-06-01 12:00:00 ──────────────────────────────────────────────────────────────────────────────── (48 background context messages from parent session) diff --git a/internal/tui/testdata/markdown_table.golden b/internal/tui/testdata/markdown_table.golden index 6fb3ee9..c195fd0 100644 --- a/internal/tui/testdata/markdown_table.golden +++ b/internal/tui/testdata/markdown_table.golden @@ -1,4 +1,4 @@ -ASSISTANT 2025-06-01 12:00:00 + ast 2025-06-01 12:00:00 ──────────────────────────────────────────────────────────────────────────────── Here's the summary: diff --git a/internal/tui/testdata/multiple_system_tags.golden b/internal/tui/testdata/multiple_system_tags.golden index 4b52b0f..075d1dc 100644 --- a/internal/tui/testdata/multiple_system_tags.golden +++ b/internal/tui/testdata/multiple_system_tags.golden @@ -1,4 +1,4 @@ -USER 2025-06-01 12:00:00 + usr 2025-06-01 12:00:00 ──────────────────────────────────────────────────────────────────────────────── Caveat: generated by local commands. diff --git a/internal/tui/testdata/system_tag_folded.golden b/internal/tui/testdata/system_tag_folded.golden index 1a8668a..26321c1 100644 --- a/internal/tui/testdata/system_tag_folded.golden +++ b/internal/tui/testdata/system_tag_folded.golden @@ -1,4 +1,4 @@ -USER 2025-06-01 12:00:00 + usr 2025-06-01 12:00:00 ──────────────────────────────────────────────────────────────────────────────── This is a side question from the user. CRITICAL CONSTRAIN... diff --git a/internal/tui/testdata/system_tag_unfolded.golden b/internal/tui/testdata/system_tag_unfolded.golden index 7a60084..f1e1e33 100644 --- a/internal/tui/testdata/system_tag_unfolded.golden +++ b/internal/tui/testdata/system_tag_unfolded.golden @@ -1,4 +1,4 @@ -USER 2025-06-01 12:00:00 + usr 2025-06-01 12:00:00 ──────────────────────────────────────────────────────────────────────────────── diff --git a/internal/tui/testdata/text_only.golden b/internal/tui/testdata/text_only.golden index 557e7be..220c765 100644 --- a/internal/tui/testdata/text_only.golden +++ b/internal/tui/testdata/text_only.golden @@ -1,4 +1,4 @@ -USER 2025-06-01 12:00:00 + usr 2025-06-01 12:00:00 ──────────────────────────────────────────────────────────────────────────────── Hello, can you help me with this? diff --git a/internal/tui/testdata/tool_folded.golden b/internal/tui/testdata/tool_folded.golden index 74cc601..e7d5659 100644 --- a/internal/tui/testdata/tool_folded.golden +++ b/internal/tui/testdata/tool_folded.golden @@ -1,4 +1,4 @@ -ASSISTANT 2025-06-01 12:00:00 + ast 2025-06-01 12:00:00 ──────────────────────────────────────────────────────────────────────────────── Let me check that file. diff --git a/main.go b/main.go index 36cba75..248fb1c 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "flag" "fmt" "os" + "os/exec" "path/filepath" "strings" @@ -13,10 +14,150 @@ import ( "github.com/sendbird/ccx/internal/session" "github.com/sendbird/ccx/internal/tmux" "github.com/sendbird/ccx/internal/tui" + "gopkg.in/yaml.v3" ) var version = "dev" +func defaultConfigHeader() string { + return "# ccx configuration\n# Keybindings: session, actions, views, navigation\n# Preferences: preferences section (auto-saved on quit)\n# Claude: command_template controls local Claude launches; {{args}} expands to ccx-provided args.\n\n" +} + +func runConfigCommand(args []string) error { + if len(args) == 0 { + return fmt.Errorf("usage: ccx config [path] [value]") + } + path := filepath.Join(os.Getenv("HOME"), ".config", "ccx", "config.yaml") + switch args[0] { + case "path": + fmt.Println(path) + return nil + case "view", "list", "ls": + data, err := os.ReadFile(path) + if err != nil { + return err + } + fmt.Print(string(data)) + return nil + case "edit": + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + if _, err := os.Stat(path); os.IsNotExist(err) { + if err := os.WriteFile(path, []byte(defaultConfigHeader()), 0644); err != nil { + return err + } + } + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vi" + } + cmd := exec.Command(editor, path) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + case "get": + if len(args) != 2 { + return fmt.Errorf("usage: ccx config get ") + } + cfg, err := readConfigMap(path) + if err != nil { + return err + } + val, ok := getConfigPath(cfg, args[1]) + if !ok { + return fmt.Errorf("config path not found: %s", args[1]) + } + data, err := yaml.Marshal(val) + if err != nil { + return err + } + fmt.Print(string(data)) + return nil + case "set": + if len(args) != 3 { + return fmt.Errorf("usage: ccx config set ") + } + cfg, err := readConfigMap(path) + if err != nil { + return err + } + setConfigPath(cfg, args[1], parseConfigValue(args[2])) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + data, err := yaml.Marshal(cfg) + if err != nil { + return err + } + if err := os.WriteFile(path, []byte(defaultConfigHeader()+string(data)), 0644); err != nil { + return err + } + fmt.Printf("%s = %v\n", args[1], parseConfigValue(args[2])) + return nil + default: + return fmt.Errorf("unknown config command %q", args[0]) + } +} + +func readConfigMap(path string) (map[string]interface{}, error) { + cfg := map[string]interface{}{} + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return cfg, nil + } + return nil, err + } + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return cfg, nil +} + +func getConfigPath(cfg map[string]interface{}, dotPath string) (interface{}, bool) { + cur := interface{}(cfg) + for _, part := range strings.Split(dotPath, ".") { + m, ok := cur.(map[string]interface{}) + if !ok { + return nil, false + } + cur, ok = m[part] + if !ok { + return nil, false + } + } + return cur, true +} + +func setConfigPath(cfg map[string]interface{}, dotPath string, value interface{}) { + parts := strings.Split(dotPath, ".") + cur := cfg + for _, part := range parts[:len(parts)-1] { + next, _ := cur[part].(map[string]interface{}) + if next == nil { + next = map[string]interface{}{} + cur[part] = next + } + cur = next + } + cur[parts[len(parts)-1]] = value +} + +func parseConfigValue(value string) interface{} { + switch value { + case "true": + return true + case "false": + return false + case "null", "nil", "~": + return nil + default: + return value + } +} + func main() { var ( showVersion bool @@ -53,7 +194,13 @@ func main() { os.Exit(1) } os.Exit(0) - case "urls", "files", "changes", "images", "conversation", "help": + case "config": + if err := runConfigCommand(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + os.Exit(0) + case "urls", "files", "changes", "images", "conversation", "info", "help": subcmd := os.Args[1] fs := flag.NewFlagSet(subcmd, flag.ExitOnError) plain := fs.Bool("plain", false, "force plain text output (no interactive picker)") @@ -92,7 +239,8 @@ func main() { flag.Usage = func() { fmt.Fprintf(os.Stderr, "ccx — Claude Code Explorer\n\n") fmt.Fprintf(os.Stderr, "Usage: ccx [flags]\n") - fmt.Fprintf(os.Stderr, " ccx [--plain]\n\n") + fmt.Fprintf(os.Stderr, " ccx [--plain]\n") + fmt.Fprintf(os.Stderr, " ccx config ...\n\n") fmt.Fprintf(os.Stderr, "Commands:\n") for _, c := range cli.Commands { fmt.Fprintf(os.Stderr, " %-10s %s\n", c.Name, c.Desc)