From 0bfcb8d7c330fa8d26aea91aa4653ce9228bdf45 Mon Sep 17 00:00:00 2001 From: David Chen Date: Thu, 2 Apr 2026 13:45:55 -0700 Subject: [PATCH] agent tracker update --- .tmux.conf | 5 +- agent-tracker/agent-config.json | 9 +- .../cmd/agent/activity_monitor_test.go | 146 ---- agent-tracker/cmd/agent/agent_start_test.go | 190 ----- .../cmd/agent/chrome_permissions_test.go | 51 -- agent-tracker/cmd/agent/create_window_test.go | 32 - agent-tracker/cmd/agent/dashboard.go | 769 ------------------ agent-tracker/cmd/agent/devices_test.go | 32 - .../cmd/agent/flutter_helpers_test.go | 256 ------ agent-tracker/cmd/agent/main.go | 108 +-- agent-tracker/cmd/agent/palette.go | 17 +- agent-tracker/cmd/agent/palette_bubbletea.go | 206 +++-- .../cmd/agent/palette_dashboard_test.go | 195 ----- agent-tracker/cmd/agent/palette_debug_test.go | 19 - .../cmd/agent/palette_path_fallback_test.go | 35 - .../cmd/agent/palette_shortcuts_test.go | 460 ----------- agent-tracker/cmd/agent/scroll_test.go | 123 --- agent-tracker/cmd/agent/status_right_panel.go | 259 ++++++ agent-tracker/cmd/agent/tmux_status.go | 743 +++++++++++++++++ agent-tracker/cmd/agent/todo_panel_test.go | 450 ---------- agent-tracker/cmd/agent/todos.go | 29 - agent-tracker/cmd/agent/tracker_cli.go | 2 +- agent-tracker/cmd/agent/tracker_panel_test.go | 102 --- agent-tracker/cmd/agent/ui_helpers.go | 71 ++ agent-tracker/cmd/tracker-server/main.go | 83 +- agent-tracker/cmd/tracker-server/main_test.go | 53 -- opencode/AGENTS.md | 17 + opencode/plugins/require_work_summary.ts | 93 +++ opencode/tools/set_work_summary.ts | 195 +++++ tmux/scripts/pane_starship_title.sh | 112 ++- tmux/tmux-status/right.sh | 199 +---- 31 files changed, 1741 insertions(+), 3320 deletions(-) delete mode 100644 agent-tracker/cmd/agent/activity_monitor_test.go delete mode 100644 agent-tracker/cmd/agent/agent_start_test.go delete mode 100644 agent-tracker/cmd/agent/chrome_permissions_test.go delete mode 100644 agent-tracker/cmd/agent/create_window_test.go delete mode 100644 agent-tracker/cmd/agent/dashboard.go delete mode 100644 agent-tracker/cmd/agent/devices_test.go delete mode 100644 agent-tracker/cmd/agent/flutter_helpers_test.go delete mode 100644 agent-tracker/cmd/agent/palette_dashboard_test.go delete mode 100644 agent-tracker/cmd/agent/palette_debug_test.go delete mode 100644 agent-tracker/cmd/agent/palette_path_fallback_test.go delete mode 100644 agent-tracker/cmd/agent/palette_shortcuts_test.go delete mode 100644 agent-tracker/cmd/agent/scroll_test.go create mode 100644 agent-tracker/cmd/agent/status_right_panel.go create mode 100644 agent-tracker/cmd/agent/tmux_status.go delete mode 100644 agent-tracker/cmd/agent/todo_panel_test.go delete mode 100644 agent-tracker/cmd/agent/tracker_panel_test.go create mode 100644 agent-tracker/cmd/agent/ui_helpers.go delete mode 100644 agent-tracker/cmd/tracker-server/main_test.go create mode 100644 opencode/plugins/require_work_summary.ts create mode 100644 opencode/tools/set_work_summary.ts diff --git a/.tmux.conf b/.tmux.conf index b664def..95ee021 100644 --- a/.tmux.conf +++ b/.tmux.conf @@ -194,7 +194,6 @@ bind -n M-i select-pane -R bind -n M-a run-shell 'pane=$(bash ~/.config/tmux/scripts/focus_pane_by_position.sh left "#{window_id}" 2>/dev/null) && [ -n "$pane" ] && tmux select-pane -t "$pane" || true' bind -n M-g run-shell 'pane=$(bash ~/.config/tmux/scripts/focus_pane_by_position.sh top-right "#{window_id}" 2>/dev/null) && [ -n "$pane" ] && tmux select-pane -t "$pane" || true' bind -n M-s run-shell "~/.config/tmux/scripts/open_agent_palette.sh '#{client_tty}' '#{window_id}' '#{@agent_id}' '#{pane_current_path}' '#{session_name}' '#{window_name}'" -bind -n M-d run-shell "test -x ~/.config/agent-tracker/bin/agent && ~/.config/agent-tracker/bin/agent tmux focus --window '#{window_id}' dashboard || true" bind -n M-r run-shell 'pane=$(bash ~/.config/tmux/scripts/focus_pane_by_position.sh bottom-right "#{window_id}" 2>/dev/null) && [ -n "$pane" ] && tmux select-pane -t "$pane" || true' bind > swap-pane -D bind < swap-pane -U @@ -271,13 +270,14 @@ set -g pane-scrollbars off set -g pane-scrollbars-position right set -g pane-active-border-style fg=#b294bb set -g pane-border-style fg=colour244 -setw -g pane-border-format '#{?pane_active, #[fg=#{@theme_color}]#[bg=#{@theme_color}]#[fg=#1d1f21]#[bold] #{?window_zoomed_flag,⛶ ,} #(~/.config/tmux/scripts/pane_starship_title.sh #{pane_pid} #{pane_width} "#{pane_current_path}" "#{pane_current_command}") #[bg=default]#[fg=#{@theme_color}]#[default], #[fg=colour244]#[bg=colour244]#[fg=#1d1f21] #{?window_zoomed_flag,⛶ ,} #(~/.config/tmux/scripts/pane_starship_title.sh #{pane_pid} #{pane_width} "#{pane_current_path}" "#{pane_current_command}") #[bg=default]#[fg=colour244]#[default] }' +setw -g pane-border-format '#{?pane_active, #[fg=#{@theme_color}]#[bg=#{@theme_color}]#[fg=#1d1f21]#[bold] #{?window_zoomed_flag,⛶ ,} #(~/.config/tmux/scripts/pane_starship_title.sh #{pane_pid} #{pane_id} "#{pane_tty}" "#{pane_title}" #{pane_width} "#{pane_current_path}" "#{pane_current_command}") #[bg=default]#[fg=#{@theme_color}]#[default], #[fg=colour244]#[bg=colour244]#[fg=#1d1f21] #{?window_zoomed_flag,⛶ ,} #(~/.config/tmux/scripts/pane_starship_title.sh #{pane_pid} #{pane_id} "#{pane_tty}" "#{pane_title}" #{pane_width} "#{pane_current_path}" "#{pane_current_command}") #[bg=default]#[fg=colour244]#[default] }' #set -g pane-active-border-style fg=brightblue #set -g pane-border-style fg=magenta # windows # windows +set -g status on set -g status-justify 'left' set -g status-interval 1 set -g status-left-length 90 @@ -298,6 +298,7 @@ setw -g window-status-bell-style bg=black set-option -g status-left "#(~/.config/tmux/tmux-status/left.sh #{q:session_id} #{q:session_name} #{q:client_width} #{q:status-bg}) " set-option -g status-right "#(~/.config/tmux/tmux-status/right.sh \"#{client_width}\" \"#{status-bg}\" \"#{session_name}\" \"#{window_index}\" \"#{pane_id}\" \"#{window_id}\")" +set-option -g "status-format[0]" '#[align=left range=left #{E:status-left-style}]#[push-default]#{T;=/#{status-left-length}:status-left}#[pop-default]#[norange default]#[list=on align=#{status-justify}]#[list=left-marker]<#[list=right-marker]>#[list=on]#{W:#[range=window|#{window_index} #{E:window-status-style}#{?#{&&:#{window_last_flag},#{!=:#{E:window-status-last-style},default}}, #{E:window-status-last-style},}#{?#{&&:#{window_bell_flag},#{!=:#{E:window-status-bell-style},default}}, #{E:window-status-bell-style},#{?#{&&:#{||:#{window_activity_flag},#{window_silence_flag}},#{!=:#{E:window-status-activity-style},default}}, #{E:window-status-activity-style},}}]#[push-default]#{T:window-status-format}#[pop-default]#[norange default]#{?loop_last_flag,,#{E:window-status-separator}},#[range=window|#{window_index} list=focus #{?#{!=:#{E:window-status-current-style},default},#{E:window-status-current-style},#{E:window-status-style}}#{?#{&&:#{window_last_flag},#{!=:#{E:window-status-last-style},default}}, #{E:window-status-last-style},}#{?#{&&:#{window_bell_flag},#{!=:#{E:window-status-bell-style},default}}, #{E:window-status-bell-style},#{?#{&&:#{||:#{window_activity_flag},#{window_silence_flag}},#{!=:#{E:window-status-activity-style},default}}, #{E:window-status-activity-style},}}]#[push-default]#{T:window-status-current-format}#[pop-default]#[norange list=on default]#{?loop_last_flag,,#{E:window-status-separator}}}#[nolist align=right range=right #{E:status-right-style}]#[push-default]#{T;=/#{status-right-length}:status-right}#[pop-default]#[norange default]' # session index switching diff --git a/agent-tracker/agent-config.json b/agent-tracker/agent-config.json index 3521101..95d4ccb 100644 --- a/agent-tracker/agent-config.json +++ b/agent-tracker/agent-config.json @@ -23,5 +23,12 @@ "ipm", "opm", "macos" - ] + ], + "status_right": { + "cpu": false, + "network": false, + "memory": false, + "memory_totals": false, + "flash_moe": false + } } diff --git a/agent-tracker/cmd/agent/activity_monitor_test.go b/agent-tracker/cmd/agent/activity_monitor_test.go deleted file mode 100644 index cc34689..0000000 --- a/agent-tracker/cmd/agent/activity_monitor_test.go +++ /dev/null @@ -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()) - } -} diff --git a/agent-tracker/cmd/agent/agent_start_test.go b/agent-tracker/cmd/agent/agent_start_test.go deleted file mode 100644 index 15270c0..0000000 --- a/agent-tracker/cmd/agent/agent_start_test.go +++ /dev/null @@ -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 := ®istry{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 := ®istry{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) - } -} diff --git a/agent-tracker/cmd/agent/chrome_permissions_test.go b/agent-tracker/cmd/agent/chrome_permissions_test.go deleted file mode 100644 index 74e5ee8..0000000 --- a/agent-tracker/cmd/agent/chrome_permissions_test.go +++ /dev/null @@ -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") - } -} diff --git a/agent-tracker/cmd/agent/create_window_test.go b/agent-tracker/cmd/agent/create_window_test.go deleted file mode 100644 index c14a281..0000000 --- a/agent-tracker/cmd/agent/create_window_test.go +++ /dev/null @@ -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) - } -} diff --git a/agent-tracker/cmd/agent/dashboard.go b/agent-tracker/cmd/agent/dashboard.go deleted file mode 100644 index 2997071..0000000 --- a/agent-tracker/cmd/agent/dashboard.go +++ /dev/null @@ -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, ¬eCursor) { - 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 -} diff --git a/agent-tracker/cmd/agent/devices_test.go b/agent-tracker/cmd/agent/devices_test.go deleted file mode 100644 index 52fdb3e..0000000 --- a/agent-tracker/cmd/agent/devices_test.go +++ /dev/null @@ -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") - } -} diff --git a/agent-tracker/cmd/agent/flutter_helpers_test.go b/agent-tracker/cmd/agent/flutter_helpers_test.go deleted file mode 100644 index becddf1..0000000 --- a/agent-tracker/cmd/agent/flutter_helpers_test.go +++ /dev/null @@ -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) - } -} diff --git a/agent-tracker/cmd/agent/main.go b/agent-tracker/cmd/agent/main.go index 0be4a45..9b7491d 100644 --- a/agent-tracker/cmd/agent/main.go +++ b/agent-tracker/cmd/agent/main.go @@ -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 ") + return fmt.Errorf("usage: agent ") } 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 ") + return fmt.Errorf("usage: agent tmux ") } 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 ") + return fmt.Errorf("usage: agent tmux focus ") } 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()) diff --git a/agent-tracker/cmd/agent/palette.go b/agent-tracker/cmd/agent/palette.go index 4da54c6..ef218de 100644 --- a/agent-tracker/cmd/agent/palette.go +++ b/agent-tracker/cmd/agent/palette.go @@ -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 diff --git a/agent-tracker/cmd/agent/palette_bubbletea.go b/agent-tracker/cmd/agent/palette_bubbletea.go index 6017f02..ec964ed 100644 --- a/agent-tracker/cmd/agent/palette_bubbletea.go +++ b/agent-tracker/cmd/agent/palette_bubbletea.go @@ -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 != "" { diff --git a/agent-tracker/cmd/agent/palette_dashboard_test.go b/agent-tracker/cmd/agent/palette_dashboard_test.go deleted file mode 100644 index 2d79caa..0000000 --- a/agent-tracker/cmd/agent/palette_dashboard_test.go +++ /dev/null @@ -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) - } -} diff --git a/agent-tracker/cmd/agent/palette_debug_test.go b/agent-tracker/cmd/agent/palette_debug_test.go deleted file mode 100644 index e579d6e..0000000 --- a/agent-tracker/cmd/agent/palette_debug_test.go +++ /dev/null @@ -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) -} diff --git a/agent-tracker/cmd/agent/palette_path_fallback_test.go b/agent-tracker/cmd/agent/palette_path_fallback_test.go deleted file mode 100644 index 6941d9d..0000000 --- a/agent-tracker/cmd/agent/palette_path_fallback_test.go +++ /dev/null @@ -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) - } -} diff --git a/agent-tracker/cmd/agent/palette_shortcuts_test.go b/agent-tracker/cmd/agent/palette_shortcuts_test.go deleted file mode 100644 index 4afb27c..0000000 --- a/agent-tracker/cmd/agent/palette_shortcuts_test.go +++ /dev/null @@ -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 := ®istry{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)) - } -} diff --git a/agent-tracker/cmd/agent/scroll_test.go b/agent-tracker/cmd/agent/scroll_test.go deleted file mode 100644 index d0a8ef7..0000000 --- a/agent-tracker/cmd/agent/scroll_test.go +++ /dev/null @@ -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) - } -} diff --git a/agent-tracker/cmd/agent/status_right_panel.go b/agent-tracker/cmd/agent/status_right_panel.go new file mode 100644 index 0000000..48b2333 --- /dev/null +++ b/agent-tracker/cmd/agent/status_right_panel.go @@ -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 +} diff --git a/agent-tracker/cmd/agent/tmux_status.go b/agent-tracker/cmd/agent/tmux_status.go new file mode 100644 index 0000000..615d156 --- /dev/null +++ b/agent-tracker/cmd/agent/tmux_status.go @@ -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 +} diff --git a/agent-tracker/cmd/agent/todo_panel_test.go b/agent-tracker/cmd/agent/todo_panel_test.go deleted file mode 100644 index cdfc22b..0000000 --- a/agent-tracker/cmd/agent/todo_panel_test.go +++ /dev/null @@ -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) - } -} diff --git a/agent-tracker/cmd/agent/todos.go b/agent-tracker/cmd/agent/todos.go index c570eb1..1b16526 100644 --- a/agent-tracker/cmd/agent/todos.go +++ b/agent-tracker/cmd/agent/todos.go @@ -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 { diff --git a/agent-tracker/cmd/agent/tracker_cli.go b/agent-tracker/cmd/agent/tracker_cli.go index 46e1852..af818d1 100644 --- a/agent-tracker/cmd/agent/tracker_cli.go +++ b/agent-tracker/cmd/agent/tracker_cli.go @@ -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 diff --git a/agent-tracker/cmd/agent/tracker_panel_test.go b/agent-tracker/cmd/agent/tracker_panel_test.go deleted file mode 100644 index 4471756..0000000 --- a/agent-tracker/cmd/agent/tracker_panel_test.go +++ /dev/null @@ -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 -} diff --git a/agent-tracker/cmd/agent/ui_helpers.go b/agent-tracker/cmd/agent/ui_helpers.go new file mode 100644 index 0000000..f5eed43 --- /dev/null +++ b/agent-tracker/cmd/agent/ui_helpers.go @@ -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 +} diff --git a/agent-tracker/cmd/tracker-server/main.go b/agent-tracker/cmd/tracker-server/main.go index 6faa999..dd21846 100644 --- a/agent-tracker/cmd/tracker-server/main.go +++ b/agent-tracker/cmd/tracker-server/main.go @@ -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 } diff --git a/agent-tracker/cmd/tracker-server/main_test.go b/agent-tracker/cmd/tracker-server/main_test.go deleted file mode 100644 index 2f1144d..0000000 --- a/agent-tracker/cmd/tracker-server/main_test.go +++ /dev/null @@ -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) - } -} diff --git a/opencode/AGENTS.md b/opencode/AGENTS.md index cfcc971..46ded57 100644 --- a/opencode/AGENTS.md +++ b/opencode/AGENTS.md @@ -4,6 +4,23 @@ - When migrating or refactoring code, do not leave legacy code. Remove all deprecated or unused code. - Put change reasoning in your plan/final message — not in code. +## Work Summary + +- `set_work_summary` is mandatory protocol, not a suggestion. +- Call `set_work_summary` at least once at the start of every busy turn before any substantive tool call, code change, research step, or substantive user-facing response. +- Prefer calling it with both fields: `set_work_summary({ theme: "...", now: "..." })`. +- `theme` answers: what is this pane about overall? Keep it stable across many turns. +- `now` answers: what are you about to do next? Update it whenever the next concrete step changes. +- Keep both labels concrete and under 48 characters. +- Since the summary line has dedicated space, prefer richer phrases that help a forgetful human re-orient instantly. +- Good `theme` examples: `Tmux status summary workflow`, `Agent tracker integration`, `Flutter auth onboarding`. +- Good `now` examples: `Patch summary enforcement`, `Read restore path handling`, `Wait for user reply`. +- Bad labels: `Working`, `Coding`, `Debugging`, `Researching`, `Task`, `Fixing stuff`. +- Bad `now` phrasing: `Debugging summary enforcement`, `Reading restore path handling`, `Waiting on user reply`. +- If you are blocked or waiting, keep the `theme` and change `now`, for example `Wait for user reply` or `Wait for tests`. +- If the labels are missing or stale, stop and update them first. +- Repeating the same `theme` across turns is acceptable when the overall mission has not changed. + --------- ## Adaptive Burst Workflow diff --git a/opencode/plugins/require_work_summary.ts b/opencode/plugins/require_work_summary.ts new file mode 100644 index 0000000..d9f53f9 --- /dev/null +++ b/opencode/plugins/require_work_summary.ts @@ -0,0 +1,93 @@ +import type { Plugin } from "@opencode-ai/plugin" + +type TurnState = { + summaryCalled: boolean +} + +const states = new Map() + +function sessionState(sessionID: string) { + let state = states.get(sessionID) + if (!state) { + state = { summaryCalled: false } + states.set(sessionID, state) + } + return state +} + +function eventSessionID(event: any) { + return String( + event?.properties?.sessionID || + event?.properties?.session?.id || + event?.properties?.info?.sessionID || + "", + ).trim() +} + +export const RequireWorkSummaryPlugin: Plugin = async () => { + return { + "chat.message": async (input: any) => { + const sessionID = String(input?.sessionID || "").trim() + if (!sessionID) { + return + } + sessionState(sessionID).summaryCalled = false + }, + + "command.execute.before": async (input: any) => { + const sessionID = String(input?.sessionID || "").trim() + if (!sessionID) { + return + } + sessionState(sessionID).summaryCalled = false + }, + + "tool.execute.before": async (input: any) => { + const sessionID = String(input?.sessionID || "").trim() + if (!sessionID) { + return + } + + const tool = String(input?.tool || "").trim() + if (!tool) { + return + } + + const state = sessionState(sessionID) + if (tool === "set_work_summary") { + return + } + + if (!state.summaryCalled) { + throw new Error( + "Call set_work_summary first with a specific theme and next-step now label before using other tools.", + ) + } + }, + + "tool.execute.after": async (input: any) => { + const sessionID = String(input?.sessionID || "").trim() + if (!sessionID) { + return + } + + const tool = String(input?.tool || "").trim() + if (tool === "set_work_summary") { + sessionState(sessionID).summaryCalled = true + } + }, + + event: async ({ event }) => { + if (event?.type === "session.deleted") { + const sessionID = eventSessionID(event) + if (sessionID) { + states.delete(sessionID) + } + return + } + + }, + } +} + +export default RequireWorkSummaryPlugin diff --git a/opencode/tools/set_work_summary.ts b/opencode/tools/set_work_summary.ts new file mode 100644 index 0000000..b21e171 --- /dev/null +++ b/opencode/tools/set_work_summary.ts @@ -0,0 +1,195 @@ +import { tool } from "@opencode-ai/plugin" + +const MAX_THEME_CHARS = 48 +const MAX_NOW_CHARS = 48 +const TMUX_THEME_OPTION = "@op_work_theme" +const TMUX_NOW_OPTION = "@op_work_now" +const TMUX_LEGACY_OPTION = "@op_work_summary" +const TRACKER_BIN = `${process.env.HOME || ""}/.config/agent-tracker/bin/agent` +const GENERIC_LABELS = new Set([ + "working", + "coding", + "debugging", + "researching", + "thinking", + "fixing", + "checking", + "investigating", + "task", + "stuff", + "project", + "feature", + "issue", + "bug", +]) + +function capitalizeFirstLetter(value: string): string { + return value.replace(/[A-Za-z]/, (letter) => letter.toUpperCase()) +} + +function normalizeLabel(value: string, maxChars: number): string { + const collapsed = value + .replace(/\s+/g, " ") + .replace(/[.!,;:]+$/g, "") + .trim() + if (collapsed.length <= maxChars) { + return capitalizeFirstLetter(collapsed) + } + + const words = collapsed.split(" ") + let clipped = "" + for (const word of words) { + const next = clipped ? `${clipped} ${word}` : word + if (next.length > maxChars) { + break + } + clipped = next + } + if (clipped) { + return capitalizeFirstLetter(clipped) + } + + return capitalizeFirstLetter(collapsed.slice(0, maxChars).trim()) +} + +function ensureSpecific(label: string, field: string): string { + if (!label) { + return "" + } + + if (GENERIC_LABELS.has(label.toLowerCase())) { + throw new Error( + `Use a more specific ${field}. Good examples: \"Tmux status summaries\", \"Patch work-context layout\", \"Wait for user reply\".`, + ) + } + + return label +} + +async function runTmux(tmuxBin: string, args: string[]) { + const proc = Bun.spawn([tmuxBin, ...args], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + if (exitCode !== 0) { + const message = stderr.trim() || stdout.trim() || `tmux exited ${exitCode}` + throw new Error(message) + } + return stdout.trim() +} + +async function readTmuxOption(tmuxBin: string, tmuxPane: string, option: string) { + return runTmux(tmuxBin, ["display-message", "-p", "-t", tmuxPane, `#{${option}}`]).catch(() => "") +} + +async function writeTmuxOption(tmuxBin: string, tmuxPane: string, option: string, value: string) { + if (!value) { + await runTmux(tmuxBin, ["set-option", "-p", "-u", "-t", tmuxPane, option]) + return + } + + await runTmux(tmuxBin, ["set-option", "-p", "-t", tmuxPane, option, value]) +} + +async function runCommand(args: string[]) { + const proc = Bun.spawn(args, { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }) + const [stderr, exitCode] = await Promise.all([ + new Response(proc.stderr).text(), + proc.exited, + ]) + if (exitCode !== 0) { + throw new Error(stderr.trim() || `${args[0]} exited ${exitCode}`) + } +} + +function trackerSummary(theme: string, now: string) { + if (theme && now) { + return `${theme} -> ${now}` + } + return theme || now +} + +export default tool({ + description: + "Set the tmux pane's stable theme and immediate current-step labels for the current OpenCode session.", + args: { + theme: tool.schema + .string() + .optional() + .describe( + "Stable grand objective. Answer: what is this pane about overall? Keep it specific and under 48 characters. Prefer richer phrases like 'Tmux status summaries' or 'Agent tracker integration'.", + ), + now: tool.schema + .string() + .optional() + .describe( + "Immediate next step. Answer: what are you about to do next? Keep it specific and under 48 characters. Use next-action phrasing like 'Read restore code', 'Patch status layout', or 'Wait for user reply'.", + ), + summary: tool.schema + .string() + .optional() + .describe("Legacy alias for theme. Prefer using theme plus now."), + }, + async execute(args) { + const tmuxPane = (process.env.TMUX_PANE || "").trim() + const tmuxBin = Bun.which("tmux") || "/opt/homebrew/bin/tmux" + const hasTheme = typeof args.theme === "string" + const hasNow = typeof args.now === "string" + const hasSummary = typeof args.summary === "string" + + if (!hasTheme && !hasNow && !hasSummary) { + throw new Error("Provide at least one of: theme, now, or summary.") + } + + let theme = hasTheme + ? ensureSpecific(normalizeLabel(args.theme || "", MAX_THEME_CHARS), "theme") + : "" + const now = hasNow + ? ensureSpecific(normalizeLabel(args.now || "", MAX_NOW_CHARS), "current-step label") + : "" + + if (!hasTheme && hasSummary) { + theme = ensureSpecific(normalizeLabel(args.summary || "", MAX_THEME_CHARS), "theme") + } + + if (!tmuxPane) { + return JSON.stringify({ theme, now }) + } + + if (!hasTheme && !hasSummary) { + theme = + (await readTmuxOption(tmuxBin, tmuxPane, TMUX_THEME_OPTION)) || + (await readTmuxOption(tmuxBin, tmuxPane, TMUX_LEGACY_OPTION)) + } + + const finalNow = hasNow ? now : await readTmuxOption(tmuxBin, tmuxPane, TMUX_NOW_OPTION) + const finalTheme = theme + + if (hasTheme || hasSummary) { + await writeTmuxOption(tmuxBin, tmuxPane, TMUX_THEME_OPTION, theme) + await writeTmuxOption(tmuxBin, tmuxPane, TMUX_LEGACY_OPTION, theme) + } + + if (hasNow) { + await writeTmuxOption(tmuxBin, tmuxPane, TMUX_NOW_OPTION, now) + } + + const trackerText = trackerSummary(finalTheme, finalNow) + if (trackerText && (await Bun.file(TRACKER_BIN).exists())) { + await runCommand([TRACKER_BIN, "tracker", "command", "-pane", tmuxPane, "-summary", trackerText, "update_task"]).catch(() => {}) + } + + await runTmux(tmuxBin, ["refresh-client", "-S"]) + return JSON.stringify({ theme: finalTheme, now: finalNow }) + }, +}) diff --git a/tmux/scripts/pane_starship_title.sh b/tmux/scripts/pane_starship_title.sh index 062fb37..3c1193d 100755 --- a/tmux/scripts/pane_starship_title.sh +++ b/tmux/scripts/pane_starship_title.sh @@ -1,11 +1,15 @@ #!/usr/bin/env bash set -euo pipefail -# Args: +# Args: pid="${1:-}" -width="${2:-80}" -pane_path="${3:-$PWD}" -pane_cmd="${4:-}" +pane_id="${2:-}" +pane_tty="${3:-}" +pane_title="${4:-}" +width="${5:-80}" +pane_path="${6:-$PWD}" +pane_cmd="${7:-}" +ps_line="" # Best-effort: inherit venv/conda from the pane's process env if [[ -n "$pid" ]]; then @@ -26,10 +30,58 @@ strip_wrappers() { } run_starship() { - local cfg + local prompt_width cfg + prompt_width="${1:-$width}" cfg="${STARSHIP_TMUX_CONFIG:-$HOME/.config/starship-tmux.toml}" STARSHIP_LOG=error STARSHIP_CONFIG="$cfg" \ - starship prompt --terminal-width "$width" | strip_wrappers | tr -d '\n' + starship prompt --terminal-width "$prompt_width" | strip_wrappers | tr -d '\n' +} + +trim_to_width() { + local text max + text="$1" + max="$2" + if (( ${#text} <= max )); then + printf '%s' "$text" + return + fi + if (( max <= 1 )); then + printf '' + return + fi + printf '%s…' "${text:0:$((max - 1))}" +} + +load_option() { + local option value + option="$1" + [[ -n "$pane_id" ]] || return 0 + value=$(tmux display-message -p -t "$pane_id" "#{${option}}" 2>/dev/null | tr -d '\r\n' || true) + value=$(printf '%s' "$value" | perl -0pe 's/\s+/ /g; s/^\s+|\s+$//g') + printf '%s' "$value" +} + +clear_work_labels() { + [[ -n "$pane_id" ]] || return 0 + tmux set-option -p -u -t "$pane_id" @op_work_theme 2>/dev/null || true + tmux set-option -p -u -t "$pane_id" @op_work_now 2>/dev/null || true + tmux set-option -p -u -t "$pane_id" @op_work_summary 2>/dev/null || true +} + +opencode_active() { + local tty_name tty_ps + [[ "$pane_title" == OC\ \|* ]] && return 0 + [[ "$pane_cmd" == "op" || "$pane_cmd" == "opencode" ]] && return 0 + tty_name="${pane_tty#/dev/}" + tty_ps="" + if [[ -n "$tty_name" ]]; then + tty_ps=$(ps -t "$tty_name" -o command= 2>/dev/null || true) + elif [[ -n "$ps_line" ]]; then + tty_ps="$ps_line" + fi + [[ "$pane_cmd" == "node" && "$tty_ps" == *"/bin/opencode"* ]] && return 0 + [[ "$pane_cmd" == "node" && "$tty_ps" == *"opencode-ai/bin/.opencode"* ]] && return 0 + return 1 } fallback() { @@ -40,8 +92,52 @@ fallback() { } if command -v starship >/dev/null 2>&1; then - (cd "$pane_path" && run_starship) || fallback + title=$(cd "$pane_path" && run_starship) || title=$(fallback) else - fallback + title=$(fallback) fi +theme=$(load_option "@op_work_theme") +if [[ -z "$theme" ]]; then + theme=$(load_option "@op_work_summary") +fi +now=$(load_option "@op_work_now") + +if ! opencode_active; then + if [[ -n "$theme" || -n "$now" ]]; then + clear_work_labels + fi + printf '%s' "$title" + exit 0 +fi + +summary_display="" +if [[ -n "$theme" ]]; then + summary_display="[$theme]" +fi +if [[ -n "$now" ]]; then + if [[ -n "$summary_display" ]]; then + summary_display="$summary_display ↳ $now" + else + summary_display="↳ $now" + fi +fi + +if [[ -z "$summary_display" ]]; then + printf '%s' "$title" + exit 0 +fi + +reserved_width=$((${#summary_display} + 3)) +prompt_width=$((width - reserved_width)) +if (( prompt_width < 16 )); then + summary_display=$(trim_to_width "$summary_display" "$width") + printf '%s' "$summary_display" + exit 0 +fi + +if command -v starship >/dev/null 2>&1; then + title=$(cd "$pane_path" && run_starship "$prompt_width") || title=$(fallback) +fi + +printf '%s · %s' "$summary_display" "$title" diff --git a/tmux/tmux-status/right.sh b/tmux/tmux-status/right.sh index 646dc95..8cd7063 100755 --- a/tmux/tmux-status/right.sh +++ b/tmux/tmux-status/right.sh @@ -1,200 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -min_width=${TMUX_RIGHT_MIN_WIDTH:-90} -width="${1:-}" -status_bg="${2:-}" -current_session="${3:-}" -current_window_index="${4:-}" -current_pane_id="${5:-}" -current_window_id="${6:-}" +agent_bin="$HOME/.config/agent-tracker/bin/agent" +[[ -x "$agent_bin" ]] || exit 0 -if [[ -z "${width:-}" || "$width" == "0" ]]; then - width=${COLUMNS:-} -fi -if [[ -n "${width:-}" && "$width" =~ ^[0-9]+$ ]]; then - if (( width < min_width )); then - exit 0 - fi -fi - -if [[ -z "$status_bg" || "$status_bg" == "default" ]]; then - status_bg=black -fi - -segment_fg="#eceff4" -host_bg="${TMUX_THEME_COLOR:-#b294bb}" -host_fg="#1d1f21" -separator="" -right_cap="█" -hostname=$(hostname -s 2>/dev/null || hostname 2>/dev/null || printf 'host') - -# --- Data gathering --- - -# Memory usage -mem_pane_bg="#5e81ac" -mem_pane_fg="#eceff4" -mem_win_bg="#4c566a" -mem_win_fg="#eceff4" -mem_sess_bg="#434c5e" -mem_sess_fg="#eceff4" -mem_total_bg="#3b4252" -mem_total_fg="#eceff4" -mem_pane_val="" -mem_win_val="" -mem_sess_val="" -mem_total_val="" -mem_script="$HOME/.config/tmux/tmux-status/mem_usage.sh" -memory_toggle="${TMUX_STATUS_MEMORY:-1}" -case "$memory_toggle" in - 0|false|FALSE|off|OFF|no|NO) memory_toggle="0" ;; - *) memory_toggle="1" ;; -esac -if [[ "$memory_toggle" == "1" ]] && [[ -x "$mem_script" ]]; then - if [[ -n "$current_session" && -n "$current_window_index" && -n "$current_pane_id" ]]; then - mem_output=$("$mem_script" "$current_session" "$current_window_index" "$current_pane_id" 2>/dev/null || true) - mem_pane_val=$(sed -n '1p' <<< "$mem_output") - mem_win_val=$(sed -n '2p' <<< "$mem_output") - mem_sess_val=$(sed -n '3p' <<< "$mem_output") - mem_total_val=$(sed -n '4p' <<< "$mem_output") - fi -fi - -# Rainbarf -rainbarf_bg="#2e3440" -rainbarf_segment="" -rainbarf_toggle="${TMUX_RAINBARF:-1}" -case "$rainbarf_toggle" in - 0|false|FALSE|off|OFF|no|NO) rainbarf_toggle="0" ;; - *) rainbarf_toggle="1" ;; -esac -if [[ "$rainbarf_toggle" == "1" ]] && command -v rainbarf >/dev/null 2>&1; then - rainbarf_output=$(rainbarf --no-battery --no-remaining --no-bolt --tmux --rgb 2>/dev/null || true) - rainbarf_output=${rainbarf_output//$'\n'/} - if [[ -n "$rainbarf_output" ]]; then - rainbarf_segment=$(printf '#[fg=%s,bg=%s]%s#[fg=%s,bg=%s]%s' \ - "$rainbarf_bg" "$status_bg" "$separator" \ - "$segment_fg" "$rainbarf_bg" "$rainbarf_output") - fi -fi - -# Notes -notes_output="" -notes_count_script="$HOME/.config/tmux/tmux-status/notes_count.sh" -if [[ -x "$notes_count_script" ]]; then - notes_output=$("$notes_count_script" "$current_window_id" 2>/dev/null || true) -fi - -# Flash-MoE -flashmoe_phase="" -flashmoe_tok_s="" -flashmoe_prompt_tokens="" -flashmoe_updated_ms="" -flashmoe_metrics="$HOME/.flash-moe/tmux_metrics" -if [[ -r "$flashmoe_metrics" ]]; then - while IFS='=' read -r key value; do - case "$key" in - phase) flashmoe_phase="$value" ;; - tok_s) flashmoe_tok_s="$value" ;; - prompt_tokens) flashmoe_prompt_tokens="$value" ;; - updated_ms) flashmoe_updated_ms="$value" ;; - esac - done < "$flashmoe_metrics" -fi -if [[ -n "$flashmoe_updated_ms" && "$flashmoe_updated_ms" =~ ^[0-9]+$ ]]; then - now_ms=$(( $(date +%s) * 1000 )) - age_ms=$(( now_ms - flashmoe_updated_ms )) - if (( age_ms > 10000 )) && [[ "$flashmoe_phase" == "gen" || "$flashmoe_phase" == "prefill" ]]; then - flashmoe_phase="idle" - fi -fi - -# --- Segment building (left to right: rainbarf | pane_mem | win_mem | notes | flashmoe | host) --- - -# Track the rightmost background for connector chaining -prev_bg="$status_bg" -[[ -n "$rainbarf_segment" ]] && prev_bg="$rainbarf_bg" - -mem_pane_segment="" -if [[ -n "$mem_pane_val" ]]; then - mem_pane_segment=$(printf '#[fg=%s,bg=%s]%s#[fg=%s,bg=%s] %s ' \ - "$mem_pane_bg" "$prev_bg" "$separator" \ - "$mem_pane_fg" "$mem_pane_bg" "$mem_pane_val") - prev_bg="$mem_pane_bg" -fi - -mem_win_segment="" -if [[ -n "$mem_win_val" ]]; then - mem_win_segment=$(printf '#[fg=%s,bg=%s]%s#[fg=%s,bg=%s] %s ' \ - "$mem_win_bg" "$prev_bg" "$separator" \ - "$mem_win_fg" "$mem_win_bg" "$mem_win_val") - prev_bg="$mem_win_bg" -fi - -mem_sess_segment="" -if [[ -n "$mem_sess_val" ]]; then - mem_sess_segment=$(printf '#[fg=%s,bg=%s]%s#[fg=%s,bg=%s] %s ' \ - "$mem_sess_bg" "$prev_bg" "$separator" \ - "$mem_sess_fg" "$mem_sess_bg" "$mem_sess_val") - prev_bg="$mem_sess_bg" -fi - -mem_total_segment="" -if [[ -n "$mem_total_val" ]]; then - mem_total_segment=$(printf '#[fg=%s,bg=%s]%s#[fg=%s,bg=%s] %s ' \ - "$mem_total_bg" "$prev_bg" "$separator" \ - "$mem_total_fg" "$mem_total_bg" "$mem_total_val") - prev_bg="$mem_total_bg" -fi - -notes_segment="" -if [[ -n "$notes_output" ]]; then - notes_bg="#cc6666" - notes_fg="#1d1f21" - notes_segment=$(printf '#[fg=%s,bg=%s]%s#[fg=%s,bg=%s,bold]%s#[default]' \ - "$notes_bg" "$prev_bg" "$separator" \ - "$notes_fg" "$notes_bg" "$notes_output") - prev_bg="$notes_bg" -fi - -flashmoe_segment="" -flashmoe_bg="#88c0d0" -flashmoe_fg="#1d1f21" -flashmoe_label="" -if [[ "$flashmoe_phase" == "prefill" ]]; then - flashmoe_bg="#ebcb8b" - if [[ "$flashmoe_prompt_tokens" =~ ^[0-9]+$ ]] && (( flashmoe_prompt_tokens > 0 )); then - flashmoe_label=" FM prefill:${flashmoe_prompt_tokens} " - else - flashmoe_label=" FM prefill " - fi -elif [[ "$flashmoe_phase" == "gen" ]]; then - flashmoe_bg="#a3be8c" - if [[ -n "$flashmoe_tok_s" && "$flashmoe_tok_s" != "0.00" ]]; then - flashmoe_label=" FM ${flashmoe_tok_s} tok/s " - else - flashmoe_label=" FM gen " - fi -fi -if [[ -n "$flashmoe_label" ]]; then - flashmoe_segment=$(printf '#[fg=%s,bg=%s]%s#[fg=%s,bg=%s,bold]%s' \ - "$flashmoe_bg" "$prev_bg" "$separator" \ - "$flashmoe_fg" "$flashmoe_bg" "$flashmoe_label") - prev_bg="$flashmoe_bg" -fi - -host_prefix=$(printf '#[fg=%s,bg=%s]%s#[fg=%s,bg=%s] ' \ - "$host_bg" "$prev_bg" "$separator" \ - "$host_fg" "$host_bg") - -printf '%s%s%s%s%s%s%s%s%s #[fg=%s,bg=%s]%s' \ - "$rainbarf_segment" \ - "$mem_pane_segment" \ - "$mem_win_segment" \ - "$mem_sess_segment" \ - "$mem_total_segment" \ - "$notes_segment" \ - "$flashmoe_segment" \ - "$host_prefix" \ - "$hostname" \ - "$host_bg" "$status_bg" "$right_cap" +exec "$agent_bin" tmux right-status "$@"