agent tracker update

This commit is contained in:
David Chen 2026-04-02 13:45:55 -07:00
parent 5064629d61
commit 0bfcb8d7c3
31 changed files with 1741 additions and 3320 deletions

View file

@ -1,146 +0,0 @@
package main
import (
"strings"
"testing"
)
func TestActivityCopyOptionsIncludeCoreFields(t *testing.T) {
proc := &activityProcess{
PID: 123,
PPID: 45,
DownKBps: 12.5,
UpKBps: 4.25,
ShortCommand: "node",
Command: "node server.js --port 3000",
Ports: []string{"3000", "9229"},
Tmux: &activityTmuxLocation{
SessionID: "$1",
SessionName: "3-Config",
WindowID: "@12",
WindowIndex: "2",
WindowName: "agent",
PaneID: "%31",
},
}
options := activityCopyOptions(proc)
joined := make([]string, 0, len(options))
for _, option := range options {
joined = append(joined, option.Label+":"+option.Value)
}
text := strings.Join(joined, "\n")
for _, expected := range []string{"PID:123", "Parent PID:45", "Process:node", "Command:node server.js --port 3000", "Download:12.5K", "Upload:4.25K", "Session:3-Config ($1)", "Window:2 agent (@12)", "Pane:%31", "Ports:3000, 9229"} {
if !strings.Contains(text, expected) {
t.Fatalf("expected %q in copy options, got %q", expected, text)
}
}
}
func TestParseActivityNetworkLine(t *testing.T) {
parsed, ok := parseActivityNetworkLine("Google Chrome H.34802,54961,47736,")
if !ok {
t.Fatal("expected network line to parse")
}
if parsed.PID != 34802 || parsed.BytesIn != 54961 || parsed.BytesOut != 47736 {
t.Fatalf("unexpected parsed network line: %#v", parsed)
}
}
func TestActivityMonitorRenderIncludesNetworkColumns(t *testing.T) {
model := newActivityMonitorModel("@12", true)
model.width = 120
model.height = 24
model.processes = map[int]*activityProcess{123: {
PID: 123,
CPU: 10.5,
ResidentMB: 128,
DownKBps: 64,
UpKBps: 8.5,
ShortCommand: "node",
Command: "node server.js",
}}
model.rows = []activityRow{{PID: 123}}
model.selectedPID = 123
model.selectedRow = 0
table := model.renderTable(80, 10)
if !strings.Contains(table, "DOWN") || !strings.Contains(table, "UP") {
t.Fatalf("expected network columns in table, got %q", table)
}
if !strings.Contains(table, "64.0K") || !strings.Contains(table, "8.50K") {
t.Fatalf("expected network speeds in table, got %q", table)
}
}
func TestActivityMonitorDefaultsToAllProcesses(t *testing.T) {
model := newActivityMonitorModel("@12", true)
if !model.showAllProcesses {
t.Fatal("expected activity monitor to default to all processes")
}
}
func TestActivityMonitorNetworkHotkeysSetSeparateSortColumns(t *testing.T) {
model := newActivityMonitorModel("@12", true)
updated, _ := model.updateNormal("j")
activity := updated.(*activityMonitorBT)
if activity.sortKey != activitySortDownload {
t.Fatalf("expected j to sort by download, got %v", activity.sortKey)
}
if !activity.sortDescending {
t.Fatal("expected download sort to default descending")
}
updated, _ = activity.updateNormal("k")
activity = updated.(*activityMonitorBT)
if activity.sortKey != activitySortUpload {
t.Fatalf("expected k to sort by upload, got %v", activity.sortKey)
}
if !activity.sortDescending {
t.Fatal("expected upload sort to default descending")
}
}
func TestActivityMonitorYOpensCopyMenu(t *testing.T) {
model := newActivityMonitorModel("@12", true)
model.processes = map[int]*activityProcess{123: {PID: 123, PPID: 1, ShortCommand: "node", Command: "node server.js"}}
model.rows = []activityRow{{PID: 123}}
model.selectedPID = 123
model.selectedRow = 0
updated, _ := model.updateNormal("y")
activity := updated.(*activityMonitorBT)
if len(activity.copyOptions) == 0 {
t.Fatalf("expected copy options to open")
}
if activity.copyOptions[0].Label != "PID" {
t.Fatalf("expected first copy option to be PID, got %#v", activity.copyOptions[0])
}
}
func TestActivityMonitorCopyMenuCopiesSelectedOption(t *testing.T) {
model := newActivityMonitorModel("@12", true)
model.copyOptions = []activityCopyOption{{Label: "PID", Value: "123"}, {Label: "Command", Value: "node server.js"}}
model.copyOptionIndex = 1
var copied string
prev := activityClipboardWriter
activityClipboardWriter = func(value string) error {
copied = value
return nil
}
defer func() { activityClipboardWriter = prev }()
updated, _ := model.updateCopyMenu("enter")
activity := updated.(*activityMonitorBT)
if copied != "node server.js" {
t.Fatalf("expected copied value %q, got %q", "node server.js", copied)
}
if len(activity.copyOptions) != 0 {
t.Fatalf("expected copy menu to close after copying")
}
if !strings.Contains(strings.ToLower(activity.currentStatus()), "copied command") {
t.Fatalf("expected copied status, got %q", activity.currentStatus())
}
}

View file

@ -1,190 +0,0 @@
package main
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func gitCheckoutBranch(t *testing.T, repo, branch string) {
t.Helper()
cmd := exec.Command("git", "checkout", "-b", branch)
cmd.Dir = repo
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git checkout -b %s: %v: %s", branch, err, strings.TrimSpace(string(output)))
}
}
func TestResolveStartSourceBranchUsesCurrentLocalBranch(t *testing.T) {
repo := t.TempDir()
initTestGitRepo(t, repo)
if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("hello\n"), 0o644); err != nil {
t.Fatalf("write readme: %v", err)
}
gitAddPath(t, repo, "README.md")
gitCommitAll(t, repo, "initial commit")
gitCheckoutBranch(t, repo, "release")
branch := resolveStartSourceBranch(repo, &repoConfig{BaseBranch: "main"})
if branch != "release" {
t.Fatalf("expected current local branch release, got %q", branch)
}
}
func TestResolveBootstrapStartOptionsPrefersRecordedValues(t *testing.T) {
options := resolveBootstrapStartOptions("/tmp/repo", &repoConfig{BaseBranch: "main"}, &agentRecord{SourceBranch: "release", KeepWorktree: true})
if options.SourceBranch != "release" {
t.Fatalf("expected recorded source branch release, got %q", options.SourceBranch)
}
if !options.KeepWorktree {
t.Fatalf("expected keep-worktree to stay enabled")
}
options = resolveBootstrapStartOptions("/tmp/repo", &repoConfig{BaseBranch: "main"}, nil)
if options.SourceBranch != "main" {
t.Fatalf("expected repo config base branch main, got %q", options.SourceBranch)
}
if options.KeepWorktree {
t.Fatalf("expected keep-worktree to default off")
}
}
func TestAgentRunPaneCommandLeavesFlutterPaneIdleWithoutDevice(t *testing.T) {
record := &agentRecord{WorkspaceRoot: "/tmp/demo", Runtime: "flutter", Device: ""}
cmd := agentRunPaneCommand(record)
if strings.Contains(cmd, "./ensure-server.sh") {
t.Fatalf("expected empty-device flutter run pane to stay idle, got %q", cmd)
}
if !strings.Contains(cmd, "exec ${SHELL:-/bin/zsh}") {
t.Fatalf("expected empty-device flutter run pane to open a shell, got %q", cmd)
}
record.Device = "web-server"
cmd = agentRunPaneCommand(record)
if !strings.Contains(cmd, "./ensure-server.sh") {
t.Fatalf("expected configured flutter device to auto-start, got %q", cmd)
}
}
func TestLoadFeatureConfigDefaultsMissingFlutterDeviceToWebServer(t *testing.T) {
path := filepath.Join(t.TempDir(), "agent.json")
payload := map[string]any{"feature": "demo", "is_flutter": true}
data, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := loadFeatureConfig(path)
if err != nil {
t.Fatalf("load feature config: %v", err)
}
if cfg.Device != defaultManagedDeviceID {
t.Fatalf("expected missing flutter device to default to %q, got %q", defaultManagedDeviceID, cfg.Device)
}
}
func TestSaveFeatureConfigPreservesExplicitEmptyDevice(t *testing.T) {
path := filepath.Join(t.TempDir(), "agent.json")
if err := saveFeatureConfig(path, featureConfig{Feature: "demo", Device: "", IsFlutter: true}); err != nil {
t.Fatalf("save feature config: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read feature config: %v", err)
}
text := string(data)
if !strings.Contains(text, "\"device\": \"\"") {
t.Fatalf("expected explicit empty device to be persisted, got %q", text)
}
}
func TestRunFeatureCommandSyncsRegistryDeviceAndBrowserState(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
workspace := filepath.Join(home, "repo", ".agents", "demo")
if err := os.MkdirAll(workspace, 0o755); err != nil {
t.Fatalf("mkdir workspace: %v", err)
}
featurePath := filepath.Join(workspace, "agent.json")
if err := saveFeatureConfig(featurePath, featureConfig{Feature: "demo", Device: "ipm", IsFlutter: true}); err != nil {
t.Fatalf("save feature config: %v", err)
}
reg := &registry{Agents: map[string]*agentRecord{
"demo": {
ID: "demo",
WorkspaceRoot: workspace,
FeatureConfig: featurePath,
Device: "ipm",
BrowserEnabled: false,
},
}}
if err := saveRegistry(reg); err != nil {
t.Fatalf("save registry: %v", err)
}
if err := runFeatureCommand([]string{"--workspace", workspace, "--device", "web-server"}); err != nil {
t.Fatalf("run feature command: %v", err)
}
updatedCfg, err := loadFeatureConfig(featurePath)
if err != nil {
t.Fatalf("load feature config: %v", err)
}
if updatedCfg.Device != "web-server" {
t.Fatalf("expected feature config device web-server, got %q", updatedCfg.Device)
}
updatedReg, err := loadRegistry()
if err != nil {
t.Fatalf("load registry: %v", err)
}
record := updatedReg.Agents["demo"]
if record == nil {
t.Fatal("expected registry record")
}
if record.Device != "web-server" {
t.Fatalf("expected registry device web-server, got %q", record.Device)
}
if !record.BrowserEnabled {
t.Fatal("expected browser to be enabled for web-server device")
}
}
func TestRunDestroyRequiresConfirmWhenRepoHasUncommittedChanges(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
workspace := filepath.Join(home, "repo", ".agents", "demo")
repoCopy := filepath.Join(workspace, "repo")
if err := os.MkdirAll(workspace, 0o755); err != nil {
t.Fatalf("mkdir workspace: %v", err)
}
if err := os.MkdirAll(repoCopy, 0o755); err != nil {
t.Fatalf("mkdir repo copy: %v", err)
}
initTestGitRepo(t, repoCopy)
if err := os.WriteFile(filepath.Join(repoCopy, "README.md"), []byte("dirty\n"), 0o644); err != nil {
t.Fatalf("write dirty repo file: %v", err)
}
reg := &registry{Agents: map[string]*agentRecord{
"demo": {
ID: "demo",
WorkspaceRoot: workspace,
RepoCopyPath: repoCopy,
},
}}
if err := saveRegistry(reg); err != nil {
t.Fatalf("save registry: %v", err)
}
err := runDestroy([]string{"--id", "demo"})
if err == nil || !strings.Contains(err.Error(), "--confirm destroy") {
t.Fatalf("expected destroy confirm error, got %v", err)
}
}

View file

@ -1,51 +0,0 @@
package main
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func TestChromeAppleEventsEnabledReadsPreference(t *testing.T) {
if runtime.GOOS != "darwin" {
t.Skip("darwin-only preference path")
}
home := t.TempDir()
t.Setenv("HOME", home)
prefsPath := filepath.Join(home, "Library", "Application Support", "Google", "Chrome", "Default", "Preferences")
if err := os.MkdirAll(filepath.Dir(prefsPath), 0o755); err != nil {
t.Fatalf("mkdir prefs dir: %v", err)
}
if err := os.WriteFile(prefsPath, []byte(`{"browser":{"allow_javascript_apple_events":true}}`), 0o644); err != nil {
t.Fatalf("write prefs: %v", err)
}
enabled, err := chromeAppleEventsEnabled()
if err != nil {
t.Fatalf("chromeAppleEventsEnabled: %v", err)
}
if !enabled {
t.Fatalf("expected allow_javascript_apple_events=true")
}
}
func TestEnsureChromeAppleEventsEnabledErrorsWhenDisabled(t *testing.T) {
if runtime.GOOS != "darwin" {
t.Skip("darwin-only preference path")
}
home := t.TempDir()
t.Setenv("HOME", home)
prefsPath := filepath.Join(home, "Library", "Application Support", "Google", "Chrome", "Default", "Preferences")
if err := os.MkdirAll(filepath.Dir(prefsPath), 0o755); err != nil {
t.Fatalf("mkdir prefs dir: %v", err)
}
if err := os.WriteFile(prefsPath, []byte(`{"browser":{"allow_javascript_apple_events":false}}`), 0o644); err != nil {
t.Fatalf("write prefs: %v", err)
}
err := ensureChromeAppleEventsEnabled()
if err == nil {
t.Fatalf("expected disabled preference to fail")
}
}

View file

@ -1,32 +0,0 @@
package main
import (
"reflect"
"testing"
)
func TestPreferredNewWindowTarget(t *testing.T) {
if got := preferredNewWindowTarget("@9", true, "@3"); got != "@9" {
t.Fatalf("expected explicit target window, got %q", got)
}
if got := preferredNewWindowTarget("", true, "@3"); got != "@3" {
t.Fatalf("expected current tmux window fallback, got %q", got)
}
if got := preferredNewWindowTarget("", false, "@3"); got != "" {
t.Fatalf("expected no target outside tmux, got %q", got)
}
}
func TestPositionedNewWindowArgs(t *testing.T) {
got := positionedNewWindowArgs("feature-x", "/tmp/repo", "@7")
want := []string{"new-window", "-P", "-F", "#{window_id}", "-a", "-t", "@7", "-n", "feature-x", "-c", "/tmp/repo"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("expected positioned new-window args %v, got %v", want, got)
}
got = positionedNewWindowArgs("feature-x", "/tmp/repo", "")
want = []string{"new-window", "-P", "-F", "#{window_id}", "-n", "feature-x", "-c", "/tmp/repo"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("expected default new-window args %v, got %v", want, got)
}
}

View file

