From bc3114d5578d0b6acd091d958d7c1baac2f02f7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CErick?= Date: Fri, 29 May 2026 13:08:41 -0300 Subject: [PATCH] feat: show prompts for opencode and codex sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add agent-aware prompt extraction so cowsay shows the first user message for opencode (via opencode.db) and codex (via threads.first_user_message in state SQLite files, covering both OpenAI CLI and JetBrains layouts). Also fix a caching bug in refreshPrompts where sessions with an empty prompt were permanently skipped — now only sessions with an already-found prompt are cached, so the prompt appears as soon as the agent processes the first message. Co-Authored-By: Claude Sonnet 4.6 --- internal/prompt/prompt.go | 78 ++++++++++++++++++++++++++++++++++++++- internal/tui/model.go | 7 +--- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go index fa00c63..dd21e53 100644 --- a/internal/prompt/prompt.go +++ b/internal/prompt/prompt.go @@ -1,12 +1,14 @@ -// Package prompt extracts the first user prompt from a Claude Code -// session log so moomux can show "what is this session doing?". +// Package prompt extracts the first user prompt from an agent session +// so moomux can show "what is this session doing?". package prompt import ( "bufio" "encoding/json" "os" + "os/exec" "path/filepath" + "runtime" "sort" "strings" ) @@ -29,6 +31,78 @@ type msg struct { Content json.RawMessage `json:"content"` } +// ForAgent returns the first user prompt for a session, dispatching to the +// right data source based on agent type. +func ForAgent(home, agent, worktreePath string) string { + switch agent { + case "opencode": + return FirstOpenCode(home, worktreePath) + case "codex": + return FirstCodex(home, worktreePath) + default: + return First(home, worktreePath) + } +} + +// FirstOpenCode returns the first user text prompt for an OpenCode session +// by querying ~/.local/share/opencode/opencode.db. +func FirstOpenCode(home, worktreePath string) string { + dbPath := filepath.Join(home, ".local", "share", "opencode", "opencode.db") + query := `SELECT json_extract(p.data, '$.text') +FROM part p +JOIN message m ON p.message_id = m.id +JOIN session s ON s.id = m.session_id +WHERE s.directory = '` + strings.ReplaceAll(worktreePath, "'", "''") + `' + AND json_extract(m.data, '$.role') = 'user' + AND json_extract(p.data, '$.type') = 'text' +ORDER BY m.time_created ASC, p.time_created ASC +LIMIT 1` + out, err := exec.Command("sqlite3", dbPath, query).Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// FirstCodex returns the first user prompt from a Codex CLI session by +// querying the threads table in the state SQLite files. +func FirstCodex(home, worktreePath string) string { + globs := codexDBGlobs(home) + query := "SELECT first_user_message FROM threads WHERE cwd = '" + + strings.ReplaceAll(worktreePath, "'", "''") + + "' AND first_user_message != '' ORDER BY created_at ASC LIMIT 1" + for _, pattern := range globs { + paths, err := filepath.Glob(pattern) + if err != nil || len(paths) == 0 { + continue + } + for _, p := range paths { + out, err := exec.Command("sqlite3", p, query).Output() + if err != nil { + continue + } + if s := strings.TrimSpace(string(out)); s != "" { + return s + } + } + } + return "" +} + +// codexDBGlobs returns glob patterns for Codex state SQLite files across +// known installation layouts (OpenAI CLI and JetBrains plugin). +func codexDBGlobs(home string) []string { + globs := []string{ + filepath.Join(home, ".codex", "state_*.sqlite"), + } + if runtime.GOOS == "darwin" { + globs = append(globs, + filepath.Join(home, "Library", "Caches", "JetBrains", "*", "aia", "codex", "state_*.sqlite"), + ) + } + return globs +} + // First returns the earliest non-banner user prompt across all jsonl // logs under ~/.claude/projects//, ranked by the in-file // timestamp so resumed sessions don't shadow the original opener. diff --git a/internal/tui/model.go b/internal/tui/model.go index d63d45b..4d383e4 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -120,13 +120,10 @@ func New(cfg *config.Config, backend Backend, statusCh <-chan watcher.Snapshot, func (m *Model) refreshPrompts() { home, _ := os.UserHomeDir() for _, s := range m.backend.Sessions() { - // Skip once checked — presence in the map (even "") means we've scanned - // already. New sessions are cleared from the map on SessionCreatedMsg so - // they get picked up on the next tick. - if _, ok := m.prompts[s.ID]; ok { + if p := m.prompts[s.ID]; p != "" { continue } - m.prompts[s.ID] = prompt.First(home, s.WorktreePath) + m.prompts[s.ID] = prompt.ForAgent(home, s.AgentName(), s.WorktreePath) } }