From 3f15e870fa4f4165c15194ff46ff647309873de4 Mon Sep 17 00:00:00 2001 From: David Chen Date: Sat, 29 Nov 2025 10:04:39 -0800 Subject: [PATCH] Refine tracker notes scopes and UI --- agent-tracker/cmd/tracker-client/main.go | 204 +++++++++++++++++++---- agent-tracker/cmd/tracker-server/main.go | 71 ++++---- agent-tracker/internal/ipc/envelope.go | 1 + 3 files changed, 208 insertions(+), 68 deletions(-) diff --git a/agent-tracker/cmd/tracker-client/main.go b/agent-tracker/cmd/tracker-client/main.go index 1842b0d..4622b3d 100644 --- a/agent-tracker/cmd/tracker-client/main.go +++ b/agent-tracker/cmd/tracker-client/main.go @@ -43,7 +43,6 @@ const ( type noteScope string const ( - scopePane noteScope = "pane" scopeWindow noteScope = "window" scopeSession noteScope = "session" scopeAll noteScope = "all" @@ -467,21 +466,8 @@ func runUI(args []string) error { helpVisible := false var editNote *ipc.Note - scopeLabel := func(s noteScope) string { - switch s { - case scopePane: - return "Pane" - case scopeSession: - return "Session" - case scopeAll: - return "All" - default: - return "Window" - } - } - - cycleScope := func(forward bool) { - order := []noteScope{scopePane, scopeWindow, scopeSession, scopeAll} + cycleScope := func(forward bool, wrap bool) { + order := []noteScope{scopeWindow, scopeSession, scopeAll} pos := 0 for i, s := range order { if scope == s { @@ -492,11 +478,60 @@ func runUI(args []string) error { if forward { if pos < len(order)-1 { scope = order[pos+1] + } else if wrap { + scope = order[0] } return } if pos > 0 { scope = order[pos-1] + } else if wrap { + scope = order[len(order)-1] + } + } + + scopeStyle := func(s noteScope) tcell.Style { + switch s { + case scopeWindow: + return tcell.StyleDefault.Foreground(tcell.ColorLightYellow).Bold(true) + case scopeSession: + return tcell.StyleDefault.Foreground(tcell.ColorFuchsia).Bold(true) + case scopeAll: + return tcell.StyleDefault.Foreground(tcell.ColorLightGreen).Bold(true) + default: + return tcell.StyleDefault.Foreground(tcell.ColorLightYellow).Bold(true) + } + } + + noteScopeOf := func(n ipc.Note) noteScope { + switch strings.ToLower(strings.TrimSpace(n.Scope)) { + case string(scopeWindow): + return scopeWindow + case string(scopeSession): + return scopeSession + case string(scopeAll): + return scopeAll + } + switch { + case strings.TrimSpace(n.WindowID) != "": + return scopeWindow + case strings.TrimSpace(n.SessionID) != "": + return scopeSession + default: + return scopeAll + } + } + + scopeTag := func(s noteScope) string { + switch s { + case scopeWindow: + return "[W]" + case scopeSession: + return "[S]" + case scopeAll: + return "[G]" + default: + return "[?]" } } @@ -535,13 +570,21 @@ func runUI(args []string) error { } matchesScope := func(n ipc.Note, s noteScope, ctx tmuxContext) bool { + ns := noteScopeOf(n) switch s { - case scopePane: - return strings.TrimSpace(n.Pane) != "" && n.Pane == ctx.PaneID case scopeWindow: - return strings.TrimSpace(n.WindowID) != "" && n.WindowID == ctx.WindowID + if ns == scopeAll { + return true + } + if ns == scopeSession && strings.TrimSpace(n.SessionID) == strings.TrimSpace(ctx.SessionID) { + return true + } + return ns == scopeWindow && strings.TrimSpace(n.WindowID) == strings.TrimSpace(ctx.WindowID) case scopeSession: - return strings.TrimSpace(n.SessionID) != "" && n.SessionID == ctx.SessionID + if ns == scopeAll { + return true + } + return strings.TrimSpace(n.SessionID) == strings.TrimSpace(ctx.SessionID) case scopeAll: return true default: @@ -689,7 +732,7 @@ func runUI(args []string) error { ctx := currentCtx return sendCommand("note_attach", func(env *ipc.Envelope) { env.NoteID = id - setScopeFields(env, scopePane, ctx) + setScopeFields(env, scopeWindow, ctx) }) } @@ -813,13 +856,34 @@ func runUI(args []string) error { if showCompletedNotes { completedState = "shown" } - subtitle = fmt.Sprintf("%s · Scope: %s · Completed: %s", st.message, scopeLabel(scope), completedState) + subtitle = fmt.Sprintf("%s · Completed: %s", st.message, completedState) } else if mode == viewEdit && editNote != nil { subtitle = fmt.Sprintf("%s · %s · %s", st.message, editNote.Session, editNote.Window) } writeStyledLine(screen, 0, 0, truncate(fmt.Sprintf("▌ %s", title), width), headerStyle) - writeStyledLine(screen, 0, 1, truncate(subtitle, width), subtleStyle) + if mode == viewNotes { + label := "" + switch scope { + case scopeWindow: + label = "Window" + case scopeSession: + label = "Session" + case scopeAll: + label = "Global" + default: + label = "Window" + } + writeStyledSegmentsPad(screen, 1, []struct { + text string + style tcell.Style + }{ + {text: label + " ", style: scopeStyle(scope)}, + {text: truncate(subtitle, width-len(label)-1), style: subtleStyle}, + }, subtleStyle) + } else { + writeStyledLine(screen, 0, 1, truncate(subtitle, width), subtleStyle) + } if width > 0 { writeStyledLine(screen, 0, 2, strings.Repeat("─", width), infoStyle) } @@ -908,6 +972,7 @@ func runUI(args []string) error { break } n := list[idx] + ns := noteScopeOf(n) indicator := "•" if n.Completed { indicator = "✓" @@ -920,7 +985,23 @@ func runUI(args []string) error { if idx == state.selected { mainStyle = mainStyle.Background(tcell.ColorDarkSlateGray) } - writeStyledLine(screen, 0, row, truncate(line, width), mainStyle) + tag := scopeTag(ns) + " " + remaining := width - len([]rune(tag)) + if remaining < 0 { + remaining = 0 + } + segs := []struct { + text string + style tcell.Style + }{ + {text: tag, style: scopeStyle(ns)}, + {text: truncate(line, remaining), style: mainStyle}, + } + fill := mainStyle + if idx == state.selected { + fill = fill.Background(tcell.ColorDarkSlateGray) + } + writeStyledSegmentsPad(screen, row, segs, fill) row++ if row >= height { break @@ -929,7 +1010,7 @@ func runUI(args []string) error { if idx == state.selected { metaStyle = metaStyle.Background(tcell.ColorDarkSlateGray) } - meta := fmt.Sprintf(" %s · %s", n.Session, n.Window) + meta := fmt.Sprintf("%s · %s", n.Session, n.Window) if archived { if n.ArchivedAt != "" { if ts, ok := parseTimestamp(n.ArchivedAt); ok { @@ -941,7 +1022,21 @@ func runUI(args []string) error { meta += fmt.Sprintf(" · %s", ts.Format("15:04")) } } - writeStyledLine(screen, 0, row, truncate(meta, width), metaStyle) + metaRemaining := width + if metaRemaining < 0 { + metaRemaining = 0 + } + segsMeta := []struct { + text string + style tcell.Style + }{ + {text: truncate(meta, metaRemaining), style: metaStyle}, + } + fillMeta := metaStyle + if idx == state.selected { + fillMeta = fillMeta.Background(tcell.ColorDarkSlateGray) + } + writeStyledSegmentsPad(screen, row, segsMeta, fillMeta) row++ if row < height { row++ @@ -1120,6 +1215,17 @@ func runUI(args []string) error { continue } + if tev.Key() == tcell.KeyTab && mode == viewNotes { + cycleScope(true, true) + draw(time.Now()) + continue + } + if tev.Key() == tcell.KeyBacktab && mode == viewNotes { + cycleScope(false, true) + draw(time.Now()) + continue + } + if tev.Key() != tcell.KeyRune { continue } @@ -1139,9 +1245,9 @@ func runUI(args []string) error { case 'n': if mode == viewNotes { if shift { - cycleScope(false) + cycleScope(false, false) } else { - cycleScope(true) + cycleScope(true, false) } draw(time.Now()) } @@ -1183,8 +1289,6 @@ func runUI(args []string) error { case 'c': if shift { switch mode { - case viewTracker: - showCompletedTasks = !showCompletedTasks case viewNotes: showCompletedNotes = !showCompletedNotes case viewArchive: @@ -1480,6 +1584,46 @@ func writeStyledLine(s tcell.Screen, x, y int, text string, style tcell.Style) { } } +func writeStyledSegments(s tcell.Screen, y int, segments ...struct { + text string + style tcell.Style +}) { + x := 0 + width, _ := s.Size() + for _, seg := range segments { + runes := []rune(seg.text) + for _, r := range runes { + if x >= width { + return + } + s.SetContent(x, y, r, nil, seg.style) + x++ + } + } +} + +func writeStyledSegmentsPad(s tcell.Screen, y int, segments []struct { + text string + style tcell.Style +}, fill tcell.Style) { + x := 0 + width, _ := s.Size() + for _, seg := range segments { + runes := []rune(seg.text) + for _, r := range runes { + if x >= width { + return + } + s.SetContent(x, y, r, nil, seg.style) + x++ + } + } + for x < width { + s.SetContent(x, y, ' ', nil, fill) + x++ + } +} + func socketPath() string { if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" { return filepath.Join(dir, "agent-tracker.sock") diff --git a/agent-tracker/cmd/tracker-server/main.go b/agent-tracker/cmd/tracker-server/main.go index e072a74..63207ab 100644 --- a/agent-tracker/cmd/tracker-server/main.go +++ b/agent-tracker/cmd/tracker-server/main.go @@ -38,7 +38,6 @@ const ( ) const ( - scopePane = "pane" scopeWindow = "window" scopeSession = "session" scopeAll = "all" @@ -58,6 +57,7 @@ type taskRecord struct { type noteRecord struct { ID string + Scope string SessionID string Session string WindowID string @@ -71,6 +71,22 @@ type noteRecord struct { ArchivedAt *time.Time } +type storedNote struct { + ID string `json:"id"` + Scope string `json:"scope"` + SessionID string `json:"session_id"` + Session string `json:"session"` + WindowID string `json:"window_id"` + Window string `json:"window"` + PaneID string `json:"pane_id"` + Summary string `json:"summary"` + Completed bool `json:"completed"` + Archived bool `json:"archived"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ArchivedAt *time.Time `json:"archived_at,omitempty"` +} + type tmuxTarget struct { SessionName string SessionID string @@ -408,7 +424,7 @@ func (s *server) deleteTask(sessionID, windowID, paneID string) error { func normalizeScope(scope string) string { scope = strings.TrimSpace(strings.ToLower(scope)) switch scope { - case scopePane, scopeWindow, scopeSession, scopeAll: + case scopeWindow, scopeSession, scopeAll: return scope default: return scopeWindow @@ -423,18 +439,12 @@ func (s *server) addNote(target tmuxTarget, scope, summary string) error { scope = normalizeScope(scope) target = normalizeNoteTargetNames(target) switch scope { - case scopePane: - if target.SessionID == "" || target.WindowID == "" || target.PaneID == "" { - return fmt.Errorf("pane notes require session, window, and pane identifiers") - } case scopeWindow: if target.SessionID == "" || target.WindowID == "" { return fmt.Errorf("window notes require session and window identifiers") } case scopeSession, scopeAll: - if target.SessionID == "" { - return fmt.Errorf("session notes require a session identifier") - } + // allow global (scopeAll) notes to omit session/window/pane } now := time.Now() @@ -442,6 +452,7 @@ func (s *server) addNote(target tmuxTarget, scope, summary string) error { defer s.mu.Unlock() n := ¬eRecord{ ID: s.newNoteIDLocked(now), + Scope: scope, SessionID: target.SessionID, Session: target.SessionName, WindowID: target.WindowID, @@ -559,6 +570,7 @@ func (s *server) attachArchivedNote(id string, target tmuxTarget) error { n.WindowID = target.WindowID n.Window = target.WindowName n.PaneID = target.PaneID + n.Scope = scopeWindow n.Archived = false n.ArchivedAt = nil n.UpdatedAt = now @@ -569,21 +581,6 @@ func (s *server) saveNotesLocked() error { if err := os.MkdirAll(filepath.Dir(s.notesPath), 0o755); err != nil { return err } - type storedNote struct { - ID string `json:"id"` - SessionID string `json:"session_id"` - Session string `json:"session"` - WindowID string `json:"window_id"` - Window string `json:"window"` - PaneID string `json:"pane_id"` - Summary string `json:"summary"` - Completed bool `json:"completed"` - Archived bool `json:"archived"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - ArchivedAt *time.Time `json:"archived_at,omitempty"` - } - records := make([]storedNote, 0, len(s.notes)) for _, n := range s.notes { records = append(records, storedNote(*n)) @@ -610,28 +607,25 @@ func (s *server) loadNotes() error { } return err } - type storedNote struct { - ID string `json:"id"` - SessionID string `json:"session_id"` - Session string `json:"session"` - WindowID string `json:"window_id"` - Window string `json:"window"` - PaneID string `json:"pane_id"` - Summary string `json:"summary"` - Completed bool `json:"completed"` - Archived bool `json:"archived"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - ArchivedAt *time.Time `json:"archived_at,omitempty"` - } var records []storedNote if err := json.Unmarshal(data, &records); err != nil { return err } for _, rec := range records { n := rec + if strings.TrimSpace(n.Scope) == "" { + switch { + case n.WindowID != "": + n.Scope = scopeWindow + case n.SessionID != "": + n.Scope = scopeSession + default: + n.Scope = scopeAll + } + } s.notes[n.ID] = ¬eRecord{ ID: n.ID, + Scope: n.Scope, SessionID: n.SessionID, Session: n.Session, WindowID: n.WindowID, @@ -677,6 +671,7 @@ func (s *server) notesForState() ([]ipc.Note, []ipc.Note) { for _, n := range records { copy := ipc.Note{ ID: n.ID, + Scope: n.Scope, SessionID: n.SessionID, Session: n.Session, WindowID: n.WindowID, diff --git a/agent-tracker/internal/ipc/envelope.go b/agent-tracker/internal/ipc/envelope.go index 8d78a6f..336a576 100644 --- a/agent-tracker/internal/ipc/envelope.go +++ b/agent-tracker/internal/ipc/envelope.go @@ -37,6 +37,7 @@ type Task struct { type Note struct { ID string `json:"id"` + Scope string `json:"scope,omitempty"` SessionID string `json:"session_id"` Session string `json:"session"` WindowID string `json:"window_id"`