@ -1,769 +0,0 @@
package main
import (
"flag"
"fmt"
"io"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/gdamore/tcell/v2"
)
type dashboardMode int
const (
modeView dashboardMode = iota
modeEditNotes
modeEditTask
modeTodoPrompt
modeConfirmDestroy
)
type todoPromptMode int
const (
todoPromptAdd todoPromptMode = iota
todoPromptEdit
)
type todoPrompt struct {
active bool
mode todoPromptMode
text []rune
cursor int
index int
}
func runDashboard(args []string) error {
fs := flag.NewFlagSet("agent dashboard", flag.ContinueOnError)
var agentID string
fs.StringVar(&agentID, "agent-id", "", "agent id")
fs.SetOutput(io.Discard)
if err := fs.Parse(args); err != nil {
return err
}
if agentID == "" {
return fmt.Errorf("agent-id is required")
}
reg, err := loadRegistry()
if err != nil {
return err
}
record := reg.Agents[agentID]
if record == nil {
return fmt.Errorf("unknown agent: %s", agentID)
}
if record.Dashboard.Todos == nil {
record.Dashboard.Todos = []todoItem{}
}
cfg := loadAppConfig()
screen, err := tcell.NewScreen()
if err != nil {
return err
}
if err := screen.Init(); err != nil {
return err
}
defer screen.Fini()
screen.Clear()
selectedCol := 0
todoIndex := 0
noteScroll := 0
taskCursor := utf8.RuneCountInString(record.Dashboard.CurrentTask)
noteCursor := len([]rune(record.Dashboard.Notes))
mode := modeView
prompt := todoPrompt{}
helpVisible := false
persist := func() {
fresh, err := loadRegistry()
if err != nil {
return
}
record.UpdatedAt = time.Now()
fresh.Agents[record.ID] = record
_ = saveRegistry(fresh)
}
ensureTodoIndex := func() {
if len(record.Dashboard.Todos) == 0 {
todoIndex = 0
return
}
if todoIndex < 0 {
todoIndex = 0
}
if todoIndex >= len(record.Dashboard.Todos) {
todoIndex = len(record.Dashboard.Todos) - 1
}
}
draw := func() {
screen.Clear()
w, h := screen.Size()
if w <= 0 || h <= 0 {
return
}
headerStyle := tcell.StyleDefault.Foreground(tcell.ColorLightCyan).Bold(true)
selectedHeaderStyle := headerStyle.Background(tcell.ColorDarkSlateGray)
selectedStyle := tcell.StyleDefault.Background(tcell.ColorDarkSlateGray)
normalStyle := tcell.StyleDefault.Foreground(tcell.ColorWhite)
subtleStyle := tcell.StyleDefault.Foreground(tcell.ColorLightSlateGray)
actionStyle := tcell.StyleDefault.Foreground(tcell.ColorLightSalmon)
colWidths := []int{
maxInt(18, w*30/100),
maxInt(24, w*38/100),
maxInt(18, w*22/100),
}
actionsWidth := maxInt(10, w-colWidths[0]-colWidths[1]-colWidths[2]-3)
used := colWidths[0] + colWidths[1] + colWidths[2] + actionsWidth + 3
if used > w {
actionsWidth -= used - w
}
if actionsWidth < 8 {
actionsWidth = 8
}
contentHeight := h - 1
if contentHeight < 2 {
contentHeight = h
}
columns := []struct {
title string
x int
w int
}{
{title: "TODOS", x: 0, w: colWidths[0]},
{title: "NOTES", x: colWidths[0] + 1, w: colWidths[1]},
{title: "CURRENT TASK", x: colWidths[0] + colWidths[1] + 2, w: colWidths[2]},
{title: "ACTIONS", x: colWidths[0] + colWidths[1] + colWidths[2] + 3, w: actionsWidth},
}
for idx, col := range columns {
style := headerStyle
if idx == selectedCol && mode == modeView {
style = selectedHeaderStyle
}
writeStyledLine(screen, col.x, 0, padRight(truncate(col.title, col.w), col.w), style)
}
for _, sepX := range []int{colWidths[0], colWidths[0] + colWidths[1] + 1, colWidths[0] + colWidths[1] + colWidths[2] + 2} {
if sepX >= 0 && sepX < w {
for row := 0; row < h; row++ {
screen.SetContent(sepX, row, '│', nil, subtleStyle)
}
}
}
for row := 1; row < contentHeight; row++ {
for x := 0; x < w; x++ {
screen.SetContent(x, row, ' ', nil, tcell.StyleDefault)
}
}
ensureTodoIndex()
visibleTodoRows := contentHeight - 1
if visibleTodoRows < 1 {
visibleTodoRows = 1
}
todoOffset := 0
if todoIndex >= visibleTodoRows {
todoOffset = todoIndex - visibleTodoRows + 1
}
for row := 0; row < visibleTodoRows; row++ {
idx := todoOffset + row
lineStyle := normalStyle
if selectedCol == 0 && idx == todoIndex && mode == modeView {
lineStyle = selectedStyle
}
text := ""
if idx < len(record.Dashboard.Todos) {
box := "[ ]"
if record.Dashboard.Todos[idx].Done {
box = "[x]"
}
text = box + " " + record.Dashboard.Todos[idx].Title
} else if len(record.Dashboard.Todos) == 0 && row == 0 {
text = cfg.Keys.AddTodo + " add todo"
lineStyle = subtleStyle
}
writeStyledLine(screen, columns[0].x, row+1, padRight(truncate(text, columns[0].w), columns[0].w), lineStyle)
}
noteLines := strings.Split(record.Dashboard.Notes, "\n")
if len(noteLines) == 0 {
noteLines = []string{""}
}
if noteScroll < 0 {
noteScroll = 0
}
maxScroll := maxInt(0, len(noteLines)-visibleTodoRows)
if noteScroll > maxScroll {
noteScroll = maxScroll
}
for row := 0; row < visibleTodoRows; row++ {
idx := noteScroll + row
style := normalStyle
if selectedCol == 1 && mode == modeView {
style = selectedStyle
}
line := ""
if idx < len(noteLines) {
line = noteLines[idx]
} else if record.Dashboard.Notes == "" && row == 0 {
line = "Enter to edit notes"
style = subtleStyle
}
if selectedCol == 1 && mode == modeEditNotes {
style = selectedStyle
}
writeStyledLine(screen, columns[1].x, row+1, padRight(truncate(line, columns[1].w), columns[1].w), style)
}
taskLines := wrapText(record.Dashboard.CurrentTask, maxInt(1, columns[2].w))
if len(taskLines) == 0 {
taskLines = []string{""}
}
for row := 0; row < visibleTodoRows; row++ {
style := normalStyle
if selectedCol == 2 && mode == modeView {
style = selectedStyle
}
if selectedCol == 2 && mode == modeEditTask {
style = selectedStyle
}
line := ""
if row < len(taskLines) {
line = taskLines[row]
} else if record.Dashboard.CurrentTask == "" && row == 0 {
line = "Enter to set current task"
style = subtleStyle
}
writeStyledLine(screen, columns[2].x, row+1, padRight(truncate(line, columns[2].w), columns[2].w), style)
}
actionLines := []string{"[D] Destroy"}
for row := 0; row < visibleTodoRows; row++ {
style := actionStyle
if selectedCol == 3 && mode == modeView {
style = selectedStyle.Foreground(tcell.ColorLightSalmon)
}
line := ""
if row < len(actionLines) {
line = actionLines[row]
}
writeStyledLine(screen, columns[3].x, row+1, padRight(truncate(line, columns[3].w), columns[3].w), style)
}
if helpVisible && h > 1 {
help := fmt.Sprintf("%s/%s/%s/%s move %s edit %s cancel %s add todo %s toggle %s destroy", cfg.Keys.MoveLeft, cfg.Keys.MoveRight, cfg.Keys.MoveUp, cfg.Keys.MoveDown, cfg.Keys.Edit, cfg.Keys.Cancel, cfg.Keys.AddTodo, cfg.Keys.ToggleTodo, cfg.Keys.Destroy)
writeStyledLine(screen, 0, h-1, padRight(truncate(help, w), w), subtleStyle)
}
if prompt.active {
drawPopup(screen, w, h, promptTitle(prompt.mode), string(prompt.text), prompt.cursor)
}
if mode == modeConfirmDestroy {
drawPopup(screen, w, h, "Destroy agent", "Destroy this agent? [y/N]", len([]rune("Destroy this agent? [y/N]")))
}
if selectedCol == 1 && mode == modeEditNotes {
line, col := cursorLineCol(record.Dashboard.Notes, noteCursor)
displayLine := line - noteScroll + 1
if displayLine >= 1 && displayLine < h {
x := columns[1].x + minInt(col, columns[1].w-1)
screen.ShowCursor(x, displayLine)
}
} else if selectedCol == 2 && mode == modeEditTask {
screen.ShowCursor(columns[2].x+minInt(taskCursor, columns[2].w-1), 1)
} else {
screen.HideCursor()
}
screen.Show()
}
draw()
for {
ev := screen.PollEvent()
switch tev := ev.(type) {
case *tcell.EventResize:
screen.Sync()
draw()
case *tcell.EventKey:
if matchesKey(tev, cfg.Keys.Help) {
helpVisible = !helpVisible
draw()
continue
}
if prompt.active {
handled := handleSingleLineEditKey(tev, &prompt.text, &prompt.cursor, true)
if tev.Key() == tcell.KeyEnter {
text := strings.TrimSpace(string(prompt.text))
if prompt.mode == todoPromptAdd && text != "" {
record.Dashboard.Todos = append(record.Dashboard.Todos, todoItem{Title: text})
todoIndex = len(record.Dashboard.Todos) - 1
persist()
} else if prompt.mode == todoPromptEdit && prompt.index >= 0 && prompt.index < len(record.Dashboard.Todos) {
record.Dashboard.Todos[prompt.index].Title = text
persist()
}
prompt.active = false
draw()
continue
}
if tev.Key() == tcell.KeyEscape {
prompt.active = false
draw()
continue
}
if handled {
draw()
continue
}
}
if mode == modeConfirmDestroy {
if tev.Key() == tcell.KeyEscape || matchesKey(tev, cfg.Keys.Back) {
mode = modeView
draw()
continue
}
if matchesKey(tev, cfg.Keys.Confirm) {
screen.Fini()
return runDestroy([]string{"--id", record.ID})
}
if tev.Key() == tcell.KeyRune {
mode = modeView
draw()
}
continue
}
switch mode {
case modeEditNotes:
if tev.Key() == tcell.KeyEscape || matchesKey(tev, cfg.Keys.Cancel) {
mode = modeView
draw()
continue
}
if handleMultilineEditKey(tev, &record.Dashboard.Notes, &noteCursor) {
persist()
draw()
}
continue
case modeEditTask:
if tev.Key() == tcell.KeyEscape || matchesKey(tev, cfg.Keys.Cancel) {
mode = modeView
draw()
continue
}
if tev.Key() == tcell.KeyEnter {
mode = modeView
persist()
draw()
continue
}
if handleSingleLineEditKey(tev, (*[]rune)(nil), nil, false) {
}
runes := []rune(record.Dashboard.CurrentTask)
if handleSingleLineEditKey(tev, &runes, &taskCursor, false) {
record.Dashboard.CurrentTask = string(runes)
persist()
draw()
}
continue
}
if tev.Key() == tcell.KeyCtrlC {
continue
}
if matchesKey(tev, cfg.Keys.MoveLeft) {
selectedCol = maxInt(0, selectedCol-1)
draw()
continue
}
if matchesKey(tev, cfg.Keys.MoveRight) {
selectedCol = minInt(3, selectedCol+1)
draw()
continue
}
switch selectedCol {
case 0:
if matchesKey(tev, cfg.Keys.MoveUp) {
todoIndex--
ensureTodoIndex()
draw()
continue
}
if matchesKey(tev, cfg.Keys.MoveDown) {
todoIndex++
ensureTodoIndex()
draw()
continue
}
if matchesKey(tev, cfg.Keys.AddTodo) {
prompt = todoPrompt{active: true, mode: todoPromptAdd, text: []rune{}, cursor: 0, index: -1}
draw()
continue
}
if matchesKey(tev, cfg.Keys.ToggleTodo) && len(record.Dashboard.Todos) > 0 {
record.Dashboard.Todos[todoIndex].Done = !record.Dashboard.Todos[todoIndex].Done
persist()
draw()
continue
}
if matchesKey(tev, cfg.Keys.DeleteTodo) && len(record.Dashboard.Todos) > 0 {
record.Dashboard.Todos = append(record.Dashboard.Todos[:todoIndex], record.Dashboard.Todos[todoIndex+1:]...)
ensureTodoIndex()
persist()
draw()
continue
}
if tev.Key() == tcell.KeyEnter && len(record.Dashboard.Todos) > 0 {
text := []rune(record.Dashboard.Todos[todoIndex].Title)
prompt = todoPrompt{active: true, mode: todoPromptEdit, text: text, cursor: len(text), index: todoIndex}
draw()
continue
}
case 1:
if matchesKey(tev, cfg.Keys.MoveUp) {
noteScroll--
draw()
continue
}
if matchesKey(tev, cfg.Keys.MoveDown) {
noteScroll++
draw()
continue
}
if tev.Key() == tcell.KeyEnter || matchesKey(tev, cfg.Keys.Edit) {
mode = modeEditNotes
noteCursor = len([]rune(record.Dashboard.Notes))
draw()
continue
}
case 2:
if tev.Key() == tcell.KeyEnter || matchesKey(tev, cfg.Keys.Edit) {
mode = modeEditTask
taskCursor = len([]rune(record.Dashboard.CurrentTask))
draw()
continue
}
case 3:
if matchesKey(tev, cfg.Keys.Destroy) || tev.Key() == tcell.KeyEnter {
mode = modeConfirmDestroy
draw()
continue
}
}
if matchesKey(tev, cfg.Keys.Destroy) {
mode = modeConfirmDestroy
draw()
}
}
}
}
func promptTitle(mode todoPromptMode) string {
if mode == todoPromptAdd {
return "Add todo"
}
return "Edit todo"
}
func drawPopup(screen tcell.Screen, width, height int, title, text string, cursor int) {
boxW := minInt(maxInt(24, width*60/100), width-4)
boxH := 5
boxX := (width - boxW) / 2
boxY := maxInt(1, (height-boxH)/2)
style := tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite)
border := tcell.StyleDefault.Foreground(tcell.ColorLightCyan)
for y := 0; y < boxH; y++ {
for x := 0; x < boxW; x++ {
ch := ' '
st := style
if y == 0 || y == boxH-1 {
ch = '─'
st = border
}
if x == 0 || x == boxW-1 {
ch = '│'
st = border
}
if (x == 0 || x == boxW-1) && (y == 0 || y == boxH-1) {
switch {
case x == 0 && y == 0:
ch = '┌'
case x == boxW-1 && y == 0:
ch = '┐'
case x == 0 && y == boxH-1:
ch = '└'
default:
ch = '┘'
}
}
screen.SetContent(boxX+x, boxY+y, ch, nil, st)
}
}
writeStyledLine(screen, boxX+2, boxY+1, truncate(title, boxW-4), border.Bold(true))
writeStyledLine(screen, boxX+2, boxY+2, padRight(truncate(text, boxW-4), boxW-4), style)
screen.ShowCursor(boxX+2+minInt(cursor, boxW-5), boxY+2)
}
func handleSingleLineEditKey(ev *tcell.EventKey, text *[]rune, cursor *int, allowEnter bool) bool {
if text == nil || cursor == nil {
return false
}
switch ev.Key() {
case tcell.KeyLeft:
if *cursor > 0 {
*cursor = *cursor - 1
}
return true
case tcell.KeyRight:
if *cursor < len(*text) {
*cursor = *cursor + 1
}
return true
case tcell.KeyBackspace, tcell.KeyBackspace2:
if *cursor > 0 {
*text = append((*text)[:*cursor-1], (*text)[*cursor:]...)
*cursor = *cursor - 1
}
return true
case tcell.KeyCtrlA:
*cursor = 0
return true
case tcell.KeyCtrlE:
*cursor = len(*text)
return true
case tcell.KeyCtrlU:
*text = (*text)[*cursor:]
*cursor = 0
return true
case tcell.KeyCtrlW:
start := previousWordBoundary(*text, *cursor)
*text = append((*text)[:start], (*text)[*cursor:]...)
*cursor = start
return true
case tcell.KeyRune:
r := ev.Rune()
*text = append((*text)[:*cursor], append([]rune{r}, (*text)[*cursor:]...)...)
*cursor = *cursor + 1
return true
case tcell.KeyEnter:
return allowEnter
default:
return false
}
}
func handleMultilineEditKey(ev *tcell.EventKey, value *string, cursor *int) bool {
runes := []rune(*value)
switch ev.Key() {
case tcell.KeyLeft:
if *cursor > 0 {
*cursor = *cursor - 1
}
case tcell.KeyRight:
if *cursor < len(runes) {
*cursor = *cursor + 1
}
case tcell.KeyUp:
line, col := cursorLineCol(*value, *cursor)
if line > 0 {
*cursor = lineColToIndex(*value, line-1, col)
}
case tcell.KeyDown:
line, col := cursorLineCol(*value, *cursor)
*cursor = lineColToIndex(*value, line+1, col)
case tcell.KeyBackspace, tcell.KeyBackspace2:
if *cursor > 0 {
runes = append(runes[:*cursor-1], runes[*cursor:]...)
*cursor = *cursor - 1
}
case tcell.KeyEnter:
runes = append(runes[:*cursor], append([]rune{'\n'}, runes[*cursor:]...)...)
*cursor = *cursor + 1
case tcell.KeyCtrlA:
line, _ := cursorLineCol(*value, *cursor)
*cursor = lineColToIndex(*value, line, 0)
case tcell.KeyCtrlE:
line, _ := cursorLineCol(*value, *cursor)
lines := strings.Split(*value, "\n")
if line >= len(lines) {
line = len(lines) - 1
}
if line < 0 {
line = 0
}
*cursor = lineColToIndex(*value, line, len([]rune(lines[line])))
case tcell.KeyCtrlU:
line, _ := cursorLineCol(*value, *cursor)
start := lineColToIndex(*value, line, 0)
runes = append(runes[:start], runes[*cursor:]...)
*cursor = start
case tcell.KeyCtrlW:
start := previousWordBoundary(runes, *cursor)
runes = append(runes[:start], runes[*cursor:]...)
*cursor = start
case tcell.KeyRune:
r := ev.Rune()
runes = append(runes[:*cursor], append([]rune{r}, runes[*cursor:]...)...)
*cursor = *cursor + 1
default:
return false
}
*value = string(runes)
return true
}
func previousWordBoundary(runes []rune, cursor int) int {
i := cursor
for i > 0 && unicode.IsSpace(runes[i-1]) {
i--
}
for i > 0 && !unicode.IsSpace(runes[i-1]) {
i--
}
return i
}
func cursorLineCol(value string, cursor int) (line, col int) {
runes := []rune(value)
if cursor < 0 {
cursor = 0
}
if cursor > len(runes) {
cursor = len(runes)
}
for i := 0; i < cursor; i++ {
if runes[i] == '\n' {
line++
col = 0
} else {
col++
}
}
return line, col
}
func lineColToIndex(value string, wantLine, wantCol int) int {
lines := strings.Split(value, "\n")
if wantLine < 0 {
wantLine = 0
}
if wantLine >= len(lines) {
wantLine = len(lines) - 1
}
if wantLine < 0 {
return 0
}
idx := 0
for i := 0; i < wantLine; i++ {
idx += len([]rune(lines[i])) + 1
}
lineRunes := []rune(lines[wantLine])
if wantCol > len(lineRunes) {
wantCol = len(lineRunes)
}
if wantCol < 0 {
wantCol = 0
}
return idx + wantCol
}
func matchesKey(ev *tcell.EventKey, binding string) bool {
binding = strings.TrimSpace(binding)
if binding == "" {
return false
}
switch strings.ToLower(binding) {
case "enter":
return ev.Key() == tcell.KeyEnter
case "escape", "esc":
return ev.Key() == tcell.KeyEscape
case "space":
return ev.Key() == tcell.KeyRune && ev.Rune() == ' '
default:
if len([]rune(binding)) == 1 {
return ev.Key() == tcell.KeyRune && unicode.ToLower(ev.Rune()) == unicode.ToLower([]rune(binding)[0])
}
return false
}
}
func wrapText(text string, width int) []string {
if width <= 0 {
return []string{""}
}
if text == "" {
return []string{""}
}
words := strings.Fields(text)
if len(words) == 0 {
return []string{""}
}
var lines []string
current := words[0]
for _, word := range words[1:] {
candidate := current + " " + word
if len([]rune(candidate)) <= width {
current = candidate
continue
}
lines = append(lines, current)
current = word
}
lines = append(lines, current)
return lines
}
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[:1])
}
return string(runes[:width-1]) + "…"
}
func writeStyledLine(s tcell.Screen, x, y int, text string, style tcell.Style) {
for idx, r := range []rune(text) {
s.SetContent(x+idx, y, r, nil, style)
}
}
func padRight(text string, width int) string {
count := len([]rune(text))
if count >= width {
return text
}
return text + strings.Repeat(" ", width-count)
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}

View file

@ -1,32 +0,0 @@
package main
import "testing"
func TestLoadManagedDevicesDefaultsToWebServer(t *testing.T) {
t.Setenv("HOME", t.TempDir())
devices := loadManagedDevices()
if len(devices) != 1 || devices[0] != defaultManagedDeviceID {
t.Fatalf("unexpected default devices: %#v", devices)
}
}
func TestSaveManagedDevicesKeepsWebServerFirst(t *testing.T) {
t.Setenv("HOME", t.TempDir())
if err := saveManagedDevices([]string{"ios", "web-server", "macos", "IOS"}); err != nil {
t.Fatalf("save managed devices: %v", err)
}
devices := loadManagedDevices()
if len(devices) != 3 {
t.Fatalf("unexpected devices length: %#v", devices)
}
if devices[0] != defaultManagedDeviceID || devices[1] != "ios" || devices[2] != "macos" {
t.Fatalf("unexpected normalized devices: %#v", devices)
}
}
func TestRemoveManagedDeviceRejectsWebServer(t *testing.T) {
t.Setenv("HOME", t.TempDir())
if err := removeManagedDevice(defaultManagedDeviceID); err == nil {
t.Fatal("expected web-server removal to fail")
}
}

View file

