mirror of
https://github.com/theniceboy/.config.git
synced 2025-12-26 22:54:59 +08:00
1893 lines
46 KiB
Go
1893 lines
46 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/david/agent-tracker/internal/ipc"
|
|
"github.com/gdamore/tcell/v2"
|
|
)
|
|
|
|
const (
|
|
statusInProgress = "in_progress"
|
|
statusCompleted = "completed"
|
|
)
|
|
|
|
var spinnerFrames = []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}
|
|
|
|
const spinnerInterval = 120 * time.Millisecond
|
|
|
|
type viewMode int
|
|
|
|
const (
|
|
viewTracker viewMode = iota
|
|
viewNotes
|
|
viewArchive
|
|
viewEdit
|
|
)
|
|
|
|
type noteScope string
|
|
|
|
const (
|
|
scopeWindow noteScope = "window"
|
|
scopeSession noteScope = "session"
|
|
scopeAll noteScope = "all"
|
|
)
|
|
|
|
type listState struct {
|
|
selected int
|
|
offset int
|
|
}
|
|
|
|
type promptMode string
|
|
|
|
const (
|
|
promptAddNote promptMode = "add_note"
|
|
promptEditNote promptMode = "edit_note"
|
|
)
|
|
|
|
type promptState struct {
|
|
active bool
|
|
mode promptMode
|
|
text []rune
|
|
cursor int
|
|
noteID string
|
|
}
|
|
|
|
func main() {
|
|
log.SetFlags(0)
|
|
if len(os.Args) < 2 {
|
|
if err := runUI(os.Args[1:]); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
return
|
|
}
|
|
cmd := os.Args[1]
|
|
args := os.Args[2:]
|
|
switch cmd {
|
|
case "ui":
|
|
if err := runUI(args); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
case "command":
|
|
if err := runCommand(args); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
case "state":
|
|
if err := runState(args); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
default:
|
|
if err := runUI(os.Args[1:]); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func runCommand(args []string) error {
|
|
fs := flag.NewFlagSet("tracker-client command", flag.ExitOnError)
|
|
var client, session, sessionID, window, windowID, pane, summary, scope, noteID string
|
|
fs.StringVar(&client, "client", "", "tmux client tty")
|
|
fs.StringVar(&session, "session", "", "tmux session name")
|
|
fs.StringVar(&sessionID, "session-id", "", "tmux session id")
|
|
fs.StringVar(&window, "window", "", "tmux window name")
|
|
fs.StringVar(&windowID, "window-id", "", "tmux window id")
|
|
fs.StringVar(&pane, "pane", "", "tmux pane id")
|
|
fs.StringVar(&summary, "summary", "", "summary or note payload")
|
|
fs.StringVar(&scope, "scope", "", "note scope")
|
|
fs.StringVar(¬eID, "note-id", "", "note identifier")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
rest := fs.Args()
|
|
if len(rest) == 0 {
|
|
return fmt.Errorf("command name required")
|
|
}
|
|
if len(rest) > 1 {
|
|
summary = strings.Join(rest[1:], " ")
|
|
}
|
|
|
|
env := ipc.Envelope{
|
|
Kind: "command",
|
|
Command: rest[0],
|
|
Client: client,
|
|
Session: strings.TrimSpace(session),
|
|
SessionID: strings.TrimSpace(sessionID),
|
|
Window: strings.TrimSpace(window),
|
|
WindowID: strings.TrimSpace(windowID),
|
|
Pane: strings.TrimSpace(pane),
|
|
Scope: strings.TrimSpace(scope),
|
|
NoteID: strings.TrimSpace(noteID),
|
|
Summary: strings.TrimSpace(summary),
|
|
}
|
|
if env.Summary != "" {
|
|
env.Message = env.Summary
|
|
}
|
|
|
|
switch env.Command {
|
|
case "start_task", "finish_task", "acknowledge", "note_add", "note_archive_pane", "note_attach":
|
|
ctx, err := resolveContext(env.Session, env.SessionID, env.Window, env.WindowID, env.Pane)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
env.Session = ctx.SessionName
|
|
env.SessionID = ctx.SessionID
|
|
env.Window = ctx.WindowName
|
|
env.WindowID = ctx.WindowID
|
|
env.Pane = ctx.PaneID
|
|
}
|
|
|
|
conn, err := net.Dial("unix", socketPath())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer conn.Close()
|
|
|
|
enc := json.NewEncoder(conn)
|
|
if err := enc.Encode(&env); err != nil {
|
|
return err
|
|
}
|
|
|
|
dec := json.NewDecoder(conn)
|
|
for {
|
|
var reply ipc.Envelope
|
|
if err := dec.Decode(&reply); err != nil {
|
|
return err
|
|
}
|
|
if reply.Kind == "ack" {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func runState(args []string) error {
|
|
fs := flag.NewFlagSet("tracker-client state", flag.ExitOnError)
|
|
var client string
|
|
fs.StringVar(&client, "client", "", "tmux client tty")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
|
|
conn, err := net.Dial("unix", socketPath())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer conn.Close()
|
|
|
|
enc := json.NewEncoder(conn)
|
|
dec := json.NewDecoder(bufio.NewReader(conn))
|
|
|
|
if err := enc.Encode(&ipc.Envelope{Kind: "ui-register", Client: client}); err != nil {
|
|
return err
|
|
}
|
|
|
|
for {
|
|
var env ipc.Envelope
|
|
if err := dec.Decode(&env); err != nil {
|
|
return err
|
|
}
|
|
if env.Kind == "state" {
|
|
out := json.NewEncoder(os.Stdout)
|
|
out.SetEscapeHTML(false)
|
|
if err := out.Encode(&env); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
type tmuxContext struct {
|
|
SessionName string
|
|
SessionID string
|
|
WindowName string
|
|
WindowID string
|
|
PaneID string
|
|
}
|
|
|
|
func (c tmuxContext) complete() bool {
|
|
return strings.TrimSpace(c.SessionID) != "" &&
|
|
strings.TrimSpace(c.WindowID) != "" &&
|
|
strings.TrimSpace(c.PaneID) != ""
|
|
}
|
|
|
|
func resolveContext(sessionName, sessionID, windowName, windowID, paneID string) (tmuxContext, error) {
|
|
ctx := tmuxContext{
|
|
SessionName: strings.TrimSpace(sessionName),
|
|
SessionID: strings.TrimSpace(sessionID),
|
|
WindowName: strings.TrimSpace(windowName),
|
|
WindowID: strings.TrimSpace(windowID),
|
|
PaneID: strings.TrimSpace(paneID),
|
|
}
|
|
|
|
fetchOrder := []string{}
|
|
if ctx.PaneID != "" {
|
|
fetchOrder = append(fetchOrder, ctx.PaneID)
|
|
}
|
|
fetchOrder = append(fetchOrder, "")
|
|
|
|
for _, target := range fetchOrder {
|
|
if ctx.complete() {
|
|
break
|
|
}
|
|
info, err := detectTmuxContext(target)
|
|
if err != nil {
|
|
if target == "" {
|
|
return tmuxContext{}, err
|
|
}
|
|
continue
|
|
}
|
|
ctx = ctx.merge(info)
|
|
}
|
|
|
|
if ctx.SessionID == "" || ctx.WindowID == "" {
|
|
return tmuxContext{}, fmt.Errorf("session and window identifiers required")
|
|
}
|
|
|
|
if ctx.SessionName == "" || ctx.WindowName == "" {
|
|
if info, err := detectTmuxContext(ctx.WindowID); err == nil {
|
|
ctx = ctx.merge(info)
|
|
}
|
|
}
|
|
|
|
if ctx.SessionName == "" {
|
|
ctx.SessionName = ctx.SessionID
|
|
}
|
|
if ctx.WindowName == "" {
|
|
ctx.WindowName = ctx.WindowID
|
|
}
|
|
|
|
return ctx, nil
|
|
}
|
|
|
|
func (c tmuxContext) merge(other tmuxContext) tmuxContext {
|
|
if c.SessionName == "" {
|
|
c.SessionName = other.SessionName
|
|
}
|
|
if c.SessionID == "" {
|
|
c.SessionID = other.SessionID
|
|
}
|
|
if c.WindowName == "" {
|
|
c.WindowName = other.WindowName
|
|
}
|
|
if c.WindowID == "" {
|
|
c.WindowID = other.WindowID
|
|
}
|
|
if c.PaneID == "" {
|
|
c.PaneID = other.PaneID
|
|
}
|
|
return c
|
|
}
|
|
|
|
func detectTmuxContext(target string) (tmuxContext, error) {
|
|
format := "#{session_name}:::#{session_id}:::#{window_name}:::#{window_id}:::#{pane_id}"
|
|
args := []string{"display-message", "-p"}
|
|
if strings.TrimSpace(target) != "" {
|
|
args = append(args, "-t", strings.TrimSpace(target))
|
|
}
|
|
args = append(args, format)
|
|
cmd := exec.Command("tmux", args...)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return tmuxContext{}, fmt.Errorf("tmux display-message: %w (%s)", err, strings.TrimSpace(string(output)))
|
|
}
|
|
parts := strings.Split(strings.TrimSpace(string(output)), ":::")
|
|
if len(parts) != 5 {
|
|
return tmuxContext{}, fmt.Errorf("unexpected tmux response: %s", strings.TrimSpace(string(output)))
|
|
}
|
|
return tmuxContext{
|
|
SessionName: strings.TrimSpace(parts[0]),
|
|
SessionID: strings.TrimSpace(parts[1]),
|
|
WindowName: strings.TrimSpace(parts[2]),
|
|
WindowID: strings.TrimSpace(parts[3]),
|
|
PaneID: strings.TrimSpace(parts[4]),
|
|
}, nil
|
|
}
|
|
|
|
func detectTmuxContextForClient(client string) (tmuxContext, error) {
|
|
if pane := strings.TrimSpace(os.Getenv("TMUX_PANE")); pane != "" {
|
|
if ctx, err := detectTmuxContext(pane); err == nil {
|
|
return ctx, nil
|
|
}
|
|
}
|
|
format := "#{session_name}:::#{session_id}:::#{window_name}:::#{window_id}:::#{pane_id}"
|
|
args := []string{"display-message", "-p"}
|
|
if strings.TrimSpace(client) != "" {
|
|
args = append(args, "-c", strings.TrimSpace(client))
|
|
}
|
|
args = append(args, format)
|
|
cmd := exec.Command("tmux", args...)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return tmuxContext{}, fmt.Errorf("tmux display-message: %w (%s)", err, strings.TrimSpace(string(output)))
|
|
}
|
|
parts := strings.Split(strings.TrimSpace(string(output)), ":::")
|
|
if len(parts) != 5 {
|
|
return tmuxContext{}, fmt.Errorf("unexpected tmux response: %s", strings.TrimSpace(string(output)))
|
|
}
|
|
return tmuxContext{
|
|
SessionName: strings.TrimSpace(parts[0]),
|
|
SessionID: strings.TrimSpace(parts[1]),
|
|
WindowName: strings.TrimSpace(parts[2]),
|
|
WindowID: strings.TrimSpace(parts[3]),
|
|
PaneID: strings.TrimSpace(parts[4]),
|
|
}, nil
|
|
}
|
|
|
|
func runUI(args []string) error {
|
|
fs := flag.NewFlagSet("tracker-client ui", flag.ExitOnError)
|
|
var client string
|
|
var originSession, originSessionID, originWindow, originWindowID, originPane string
|
|
fs.StringVar(&client, "client", "", "tmux client tty")
|
|
fs.StringVar(&originSession, "origin-session", "", "origin session name")
|
|
fs.StringVar(&originSessionID, "origin-session-id", "", "origin session id")
|
|
fs.StringVar(&originWindow, "origin-window", "", "origin window name")
|
|
fs.StringVar(&originWindowID, "origin-window-id", "", "origin window id")
|
|
fs.StringVar(&originPane, "origin-pane", "", "origin pane id")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
|
|
conn, err := net.Dial("unix", socketPath())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer conn.Close()
|
|
|
|
screen, err := tcell.NewScreen()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := screen.Init(); err != nil {
|
|
return err
|
|
}
|
|
defer screen.Fini()
|
|
screen.Clear()
|
|
|
|
enc := json.NewEncoder(conn)
|
|
dec := json.NewDecoder(bufio.NewReader(conn))
|
|
|
|
if err := enc.Encode(&ipc.Envelope{Kind: "ui-register", Client: client}); err != nil {
|
|
return err
|
|
}
|
|
|
|
type state struct {
|
|
message string
|
|
tasks []ipc.Task
|
|
notes []ipc.Note
|
|
archived []ipc.Note
|
|
}
|
|
st := state{message: "Connecting to tracker…"}
|
|
|
|
originCtx := tmuxContext{
|
|
SessionName: strings.TrimSpace(originSession),
|
|
SessionID: strings.TrimSpace(originSessionID),
|
|
WindowName: strings.TrimSpace(originWindow),
|
|
WindowID: strings.TrimSpace(originWindowID),
|
|
PaneID: strings.TrimSpace(originPane),
|
|
}
|
|
|
|
currentCtx := originCtx
|
|
refreshCtx := func() {
|
|
if originCtx.complete() {
|
|
currentCtx = originCtx
|
|
return
|
|
}
|
|
if ctx, err := detectTmuxContextForClient(client); err == nil {
|
|
currentCtx = ctx
|
|
}
|
|
}
|
|
refreshCtx()
|
|
|
|
incoming := make(chan ipc.Envelope)
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
for {
|
|
var env ipc.Envelope
|
|
if err := dec.Decode(&env); err != nil {
|
|
errCh <- err
|
|
close(incoming)
|
|
return
|
|
}
|
|
incoming <- env
|
|
}
|
|
}()
|
|
|
|
events := make(chan tcell.Event)
|
|
go func() {
|
|
for {
|
|
ev := screen.PollEvent()
|
|
if ev == nil {
|
|
close(events)
|
|
return
|
|
}
|
|
events <- ev
|
|
}
|
|
}()
|
|
|
|
ticker := time.NewTicker(spinnerInterval)
|
|
defer ticker.Stop()
|
|
|
|
var encMu sync.Mutex
|
|
sendCommand := func(name string, opts ...func(*ipc.Envelope)) error {
|
|
encMu.Lock()
|
|
defer encMu.Unlock()
|
|
env := ipc.Envelope{Kind: "command", Command: name, Client: client}
|
|
for _, opt := range opts {
|
|
opt(&env)
|
|
}
|
|
return enc.Encode(&env)
|
|
}
|
|
|
|
mode := viewNotes
|
|
scope := scopeWindow
|
|
showCompletedTasks := true
|
|
showCompletedNotes := false
|
|
showCompletedArchive := false
|
|
taskList := listState{}
|
|
noteList := listState{}
|
|
archiveList := listState{}
|
|
keepTasksVisible := make(map[string]bool)
|
|
keepNotesVisible := make(map[string]bool)
|
|
prompt := promptState{}
|
|
helpVisible := false
|
|
var editNote *ipc.Note
|
|
|
|
cycleScope := func(forward bool, wrap bool) {
|
|
order := []noteScope{scopeWindow, scopeSession, scopeAll}
|
|
pos := 0
|
|
for i, s := range order {
|
|
if scope == s {
|
|
pos = i
|
|
break
|
|
}
|
|
}
|
|
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 "?"
|
|
}
|
|
}
|
|
|
|
clampList := func(state *listState, length int, rowHeight int, visibleRows int) {
|
|
if length == 0 {
|
|
state.selected = 0
|
|
state.offset = 0
|
|
return
|
|
}
|
|
if state.selected >= length {
|
|
state.selected = length - 1
|
|
}
|
|
if state.selected < 0 {
|
|
state.selected = 0
|
|
}
|
|
capacity := visibleRows / rowHeight
|
|
if capacity < 1 {
|
|
capacity = 1
|
|
}
|
|
maxOffset := length - capacity
|
|
if maxOffset < 0 {
|
|
maxOffset = 0
|
|
}
|
|
if state.offset > maxOffset {
|
|
state.offset = maxOffset
|
|
}
|
|
if state.selected < state.offset {
|
|
state.offset = state.selected
|
|
}
|
|
if state.selected >= state.offset+capacity {
|
|
state.offset = state.selected - capacity + 1
|
|
}
|
|
if state.offset < 0 {
|
|
state.offset = 0
|
|
}
|
|
}
|
|
|
|
matchesScope := func(n ipc.Note, s noteScope, ctx tmuxContext) bool {
|
|
ns := noteScopeOf(n)
|
|
switch s {
|
|
case scopeWindow:
|
|
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:
|
|
if ns == scopeAll {
|
|
return true
|
|
}
|
|
return strings.TrimSpace(n.SessionID) == strings.TrimSpace(ctx.SessionID)
|
|
case scopeAll:
|
|
return true
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
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) {
|
|
return ic.After(jc)
|
|
}
|
|
if hasIC != hasJC {
|
|
return hasIC
|
|
}
|
|
return notes[i].Summary < notes[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))
|
|
if !showCompletedTasks && t.Status == statusCompleted && !keepTasksVisible[key] {
|
|
continue
|
|
}
|
|
result = append(result, t)
|
|
}
|
|
sortTasks(result)
|
|
return result
|
|
}
|
|
|
|
getVisibleNotes := func() []ipc.Note {
|
|
result := make([]ipc.Note, 0, len(st.notes))
|
|
for _, n := range st.notes {
|
|
if n.Archived {
|
|
continue
|
|
}
|
|
if !showCompletedNotes && n.Completed && !keepNotesVisible[n.ID] {
|
|
continue
|
|
}
|
|
if matchesScope(n, scope, currentCtx) {
|
|
result = append(result, n)
|
|
}
|
|
}
|
|
sortNotes(result)
|
|
return result
|
|
}
|
|
|
|
getArchivedNotes := func() []ipc.Note {
|
|
result := make([]ipc.Note, 0, len(st.archived))
|
|
for _, n := range st.archived {
|
|
if !showCompletedArchive && n.Completed {
|
|
continue
|
|
}
|
|
result = append(result, n)
|
|
}
|
|
sortNotes(result)
|
|
return result
|
|
}
|
|
|
|
setScopeFields := func(env *ipc.Envelope, s noteScope, ctx tmuxContext) {
|
|
env.Scope = string(s)
|
|
env.Session = ctx.SessionName
|
|
env.SessionID = ctx.SessionID
|
|
if ctx.WindowName != "" || ctx.WindowID != "" {
|
|
env.Window = ctx.WindowName
|
|
env.WindowID = ctx.WindowID
|
|
}
|
|
if ctx.PaneID != "" {
|
|
env.Pane = ctx.PaneID
|
|
}
|
|
}
|
|
|
|
addNote := func(text string) error {
|
|
text = strings.TrimSpace(text)
|
|
if text == "" {
|
|
return fmt.Errorf("note text required")
|
|
}
|
|
refreshCtx()
|
|
ctx := currentCtx
|
|
return sendCommand("note_add", func(env *ipc.Envelope) {
|
|
env.Summary = text
|
|
setScopeFields(env, scope, ctx)
|
|
})
|
|
}
|
|
|
|
updateNote := func(id, text, scope string) error {
|
|
text = strings.TrimSpace(text)
|
|
scope = strings.TrimSpace(scope)
|
|
if text == "" && scope == "" {
|
|
return fmt.Errorf("note text or scope required")
|
|
}
|
|
if strings.TrimSpace(id) == "" {
|
|
return fmt.Errorf("note id required")
|
|
}
|
|
return sendCommand("note_edit", func(env *ipc.Envelope) {
|
|
env.NoteID = id
|
|
env.Summary = text
|
|
env.Scope = scope
|
|
})
|
|
}
|
|
|
|
cycleNoteScope := func(n ipc.Note) error {
|
|
current := noteScopeOf(n)
|
|
var next noteScope
|
|
switch current {
|
|
case scopeWindow:
|
|
next = scopeSession
|
|
case scopeSession:
|
|
next = scopeAll
|
|
case scopeAll:
|
|
next = scopeWindow
|
|
default:
|
|
next = scopeWindow
|
|
}
|
|
return updateNote(n.ID, "", string(next))
|
|
}
|
|
|
|
toggleNote := func(id string) error {
|
|
if strings.TrimSpace(id) == "" {
|
|
return fmt.Errorf("note id required")
|
|
}
|
|
keepNotesVisible[id] = true
|
|
return sendCommand("note_toggle_complete", func(env *ipc.Envelope) {
|
|
env.NoteID = id
|
|
})
|
|
}
|
|
|
|
deleteNote := func(id string) error {
|
|
if strings.TrimSpace(id) == "" {
|
|
return fmt.Errorf("note id required")
|
|
}
|
|
return sendCommand("note_delete", func(env *ipc.Envelope) {
|
|
env.NoteID = id
|
|
})
|
|
}
|
|
|
|
archiveNote := func(id string) error {
|
|
if strings.TrimSpace(id) == "" {
|
|
return fmt.Errorf("note id required")
|
|
}
|
|
return sendCommand("note_archive", func(env *ipc.Envelope) {
|
|
env.NoteID = id
|
|
})
|
|
}
|
|
|
|
attachNote := func(id string) error {
|
|
if strings.TrimSpace(id) == "" {
|
|
return fmt.Errorf("note id required")
|
|
}
|
|
refreshCtx()
|
|
ctx := currentCtx
|
|
return sendCommand("note_attach", func(env *ipc.Envelope) {
|
|
env.NoteID = id
|
|
setScopeFields(env, scopeWindow, ctx)
|
|
})
|
|
}
|
|
|
|
toggleTask := func(t ipc.Task) error {
|
|
key := fmt.Sprintf("%s|%s|%s", strings.TrimSpace(t.SessionID), strings.TrimSpace(t.WindowID), strings.TrimSpace(t.Pane))
|
|
keepTasksVisible[key] = true
|
|
if t.Status == statusInProgress {
|
|
return sendCommand("finish_task", func(env *ipc.Envelope) {
|
|
env.Session = t.Session
|
|
env.SessionID = t.SessionID
|
|
env.Window = t.Window
|
|
env.WindowID = t.WindowID
|
|
env.Pane = t.Pane
|
|
})
|
|
}
|
|
return sendCommand("acknowledge", func(env *ipc.Envelope) {
|
|
env.Session = t.Session
|
|
env.SessionID = t.SessionID
|
|
env.Window = t.Window
|
|
env.WindowID = t.WindowID
|
|
env.Pane = t.Pane
|
|
})
|
|
}
|
|
|
|
deleteTask := func(t ipc.Task) error {
|
|
return sendCommand("delete_task", func(env *ipc.Envelope) {
|
|
env.Session = t.Session
|
|
env.SessionID = t.SessionID
|
|
env.Window = t.Window
|
|
env.WindowID = t.WindowID
|
|
env.Pane = t.Pane
|
|
})
|
|
}
|
|
|
|
focusTask := func(t ipc.Task) error {
|
|
return sendCommand("focus_task", func(env *ipc.Envelope) {
|
|
env.Session = t.Session
|
|
env.SessionID = t.SessionID
|
|
env.Window = t.Window
|
|
env.WindowID = t.WindowID
|
|
env.Pane = t.Pane
|
|
})
|
|
}
|
|
|
|
startAddPrompt := func() {
|
|
prompt = promptState{active: true, mode: promptAddNote, text: []rune{}, cursor: 0}
|
|
}
|
|
|
|
startEditPrompt := func(n ipc.Note) {
|
|
copy := n
|
|
editNote = ©
|
|
mode = viewEdit
|
|
runes := []rune(n.Summary)
|
|
prompt = promptState{active: true, mode: promptEditNote, text: runes, cursor: len(runes), noteID: n.ID}
|
|
}
|
|
|
|
handlePromptKey := func(tev *tcell.EventKey) (bool, error) {
|
|
if !prompt.active {
|
|
return false, nil
|
|
}
|
|
switch tev.Key() {
|
|
case tcell.KeyEnter:
|
|
text := strings.TrimSpace(string(prompt.text))
|
|
var err error
|
|
switch prompt.mode {
|
|
case promptAddNote:
|
|
err = addNote(text)
|
|
case promptEditNote:
|
|
err = updateNote(prompt.noteID, text, "")
|
|
}
|
|
prompt.active = false
|
|
if prompt.mode == promptEditNote {
|
|
mode = viewNotes
|
|
editNote = nil
|
|
}
|
|
if err != nil {
|
|
return true, err
|
|
}
|
|
return true, nil
|
|
case tcell.KeyEscape:
|
|
prompt.active = false
|
|
if prompt.mode == promptEditNote {
|
|
mode = viewNotes
|
|
editNote = nil
|
|
}
|
|
return true, nil
|
|
case tcell.KeyLeft:
|
|
if prompt.cursor > 0 {
|
|
prompt.cursor--
|
|
}
|
|
return true, nil
|
|
case tcell.KeyRight:
|
|
if prompt.cursor < len(prompt.text) {
|
|
prompt.cursor++
|
|
}
|
|
return true, nil
|
|
case tcell.KeyBackspace, tcell.KeyBackspace2:
|
|
if prompt.cursor > 0 {
|
|
prompt.text = append(prompt.text[:prompt.cursor-1], prompt.text[prompt.cursor:]...)
|
|
prompt.cursor--
|
|
}
|
|
return true, nil
|
|
case tcell.KeyCtrlW:
|
|
if prompt.cursor > 0 {
|
|
// skip trailing spaces
|
|
i := prompt.cursor
|
|
for i > 0 && unicode.IsSpace(prompt.text[i-1]) {
|
|
i--
|
|
}
|
|
// skip non-spaces
|
|
for i > 0 && !unicode.IsSpace(prompt.text[i-1]) {
|
|
i--
|
|
}
|
|
prompt.text = append(prompt.text[:i], prompt.text[prompt.cursor:]...)
|
|
prompt.cursor = i
|
|
}
|
|
return true, nil
|
|
case tcell.KeyCtrlU:
|
|
prompt.text = prompt.text[:0]
|
|
prompt.cursor = 0
|
|
return true, nil
|
|
case tcell.KeyRune:
|
|
r := tev.Rune()
|
|
prompt.text = append(prompt.text[:prompt.cursor], append([]rune{r}, prompt.text[prompt.cursor:]...)...)
|
|
prompt.cursor++
|
|
return true, nil
|
|
default:
|
|
return true, nil
|
|
}
|
|
}
|
|
|
|
draw := func(now time.Time) {
|
|
screen.Clear()
|
|
width, height := screen.Size()
|
|
|
|
headerStyle := tcell.StyleDefault.Foreground(tcell.ColorLightCyan).Bold(true)
|
|
subtleStyle := tcell.StyleDefault.Foreground(tcell.ColorLightSlateGray)
|
|
infoStyle := tcell.StyleDefault.Foreground(tcell.ColorSilver)
|
|
|
|
title := "Tracker"
|
|
if mode == viewNotes {
|
|
title = "Notes"
|
|
} else if mode == viewArchive {
|
|
title = "Archive"
|
|
} else if mode == viewEdit {
|
|
title = "Edit Note"
|
|
}
|
|
subtitle := st.message
|
|
if mode == viewNotes {
|
|
completedState := "hidden"
|
|
if showCompletedNotes {
|
|
completedState = "shown"
|
|
}
|
|
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)
|
|
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)
|
|
}
|
|
|
|
visibleRows := height - 3
|
|
if visibleRows < 0 {
|
|
visibleRows = 0
|
|
}
|
|
|
|
renderTasks := func(list []ipc.Task, state *listState) {
|
|
clampList(state, len(list), 3, visibleRows)
|
|
row := 3
|
|
for idx := state.offset; idx < len(list); idx++ {
|
|
if row >= height {
|
|
break
|
|
}
|
|
t := list[idx]
|
|
indicator := taskIndicator(t, now)
|
|
summary := t.Summary
|
|
if summary == "" {
|
|
summary = "(no summary)"
|
|
}
|
|
|
|
// Style definitions
|
|
baseStyle := tcell.StyleDefault
|
|
accentStyle := tcell.StyleDefault.Foreground(tcell.ColorLightSlateGray)
|
|
timeStyle := tcell.StyleDefault.Foreground(tcell.ColorDarkCyan)
|
|
|
|
switch t.Status {
|
|
case statusInProgress:
|
|
baseStyle = baseStyle.Foreground(tcell.ColorLightGoldenrodYellow).Bold(true)
|
|
indicator = "▶ " + indicator
|
|
case statusCompleted:
|
|
if t.Acknowledged {
|
|
baseStyle = baseStyle.Foreground(tcell.ColorLightGreen)
|
|
} else {
|
|
baseStyle = baseStyle.Foreground(tcell.ColorFuchsia)
|
|
}
|
|
}
|
|
|
|
if idx == state.selected {
|
|
baseStyle = baseStyle.Background(tcell.ColorDarkSlateGray)
|
|
accentStyle = accentStyle.Background(tcell.ColorDarkSlateGray)
|
|
timeStyle = timeStyle.Background(tcell.ColorDarkSlateGray)
|
|
}
|
|
|
|
// Line 1: Indicator + Summary + Right-aligned Duration
|
|
dur := liveDuration(t, now)
|
|
availWidth := width - len(indicator) - 1 - len(dur) - 3
|
|
if availWidth < 0 {
|
|
availWidth = 0
|
|
}
|
|
|
|
line1Segs := []struct {
|
|
text string
|
|
style tcell.Style
|
|
}{
|
|
{text: indicator + " ", style: baseStyle},
|
|
{text: truncate(summary, availWidth), style: baseStyle},
|
|
}
|
|
|
|
// Fill spacing for right alignment
|
|
usedWidth := len(indicator) + 1 + len([]rune(truncate(summary, availWidth)))
|
|
padding := width - usedWidth - len(dur)
|
|
if padding > 0 {
|
|
line1Segs = append(line1Segs, struct {
|
|
text string
|
|
style tcell.Style
|
|
}{
|
|
text: strings.Repeat(" ", padding),
|
|
style: baseStyle,
|
|
})
|
|
}
|
|
line1Segs = append(line1Segs, struct {
|
|
text string
|
|
style tcell.Style
|
|
}{
|
|
text: dur,
|
|
style: timeStyle,
|
|
})
|
|
|
|
writeStyledSegments(screen, row, line1Segs...)
|
|
row++
|
|
|
|
if row >= height {
|
|
break
|
|
}
|
|
|
|
// Line 2: Meta info (Session / Window)
|
|
meta := fmt.Sprintf(" └ %s / %s", t.Session, t.Window)
|
|
if t.Status == statusCompleted && !t.Acknowledged {
|
|
meta += " (awaiting review)"
|
|
}
|
|
|
|
writeStyledLine(screen, 0, row, truncate(meta, width), accentStyle)
|
|
row++
|
|
|
|
if t.CompletionNote != "" && row < height {
|
|
note := fmt.Sprintf(" Note: %s", t.CompletionNote)
|
|
noteStyle := tcell.StyleDefault.Foreground(tcell.ColorLightSteelBlue)
|
|
if idx == state.selected {
|
|
noteStyle = noteStyle.Background(tcell.ColorDarkSlateGray)
|
|
}
|
|
writeStyledLine(screen, 0, row, truncate(note, width), noteStyle)
|
|
row++
|
|
}
|
|
|
|
// Spacer
|
|
if row < height {
|
|
if idx == state.selected {
|
|
// Optional: subtle separator for selected item
|
|
}
|
|
row++
|
|
}
|
|
}
|
|
}
|
|
|
|
wrapText := func(text string, maxWidth int) []string {
|
|
if maxWidth <= 0 {
|
|
return []string{""}
|
|
}
|
|
runes := []rune(text)
|
|
if len(runes) <= maxWidth {
|
|
return []string{text}
|
|
}
|
|
var lines []string
|
|
for len(runes) > maxWidth {
|
|
split := maxWidth
|
|
found := false
|
|
for i := maxWidth; i > 0; i-- {
|
|
if unicode.IsSpace(runes[i]) {
|
|
split = i
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
split = maxWidth
|
|
}
|
|
lines = append(lines, string(runes[:split]))
|
|
runes = runes[split:]
|
|
for len(runes) > 0 && unicode.IsSpace(runes[0]) {
|
|
runes = runes[1:]
|
|
}
|
|
}
|
|
if len(runes) > 0 {
|
|
lines = append(lines, string(runes))
|
|
}
|
|
return lines
|
|
}
|
|
|
|
renderNotes := func(list []ipc.Note, state *listState, archived bool) {
|
|
clampList(state, len(list), 3, visibleRows)
|
|
row := 3
|
|
for idx := state.offset; idx < len(list); idx++ {
|
|
if row >= height {
|
|
break
|
|
}
|
|
n := list[idx]
|
|
ns := noteScopeOf(n)
|
|
|
|
// Styles
|
|
scopeSt := scopeStyle(ns)
|
|
textStyle := tcell.StyleDefault.Foreground(tcell.ColorWhite)
|
|
metaStyle := tcell.StyleDefault.Foreground(tcell.ColorLightSlateGray)
|
|
timeStyle := tcell.StyleDefault.Foreground(tcell.ColorDarkCyan)
|
|
|
|
if n.Completed {
|
|
textStyle = tcell.StyleDefault.Foreground(tcell.ColorDarkGray)
|
|
scopeSt = tcell.StyleDefault.Foreground(tcell.ColorDarkGray)
|
|
metaStyle = tcell.StyleDefault.Foreground(tcell.ColorDarkGray)
|
|
timeStyle = tcell.StyleDefault.Foreground(tcell.ColorDarkGray)
|
|
}
|
|
|
|
if idx == state.selected {
|
|
textStyle = textStyle.Background(tcell.ColorDarkSlateGray)
|
|
scopeSt = scopeSt.Background(tcell.ColorDarkSlateGray)
|
|
metaStyle = metaStyle.Background(tcell.ColorDarkSlateGray)
|
|
timeStyle = timeStyle.Background(tcell.ColorDarkSlateGray)
|
|
}
|
|
|
|
// Tag Logic
|
|
tagText := " " + scopeTag(ns) + " "
|
|
|
|
// Timestamp Logic
|
|
tsStr := ""
|
|
if archived {
|
|
if n.ArchivedAt != "" {
|
|
if ts, ok := parseTimestamp(n.ArchivedAt); ok {
|
|
tsStr = ts.Format("15:04")
|
|
}
|
|
}
|
|
} else {
|
|
if n.CreatedAt != "" {
|
|
if ts, ok := parseTimestamp(n.CreatedAt); ok {
|
|
tsStr = ts.Format("15:04")
|
|
}
|
|
}
|
|
}
|
|
|
|
summary := n.Summary
|
|
if n.Completed {
|
|
summary = "✓ " + summary
|
|
}
|
|
|
|
// --- Line 1 Rendering ---
|
|
availWidth1 := width - len(tagText) - len(tsStr) - 2
|
|
if availWidth1 < 5 {
|
|
availWidth1 = 5
|
|
}
|
|
|
|
// Split summary for first line
|
|
line1Text := summary
|
|
remText := ""
|
|
runes := []rune(summary)
|
|
if len(runes) > availWidth1 {
|
|
split := availWidth1
|
|
found := false
|
|
for i := availWidth1; i > 0; i-- {
|
|
if unicode.IsSpace(runes[i]) {
|
|
split = i
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
split = availWidth1
|
|
}
|
|
|
|
line1Text = string(runes[:split])
|
|
remText = string(runes[split:])
|
|
// trim leading space from remainder
|
|
trimRunes := []rune(remText)
|
|
for len(trimRunes) > 0 && unicode.IsSpace(trimRunes[0]) {
|
|
trimRunes = trimRunes[1:]
|
|
}
|
|
remText = string(trimRunes)
|
|
}
|
|
|
|
segs := []struct {
|
|
text string
|
|
style tcell.Style
|
|
}{
|
|
{text: tagText, style: scopeSt},
|
|
{text: line1Text, style: textStyle},
|
|
}
|
|
|
|
usedLen := len(tagText) + len([]rune(line1Text))
|
|
padding := width - usedLen - len(tsStr)
|
|
if padding > 0 {
|
|
segs = append(segs, struct {
|
|
text string
|
|
style tcell.Style
|
|
}{
|
|
text: strings.Repeat(" ", padding),
|
|
style: textStyle,
|
|
})
|
|
}
|
|
segs = append(segs, struct {
|
|
text string
|
|
style tcell.Style
|
|
}{
|
|
text: tsStr,
|
|
style: timeStyle,
|
|
})
|
|
|
|
writeStyledSegments(screen, row, segs...)
|
|
row++
|
|
if row >= height {
|
|
break
|
|
}
|
|
|
|
// --- Wrapped Lines Rendering ---
|
|
if remText != "" {
|
|
indent := len(tagText)
|
|
availWidthN := width - indent
|
|
if availWidthN < 5 {
|
|
availWidthN = 5
|
|
}
|
|
|
|
wrappedRem := wrapText(remText, availWidthN)
|
|
for _, wLine := range wrappedRem {
|
|
segs := []struct {
|
|
text string
|
|
style tcell.Style
|
|
}{
|
|
{text: strings.Repeat(" ", indent), style: scopeSt},
|
|
{text: wLine, style: textStyle},
|
|
}
|
|
used := indent + len([]rune(wLine))
|
|
if width > used {
|
|
segs = append(segs, struct {
|
|
text string
|
|
style tcell.Style
|
|
}{
|
|
text: strings.Repeat(" ", width-used),
|
|
style: textStyle,
|
|
})
|
|
}
|
|
writeStyledSegments(screen, row, segs...)
|
|
row++
|
|
if row >= height {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if row >= height {
|
|
break
|
|
}
|
|
|
|
// --- Meta Line Rendering ---
|
|
prefix := " "
|
|
metaText := fmt.Sprintf("%s / %s", n.Session, n.Window)
|
|
|
|
metaSegs := []struct {
|
|
text string
|
|
style tcell.Style
|
|
}{
|
|
{text: prefix, style: metaStyle},
|
|
{text: truncate(metaText, width-len(prefix)), style: metaStyle},
|
|
}
|
|
metaUsed := len(prefix) + len([]rune(truncate(metaText, width-len(prefix))))
|
|
if width > metaUsed {
|
|
metaSegs = append(metaSegs, struct {
|
|
text string
|
|
style tcell.Style
|
|
}{
|
|
text: strings.Repeat(" ", width-metaUsed),
|
|
style: metaStyle,
|
|
})
|
|
}
|
|
|
|
writeStyledSegments(screen, row, metaSegs...)
|
|
row++
|
|
if row >= height {
|
|
break
|
|
}
|
|
|
|
// --- Spacer Rendering ---
|
|
spacerStyle := tcell.StyleDefault
|
|
if idx == state.selected {
|
|
spacerStyle = spacerStyle.Background(tcell.ColorDarkSlateGray)
|
|
}
|
|
writeStyledLine(screen, 0, row, strings.Repeat(" ", width), spacerStyle)
|
|
row++
|
|
}
|
|
}
|
|
|
|
if helpVisible {
|
|
helpLines := []string{
|
|
"t: toggle Tracker/Notes | s/S: scope (Notes) | Alt-A: archive view | Shift-A: archive note",
|
|
"a/k: add note | i: edit note | Enter/c: complete (Tracker/Notes) | p: focus task",
|
|
"Shift-D: delete | Shift-C: show/hide completed | Esc: close | ?: toggle help",
|
|
}
|
|
row := 3
|
|
for _, line := range helpLines {
|
|
if row >= height {
|
|
break
|
|
}
|
|
writeStyledLine(screen, 0, row, truncate(line, width), infoStyle)
|
|
row++
|
|
}
|
|
screen.Show()
|
|
return
|
|
}
|
|
|
|
switch mode {
|
|
case viewTracker:
|
|
list := getVisibleTasks()
|
|
if len(list) == 0 && height > 3 {
|
|
writeStyledLine(screen, 0, 3, truncate("No tasks.", width), infoStyle)
|
|
} else {
|
|
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)
|
|
} else {
|
|
renderNotes(list, ¬eList, false)
|
|
}
|
|
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)
|
|
}
|
|
case viewEdit:
|
|
bodyStyle := tcell.StyleDefault.Foreground(tcell.ColorLightGreen)
|
|
if editNote == nil {
|
|
writeStyledLine(screen, 0, 3, truncate("No note selected.", width), infoStyle)
|
|
} else {
|
|
writeStyledLine(screen, 0, 3, truncate("Editing note (Enter to save, Esc to cancel):", width), infoStyle)
|
|
writeStyledLine(screen, 0, 5, truncate(string(prompt.text), width), bodyStyle)
|
|
if prompt.active {
|
|
cx := prompt.cursor
|
|
if cx > width-1 {
|
|
cx = width - 1
|
|
}
|
|
screen.ShowCursor(cx, 5)
|
|
}
|
|
}
|
|
}
|
|
|
|
if prompt.active && mode != viewEdit {
|
|
label := "Add note: "
|
|
if prompt.mode == promptEditNote {
|
|
label = "Edit note: "
|
|
}
|
|
line := label + string(prompt.text)
|
|
writeStyledLine(screen, 0, height-1, truncate(line, width), tcell.StyleDefault.Foreground(tcell.ColorLightGreen))
|
|
|
|
cx := len(label) + prompt.cursor
|
|
if cx > width-1 {
|
|
cx = width - 1
|
|
}
|
|
screen.ShowCursor(cx, height-1)
|
|
}
|
|
|
|
screen.Show()
|
|
}
|
|
|
|
draw(time.Now())
|
|
|
|
for {
|
|
select {
|
|
case env, ok := <-incoming:
|
|
if !ok {
|
|
return <-errCh
|
|
}
|
|
switch env.Kind {
|
|
case "state":
|
|
st.message = env.Message
|
|
st.tasks = make([]ipc.Task, len(env.Tasks))
|
|
copy(st.tasks, env.Tasks)
|
|
st.notes = make([]ipc.Note, len(env.Notes))
|
|
copy(st.notes, env.Notes)
|
|
st.archived = make([]ipc.Note, len(env.Archived))
|
|
copy(st.archived, env.Archived)
|
|
refreshCtx()
|
|
draw(time.Now())
|
|
case "ack":
|
|
default:
|
|
}
|
|
case ev, ok := <-events:
|
|
if !ok {
|
|
return nil
|
|
}
|
|
switch tev := ev.(type) {
|
|
case *tcell.EventKey:
|
|
if handled, err := handlePromptKey(tev); handled {
|
|
if err != nil {
|
|
st.message = err.Error()
|
|
}
|
|
draw(time.Now())
|
|
continue
|
|
}
|
|
|
|
if tev.Key() == tcell.KeyRune && tev.Rune() == '?' {
|
|
helpVisible = !helpVisible
|
|
draw(time.Now())
|
|
continue
|
|
}
|
|
|
|
if tev.Key() == tcell.KeyEscape {
|
|
if prompt.active {
|
|
prompt.active = false
|
|
draw(time.Now())
|
|
continue
|
|
}
|
|
if mode == viewEdit {
|
|
mode = viewNotes
|
|
editNote = nil
|
|
draw(time.Now())
|
|
continue
|
|
}
|
|
if err := sendCommand("hide"); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if tev.Key() == tcell.KeyCtrlC {
|
|
if err := sendCommand("hide"); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if tev.Modifiers()&tcell.ModAlt != 0 {
|
|
r := unicode.ToLower(tev.Rune())
|
|
if r == 'a' {
|
|
if mode == viewNotes {
|
|
mode = viewArchive
|
|
} else if mode == viewArchive {
|
|
mode = viewNotes
|
|
} else {
|
|
mode = viewNotes
|
|
}
|
|
draw(time.Now())
|
|
continue
|
|
}
|
|
}
|
|
|
|
if tev.Key() == tcell.KeyEnter {
|
|
switch mode {
|
|
case viewTracker:
|
|
tasks := getVisibleTasks()
|
|
if len(tasks) > 0 && taskList.selected < len(tasks) {
|
|
if err := focusTask(tasks[taskList.selected]); err != nil {
|
|
st.message = err.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()
|
|
}
|
|
}
|
|
case viewArchive:
|
|
notes := getArchivedNotes()
|
|
if len(notes) > 0 && archiveList.selected < len(notes) {
|
|
if err := attachNote(notes[archiveList.selected].ID); err != nil {
|
|
st.message = err.Error()
|
|
}
|
|
}
|
|
}
|
|
draw(time.Now())
|
|
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
|
|
}
|
|
|
|
r := tev.Rune()
|
|
lower := unicode.ToLower(r)
|
|
shift := tev.Modifiers()&tcell.ModShift != 0 || unicode.IsUpper(r)
|
|
|
|
switch lower {
|
|
case 't':
|
|
if mode == viewTracker {
|
|
mode = viewNotes
|
|
} else {
|
|
mode = viewTracker
|
|
}
|
|
draw(time.Now())
|
|
case 'n':
|
|
if mode == viewNotes {
|
|
if shift {
|
|
cycleScope(false, false)
|
|
} else {
|
|
cycleScope(true, false)
|
|
}
|
|
draw(time.Now())
|
|
}
|
|
case 's':
|
|
if mode == viewNotes {
|
|
notes := getVisibleNotes()
|
|
if len(notes) > 0 && noteList.selected < len(notes) {
|
|
if err := cycleNoteScope(notes[noteList.selected]); err != nil {
|
|
st.message = err.Error()
|
|
}
|
|
}
|
|
draw(time.Now())
|
|
}
|
|
case 'u':
|
|
switch mode {
|
|
case viewTracker:
|
|
if taskList.selected > 0 {
|
|
taskList.selected--
|
|
}
|
|
case viewNotes:
|
|
if noteList.selected > 0 {
|
|
noteList.selected--
|
|
}
|
|
case viewArchive:
|
|
if archiveList.selected > 0 {
|
|
archiveList.selected--
|
|
}
|
|
}
|
|
draw(time.Now())
|
|
case 'e':
|
|
switch mode {
|
|
case viewTracker:
|
|
tasks := getVisibleTasks()
|
|
if taskList.selected < len(tasks)-1 {
|
|
taskList.selected++
|
|
}
|
|
case viewNotes:
|
|
notes := getVisibleNotes()
|
|
if noteList.selected < len(notes)-1 {
|
|
noteList.selected++
|
|
}
|
|
case viewArchive:
|
|
notes := getArchivedNotes()
|
|
if archiveList.selected < len(notes)-1 {
|
|
archiveList.selected++
|
|
}
|
|
}
|
|
draw(time.Now())
|
|
case 'c':
|
|
if shift {
|
|
switch mode {
|
|
case viewNotes:
|
|
showCompletedNotes = !showCompletedNotes
|
|
case viewArchive:
|
|
showCompletedArchive = !showCompletedArchive
|
|
}
|
|
draw(time.Now())
|
|
break
|
|
}
|
|
switch mode {
|
|
case viewTracker:
|
|
tasks := getVisibleTasks()
|
|
if len(tasks) > 0 && taskList.selected < len(tasks) {
|
|
if err := toggleTask(tasks[taskList.selected]); err != nil {
|
|
st.message = err.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()
|
|
}
|
|
}
|
|
case viewArchive:
|
|
notes := getArchivedNotes()
|
|
if len(notes) > 0 && archiveList.selected < len(notes) {
|
|
if err := attachNote(notes[archiveList.selected].ID); err != nil {
|
|
st.message = err.Error()
|
|
}
|
|
}
|
|
}
|
|
draw(time.Now())
|
|
case 'p':
|
|
if mode == viewTracker {
|
|
tasks := getVisibleTasks()
|
|
if len(tasks) > 0 && taskList.selected < len(tasks) {
|
|
if err := focusTask(tasks[taskList.selected]); err != nil {
|
|
st.message = err.Error()
|
|
}
|
|
}
|
|
draw(time.Now())
|
|
} else if mode == viewNotes {
|
|
notes := getVisibleNotes()
|
|
if len(notes) > 0 && noteList.selected < len(notes) {
|
|
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()
|
|
}
|
|
}
|
|
draw(time.Now())
|
|
}
|
|
case 'a':
|
|
if shift && mode == viewNotes {
|
|
notes := getVisibleNotes()
|
|
if len(notes) > 0 && noteList.selected < len(notes) {
|
|
if err := archiveNote(notes[noteList.selected].ID); err != nil {
|
|
st.message = err.Error()
|
|
}
|
|
}
|
|
draw(time.Now())
|
|
break
|
|
}
|
|
if mode == viewNotes {
|
|
startAddPrompt()
|
|
draw(time.Now())
|
|
}
|
|
case 'k':
|
|
if mode == viewNotes {
|
|
notes := getVisibleNotes()
|
|
if len(notes) > 0 && noteList.selected < len(notes) {
|
|
startEditPrompt(notes[noteList.selected])
|
|
}
|
|
draw(time.Now())
|
|
}
|
|
case 'i':
|
|
if mode == viewNotes {
|
|
notes := getVisibleNotes()
|
|
if len(notes) > 0 && noteList.selected < len(notes) {
|
|
// same as focus in Tracker mode
|
|
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()
|
|
}
|
|
}
|
|
draw(time.Now())
|
|
}
|
|
if mode == viewTracker {
|
|
tasks := getVisibleTasks()
|
|
if len(tasks) > 0 && taskList.selected < len(tasks) {
|
|
if err := focusTask(tasks[taskList.selected]); err != nil {
|
|
st.message = err.Error()
|
|
}
|
|
draw(time.Now())
|
|
}
|
|
}
|
|
case 'd':
|
|
if shift {
|
|
switch mode {
|
|
case viewTracker:
|
|
tasks := getVisibleTasks()
|
|
if len(tasks) > 0 && taskList.selected < len(tasks) {
|
|
if err := deleteTask(tasks[taskList.selected]); err != nil {
|
|
st.message = err.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()
|
|
}
|
|
}
|
|
case viewArchive:
|
|
notes := getArchivedNotes()
|
|
if len(notes) > 0 && archiveList.selected < len(notes) {
|
|
if err := deleteNote(notes[archiveList.selected].ID); err != nil {
|
|
st.message = err.Error()
|
|
}
|
|
}
|
|
}
|
|
draw(time.Now())
|
|
}
|
|
}
|
|
case *tcell.EventResize:
|
|
draw(time.Now())
|
|
}
|
|
case now := <-ticker.C:
|
|
draw(now)
|
|
case err := <-errCh:
|
|
if err == nil || errors.Is(err, io.EOF) || strings.Contains(err.Error(), "use of closed network connection") {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
func sortTasks(tasks []ipc.Task) {
|
|
sort.SliceStable(tasks, func(i, j int) bool {
|
|
ti, tj := tasks[i], tasks[j]
|
|
ranki := taskStatusRank(ti.Status)
|
|
rankj := taskStatusRank(tj.Status)
|
|
if ranki != rankj {
|
|
return ranki < rankj
|
|
}
|
|
switch ti.Status {
|
|
case statusInProgress:
|
|
return ti.StartedAt < tj.StartedAt
|
|
case statusCompleted:
|
|
if ti.Acknowledged != tj.Acknowledged {
|
|
return !ti.Acknowledged && tj.Acknowledged
|
|
}
|
|
if ci, hasCi := parseTimestamp(ti.CompletedAt); hasCi {
|
|
if cj, hasCj := parseTimestamp(tj.CompletedAt); hasCj {
|
|
if !ci.Equal(cj) {
|
|
return ci.After(cj)
|
|
}
|
|
} else {
|
|
return true
|
|
}
|
|
} else if _, hasCj := parseTimestamp(tj.CompletedAt); hasCj {
|
|
return false
|
|
}
|
|
si, hasSi := parseTimestamp(ti.StartedAt)
|
|
sj, hasSj := parseTimestamp(tj.StartedAt)
|
|
if hasSi && hasSj && !si.Equal(sj) {
|
|
return si.After(sj)
|
|
}
|
|
if hasSi != hasSj {
|
|
return hasSi
|
|
}
|
|
return ti.StartedAt > tj.StartedAt
|
|
default:
|
|
return ti.StartedAt < tj.StartedAt
|
|
}
|
|
})
|
|
}
|
|
|
|
func taskStatusRank(status string) int {
|
|
switch status {
|
|
case statusInProgress:
|
|
return 0
|
|
case statusCompleted:
|
|
return 1
|
|
default:
|
|
return 2
|
|
}
|
|
}
|
|
|
|
func parseTimestamp(value string) (time.Time, bool) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return time.Time{}, false
|
|
}
|
|
ts, err := time.Parse(time.RFC3339, value)
|
|
if err != nil {
|
|
return time.Time{}, false
|
|
}
|
|
return ts, true
|
|
}
|
|
|
|
func taskIndicator(t ipc.Task, now time.Time) string {
|
|
switch t.Status {
|
|
case statusInProgress:
|
|
idx := int(now.UnixNano()/int64(spinnerInterval)) % len(spinnerFrames)
|
|
return string(spinnerFrames[idx])
|
|
case statusCompleted:
|
|
if t.Acknowledged {
|
|
return "✓"
|
|
}
|
|
return "⚑"
|
|
default:
|
|
return "?"
|
|
}
|
|
}
|
|
|
|
func liveDuration(t ipc.Task, now time.Time) string {
|
|
if t.StartedAt == "" {
|
|
return "0s"
|
|
}
|
|
start, err := time.Parse(time.RFC3339, t.StartedAt)
|
|
if err != nil {
|
|
return formatDuration(t.DurationSeconds)
|
|
}
|
|
if t.Status == statusCompleted {
|
|
if t.CompletedAt != "" {
|
|
if end, err := time.Parse(time.RFC3339, t.CompletedAt); err == nil {
|
|
return formatDuration(end.Sub(start).Seconds())
|
|
}
|
|
}
|
|
return formatDuration(t.DurationSeconds)
|
|
}
|
|
return formatDuration(time.Since(start).Seconds())
|
|
}
|
|
|
|
func formatDuration(seconds float64) string {
|
|
if seconds < 0 {
|
|
seconds = 0
|
|
}
|
|
d := time.Duration(seconds * float64(time.Second))
|
|
if d >= 99*time.Hour {
|
|
return ">=99h"
|
|
}
|
|
hours := d / time.Hour
|
|
minutes := (d % time.Hour) / time.Minute
|
|
secondsPart := (d % time.Minute) / time.Second
|
|
if hours > 0 {
|
|
return fmt.Sprintf("%02dh%02dm", hours, minutes)
|
|
}
|
|
if minutes > 0 {
|
|
return fmt.Sprintf("%02dm%02ds", minutes, secondsPart)
|
|
}
|
|
return fmt.Sprintf("%02ds", secondsPart)
|
|
}
|
|
|
|
func truncate(text string, width int) string {
|
|
if width <= 0 {
|
|
return ""
|
|
}
|
|
runes := []rune(text)
|
|
if len(runes) <= width {
|
|
return text
|
|
}
|
|
if width <= 1 {
|
|
return string(runes[:width])
|
|
}
|
|
return string(runes[:width-1]) + "…"
|
|
}
|
|
|
|
func writeStyledLine(s tcell.Screen, x, y int, text string, style tcell.Style) {
|
|
width, _ := s.Size()
|
|
if x >= width {
|
|
return
|
|
}
|
|
runes := []rune(text)
|
|
limit := width - x
|
|
for i := 0; i < limit; i++ {
|
|
r := rune(' ')
|
|
if i < len(runes) {
|
|
r = runes[i]
|
|
}
|
|
s.SetContent(x+i, y, r, nil, 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")
|
|
}
|
|
return filepath.Join(os.TempDir(), "agent-tracker.sock")
|
|
}
|