diff --git a/agent-tracker/cmd/tracker-client/main.go b/agent-tracker/cmd/tracker-client/main.go index 4fca2da..265c354 100644 --- a/agent-tracker/cmd/tracker-client/main.go +++ b/agent-tracker/cmd/tracker-client/main.go @@ -58,6 +58,7 @@ type promptMode string const ( promptAddNote promptMode = "add_note" promptEditNote promptMode = "edit_note" + promptAddGoal promptMode = "add_goal" ) type promptState struct { @@ -390,6 +391,7 @@ func runUI(args []string) error { tasks []ipc.Task notes []ipc.Note archived []ipc.Note + goals []ipc.Goal } st := state{message: "Connecting to tracker…"} @@ -461,10 +463,12 @@ func runUI(args []string) error { taskList := listState{} noteList := listState{} archiveList := listState{} + goalList := listState{} keepTasksVisible := make(map[string]bool) keepNotesVisible := make(map[string]bool) prompt := promptState{} helpVisible := false + focusGoals := false var editNote *ipc.Note cycleScope := func(forward bool, wrap bool) { @@ -595,6 +599,14 @@ 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) { @@ -607,7 +619,22 @@ func runUI(args []string) error { }) } + sortGoals := func(goals []ipc.Goal) { + sort.SliceStable(goals, func(i, j int) bool { + ci, hasCi := parseTimestamp(goals[i].CreatedAt) + cj, hasCj := parseTimestamp(goals[j].CreatedAt) + if hasCi && hasCj && !ci.Equal(cj) { + return ci.After(cj) + } + if hasCi != hasCj { + return hasCi + } + return goals[i].Summary < goals[j].Summary + }) + } + getVisibleTasks := func() []ipc.Task { + result := make([]ipc.Task, 0, len(st.tasks)) for _, t := range st.tasks { key := fmt.Sprintf("%s|%s|%s", strings.TrimSpace(t.SessionID), strings.TrimSpace(t.WindowID), strings.TrimSpace(t.Pane)) @@ -637,6 +664,18 @@ func runUI(args []string) error { return result } + 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) + } + } + sortGoals(result) + return result + } + getArchivedNotes := func() []ipc.Note { result := make([]ipc.Note, 0, len(st.archived)) for _, n := range st.archived { @@ -662,6 +701,41 @@ func runUI(args []string) error { } } + addGoal := func(text string) error { + text = strings.TrimSpace(text) + if text == "" { + return fmt.Errorf("goal text required") + } + refreshCtx() + ctx := currentCtx + if strings.TrimSpace(ctx.SessionID) == "" { + return fmt.Errorf("session required to add goal") + } + return sendCommand("goal_add", func(env *ipc.Envelope) { + env.Summary = text + env.Session = ctx.SessionName + env.SessionID = ctx.SessionID + }) + } + + toggleGoal := func(id string) error { + if strings.TrimSpace(id) == "" { + return fmt.Errorf("goal id required") + } + return sendCommand("goal_toggle_complete", func(env *ipc.Envelope) { + env.GoalID = id + }) + } + + deleteGoal := func(id string) error { + if strings.TrimSpace(id) == "" { + return fmt.Errorf("goal id required") + } + return sendCommand("goal_delete", func(env *ipc.Envelope) { + env.GoalID = id + }) + } + addNote := func(text string) error { text = strings.TrimSpace(text) if text == "" { @@ -792,6 +866,10 @@ func runUI(args []string) error { prompt = promptState{active: true, mode: promptAddNote, text: []rune{}, cursor: 0} } + startGoalPrompt := func() { + prompt = promptState{active: true, mode: promptAddGoal, text: []rune{}, cursor: 0} + } + startEditPrompt := func(n ipc.Note) { copy := n editNote = © @@ -813,6 +891,8 @@ func runUI(args []string) error { err = addNote(text) case promptEditNote: err = updateNote(prompt.noteID, text, "") + case promptAddGoal: + err = addGoal(text) } prompt.active = false if prompt.mode == promptEditNote { @@ -1076,9 +1156,116 @@ func runUI(args []string) error { return lines } - renderNotes := func(list []ipc.Note, state *listState, archived bool) { - clampList(state, len(list), 3, visibleRows) - row := 3 + 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) + } + writeStyledLine(screen, 0, row, truncate(header, width), infoStyle) + row++ + availableRows := height - row + if availableRows < 0 { + availableRows = 0 + } + 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) + row++ + } + return row + } + for idx := state.offset; idx < len(list); idx++ { + if row >= height { + break + } + g := list[idx] + indicator := "•" + baseStyle := tcell.StyleDefault.Foreground(tcell.ColorWhite) + metaStyle := tcell.StyleDefault.Foreground(tcell.ColorLightSlateGray) + timeStyle := tcell.StyleDefault.Foreground(tcell.ColorDarkCyan) + if g.Completed { + indicator = "✓" + baseStyle = tcell.StyleDefault.Foreground(tcell.ColorDarkGray) + metaStyle = tcell.StyleDefault.Foreground(tcell.ColorDarkGray) + timeStyle = tcell.StyleDefault.Foreground(tcell.ColorDarkGray) + } + if focused && idx == state.selected { + baseStyle = baseStyle.Background(tcell.ColorDarkSlateGray) + metaStyle = metaStyle.Background(tcell.ColorDarkSlateGray) + timeStyle = timeStyle.Background(tcell.ColorDarkSlateGray) + } + + created := "" + if ts, ok := parseTimestamp(g.CreatedAt); ok { + created = ts.Format("15:04") + } + + summary := g.Summary + if summary == "" { + summary = "(no summary)" + } + avail := width - len(indicator) - 1 - len(created) + if avail < 5 { + avail = 5 + } + lineText := truncate(summary, avail) + segs := []struct { + text string + style tcell.Style + }{ + {text: indicator + " ", style: baseStyle}, + {text: lineText, style: baseStyle}, + } + used := len(indicator) + 1 + len([]rune(lineText)) + padding := width - used - len(created) + if padding > 0 { + segs = append(segs, struct { + text string + style tcell.Style + }{ + text: strings.Repeat(" ", padding), + style: baseStyle, + }) + } + segs = append(segs, struct { + text string + style tcell.Style + }{ + text: created, + style: timeStyle, + }) + writeStyledSegments(screen, row, segs...) + row++ + if row >= height { + break + } + + meta := fmt.Sprintf(" └ %s", g.Session) + writeStyledLine(screen, 0, row, truncate(meta, width), metaStyle) + row++ + if row >= height { + break + } + + spacerStyle := tcell.StyleDefault + if focused && idx == state.selected { + spacerStyle = spacerStyle.Background(tcell.ColorDarkSlateGray) + } + writeStyledLine(screen, 0, row, strings.Repeat(" ", width), spacerStyle) + row++ + } + return row + } + + renderNotes := func(list []ipc.Note, state *listState, archived bool, startRow int, focused bool) int { + availableRows := height - startRow + if availableRows < 0 { + availableRows = 0 + } + clampList(state, len(list), 3, availableRows) + row := startRow for idx := state.offset; idx < len(list); idx++ { if row >= height { break @@ -1099,7 +1286,7 @@ func runUI(args []string) error { timeStyle = tcell.StyleDefault.Foreground(tcell.ColorDarkGray) } - if idx == state.selected { + if focused && idx == state.selected { textStyle = textStyle.Background(tcell.ColorDarkSlateGray) scopeSt = scopeSt.Background(tcell.ColorDarkSlateGray) metaStyle = metaStyle.Background(tcell.ColorDarkSlateGray) @@ -1265,19 +1452,20 @@ func runUI(args []string) error { // --- Spacer Rendering --- spacerStyle := tcell.StyleDefault - if idx == state.selected { + if focused && idx == state.selected { spacerStyle = spacerStyle.Background(tcell.ColorDarkSlateGray) } writeStyledLine(screen, 0, row, strings.Repeat(" ", width), spacerStyle) row++ } + return row } if helpVisible { helpLines := []string{ - "t: toggle Tracker/Notes | n/i: view scope | s/S: note scope | Alt-A: archive view", - "a/k: add note | p: focus/edit | Enter/c: complete | Shift-A: archive note", - "Shift-D: delete | Shift-C: show/hide completed | Esc: close | ?: toggle help", + "t: toggle Tracker/Notes | Tab: focus goals/notes | n/i: view scope | Alt-A: archive view", + "Goals: a add | Enter/c: complete | Shift-D: delete (focus goals first)", + "Notes: a add | k edit | Enter/c: complete | Shift-A: archive | Shift-D: delete | Shift-C: show/hide completed | Esc: close | ?: toggle help", } row := 3 for _, line := range helpLines { @@ -1300,18 +1488,25 @@ func runUI(args []string) error { renderTasks(list, &taskList) } case viewNotes: - list := getVisibleNotes() - if len(list) == 0 && height > 3 { - writeStyledLine(screen, 0, 3, truncate("No notes in this scope.", width), infoStyle) + goals := getVisibleGoals() + notes := getVisibleNotes() + rowStart := 3 + rowStart = renderGoals(goals, &goalList, focusGoals, rowStart) + if rowStart < height { + writeStyledLine(screen, 0, rowStart, truncate(strings.Repeat("─", width), width), infoStyle) + rowStart++ + } + if len(notes) == 0 && height > rowStart { + writeStyledLine(screen, 0, rowStart, truncate("No notes in this scope.", width), infoStyle) } else { - renderNotes(list, ¬eList, false) + renderNotes(notes, ¬eList, false, rowStart, !focusGoals) } case viewArchive: list := getArchivedNotes() if len(list) == 0 && height > 3 { writeStyledLine(screen, 0, 3, truncate("Archive is empty.", width), infoStyle) } else { - renderNotes(list, &archiveList, true) + renderNotes(list, &archiveList, true, 3, true) } case viewEdit: bodyStyle := tcell.StyleDefault.Foreground(tcell.ColorLightGreen) @@ -1334,6 +1529,8 @@ func runUI(args []string) error { label := "Add note: " if prompt.mode == promptEditNote { label = "Edit note: " + } else if prompt.mode == promptAddGoal { + label = "Add goal: " } line := label + string(prompt.text) writeStyledLine(screen, 0, height-1, truncate(line, width), tcell.StyleDefault.Foreground(tcell.ColorLightGreen)) @@ -1365,6 +1562,8 @@ func runUI(args []string) error { copy(st.notes, env.Notes) st.archived = make([]ipc.Note, len(env.Archived)) copy(st.archived, env.Archived) + st.goals = make([]ipc.Goal, len(env.Goals)) + copy(st.goals, env.Goals) refreshCtx() draw(time.Now()) case "ack": @@ -1440,10 +1639,19 @@ func runUI(args []string) error { } } case viewNotes: - notes := getVisibleNotes() - if len(notes) > 0 && noteList.selected < len(notes) { - if err := toggleNote(notes[noteList.selected].ID); err != nil { - st.message = err.Error() + if focusGoals { + goals := getVisibleGoals() + if len(goals) > 0 && goalList.selected < len(goals) { + if err := toggleGoal(goals[goalList.selected].ID); 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 { + st.message = err.Error() + } } } case viewArchive: @@ -1459,12 +1667,7 @@ func runUI(args []string) error { } if tev.Key() == tcell.KeyTab && mode == viewNotes { - cycleScope(true, true) - draw(time.Now()) - continue - } - if tev.Key() == tcell.KeyBacktab && mode == viewNotes { - cycleScope(false, true) + focusGoals = !focusGoals draw(time.Now()) continue } @@ -1512,8 +1715,14 @@ func runUI(args []string) error { taskList.selected-- } case viewNotes: - if noteList.selected > 0 { - noteList.selected-- + if focusGoals { + if goalList.selected > 0 { + goalList.selected-- + } + } else { + if noteList.selected > 0 { + noteList.selected-- + } } case viewArchive: if archiveList.selected > 0 { @@ -1529,9 +1738,16 @@ func runUI(args []string) error { taskList.selected++ } case viewNotes: - notes := getVisibleNotes() - if noteList.selected < len(notes)-1 { - noteList.selected++ + if focusGoals { + goals := getVisibleGoals() + if goalList.selected < len(goals)-1 { + goalList.selected++ + } + } else { + notes := getVisibleNotes() + if noteList.selected < len(notes)-1 { + noteList.selected++ + } } case viewArchive: notes := getArchivedNotes() @@ -1560,10 +1776,19 @@ func runUI(args []string) error { } } case viewNotes: - notes := getVisibleNotes() - if len(notes) > 0 && noteList.selected < len(notes) { - if err := toggleNote(notes[noteList.selected].ID); err != nil { - st.message = err.Error() + if focusGoals { + goals := getVisibleGoals() + if len(goals) > 0 && goalList.selected < len(goals) { + if err := toggleGoal(goals[goalList.selected].ID); 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 { + st.message = err.Error() + } } } case viewArchive: @@ -1600,7 +1825,7 @@ func runUI(args []string) error { draw(time.Now()) } case 'a': - if shift && mode == viewNotes { + if shift && mode == viewNotes && !focusGoals { notes := getVisibleNotes() if len(notes) > 0 && noteList.selected < len(notes) { if err := archiveNote(notes[noteList.selected].ID); err != nil { @@ -1611,7 +1836,11 @@ func runUI(args []string) error { break } if mode == viewNotes { - startAddPrompt() + if focusGoals { + startGoalPrompt() + } else { + startAddPrompt() + } draw(time.Now()) } case 'k': @@ -1634,10 +1863,19 @@ func runUI(args []string) error { } } case viewNotes: - notes := getVisibleNotes() - if len(notes) > 0 && noteList.selected < len(notes) { - if err := deleteNote(notes[noteList.selected].ID); err != nil { - st.message = err.Error() + if focusGoals { + goals := getVisibleGoals() + if len(goals) > 0 && goalList.selected < len(goals) { + if err := deleteGoal(goals[goalList.selected].ID); err != nil { + st.message = err.Error() + } + } + } else { + notes := getVisibleNotes() + if len(notes) > 0 && noteList.selected < len(notes) { + if err := deleteNote(notes[noteList.selected].ID); err != nil { + st.message = err.Error() + } } } case viewArchive: diff --git a/agent-tracker/cmd/tracker-server/main.go b/agent-tracker/cmd/tracker-server/main.go index 5593e44..61e6463 100644 --- a/agent-tracker/cmd/tracker-server/main.go +++ b/agent-tracker/cmd/tracker-server/main.go @@ -71,6 +71,16 @@ type noteRecord struct { ArchivedAt *time.Time } +type goalRecord struct { + ID string + SessionID string + Session string + Summary string + Completed bool + CreatedAt time.Time + UpdatedAt time.Time +} + type storedNote struct { ID string `json:"id"` Scope string `json:"scope"` @@ -87,6 +97,16 @@ type storedNote struct { ArchivedAt *time.Time `json:"archived_at,omitempty"` } +type storedGoal struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Session string `json:"session"` + Summary string `json:"summary"` + Completed bool `json:"completed"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type tmuxTarget struct { SessionName string SessionID string @@ -108,9 +128,12 @@ type server struct { height int tasks map[string]*taskRecord notes map[string]*noteRecord + goals map[string]*goalRecord subscribers map[*uiSubscriber]struct{} notesPath string + goalsPath string noteCounter uint64 + goalCounter uint64 } func newServer() *server { @@ -118,11 +141,13 @@ func newServer() *server { socketPath: socketPath(), pos: posTopRight, width: 84, - height: 24, + height: 28, tasks: make(map[string]*taskRecord), notes: make(map[string]*noteRecord), + goals: make(map[string]*goalRecord), subscribers: make(map[*uiSubscriber]struct{}), notesPath: notesStorePath(), + goalsPath: goalsStorePath(), } } @@ -137,6 +162,9 @@ func (s *server) run() error { if err := s.loadNotes(); err != nil { return err } + if err := s.loadGoals(); err != nil { + return err + } if err := os.MkdirAll(filepath.Dir(s.socketPath), 0o755); err != nil { return err } @@ -362,6 +390,32 @@ func (s *server) handleCommand(env ipc.Envelope) error { s.broadcastStateAsync() s.statusRefreshAsync() return nil + case "goal_add": + target := tmuxTarget{ + SessionName: strings.TrimSpace(env.Session), + SessionID: strings.TrimSpace(env.SessionID), + } + summary := firstNonEmpty(env.Summary, env.Message) + if err := s.addGoal(target, summary); err != nil { + return err + } + s.broadcastStateAsync() + s.statusRefreshAsync() + return nil + case "goal_toggle_complete": + if err := s.toggleGoalCompletion(strings.TrimSpace(env.GoalID)); err != nil { + return err + } + s.broadcastStateAsync() + s.statusRefreshAsync() + return nil + case "goal_delete": + if err := s.deleteGoal(strings.TrimSpace(env.GoalID)); err != nil { + return err + } + s.broadcastStateAsync() + s.statusRefreshAsync() + return nil default: return fmt.Errorf("unknown command %q", env.Command) } @@ -646,11 +700,112 @@ func (s *server) loadNotes() error { return nil } +func (s *server) saveGoalsLocked() error { + if err := os.MkdirAll(filepath.Dir(s.goalsPath), 0o755); err != nil { + return err + } + records := make([]storedGoal, 0, len(s.goals)) + for _, g := range s.goals { + records = append(records, storedGoal(*g)) + } + data, err := json.MarshalIndent(records, "", " ") + if err != nil { + return err + } + tmp := s.goalsPath + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, s.goalsPath) +} + +func (s *server) loadGoals() error { + if s.goals == nil { + s.goals = make(map[string]*goalRecord) + } + data, err := os.ReadFile(s.goalsPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + var records []storedGoal + if err := json.Unmarshal(data, &records); err != nil { + return err + } + for _, rec := range records { + g := rec + s.goals[g.ID] = &goalRecord{ + ID: g.ID, + SessionID: g.SessionID, + Session: g.Session, + Summary: g.Summary, + Completed: g.Completed, + CreatedAt: g.CreatedAt, + UpdatedAt: g.UpdatedAt, + } + } + return nil +} + +func (s *server) addGoal(target tmuxTarget, summary string) error { + summary = strings.TrimSpace(summary) + if summary == "" { + return fmt.Errorf("goal summary required") + } + target = normalizeNoteTargetNames(target) + if strings.TrimSpace(target.SessionID) == "" { + return fmt.Errorf("session required for goal") + } + now := time.Now() + s.mu.Lock() + defer s.mu.Unlock() + g := &goalRecord{ + ID: s.newGoalIDLocked(now), + SessionID: target.SessionID, + Session: target.SessionName, + Summary: summary, + Completed: false, + CreatedAt: now, + UpdatedAt: now, + } + s.goals[g.ID] = g + return s.saveGoalsLocked() +} + +func (s *server) toggleGoalCompletion(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + g, ok := s.goals[id] + if !ok { + return fmt.Errorf("goal not found") + } + g.Completed = !g.Completed + g.UpdatedAt = time.Now() + return s.saveGoalsLocked() +} + +func (s *server) deleteGoal(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.goals[id]; !ok { + return fmt.Errorf("goal not found") + } + delete(s.goals, id) + return s.saveGoalsLocked() +} + func (s *server) newNoteIDLocked(now time.Time) string { counter := atomic.AddUint64(&s.noteCounter, 1) return fmt.Sprintf("%x-%x", now.UnixNano(), counter) } +func (s *server) newGoalIDLocked(now time.Time) string { + counter := atomic.AddUint64(&s.goalCounter, 1) + return fmt.Sprintf("%x-%x", now.UnixNano(), counter) +} + func normalizeNoteTargetNames(target tmuxTarget) tmuxTarget { if strings.TrimSpace(target.SessionName) == "" { target.SessionName = target.SessionID @@ -1109,6 +1264,36 @@ func (s *server) buildStateEnvelope() *ipc.Envelope { } activeNotes, archived := s.notesForState() + + s.mu.Lock() + goalCopies := make([]*goalRecord, 0, len(s.goals)) + for _, g := range s.goals { + copy := *g + goalCopies = append(goalCopies, ©) + } + s.mu.Unlock() + + goals := make([]ipc.Goal, 0, len(goalCopies)) + for _, g := range goalCopies { + created := "" + updated := "" + if !g.CreatedAt.IsZero() { + created = g.CreatedAt.Format(time.RFC3339) + } + if !g.UpdatedAt.IsZero() { + updated = g.UpdatedAt.Format(time.RFC3339) + } + goals = append(goals, ipc.Goal{ + ID: g.ID, + SessionID: g.SessionID, + Session: g.Session, + Summary: g.Summary, + Completed: g.Completed, + CreatedAt: created, + UpdatedAt: updated, + }) + } + msg := stateSummary(tasks, activeNotes, archived) return &ipc.Envelope{ Kind: "state", @@ -1118,6 +1303,7 @@ func (s *server) buildStateEnvelope() *ipc.Envelope { Tasks: tasks, Notes: activeNotes, Archived: archived, + Goals: goals, } } @@ -1291,6 +1477,11 @@ func notesStorePath() string { return filepath.Join(base, "notes.json") } +func goalsStorePath() string { + base := filepath.Join(os.Getenv("HOME"), ".config", "agent-tracker", "run") + return filepath.Join(base, "goals.json") +} + func taskKey(sessionID, windowID, paneID string) string { return strings.Join([]string{sessionID, windowID, paneID}, "|") } diff --git a/agent-tracker/internal/ipc/envelope.go b/agent-tracker/internal/ipc/envelope.go index 336a576..106c0bf 100644 --- a/agent-tracker/internal/ipc/envelope.go +++ b/agent-tracker/internal/ipc/envelope.go @@ -11,6 +11,7 @@ type Envelope struct { Pane string `json:"pane,omitempty"` Scope string `json:"scope,omitempty"` NoteID string `json:"note_id,omitempty"` + GoalID string `json:"goal_id,omitempty"` Position string `json:"position,omitempty"` Visible *bool `json:"visible,omitempty"` Message string `json:"message,omitempty"` @@ -18,6 +19,7 @@ type Envelope struct { Tasks []Task `json:"tasks,omitempty"` Notes []Note `json:"notes,omitempty"` Archived []Note `json:"archived,omitempty"` + Goals []Goal `json:"goals,omitempty"` } type Task struct { @@ -50,3 +52,13 @@ type Note struct { UpdatedAt string `json:"updated_at"` ArchivedAt string `json:"archived_at,omitempty"` } + +type Goal struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Session string `json:"session"` + Summary string `json:"summary"` + Completed bool `json:"completed"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +}