From 94422944e6d1e37508bcdbde48f1927e33792bd4 Mon Sep 17 00:00:00 2001 From: David Chen Date: Thu, 4 Dec 2025 14:21:38 -0800 Subject: [PATCH] agent tracker ui fix --- agent-tracker/cmd/tracker-client/main.go | 266 ++++++++++++++++++----- agent-tracker/cmd/tracker-server/main.go | 32 ++- agent-tracker/internal/ipc/envelope.go | 1 - 3 files changed, 227 insertions(+), 72 deletions(-) diff --git a/agent-tracker/cmd/tracker-client/main.go b/agent-tracker/cmd/tracker-client/main.go index 265c354..7c66662 100644 --- a/agent-tracker/cmd/tracker-client/main.go +++ b/agent-tracker/cmd/tracker-client/main.go @@ -17,6 +17,7 @@ import ( "sync" "time" "unicode" + "unicode/utf8" "github.com/david/agent-tracker/internal/ipc" "github.com/gdamore/tcell/v2" @@ -599,14 +600,6 @@ func runUI(args []string) error { sortNotes := func(notes []ipc.Note) { sort.SliceStable(notes, func(i, j int) bool { - iu, hasIU := parseTimestamp(notes[i].UpdatedAt) - ju, hasJU := parseTimestamp(notes[j].UpdatedAt) - if hasIU && hasJU && !iu.Equal(ju) { - return iu.After(ju) - } - if hasIU != hasJU { - return hasIU - } ic, hasIC := parseTimestamp(notes[i].CreatedAt) jc, hasJC := parseTimestamp(notes[j].CreatedAt) if hasIC && hasJC && !ic.Equal(jc) { @@ -633,6 +626,10 @@ func runUI(args []string) error { }) } + textWidth := func(s string) int { + return utf8.RuneCountInString(s) + } + getVisibleTasks := func() []ipc.Task { result := make([]ipc.Task, 0, len(st.tasks)) @@ -666,11 +663,8 @@ func runUI(args []string) error { getVisibleGoals := func() []ipc.Goal { result := make([]ipc.Goal, 0, len(st.goals)) - currentSession := strings.TrimSpace(currentCtx.SessionID) for _, g := range st.goals { - if strings.TrimSpace(g.SessionID) == currentSession { - result = append(result, g) - } + result = append(result, g) } sortGoals(result) return result @@ -736,6 +730,16 @@ func runUI(args []string) error { }) } + focusGoal := func(g ipc.Goal) error { + if strings.TrimSpace(g.SessionID) == "" { + return fmt.Errorf("session required to focus goal") + } + return sendCommand("goal_focus", func(env *ipc.Envelope) { + env.SessionID = g.SessionID + env.Session = g.Session + }) + } + addNote := func(text string) error { text = strings.TrimSpace(text) if text == "" { @@ -945,6 +949,11 @@ func runUI(args []string) error { prompt.text = prompt.text[:0] prompt.cursor = 0 return true, nil + case tcell.KeyTab: + if prompt.mode == promptAddNote { + cycleScope(true, true) + } + return true, nil case tcell.KeyRune: r := tev.Rune() prompt.text = append(prompt.text[:prompt.cursor], append([]rune{r}, prompt.text[prompt.cursor:]...)...) @@ -983,28 +992,7 @@ func runUI(args []string) error { } writeStyledLine(screen, 0, 0, truncate(fmt.Sprintf("▌ %s", title), width), headerStyle) - 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) - } + writeStyledLine(screen, 0, 1, truncate(subtitle, width), subtleStyle) if width > 0 { writeStyledLine(screen, 0, 2, strings.Repeat("─", width), infoStyle) } @@ -1157,12 +1145,35 @@ func runUI(args []string) error { } renderGoals := func(list []ipc.Goal, state *listState, focused bool, startRow int) int { - row := startRow - header := "Goals (session)" - if strings.TrimSpace(currentCtx.SessionName) != "" { - header = fmt.Sprintf("Goals · %s", currentCtx.SessionName) + gutterText := " " + gutterStyle := infoStyle + if focused { + gutterText = "│ " + gutterStyle = tcell.StyleDefault.Foreground(tcell.ColorLightCyan) } - writeStyledLine(screen, 0, row, truncate(header, width), infoStyle) + row := startRow + header := "Goals (all sessions)" + if strings.TrimSpace(currentCtx.SessionName) != "" { + header = fmt.Sprintf("Goals (all) · current: %s", currentCtx.SessionName) + } + headerStyle := infoStyle + if focused { + headerStyle = headerStyle.Bold(true) + } + contentWidth := width - textWidth(gutterText) + if contentWidth < 0 { + contentWidth = 0 + } + writeStyledSegments(screen, row, + struct { + text string + style tcell.Style + }{text: gutterText, style: gutterStyle}, + struct { + text string + style tcell.Style + }{text: truncate(header, contentWidth), style: headerStyle}, + ) row++ availableRows := height - row if availableRows < 0 { @@ -1171,7 +1182,16 @@ func runUI(args []string) error { clampList(state, len(list), 3, availableRows) if len(list) == 0 { if row < height { - writeStyledLine(screen, 0, row, truncate("No goals for this session.", width), subtleStyle) + writeStyledSegments(screen, row, + struct { + text string + style tcell.Style + }{text: gutterText, style: gutterStyle}, + struct { + text string + style tcell.Style + }{text: truncate("No goals for this session.", contentWidth), style: subtleStyle}, + ) row++ } return row @@ -1191,10 +1211,15 @@ func runUI(args []string) error { metaStyle = tcell.StyleDefault.Foreground(tcell.ColorDarkGray) timeStyle = tcell.StyleDefault.Foreground(tcell.ColorDarkGray) } + if strings.TrimSpace(g.SessionID) == strings.TrimSpace(currentCtx.SessionID) && strings.TrimSpace(currentCtx.SessionID) != "" { + metaStyle = tcell.StyleDefault.Foreground(tcell.ColorLightGreen) + } + itemGutterStyle := gutterStyle if focused && idx == state.selected { baseStyle = baseStyle.Background(tcell.ColorDarkSlateGray) metaStyle = metaStyle.Background(tcell.ColorDarkSlateGray) timeStyle = timeStyle.Background(tcell.ColorDarkSlateGray) + itemGutterStyle = itemGutterStyle.Background(tcell.ColorDarkSlateGray) } created := "" @@ -1206,7 +1231,7 @@ func runUI(args []string) error { if summary == "" { summary = "(no summary)" } - avail := width - len(indicator) - 1 - len(created) + avail := contentWidth - textWidth(indicator) - 1 - textWidth(created) if avail < 5 { avail = 5 } @@ -1215,11 +1240,12 @@ func runUI(args []string) error { text string style tcell.Style }{ + {text: gutterText, style: itemGutterStyle}, {text: indicator + " ", style: baseStyle}, {text: lineText, style: baseStyle}, } - used := len(indicator) + 1 + len([]rune(lineText)) - padding := width - used - len(created) + used := textWidth(indicator) + 1 + textWidth(lineText) + padding := contentWidth - used - textWidth(created) if padding > 0 { segs = append(segs, struct { text string @@ -1243,7 +1269,22 @@ func runUI(args []string) error { } meta := fmt.Sprintf(" └ %s", g.Session) - writeStyledLine(screen, 0, row, truncate(meta, width), metaStyle) + metaDisplay := truncate(meta, contentWidth) + metaPadding := contentWidth - textWidth(metaDisplay) + writeStyledSegments(screen, row, + struct { + text string + style tcell.Style + }{text: gutterText, style: itemGutterStyle}, + struct { + text string + style tcell.Style + }{text: metaDisplay, style: metaStyle}, + struct { + text string + style tcell.Style + }{text: strings.Repeat(" ", metaPadding), style: metaStyle}, + ) row++ if row >= height { break @@ -1253,7 +1294,16 @@ func runUI(args []string) error { if focused && idx == state.selected { spacerStyle = spacerStyle.Background(tcell.ColorDarkSlateGray) } - writeStyledLine(screen, 0, row, strings.Repeat(" ", width), spacerStyle) + writeStyledSegments(screen, row, + struct { + text string + style tcell.Style + }{text: gutterText, style: itemGutterStyle}, + struct { + text string + style tcell.Style + }{text: strings.Repeat(" ", contentWidth), style: spacerStyle}, + ) row++ } return row @@ -1266,6 +1316,16 @@ func runUI(args []string) error { } clampList(state, len(list), 3, availableRows) row := startRow + gutterText := " " + gutterStyle := infoStyle + if focused { + gutterText = "│ " + gutterStyle = tcell.StyleDefault.Foreground(tcell.ColorLightCyan) + } + contentWidth := width - textWidth(gutterText) + if contentWidth < 0 { + contentWidth = 0 + } for idx := state.offset; idx < len(list); idx++ { if row >= height { break @@ -1318,7 +1378,7 @@ func runUI(args []string) error { } // --- Line 1 Rendering --- - availWidth1 := width - len(tagText) - len(tsStr) - 2 + availWidth1 := contentWidth - len(tagText) - len(tsStr) - 2 if availWidth1 < 5 { availWidth1 = 5 } @@ -1355,12 +1415,13 @@ func runUI(args []string) error { text string style tcell.Style }{ + {text: gutterText, style: gutterStyle}, {text: tagText, style: scopeSt}, {text: line1Text, style: textStyle}, } usedLen := len(tagText) + len([]rune(line1Text)) - padding := width - usedLen - len(tsStr) + padding := contentWidth - usedLen - len(tsStr) if padding > 0 { segs = append(segs, struct { text string @@ -1387,7 +1448,7 @@ func runUI(args []string) error { // --- Wrapped Lines Rendering --- if remText != "" { indent := len(tagText) - availWidthN := width - indent + availWidthN := contentWidth - indent if availWidthN < 5 { availWidthN = 5 } @@ -1398,16 +1459,17 @@ func runUI(args []string) error { text string style tcell.Style }{ + {text: gutterText, style: gutterStyle}, {text: strings.Repeat(" ", indent), style: scopeSt}, {text: wLine, style: textStyle}, } used := indent + len([]rune(wLine)) - if width > used { + if contentWidth > used { segs = append(segs, struct { text string style tcell.Style }{ - text: strings.Repeat(" ", width-used), + text: strings.Repeat(" ", contentWidth-used), style: textStyle, }) } @@ -1430,16 +1492,17 @@ func runUI(args []string) error { text string style tcell.Style }{ + {text: gutterText, style: gutterStyle}, {text: prefix, style: metaStyle}, - {text: truncate(metaText, width-len(prefix)), style: metaStyle}, + {text: truncate(metaText, contentWidth-len(prefix)), style: metaStyle}, } - metaUsed := len(prefix) + len([]rune(truncate(metaText, width-len(prefix)))) - if width > metaUsed { + metaUsed := len(prefix) + len([]rune(truncate(metaText, contentWidth-len(prefix)))) + if contentWidth > metaUsed { metaSegs = append(metaSegs, struct { text string style tcell.Style }{ - text: strings.Repeat(" ", width-metaUsed), + text: strings.Repeat(" ", contentWidth-metaUsed), style: metaStyle, }) } @@ -1455,7 +1518,16 @@ func runUI(args []string) error { if focused && idx == state.selected { spacerStyle = spacerStyle.Background(tcell.ColorDarkSlateGray) } - writeStyledLine(screen, 0, row, strings.Repeat(" ", width), spacerStyle) + writeStyledSegments(screen, row, + struct { + text string + style tcell.Style + }{text: gutterText, style: gutterStyle}, + struct { + text string + style tcell.Style + }{text: strings.Repeat(" ", contentWidth), style: spacerStyle}, + ) row++ } return row @@ -1493,11 +1565,79 @@ func runUI(args []string) error { rowStart := 3 rowStart = renderGoals(goals, &goalList, focusGoals, rowStart) if rowStart < height { - writeStyledLine(screen, 0, rowStart, truncate(strings.Repeat("─", width), width), infoStyle) + notesHeader := "Notes" + notesStyle := infoStyle + gutterText := " " + gutterStyle := infoStyle + scopeLabel := "Window" + switch scope { + case scopeSession: + scopeLabel = "Session" + case scopeAll: + scopeLabel = "Global" + } + scopeText := fmt.Sprintf("[%s]", scopeLabel) + scopeLabelStyle := scopeStyle(scope) + if !focusGoals { + notesStyle = notesStyle.Bold(true) + gutterText = "│ " + gutterStyle = tcell.StyleDefault.Foreground(tcell.ColorLightCyan) + } + contentWidth := width - textWidth(gutterText) + if contentWidth < 0 { + contentWidth = 0 + } + spaceWidth := 1 + combinedWidth := textWidth(notesHeader) + spaceWidth + textWidth(scopeText) + if combinedWidth > contentWidth { + if contentWidth > textWidth(notesHeader)+spaceWidth { + scopeText = truncate(scopeText, contentWidth-(textWidth(notesHeader)+spaceWidth)) + } else { + scopeText = "" + spaceWidth = 0 + } + } + writeStyledSegments(screen, rowStart, + struct { + text string + style tcell.Style + }{text: gutterText, style: gutterStyle}, + struct { + text string + style tcell.Style + }{text: truncate(notesHeader, contentWidth), style: notesStyle}, + struct { + text string + style tcell.Style + }{text: strings.Repeat(" ", spaceWidth), style: notesStyle}, + struct { + text string + style tcell.Style + }{text: scopeText, style: scopeLabelStyle}, + ) rowStart++ } if len(notes) == 0 && height > rowStart { - writeStyledLine(screen, 0, rowStart, truncate("No notes in this scope.", width), infoStyle) + gutterText := " " + gutterStyle := infoStyle + if !focusGoals { + gutterText = "│ " + gutterStyle = tcell.StyleDefault.Foreground(tcell.ColorLightCyan) + } + contentWidth := width - textWidth(gutterText) + if contentWidth < 0 { + contentWidth = 0 + } + writeStyledSegments(screen, rowStart, + struct { + text string + style tcell.Style + }{text: gutterText, style: gutterStyle}, + struct { + text string + style tcell.Style + }{text: truncate("No notes in this scope.", contentWidth), style: infoStyle}, + ) } else { renderNotes(notes, ¬eList, false, rowStart, !focusGoals) } @@ -1531,6 +1671,8 @@ func runUI(args []string) error { label = "Edit note: " } else if prompt.mode == promptAddGoal { label = "Add goal: " + } else if prompt.mode == promptAddNote { + label = fmt.Sprintf("Add note (%s): ", scopeTag(scope)) } line := label + string(prompt.text) writeStyledLine(screen, 0, height-1, truncate(line, width), tcell.StyleDefault.Foreground(tcell.ColorLightGreen)) @@ -1642,14 +1784,20 @@ func runUI(args []string) error { if focusGoals { goals := getVisibleGoals() if len(goals) > 0 && goalList.selected < len(goals) { - if err := toggleGoal(goals[goalList.selected].ID); err != nil { + if err := focusGoal(goals[goalList.selected]); err != nil { st.message = err.Error() } } } else { notes := getVisibleNotes() if len(notes) > 0 && noteList.selected < len(notes) { - if err := toggleNote(notes[noteList.selected].ID); err != nil { + if err := focusTask(ipc.Task{ + Session: notes[noteList.selected].Session, + SessionID: notes[noteList.selected].SessionID, + Window: notes[noteList.selected].Window, + WindowID: notes[noteList.selected].WindowID, + Pane: notes[noteList.selected].Pane, + }); err != nil { st.message = err.Error() } } diff --git a/agent-tracker/cmd/tracker-server/main.go b/agent-tracker/cmd/tracker-server/main.go index 61e6463..f449beb 100644 --- a/agent-tracker/cmd/tracker-server/main.go +++ b/agent-tracker/cmd/tracker-server/main.go @@ -67,7 +67,6 @@ type noteRecord struct { Completed bool Archived bool CreatedAt time.Time - UpdatedAt time.Time ArchivedAt *time.Time } @@ -93,7 +92,6 @@ type storedNote struct { 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"` } @@ -416,6 +414,11 @@ func (s *server) handleCommand(env ipc.Envelope) error { s.broadcastStateAsync() s.statusRefreshAsync() return nil + case "goal_focus": + if err := s.focusGoal(env.Client, strings.TrimSpace(env.SessionID)); err != nil { + return err + } + return nil default: return fmt.Errorf("unknown command %q", env.Command) } @@ -514,7 +517,6 @@ func (s *server) addNote(target tmuxTarget, scope, summary string) error { PaneID: target.PaneID, Summary: summary, CreatedAt: now, - UpdatedAt: now, } s.notes[n.ID] = n return s.saveNotesLocked() @@ -536,8 +538,6 @@ func (s *server) editNote(id, scope, summary string) error { if scope != "" { n.Scope = scope } - now := time.Now() - n.UpdatedAt = now return s.saveNotesLocked() } @@ -549,7 +549,6 @@ func (s *server) toggleNoteCompletion(id string) error { return fmt.Errorf("note not found") } n.Completed = !n.Completed - n.UpdatedAt = time.Now() return s.saveNotesLocked() } @@ -576,7 +575,6 @@ func (s *server) archiveNote(id string) error { now := time.Now() n.Archived = true n.ArchivedAt = &now - n.UpdatedAt = now return s.saveNotesLocked() } @@ -598,7 +596,6 @@ func (s *server) archiveNotesForPane(sessionID, windowID, paneID string) error { if n.SessionID == sessionID && n.WindowID == windowID && n.PaneID == paneID { n.Archived = true n.ArchivedAt = &now - n.UpdatedAt = now changed = true } } @@ -622,7 +619,6 @@ func (s *server) attachArchivedNote(id string, target tmuxTarget) error { if !n.Archived { return fmt.Errorf("note is not archived") } - now := time.Now() n.SessionID = target.SessionID n.Session = target.SessionName n.WindowID = target.WindowID @@ -631,7 +627,6 @@ func (s *server) attachArchivedNote(id string, target tmuxTarget) error { n.Scope = scopeWindow n.Archived = false n.ArchivedAt = nil - n.UpdatedAt = now return s.saveNotesLocked() } @@ -693,7 +688,6 @@ func (s *server) loadNotes() error { Completed: n.Completed, Archived: n.Archived, CreatedAt: n.CreatedAt, - UpdatedAt: n.UpdatedAt, ArchivedAt: n.ArchivedAt, } } @@ -840,7 +834,6 @@ func (s *server) notesForState() ([]ipc.Note, []ipc.Note) { Completed: n.Completed, Archived: n.Archived, CreatedAt: n.CreatedAt.Format(time.RFC3339), - UpdatedAt: n.UpdatedAt.Format(time.RFC3339), } if n.ArchivedAt != nil { copy.ArchivedAt = n.ArchivedAt.Format(time.RFC3339) @@ -900,6 +893,21 @@ func (s *server) focusTask(client string, target tmuxTarget) error { return s.hide() } +func (s *server) focusGoal(client string, sessionID string) error { + client = strings.TrimSpace(client) + if client == "" { + return fmt.Errorf("client required for goal_focus") + } + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return fmt.Errorf("session id required for goal_focus") + } + if err := runTmux("switch-client", "-c", client, "-t", sessionID); err != nil { + return err + } + return s.hide() +} + func (s *server) toggle() error { s.mu.Lock() visible := s.visible diff --git a/agent-tracker/internal/ipc/envelope.go b/agent-tracker/internal/ipc/envelope.go index 106c0bf..c44401b 100644 --- a/agent-tracker/internal/ipc/envelope.go +++ b/agent-tracker/internal/ipc/envelope.go @@ -49,7 +49,6 @@ type Note struct { Completed bool `json:"completed"` Archived bool `json:"archived"` CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` ArchivedAt string `json:"archived_at,omitempty"` }