diff --git a/Makefile b/Makefile index 81864bd..91e26f5 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,8 @@ -.PHONY: build install clean lint test +.PHONY: build install clean lint test hooks + +hooks: + git config core.hooksPath .githooks + @chmod +x .githooks/pre-commit build: go build -o bin/td . diff --git a/README.md b/README.md index 7cb697c..ee16907 100644 --- a/README.md +++ b/README.md @@ -327,7 +327,16 @@ Dispatch profiles: | MCP (`notion`, `linear`, …) | `generic_workspace` | MCP-focused prompt; deliverable in the external tool | | `generic` | `generic_workspace` | Files, research, bootstraps — no required MCP | -The confirmation panel shows the target and path. Press `Enter` to launch headless `claude` (stream-json log under `~/.config/td/trackers/`). The task tag updates to `@dispatched`. Press `r` to resume in iTerm2. +The confirmation panel shows the target and path. Press `Enter` to launch headless `claude` (stream-json log on disk; path below). The task tag updates to `@dispatched`. + +While a session is running: + +- **`w`** — watch the live stream-json log in the TUI (does not interrupt the agent) +- **`r`** — resume in iTerm2 (kills the headless process; requires **two presses** while status is `running`) + +Open the sessions sidebar with **`h`** for the same shortcuts plus **`L`** (open raw log in your editor). + +Tracker logs live under `$(go env USERCONFIGDIR)/td/trackers/` on macOS that is `~/Library/Application Support/td/trackers/`. The dispatch flow requires: - At least one `[[repos]]`, `[[mcp]]`, or use of `generic` routing diff --git a/internal/ai/client.go b/internal/ai/client.go index 59092ca..f624f75 100644 --- a/internal/ai/client.go +++ b/internal/ai/client.go @@ -71,7 +71,7 @@ func (c *Client) Complete(system, prompt string) (string, error) { if err != nil { return "", fmt.Errorf("request failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() respBody, err := io.ReadAll(resp.Body) if err != nil { diff --git a/internal/ai/router.go b/internal/ai/router.go index b200563..ff05546 100644 --- a/internal/ai/router.go +++ b/internal/ai/router.go @@ -83,11 +83,7 @@ func (c *Client) Route(taskText string, repos []config.Repo, mcps []config.MCP) var route RouteResult if err := json.Unmarshal([]byte(result), &route); err != nil { - // Fallback: treat raw text as a repo name (backward compat) - if strings.EqualFold(result, "generic") { - return &RouteResult{Generic: true, Actionable: true}, nil - } - return &RouteResult{Repo: result, Actionable: true}, nil + return &RouteResult{Actionable: false, Missing: "routing response was not valid JSON"}, nil } // Normalise: a literal "generic" repo name is the generic workspace. diff --git a/internal/config/config.go b/internal/config/config.go index 65154c3..ed0e400 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -165,10 +165,10 @@ func AppendRepos(repos []Repo) error { if err != nil { return err } - defer f.Close() + defer func() { _ = f.Close() }() for _, r := range repos { - fmt.Fprintf(f, "\n[[repos]]\nname = %q\npath = %q\ndescription = %q\n", r.Name, r.Path, r.Description) + _, _ = fmt.Fprintf(f, "\n[[repos]]\nname = %q\npath = %q\ndescription = %q\n", r.Name, r.Path, r.Description) } return nil } diff --git a/internal/scan/scan.go b/internal/scan/scan.go index 666b423..0173e2e 100644 --- a/internal/scan/scan.go +++ b/internal/scan/scan.go @@ -26,7 +26,7 @@ func FindRepos(dir string) ([]string, error) { } var repos []string - filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil { return nil } @@ -42,7 +42,9 @@ func FindRepos(dir string) ([]string, error) { repos = append(repos, path) } return nil - }) + }); err != nil { + return nil, err + } return repos, nil } @@ -59,7 +61,7 @@ Output format: func DescribeRepos(paths []string, apiKey, model string) ([]Suggestion, error) { var prompt strings.Builder - prompt.WriteString(fmt.Sprintf("Analyze these %d git repositories:\n\n", len(paths))) + fmt.Fprintf(&prompt, "Analyze these %d git repositories:\n\n", len(paths)) for i, p := range paths { ctx := collectContext(p) @@ -125,7 +127,7 @@ func SaveSelected(suggestions []Suggestion) error { func collectContext(repoPath string) string { var ctx strings.Builder - ctx.WriteString(fmt.Sprintf("Path: %s\n", repoPath)) + fmt.Fprintf(&ctx, "Path: %s\n", repoPath) entries, err := os.ReadDir(repoPath) if err == nil { @@ -135,7 +137,7 @@ func collectContext(repoPath string) string { names = append(names, e.Name()) } } - ctx.WriteString(fmt.Sprintf("Files: %s\n", strings.Join(names, ", "))) + fmt.Fprintf(&ctx, "Files: %s\n", strings.Join(names, ", ")) } for _, readme := range []string{"README.md", "readme.md", "README"} { @@ -145,7 +147,7 @@ func collectContext(repoPath string) string { if len(content) > 500 { content = content[:500] + "..." } - ctx.WriteString(fmt.Sprintf("README:\n%s\n", content)) + fmt.Fprintf(&ctx, "README:\n%s\n", content) break } } @@ -157,7 +159,7 @@ func collectContext(repoPath string) string { if len(content) > 300 { content = content[:300] + "..." } - ctx.WriteString(fmt.Sprintf("%s:\n%s\n", cfgFile, content)) + fmt.Fprintf(&ctx, "%s:\n%s\n", cfgFile, content) break } } diff --git a/internal/taskfile/carry.go b/internal/taskfile/carry.go index 005483d..11f1e29 100644 --- a/internal/taskfile/carry.go +++ b/internal/taskfile/carry.go @@ -66,7 +66,7 @@ func findCarriedTasks(tasksDir, todayPath string) []Task { func findPreviousFile(tasksDir, todayPath string) string { var files []string - filepath.WalkDir(tasksDir, func(path string, d fs.DirEntry, err error) error { + if err := filepath.WalkDir(tasksDir, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } @@ -74,7 +74,9 @@ func findPreviousFile(tasksDir, todayPath string) string { files = append(files, path) } return nil - }) + }); err != nil { + return "" + } if len(files) == 0 { return "" @@ -87,7 +89,7 @@ func findPreviousFile(tasksDir, todayPath string) string { func generateTemplate(date time.Time, carried []Task) string { var b strings.Builder - b.WriteString(fmt.Sprintf("# %s\n", date.Format("Monday, January 2 2006"))) + fmt.Fprintf(&b, "# %s\n", date.Format("Monday, January 2 2006")) if len(carried) > 0 { b.WriteString("\n## Carried over\n") diff --git a/internal/tracker/parser.go b/internal/tracker/parser.go index 7e8d746..38bed45 100644 --- a/internal/tracker/parser.go +++ b/internal/tracker/parser.go @@ -23,7 +23,7 @@ func (s *Store) Poll(t *Task) { } return } - defer f.Close() + defer func() { _ = f.Close() }() scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 512*1024), 512*1024) @@ -53,12 +53,10 @@ func (s *Store) Poll(t *Task) { t.DurationMs = ev.DurationMs t.Turns = ev.NumTurns - if ev.Subtype == "success" && ev.StopReason == "end_turn" && len(ev.PermissionDenials) == 0 { - t.Status = StatusDone - } else if len(ev.PermissionDenials) > 0 { + if len(ev.PermissionDenials) > 0 { t.Status = StatusNeedsInput t.Error = "permission denied: " + ev.PermissionDenials[0] - } else if ev.Subtype != "success" { + } else if ev.Subtype != "success" && ev.Subtype != "" { t.Status = StatusFailed t.Error = ev.Subtype } else { @@ -86,7 +84,7 @@ func parseToolUse(ev *event, t *Task) { } func extractDetail(tool string, input json.RawMessage) string { - var m map[string]interface{} + var m map[string]any if json.Unmarshal(input, &m) != nil { return "" } diff --git a/internal/tracker/store.go b/internal/tracker/store.go index 994dd8a..9b7945f 100644 --- a/internal/tracker/store.go +++ b/internal/tracker/store.go @@ -7,10 +7,12 @@ import ( "os" "path/filepath" "sort" + "sync" "syscall" ) type Store struct { + mu sync.Mutex dir string } @@ -65,6 +67,8 @@ func (s *Store) save(st *state) error { } func (s *Store) Register(t *Task) error { + s.mu.Lock() + defer s.mu.Unlock() st := s.load() st.Tasks[t.ID] = t return s.save(st) @@ -75,11 +79,15 @@ func (s *Store) Update(t *Task) error { } func (s *Store) Get(id string) *Task { + s.mu.Lock() + defer s.mu.Unlock() st := s.load() return st.Tasks[id] } func (s *Store) ActiveTasks() []*Task { + s.mu.Lock() + defer s.mu.Unlock() st := s.load() var active []*Task for _, t := range st.Tasks { @@ -91,12 +99,16 @@ func (s *Store) ActiveTasks() []*Task { } func (s *Store) AllTasks() map[string]*Task { + s.mu.Lock() + defer s.mu.Unlock() return s.load().Tasks } // ListRecent returns tasks sorted by StartedAt (newest first), capped at limit. // limit <= 0 means no cap. func (s *Store) ListRecent(limit int) []*Task { + s.mu.Lock() + defer s.mu.Unlock() st := s.load() all := make([]*Task, 0, len(st.Tasks)) for _, t := range st.Tasks { @@ -113,9 +125,11 @@ func (s *Store) ListRecent(limit int) []*Task { // Remove deletes a tracker and its log file. func (s *Store) Remove(id string) error { + s.mu.Lock() + defer s.mu.Unlock() st := s.load() delete(st.Tasks, id) - os.Remove(s.LogPath(id)) + _ = os.Remove(s.LogPath(id)) return s.save(st) } diff --git a/internal/tui/app.go b/internal/tui/app.go index bd4e685..10984dc 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -111,12 +111,16 @@ type Model struct { doneExpanded bool - trackerStore *tracker.Store - trackedTasks map[string]*tracker.Task - showHistory bool - historyCursor int + trackerStore *tracker.Store + trackedTasks map[string]*tracker.Task + showHistory bool + historyCursor int confirmHistoryDel bool - historySessions []*tracker.Task + historySessions []*tracker.Task + pendingResumeID string + + watchMode bool + watchModel WatchModel } func NewModel(cfg *config.Config, filePath string, tasks []taskfile.Task, autoEdit bool) Model { @@ -155,11 +159,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + if m.watchMode { + m.watchModel = m.watchModel.resize(msg.Width, msg.Height) + } if m.showHistory && !m.canShowHistorySidebar() { m.showHistory = false } return m, nil + case watchTickMsg: + if m.watchMode { + var cmd tea.Cmd + m.watchModel, cmd = m.watchModel.onTick() + return m, cmd + } + return m, nil + case tea.KeyMsg: if m.loading { if msg.String() == "ctrl+c" { @@ -173,6 +188,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil } + if m.watchMode { + if msg.String() == "q" || msg.String() == "esc" { + m.watchMode = false + return m, nil + } + var cmd tea.Cmd + m.watchModel, cmd = m.watchModel.handleKey(msg) + return m, cmd + } if m.dispatch == dstateConfirm { return m.handleDispatchKey(msg) } @@ -300,6 +324,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case trackerPollMsg: m.trackedTasks = msg.tasks m.refreshHistorySessions() + if m.watchMode && m.watchModel.task != nil { + if t, ok := msg.tasks[m.watchModel.task.ID]; ok { + m.watchModel.task = t + } + } return m, m.scheduleTrackerTick() case spinner.TickMsg: @@ -347,19 +376,23 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.cursor < len(m.displayOrder)-1 { m.cursor++ } + m.pendingResumeID = "" case "k", "up": if m.cursor > 0 { m.cursor-- } + m.pendingResumeID = "" case "G": if len(m.displayOrder) > 0 { m.cursor = len(m.displayOrder) - 1 } + m.pendingResumeID = "" case "g": m.cursor = 0 + m.pendingResumeID = "" case "e": return m, m.openEditor() @@ -391,6 +424,33 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil + case "w": + m.pendingResumeID = "" + if m.trackerStore == nil { + return m, nil + } + var tr *tracker.Task + if t := m.currentTask(); t != nil && t.Tag == taskfile.TagDispatched { + tr = m.trackerForTask(*t) + } + if tr == nil { + // No dispatched task selected: fall back to the most recently touched + // session so that pressing w from the task list always opens something + // useful when only one session is active. + for _, s := range m.trackerStore.ListRecent(1) { + tr = s + break + } + } + if tr == nil { + m.err = fmt.Errorf("no session to watch") + return m, nil + } + logPath := m.trackerStore.LogPath(tr.ID) + m.watchMode = true + m.watchModel = newWatchModel(tr, logPath, m.width, m.height) + return m, m.watchModel.init() + case "r": t := m.currentTask() if t == nil || t.Tag != taskfile.TagDispatched { @@ -401,18 +461,11 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.err = fmt.Errorf("no session to resume") return m, nil } - if tracker.IsProcessAlive(tr.PID) { - proc, _ := os.FindProcess(tr.PID) - if proc != nil { - _ = proc.Kill() - } - } - if err := resumeInITerm(tr); err != nil { - m.err = fmt.Errorf("resume: %w", err) - } else { - m.status = fmt.Sprintf("Resumed in iTerm (%s)", tr.RepoName) + m, ok := m.confirmResume(tr) + if !ok { + return m, nil } - return m, nil + return m.resumeTrackerSession(tr) case "D", "ctrl+d": t := m.currentTask() @@ -831,3 +884,42 @@ func (m Model) trackerForTask(t taskfile.Task) *tracker.Task { } return nil } + +// confirmResume gates resume on running sessions: first press warns, second confirms. +func (m Model) confirmResume(tr *tracker.Task) (Model, bool) { + if tr.Status != tracker.StatusRunning { + m.pendingResumeID = "" + return m, true + } + if m.pendingResumeID != tr.ID { + m.pendingResumeID = tr.ID + m.status = "Session running — press r again to confirm, or w to watch" + m.err = nil + return m, false + } + m.pendingResumeID = "" + return m, true +} + +func (m Model) resumeTrackerSession(tr *tracker.Task) (tea.Model, tea.Cmd) { + if tr == nil { + return m, nil + } + if tr.SessionID == "" { + m.err = fmt.Errorf("no session to resume") + return m, nil + } + if tracker.IsProcessAlive(tr.PID) { + proc, _ := os.FindProcess(tr.PID) + if proc != nil { + _ = proc.Kill() + } + } + if err := resumeInITerm(tr); err != nil { + m.err = fmt.Errorf("resume: %w", err) + } else { + m.status = fmt.Sprintf("Resumed in iTerm (%s)", tr.RepoName) + m.err = nil + } + return m, nil +} diff --git a/internal/tui/dispatch.go b/internal/tui/dispatch.go index 0424e60..6c0d4b6 100644 --- a/internal/tui/dispatch.go +++ b/internal/tui/dispatch.go @@ -50,13 +50,13 @@ func launchHeadless(store *tracker.Store, repoPath, repoName, taskText string, m cmd.Stderr = logFile if err := cmd.Start(); err != nil { - logFile.Close() + _ = logFile.Close() return nil, err } go func() { _ = cmd.Wait() - logFile.Close() + _ = logFile.Close() }() t := &tracker.Task{ diff --git a/internal/tui/history.go b/internal/tui/history.go index b5ecc17..fd2b752 100644 --- a/internal/tui/history.go +++ b/internal/tui/history.go @@ -2,7 +2,6 @@ package tui import ( "fmt" - "os" "os/exec" "strings" "time" @@ -65,6 +64,7 @@ func (m Model) handleHistoryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "h", "esc": m.showHistory = false m.confirmHistoryDel = false + m.pendingResumeID = "" return m, nil case "?": @@ -74,6 +74,7 @@ func (m Model) handleHistoryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "tab": m.showHistory = false m.confirmHistoryDel = false + m.pendingResumeID = "" m.viewMode = viewSettings m.confirmDelete = false return m, nil @@ -83,25 +84,52 @@ func (m Model) handleHistoryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.historyCursor++ } m.confirmHistoryDel = false + m.pendingResumeID = "" case "k", "up": if m.historyCursor > 0 { m.historyCursor-- } m.confirmHistoryDel = false + m.pendingResumeID = "" case "g": m.historyCursor = 0 m.confirmHistoryDel = false + m.pendingResumeID = "" case "G": if len(m.historySessions) > 0 { m.historyCursor = len(m.historySessions) - 1 } m.confirmHistoryDel = false + m.pendingResumeID = "" case "enter", "r": - return m.resumeHistorySession() + tr := m.selectedHistorySession() + if tr != nil && tr.Status == tracker.StatusRunning { + if m.pendingResumeID == tr.ID { + // Second press confirmed — proceed with resume. + m.pendingResumeID = "" + return m.resumeTrackerSession(tr) + } + // First press — require confirmation. + m.pendingResumeID = tr.ID + return m, nil + } + m.pendingResumeID = "" + return m.resumeTrackerSession(tr) + + case "w": + m.pendingResumeID = "" + tr := m.selectedHistorySession() + if tr == nil || m.trackerStore == nil { + return m, nil + } + logPath := m.trackerStore.LogPath(tr.ID) + m.watchMode = true + m.watchModel = newWatchModel(tr, logPath, m.width, m.height) + return m, m.watchModel.init() case "l", "L": return m.openHistoryLog() @@ -121,6 +149,7 @@ func (m Model) handleHistoryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } m.confirmHistoryDel = true + m.pendingResumeID = "" return m, nil default: @@ -130,30 +159,6 @@ func (m Model) handleHistoryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -func (m Model) resumeHistorySession() (tea.Model, tea.Cmd) { - tr := m.selectedHistorySession() - if tr == nil { - return m, nil - } - if tr.SessionID == "" { - m.err = fmt.Errorf("no Claude session to resume") - return m, nil - } - if tracker.IsProcessAlive(tr.PID) { - proc, _ := os.FindProcess(tr.PID) - if proc != nil { - _ = proc.Kill() - } - } - if err := resumeInITerm(tr); err != nil { - m.err = fmt.Errorf("resume: %w", err) - } else { - m.status = fmt.Sprintf("Resumed in iTerm (%s)", tr.RepoName) - m.err = nil - } - return m, nil -} - func (m Model) openHistoryLog() (tea.Model, tea.Cmd) { tr := m.selectedHistorySession() if tr == nil || m.trackerStore == nil { @@ -184,13 +189,18 @@ func (m Model) renderHistorySidebar() string { } } - if m.confirmHistoryDel { + if m.pendingResumeID != "" { + lines = append(lines, "") + lines = append(lines, settingsDeleteWarn.Render(" ⚠ Session is running.")) + lines = append(lines, settingsDeleteWarn.Render(" r again to confirm,")) + lines = append(lines, dimStyle.Render(" or w to watch.")) + } else if m.confirmHistoryDel { lines = append(lines, "") lines = append(lines, settingsDeleteWarn.Render(" Delete session? D again")) } lines = append(lines, "") - lines = append(lines, dimStyle.Render(" Enter resume")) + lines = append(lines, dimStyle.Render(" Enter/r resume w watch")) lines = append(lines, dimStyle.Render(" L log D remove")) lines = append(lines, dimStyle.Render(" h hide")) diff --git a/internal/tui/picker.go b/internal/tui/picker.go index c94fbdc..b7d2338 100644 --- a/internal/tui/picker.go +++ b/internal/tui/picker.go @@ -15,7 +15,7 @@ func (m Model) handlePickerKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "enter": filtered := m.filteredRepos() - if len(filtered) == 0 { + if len(filtered) == 0 || m.pickerCursor >= len(filtered) { return m, nil } repo := filtered[m.pickerCursor] @@ -120,7 +120,7 @@ func (m Model) pickerView() string { if inputLine == "" { inputLine = dimStyle.Render("type to filter...") } - b.WriteString(fmt.Sprintf(" > %s\n\n", inputLine)) + fmt.Fprintf(&b, " > %s\n\n", inputLine) filtered := m.filteredRepos() for i, r := range filtered { @@ -151,10 +151,7 @@ func (m Model) pickerView() string { } nameWidth := lipgloss.Width(name) - pad := 24 - nameWidth - if pad < 2 { - pad = 2 - } + pad := max(24-nameWidth, 2) b.WriteString(cursor + name + strings.Repeat(" ", pad) + desc + "\n") } diff --git a/internal/tui/settings.go b/internal/tui/settings.go index 80e6cec..6ec8137 100644 --- a/internal/tui/settings.go +++ b/internal/tui/settings.go @@ -162,10 +162,10 @@ func (m Model) settingsView() string { b.WriteString("\n\n") configPath := config.ConfigPath() - b.WriteString(fmt.Sprintf(" %s %s\n", settingsLabel.Render("path"), settingsValue.Render(configPath))) - b.WriteString(fmt.Sprintf(" %s %s\n", settingsLabel.Render("tasks"), settingsValue.Render(m.cfg.TasksDir))) - b.WriteString(fmt.Sprintf(" %s %s\n", settingsLabel.Render("generic"), settingsValue.Render(m.cfg.GenericWorkspace))) - b.WriteString(fmt.Sprintf(" %s %s\n", settingsLabel.Render("model"), settingsValue.Render(m.cfg.AnthropicModel))) + fmt.Fprintf(&b, " %s %s\n", settingsLabel.Render("path"), settingsValue.Render(configPath)) + fmt.Fprintf(&b, " %s %s\n", settingsLabel.Render("tasks"), settingsValue.Render(m.cfg.TasksDir)) + fmt.Fprintf(&b, " %s %s\n", settingsLabel.Render("generic"), settingsValue.Render(m.cfg.GenericWorkspace)) + fmt.Fprintf(&b, " %s %s\n", settingsLabel.Render("model"), settingsValue.Render(m.cfg.AnthropicModel)) if m.err != nil { b.WriteString("\n" + errorStyle.Render(" "+m.err.Error()) + "\n") diff --git a/internal/tui/styles.go b/internal/tui/styles.go index b63fcff..99c1c28 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -59,7 +59,6 @@ var ( ) var ( - helpFooter = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) footerKey = lipgloss.NewStyle().Foreground(lipgloss.Color("81")).Bold(true) footerDesc = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) ) diff --git a/internal/tui/views.go b/internal/tui/views.go index 8cd4f03..7d13105 100644 --- a/internal/tui/views.go +++ b/internal/tui/views.go @@ -17,6 +17,9 @@ func (m Model) View() string { if m.showHelp { return m.helpView() } + if m.watchMode { + return m.watchModel.view() + } if m.loading { return m.loadingView() } @@ -364,10 +367,13 @@ func (m Model) renderFooter() string { parts = append(parts, keyHint("d", fmt.Sprintf("dispatch(%d)", ac))) } parts = append(parts, keyHint("D", "delete")) + c := m.counts() + if c.inflight > 0 { + parts = append(parts, keyHint("w", "watch")) + } if t := m.currentTask(); t != nil && t.Tag == taskfile.TagDispatched { parts = append(parts, keyHint("r", "resume")) } - c := m.counts() if c.done > 0 { if m.doneExpanded { parts = append(parts, keyHint("v", "hide done")) @@ -478,9 +484,10 @@ func (m Model) helpView() string { {"Sessions (h)", []binding{ {"h", "toggle sessions sidebar"}, {"j / k", "select session"}, - {"Enter / r", "resume in iTerm"}, - {"L", "open session log"}, - {"D", "remove session (twice)"}, + {"w", "watch session log (read-only)"}, + {"Enter / r", "resume in iTerm (guard on running)"}, + {"L", "open session log in editor"}, + {"D", "remove session (press twice)"}, }}, {"General", []binding{ {"Tab", "toggle settings view"}, @@ -498,9 +505,9 @@ func (m Model) helpView() string { for _, s := range sections { b.WriteString(helpSection.Render(" "+s.name) + "\n") for _, bind := range s.bindings { - b.WriteString(fmt.Sprintf(" %s %s\n", + fmt.Fprintf(&b, " %s %s\n", helpKey.Render(bind.key), - helpDesc.Render(bind.desc))) + helpDesc.Render(bind.desc)) } } @@ -525,7 +532,7 @@ func (m Model) scanInputView() string { if inputLine == "" { inputLine = dimStyle.Render("~/Development/...") } - b.WriteString(fmt.Sprintf(" > %s", inputLine)) + fmt.Fprintf(&b, " > %s", inputLine) b.WriteString(cursorStyle.Render("█") + "\n") b.WriteString("\n " + keyHint("Enter", "scan") + " " + keyHint("Esc", "cancel") + "\n") diff --git a/internal/tui/watch.go b/internal/tui/watch.go new file mode 100644 index 0000000..7af3165 --- /dev/null +++ b/internal/tui/watch.go @@ -0,0 +1,373 @@ +package tui + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "td/internal/tracker" +) + +var ( + watchInitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("62")).Bold(true) + watchToolStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) + watchDoneStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("77")).Bold(true) + watchFailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) + watchIndentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("238")) + watchStatusBar = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) + watchStatusKey = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) + watchTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")) + watchToolName = lipgloss.NewStyle().Bold(true) +) + +type watchTickMsg struct{} + +// watchEvent is a minimal representation of a Claude stream-json event for watch rendering. +type watchEvent struct { + Type string `json:"type"` + Subtype string `json:"subtype,omitempty"` + SessionID string `json:"session_id,omitempty"` + Model string `json:"model,omitempty"` + Message *watchMessage `json:"message,omitempty"` + Content json.RawMessage `json:"content,omitempty"` + TotalCostUSD float64 `json:"total_cost_usd,omitempty"` + DurationMs int64 `json:"duration_ms,omitempty"` + NumTurns int `json:"num_turns,omitempty"` + Error string `json:"error,omitempty"` +} + +type watchMessage struct { + Content []watchContentBlock `json:"content"` +} + +type watchContentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Name string `json:"name,omitempty"` + Input json.RawMessage `json:"input,omitempty"` +} + +// WatchModel streams the .jsonl log of a running or completed session. +type WatchModel struct { + task *tracker.Task + logPath string + lines []string + byteOffset int64 + viewport viewport.Model + width int + height int + atBottom bool + logMissing bool +} + +// viewportDims returns the usable viewport width and height for the watch overlay. +func viewportDims(width, height int) (vpW, vpH int) { + return max(width-4, 20), max(height-6, 3) +} + +func newWatchModel(task *tracker.Task, logPath string, width, height int) WatchModel { + vpW, vpH := viewportDims(width, height) + vp := viewport.New(vpW, vpH) + w := WatchModel{ + task: task, + logPath: logPath, + viewport: vp, + width: width, + height: height, + atBottom: true, + } + lines, offset, missing := readWatchLines(logPath, 0) + w.logMissing = missing + if len(lines) > 0 { + w.lines = lines + w.byteOffset = offset + w.viewport.SetContent(strings.Join(lines, "\n")) + w.viewport.GotoBottom() + } + return w +} + +func (w WatchModel) init() tea.Cmd { + return func() tea.Msg { return watchTickMsg{} } +} + +func (w WatchModel) onTick() (WatchModel, tea.Cmd) { + newLines, newOffset, missing := readWatchLines(w.logPath, w.byteOffset) + w.logMissing = missing + if len(newLines) > 0 { + wasAtBottom := w.atBottom + w.lines = append(w.lines, newLines...) + w.viewport.SetContent(strings.Join(w.lines, "\n")) + if wasAtBottom { + w.viewport.GotoBottom() + w.atBottom = true + } + } + w.byteOffset = newOffset + + if w.task != nil && w.task.Status == tracker.StatusRunning { + return w, tea.Tick(100*time.Millisecond, func(time.Time) tea.Msg { + return watchTickMsg{} + }) + } + return w, nil +} + +func (w WatchModel) handleKey(msg tea.KeyMsg) (WatchModel, tea.Cmd) { + var cmd tea.Cmd + w.viewport, cmd = w.viewport.Update(msg) + w.atBottom = w.viewport.AtBottom() + return w, cmd +} + +func (w WatchModel) resize(width, height int) WatchModel { + w.width = width + w.height = height + vpW, vpH := viewportDims(width, height) + w.viewport.Width = vpW + w.viewport.Height = vpH + w.viewport.SetContent(strings.Join(w.lines, "\n")) + if w.atBottom { + w.viewport.GotoBottom() + } + return w +} + +func (w WatchModel) view() string { + if w.logMissing { + var b strings.Builder + b.WriteString("\n ") + b.WriteString(errorStyle.Render("Log file not found")) + b.WriteString("\n ") + b.WriteString(dimStyle.Render(w.logPath)) + b.WriteString("\n\n ") + b.WriteString(keyHint("q", "back")) + b.WriteString("\n") + return frameStyle.Width(w.width - 4).Render(b.String()) + } + + var b strings.Builder + + // Header + title := "Watch" + if w.task != nil { + title = "Watch · " + sessionTitle(w.task.TaskText, 50) + } + b.WriteString(watchTitleStyle.Render(title)) + b.WriteString("\n") + b.WriteString(historyBorderStyle.Render(strings.Repeat("─", w.viewport.Width))) + b.WriteString("\n") + + // Log viewport + b.WriteString(w.viewport.View()) + b.WriteString("\n") + + // Status bar + b.WriteString(historyBorderStyle.Render(strings.Repeat("─", w.viewport.Width))) + b.WriteString("\n") + b.WriteString(w.renderStatusBar()) + b.WriteString("\n") + b.WriteString(keyHint("q", "back") + " " + keyHint("↑↓", "scroll") + " " + keyHint("G", "bottom")) + + return frameStyle.Width(w.width - 4).Render(b.String()) +} + +func (w WatchModel) renderStatusBar() string { + if w.task == nil { + return "" + } + t := w.task + statusStyled := watchStatusKey.Render("[" + sessionStatusShort(t.Status) + "]") + title := sessionTitle(t.TaskText, 28) + repo := t.RepoName + + parts := []string{statusStyled, watchStatusBar.Render(title), watchStatusBar.Render("repo: " + repo)} + if t.Turns > 0 { + parts = append(parts, watchStatusBar.Render(fmt.Sprintf("%d turns", t.Turns))) + } + if t.CostUSD > 0 { + parts = append(parts, watchStatusBar.Render(fmt.Sprintf("$%.3f", t.CostUSD))) + } + parts = append(parts, watchStatusBar.Render(formatDuration(t.Elapsed()))) + + return strings.Join(parts, watchStatusBar.Render(" · ")) +} + +// readWatchLines reads new JSONL lines from logPath starting at offset, returning rendered display lines. +// If the file has shrunk (e.g. log rotation), offset is reset to 0 and a notice line is prepended. +func readWatchLines(logPath string, offset int64) (lines []string, newOffset int64, missing bool) { + f, err := os.Open(logPath) + if err != nil { + return nil, offset, true + } + defer func() { _ = f.Close() }() + + // Detect log rotation: if file is smaller than our stored offset, start over. + if info, err := f.Stat(); err == nil && info.Size() < offset { + offset = 0 + lines = append(lines, dimStyle.Render("--- log rotated, re-reading from start ---")) + } + + if _, err := f.Seek(offset, io.SeekStart); err != nil { + return nil, offset, false + } + + data, err := io.ReadAll(f) + if err != nil || len(data) == 0 { + return nil, offset, false + } + + newOffset = offset + int64(len(data)) + + for raw := range strings.SplitSeq(string(data), "\n") { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + lines = append(lines, parseWatchLine(raw)...) + } + + return lines, newOffset, false +} + +// parseWatchLine maps a single JSONL event to zero or more display lines. +func parseWatchLine(raw string) []string { + var ev watchEvent + if json.Unmarshal([]byte(raw), &ev) != nil { + return []string{watchFailStyle.Render("!") + " " + dimStyle.Render("[malformed event — could not parse JSON]")} + } + + switch ev.Type { + case "system": + text := watchInitStyle.Render("◆") + " Session started" + if ev.Model != "" { + text += " — model: " + dimStyle.Render(ev.Model) + } + if ev.SessionID != "" { + short := ev.SessionID + if len(short) > 8 { + short = short[:8] + } + text += " · " + dimStyle.Render("session: "+short) + } + return []string{text, ""} + + case "assistant": + if ev.Message == nil { + return nil + } + var out []string + for _, block := range ev.Message.Content { + switch block.Type { + case "text": + if block.Text != "" { + for line := range strings.SplitSeq(strings.TrimRight(block.Text, "\n"), "\n") { + if strings.TrimSpace(line) != "" { + out = append(out, " "+line) + } + } + out = append(out, "") + } + case "tool_use": + summary := watchSummarizeInput(block.Name, block.Input) + line := watchToolStyle.Render("▶") + " " + watchToolName.Render(block.Name) + if summary != "" { + line += " " + dimStyle.Render(summary) + } + out = append(out, line) + } + } + return out + + case "tool": + result := watchParseContent(ev.Content) + if result != "" { + short := truncate(strings.ReplaceAll(result, "\n", " "), 100) + return []string{" " + watchIndentStyle.Render("└") + " " + dimStyle.Render(short)} + } + return nil + + case "result": + // Claude CLI emits subtype:"success" on clean completion; the + // (subtype=="" && num_turns>0) branch handles older schema versions + // that omit subtype entirely. + if ev.Subtype == "success" || (ev.Subtype == "" && ev.NumTurns > 0) { + cost := fmt.Sprintf("$%.3f", ev.TotalCostUSD) + dur := formatDuration(time.Duration(ev.DurationMs) * time.Millisecond) + return []string{"", watchDoneStyle.Render("✓") + fmt.Sprintf(" Done — %d turns · %s · %s", ev.NumTurns, cost, dur)} + } + errMsg := ev.Error + if errMsg == "" { + errMsg = ev.Subtype + } + if errMsg == "" { + errMsg = "unknown error" + } + return []string{"", watchFailStyle.Render("✗") + " Failed — " + dimStyle.Render(errMsg)} + } + + return nil +} + +func watchSummarizeInput(tool string, input json.RawMessage) string { + var m map[string]any + if json.Unmarshal(input, &m) != nil { + return "" + } + switch tool { + case "Edit", "Write", "Read": + if p, ok := m["file_path"].(string); ok { + return filepath.Base(p) + } + if p, ok := m["path"].(string); ok { + return filepath.Base(p) + } + case "Bash": + if cmd, ok := m["command"].(string); ok { + return truncate(cmd, 60) + } + case "Grep", "Glob": + if p, ok := m["pattern"].(string); ok { + return truncate(p, 60) + } + case "WebSearch": + if q, ok := m["query"].(string); ok { + return truncate(q, 60) + } + case "WebFetch": + if u, ok := m["url"].(string); ok { + return truncate(u, 60) + } + } + return "" +} + +func watchParseContent(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var s string + if json.Unmarshal(raw, &s) == nil { + return s + } + var blocks []struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + } + if json.Unmarshal(raw, &blocks) == nil { + for _, b := range blocks { + if b.Text != "" { + return b.Text + } + } + } + return "" +} diff --git a/internal/tui/watch_test.go b/internal/tui/watch_test.go new file mode 100644 index 0000000..faa93b1 --- /dev/null +++ b/internal/tui/watch_test.go @@ -0,0 +1,79 @@ +package tui + +import ( + "strings" + "testing" +) + +func TestParseWatchLine_system(t *testing.T) { + raw := `{"type":"system","session_id":"abc12345","model":"claude-sonnet-4-6"}` + lines := parseWatchLine(raw) + if len(lines) == 0 { + t.Fatal("expected lines") + } + if !strings.Contains(lines[0], "Session started") { + t.Fatalf("got %q", lines[0]) + } +} + +func TestParseWatchLine_toolUse(t *testing.T) { + raw := `{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"git status"}}]}}` + lines := parseWatchLine(raw) + found := false + for _, l := range lines { + if strings.Contains(l, "Bash") { + found = true + } + } + if !found { + t.Fatalf("expected Bash tool line, got %v", lines) + } +} + +func TestParseWatchLine_resultSuccess(t *testing.T) { + raw := `{"type":"result","subtype":"success","num_turns":3,"total_cost_usd":0.012,"duration_ms":45000}` + lines := parseWatchLine(raw) + found := false + for _, l := range lines { + if strings.Contains(l, "Done") { + found = true + } + } + if !found { + t.Fatalf("expected Done line, got %v", lines) + } +} + +func TestParseWatchLine_resultFailure(t *testing.T) { + raw := `{"type":"result","subtype":"error","error":"context deadline exceeded"}` + lines := parseWatchLine(raw) + found := false + for _, l := range lines { + if strings.Contains(l, "Failed") { + found = true + } + } + if !found { + t.Fatalf("expected Failed line, got %v", lines) + } + // Error message should appear in the output. + hasMsg := false + for _, l := range lines { + if strings.Contains(l, "context deadline exceeded") { + hasMsg = true + } + } + if !hasMsg { + t.Fatalf("expected error message in output, got %v", lines) + } +} + +func TestParseWatchLine_invalidJSON(t *testing.T) { + lines := parseWatchLine("not json") + if len(lines) == 0 { + t.Fatal("expected a malformed-event indicator line, got nothing") + } + if !strings.Contains(lines[0], "malformed") { + t.Fatalf("expected malformed-event indicator, got %q", lines[0]) + } +} diff --git a/main.go b/main.go index 507eeb7..8f4e95f 100644 --- a/main.go +++ b/main.go @@ -115,7 +115,9 @@ Keybindings (in TUI): a Cycle tag: none → agent → draft → none A Assign @agent:repo with picker d Dispatch @agent task to repo - h Toggle sessions sidebar (resume past agent runs) + w Watch session log (in-flight or selected dispatched task) + r Resume in iTerm (two presses while session is running) + h Toggle sessions sidebar (past agent runs) Tab Toggle settings view (scan, manage repos) c Open config file ? Toggle help overlay