@ -1,256 +0,0 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func initTestGitRepo(t *testing.T, repo string) {
t.Helper()
cmd := exec.Command("git", "init")
cmd.Dir = repo
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git init: %v: %s", err, strings.TrimSpace(string(output)))
}
}
func gitAddPath(t *testing.T, repo, relPath string) {
t.Helper()
cmd := exec.Command("git", "add", "--", relPath)
cmd.Dir = repo
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git add %s: %v: %s", relPath, err, strings.TrimSpace(string(output)))
}
}
func gitCommitAll(t *testing.T, repo, message string) {
t.Helper()
cmd := exec.Command("git", "-c", "user.name=Test User", "-c", "user.email=test@example.com", "commit", "-m", message)
cmd.Dir = repo
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git commit: %v: %s", err, strings.TrimSpace(string(output)))
}
}
func gitStatusPath(t *testing.T, repo, relPath string) string {
t.Helper()
cmd := exec.Command("git", "status", "--porcelain", "--", relPath)
cmd.Dir = repo
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git status %s: %v: %s", relPath, err, strings.TrimSpace(string(output)))
}
return strings.TrimSpace(string(output))
}
func gitLsFilesPath(t *testing.T, repo, relPath string) string {
t.Helper()
cmd := exec.Command("git", "ls-files", "-v", "--", relPath)
cmd.Dir = repo
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git ls-files %s: %v: %s", relPath, err, strings.TrimSpace(string(output)))
}
return strings.TrimSpace(string(output))
}
func TestWriteFlutterHelperScriptsCreatesHotReloadScript(t *testing.T) {
workspace := t.TempDir()
repo := filepath.Join(workspace, "repo")
if err := os.MkdirAll(repo, 0o755); err != nil {
t.Fatalf("mkdir repo: %v", err)
}
initTestGitRepo(t, repo)
if err := os.WriteFile(filepath.Join(repo, "hot-reload.sh"), []byte("#!/bin/bash\nexit 0\n"), 0o755); err != nil {
t.Fatalf("seed tracked hot-reload.sh: %v", err)
}
gitAddPath(t, repo, "hot-reload.sh")
gitCommitAll(t, repo, "seed hot reload")
if err := writeFlutterHelperScripts(workspace, repo, "http://localhost:9100", "web-server"); err != nil {
t.Fatalf("write flutter helper scripts: %v", err)
}
for _, path := range []string{filepath.Join(workspace, "ensure-server.sh"), filepath.Join(repo, "hot-reload.sh")} {
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat %s: %v", path, err)
}
if info.Mode()&0o111 == 0 {
t.Fatalf("expected %s to be executable, mode=%v", path, info.Mode())
}
}
if _, err := os.Stat(filepath.Join(workspace, "hot-reload.sh")); !os.IsNotExist(err) {
t.Fatalf("expected workspace hot-reload.sh to be removed, err=%v", err)
}
ensureData, err := os.ReadFile(filepath.Join(workspace, "ensure-server.sh"))
if err != nil {
t.Fatalf("read ensure-server.sh: %v", err)
}
ensureText := string(ensureData)
for _, snippet := range []string{
`tmux capture-pane -p -S -200 -t "$TMUX_PANE"`,
`browser refresh --workspace "$DIR" --preserve-focus`,
} {
if !strings.Contains(ensureText, snippet) {
t.Fatalf("expected ensure-server.sh to contain %q", snippet)
}
}
data, err := os.ReadFile(filepath.Join(repo, "hot-reload.sh"))
if err != nil {
t.Fatalf("read hot-reload.sh: %v", err)
}
text := string(data)
for _, snippet := range []string{
`WORKSPACE_DIR="$(dirname "$REPO_DIR")"`,
`flutter analyze lib --no-fatal-infos --no-fatal-warnings`,
`tmux send-keys -t "$target_pane" r`,
`cd '$WORKSPACE_DIR' && ./ensure-server.sh`,
`browser refresh --workspace "$WORKSPACE_DIR" --preserve-focus`,
} {
if !strings.Contains(text, snippet) {
t.Fatalf("expected hot-reload.sh to contain %q", snippet)
}
}
if status := gitStatusPath(t, repo, "hot-reload.sh"); status != "" {
t.Fatalf("expected repo hot-reload.sh to stay hidden from git status, got %q", status)
}
}
func TestWriteFlutterHelperScriptsRemovesLegacyBrowserHelpers(t *testing.T) {
workspace := t.TempDir()
repo := filepath.Join(workspace, "repo")
if err := os.MkdirAll(repo, 0o755); err != nil {
t.Fatalf("mkdir repo: %v", err)
}
initTestGitRepo(t, repo)
for _, name := range []string{"open-tab.sh", "refresh-tab.sh", "on-tmux-window-activate.sh"} {
if err := os.WriteFile(filepath.Join(workspace, name), []byte("legacy"), 0o755); err != nil {
t.Fatalf("seed %s: %v", name, err)
}
}
if err := os.WriteFile(filepath.Join(workspace, "hot-reload.sh"), []byte("legacy"), 0o755); err != nil {
t.Fatalf("seed workspace hot-reload.sh: %v", err)
}
if err := writeFlutterHelperScripts(workspace, repo, "http://localhost:9100", "web-server"); err != nil {
t.Fatalf("write flutter helper scripts: %v", err)
}
for _, name := range []string{"hot-reload.sh", "open-tab.sh", "refresh-tab.sh", "on-tmux-window-activate.sh"} {
if _, err := os.Stat(filepath.Join(workspace, name)); !os.IsNotExist(err) {
t.Fatalf("expected %s to be removed, err=%v", name, err)
}
}
}
func TestRunFeatureCommandWriteHelperScripts(t *testing.T) {
workspace := t.TempDir()
repo := filepath.Join(workspace, "repo")
if err := os.MkdirAll(repo, 0o755); err != nil {
t.Fatalf("mkdir repo: %v", err)
}
initTestGitRepo(t, repo)
if err := saveFeatureConfig(filepath.Join(workspace, "agent.json"), featureConfig{
Feature: "demo",
Port: 9100,
URL: "http://localhost:9100",
Device: "web-server",
IsFlutter: true,
}); err != nil {
t.Fatalf("save feature config: %v", err)
}
if err := runFeatureCommand([]string{"--workspace", workspace, "--write-helper-scripts"}); err != nil {
t.Fatalf("run feature command: %v", err)
}
if _, err := os.Stat(filepath.Join(repo, "hot-reload.sh")); err != nil {
t.Fatalf("expected hot-reload.sh after rewrite: %v", err)
}
}
func TestWriteFlutterHelperScriptsIgnoresUntrackedRepoHelper(t *testing.T) {
workspace := t.TempDir()
repo := filepath.Join(workspace, "repo")
if err := os.MkdirAll(repo, 0o755); err != nil {
t.Fatalf("mkdir repo: %v", err)
}
initTestGitRepo(t, repo)
if err := writeFlutterHelperScripts(workspace, repo, "http://localhost:9100", "web-server"); err != nil {
t.Fatalf("write flutter helper scripts: %v", err)
}
if status := gitStatusPath(t, repo, "hot-reload.sh"); status != "" {
t.Fatalf("expected untracked repo hot-reload.sh to stay hidden from git status, got %q", status)
}
data, err := os.ReadFile(filepath.Join(repo, ".git", "info", "exclude"))
if err != nil {
t.Fatalf("read .git/info/exclude: %v", err)
}
if !strings.Contains(string(data), "hot-reload.sh") {
t.Fatalf("expected .git/info/exclude to contain hot-reload.sh")
}
}
func TestRunBootstrapRewritesTrackedHotReloadScript(t *testing.T) {
t.Setenv("HOME", t.TempDir())
repoRoot := t.TempDir()
initTestGitRepo(t, repoRoot)
if err := os.WriteFile(filepath.Join(repoRoot, "pubspec.yaml"), []byte("name: demo\n"), 0o644); err != nil {
t.Fatalf("write pubspec: %v", err)
}
if err := os.WriteFile(filepath.Join(repoRoot, "hot-reload.sh"), []byte("#!/bin/bash\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write source hot-reload: %v", err)
}
gitAddPath(t, repoRoot, "pubspec.yaml")
gitAddPath(t, repoRoot, "hot-reload.sh")
gitCommitAll(t, repoRoot, "seed flutter repo")
workspace := filepath.Join(repoRoot, ".agents", "feature-x")
if err := os.MkdirAll(workspace, 0o755); err != nil {
t.Fatalf("mkdir workspace: %v", err)
}
if err := saveFeatureConfig(filepath.Join(workspace, "agent.json"), featureConfig{
Feature: "feature-x",
Port: 9100,
URL: "http://localhost:9100",
Device: "web-server",
IsFlutter: true,
}); err != nil {
t.Fatalf("save feature config: %v", err)
}
if err := runBootstrap([]string{"--workspace", workspace}); err != nil {
t.Fatalf("run bootstrap: %v", err)
}
repoCopy := filepath.Join(workspace, "repo")
data, err := os.ReadFile(filepath.Join(repoCopy, "hot-reload.sh"))
if err != nil {
t.Fatalf("read rewritten hot-reload: %v", err)
}
text := string(data)
for _, snippet := range []string{
`INFO="$WORKSPACE_DIR/agent.json"`,
`browser refresh --workspace "$WORKSPACE_DIR" --preserve-focus`,
} {
if !strings.Contains(text, snippet) {
t.Fatalf("expected rewritten hot-reload to contain %q", snippet)
}
}
if status := gitStatusPath(t, repoCopy, "hot-reload.sh"); status != "" {
t.Fatalf("expected rewritten hot-reload to stay hidden from git status, got %q", status)
}
if marker := gitLsFilesPath(t, repoCopy, "hot-reload.sh"); !strings.HasPrefix(marker, "S ") {
t.Fatalf("expected rewritten hot-reload to be marked skip-worktree, got %q", marker)
}
}

View file

