agent tracker ui fix

This commit is contained in:
David Chen 2025-12-04 14:21:38 -08:00
parent 4ee7399b30
commit 94422944e6
3 changed files with 227 additions and 72 deletions

View file

@ -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, &noteList, 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()
}
}

View file

@ -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

View file

@ -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"`
}