Refine tracker notes scopes and UI

This commit is contained in:
David Chen 2025-11-29 10:04:39 -08:00
parent 62fd493769
commit 3f15e870fa
3 changed files with 208 additions and 68 deletions

View file

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

View file

@ -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 := &noteRecord{
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] = &noteRecord{
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,

View file

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