mirror of
https://github.com/theniceboy/.config.git
synced 2025-12-26 14:44:57 +08:00
agent feat: goals
This commit is contained in:
parent
687267a473
commit
4ee7399b30
3 changed files with 480 additions and 39 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}, "|")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue