mirror of
https://github.com/theniceboy/.config.git
synced 2026-04-25 23:15:24 +08:00
762 lines
23 KiB
Go
762 lines
23 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/david/agent-tracker/internal/ipc"
|
|
)
|
|
|
|
const (
|
|
trackerTaskStatusInProgress = "in_progress"
|
|
trackerTaskStatusCompleted = "completed"
|
|
)
|
|
|
|
type trackerPanelTickMsg struct{}
|
|
|
|
type trackerPanelStateMsg struct {
|
|
env *ipc.Envelope
|
|
err error
|
|
}
|
|
|
|
type trackerPanelCommandMsg struct {
|
|
message string
|
|
err error
|
|
close bool
|
|
}
|
|
|
|
type trackerPanelListState struct {
|
|
selected int
|
|
offset int
|
|
}
|
|
|
|
type trackerPanelContext struct {
|
|
SessionName string
|
|
SessionID string
|
|
WindowName string
|
|
WindowID string
|
|
PaneID string
|
|
}
|
|
|
|
type trackerPanelModel struct {
|
|
runtime *paletteRuntime
|
|
currentCtx trackerPanelContext
|
|
width int
|
|
height int
|
|
taskList trackerPanelListState
|
|
state ipc.Envelope
|
|
loaded bool
|
|
message string
|
|
refreshedAt time.Time
|
|
refreshInFlight bool
|
|
pendingRefresh bool
|
|
showAltHints bool
|
|
helpVisible bool
|
|
requestBack bool
|
|
requestClose bool
|
|
}
|
|
|
|
func newTrackerPanelModel(runtime *paletteRuntime) *trackerPanelModel {
|
|
model := &trackerPanelModel{runtime: runtime, message: "Loading tracker..."}
|
|
model.syncCurrentContext()
|
|
return model
|
|
}
|
|
|
|
func (m *trackerPanelModel) activate() tea.Cmd {
|
|
m.requestBack = false
|
|
m.requestClose = false
|
|
m.syncCurrentContext()
|
|
if !m.loaded {
|
|
m.message = "Loading tracker..."
|
|
}
|
|
return tea.Batch(trackerPanelTickCmd(), m.requestRefreshCmd())
|
|
}
|
|
|
|
func (m *trackerPanelModel) Init() tea.Cmd {
|
|
return m.activate()
|
|
}
|
|
|
|
func (m *trackerPanelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
return m, nil
|
|
case trackerPanelTickMsg:
|
|
cmds := []tea.Cmd{trackerPanelTickCmd()}
|
|
if !m.loaded || time.Since(m.refreshedAt) >= time.Second {
|
|
cmds = append(cmds, m.requestRefreshCmd())
|
|
}
|
|
return m, tea.Batch(cmds...)
|
|
case trackerPanelStateMsg:
|
|
m.refreshInFlight = false
|
|
if msg.err != nil {
|
|
m.message = msg.err.Error()
|
|
} else if msg.env != nil {
|
|
m.state = *msg.env
|
|
m.loaded = true
|
|
m.refreshedAt = time.Now()
|
|
if text := strings.TrimSpace(msg.env.Message); text != "" {
|
|
m.message = text
|
|
}
|
|
m.syncCurrentContext()
|
|
m.clampSelections()
|
|
}
|
|
if m.pendingRefresh {
|
|
m.pendingRefresh = false
|
|
return m, m.requestRefreshCmd()
|
|
}
|
|
return m, nil
|
|
case trackerPanelCommandMsg:
|
|
if msg.err != nil {
|
|
m.message = msg.err.Error()
|
|
return m, nil
|
|
}
|
|
if text := strings.TrimSpace(msg.message); text != "" {
|
|
m.message = text
|
|
}
|
|
if msg.close {
|
|
m.requestClose = true
|
|
return m, nil
|
|
}
|
|
return m, m.requestRefreshCmd()
|
|
case tea.KeyMsg:
|
|
if isAltFooterToggleKey(msg) {
|
|
m.showAltHints = !m.showAltHints
|
|
return m, nil
|
|
}
|
|
m.showAltHints = false
|
|
return m.updateNormal(msg.String())
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *trackerPanelModel) updateNormal(key string) (tea.Model, tea.Cmd) {
|
|
switch key {
|
|
case "esc", "ctrl+c":
|
|
m.requestBack = true
|
|
return m, nil
|
|
case "?":
|
|
m.helpVisible = !m.helpVisible
|
|
return m, nil
|
|
}
|
|
if m.helpVisible {
|
|
return m, nil
|
|
}
|
|
switch key {
|
|
case "u", "up", "ctrl+u":
|
|
m.moveSelection(-1)
|
|
return m, nil
|
|
case "e", "down", "ctrl+e":
|
|
m.moveSelection(1)
|
|
return m, nil
|
|
case "enter", "p":
|
|
return m.runPrimaryAction()
|
|
case "c":
|
|
return m.toggleSelected()
|
|
case "D":
|
|
return m.deleteSelected()
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *trackerPanelModel) View() string {
|
|
return m.render(newPaletteStyles(), m.width, m.height)
|
|
}
|
|
|
|
func (m *trackerPanelModel) render(styles paletteStyles, width, height int) string {
|
|
if width <= 0 {
|
|
width = 96
|
|
}
|
|
if height <= 0 {
|
|
height = 28
|
|
}
|
|
contentWidth := maxInt(16, width-2)
|
|
contentHeight := maxInt(8, height-7)
|
|
contextLine := "Tracker"
|
|
if location := m.renderContextLine(); strings.TrimSpace(location) != "" {
|
|
contextLine += " · " + location
|
|
}
|
|
header := lipgloss.JoinVertical(lipgloss.Left,
|
|
styles.title.Render("Tracker"),
|
|
styles.meta.Render(truncate(contextLine, maxInt(10, contentWidth))),
|
|
styles.muted.Render(truncate(m.renderMetricsLine(), maxInt(10, contentWidth))),
|
|
)
|
|
var body string
|
|
if m.helpVisible {
|
|
body = m.renderHelp(styles, contentWidth, contentHeight)
|
|
} else {
|
|
body = m.renderTasks(styles, contentWidth, contentHeight)
|
|
}
|
|
footer := m.renderFooter(styles, contentWidth)
|
|
view := lipgloss.JoinVertical(lipgloss.Left, header, "", body, "", footer)
|
|
return lipgloss.NewStyle().Width(width).Height(height).Padding(0, 1).Render(view)
|
|
}
|
|
|
|
func (m *trackerPanelModel) renderTasks(styles paletteStyles, width, height int) string {
|
|
list := m.visibleTasks()
|
|
if width < 84 {
|
|
return m.renderTaskListSection(styles, width, height, list, "Queue", "Across your active tracker feed")
|
|
}
|
|
leftWidth := maxInt(34, width*56/100)
|
|
rightWidth := maxInt(24, width-leftWidth-5)
|
|
listSection := m.renderTaskListSection(styles, leftWidth, height, list, "Queue", "What still needs attention")
|
|
detailSection := m.renderTaskDetailSection(styles, rightWidth, height)
|
|
divider := styles.muted.Render(renderVerticalDivider(height))
|
|
return lipgloss.JoinHorizontal(lipgloss.Top, listSection, " ", divider, " ", detailSection)
|
|
}
|
|
|
|
func (m *trackerPanelModel) renderTaskListSection(styles paletteStyles, width, height int, tasks []ipc.Task, title, meta string) string {
|
|
contentHeight := maxInt(1, height-3)
|
|
entryHeight := 3
|
|
entriesPerPage := maxInt(1, contentHeight/entryHeight)
|
|
selected := clampInt(m.taskList.selected, 0, maxInt(0, len(tasks)-1))
|
|
offset := stableListOffset(m.taskList.offset, selected, entriesPerPage, len(tasks))
|
|
m.taskList.selected = selected
|
|
m.taskList.offset = offset
|
|
rows := []string{}
|
|
if len(tasks) == 0 {
|
|
rows = append(rows, trackerEmptyState(styles, "No tasks in motion."))
|
|
} else {
|
|
now := time.Now()
|
|
for idx := offset; idx < len(tasks) && idx < offset+entriesPerPage; idx++ {
|
|
rows = append(rows, m.renderTaskRow(styles, tasks[idx], idx == selected, width, now))
|
|
}
|
|
}
|
|
sectionMeta := fmt.Sprintf("%d tasks", len(tasks))
|
|
if strings.TrimSpace(meta) != "" {
|
|
sectionMeta += " · " + meta
|
|
}
|
|
return trackerRenderSection(styles, title, sectionMeta, strings.Join(rows, "\n"), width, height)
|
|
}
|
|
|
|
func (m *trackerPanelModel) renderTaskRow(styles paletteStyles, task ipc.Task, selected bool, width int, now time.Time) string {
|
|
selectedBG := lipgloss.Color("238")
|
|
rowWidth := maxInt(16, width)
|
|
titleStyle := styles.itemTitle
|
|
metaStyle := styles.itemSubtitle
|
|
indicator := trackerTaskIndicator(task, now)
|
|
indicatorStyle := styles.selectedLabel
|
|
if task.Status == trackerTaskStatusCompleted {
|
|
indicatorStyle = styles.statusBad
|
|
titleStyle = styles.itemTitle.Copy().Foreground(lipgloss.Color("246"))
|
|
metaStyle = styles.itemSubtitle.Copy().Foreground(lipgloss.Color("246"))
|
|
if task.Acknowledged {
|
|
indicatorStyle = styles.todoCheckDone
|
|
}
|
|
}
|
|
padStyle := lipgloss.NewStyle()
|
|
if selected {
|
|
titleStyle = titleStyle.Copy().Foreground(lipgloss.Color("230")).Background(selectedBG)
|
|
metaStyle = metaStyle.Copy().Foreground(lipgloss.Color("251")).Background(selectedBG)
|
|
indicatorStyle = indicatorStyle.Copy().Background(selectedBG)
|
|
padStyle = padStyle.Copy().Foreground(lipgloss.Color("230")).Background(selectedBG)
|
|
}
|
|
duration := trackerLiveDuration(task, now)
|
|
meta := trackerFirstNonEmpty(strings.TrimSpace(task.Session), "Session")
|
|
if strings.TrimSpace(task.Window) != "" {
|
|
meta += " / " + strings.TrimSpace(task.Window)
|
|
}
|
|
if task.Status == trackerTaskStatusCompleted && !task.Acknowledged {
|
|
meta += " · awaiting review"
|
|
}
|
|
if duration != "" {
|
|
meta = strings.TrimSpace(meta + " · " + duration)
|
|
}
|
|
titleText := truncate(firstPaletteLine(task.Summary), maxInt(1, rowWidth-4-lipgloss.Width(indicator)))
|
|
line1RawWidth := 1 + lipgloss.Width(indicator) + 1 + lipgloss.Width(titleText)
|
|
line1Pad := maxInt(0, rowWidth-line1RawWidth)
|
|
line1 := padStyle.Render(" ") + indicatorStyle.Render(indicator) + padStyle.Render(" ") + titleStyle.Render(titleText) + padStyle.Render(strings.Repeat(" ", line1Pad))
|
|
metaText := truncate(meta, maxInt(1, rowWidth-3))
|
|
line2Pad := maxInt(0, rowWidth-2-lipgloss.Width(metaText))
|
|
line2 := padStyle.Render(" ") + metaStyle.Render(metaText) + padStyle.Render(strings.Repeat(" ", line2Pad))
|
|
line3 := padStyle.Render(strings.Repeat(" ", rowWidth))
|
|
return lipgloss.JoinVertical(lipgloss.Left, line1, line2, line3)
|
|
}
|
|
|
|
func (m *trackerPanelModel) renderTaskDetailSection(styles paletteStyles, width, height int) string {
|
|
task := m.selectedTask()
|
|
if task == nil {
|
|
return trackerRenderSection(styles, "Selected", "Open a task to jump into its tmux pane", trackerEmptyState(styles, "Nothing is selected."), width, height)
|
|
}
|
|
status := "Live"
|
|
if task.Status == trackerTaskStatusCompleted {
|
|
if task.Acknowledged {
|
|
status = "Done"
|
|
} else {
|
|
status = "Needs review"
|
|
}
|
|
}
|
|
meta := trackerFirstNonEmpty(strings.TrimSpace(task.Session), task.SessionID)
|
|
if strings.TrimSpace(task.Window) != "" {
|
|
meta += " / " + strings.TrimSpace(task.Window)
|
|
}
|
|
lines := []string{
|
|
trackerRenderWrappedText(styles.panelText.Copy().Bold(true), firstPaletteLine(task.Summary), maxInt(10, width)),
|
|
"",
|
|
trackerDetailLine(styles, "status", status, width),
|
|
trackerDetailLine(styles, "window", meta, width),
|
|
trackerDetailLine(styles, "elapsed", trackerLiveDuration(*task, time.Now()), width),
|
|
}
|
|
if note := strings.TrimSpace(task.CompletionNote); note != "" {
|
|
lines = append(lines, "", styles.muted.Render("note"), trackerRenderWrappedText(styles.panelTextDone, note, maxInt(10, width)))
|
|
}
|
|
return trackerRenderSection(styles, "Selected", "Enter opens the highlighted pane", strings.Join(lines, "\n"), width, height)
|
|
}
|
|
|
|
func (m *trackerPanelModel) renderHelp(styles paletteStyles, width, height int) string {
|
|
lines := []string{
|
|
"u and e move through tasks. enter opens the highlighted tmux pane.",
|
|
"c settles a task. shift-d deletes it. esc returns to the command palette.",
|
|
}
|
|
styled := make([]string, 0, len(lines))
|
|
for _, line := range lines {
|
|
styled = append(styled, styles.panelText.Render(truncate(line, maxInt(10, width-4))))
|
|
}
|
|
return trackerRenderSection(styles, "Guide", "Task-only tracker", strings.Join(styled, "\n\n"), width, height)
|
|
}
|
|
|
|
func (m *trackerPanelModel) renderFooter(styles paletteStyles, width int) string {
|
|
renderSegments := func(pairs [][2]string) string {
|
|
return renderShortcutPairs(func(v string) string { return styles.shortcutKey.Render(v) }, func(v string) string { return styles.shortcutText.Render(v) }, " ", pairs)
|
|
}
|
|
footer := ""
|
|
if m.showAltHints {
|
|
footer = pickRenderedShortcutFooter(width, renderSegments,
|
|
[][2]string{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}},
|
|
[][2]string{{"Alt-S", "close"}},
|
|
)
|
|
} else {
|
|
footer = pickRenderedShortcutFooter(width, renderSegments,
|
|
[][2]string{{"u/e", "move"}, {"Enter", "open"}, {"c", "settle"}, {"Shift-D", "delete"}, {"Esc", "back"}, {footerHintToggleKey, "more"}},
|
|
[][2]string{{"u/e", "move"}, {"Enter", "open"}, {"c", "settle"}, {"Esc", "back"}, {footerHintToggleKey, "more"}},
|
|
[][2]string{{"Esc", "back"}, {footerHintToggleKey, "more"}},
|
|
)
|
|
}
|
|
if lipgloss.Width(footer) > width {
|
|
return styles.muted.Copy().Width(width).Render(truncate(strings.TrimSpace(m.currentStatus()), width))
|
|
}
|
|
status := strings.TrimSpace(m.currentStatus())
|
|
if status != "" && status != "Tracker" && status != "Loading tracker..." {
|
|
footer = strings.TrimSpace(status) + " " + footer
|
|
}
|
|
return lipgloss.NewStyle().Width(width).Render(footer)
|
|
}
|
|
|
|
func trackerRenderSection(styles paletteStyles, title, meta, content string, width, height int) string {
|
|
header := []string{styles.panelTitle.Render(title)}
|
|
if strings.TrimSpace(meta) != "" {
|
|
header = append(header, styles.meta.Render(truncate(meta, maxInt(10, width))))
|
|
}
|
|
body := lipgloss.JoinVertical(lipgloss.Left, strings.Join(header, "\n"), "", content)
|
|
return lipgloss.NewStyle().Width(width).Height(height).Render(body)
|
|
}
|
|
|
|
func trackerEmptyState(styles paletteStyles, text string) string {
|
|
return styles.muted.Render(text)
|
|
}
|
|
|
|
func trackerDetailLine(styles paletteStyles, label, value string, width int) string {
|
|
label = strings.TrimSpace(label)
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
value = "-"
|
|
}
|
|
labelWidth := 10
|
|
contentWidth := maxInt(10, width-labelWidth-1)
|
|
parts := wrapText(value, contentWidth)
|
|
if len(parts) == 0 {
|
|
parts = []string{value}
|
|
}
|
|
lines := []string{styles.muted.Copy().Width(labelWidth).Render(label+":") + styles.panelText.Render(truncate(parts[0], contentWidth))}
|
|
indent := strings.Repeat(" ", labelWidth)
|
|
for _, part := range parts[1:] {
|
|
lines = append(lines, indent+styles.panelText.Render(truncate(part, contentWidth)))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func trackerRenderWrappedText(style lipgloss.Style, text string, width int) string {
|
|
text = strings.TrimSpace(text)
|
|
if text == "" {
|
|
return ""
|
|
}
|
|
parts := wrapText(text, maxInt(10, width))
|
|
if len(parts) == 0 {
|
|
parts = []string{text}
|
|
}
|
|
lines := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
lines = append(lines, style.Render(truncate(part, maxInt(10, width))))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func (m *trackerPanelModel) renderContextLine() string {
|
|
parts := []string{}
|
|
if strings.TrimSpace(m.currentCtx.SessionName) != "" {
|
|
parts = append(parts, strings.TrimSpace(m.currentCtx.SessionName))
|
|
}
|
|
if strings.TrimSpace(m.currentCtx.WindowName) != "" {
|
|
parts = append(parts, strings.TrimSpace(m.currentCtx.WindowName))
|
|
}
|
|
if len(parts) == 0 {
|
|
return "No tmux context detected"
|
|
}
|
|
return strings.Join(parts, " · ")
|
|
}
|
|
|
|
func (m *trackerPanelModel) renderMetricsLine() string {
|
|
active := 0
|
|
review := 0
|
|
for _, task := range m.state.Tasks {
|
|
switch task.Status {
|
|
case trackerTaskStatusInProgress:
|
|
active++
|
|
case trackerTaskStatusCompleted:
|
|
if !task.Acknowledged {
|
|
review++
|
|
}
|
|
}
|
|
}
|
|
return fmt.Sprintf("%d live · %d review", active, review)
|
|
}
|
|
|
|
func (m *trackerPanelModel) currentStatus() string {
|
|
if text := strings.TrimSpace(m.message); text != "" {
|
|
return text
|
|
}
|
|
if text := strings.TrimSpace(m.state.Message); text != "" {
|
|
return text
|
|
}
|
|
return "Tracker"
|
|
}
|
|
|
|
func (m *trackerPanelModel) syncCurrentContext() {
|
|
if m.runtime == nil {
|
|
return
|
|
}
|
|
m.currentCtx = m.runtime.currentTrackerContext()
|
|
}
|
|
|
|
func (m *trackerPanelModel) requestRefreshCmd() tea.Cmd {
|
|
if m.refreshInFlight {
|
|
m.pendingRefresh = true
|
|
return nil
|
|
}
|
|
m.refreshInFlight = true
|
|
return func() tea.Msg {
|
|
env, err := trackerLoadState("")
|
|
return trackerPanelStateMsg{env: env, err: err}
|
|
}
|
|
}
|
|
|
|
func (m *trackerPanelModel) moveSelection(delta int) {
|
|
list := m.visibleTasks()
|
|
m.taskList.selected = clampInt(m.taskList.selected+delta, 0, maxInt(0, len(list)-1))
|
|
}
|
|
|
|
func (m *trackerPanelModel) runPrimaryAction() (tea.Model, tea.Cmd) {
|
|
task := m.selectedTask()
|
|
if task == nil {
|
|
return m, nil
|
|
}
|
|
return m, trackerPanelCommandFunc(func() error {
|
|
return focusTrackerTask(*task)
|
|
}, true)
|
|
}
|
|
|
|
func (m *trackerPanelModel) toggleSelected() (tea.Model, tea.Cmd) {
|
|
task := m.selectedTask()
|
|
if task == nil {
|
|
return m, nil
|
|
}
|
|
return m, trackerPanelCommandCmd(m.toggleTask(*task), "Task updated")
|
|
}
|
|
|
|
func (m *trackerPanelModel) deleteSelected() (tea.Model, tea.Cmd) {
|
|
task := m.selectedTask()
|
|
if task == nil {
|
|
return m, nil
|
|
}
|
|
return m, trackerPanelCommandCmd(m.deleteTask(*task), "Task deleted")
|
|
}
|
|
|
|
func (m *trackerPanelModel) toggleTask(task ipc.Task) error {
|
|
env := ipc.Envelope{Session: task.Session, SessionID: task.SessionID, Window: task.Window, WindowID: task.WindowID, Pane: task.Pane}
|
|
command := "acknowledge"
|
|
if task.Status == trackerTaskStatusInProgress {
|
|
command = "finish_task"
|
|
}
|
|
return sendTrackerCommand(command, &env)
|
|
}
|
|
|
|
func (m *trackerPanelModel) deleteTask(task ipc.Task) error {
|
|
env := ipc.Envelope{Session: task.Session, SessionID: task.SessionID, Window: task.Window, WindowID: task.WindowID, Pane: task.Pane}
|
|
return sendTrackerCommand("delete_task", &env)
|
|
}
|
|
|
|
func (m *trackerPanelModel) clampSelections() {
|
|
m.taskList.selected = clampInt(m.taskList.selected, 0, maxInt(0, len(m.visibleTasks())-1))
|
|
}
|
|
|
|
func (m *trackerPanelModel) visibleTasks() []ipc.Task {
|
|
result := append([]ipc.Task(nil), m.state.Tasks...)
|
|
trackerSortTasks(result)
|
|
return result
|
|
}
|
|
|
|
func (m *trackerPanelModel) selectedTask() *ipc.Task {
|
|
tasks := m.visibleTasks()
|
|
if len(tasks) == 0 || m.taskList.selected < 0 || m.taskList.selected >= len(tasks) {
|
|
return nil
|
|
}
|
|
task := tasks[m.taskList.selected]
|
|
return &task
|
|
}
|
|
|
|
func (r *paletteRuntime) currentTrackerContext() trackerPanelContext {
|
|
ctx := trackerPanelContext{
|
|
SessionName: strings.TrimSpace(r.currentSessionName),
|
|
WindowName: strings.TrimSpace(r.currentWindowName),
|
|
WindowID: strings.TrimSpace(r.windowID),
|
|
}
|
|
args := []string{"display-message", "-p"}
|
|
if strings.TrimSpace(r.windowID) != "" {
|
|
args = append(args, "-t", strings.TrimSpace(r.windowID))
|
|
}
|
|
args = append(args, "#{session_name}:::#{session_id}:::#{window_name}:::#{window_id}:::#{pane_id}")
|
|
out, err := runTmuxOutput(args...)
|
|
if err != nil {
|
|
return ctx
|
|
}
|
|
parts := strings.Split(strings.TrimSpace(out), ":::")
|
|
if len(parts) != 5 {
|
|
return ctx
|
|
}
|
|
ctx.SessionName = trackerFirstNonEmpty(strings.TrimSpace(parts[0]), ctx.SessionName)
|
|
ctx.SessionID = strings.TrimSpace(parts[1])
|
|
ctx.WindowName = trackerFirstNonEmpty(strings.TrimSpace(parts[2]), ctx.WindowName)
|
|
ctx.WindowID = trackerFirstNonEmpty(strings.TrimSpace(parts[3]), ctx.WindowID)
|
|
ctx.PaneID = strings.TrimSpace(parts[4])
|
|
return ctx
|
|
}
|
|
|
|
func trackerPanelTickCmd() tea.Cmd {
|
|
return tea.Tick(120*time.Millisecond, func(time.Time) tea.Msg { return trackerPanelTickMsg{} })
|
|
}
|
|
|
|
func trackerPanelCommandCmd(err error, message string) tea.Cmd {
|
|
return func() tea.Msg { return trackerPanelCommandMsg{message: message, err: err} }
|
|
}
|
|
|
|
func trackerPanelCommandFunc(fn func() error, close bool) tea.Cmd {
|
|
return func() tea.Msg { return trackerPanelCommandMsg{err: fn(), close: close} }
|
|
}
|
|
|
|
func trackerLoadState(client string) (*ipc.Envelope, error) {
|
|
conn, err := net.Dial("unix", trackerSocketPath())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer conn.Close()
|
|
enc := json.NewEncoder(conn)
|
|
dec := json.NewDecoder(bufio.NewReader(conn))
|
|
if err := enc.Encode(&ipc.Envelope{Kind: "ui-register", Client: strings.TrimSpace(client)}); err != nil {
|
|
return nil, err
|
|
}
|
|
for {
|
|
var env ipc.Envelope
|
|
if err := dec.Decode(&env); err != nil {
|
|
return nil, err
|
|
}
|
|
if env.Kind == "state" {
|
|
return &env, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func sendTrackerCommand(command string, env *ipc.Envelope) error {
|
|
conn, err := net.Dial("unix", trackerSocketPath())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer conn.Close()
|
|
request := ipc.Envelope{Kind: "command", Command: strings.TrimSpace(command)}
|
|
if env != nil {
|
|
request.Client = strings.TrimSpace(env.Client)
|
|
request.Session = strings.TrimSpace(env.Session)
|
|
request.SessionID = strings.TrimSpace(env.SessionID)
|
|
request.Window = strings.TrimSpace(env.Window)
|
|
request.WindowID = strings.TrimSpace(env.WindowID)
|
|
request.Pane = strings.TrimSpace(env.Pane)
|
|
request.Summary = strings.TrimSpace(env.Summary)
|
|
request.Message = strings.TrimSpace(env.Message)
|
|
}
|
|
enc := json.NewEncoder(conn)
|
|
if err := enc.Encode(&request); err != nil {
|
|
return err
|
|
}
|
|
dec := json.NewDecoder(bufio.NewReader(conn))
|
|
for {
|
|
var reply ipc.Envelope
|
|
if err := dec.Decode(&reply); err != nil {
|
|
return err
|
|
}
|
|
if reply.Kind == "ack" {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func trackerSocketPath() string {
|
|
if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
|
|
return filepath.Join(dir, "agent-tracker.sock")
|
|
}
|
|
return filepath.Join(os.TempDir(), "agent-tracker.sock")
|
|
}
|
|
|
|
func trackerSortTasks(tasks []ipc.Task) {
|
|
sort.SliceStable(tasks, func(i, j int) bool {
|
|
left, right := tasks[i], tasks[j]
|
|
leftRank, rightRank := trackerTaskStatusRank(left.Status), trackerTaskStatusRank(right.Status)
|
|
if leftRank != rightRank {
|
|
return leftRank < rightRank
|
|
}
|
|
switch left.Status {
|
|
case trackerTaskStatusInProgress:
|
|
return left.StartedAt < right.StartedAt
|
|
case trackerTaskStatusCompleted:
|
|
if left.Acknowledged != right.Acknowledged {
|
|
return !left.Acknowledged && right.Acknowledged
|
|
}
|
|
li, hasLi := trackerParseTimestamp(left.CompletedAt)
|
|
rj, hasRj := trackerParseTimestamp(right.CompletedAt)
|
|
if hasLi && hasRj && !li.Equal(rj) {
|
|
return li.After(rj)
|
|
}
|
|
if hasLi != hasRj {
|
|
return hasLi
|
|
}
|
|
}
|
|
li, hasLi := trackerParseTimestamp(left.StartedAt)
|
|
rj, hasRj := trackerParseTimestamp(right.StartedAt)
|
|
if hasLi && hasRj && !li.Equal(rj) {
|
|
return li.After(rj)
|
|
}
|
|
if hasLi != hasRj {
|
|
return hasLi
|
|
}
|
|
return left.Summary < right.Summary
|
|
})
|
|
}
|
|
|
|
func trackerTaskStatusRank(status string) int {
|
|
switch status {
|
|
case trackerTaskStatusInProgress:
|
|
return 0
|
|
case trackerTaskStatusCompleted:
|
|
return 1
|
|
default:
|
|
return 2
|
|
}
|
|
}
|
|
|
|
func trackerParseTimestamp(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 trackerTaskIndicator(task ipc.Task, now time.Time) string {
|
|
switch task.Status {
|
|
case trackerTaskStatusInProgress:
|
|
frames := []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}
|
|
return string(frames[int(now.UnixNano()/int64(100*time.Millisecond))%len(frames)])
|
|
case trackerTaskStatusCompleted:
|
|
if task.Acknowledged {
|
|
return "✓"
|
|
}
|
|
return "⚑"
|
|
default:
|
|
return "•"
|
|
}
|
|
}
|
|
|
|
func trackerLiveDuration(task ipc.Task, now time.Time) string {
|
|
start, ok := trackerParseTimestamp(task.StartedAt)
|
|
if !ok {
|
|
return trackerFormatDuration(task.DurationSeconds)
|
|
}
|
|
if task.Status == trackerTaskStatusCompleted {
|
|
if end, ok := trackerParseTimestamp(task.CompletedAt); ok {
|
|
return trackerFormatDuration(end.Sub(start).Seconds())
|
|
}
|
|
return trackerFormatDuration(task.DurationSeconds)
|
|
}
|
|
return trackerFormatDuration(now.Sub(start).Seconds())
|
|
}
|
|
|
|
func trackerFormatDuration(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 focusTrackerTask(task ipc.Task) error {
|
|
if strings.TrimSpace(task.SessionID) == "" {
|
|
return fmt.Errorf("session required to focus task")
|
|
}
|
|
if err := runTmux("switch-client", "-t", strings.TrimSpace(task.SessionID)); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(task.WindowID) != "" {
|
|
if err := runTmux("select-window", "-t", strings.TrimSpace(task.WindowID)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if strings.TrimSpace(task.Pane) != "" {
|
|
if err := runTmux("select-pane", "-t", strings.TrimSpace(task.Pane)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func trackerFirstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|