@ -27,37 +27,35 @@ type registry struct {
}
type agentRecord struct {
ID string `json:"id"`
Name string `json:"name"`
RepoRoot string `json:"repo_root"`
WorkspaceRoot string `json:"workspace_root"`
RepoCopyPath string `json:"repo_copy_path"`
Branch string `json:"branch"`
SourceBranch string `json:"source_branch,omitempty"`
KeepWorktree bool `json:"keep_worktree,omitempty"`
Runtime string `json:"runtime,omitempty"`
Device string `json:"device,omitempty"`
FeatureConfig string `json:"feature_config,omitempty"`
RunLogPath string `json:"run_log_path,omitempty"`
Port int `json:"port,omitempty"`
URL string `json:"url,omitempty"`
BrowserEnabled bool `json:"browser_enabled,omitempty"`
TmuxSessionName string `json:"tmux_session_name,omitempty"`
TmuxSessionID string `json:"tmux_session_id,omitempty"`
TmuxWindowID string `json:"tmux_window_id,omitempty"`
Panes agentPanes `json:"panes"`
Dashboard dashboardDoc `json:"dashboard"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastFocusedAt *time.Time `json:"last_focused_at,omitempty"`
LaunchWindowID string `json:"-"`
ID string `json:"id"`
Name string `json:"name"`
RepoRoot string `json:"repo_root"`
WorkspaceRoot string `json:"workspace_root"`
RepoCopyPath string `json:"repo_copy_path"`
Branch string `json:"branch"`
SourceBranch string `json:"source_branch,omitempty"`
KeepWorktree bool `json:"keep_worktree,omitempty"`
Runtime string `json:"runtime,omitempty"`
Device string `json:"device,omitempty"`
FeatureConfig string `json:"feature_config,omitempty"`
RunLogPath string `json:"run_log_path,omitempty"`
Port int `json:"port,omitempty"`
URL string `json:"url,omitempty"`
BrowserEnabled bool `json:"browser_enabled,omitempty"`
TmuxSessionName string `json:"tmux_session_name,omitempty"`
TmuxSessionID string `json:"tmux_session_id,omitempty"`
TmuxWindowID string `json:"tmux_window_id,omitempty"`
Panes agentPanes `json:"panes"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastFocusedAt *time.Time `json:"last_focused_at,omitempty"`
LaunchWindowID string `json:"-"`
}
type agentPanes struct {
AI string `json:"ai,omitempty"`
Git string `json:"git,omitempty"`
Run string `json:"run,omitempty"`
Dashboard string `json:"dashboard,omitempty"`
AI string `json:"ai,omitempty"`
Git string `json:"git,omitempty"`
Run string `json:"run,omitempty"`
}
type agentStartOptions struct {
@ -65,20 +63,21 @@ type agentStartOptions struct {
KeepWorktree bool
}
type dashboardDoc struct {
Todos []todoItem `json:"todos"`
Notes string `json:"notes"`
CurrentTask string `json:"current_task"`
}
type todoItem struct {
Title string `json:"title"`
Done bool `json:"done"`
}
type appConfig struct {
Keys keyConfig `json:"keys"`
Devices []string `json:"devices,omitempty"`
Keys keyConfig `json:"keys"`
Devices []string `json:"devices,omitempty"`
StatusRight *statusRightConfig `json:"status_right,omitempty"`
}
type statusRightConfig struct {
CPU *bool `json:"cpu,omitempty"`
Network *bool `json:"network,omitempty"`
Memory *bool `json:"memory,omitempty"`
MemoryTotals *bool `json:"memory_totals,omitempty"`
Agent *bool `json:"agent,omitempty"`
Notes *bool `json:"notes,omitempty"`
FlashMoe *bool `json:"flash_moe,omitempty"`
Host *bool `json:"host,omitempty"`
}
type keyConfig struct {
@ -97,7 +96,6 @@ type keyConfig struct {
Help string `json:"help"`
FocusAI string `json:"focus_ai"`
FocusGit string `json:"focus_git"`
FocusDash string `json:"focus_dashboard"`
FocusRun string `json:"focus_run"`
}
@ -130,7 +128,7 @@ func main() {
func run(args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: agent <start|resume|list|destroy|init|config|setup|dashboard|tmux|tracker|browser|feature>")
return fmt.Errorf("usage: agent <start|resume|list|destroy|init|config|setup|tmux|tracker|browser|feature>")
}
switch args[0] {
case "start":
@ -147,8 +145,6 @@ func run(args []string) error {
return runConfig(args[1:])
case "setup":
return runSetup(args[1:])
case "dashboard":
return runDashboard(args[1:])
case "palette":
return runPalette(args[1:])
case "tmux":
@ -1006,7 +1002,7 @@ func destroyRequiresExplicitConfirm(record *agentRecord) (bool, error) {
func runTmuxCommand(args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: agent tmux <on-focus|focus|palette>")
return fmt.Errorf("usage: agent tmux <on-focus|focus|palette|right-status>")
}
switch args[0] {
case "on-focus":
@ -1015,6 +1011,8 @@ func runTmuxCommand(args []string) error {
return runTmuxFocus(args[1:])
case "palette":
return runTmuxPalette(args[1:])
case "right-status":
return runTmuxRightStatus(args[1:])
default:
return fmt.Errorf("unknown tmux subcommand: %s", args[0])
}
@ -1185,7 +1183,7 @@ func runTmuxFocus(args []string) error {
return err
}
if fs.NArg() == 0 {
return fmt.Errorf("usage: agent tmux focus <ai|git|dashboard|run>")
return fmt.Errorf("usage: agent tmux focus <ai|git|run>")
}
role := strings.ToLower(fs.Arg(0))
ctx, err := detectCurrentAgentFromTmux(windowID)
@ -1206,8 +1204,6 @@ func runTmuxFocus(args []string) error {
target = record.Panes.AI
case "git":
target = record.Panes.Git
case "dashboard":
return openDashboardPopup(record.ID)
case "run":
target = record.Panes.Run
default:
@ -1219,19 +1215,6 @@ func runTmuxFocus(args []string) error {
return runTmux("select-pane", "-t", target)
}
func openDashboardPopup(agentID string) error {
agentID = strings.TrimSpace(agentID)
if agentID == "" {
return fmt.Errorf("agent id is required")
}
exe, err := os.Executable()
if err != nil {
return err
}
cmd := fmt.Sprintf("%s dashboard --agent-id %s", shellQuote(exe), shellQuote(agentID))
return runTmux("display-popup", "-E", "-w", "78%", "-h", "80%", "-T", "dashboard", cmd)
}
func runTmuxPalette(args []string) error {
fs := flag.NewFlagSet("agent tmux palette", flag.ContinueOnError)
var windowID string
@ -2660,7 +2643,6 @@ func loadAppConfig() appConfig {
Help: "?",
FocusAI: "M-a",
FocusGit: "M-g",
FocusDash: "M-s",
FocusRun: "M-r",
}}
data, err := os.ReadFile(configPath())

View file

@ -20,6 +20,7 @@ const (
paletteModeTodos
paletteModeActivity
paletteModeDevices
paletteModeStatusRight
paletteModeTracker
)
@ -44,9 +45,8 @@ const (
paletteActionPromptStartAgent paletteActionKind = iota
paletteActionOpenActivityMonitor
paletteActionConfirmDestroy
paletteActionToggleTodo
paletteActionReloadTmuxConfig
paletteActionToggleMemoryDisplay
paletteActionOpenStatusRight
paletteActionOpenSnippets
paletteActionOpenTodos
paletteActionOpenDevices
@ -54,13 +54,12 @@ const (
)
type paletteAction struct {
Section string
Title string
Subtitle string
Keywords []string
Kind paletteActionKind
RepoRoot string
TodoIndex int
Section string
Title string
Subtitle string
Keywords []string
Kind paletteActionKind
RepoRoot string
}
type paletteResultKind int

View file

@ -55,6 +55,7 @@ type paletteModel struct {
todo *todoPanelModel
activity *activityMonitorBT
devices *devicePanelModel
status *statusRightPanelModel
tracker *trackerPanelModel
}
@ -329,15 +330,6 @@ func (r *paletteRuntime) persistRecord(update func(*agentRecord) error) error {
}
func (r *paletteRuntime) buildActions() []paletteAction {
memoryDisplayEnabled := paletteMemoryDisplayEnabled()
memoryTitle := "Hide memory display"
memorySubtitle := "Hide bottom-right tmux memory stats"
memoryKeywords := []string{"tmux", "memory", "status", "status-right", "hide", "bottom-right"}
if !memoryDisplayEnabled {
memoryTitle = "Show memory display"
memorySubtitle = "Show bottom-right tmux memory stats"
memoryKeywords = []string{"tmux", "memory", "status", "status-right", "show", "bottom-right"}
}
actions := []paletteAction{
{
Section: "Agent",
@ -402,10 +394,10 @@ func (r *paletteRuntime) buildActions() []paletteAction {
},
paletteAction{
Section: "System",
Title: memoryTitle,
Subtitle: memorySubtitle,
Keywords: memoryKeywords,
Kind: paletteActionToggleMemoryDisplay,
Title: "Bottom-right status",
Subtitle: "Open control center for tmux right-side status modules",
Keywords: []string{"tmux", "status", "status-right", "bottom-right", "control", "center", "istat", "cpu", "network", "memory", "notes", "host", "flash"},
Kind: paletteActionOpenStatusRight,
},
)
if strings.TrimSpace(r.agentID) == "" {
@ -414,20 +406,6 @@ func (r *paletteRuntime) buildActions() []paletteAction {
if r.record == nil {
return actions
}
for idx, todo := range r.record.Dashboard.Todos {
status := "open"
if todo.Done {
status = "done"
}
actions = append(actions, paletteAction{
Section: "Agent",
Title: fmt.Sprintf("Toggle todo: %s", todo.Title),
Subtitle: fmt.Sprintf("Mark todo %s", status),
Keywords: []string{"agent", "todo", "toggle", status, todo.Title},
Kind: paletteActionToggleTodo,
TodoIndex: idx,
})
}
return actions
}
@ -568,15 +546,6 @@ func (r *paletteRuntime) execute(result paletteResult) (bool, string, error) {
action := result.Action
text := strings.TrimSpace(result.Input)
switch action.Kind {
case paletteActionToggleTodo:
err := r.persistRecord(func(record *agentRecord) error {
if action.TodoIndex < 0 || action.TodoIndex >= len(record.Dashboard.Todos) {
return fmt.Errorf("todo no longer exists")
}
record.Dashboard.Todos[action.TodoIndex].Done = !record.Dashboard.Todos[action.TodoIndex].Done
return nil
})
return false, "", err
case paletteActionPromptStartAgent:
if err := r.runAgentStart(action.RepoRoot, text, result.Device, result.KeepWorktree); err != nil {
return true, "", err
@ -598,35 +567,59 @@ func (r *paletteRuntime) execute(result paletteResult) (bool, string, error) {
return false, "", nil
case paletteActionReloadTmuxConfig:
return false, "", paletteTmuxRunner("source-file", os.Getenv("HOME")+"/.config/.tmux.conf")
case paletteActionToggleMemoryDisplay:
return false, "", togglePaletteMemoryDisplay()
default:
return false, "", nil
}
}
func paletteMemoryDisplayEnabled() bool {
out, err := paletteTmuxOutput("show-environment", "-g", "TMUX_STATUS_MEMORY")
if err != nil {
return true
func statusRightModuleLabel(module string) string {
switch module {
case statusRightModuleCPU:
return "CPU"
case statusRightModuleNetwork:
return "Network"
case statusRightModuleMemory:
return "Memory"
case statusRightModuleMemoryTotals:
return "Tmux Memory"
case statusRightModuleAgent:
return "Agent"
case statusRightModuleNotes:
return "Notes"
case statusRightModuleFlashMoe:
return "Flash-MoE"
case statusRightModuleHost:
return "Host"
default:
return module
}
line := strings.TrimSpace(out)
if strings.HasPrefix(line, "TMUX_STATUS_MEMORY=") {
value := strings.TrimSpace(strings.TrimPrefix(line, "TMUX_STATUS_MEMORY="))
switch strings.ToLower(value) {
case "0", "false", "off", "no":
return false
}
}
return true
}
func togglePaletteMemoryDisplay() error {
value := "0"
if !paletteMemoryDisplayEnabled() {
value = "1"
func statusRightModuleDescription(module string) string {
switch module {
case statusRightModuleCPU:
return "CPU usage"
case statusRightModuleNetwork:
return "network throughput"
case statusRightModuleMemory:
return "pane memory stats"
case statusRightModuleMemoryTotals:
return "window, session, and total tmux memory"
case statusRightModuleAgent:
return "active agent device"
case statusRightModuleNotes:
return "todo count"
case statusRightModuleFlashMoe:
return "Flash-MoE status"
case statusRightModuleHost:
return "hostname"
default:
return module
}
if err := paletteTmuxRunner("set-environment", "-g", "TMUX_STATUS_MEMORY", value); err != nil {
}
func togglePaletteStatusRightModule(module string) error {
if err := toggleStatusRightModule(module); err != nil {
return err
}
return paletteTmuxRunner("refresh-client", "-S")
@ -651,6 +644,9 @@ func newPaletteModel(runtime *paletteRuntime, state paletteUIState) *paletteMode
if state.Mode == paletteModeDevices {
model.openDevicesPanel()
}
if state.Mode == paletteModeStatusRight {
model.openStatusRightPanel()
}
if state.Mode == paletteModeTracker {
_, _ = model.openTrackerPanel()
}
@ -731,6 +727,19 @@ func (m *paletteModel) openDevicesPanel() {
m.state.ShowAltHints = false
}
func (m *paletteModel) openStatusRightPanel() {
if m.status == nil {
m.status = newStatusRightPanelModel()
} else {
m.status.reload()
m.status.requestBack = false
}
m.status.showAltHints = false
m.state.Mode = paletteModeStatusRight
m.state.Message = ""
m.state.ShowAltHints = false
}
func (m *paletteModel) openTrackerPanel() (tea.Cmd, error) {
if m.tracker == nil {
m.tracker = newTrackerPanelModel(m.runtime)
@ -765,8 +774,12 @@ func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.tracker.width = msg.Width
m.tracker.height = msg.Height
}
if m.status != nil {
m.status.width = msg.Width
m.status.height = msg.Height
}
case tea.KeyMsg:
if m.state.Mode != paletteModeActivity && m.state.Mode != paletteModeTodos && m.state.Mode != paletteModeDevices && m.state.Mode != paletteModeTracker {
if m.state.Mode != paletteModeActivity && m.state.Mode != paletteModeTodos && m.state.Mode != paletteModeDevices && m.state.Mode != paletteModeStatusRight && m.state.Mode != paletteModeTracker {
if isAltFooterToggleKey(msg) {
m.state.ShowAltHints = !m.state.ShowAltHints
return m, nil
@ -842,6 +855,22 @@ func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, cmd
}
if m.state.Mode == paletteModeStatusRight {
if m.status == nil {
m.openStatusRightPanel()
}
model, cmd := m.status.Update(msg)
if updated, ok := model.(*statusRightPanelModel); ok {
m.status = updated
}
if m.status.requestBack {
m.status.requestBack = false
m.state.Mode = paletteModeList
m.state.Message = m.status.currentStatus()
return m, nil
}
return m, cmd
}
if m.state.Mode == paletteModeTracker {
if m.tracker == nil {
cmd, err := m.openTrackerPanel()
@ -918,6 +947,19 @@ func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, cmd
}
if m.state.Mode == paletteModeStatusRight && m.status != nil {
model, cmd := m.status.Update(msg)
if updated, ok := model.(*statusRightPanelModel); ok {
m.status = updated
}
if m.status.requestBack {
m.status.requestBack = false
m.state.Mode = paletteModeList
m.state.Message = m.status.currentStatus()
return m, nil
}
return m, cmd
}
if m.state.Mode == paletteModeTracker && m.tracker != nil {
model, cmd := m.tracker.Update(msg)
if updated, ok := model.(*trackerPanelModel); ok {
@ -1052,6 +1094,9 @@ func (m *paletteModel) selectAction(action paletteAction) (tea.Model, tea.Cmd) {
case paletteActionOpenDevices:
m.openDevicesPanel()
return m, nil
case paletteActionOpenStatusRight:
m.openStatusRightPanel()
return m, nil
default:
m.state.Mode = paletteModeList
m.result = paletteResult{Kind: paletteResultRunAction, Action: action, State: m.state}
@ -1376,6 +1421,14 @@ func (m *paletteModel) View() string {
}
return styles.muted.Render("Device panel unavailable")
}
if m.state.Mode == paletteModeStatusRight {
if m.status != nil {
m.status.width = width
m.status.height = height
return m.status.render(styles, width, height)
}
return styles.muted.Render("Status panel unavailable")
}
if m.state.Mode == paletteModeTracker {
if m.tracker != nil {
m.tracker.width = width
@ -1421,10 +1474,10 @@ func (m *paletteModel) renderListView(styles paletteStyles, width, height int) s
)
contentHeight := maxInt(8, height-7)
listWidth := maxInt(34, width*48/100)
dashboardWidth := maxInt(28, width-listWidth-3)
sidebarWidth := maxInt(28, width-listWidth-3)
list := m.renderActions(styles, actions, listWidth, contentHeight)
dashboard := m.renderDashboard(styles, dashboardWidth, contentHeight)
body := lipgloss.JoinHorizontal(lipgloss.Top, list, strings.Repeat(" ", 3), dashboard)
sidebar := m.renderSidebar(styles, sidebarWidth, contentHeight)
body := lipgloss.JoinHorizontal(lipgloss.Top, list, strings.Repeat(" ", 3), sidebar)
footer := renderPaletteFooter(styles, width, m.state.Message, m.state.ShowAltHints)
view := lipgloss.JoinVertical(lipgloss.Left, header, "", filterLine, "", body, "", footer)
return lipgloss.NewStyle().Width(width).Height(height).Padding(0, 1).Render(view)
@ -1486,9 +1539,9 @@ func (m *paletteModel) renderActions(styles paletteStyles, actions []paletteActi
return lipgloss.NewStyle().Width(width).Height(height).Render(content)
}
func (m *paletteModel) renderDashboard(styles paletteStyles, width, height int) string {
func (m *paletteModel) renderSidebar(styles paletteStyles, width, height int) string {
lines := []string{}
trackerContext, trackerAgent, trackerBootstrap := m.runtime.dashboardTrackerStatus()
trackerContext, trackerAgent, trackerBootstrap := m.runtime.sidebarTrackerStatus()
lines = append(lines, styles.panelTitle.Render("Tracker Status"))
lines = append(lines, renderPaletteStat(styles, "Context", trackerContext, width, 9))
lines = append(lines, renderPaletteStat(styles, "Agent", trackerAgent, width, 9))
@ -1496,7 +1549,7 @@ func (m *paletteModel) renderDashboard(styles paletteStyles, width, height int)
lines = append(lines, "")
lines = append(lines, styles.panelTitle.Render("Todo Preview"))
previewLimit := clampInt((height-6)/4, 1, 3)
sections := m.runtime.dashboardTodoPreviewSections()
sections := m.runtime.sidebarTodoPreviewSections()
for idx, section := range sections {
if idx > 0 {
lines = append(lines, "")
@ -1507,7 +1560,7 @@ func (m *paletteModel) renderDashboard(styles paletteStyles, width, height int)
return lipgloss.NewStyle().Width(width).Height(height).Render(content)
}
func (r *paletteRuntime) dashboardTrackerStatus() (contextSummary, agentSummary, bootstrapSummary string) {
func (r *paletteRuntime) sidebarTrackerStatus() (contextSummary, agentSummary, bootstrapSummary string) {
contextParts := []string{}
if r.currentSessionName != "" {
contextParts = append(contextParts, r.currentSessionName)
@ -1540,7 +1593,7 @@ func (r *paletteRuntime) dashboardTrackerStatus() (contextSummary, agentSummary,
return contextSummary, agentSummary, bootstrapSummary
}
func (r *paletteRuntime) dashboardTodoPreviewSections() []paletteTodoPreviewSection {
func (r *paletteRuntime) sidebarTodoPreviewSections() []paletteTodoPreviewSection {
sections := []paletteTodoPreviewSection{}
store, err := loadTmuxTodoStore()
windowID := strings.TrimSpace(r.windowID)
@ -1561,17 +1614,6 @@ func (r *paletteRuntime) dashboardTodoPreviewSections() []paletteTodoPreviewSect
Empty: "No global todos",
})
}
if r.record != nil {
section := paletteTodoPreviewSection{
Title: "Dashboard",
Items: paletteAgentTodoPreviewItems(r.record.Dashboard.Todos),
Empty: "No dashboard todos",
}
if currentTask := firstPaletteLine(r.record.Dashboard.CurrentTask); currentTask != "" {
section.Lead = "Task: " + currentTask
}
sections = append(sections, section)
}
return sections
}
@ -2128,18 +2170,6 @@ func paletteTmuxTodoPreviewItems(items []tmuxTodoItem) []paletteTodoPreviewItem
return rows
}
func paletteAgentTodoPreviewItems(todos []todoItem) []paletteTodoPreviewItem {
rows := make([]paletteTodoPreviewItem, 0, len(todos))
for _, todo := range todos {
title := firstPaletteLine(todo.Title)
if title == "" || todo.Done {
continue
}
rows = append(rows, paletteTodoPreviewItem{Title: title, Done: todo.Done})
}
return rows
}
func renderPaletteTodoPreviewSection(styles paletteStyles, section paletteTodoPreviewSection, width int, previewLimit int) []string {
lines := []string{styles.statLabel.Render(section.Title)}
if section.Lead != "" {

View file

@ -1,195 +0,0 @@
package main
import (
"os"
"strings"
"testing"
"time"
)
func compactPaletteWhitespace(value string) string {
return strings.Join(strings.Fields(value), " ")
}
func TestPaletteViewShowsStatusDashboardWithoutSelectedPreview(t *testing.T) {
home := t.TempDir()
writeTestTodoStore(t, home, &tmuxTodoStore{
Version: tmuxTodoStoreVersion,
Global: []tmuxTodoItem{{Title: "global todo", CreatedAt: time.Now()}},
Windows: map[string][]tmuxTodoItem{
"@9": {
{Title: "window todo 1", CreatedAt: time.Now()},
{Title: "window todo 2", Done: true, CreatedAt: time.Now()},
},
},
})
model := newPaletteModel(&paletteRuntime{
windowID: "@9",
currentSessionName: "dev",
currentWindowName: "agent",
currentPath: "/tmp/repo",
mainRepoRoot: "/tmp/repo",
}, paletteUIState{Mode: paletteModeList})
model.width = 96
model.height = 28
view := model.View()
compact := compactPaletteWhitespace(view)
if !strings.Contains(compact, "Tracker Status") {
t.Fatalf("expected tracker status section in view: %q", view)
}
if !strings.Contains(compact, "Todo Preview") {
t.Fatalf("expected todo preview section in view: %q", view)
}
if !strings.Contains(compact, "No active agent") {
t.Fatalf("expected no active agent status in view: %q", view)
}
if !strings.Contains(compact, "window todo 1") || !strings.Contains(compact, "global todo") {
t.Fatalf("expected todo previews in view: %q", view)
}
if strings.Contains(view, "[ ]") || strings.Contains(view, "[x]") {
t.Fatalf("expected todo panel checkbox visuals in view: %q", view)
}
if !strings.Contains(view, "○") {
t.Fatalf("expected open todo checkbox visual in view: %q", view)
}
if strings.Contains(compact, "window todo 2") {
t.Fatalf("expected completed window todo to stay out of preview when open work exists: %q", view)
}
if strings.Contains(compact, "Selected") {
t.Fatalf("unexpected selected-action preview in view: %q", view)
}
if strings.Contains(compact, "Current Task") || strings.Contains(compact, "Next Todos") {
t.Fatalf("unexpected legacy agent preview content in view: %q", view)
}
}
func TestPaletteViewShowsBootstrapAndDashboardTodoStatus(t *testing.T) {
home := t.TempDir()
writeTestTodoStore(t, home, &tmuxTodoStore{
Version: tmuxTodoStoreVersion,
Global: []tmuxTodoItem{{Title: "global todo", CreatedAt: time.Now()}},
Windows: map[string][]tmuxTodoItem{
"@4": {{Title: "window todo", CreatedAt: time.Now()}},
},
})
workspaceRoot := t.TempDir()
if err := os.MkdirAll(bootstrapStateDirPath(workspaceRoot), 0o755); err != nil {
t.Fatalf("mkdir bootstrap dir: %v", err)
}
if err := os.WriteFile(bootstrapGitReadyPath(workspaceRoot), []byte(time.Now().Format(time.RFC3339Nano)), 0o644); err != nil {
t.Fatalf("write git-ready marker: %v", err)
}
if err := os.WriteFile(bootstrapPIDPath(workspaceRoot), []byte("1\n"), 0o644); err != nil {
t.Fatalf("write pid marker: %v", err)
}
model := newPaletteModel(&paletteRuntime{
windowID: "@4",
currentSessionName: "dev",
currentWindowName: "agent-4",
mainRepoRoot: "/tmp/repo",
record: &agentRecord{
ID: "icon-pad",
Branch: "feature/icon-pad",
WorkspaceRoot: workspaceRoot,
Dashboard: dashboardDoc{
CurrentTask: "Ship status dashboard",
Todos: []todoItem{
{Title: "wire dashboard"},
{Title: "verify tests", Done: true},
},
},
},
}, paletteUIState{Mode: paletteModeList})
model.width = 104
model.height = 28
view := model.View()
compact := compactPaletteWhitespace(view)
if !strings.Contains(compact, "copying repo") {
t.Fatalf("expected bootstrap status in view: %q", view)
}
if !strings.Contains(compact, "icon-pad on feature/icon-pad") {
t.Fatalf("expected agent summary in view: %q", view)
}
if !strings.Contains(compact, "Todo Preview") {
t.Fatalf("expected todo preview section in view: %q", view)
}
if !strings.Contains(compact, "Ship status dashboard") {
t.Fatalf("expected current task summary in view: %q", view)
}
if !strings.Contains(compact, "wire dashboard") || !strings.Contains(compact, "window todo") || !strings.Contains(compact, "global todo") {
t.Fatalf("expected todo previews in view: %q", view)
}
if strings.Contains(view, "[ ]") || strings.Contains(view, "[x]") {
t.Fatalf("expected todo panel checkbox visuals in view: %q", view)
}
if !strings.Contains(view, "○") {
t.Fatalf("expected open todo checkbox visual in view: %q", view)
}
if strings.Contains(compact, "verify tests") {
t.Fatalf("expected completed dashboard todo to stay out of preview when open work exists: %q", view)
}
if strings.Contains(compact, "Selected") {
t.Fatalf("unexpected selected-action preview in view: %q", view)
}
}
func TestPaletteTodoPreviewItemsHideCompletedTodos(t *testing.T) {
tmuxItems := paletteTmuxTodoPreviewItems([]tmuxTodoItem{
{Title: "done one", Done: true},
{Title: "open one"},
{Title: "done two", Done: true},
})
if len(tmuxItems) != 1 || tmuxItems[0].Title != "open one" {
t.Fatalf("expected only open tmux todo in preview, got %#v", tmuxItems)
}
tmuxDoneOnly := paletteTmuxTodoPreviewItems([]tmuxTodoItem{{Title: "done only", Done: true}})
if len(tmuxDoneOnly) != 0 {
t.Fatalf("expected completed tmux todos to stay out of preview, got %#v", tmuxDoneOnly)
}
agentItems := paletteAgentTodoPreviewItems([]todoItem{
{Title: "done agent", Done: true},
{Title: "open agent"},
})
if len(agentItems) != 1 || agentItems[0].Title != "open agent" {
t.Fatalf("expected only open dashboard todo in preview, got %#v", agentItems)
}
agentDoneOnly := paletteAgentTodoPreviewItems([]todoItem{{Title: "done only", Done: true}})
if len(agentDoneOnly) != 0 {
t.Fatalf("expected completed dashboard todos to stay out of preview, got %#v", agentDoneOnly)
}
}
func TestPaletteBootstrapStatusSummaries(t *testing.T) {
if got := paletteBootstrapStatus(nil); got != "No active agent" {
t.Fatalf("expected no active agent, got %q", got)
}
workspaceRoot := t.TempDir()
record := &agentRecord{WorkspaceRoot: workspaceRoot}
if err := os.MkdirAll(bootstrapStateDirPath(workspaceRoot), 0o755); err != nil {
t.Fatalf("mkdir bootstrap dir: %v", err)
}
if err := os.WriteFile(bootstrapFailedPath(workspaceRoot), []byte("clone failed\nextra\n"), 0o644); err != nil {
t.Fatalf("write failed marker: %v", err)
}
if got := paletteBootstrapStatus(record); got != "failed: clone failed" {
t.Fatalf("expected failure summary, got %q", got)
}
if err := os.Remove(bootstrapFailedPath(workspaceRoot)); err != nil {
t.Fatalf("remove failed marker: %v", err)
}
if err := os.WriteFile(bootstrapRepoReadyPath(workspaceRoot), []byte(time.Now().Format(time.RFC3339Nano)), 0o644); err != nil {
t.Fatalf("write repo-ready marker: %v", err)
}
if got := paletteBootstrapStatus(record); got != "ready" {
t.Fatalf("expected ready summary, got %q", got)
}
}

View file

@ -1,19 +0,0 @@
package main
import "testing"
func TestPaletteBuildActionsIncludesDestroyForAgentRecord(t *testing.T) {
r := &paletteRuntime{
agentID: "memory-hack",
mainRepoRoot: "/tmp/repo",
currentPath: "/tmp/repo/.agents/memory-hack/repo",
record: &agentRecord{ID: "memory-hack", RepoRoot: "/tmp/repo"},
}
actions := r.buildActions()
for _, action := range actions {
if action.Kind == paletteActionConfirmDestroy {
return
}
}
t.Fatalf("destroy action missing: %#v", actions)
}

View file

@ -1,35 +0,0 @@
package main
import "testing"
func TestDetectPaletteAgentIDFromPath(t *testing.T) {
got := detectPaletteAgentIDFromPath("/Users/david/Github/instaboard/.agents/icon-pad/repo")
if got != "icon-pad" {
t.Fatalf("expected icon-pad, got %q", got)
}
}
func TestPaletteBuildActionsIncludesDestroyWhenAgentIDKnownWithoutRecord(t *testing.T) {
r := &paletteRuntime{
agentID: "icon-pad",
mainRepoRoot: "/tmp/repo",
currentPath: "/tmp/repo/.agents/icon-pad/repo",
}
actions := r.buildActions()
for _, action := range actions {
if action.Kind == paletteActionConfirmDestroy {
return
}
}
t.Fatalf("destroy action missing: %#v", actions)
}
func TestPaletteEffectiveAgentIDIgnoresLiteralAndUsesPath(t *testing.T) {
r := &paletteRuntime{
agentID: "#{q:@agent_id}",
currentPath: "/tmp/repo/.agents/icon-pad/repo",
}
if got := r.effectiveAgentID(); got != "icon-pad" {
t.Fatalf("expected icon-pad, got %q", got)
}
}

View file

@ -1,460 +0,0 @@
package main
import (
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
func setupPromptRepo(t *testing.T) string {
t.Helper()
repo := t.TempDir()
if err := os.WriteFile(filepath.Join(repo, ".agent.yaml"), []byte("base_branch: main\n"), 0o644); err != nil {
t.Fatalf("write .agent.yaml: %v", err)
}
return repo
}
func TestPaletteAltCOpensStartAgentPrompt(t *testing.T) {
repo := setupPromptRepo(t)
model := newPaletteModel(&paletteRuntime{mainRepoRoot: repo}, paletteUIState{Mode: paletteModeList})
updated, cmd := model.updateList("alt+c")
if cmd != nil {
t.Fatalf("expected no command when opening start prompt, got %v", cmd)
}
palette, ok := updated.(*paletteModel)
if !ok {
t.Fatalf("expected palette model, got %T", updated)
}
if palette.state.Mode != paletteModePrompt {
t.Fatalf("expected prompt mode, got %v", palette.state.Mode)
}
if palette.state.PromptKind != palettePromptStartAgent {
t.Fatalf("expected start-agent prompt, got %v", palette.state.PromptKind)
}
if palette.state.PromptRepoRoot != repo {
t.Fatalf("expected prompt repo root %q, got %q", repo, palette.state.PromptRepoRoot)
}
}
func TestPaletteStartPromptAddsNoDeviceOptionForFlutterRepo(t *testing.T) {
repo := setupPromptRepo(t)
if err := os.WriteFile(filepath.Join(repo, "pubspec.yaml"), []byte("name: demo\n"), 0o644); err != nil {
t.Fatalf("write pubspec: %v", err)
}
model := newPaletteModel(&paletteRuntime{mainRepoRoot: repo}, paletteUIState{Mode: paletteModeList})
model.openPrompt(palettePromptStartAgent, "feature-x", repo)
if len(model.state.PromptDevices) < 2 {
t.Fatalf("expected flutter prompt devices to include no-device option and launch devices, got %v", model.state.PromptDevices)
}
if model.state.PromptDevices[0] != paletteNoDeviceOption {
t.Fatalf("expected first prompt device to be no-device option, got %q", model.state.PromptDevices[0])
}
if model.state.PromptDeviceIndex != 1 {
t.Fatalf("expected default device selection to stay on %q, got index %d", defaultManagedDeviceID, model.state.PromptDeviceIndex)
}
}
func TestPaletteStartPromptDeviceSelectionUsesNI(t *testing.T) {
repo := setupPromptRepo(t)
model := newPaletteModel(&paletteRuntime{mainRepoRoot: repo}, paletteUIState{Mode: paletteModeList})
model.openPrompt(palettePromptStartAgent, "feature-x", repo)
model.state.PromptField = palettePromptFieldDevice
model.state.PromptDevices = []string{"ios", "android", "web-server"}
model.state.PromptDeviceIndex = 1
updated, _ := model.updatePrompt("n")
palette := updated.(*paletteModel)
if palette.state.PromptDeviceIndex != 0 {
t.Fatalf("expected n to move device selection left, got %d", palette.state.PromptDeviceIndex)
}
updated, _ = palette.updatePrompt("i")
palette = updated.(*paletteModel)
if palette.state.PromptDeviceIndex != 1 {
t.Fatalf("expected i to move device selection right, got %d", palette.state.PromptDeviceIndex)
}
updated, _ = palette.updatePrompt("e")
palette = updated.(*paletteModel)
if palette.state.PromptDeviceIndex != 1 {
t.Fatalf("expected e to leave device selection unchanged, got %d", palette.state.PromptDeviceIndex)
}
}
func TestPaletteStartPromptAltDTogglesDevices(t *testing.T) {
repo := setupPromptRepo(t)
model := newPaletteModel(&paletteRuntime{mainRepoRoot: repo}, paletteUIState{Mode: paletteModeList})
model.openPrompt(palettePromptStartAgent, "feature-x", repo)
model.state.PromptDevices = []string{"ios", "android", "web-server"}
model.state.PromptDeviceIndex = 1
model.state.PromptField = palettePromptFieldName
updated, _ := model.updatePrompt("alt+d")
palette := updated.(*paletteModel)
if palette.state.PromptField != palettePromptFieldName {
t.Fatalf("expected alt+d to keep the current focus field, got %v", palette.state.PromptField)
}
if palette.state.PromptDeviceIndex != 2 {
t.Fatalf("expected alt+d to advance to next device, got %d", palette.state.PromptDeviceIndex)
}
updated, _ = palette.updatePrompt("alt+d")
palette = updated.(*paletteModel)
if palette.state.PromptDeviceIndex != 0 {
t.Fatalf("expected alt+d to wrap device selection, got %d", palette.state.PromptDeviceIndex)
}
updated, _ = palette.updatePrompt("alt+D")
palette = updated.(*paletteModel)
if palette.state.PromptDeviceIndex != 2 {
t.Fatalf("expected alt+shift+d to move device selection backward, got %d", palette.state.PromptDeviceIndex)
}
}
func TestRenderPaletteFooterTogglesAltHints(t *testing.T) {
styles := newPaletteStyles()
defaultFooter := renderPaletteFooter(styles, 140, "", false)
if !strings.Contains(defaultFooter, "more") {
t.Fatalf("expected default footer to advertise more options, got %q", defaultFooter)
}
if !strings.Contains(defaultFooter, footerHintToggleKey) {
t.Fatalf("expected default footer to show %q toggle, got %q", footerHintToggleKey, defaultFooter)
}
if strings.Contains(defaultFooter, "create") {
t.Fatalf("expected default footer to hide alt shortcuts, got %q", defaultFooter)
}
altFooter := renderPaletteFooter(styles, 140, "", true)
if !strings.Contains(altFooter, "create") || !strings.Contains(altFooter, "tracker") {
t.Fatalf("expected alt footer to show alt shortcuts, got %q", altFooter)
}
if strings.Contains(altFooter, "filter") || strings.Contains(altFooter, "Enter") {
t.Fatalf("expected alt footer to hide default shortcuts, got %q", altFooter)
}
}
func TestPalettePromptAltToggleShowsAltHints(t *testing.T) {
repo := setupPromptRepo(t)
if err := os.WriteFile(filepath.Join(repo, "pubspec.yaml"), []byte("name: demo\n"), 0o644); err != nil {
t.Fatalf("write pubspec: %v", err)
}
model := newPaletteModel(&paletteRuntime{mainRepoRoot: repo}, paletteUIState{Mode: paletteModeList})
model.openPrompt(palettePromptStartAgent, "feature-x", repo)
defaultView := model.renderPrompt(newPaletteStyles(), 100, 24)
if !strings.Contains(defaultView, footerHintToggleKey) || !strings.Contains(defaultView, "more") {
t.Fatalf("expected default prompt to advertise alt options, got %q", defaultView)
}
if strings.Contains(defaultView, "Alt-D") {
t.Fatalf("expected default prompt to hide alt device shortcuts, got %q", defaultView)
}
updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
palette := updated.(*paletteModel)
altView := palette.renderPrompt(newPaletteStyles(), 100, 24)
if !strings.Contains(altView, "Alt-D") || !strings.Contains(altView, "Alt-S") {
t.Fatalf("expected alt prompt view to show alt shortcuts, got %q", altView)
}
if strings.Contains(altView, "Tab") || strings.Contains(altView, "n/i") {
t.Fatalf("expected alt prompt view to hide default shortcuts, got %q", altView)
}
updated, _ = palette.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
palette = updated.(*paletteModel)
if palette.state.ShowAltHints {
t.Fatalf("expected normal keypress to hide alt hints")
}
}
func TestPaletteStartPromptWorktreeToggleUsesNI(t *testing.T) {
repo := setupPromptRepo(t)
model := newPaletteModel(&paletteRuntime{mainRepoRoot: repo}, paletteUIState{Mode: paletteModeList})
model.openPrompt(palettePromptStartAgent, "feature-x", repo)
model.state.PromptField = palettePromptFieldWorktree
updated, _ := model.updatePrompt("i")
palette := updated.(*paletteModel)
if !palette.state.PromptKeepWorktree {
t.Fatalf("expected i to switch worktree mode to keep")
}
updated, _ = palette.updatePrompt("n")
palette = updated.(*paletteModel)
if palette.state.PromptKeepWorktree {
t.Fatalf("expected n to switch worktree mode back to clear")
}
}
func TestPaletteStartPromptEnterCarriesKeepWorktree(t *testing.T) {
repo := setupPromptRepo(t)
model := newPaletteModel(&paletteRuntime{mainRepoRoot: repo}, paletteUIState{Mode: paletteModeList})
model.openPrompt(palettePromptStartAgent, "feature-x", repo)
model.state.PromptKeepWorktree = true
updated, cmd := model.updatePrompt("enter")
if cmd == nil {
t.Fatalf("expected enter to quit palette for action execution")
}
palette := updated.(*paletteModel)
if !palette.result.KeepWorktree {
t.Fatalf("expected start-agent result to preserve keep-worktree selection")
}
}
func TestPaletteStartPromptHidesCreateOptionsWithoutRepo(t *testing.T) {
model := newPaletteModel(&paletteRuntime{}, paletteUIState{Mode: paletteModeList})
model.openPrompt(palettePromptStartAgent, "feature-x", "")
view := model.renderPrompt(newPaletteStyles(), 80, 20)
if !strings.Contains(view, "Main repo not found") {
t.Fatalf("expected missing repo message in prompt: %q", view)
}
for _, unwanted := range []string{"BRANCH", "DEVICE", "WORKTREE", "Enter create", "CLEAR", "KEEP"} {
if strings.Contains(view, unwanted) {
t.Fatalf("did not expect %q in missing-repo prompt: %q", unwanted, view)
}
}
updated, cmd := model.updatePrompt("enter")
if cmd != nil {
t.Fatalf("expected no command when repo is missing, got %v", cmd)
}
palette := updated.(*paletteModel)
if palette.state.Mode != paletteModePrompt {
t.Fatalf("expected prompt to remain open when repo is missing, got %v", palette.state.Mode)
}
}
func TestRenderPaletteDeviceChipKeepsPaddingWhenActive(t *testing.T) {
styles := newPaletteStyles()
inactive := renderPaletteDeviceChip(styles, "ios", false)
active := renderPaletteDeviceChip(styles, "ios", true)
if lipgloss.Width(active) != lipgloss.Width(inactive) {
t.Fatalf("expected active chip width %d to match inactive chip width %d", lipgloss.Width(active), lipgloss.Width(inactive))
}
}
func TestBuildAgentStartArgsIncludesKeepWorktree(t *testing.T) {
got := buildAgentStartArgs("feature-x", "ios", true)
want := []string{"start", "--keep-worktree", "-d", "ios", "feature-x"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("expected args %v, got %v", want, got)
}
got = buildAgentStartArgs("feature-x", "", false)
want = []string{"start", "feature-x"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("expected args %v, got %v", want, got)
}
got = buildAgentStartArgs("feature-x", paletteNoDeviceOption, false)
want = []string{"start", "--no-device", "feature-x"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("expected args %v, got %v", want, got)
}
}
func TestPaletteDestroyQueuesAndClosesImmediately(t *testing.T) {
prev := paletteDestroyLauncher
t.Cleanup(func() { paletteDestroyLauncher = prev })
calledWith := ""
calledConfirm := ""
paletteDestroyLauncher = func(agentID string, confirmText string) error {
calledWith = agentID
calledConfirm = confirmText
return nil
}
runtime := &paletteRuntime{agentID: "feature-x"}
reopen, message, err := runtime.execute(paletteResult{Action: paletteAction{Kind: paletteActionConfirmDestroy}})
if err != nil {
t.Fatalf("execute destroy: %v", err)
}
if reopen {
t.Fatalf("expected destroy action to close the palette immediately")
}
if message != "" {
t.Fatalf("expected no destroy message, got %q", message)
}
if calledWith != "feature-x" {
t.Fatalf("expected destroy launcher to receive feature-x, got %q", calledWith)
}
if calledConfirm != "" {
t.Fatalf("expected empty destroy confirm text, got %q", calledConfirm)
}
}
func TestPaletteDestroyRequiresTypedDestroyForDirtyRepo(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
registryDir := filepath.Join(home, ".config", "agent-tracker", "run")
if err := os.MkdirAll(registryDir, 0o755); err != nil {
t.Fatalf("mkdir registry dir: %v", err)
}
repo := filepath.Join(home, "repo")
if err := os.MkdirAll(repo, 0o755); err != nil {
t.Fatalf("mkdir repo: %v", err)
}
initTestGitRepo(t, repo)
if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("dirty\n"), 0o644); err != nil {
t.Fatalf("write dirty repo file: %v", err)
}
reg := &registry{Agents: map[string]*agentRecord{
"feature-x": {ID: "feature-x", RepoCopyPath: repo},
}}
if err := saveRegistry(reg); err != nil {
t.Fatalf("save registry: %v", err)
}
model := newPaletteModel(&paletteRuntime{agentID: "feature-x", record: &agentRecord{ID: "feature-x", RepoCopyPath: repo}}, paletteUIState{Mode: paletteModeList})
updated, cmd := model.selectAction(paletteAction{Kind: paletteActionConfirmDestroy})
if cmd != nil {
t.Fatalf("expected no quit when opening destroy confirm, got %v", cmd)
}
palette := updated.(*paletteModel)
if !palette.state.ConfirmRequiresText {
t.Fatal("expected typed destroy confirmation for dirty repo")
}
updated, cmd = palette.updateConfirm("enter")
if cmd != nil {
t.Fatalf("expected no quit for missing typed confirmation, got %v", cmd)
}
palette = updated.(*paletteModel)
if palette.state.Message != "Type destroy to confirm" {
t.Fatalf("expected typed destroy message, got %q", palette.state.Message)
}
for _, key := range []string{"d", "e", "s", "t", "r", "o", "y"} {
updated, _ = palette.updateConfirm(key)
palette = updated.(*paletteModel)
}
updated, cmd = palette.updateConfirm("enter")
if cmd == nil {
t.Fatal("expected quit after typed destroy confirmation")
}
palette = updated.(*paletteModel)
if palette.result.Input != "destroy" {
t.Fatalf("expected destroy confirm input, got %q", palette.result.Input)
}
}
func TestPaletteBuildActionsIncludesMemoryToggle(t *testing.T) {
prevOutput := paletteTmuxOutput
t.Cleanup(func() { paletteTmuxOutput = prevOutput })
paletteTmuxOutput = func(args ...string) (string, error) {
return "TMUX_STATUS_MEMORY=0\n", nil
}
actions := (&paletteRuntime{mainRepoRoot: "/tmp/repo"}).buildActions()
for _, action := range actions {
if action.Kind != paletteActionToggleMemoryDisplay {
continue
}
if action.Title != "Show memory display" {
t.Fatalf("expected show title, got %q", action.Title)
}
return
}
t.Fatalf("memory toggle action missing: %#v", actions)
}
func TestPaletteToggleMemoryDisplayTurnsItOffAndRefreshesTmux(t *testing.T) {
prevOutput := paletteTmuxOutput
prevRunner := paletteTmuxRunner
t.Cleanup(func() {
paletteTmuxOutput = prevOutput
paletteTmuxRunner = prevRunner
})
paletteTmuxOutput = func(args ...string) (string, error) {
return "TMUX_STATUS_MEMORY=1\n", nil
}
var calls []string
paletteTmuxRunner = func(args ...string) error {
calls = append(calls, strings.Join(args, " "))
return nil
}
runtime := &paletteRuntime{}
_, _, err := runtime.execute(paletteResult{Action: paletteAction{Kind: paletteActionToggleMemoryDisplay}})
if err != nil {
t.Fatalf("execute memory toggle: %v", err)
}
want := []string{
"set-environment -g TMUX_STATUS_MEMORY 0",
"refresh-client -S",
}
if !reflect.DeepEqual(calls, want) {
t.Fatalf("expected calls %v, got %v", want, calls)
}
}
func TestPaletteIgnoresImmediateAltSCloseOnOpen(t *testing.T) {
model := newPaletteModel(&paletteRuntime{}, paletteUIState{Mode: paletteModeList})
updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}, Alt: true})
if cmd != nil {
t.Fatalf("expected no quit command for immediate alt+s, got %v", cmd)
}
palette := updated.(*paletteModel)
if palette.state.Mode != paletteModeList {
t.Fatalf("expected immediate alt+s to leave palette open, got mode %v", palette.state.Mode)
}
palette.openedAt = time.Now().Add(-time.Second)
updated, cmd = palette.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}, Alt: true})
if cmd == nil {
t.Fatalf("expected delayed alt+s to close the palette")
}
palette = updated.(*paletteModel)
if palette.result.Kind != paletteResultClose {
t.Fatalf("expected delayed alt+s to close the palette")
}
}
func TestLoadPaletteRuntimeSurvivesMalformedRegistry(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
registryDir := filepath.Join(home, ".config", "agent-tracker", "run")
if err := os.MkdirAll(registryDir, 0o755); err != nil {
t.Fatalf("mkdir registry dir: %v", err)
}
if err := os.WriteFile(filepath.Join(registryDir, "agents.json"), []byte("{}\n}"), 0o644); err != nil {
t.Fatalf("write malformed registry: %v", err)
}
runtime, err := loadPaletteRuntime([]string{"--window=@1", "--path=" + home, "--session-name=test", "--window-name=shell"})
if err != nil {
t.Fatalf("loadPaletteRuntime returned error: %v", err)
}
if runtime.reg == nil || runtime.reg.Agents == nil {
t.Fatalf("expected fallback empty registry, got %#v", runtime.reg)
}
if got := runtime.startupMessage; !strings.Contains(got, "Ignoring malformed registry") {
t.Fatalf("expected startup warning, got %q", got)
}
model := newPaletteModel(runtime, paletteUIState{Mode: paletteModeList, Message: runtime.startupMessage})
view := model.View()
if !strings.Contains(view, "Ignoring malformed registry") {
t.Fatalf("expected warning in palette view, got %s", fmt.Sprintf("%q", view))
}
if !strings.Contains(view, "Command Palette") {
t.Fatalf("expected palette to still render, got %s", fmt.Sprintf("%q", view))
}
}

View file

@ -1,123 +0,0 @@
package main
import (
"fmt"
"testing"
"time"
)
func TestStableListOffsetKeepsViewportStableWhenReversing(t *testing.T) {
offset := 0
for _, selected := range []int{0, 1, 2, 3, 4, 5, 6, 7} {
offset = stableListOffset(offset, selected, 5, 20)
}
if offset != 3 {
t.Fatalf("expected offset 3 after scrolling down, got %d", offset)
}
offset = stableListOffset(offset, 6, 5, 20)
if offset != 3 {
t.Fatalf("expected offset to stay 3 when reversing inside viewport, got %d", offset)
}
offset = stableListOffset(offset, 2, 5, 20)
if offset != 2 {
t.Fatalf("expected offset to move only once selection crosses the top edge, got %d", offset)
}
}
func TestActivityMonitorViewportDoesNotJumpWhenReversing(t *testing.T) {
m := newActivityMonitorModel("@1", false)
m.processes = map[int]*activityProcess{}
for i := 0; i < 20; i++ {
pid := 100 + i
m.rows = append(m.rows, activityRow{PID: pid})
m.processes[pid] = &activityProcess{
PID: pid,
CPU: 1,
ResidentMB: 10,
Command: fmt.Sprintf("proc-%d", i),
ShortCommand: fmt.Sprintf("proc-%d", i),
}
}
m.selectedRow = 0
m.selectedPID = m.rows[0].PID
_ = m.renderTable(80, 6)
for i := 0; i < 7; i++ {
m.moveSelection(1)
_ = m.renderTable(80, 6)
}
if m.rowOffset != 3 {
t.Fatalf("expected activity monitor offset 3 after scrolling down, got %d", m.rowOffset)
}
m.moveSelection(-1)
_ = m.renderTable(80, 6)
if m.rowOffset != 3 {
t.Fatalf("expected activity monitor offset to stay 3 when reversing inside viewport, got %d", m.rowOffset)
}
}
func TestTodoPanelViewportDoesNotJumpWhenReversing(t *testing.T) {
home := t.TempDir()
windowItems := make([]tmuxTodoItem, 0, 20)
for i := 0; i < 20; i++ {
windowItems = append(windowItems, tmuxTodoItem{Title: fmt.Sprintf("window %d", i), Priority: 1, CreatedAt: time.Now()})
}
writeTestTodoStore(t, home, &tmuxTodoStore{
Version: tmuxTodoStoreVersion,
Windows: map[string][]tmuxTodoItem{"@1": windowItems},
})
m, err := newTodoPanelModel("$1", "@1")
if err != nil {
t.Fatalf("new todo panel model: %v", err)
}
_ = m.renderColumn(todoScopeWindow, 40, 7)
for i := 0; i < 7; i++ {
m.moveSelection(1)
_ = m.renderColumn(todoScopeWindow, 40, 7)
}
if m.windowOffset != 3 {
t.Fatalf("expected todo panel offset 3 after scrolling down, got %d", m.windowOffset)
}
m.moveSelection(-1)
_ = m.renderColumn(todoScopeWindow, 40, 7)
if m.windowOffset != 3 {
t.Fatalf("expected todo panel offset to stay 3 when reversing inside viewport, got %d", m.windowOffset)
}
}
func TestPaletteViewportDoesNotJumpWhenReversing(t *testing.T) {
todos := make([]todoItem, 0, 10)
for i := 0; i < 10; i++ {
todos = append(todos, todoItem{Title: fmt.Sprintf("todo %d", i)})
}
runtime := &paletteRuntime{
agentID: "agent-1",
record: &agentRecord{
ID: "agent-1",
Dashboard: dashboardDoc{Todos: todos},
},
}
m := newPaletteModel(runtime, paletteUIState{})
styles := newPaletteStyles()
_ = m.renderListView(styles, 96, 20)
for i := 0; i < 5; i++ {
m.state.Selected++
_ = m.renderListView(styles, 96, 20)
}
if m.state.ActionOffset != 3 {
t.Fatalf("expected palette offset 3 after scrolling down, got %d", m.state.ActionOffset)
}
m.state.Selected--
_ = m.renderListView(styles, 96, 20)
if m.state.ActionOffset != 3 {
t.Fatalf("expected palette offset to stay 3 when reversing inside viewport, got %d", m.state.ActionOffset)
}
}

View file

@ -0,0 +1,259 @@
package main
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type statusRightPanelEntry struct {
Module string
Title string
Subtitle string
Enabled bool
}
type statusRightPanelModel struct {
entries []statusRightPanelEntry
selected int
width int
height int
status string
statusUntil time.Time
showAltHints bool
requestBack bool
}
func newStatusRightPanelModel() *statusRightPanelModel {
model := &statusRightPanelModel{}
model.reload()
return model
}
func (m *statusRightPanelModel) reload() {
entries := make([]statusRightPanelEntry, 0, len(statusRightModules()))
for _, module := range statusRightModules() {
entries = append(entries, statusRightPanelEntry{
Module: module,
Title: statusRightModuleLabel(module),
Subtitle: capitalizeStatusRightDescription(statusRightModuleDescription(module)),
Enabled: statusRightModuleEnabled(module),
})
}
m.entries = entries
m.selected = clampInt(m.selected, 0, maxInt(0, len(m.entries)-1))
}
func capitalizeStatusRightDescription(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
return strings.ToUpper(value[:1]) + value[1:]
}
func (m *statusRightPanelModel) Init() tea.Cmd {
return nil
}
func (m *statusRightPanelModel) 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 tea.KeyMsg:
if isAltFooterToggleKey(msg) {
m.showAltHints = !m.showAltHints
return m, nil
}
m.showAltHints = false
switch msg.String() {
case "esc":
m.requestBack = true
case "ctrl+u", "alt+u", "up", "u":
m.selected = clampInt(m.selected-1, 0, maxInt(0, len(m.entries)-1))
case "ctrl+e", "alt+e", "down", "e":
m.selected = clampInt(m.selected+1, 0, maxInt(0, len(m.entries)-1))
case "enter", " ":
m.toggleSelected()
}
}
return m, nil
}
func (m *statusRightPanelModel) toggleSelected() {
entry, ok := m.currentEntry()
if !ok {
return
}
if err := togglePaletteStatusRightModule(entry.Module); err != nil {
m.setStatus(err.Error(), 1500*time.Millisecond)
return
}
m.reload()
updated, ok := m.currentEntry()
if !ok {
return
}
verb := "disabled"
if updated.Enabled {
verb = "enabled"
}
m.setStatus(fmt.Sprintf("%s %s", updated.Title, verb), 1500*time.Millisecond)
}
func (m *statusRightPanelModel) currentEntry() (statusRightPanelEntry, bool) {
if len(m.entries) == 0 || m.selected < 0 || m.selected >= len(m.entries) {
return statusRightPanelEntry{}, false
}
return m.entries[m.selected], true
}
func (m *statusRightPanelModel) View() string {
return m.render(newPaletteStyles(), m.width, m.height)
}
func (m *statusRightPanelModel) render(styles paletteStyles, width, height int) string {
if width <= 0 {
width = 96
}
if height <= 0 {
height = 28
}
header := lipgloss.JoinVertical(lipgloss.Left,
styles.title.Render("Bottom-right Status"),
styles.meta.Render("Interactive control center for tmux status-right modules"),
styles.meta.Render("Current layout: "+truncate(m.layoutSummary(), maxInt(28, width-2))),
)
lines := []string{styles.meta.Render(fmt.Sprintf("%d modules", len(m.entries))), ""}
for idx, entry := range m.entries {
rowStyle := styles.item.Width(maxInt(24, width-2))
titleStyle := styles.itemTitle
metaStyle := styles.itemSubtitle
detailStyle := styles.meta
fillStyle := lipgloss.NewStyle()
badgeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("235")).Background(lipgloss.Color("241")).Padding(0, 1).Bold(true)
badgeLabel := "OFF"
if entry.Enabled {
badgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("235")).Background(lipgloss.Color("150")).Padding(0, 1).Bold(true)
badgeLabel = "ON"
}
if idx == m.selected {
selectedBG := lipgloss.Color("238")
rowStyle = styles.selectedItem.Width(maxInt(24, width-2))
titleStyle = titleStyle.Background(selectedBG).Foreground(lipgloss.Color("230"))
metaStyle = styles.selectedSubtle.Background(selectedBG)
detailStyle = styles.selectedSubtle.Background(selectedBG)
fillStyle = fillStyle.Background(selectedBG)
}
badge := badgeStyle.Render(badgeLabel)
innerWidth := maxInt(22, width-2)
titleText := truncate(entry.Title, maxInt(8, innerWidth-lipgloss.Width(badge)-1))
gapWidth := maxInt(1, innerWidth-lipgloss.Width(titleText)-lipgloss.Width(badge))
titleRow := lipgloss.JoinHorizontal(lipgloss.Left,
titleStyle.Render(titleText),
fillStyle.Render(strings.Repeat(" ", gapWidth)),
badge,
)
detail := entry.Subtitle + ". " + statusRightVisibilityText(entry.Enabled)
detailText := truncate(detail, innerWidth)
detailGap := maxInt(0, innerWidth-lipgloss.Width(detailText))
detailRow := lipgloss.JoinHorizontal(lipgloss.Left,
metaStyle.Render(detailText),
fillStyle.Render(strings.Repeat(" ", detailGap)),
)
orderText := truncate(statusRightOrderHint(entry.Module, entry.Enabled), innerWidth)
orderGap := maxInt(0, innerWidth-lipgloss.Width(orderText))
orderRow := lipgloss.JoinHorizontal(lipgloss.Left,
detailStyle.Render(orderText),
fillStyle.Render(strings.Repeat(" ", orderGap)),
)
lines = append(lines, rowStyle.Render(lipgloss.JoinVertical(lipgloss.Left, titleRow, detailRow, orderRow)))
}
bodyHeight := maxInt(8, height-7)
body := lipgloss.NewStyle().Height(bodyHeight).Render(strings.Join(lines, "\n"))
footer := m.renderFooter(styles, width)
view := lipgloss.JoinVertical(lipgloss.Left, header, "", body, "", footer)
return lipgloss.NewStyle().Width(width).Height(height).Padding(0, 1).Render(view)
}
func statusRightVisibilityText(enabled bool) string {
if enabled {
return "Visible in the tmux bottom-right status"
}
return "Hidden from the tmux bottom-right status"
}
func statusRightOrderHint(module string, enabled bool) string {
prefix := "Order slot: " + statusRightModuleLabel(module)
if enabled {
return prefix + " follows the fixed module order"
}
return prefix + " stays reserved until re-enabled"
}
func (m *statusRightPanelModel) layoutSummary() string {
labels := make([]string, 0, len(m.entries))
for _, entry := range m.entries {
if entry.Enabled {
labels = append(labels, entry.Title)
}
}
if len(labels) == 0 {
return "nothing enabled"
}
return strings.Join(labels, " -> ")
}
func (m *statusRightPanelModel) renderFooter(styles paletteStyles, width int) string {
status := strings.TrimSpace(m.currentStatus())
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{{"Space", "toggle"}, {"Alt-S", "close"}, {footerHintToggleKey, "hide"}},
[][2]string{{"Space", "toggle"}, {"Alt-S", "close"}},
)
} else {
footer = pickRenderedShortcutFooter(width, renderSegments,
[][2]string{{"u/e", "move"}, {"Enter", "toggle"}, {"Esc", "back"}, {footerHintToggleKey, "more"}},
[][2]string{{"u/e", "move"}, {"Enter", "toggle"}, {footerHintToggleKey, "more"}},
[][2]string{{"Esc", "back"}, {footerHintToggleKey, "more"}},
)
}
if status != "" {
statusText := styles.statusBad.Render(truncate(status, maxInt(12, minInt(24, width/3))))
if lipgloss.Width(footer)+2+lipgloss.Width(statusText) <= width {
gap := width - lipgloss.Width(footer) - lipgloss.Width(statusText)
if gap < 2 {
gap = 2
}
return footer + strings.Repeat(" ", gap) + statusText
}
return statusText
}
return lipgloss.NewStyle().Width(width).Render(footer)
}
func (m *statusRightPanelModel) setStatus(text string, duration time.Duration) {
m.status = text
m.statusUntil = time.Now().Add(duration)
}
func (m *statusRightPanelModel) currentStatus() string {
if m.status == "" {
return ""
}
if !m.statusUntil.IsZero() && time.Now().After(m.statusUntil) {
m.status = ""
return ""
}
return m.status
}

View file

@ -0,0 +1,743 @@
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
const (
statusRightModuleCPU = "cpu"
statusRightModuleNetwork = "network"
statusRightModuleMemory = "memory"
statusRightModuleMemoryTotals = "memory_totals"
statusRightModuleAgent = "agent"
statusRightModuleNotes = "notes"
statusRightModuleFlashMoe = "flash_moe"
statusRightModuleHost = "host"
)
const (
statusIconCPU = ""
statusIconNetwork = "󰖩"
statusIconMemory = ""
statusIconWindow = "󰖲"
statusIconSession = ""
statusIconTotal = "󰍛"
statusIconAgent = "󰚩"
statusIconNotes = "󰎚"
statusIconFlashMoe = "󱙺"
)
func statusRightModules() []string {
return []string{
statusRightModuleCPU,
statusRightModuleNetwork,
statusRightModuleMemory,
statusRightModuleMemoryTotals,
statusRightModuleAgent,
statusRightModuleNotes,
statusRightModuleFlashMoe,
statusRightModuleHost,
}
}
var cpuUsagePattern = regexp.MustCompile(`CPU usage:\s*([0-9.]+)% user,\s*([0-9.]+)% sys,`)
var statusCommandOutput = func(name string, args ...string) ([]byte, error) {
cmd := exec.Command(name, args...)
return cmd.Output()
}
var statusCommandStart = func(name string, args ...string) error {
cmd := exec.Command(name, args...)
if err := cmd.Start(); err != nil {
return err
}
if cmd.Process != nil {
_ = cmd.Process.Release()
}
return nil
}
var statusNow = time.Now
var statusHostname = os.Hostname
var statusDetectCurrentAgentFromTmux = detectCurrentAgentFromTmux
var statusLoadRegistry = loadRegistry
var statusMemoryCachePath = func() string { return "/tmp/tmux-mem-usage.json" }
var statusMemoryCacheRefreshScript = func() string {
return filepath.Join(os.Getenv("HOME"), ".config", "tmux", "tmux-status", "mem_usage_cache.py")
}
var statusTodoFilePath = func() string {
return filepath.Join(os.Getenv("HOME"), ".cache", "agent", "todos.json")
}
var statusFlashMoeMetricsPath = func() string {
return filepath.Join(os.Getenv("HOME"), ".flash-moe", "tmux_metrics")
}
var statusNetworkRateCachePath = func() string {
return "/tmp/agent-tmux-network-rate.json"
}
type tmuxRightStatusArgs struct {
Width int
StatusBG string
SessionName string
WindowIndex string
PaneID string
WindowID string
}
type statusSegment struct {
FG string
BG string
Text string
Bold bool
}
type statusMemoryCache struct {
Pane map[string]string `json:"pane"`
Window map[string]string `json:"window"`
Session map[string]string `json:"session"`
Total string `json:"total"`
}
type statusTodoCache struct {
Windows map[string][]statusTodoItem `json:"windows"`
}
type statusTodoItem struct {
Done bool `json:"done"`
}
type statusNetworkCounter struct {
InBytes uint64
OutBytes uint64
}
type statusNetworkRateCache struct {
Interface string `json:"interface"`
InBytes uint64 `json:"in_bytes"`
OutBytes uint64 `json:"out_bytes"`
SampledAt int64 `json:"sampled_at_unix_ms"`
}
func runTmuxRightStatus(args []string) error {
fs := flag.NewFlagSet("agent tmux right-status", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
if err := fs.Parse(args); err != nil {
return err
}
values := fs.Args()
parsed := tmuxRightStatusArgs{}
if len(values) > 0 {
parsed.Width, _ = strconv.Atoi(strings.TrimSpace(values[0]))
}
if len(values) > 1 {
parsed.StatusBG = strings.TrimSpace(values[1])
}
if len(values) > 2 {
parsed.SessionName = strings.TrimSpace(values[2])
}
if len(values) > 3 {
parsed.WindowIndex = strings.TrimSpace(values[3])
}
if len(values) > 4 {
parsed.PaneID = strings.TrimSpace(values[4])
}
if len(values) > 5 {
parsed.WindowID = strings.TrimSpace(values[5])
}
if parsed.StatusBG == "" || parsed.StatusBG == "default" {
parsed.StatusBG = "black"
}
if parsed.Width > 0 && parsed.Width < statusRightMinimumWidth() {
return nil
}
fmt.Print(renderTmuxRightStatus(parsed))
return nil
}
func renderTmuxRightStatus(args tmuxRightStatusArgs) string {
segments := make([]statusSegment, 0, 6)
if statusRightModuleEnabled(statusRightModuleCPU) {
if label := loadCPUStatusLabel(); label != "" {
segments = append(segments, statusSegment{FG: "#1d1f21", BG: "#d08770", Text: label, Bold: true})
}
}
if statusRightModuleEnabled(statusRightModuleNetwork) {
if label := loadNetworkStatusLabel(); label != "" {
segments = append(segments, statusSegment{FG: "#1d1f21", BG: "#8fbcbb", Text: label, Bold: true})
}
}
if statusRightModuleEnabled(statusRightModuleMemory) {
if label := loadMemoryStatusLabel(args.PaneID); label != "" {
segments = append(segments, statusSegment{FG: "#eceff4", BG: "#5e81ac", Text: label})
}
}
if statusRightModuleEnabled(statusRightModuleMemoryTotals) {
segments = append(segments, loadMemoryTotalsStatusSegments(args)...)
}
if statusRightModuleEnabled(statusRightModuleAgent) {
if label := loadAgentStatusLabel(args.WindowID); label != "" {
segments = append(segments, statusSegment{FG: "#1d1f21", BG: "#81a1c1", Text: label, Bold: true})
}
}
if statusRightModuleEnabled(statusRightModuleNotes) {
if label := loadNotesStatusLabel(args.WindowID); label != "" {
segments = append(segments, statusSegment{FG: "#1d1f21", BG: "#cc6666", Text: label, Bold: true})
}
}
if statusRightModuleEnabled(statusRightModuleFlashMoe) {
if segment, ok := loadFlashMoeStatusSegment(); ok {
segments = append(segments, segment)
}
}
if statusRightModuleEnabled(statusRightModuleHost) {
if label := loadHostStatusLabel(); label != "" {
segments = append(segments, statusSegment{FG: "#1d1f21", BG: statusThemeColor(), Text: label})
}
}
return formatRightStatusSegments(args.StatusBG, segments)
}
func formatRightStatusSegments(statusBG string, segments []statusSegment) string {
if len(segments) == 0 {
return ""
}
separator := ""
rightCap := "█"
prevBG := statusBG
var builder strings.Builder
for _, segment := range segments {
builder.WriteString(fmt.Sprintf("#[fg=%s,bg=%s]%s#[fg=%s,bg=%s", segment.BG, prevBG, separator, segment.FG, segment.BG))
if segment.Bold {
builder.WriteString(",bold")
}
builder.WriteString("]")
builder.WriteString(segment.Text)
prevBG = segment.BG
}
builder.WriteString(fmt.Sprintf(" #[fg=%s,bg=%s]%s", prevBG, statusBG, rightCap))
return builder.String()
}
func statusRightMinimumWidth() int {
value := strings.TrimSpace(os.Getenv("TMUX_RIGHT_MIN_WIDTH"))
if value == "" {
return 90
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 1 {
return 90
}
return parsed
}
func statusThemeColor() string {
value := strings.TrimSpace(os.Getenv("TMUX_THEME_COLOR"))
if value == "" {
return "#b294bb"
}
return value
}
func loadCPUStatusLabel() string {
output, err := statusCommandOutput("top", "-l", "1", "-n", "0")
if err != nil {
return ""
}
total, ok := parseCPUUsageTotal(string(output))
if !ok {
return ""
}
return fmt.Sprintf(" %s %s ", statusIconCPU, formatUsagePercent(total))
}
func parseCPUUsageTotal(output string) (float64, bool) {
matches := cpuUsagePattern.FindStringSubmatch(output)
if len(matches) != 3 {
return 0, false
}
user, err := strconv.ParseFloat(matches[1], 64)
if err != nil {
return 0, false
}
system, err := strconv.ParseFloat(matches[2], 64)
if err != nil {
return 0, false
}
total := math.Max(0, user+system)
if total > 100 {
total = 100
}
return total, true
}
func formatUsagePercent(value float64) string {
if value < 10 && math.Abs(value-math.Round(value)) > 0.05 {
return fmt.Sprintf("%.1f%%", value)
}
return fmt.Sprintf("%.0f%%", value)
}
func loadNetworkStatusLabel() string {
preferred := loadPrimaryNetworkInterface()
output, err := statusCommandOutput("netstat", "-ibn")
if err != nil {
return ""
}
counters := parseNetworkCounters(string(output))
iface, current, ok := pickNetworkCounter(counters, preferred)
if !ok {
return ""
}
now := statusNow().UnixMilli()
previous, _ := loadNetworkRateCache()
rate := " ↓-- ↑-- "
if previous.Interface == iface && previous.SampledAt > 0 && now > previous.SampledAt && current.InBytes >= previous.InBytes && current.OutBytes >= previous.OutBytes {
seconds := float64(now-previous.SampledAt) / 1000
if seconds >= 0.25 {
down := float64(current.InBytes-previous.InBytes) / seconds
up := float64(current.OutBytes-previous.OutBytes) / seconds
rate = fmt.Sprintf(" %s ↓%s ↑%s ", statusIconNetwork, formatByteRate(down), formatByteRate(up))
}
}
_ = saveNetworkRateCache(statusNetworkRateCache{
Interface: iface,
InBytes: current.InBytes,
OutBytes: current.OutBytes,
SampledAt: now,
})
if rate == " ↓-- ↑-- " {
return fmt.Sprintf(" %s ↓-- ↑-- ", statusIconNetwork)
}
return rate
}
func loadPrimaryNetworkInterface() string {
output, err := statusCommandOutput("route", "-n", "get", "default")
if err != nil {
return ""
}
scanner := bufio.NewScanner(strings.NewReader(string(output)))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "interface:") {
continue
}
return strings.TrimSpace(strings.TrimPrefix(line, "interface:"))
}
return ""
}
func parseNetworkCounters(output string) map[string]statusNetworkCounter {
counters := make(map[string]statusNetworkCounter)
scanner := bufio.NewScanner(strings.NewReader(output))
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 10 {
continue
}
name := strings.TrimSuffix(strings.TrimSpace(fields[0]), "*")
if name == "" || strings.EqualFold(name, "Name") {
continue
}
if _, exists := counters[name]; exists {
continue
}
inBytes, err := strconv.ParseUint(fields[6], 10, 64)
if err != nil {
continue
}
outBytes, err := strconv.ParseUint(fields[9], 10, 64)
if err != nil {
continue
}
counters[name] = statusNetworkCounter{InBytes: inBytes, OutBytes: outBytes}
}
return counters
}
func pickNetworkCounter(counters map[string]statusNetworkCounter, preferred string) (string, statusNetworkCounter, bool) {
preferred = strings.TrimSpace(preferred)
if preferred != "" {
if counter, ok := counters[preferred]; ok {
return preferred, counter, true
}
}
var bestName string
var bestCounter statusNetworkCounter
var bestTotal uint64
for name, counter := range counters {
if ignoreNetworkInterface(name) {
continue
}
total := counter.InBytes + counter.OutBytes
if total <= bestTotal {
continue
}
bestName = name
bestCounter = counter
bestTotal = total
}
if bestName == "" {
return "", statusNetworkCounter{}, false
}
return bestName, bestCounter, true
}
func ignoreNetworkInterface(name string) bool {
for _, prefix := range []string{"lo", "awdl", "llw", "gif", "stf", "anpi", "ap", "bridge", "pktap"} {
if strings.HasPrefix(name, prefix) {
return true
}
}
return false
}
func formatByteRate(bytesPerSecond float64) string {
if bytesPerSecond < 0 {
bytesPerSecond = 0
}
units := []string{"B", "K", "M", "G"}
value := bytesPerSecond
unit := units[0]
for _, candidate := range units[1:] {
if value < 1024 {
break
}
value /= 1024
unit = candidate
}
if value < 10 && unit != "B" {
return fmt.Sprintf("%.1f%s", value, unit)
}
return fmt.Sprintf("%.0f%s", value, unit)
}
func loadNetworkRateCache() (statusNetworkRateCache, error) {
data, err := os.ReadFile(statusNetworkRateCachePath())
if err != nil {
return statusNetworkRateCache{}, err
}
var cache statusNetworkRateCache
if err := json.Unmarshal(data, &cache); err != nil {
return statusNetworkRateCache{}, err
}
return cache, nil
}
func saveNetworkRateCache(cache statusNetworkRateCache) error {
data, err := json.Marshal(cache)
if err != nil {
return err
}
path := statusNetworkRateCachePath()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
return err
}
return os.Rename(tmpPath, path)
}
func loadMemoryStatusLabel(paneID string) string {
paneID = strings.TrimSpace(paneID)
if paneID == "" {
return ""
}
cache, ok := loadMemoryStatusCache()
if !ok {
return ""
}
value := strings.TrimSpace(cache.Pane[paneID])
if value == "" {
return ""
}
return fmt.Sprintf(" %s %s ", statusIconMemory, value)
}
func loadMemoryTotalsStatusSegments(args tmuxRightStatusArgs) []statusSegment {
cache, ok := loadMemoryStatusCache()
if !ok {
return nil
}
segments := make([]statusSegment, 0, 3)
windowKey := strings.TrimSpace(args.SessionName)
if windowKey != "" && strings.TrimSpace(args.WindowIndex) != "" {
windowKey = windowKey + ":" + strings.TrimSpace(args.WindowIndex)
}
if value := strings.TrimSpace(cache.Window[windowKey]); value != "" {
segments = append(segments, statusSegment{FG: "#eceff4", BG: "#4c566a", Text: fmt.Sprintf(" %s %s ", statusIconWindow, value)})
}
if value := strings.TrimSpace(cache.Session[strings.TrimSpace(args.SessionName)]); value != "" {
segments = append(segments, statusSegment{FG: "#eceff4", BG: "#434c5e", Text: fmt.Sprintf(" %s %s ", statusIconSession, value)})
}
if value := strings.TrimSpace(cache.Total); value != "" {
segments = append(segments, statusSegment{FG: "#eceff4", BG: "#3b4252", Text: fmt.Sprintf(" %s %s ", statusIconTotal, value)})
}
return segments
}
func loadMemoryStatusCache() (statusMemoryCache, bool) {
refreshMemoryUsageCache()
data, err := os.ReadFile(statusMemoryCachePath())
if err != nil {
return statusMemoryCache{}, false
}
var cache statusMemoryCache
if err := json.Unmarshal(data, &cache); err != nil {
return statusMemoryCache{}, false
}
return cache, true
}
func loadAgentStatusLabel(windowID string) string {
windowID = strings.TrimSpace(windowID)
if windowID == "" {
return ""
}
ref, err := statusDetectCurrentAgentFromTmux(windowID)
if err != nil || strings.TrimSpace(ref.ID) == "" {
return ""
}
reg, err := statusLoadRegistry()
if err != nil || reg == nil {
return ""
}
record := reg.Agents[strings.TrimSpace(ref.ID)]
if record == nil {
return ""
}
device := strings.TrimSpace(record.Device)
if device == "" {
device = "no device"
}
return fmt.Sprintf(" %s %s ", statusIconAgent, device)
}
func refreshMemoryUsageCache() {
script := statusMemoryCacheRefreshScript()
if strings.TrimSpace(script) == "" || !fileExists(script) {
return
}
_ = statusCommandStart("python3", script)
}
func loadNotesStatusLabel(windowID string) string {
windowID = strings.TrimSpace(windowID)
if windowID == "" {
return ""
}
data, err := os.ReadFile(statusTodoFilePath())
if err != nil {
return ""
}
var cache statusTodoCache
if err := json.Unmarshal(data, &cache); err != nil {
return ""
}
items := cache.Windows[windowID]
count := 0
for _, item := range items {
if !item.Done {
count++
}
}
if count == 0 {
return ""
}
return fmt.Sprintf(" %s %d ", statusIconNotes, count)
}
func loadFlashMoeStatusSegment() (statusSegment, bool) {
metricsPath := statusFlashMoeMetricsPath()
if strings.TrimSpace(metricsPath) == "" {
return statusSegment{}, false
}
data, err := os.ReadFile(metricsPath)
if err != nil {
return statusSegment{}, false
}
values := map[string]string{}
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
values[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
phase := values["phase"]
if updated := strings.TrimSpace(values["updated_ms"]); updated != "" {
if updatedMS, err := strconv.ParseInt(updated, 10, 64); err == nil {
ageMS := statusNow().UnixMilli() - updatedMS
if ageMS > 10000 && (phase == "gen" || phase == "prefill") {
phase = "idle"
}
}
}
switch phase {
case "prefill":
promptTokens := strings.TrimSpace(values["prompt_tokens"])
label := fmt.Sprintf(" %s prefill ", statusIconFlashMoe)
if promptTokens != "" && promptTokens != "0" {
label = fmt.Sprintf(" %s prefill:%s ", statusIconFlashMoe, promptTokens)
}
return statusSegment{FG: "#1d1f21", BG: "#ebcb8b", Text: label, Bold: true}, true
case "gen":
tokS := strings.TrimSpace(values["tok_s"])
label := fmt.Sprintf(" %s gen ", statusIconFlashMoe)
if tokS != "" && tokS != "0.00" {
label = fmt.Sprintf(" %s %s tok/s ", statusIconFlashMoe, tokS)
}
return statusSegment{FG: "#1d1f21", BG: "#a3be8c", Text: label, Bold: true}, true
default:
return statusSegment{}, false
}
}
func loadHostStatusLabel() string {
host, err := statusHostname()
if err != nil {
return ""
}
host = strings.TrimSpace(host)
if host == "" {
return ""
}
if short, _, ok := strings.Cut(host, "."); ok {
host = short
}
return fmt.Sprintf(" %s", host)
}
func defaultStatusRightModuleEnabled(module string) bool {
switch module {
case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleAgent, statusRightModuleNotes, statusRightModuleFlashMoe, statusRightModuleHost:
return true
case statusRightModuleMemoryTotals:
return true
default:
return false
}
}
func isValidStatusRightModule(module string) bool {
switch module {
case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleMemoryTotals, statusRightModuleAgent, statusRightModuleNotes, statusRightModuleFlashMoe, statusRightModuleHost:
return true
default:
return false
}
}
func statusRightModuleEnabled(module string) bool {
if !isValidStatusRightModule(module) {
return false
}
cfg := loadAppConfig()
if cfg.StatusRight == nil {
return defaultStatusRightModuleEnabled(module)
}
return cfg.StatusRight.moduleEnabled(module)
}
func (cfg statusRightConfig) moduleEnabled(module string) bool {
switch module {
case statusRightModuleCPU:
return derefBool(cfg.CPU, defaultStatusRightModuleEnabled(module))
case statusRightModuleNetwork:
return derefBool(cfg.Network, defaultStatusRightModuleEnabled(module))
case statusRightModuleMemory:
return derefBool(cfg.Memory, defaultStatusRightModuleEnabled(module))
case statusRightModuleMemoryTotals:
return derefBool(cfg.MemoryTotals, defaultStatusRightModuleEnabled(module))
case statusRightModuleAgent:
return derefBool(cfg.Agent, defaultStatusRightModuleEnabled(module))
case statusRightModuleNotes:
return derefBool(cfg.Notes, defaultStatusRightModuleEnabled(module))
case statusRightModuleFlashMoe:
return derefBool(cfg.FlashMoe, defaultStatusRightModuleEnabled(module))
case statusRightModuleHost:
return derefBool(cfg.Host, defaultStatusRightModuleEnabled(module))
default:
return false
}
}
func toggleStatusRightModule(module string) error {
if !isValidStatusRightModule(module) {
return fmt.Errorf("unknown status-right module: %s", module)
}
enabled := !statusRightModuleEnabled(module)
return updateAppConfig(func(cfg *appConfig) {
if cfg.StatusRight == nil {
cfg.StatusRight = &statusRightConfig{}
}
cfg.StatusRight.setModuleEnabled(module, enabled)
if cfg.StatusRight.isDefault() {
cfg.StatusRight = nil
}
})
}
func (cfg *statusRightConfig) setModuleEnabled(module string, enabled bool) {
value := boolPtr(enabled)
if enabled == defaultStatusRightModuleEnabled(module) {
value = nil
}
switch module {
case statusRightModuleCPU:
cfg.CPU = value
case statusRightModuleNetwork:
cfg.Network = value
case statusRightModuleMemory:
cfg.Memory = value
case statusRightModuleMemoryTotals:
cfg.MemoryTotals = value
case statusRightModuleAgent:
cfg.Agent = value
case statusRightModuleNotes:
cfg.Notes = value
case statusRightModuleFlashMoe:
cfg.FlashMoe = value
case statusRightModuleHost:
cfg.Host = value
}
}
func (cfg *statusRightConfig) isDefault() bool {
if cfg == nil {
return true
}
return cfg.CPU == nil && cfg.Network == nil && cfg.Memory == nil && cfg.MemoryTotals == nil && cfg.Agent == nil && cfg.Notes == nil && cfg.FlashMoe == nil && cfg.Host == nil
}
func derefBool(value *bool, fallback bool) bool {
if value == nil {
return fallback
}
return *value
}
func boolPtr(value bool) *bool {
ptr := new(bool)
*ptr = value
return ptr
}

View file

@ -1,450 +0,0 @@
package main
import (
"fmt"
"strings"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
func writeTestTodoStore(t *testing.T, home string, store *tmuxTodoStore) {
t.Helper()
t.Setenv("HOME", home)
if err := saveTmuxTodoStore(store); err != nil {
t.Fatalf("save todo store: %v", err)
}
}
func TestTodoPanelViewShowsTwoColumnsWithoutPreview(t *testing.T) {
home := t.TempDir()
writeTestTodoStore(t, home, &tmuxTodoStore{
Version: tmuxTodoStoreVersion,
Global: []tmuxTodoItem{{Title: "global todo", Priority: 2, CreatedAt: time.Now()}},
Windows: map[string][]tmuxTodoItem{
"@1": {{Title: "window todo", Priority: 1, CreatedAt: time.Now()}},
},
})
model, err := newTodoPanelModel("$1", "@1")
if err != nil {
t.Fatalf("new todo panel model: %v", err)
}
model.width = 96
model.height = 24
view := model.View()
if !strings.Contains(view, "WINDOW") {
t.Fatalf("expected window column header in view: %q", view)
}
if !strings.Contains(view, "GLOBAL") {
t.Fatalf("expected global column header in view: %q", view)
}
if strings.Contains(view, "Overview") || strings.Contains(view, "Selected") {
t.Fatalf("unexpected preview content in view: %q", view)
}
if strings.Contains(view, "SESSION") {
t.Fatalf("unexpected session scope in view: %q", view)
}
}
func TestTodoPanelSwitchesFocusedColumnWithNI(t *testing.T) {
home := t.TempDir()
writeTestTodoStore(t, home, &tmuxTodoStore{
Version: tmuxTodoStoreVersion,
Global: []tmuxTodoItem{{Title: "global todo", Priority: 2, CreatedAt: time.Now()}},
Windows: map[string][]tmuxTodoItem{
"@1": {{Title: "window todo", Priority: 1, CreatedAt: time.Now()}},
},
})
model, err := newTodoPanelModel("$1", "@1")
if err != nil {
t.Fatalf("new todo panel model: %v", err)
}
if model.focusedScope != todoScopeWindow {
t.Fatalf("expected window focus by default, got %v", model.focusedScope)
}
model.updateList("i")
if model.focusedScope != todoScopeGlobal {
t.Fatalf("expected global focus after i, got %v", model.focusedScope)
}
model.updateList("n")
if model.focusedScope != todoScopeWindow {
t.Fatalf("expected window focus after n, got %v", model.focusedScope)
}
}
func TestTodoPanelDefaultsToWindowFocusEvenWithoutWindowTodos(t *testing.T) {
home := t.TempDir()
writeTestTodoStore(t, home, &tmuxTodoStore{
Version: tmuxTodoStoreVersion,
Global: []tmuxTodoItem{{Title: "global todo", Priority: 2, CreatedAt: time.Now()}},
})
model, err := newTodoPanelModel("$1", "@1")
if err != nil {
t.Fatalf("new todo panel model: %v", err)
}
if model.focusedScope != todoScopeWindow {
t.Fatalf("expected window focus by default, got %v", model.focusedScope)
}
}
func TestTodoPanelAddModeAcceptsNICharacters(t *testing.T) {
home := t.TempDir()
writeTestTodoStore(t, home, &tmuxTodoStore{Version: tmuxTodoStoreVersion})
model, err := newTodoPanelModel("$1", "@1")
if err != nil {
t.Fatalf("new todo panel model: %v", err)
}
model.updateList("a")
model.updateAdd("n")
model.updateAdd("i")
if got := string(model.addText); got != "ni" {
t.Fatalf("expected add text to include typed letters, got %q", got)
}
if model.addScope != todoScopeWindow {
t.Fatalf("expected add scope to stay window while typing, got %v", model.addScope)
}
}
func TestTodoPanelAddModeUsesArrowsAndTabForTarget(t *testing.T) {
home := t.TempDir()
writeTestTodoStore(t, home, &tmuxTodoStore{Version: tmuxTodoStoreVersion})
model, err := newTodoPanelModel("$1", "@1")
if err != nil {
t.Fatalf("new todo panel model: %v", err)
}
model.updateList("a")
model.updateAdd("right")
if model.addScope != todoScopeGlobal {
t.Fatalf("expected right to switch add scope to global, got %v", model.addScope)
}
model.updateAdd("left")
if model.addScope != todoScopeWindow {
t.Fatalf("expected left to switch add scope to window, got %v", model.addScope)
}
model.updateAdd("tab")
if model.addScope != todoScopeGlobal {
t.Fatalf("expected tab to toggle add scope to global, got %v", model.addScope)
}
}
func TestTodoPanelCanEditSelectedTodo(t *testing.T) {
home := t.TempDir()
writeTestTodoStore(t, home, &tmuxTodoStore{
Version: tmuxTodoStoreVersion,
Windows: map[string][]tmuxTodoItem{
"@1": {{Title: "old title", Priority: 2, CreatedAt: time.Now()}},
},
})
model, err := newTodoPanelModel("$1", "@1")
if err != nil {
t.Fatalf("new todo panel model: %v", err)
}
updated, cmd := model.updateList("E")
if cmd != nil {
t.Fatalf("expected no tea command when opening edit mode, got %v", cmd)
}
panel := updated.(*todoPanelModel)
if panel.mode != todoPanelModeEdit {
t.Fatalf("expected edit mode, got %v", panel.mode)
}
if got := string(panel.addText); got != "old title" {
t.Fatalf("expected initial edit text, got %q", got)
}
for range []rune("old title") {
panel.updateEdit("backspace")
}
for _, key := range []string{"n", "e", "w", " ", "t", "i", "t", "l", "e"} {
panel.updateEdit(key)
}
updated, cmd = panel.updateEdit("enter")
if cmd != nil {
t.Fatalf("expected no tea command when saving edit, got %v", cmd)
}
panel = updated.(*todoPanelModel)
if panel.mode != todoPanelModeList {
t.Fatalf("expected list mode after save, got %v", panel.mode)
}
store, err := loadTmuxTodoStore()
if err != nil {
t.Fatalf("load todo store: %v", err)
}
if got := store.Windows["@1"][0].Title; got != "new title" {
t.Fatalf("expected edited title, got %q", got)
}
}
func TestTodoPanelCtrlEReordersFocusedColumn(t *testing.T) {
home := t.TempDir()
writeTestTodoStore(t, home, &tmuxTodoStore{
Version: tmuxTodoStoreVersion,
Windows: map[string][]tmuxTodoItem{
"@1": {
{Title: "first", Priority: 2, CreatedAt: time.Now()},
{Title: "second", Priority: 2, CreatedAt: time.Now()},
},
},
})
model, err := newTodoPanelModel("$1", "@1")
if err != nil {
t.Fatalf("new todo panel model: %v", err)
}
if _, cmd := model.updateList("ctrl+e"); cmd != nil {
t.Fatalf("expected no tea command from reorder")
}
store, err := loadTmuxTodoStore()
if err != nil {
t.Fatalf("load todo store: %v", err)
}
items := store.Windows["@1"]
if len(items) != 2 {
t.Fatalf("expected 2 items after reorder, got %d", len(items))
}
if items[0].Title != "second" || items[1].Title != "first" {
t.Fatalf("unexpected reordered items: %#v", items)
}
if model.selectedWindow != 1 {
t.Fatalf("expected moved selection to stay on reordered item, got %d", model.selectedWindow)
}
}
func TestTodoPanelWrapsLongTodoTitles(t *testing.T) {
home := t.TempDir()
longTitle := "this is a very long todo title that should wrap across multiple lines in the panel"
writeTestTodoStore(t, home, &tmuxTodoStore{
Version: tmuxTodoStoreVersion,
Windows: map[string][]tmuxTodoItem{
"@1": {{Title: longTitle, Priority: 2, CreatedAt: time.Now()}},
},
})
model, err := newTodoPanelModel("$1", "@1")
if err != nil {
t.Fatalf("new todo panel model: %v", err)
}
column := model.renderColumn(todoScopeWindow, 28, 10)
if !strings.Contains(column, "this is a very") || !strings.Contains(column, "long todo title") {
t.Fatalf("expected wrapped long todo across multiple lines, got %q", column)
}
if !strings.Contains(column, "MED") {
t.Fatalf("expected priority chip to remain visible, got %q", column)
}
}
func TestTodoPanelYCopiesSelectedTodo(t *testing.T) {
home := t.TempDir()
writeTestTodoStore(t, home, &tmuxTodoStore{
Version: tmuxTodoStoreVersion,
Windows: map[string][]tmuxTodoItem{
"@1": {{Title: "window todo", Priority: 1, CreatedAt: time.Now()}},
},
})
prev := todoPanelClipboardWriter
t.Cleanup(func() { todoPanelClipboardWriter = prev })
got := ""
todoPanelClipboardWriter = func(value string) error {
got = value
return nil
}
model, err := newTodoPanelModel("$1", "@1")
if err != nil {
t.Fatalf("new todo panel model: %v", err)
}
updated, cmd := model.updateList("y")
if cmd != nil {
t.Fatalf("expected no tea command from copy, got %v", cmd)
}
panel := updated.(*todoPanelModel)
if got != "window todo" {
t.Fatalf("expected copied todo title, got %q", got)
}
if status := panel.currentStatus(); status != "Copied todo" {
t.Fatalf("expected copied status, got %q", status)
}
}
func TestTodoPanelFooterStaysSingleLine(t *testing.T) {
home := t.TempDir()
writeTestTodoStore(t, home, &tmuxTodoStore{
Version: tmuxTodoStoreVersion,
Global: []tmuxTodoItem{{Title: "global todo", Priority: 2, CreatedAt: time.Now()}},
Windows: map[string][]tmuxTodoItem{
"@1": {{Title: "window todo", Priority: 1, CreatedAt: time.Now()}},
},
})
model, err := newTodoPanelModel("$1", "@1")
if err != nil {
t.Fatalf("new todo panel model: %v", err)
}
for _, width := range []int{72, 78, 84, 96, 120} {
footer := model.renderFooter(width)
if strings.Contains(footer, "\n") {
t.Fatalf("width %d: footer wrapped into multiple lines: %q", width, footer)
}
if got := lipgloss.Width(footer); got > maxInt(1, width-2) {
t.Fatalf("width %d: footer width %d exceeds content width %d", width, got, maxInt(1, width-2))
}
}
}
func TestTodoPanelFooterStatusStaysSingleLine(t *testing.T) {
home := t.TempDir()
writeTestTodoStore(t, home, &tmuxTodoStore{Version: tmuxTodoStoreVersion})
model, err := newTodoPanelModel("$1", "@1")
if err != nil {
t.Fatalf("new todo panel model: %v", err)
}
model.setStatus("something went wrong with a very long message", time.Minute)
for _, width := range []int{72, 78, 84, 96, 120} {
footer := model.renderFooter(width)
if strings.Contains(footer, "\n") {
t.Fatalf("width %d: footer wrapped into multiple lines: %q", width, footer)
}
if got := lipgloss.Width(footer); got > maxInt(1, width-2) {
t.Fatalf("width %d: footer width %d exceeds content width %d", width, got, maxInt(1, width-2))
}
}
}
func TestTodoPanelFooterTogglesAltHints(t *testing.T) {
home := t.TempDir()
writeTestTodoStore(t, home, &tmuxTodoStore{Version: tmuxTodoStoreVersion})
model, err := newTodoPanelModel("$1", "@1")
if err != nil {
t.Fatalf("new todo panel model: %v", err)
}
footer := model.renderFooter(96)
if !strings.Contains(footer, "more") {
t.Fatalf("expected default todo footer to advertise alt options, got %q", footer)
}
if !strings.Contains(footer, footerHintToggleKey) {
t.Fatalf("expected default todo footer to show %q toggle, got %q", footerHintToggleKey, footer)
}
if strings.Contains(footer, "Alt-S") {
t.Fatalf("expected default todo footer to hide alt shortcuts, got %q", footer)
}
updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
panel := updated.(*todoPanelModel)
altFooter := panel.renderFooter(96)
if !strings.Contains(altFooter, "Alt-S") {
t.Fatalf("expected alt todo footer to show alt shortcuts, got %q", altFooter)
}
if strings.Contains(altFooter, "toggle") || strings.Contains(altFooter, "add") {
t.Fatalf("expected alt todo footer to hide default shortcuts, got %q", altFooter)
}
}
func TestTodoPanelViewNeverOverflowsWidth(t *testing.T) {
home := t.TempDir()
writeTestTodoStore(t, home, &tmuxTodoStore{
Version: tmuxTodoStoreVersion,
Global: []tmuxTodoItem{{Title: "global todo", Priority: 2, CreatedAt: time.Now()}},
Windows: map[string][]tmuxTodoItem{
"@1": {
{Title: "window todo", Priority: 1, CreatedAt: time.Now()},
{Title: "another todo", Priority: 3, CreatedAt: time.Now()},
},
},
})
model, err := newTodoPanelModel("$1", "@1")
if err != nil {
t.Fatalf("new todo panel model: %v", err)
}
for _, width := range []int{72, 78, 84, 96, 120} {
model.width = width
model.height = 20
view := model.View()
for idx, line := range strings.Split(view, "\n") {
if got := lipgloss.Width(line); got > width {
t.Fatalf("width %d: line %d width %d exceeds viewport %d", width, idx+1, got, width)
}
}
}
}
func TestTodoPanelFooterHasNoSpacerLine(t *testing.T) {
home := t.TempDir()
windowItems := make([]tmuxTodoItem, 0, 12)
globalItems := make([]tmuxTodoItem, 0, 12)
for i := 0; i < 12; i++ {
windowItems = append(windowItems, tmuxTodoItem{Title: fmt.Sprintf("window %d", i), Priority: 1, CreatedAt: time.Now()})
globalItems = append(globalItems, tmuxTodoItem{Title: fmt.Sprintf("global %d", i), Priority: 2, CreatedAt: time.Now()})
}
writeTestTodoStore(t, home, &tmuxTodoStore{
Version: tmuxTodoStoreVersion,
Global: globalItems,
Windows: map[string][]tmuxTodoItem{"@1": windowItems},
})
model, err := newTodoPanelModel("$1", "@1")
if err != nil {
t.Fatalf("new todo panel model: %v", err)
}
for _, width := range []int{72, 78, 84, 96, 120} {
model.width = width
model.height = 20
lines := strings.Split(model.View(), "\n")
if len(lines) < 2 {
t.Fatalf("width %d: expected multi-line view, got %q", width, model.View())
}
last := strings.TrimSpace(lines[len(lines)-1])
prev := strings.TrimSpace(lines[len(lines)-2])
if !strings.Contains(last, "Esc") {
t.Fatalf("width %d: expected footer on last line, got %q", width, last)
}
if prev == "" {
t.Fatalf("width %d: expected no blank spacer line above footer", width)
}
}
}
func TestTodoPanelFooterShowsCopyShortcutWhenSpaceAllows(t *testing.T) {
home := t.TempDir()
writeTestTodoStore(t, home, &tmuxTodoStore{
Version: tmuxTodoStoreVersion,
Windows: map[string][]tmuxTodoItem{
"@1": {{Title: "window todo", Priority: 1, CreatedAt: time.Now()}},
},
})
model, err := newTodoPanelModel("$1", "@1")
if err != nil {
t.Fatalf("new todo panel model: %v", err)
}
footer := model.renderFooter(96)
if !strings.Contains(footer, "copy") || !strings.Contains(footer, "y") {
t.Fatalf("expected footer to show copy shortcut, got %q", footer)
}
if !strings.Contains(footer, "edit") || !strings.Contains(footer, "E") {
t.Fatalf("expected footer to show edit shortcut, got %q", footer)
}
}

View file

@ -132,9 +132,6 @@ func bootstrapTmuxTodoStore() (*tmuxTodoStore, error) {
if err := importLegacyYamlTodos(store); err != nil {
return nil, err
}
if err := importLegacyDashboardTodos(store); err != nil {
return nil, err
}
if err := saveTmuxTodoStore(store); err != nil {
return nil, err
}
@ -235,32 +232,6 @@ func importLegacyYamlTodos(store *tmuxTodoStore) error {
return nil
}
func importLegacyDashboardTodos(store *tmuxTodoStore) error {
reg, err := loadRegistry()
if err != nil {
return err
}
for _, record := range reg.Agents {
if record == nil {
continue
}
scope := todoScopeGlobal
scopeID := "global"
switch {
case strings.TrimSpace(record.TmuxWindowID) != "":
scope = todoScopeWindow
scopeID = strings.TrimSpace(record.TmuxWindowID)
case strings.TrimSpace(record.TmuxSessionID) != "":
scope = todoScopeSession
scopeID = strings.TrimSpace(record.TmuxSessionID)
}
for _, todo := range record.Dashboard.Todos {
appendUniqueTodo(store, scope, scopeID, tmuxTodoItem{Title: todo.Title, Done: todo.Done, Priority: 2})
}
}
return nil
}
func collectAllTmuxTodos(currentSessionID, currentWindowID string) []tmuxTodoEntry {
store, err := loadTmuxTodoStore()
if err != nil {

View file

@ -67,7 +67,7 @@ func runTrackerCommand(args []string) error {
}
command := strings.TrimSpace(rest[0])
switch command {
case "start_task", "finish_task", "acknowledge", "delete_task":
case "start_task", "finish_task", "update_task", "acknowledge", "delete_task":
ctx, err := resolveTrackerContext(env.Session, env.SessionID, env.Window, env.WindowID, env.Pane)
if err != nil {
return err

View file

@ -1,102 +0,0 @@
package main
import (
"strings"
"testing"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/david/agent-tracker/internal/ipc"
)
func TestPaletteAltROpensTrackerPanel(t *testing.T) {
model := newPaletteModel(&paletteRuntime{}, paletteUIState{Mode: paletteModeList})
updated, cmd := model.updateList("alt+r")
if cmd == nil {
t.Fatalf("expected tracker shortcut to return a command")
}
palette := updated.(*paletteModel)
if palette.state.Mode != paletteModeTracker {
t.Fatalf("expected tracker mode, got %v", palette.state.Mode)
}
if palette.tracker == nil {
t.Fatalf("expected tracker panel to be created")
}
}
func TestTrackerSortTasksPrioritizesActiveAndUnacknowledged(t *testing.T) {
tasks := []ipc.Task{
{SessionID: "$1", WindowID: "@1", Pane: "%1", Summary: "ack", Status: trackerTaskStatusCompleted, Acknowledged: true, CompletedAt: "2026-03-23T18:00:00Z"},
{SessionID: "$1", WindowID: "@2", Pane: "%2", Summary: "active", Status: trackerTaskStatusInProgress, StartedAt: "2026-03-23T17:00:00Z"},
{SessionID: "$1", WindowID: "@3", Pane: "%3", Summary: "review", Status: trackerTaskStatusCompleted, Acknowledged: false, CompletedAt: "2026-03-23T19:00:00Z"},
}
trackerSortTasks(tasks)
if tasks[0].Summary != "active" {
t.Fatalf("expected active task first, got %#v", tasks)
}
if tasks[1].Summary != "review" {
t.Fatalf("expected unacknowledged completed task second, got %#v", tasks)
}
}
func TestTrackerDetailLineWrapsWithinPanel(t *testing.T) {
line := trackerDetailLine(newPaletteStyles(), "where", "session-name / a-very-long-window-name-that-needs-wrapping", 26)
if !strings.Contains(line, "\n") {
t.Fatalf("expected wrapped detail line, got %q", line)
}
}
func TestTrackerTaskRowUsesIndicatorInsteadOfLiveDoneLabel(t *testing.T) {
width := 36
row := (&trackerPanelModel{}).renderTaskRow(newPaletteStyles(), ipc.Task{Summary: "Build release", Status: trackerTaskStatusInProgress}, true, width, mustTrackerTime(t, "2026-03-23T20:00:00Z"))
if strings.Contains(row, "LIVE") || strings.Contains(row, "DONE") || strings.Contains(row, "REVIEW") {
t.Fatalf("expected indicator-based tracker row, got %q", row)
}
for _, line := range strings.Split(row, "\n") {
if lipgloss.Width(line) > width {
t.Fatalf("expected rendered task row width <= %d, got %d for %q", width, lipgloss.Width(line), line)
}
}
doneRow := (&trackerPanelModel{}).renderTaskRow(newPaletteStyles(), ipc.Task{Summary: "Build release", Status: trackerTaskStatusCompleted, Acknowledged: true}, false, width, mustTrackerTime(t, "2026-03-23T20:00:00Z"))
if !strings.Contains(doneRow, "✓") {
t.Fatalf("expected completed task row to use checkmark indicator, got %q", doneRow)
}
}
func TestTrackerVisibleTasksReturnsSortedTasks(t *testing.T) {
model := &trackerPanelModel{state: ipc.Envelope{Tasks: []ipc.Task{
{Summary: "done", Status: trackerTaskStatusCompleted, Acknowledged: true, CompletedAt: "2026-03-23T18:00:00Z"},
{Summary: "active", Status: trackerTaskStatusInProgress, StartedAt: "2026-03-23T17:00:00Z"},
{Summary: "review", Status: trackerTaskStatusCompleted, Acknowledged: false, CompletedAt: "2026-03-23T19:00:00Z"},
}}}
visible := model.visibleTasks()
if len(visible) != 3 {
t.Fatalf("expected 3 visible tasks, got %d", len(visible))
}
if visible[0].Summary != "active" || visible[1].Summary != "review" {
t.Fatalf("expected visible tasks to stay sorted by tracker priority, got %#v", visible)
}
}
func TestTrackerTaskRowKeepsSameHeightWhenSelected(t *testing.T) {
styles := newPaletteStyles()
task := ipc.Task{Summary: "Build release", Status: trackerTaskStatusCompleted, Acknowledged: false, CompletionNote: "this should stay in the detail pane only"}
selected := (&trackerPanelModel{}).renderTaskRow(styles, task, true, 36, mustTrackerTime(t, "2026-03-23T20:00:00Z"))
unselected := (&trackerPanelModel{}).renderTaskRow(styles, task, false, 36, mustTrackerTime(t, "2026-03-23T20:00:00Z"))
if strings.Count(selected, "\n") != strings.Count(unselected, "\n") {
t.Fatalf("expected selected and unselected task rows to keep same height, got %q vs %q", selected, unselected)
}
}
func mustTrackerTime(t *testing.T, value string) time.Time {
t.Helper()
ts, err := time.Parse(time.RFC3339, value)
if err != nil {
t.Fatalf("parse time: %v", err)
}
return ts
}

View file

@ -0,0 +1,71 @@
package main
import (
"strings"
"unicode"
)
func wrapText(text string, width int) []string {
if width <= 0 {
return []string{""}
}
if text == "" {
return []string{""}
}
words := strings.Fields(text)
if len(words) == 0 {
return []string{""}
}
var lines []string
current := words[0]
for _, word := range words[1:] {
candidate := current + " " + word
if len([]rune(candidate)) <= width {
current = candidate
continue
}
lines = append(lines, current)
current = word
}
lines = append(lines, current)
return lines
}
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[:1])
}
return string(runes[:width-1]) + "…"
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func previousWordBoundary(runes []rune, cursor int) int {
i := cursor
for i > 0 && unicode.IsSpace(runes[i-1]) {
i--
}
for i > 0 && !unicode.IsSpace(runes[i-1]) {
i--
}
return i
}

View file

@ -204,6 +204,21 @@ func (s *server) handleCommand(env ipc.Envelope) error {
s.broadcastStateAsync()
s.statusRefreshAsync()
return nil
case "update_task":
target, err := requireSessionWindow(env)
if err != nil {
return err
}
summary := firstNonEmpty(env.Summary, env.Message)
if summary == "" {
return fmt.Errorf("update_task requires summary")
}
if err := s.updateTaskSummary(target, summary); err != nil {
return err
}
s.broadcastStateAsync()
s.statusRefreshAsync()
return nil
case "notifications_toggle":
enabled, err := s.toggleNotifications()
if err != nil {
@ -255,16 +270,64 @@ func (s *server) startTask(target tmuxTarget, summary string) error {
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
s.tasks[taskKey(target.SessionID, target.WindowID, target.PaneID)] = &taskRecord{
SessionID: target.SessionID,
SessionName: strings.TrimSpace(target.SessionName),
WindowID: target.WindowID,
WindowName: strings.TrimSpace(target.WindowName),
Pane: target.PaneID,
Summary: summary,
StartedAt: now,
Status: statusInProgress,
Acknowledged: true,
key := taskKey(target.SessionID, target.WindowID, target.PaneID)
t, ok := s.tasks[key]
if !ok {
s.tasks[key] = &taskRecord{
SessionID: target.SessionID,
SessionName: strings.TrimSpace(target.SessionName),
WindowID: target.WindowID,
WindowName: strings.TrimSpace(target.WindowName),
Pane: target.PaneID,
Summary: summary,
StartedAt: now,
Status: statusInProgress,
Acknowledged: true,
}
return nil
}
mergeTaskNamesFromTarget(t, target)
if !(t.Status == statusInProgress && strings.TrimSpace(t.Summary) != "") {
t.Summary = summary
}
t.StartedAt = now
t.Status = statusInProgress
t.CompletedAt = nil
t.CompletionNote = ""
t.Acknowledged = true
return nil
}
func (s *server) updateTaskSummary(target tmuxTarget, summary string) error {
if target.SessionID == "" || target.WindowID == "" {
return fmt.Errorf("cannot update task: missing session or window ID")
}
target = normalizeTargetNames(target)
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
key := taskKey(target.SessionID, target.WindowID, target.PaneID)
t, ok := s.tasks[key]
if !ok {
t = &taskRecord{
SessionID: target.SessionID,
SessionName: strings.TrimSpace(target.SessionName),
WindowID: target.WindowID,
WindowName: strings.TrimSpace(target.WindowName),
Pane: target.PaneID,
StartedAt: now,
Status: statusInProgress,
Acknowledged: true,
}
s.tasks[key] = t
}
mergeTaskNamesFromTarget(t, target)
t.Summary = summary
if t.Status == "" {
t.Status = statusInProgress
}
if t.StartedAt.IsZero() {
t.StartedAt = now
}
return nil
}

View file

@ -1,53 +0,0 @@
package main
import (
"testing"
"time"
)
func TestNormalizeTargetNamesClearsIDPlaceholders(t *testing.T) {
target := normalizeTargetNames(tmuxTarget{
SessionName: "$3",
SessionID: "$3",
WindowName: "@12",
WindowID: "@12",
PaneID: "%7",
})
if target.SessionName != "" {
t.Fatalf("expected placeholder session name to be cleared, got %q", target.SessionName)
}
if target.WindowName != "" {
t.Fatalf("expected placeholder window name to be cleared, got %q", target.WindowName)
}
}
func TestBuildStateEnvelopeUsesStoredTaskNames(t *testing.T) {
srv := newServer()
started := time.Date(2026, 3, 24, 10, 0, 0, 0, time.UTC)
srv.tasks[taskKey("$3", "@12", "%7")] = &taskRecord{
SessionID: "$3",
SessionName: "workbench",
WindowID: "@12",
WindowName: "agent-tracker",
Pane: "%7",
Summary: "Polish notifications",
StartedAt: started,
Status: statusInProgress,
Acknowledged: true,
}
env := srv.buildStateEnvelope()
if env == nil {
t.Fatal("expected state envelope")
}
if len(env.Tasks) != 1 {
t.Fatalf("expected 1 task, got %d", len(env.Tasks))
}
task := env.Tasks[0]
if task.Session != "workbench" {
t.Fatalf("expected stored session name, got %q", task.Session)
}
if task.Window != "agent-tracker" {
t.Fatalf("expected stored window name, got %q", task.Window)
}
}