theniceboy/agent-tracker/cmd/agent/tracker_panel.go
2026-03-27 21:04:47 -07:00

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 ""
}