mirror of
https://github.com/theniceboy/.config.git
synced 2026-05-11 17:36:05 +08:00
agent tracker update
This commit is contained in:
parent
5064629d61
commit
0bfcb8d7c3
31 changed files with 1741 additions and 3320 deletions
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -27,37 +27,35 @@ type registry struct {
|
|||
}
|
||||
|
||||
type agentRecord struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
RepoRoot string `json:"repo_root"`
|
||||
WorkspaceRoot string `json:"workspace_root"`
|
||||
RepoCopyPath string `json:"repo_copy_path"`
|
||||
Branch string `json:"branch"`
|
||||
SourceBranch string `json:"source_branch,omitempty"`
|
||||
KeepWorktree bool `json:"keep_worktree,omitempty"`
|
||||
Runtime string `json:"runtime,omitempty"`
|
||||
Device string `json:"device,omitempty"`
|
||||
FeatureConfig string `json:"feature_config,omitempty"`
|
||||
RunLogPath string `json:"run_log_path,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
BrowserEnabled bool `json:"browser_enabled,omitempty"`
|
||||
TmuxSessionName string `json:"tmux_session_name,omitempty"`
|
||||
TmuxSessionID string `json:"tmux_session_id,omitempty"`
|
||||
TmuxWindowID string `json:"tmux_window_id,omitempty"`
|
||||
Panes agentPanes `json:"panes"`
|
||||
Dashboard dashboardDoc `json:"dashboard"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastFocusedAt *time.Time `json:"last_focused_at,omitempty"`
|
||||
LaunchWindowID string `json:"-"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
RepoRoot string `json:"repo_root"`
|
||||
WorkspaceRoot string `json:"workspace_root"`
|
||||
RepoCopyPath string `json:"repo_copy_path"`
|
||||
Branch string `json:"branch"`
|
||||
SourceBranch string `json:"source_branch,omitempty"`
|
||||
KeepWorktree bool `json:"keep_worktree,omitempty"`
|
||||
Runtime string `json:"runtime,omitempty"`
|
||||
Device string `json:"device,omitempty"`
|
||||
FeatureConfig string `json:"feature_config,omitempty"`
|
||||
RunLogPath string `json:"run_log_path,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
BrowserEnabled bool `json:"browser_enabled,omitempty"`
|
||||
TmuxSessionName string `json:"tmux_session_name,omitempty"`
|
||||
TmuxSessionID string `json:"tmux_session_id,omitempty"`
|
||||
TmuxWindowID string `json:"tmux_window_id,omitempty"`
|
||||
Panes agentPanes `json:"panes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastFocusedAt *time.Time `json:"last_focused_at,omitempty"`
|
||||
LaunchWindowID string `json:"-"`
|
||||
}
|
||||
|
||||
type agentPanes struct {
|
||||
AI string `json:"ai,omitempty"`
|
||||
Git string `json:"git,omitempty"`
|
||||
Run string `json:"run,omitempty"`
|
||||
Dashboard string `json:"dashboard,omitempty"`
|
||||
AI string `json:"ai,omitempty"`
|
||||
Git string `json:"git,omitempty"`
|
||||
Run string `json:"run,omitempty"`
|
||||
}
|
||||
|
||||
type agentStartOptions struct {
|
||||
|
|
@ -65,20 +63,21 @@ type agentStartOptions struct {
|
|||
KeepWorktree bool
|
||||
}
|
||||
|
||||
type dashboardDoc struct {
|
||||
Todos []todoItem `json:"todos"`
|
||||
Notes string `json:"notes"`
|
||||
CurrentTask string `json:"current_task"`
|
||||
}
|
||||
|
||||
type todoItem struct {
|
||||
Title string `json:"title"`
|
||||
Done bool `json:"done"`
|
||||
}
|
||||
|
||||
type appConfig struct {
|
||||
Keys keyConfig `json:"keys"`
|
||||
Devices []string `json:"devices,omitempty"`
|
||||
Keys keyConfig `json:"keys"`
|
||||
Devices []string `json:"devices,omitempty"`
|
||||
StatusRight *statusRightConfig `json:"status_right,omitempty"`
|
||||
}
|
||||
|
||||
type statusRightConfig struct {
|
||||
CPU *bool `json:"cpu,omitempty"`
|
||||
Network *bool `json:"network,omitempty"`
|
||||
Memory *bool `json:"memory,omitempty"`
|
||||
MemoryTotals *bool `json:"memory_totals,omitempty"`
|
||||
Agent *bool `json:"agent,omitempty"`
|
||||
Notes *bool `json:"notes,omitempty"`
|
||||
FlashMoe *bool `json:"flash_moe,omitempty"`
|
||||
Host *bool `json:"host,omitempty"`
|
||||
}
|
||||
|
||||
type keyConfig struct {
|
||||
|
|
@ -97,7 +96,6 @@ type keyConfig struct {
|
|||
Help string `json:"help"`
|
||||
FocusAI string `json:"focus_ai"`
|
||||
FocusGit string `json:"focus_git"`
|
||||
FocusDash string `json:"focus_dashboard"`
|
||||
FocusRun string `json:"focus_run"`
|
||||
}
|
||||
|
||||
|
|
@ -130,7 +128,7 @@ func main() {
|
|||
|
||||
func run(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: agent <start|resume|list|destroy|init|config|setup|dashboard|tmux|tracker|browser|feature>")
|
||||
return fmt.Errorf("usage: agent <start|resume|list|destroy|init|config|setup|tmux|tracker|browser|feature>")
|
||||
}
|
||||
switch args[0] {
|
||||
case "start":
|
||||
|
|
@ -147,8 +145,6 @@ func run(args []string) error {
|
|||
return runConfig(args[1:])
|
||||
case "setup":
|
||||
return runSetup(args[1:])
|
||||
case "dashboard":
|
||||
return runDashboard(args[1:])
|
||||
case "palette":
|
||||
return runPalette(args[1:])
|
||||
case "tmux":
|
||||
|
|
@ -1006,7 +1002,7 @@ func destroyRequiresExplicitConfirm(record *agentRecord) (bool, error) {
|
|||
|
||||
func runTmuxCommand(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: agent tmux <on-focus|focus|palette>")
|
||||
return fmt.Errorf("usage: agent tmux <on-focus|focus|palette|right-status>")
|
||||
}
|
||||
switch args[0] {
|
||||
case "on-focus":
|
||||
|
|
@ -1015,6 +1011,8 @@ func runTmuxCommand(args []string) error {
|
|||
return runTmuxFocus(args[1:])
|
||||
case "palette":
|
||||
return runTmuxPalette(args[1:])
|
||||
case "right-status":
|
||||
return runTmuxRightStatus(args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown tmux subcommand: %s", args[0])
|
||||
}
|
||||
|
|
@ -1185,7 +1183,7 @@ func runTmuxFocus(args []string) error {
|
|||
return err
|
||||
}
|
||||
if fs.NArg() == 0 {
|
||||
return fmt.Errorf("usage: agent tmux focus <ai|git|dashboard|run>")
|
||||
return fmt.Errorf("usage: agent tmux focus <ai|git|run>")
|
||||
}
|
||||
role := strings.ToLower(fs.Arg(0))
|
||||
ctx, err := detectCurrentAgentFromTmux(windowID)
|
||||
|
|
@ -1206,8 +1204,6 @@ func runTmuxFocus(args []string) error {
|
|||
target = record.Panes.AI
|
||||
case "git":
|
||||
target = record.Panes.Git
|
||||
case "dashboard":
|
||||
return openDashboardPopup(record.ID)
|
||||
case "run":
|
||||
target = record.Panes.Run
|
||||
default:
|
||||
|
|
@ -1219,19 +1215,6 @@ func runTmuxFocus(args []string) error {
|
|||
return runTmux("select-pane", "-t", target)
|
||||
}
|
||||
|
||||
func openDashboardPopup(agentID string) error {
|
||||
agentID = strings.TrimSpace(agentID)
|
||||
if agentID == "" {
|
||||
return fmt.Errorf("agent id is required")
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := fmt.Sprintf("%s dashboard --agent-id %s", shellQuote(exe), shellQuote(agentID))
|
||||
return runTmux("display-popup", "-E", "-w", "78%", "-h", "80%", "-T", "dashboard", cmd)
|
||||
}
|
||||
|
||||
func runTmuxPalette(args []string) error {
|
||||
fs := flag.NewFlagSet("agent tmux palette", flag.ContinueOnError)
|
||||
var windowID string
|
||||
|
|
@ -2660,7 +2643,6 @@ func loadAppConfig() appConfig {
|
|||
Help: "?",
|
||||
FocusAI: "M-a",
|
||||
FocusGit: "M-g",
|
||||
FocusDash: "M-s",
|
||||
FocusRun: "M-r",
|
||||
}}
|
||||
data, err := os.ReadFile(configPath())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
259
agent-tracker/cmd/agent/status_right_panel.go
Normal file
259
agent-tracker/cmd/agent/status_right_panel.go
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type statusRightPanelEntry struct {
|
||||
Module string
|
||||
Title string
|
||||
Subtitle string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
type statusRightPanelModel struct {
|
||||
entries []statusRightPanelEntry
|
||||
selected int
|
||||
width int
|
||||
height int
|
||||
status string
|
||||
statusUntil time.Time
|
||||
showAltHints bool
|
||||
requestBack bool
|
||||
}
|
||||
|
||||
func newStatusRightPanelModel() *statusRightPanelModel {
|
||||
model := &statusRightPanelModel{}
|
||||
model.reload()
|
||||
return model
|
||||
}
|
||||
|
||||
func (m *statusRightPanelModel) reload() {
|
||||
entries := make([]statusRightPanelEntry, 0, len(statusRightModules()))
|
||||
for _, module := range statusRightModules() {
|
||||
entries = append(entries, statusRightPanelEntry{
|
||||
Module: module,
|
||||
Title: statusRightModuleLabel(module),
|
||||
Subtitle: capitalizeStatusRightDescription(statusRightModuleDescription(module)),
|
||||
Enabled: statusRightModuleEnabled(module),
|
||||
})
|
||||
}
|
||||
m.entries = entries
|
||||
m.selected = clampInt(m.selected, 0, maxInt(0, len(m.entries)-1))
|
||||
}
|
||||
|
||||
func capitalizeStatusRightDescription(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(value[:1]) + value[1:]
|
||||
}
|
||||
|
||||
func (m *statusRightPanelModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *statusRightPanelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
return m, nil
|
||||
case tea.KeyMsg:
|
||||
if isAltFooterToggleKey(msg) {
|
||||
m.showAltHints = !m.showAltHints
|
||||
return m, nil
|
||||
}
|
||||
m.showAltHints = false
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.requestBack = true
|
||||
case "ctrl+u", "alt+u", "up", "u":
|
||||
m.selected = clampInt(m.selected-1, 0, maxInt(0, len(m.entries)-1))
|
||||
case "ctrl+e", "alt+e", "down", "e":
|
||||
m.selected = clampInt(m.selected+1, 0, maxInt(0, len(m.entries)-1))
|
||||
case "enter", " ":
|
||||
m.toggleSelected()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *statusRightPanelModel) toggleSelected() {
|
||||
entry, ok := m.currentEntry()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := togglePaletteStatusRightModule(entry.Module); err != nil {
|
||||
m.setStatus(err.Error(), 1500*time.Millisecond)
|
||||
return
|
||||
}
|
||||
m.reload()
|
||||
updated, ok := m.currentEntry()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
verb := "disabled"
|
||||
if updated.Enabled {
|
||||
verb = "enabled"
|
||||
}
|
||||
m.setStatus(fmt.Sprintf("%s %s", updated.Title, verb), 1500*time.Millisecond)
|
||||
}
|
||||
|
||||
func (m *statusRightPanelModel) currentEntry() (statusRightPanelEntry, bool) {
|
||||
if len(m.entries) == 0 || m.selected < 0 || m.selected >= len(m.entries) {
|
||||
return statusRightPanelEntry{}, false
|
||||
}
|
||||
return m.entries[m.selected], true
|
||||
}
|
||||
|
||||
func (m *statusRightPanelModel) View() string {
|
||||
return m.render(newPaletteStyles(), m.width, m.height)
|
||||
}
|
||||
|
||||
func (m *statusRightPanelModel) render(styles paletteStyles, width, height int) string {
|
||||
if width <= 0 {
|
||||
width = 96
|
||||
}
|
||||
if height <= 0 {
|
||||
height = 28
|
||||
}
|
||||
header := lipgloss.JoinVertical(lipgloss.Left,
|
||||
styles.title.Render("Bottom-right Status"),
|
||||
styles.meta.Render("Interactive control center for tmux status-right modules"),
|
||||
styles.meta.Render("Current layout: "+truncate(m.layoutSummary(), maxInt(28, width-2))),
|
||||
)
|
||||
|
||||
lines := []string{styles.meta.Render(fmt.Sprintf("%d modules", len(m.entries))), ""}
|
||||
for idx, entry := range m.entries {
|
||||
rowStyle := styles.item.Width(maxInt(24, width-2))
|
||||
titleStyle := styles.itemTitle
|
||||
metaStyle := styles.itemSubtitle
|
||||
detailStyle := styles.meta
|
||||
fillStyle := lipgloss.NewStyle()
|
||||
badgeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("235")).Background(lipgloss.Color("241")).Padding(0, 1).Bold(true)
|
||||
badgeLabel := "OFF"
|
||||
if entry.Enabled {
|
||||
badgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("235")).Background(lipgloss.Color("150")).Padding(0, 1).Bold(true)
|
||||
badgeLabel = "ON"
|
||||
}
|
||||
if idx == m.selected {
|
||||
selectedBG := lipgloss.Color("238")
|
||||
rowStyle = styles.selectedItem.Width(maxInt(24, width-2))
|
||||
titleStyle = titleStyle.Background(selectedBG).Foreground(lipgloss.Color("230"))
|
||||
metaStyle = styles.selectedSubtle.Background(selectedBG)
|
||||
detailStyle = styles.selectedSubtle.Background(selectedBG)
|
||||
fillStyle = fillStyle.Background(selectedBG)
|
||||
}
|
||||
badge := badgeStyle.Render(badgeLabel)
|
||||
innerWidth := maxInt(22, width-2)
|
||||
titleText := truncate(entry.Title, maxInt(8, innerWidth-lipgloss.Width(badge)-1))
|
||||
gapWidth := maxInt(1, innerWidth-lipgloss.Width(titleText)-lipgloss.Width(badge))
|
||||
titleRow := lipgloss.JoinHorizontal(lipgloss.Left,
|
||||
titleStyle.Render(titleText),
|
||||
fillStyle.Render(strings.Repeat(" ", gapWidth)),
|
||||
badge,
|
||||
)
|
||||
detail := entry.Subtitle + ". " + statusRightVisibilityText(entry.Enabled)
|
||||
detailText := truncate(detail, innerWidth)
|
||||
detailGap := maxInt(0, innerWidth-lipgloss.Width(detailText))
|
||||
detailRow := lipgloss.JoinHorizontal(lipgloss.Left,
|
||||
metaStyle.Render(detailText),
|
||||
fillStyle.Render(strings.Repeat(" ", detailGap)),
|
||||
)
|
||||
orderText := truncate(statusRightOrderHint(entry.Module, entry.Enabled), innerWidth)
|
||||
orderGap := maxInt(0, innerWidth-lipgloss.Width(orderText))
|
||||
orderRow := lipgloss.JoinHorizontal(lipgloss.Left,
|
||||
detailStyle.Render(orderText),
|
||||
fillStyle.Render(strings.Repeat(" ", orderGap)),
|
||||
)
|
||||
lines = append(lines, rowStyle.Render(lipgloss.JoinVertical(lipgloss.Left, titleRow, detailRow, orderRow)))
|
||||
}
|
||||
bodyHeight := maxInt(8, height-7)
|
||||
body := lipgloss.NewStyle().Height(bodyHeight).Render(strings.Join(lines, "\n"))
|
||||
footer := m.renderFooter(styles, width)
|
||||
view := lipgloss.JoinVertical(lipgloss.Left, header, "", body, "", footer)
|
||||
return lipgloss.NewStyle().Width(width).Height(height).Padding(0, 1).Render(view)
|
||||
}
|
||||
|
||||
func statusRightVisibilityText(enabled bool) string {
|
||||
if enabled {
|
||||
return "Visible in the tmux bottom-right status"
|
||||
}
|
||||
return "Hidden from the tmux bottom-right status"
|
||||
}
|
||||
|
||||
func statusRightOrderHint(module string, enabled bool) string {
|
||||
prefix := "Order slot: " + statusRightModuleLabel(module)
|
||||
if enabled {
|
||||
return prefix + " follows the fixed module order"
|
||||
}
|
||||
return prefix + " stays reserved until re-enabled"
|
||||
}
|
||||
|
||||
func (m *statusRightPanelModel) layoutSummary() string {
|
||||
labels := make([]string, 0, len(m.entries))
|
||||
for _, entry := range m.entries {
|
||||
if entry.Enabled {
|
||||
labels = append(labels, entry.Title)
|
||||
}
|
||||
}
|
||||
if len(labels) == 0 {
|
||||
return "nothing enabled"
|
||||
}
|
||||
return strings.Join(labels, " -> ")
|
||||
}
|
||||
|
||||
func (m *statusRightPanelModel) renderFooter(styles paletteStyles, width int) string {
|
||||
status := strings.TrimSpace(m.currentStatus())
|
||||
renderSegments := func(pairs [][2]string) string {
|
||||
return renderShortcutPairs(func(v string) string { return styles.shortcutKey.Render(v) }, func(v string) string { return styles.shortcutText.Render(v) }, " ", pairs)
|
||||
}
|
||||
footer := ""
|
||||
if m.showAltHints {
|
||||
footer = pickRenderedShortcutFooter(width, renderSegments,
|
||||
[][2]string{{"Space", "toggle"}, {"Alt-S", "close"}, {footerHintToggleKey, "hide"}},
|
||||
[][2]string{{"Space", "toggle"}, {"Alt-S", "close"}},
|
||||
)
|
||||
} else {
|
||||
footer = pickRenderedShortcutFooter(width, renderSegments,
|
||||
[][2]string{{"u/e", "move"}, {"Enter", "toggle"}, {"Esc", "back"}, {footerHintToggleKey, "more"}},
|
||||
[][2]string{{"u/e", "move"}, {"Enter", "toggle"}, {footerHintToggleKey, "more"}},
|
||||
[][2]string{{"Esc", "back"}, {footerHintToggleKey, "more"}},
|
||||
)
|
||||
}
|
||||
if status != "" {
|
||||
statusText := styles.statusBad.Render(truncate(status, maxInt(12, minInt(24, width/3))))
|
||||
if lipgloss.Width(footer)+2+lipgloss.Width(statusText) <= width {
|
||||
gap := width - lipgloss.Width(footer) - lipgloss.Width(statusText)
|
||||
if gap < 2 {
|
||||
gap = 2
|
||||
}
|
||||
return footer + strings.Repeat(" ", gap) + statusText
|
||||
}
|
||||
return statusText
|
||||
}
|
||||
return lipgloss.NewStyle().Width(width).Render(footer)
|
||||
}
|
||||
|
||||
func (m *statusRightPanelModel) setStatus(text string, duration time.Duration) {
|
||||
m.status = text
|
||||
m.statusUntil = time.Now().Add(duration)
|
||||
}
|
||||
|
||||
func (m *statusRightPanelModel) currentStatus() string {
|
||||
if m.status == "" {
|
||||
return ""
|
||||
}
|
||||
if !m.statusUntil.IsZero() && time.Now().After(m.statusUntil) {
|
||||
m.status = ""
|
||||
return ""
|
||||
}
|
||||
return m.status
|
||||
}
|
||||
743
agent-tracker/cmd/agent/tmux_status.go
Normal file
743
agent-tracker/cmd/agent/tmux_status.go
Normal file
|
|
@ -0,0 +1,743 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
statusRightModuleCPU = "cpu"
|
||||
statusRightModuleNetwork = "network"
|
||||
statusRightModuleMemory = "memory"
|
||||
statusRightModuleMemoryTotals = "memory_totals"
|
||||
statusRightModuleAgent = "agent"
|
||||
statusRightModuleNotes = "notes"
|
||||
statusRightModuleFlashMoe = "flash_moe"
|
||||
statusRightModuleHost = "host"
|
||||
)
|
||||
|
||||
const (
|
||||
statusIconCPU = ""
|
||||
statusIconNetwork = ""
|
||||
statusIconMemory = ""
|
||||
statusIconWindow = ""
|
||||
statusIconSession = ""
|
||||
statusIconTotal = ""
|
||||
statusIconAgent = ""
|
||||
statusIconNotes = ""
|
||||
statusIconFlashMoe = ""
|
||||
)
|
||||
|
||||
func statusRightModules() []string {
|
||||
return []string{
|
||||
statusRightModuleCPU,
|
||||
statusRightModuleNetwork,
|
||||
statusRightModuleMemory,
|
||||
statusRightModuleMemoryTotals,
|
||||
statusRightModuleAgent,
|
||||
statusRightModuleNotes,
|
||||
statusRightModuleFlashMoe,
|
||||
statusRightModuleHost,
|
||||
}
|
||||
}
|
||||
|
||||
var cpuUsagePattern = regexp.MustCompile(`CPU usage:\s*([0-9.]+)% user,\s*([0-9.]+)% sys,`)
|
||||
|
||||
var statusCommandOutput = func(name string, args ...string) ([]byte, error) {
|
||||
cmd := exec.Command(name, args...)
|
||||
return cmd.Output()
|
||||
}
|
||||
|
||||
var statusCommandStart = func(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
if cmd.Process != nil {
|
||||
_ = cmd.Process.Release()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var statusNow = time.Now
|
||||
var statusHostname = os.Hostname
|
||||
var statusDetectCurrentAgentFromTmux = detectCurrentAgentFromTmux
|
||||
var statusLoadRegistry = loadRegistry
|
||||
var statusMemoryCachePath = func() string { return "/tmp/tmux-mem-usage.json" }
|
||||
var statusMemoryCacheRefreshScript = func() string {
|
||||
return filepath.Join(os.Getenv("HOME"), ".config", "tmux", "tmux-status", "mem_usage_cache.py")
|
||||
}
|
||||
var statusTodoFilePath = func() string {
|
||||
return filepath.Join(os.Getenv("HOME"), ".cache", "agent", "todos.json")
|
||||
}
|
||||
var statusFlashMoeMetricsPath = func() string {
|
||||
return filepath.Join(os.Getenv("HOME"), ".flash-moe", "tmux_metrics")
|
||||
}
|
||||
var statusNetworkRateCachePath = func() string {
|
||||
return "/tmp/agent-tmux-network-rate.json"
|
||||
}
|
||||
|
||||
type tmuxRightStatusArgs struct {
|
||||
Width int
|
||||
StatusBG string
|
||||
SessionName string
|
||||
WindowIndex string
|
||||
PaneID string
|
||||
WindowID string
|
||||
}
|
||||
|
||||
type statusSegment struct {
|
||||
FG string
|
||||
BG string
|
||||
Text string
|
||||
Bold bool
|
||||
}
|
||||
|
||||
type statusMemoryCache struct {
|
||||
Pane map[string]string `json:"pane"`
|
||||
Window map[string]string `json:"window"`
|
||||
Session map[string]string `json:"session"`
|
||||
Total string `json:"total"`
|
||||
}
|
||||
|
||||
type statusTodoCache struct {
|
||||
Windows map[string][]statusTodoItem `json:"windows"`
|
||||
}
|
||||
|
||||
type statusTodoItem struct {
|
||||
Done bool `json:"done"`
|
||||
}
|
||||
|
||||
type statusNetworkCounter struct {
|
||||
InBytes uint64
|
||||
OutBytes uint64
|
||||
}
|
||||
|
||||
type statusNetworkRateCache struct {
|
||||
Interface string `json:"interface"`
|
||||
InBytes uint64 `json:"in_bytes"`
|
||||
OutBytes uint64 `json:"out_bytes"`
|
||||
SampledAt int64 `json:"sampled_at_unix_ms"`
|
||||
}
|
||||
|
||||
func runTmuxRightStatus(args []string) error {
|
||||
fs := flag.NewFlagSet("agent tmux right-status", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
values := fs.Args()
|
||||
parsed := tmuxRightStatusArgs{}
|
||||
if len(values) > 0 {
|
||||
parsed.Width, _ = strconv.Atoi(strings.TrimSpace(values[0]))
|
||||
}
|
||||
if len(values) > 1 {
|
||||
parsed.StatusBG = strings.TrimSpace(values[1])
|
||||
}
|
||||
if len(values) > 2 {
|
||||
parsed.SessionName = strings.TrimSpace(values[2])
|
||||
}
|
||||
if len(values) > 3 {
|
||||
parsed.WindowIndex = strings.TrimSpace(values[3])
|
||||
}
|
||||
if len(values) > 4 {
|
||||
parsed.PaneID = strings.TrimSpace(values[4])
|
||||
}
|
||||
if len(values) > 5 {
|
||||
parsed.WindowID = strings.TrimSpace(values[5])
|
||||
}
|
||||
if parsed.StatusBG == "" || parsed.StatusBG == "default" {
|
||||
parsed.StatusBG = "black"
|
||||
}
|
||||
if parsed.Width > 0 && parsed.Width < statusRightMinimumWidth() {
|
||||
return nil
|
||||
}
|
||||
fmt.Print(renderTmuxRightStatus(parsed))
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderTmuxRightStatus(args tmuxRightStatusArgs) string {
|
||||
segments := make([]statusSegment, 0, 6)
|
||||
if statusRightModuleEnabled(statusRightModuleCPU) {
|
||||
if label := loadCPUStatusLabel(); label != "" {
|
||||
segments = append(segments, statusSegment{FG: "#1d1f21", BG: "#d08770", Text: label, Bold: true})
|
||||
}
|
||||
}
|
||||
if statusRightModuleEnabled(statusRightModuleNetwork) {
|
||||
if label := loadNetworkStatusLabel(); label != "" {
|
||||
segments = append(segments, statusSegment{FG: "#1d1f21", BG: "#8fbcbb", Text: label, Bold: true})
|
||||
}
|
||||
}
|
||||
if statusRightModuleEnabled(statusRightModuleMemory) {
|
||||
if label := loadMemoryStatusLabel(args.PaneID); label != "" {
|
||||
segments = append(segments, statusSegment{FG: "#eceff4", BG: "#5e81ac", Text: label})
|
||||
}
|
||||
}
|
||||
if statusRightModuleEnabled(statusRightModuleMemoryTotals) {
|
||||
segments = append(segments, loadMemoryTotalsStatusSegments(args)...)
|
||||
}
|
||||
if statusRightModuleEnabled(statusRightModuleAgent) {
|
||||
if label := loadAgentStatusLabel(args.WindowID); label != "" {
|
||||
segments = append(segments, statusSegment{FG: "#1d1f21", BG: "#81a1c1", Text: label, Bold: true})
|
||||
}
|
||||
}
|
||||
if statusRightModuleEnabled(statusRightModuleNotes) {
|
||||
if label := loadNotesStatusLabel(args.WindowID); label != "" {
|
||||
segments = append(segments, statusSegment{FG: "#1d1f21", BG: "#cc6666", Text: label, Bold: true})
|
||||
}
|
||||
}
|
||||
if statusRightModuleEnabled(statusRightModuleFlashMoe) {
|
||||
if segment, ok := loadFlashMoeStatusSegment(); ok {
|
||||
segments = append(segments, segment)
|
||||
}
|
||||
}
|
||||
if statusRightModuleEnabled(statusRightModuleHost) {
|
||||
if label := loadHostStatusLabel(); label != "" {
|
||||
segments = append(segments, statusSegment{FG: "#1d1f21", BG: statusThemeColor(), Text: label})
|
||||
}
|
||||
}
|
||||
return formatRightStatusSegments(args.StatusBG, segments)
|
||||
}
|
||||
|
||||
func formatRightStatusSegments(statusBG string, segments []statusSegment) string {
|
||||
if len(segments) == 0 {
|
||||
return ""
|
||||
}
|
||||
separator := ""
|
||||
rightCap := "█"
|
||||
prevBG := statusBG
|
||||
var builder strings.Builder
|
||||
for _, segment := range segments {
|
||||
builder.WriteString(fmt.Sprintf("#[fg=%s,bg=%s]%s#[fg=%s,bg=%s", segment.BG, prevBG, separator, segment.FG, segment.BG))
|
||||
if segment.Bold {
|
||||
builder.WriteString(",bold")
|
||||
}
|
||||
builder.WriteString("]")
|
||||
builder.WriteString(segment.Text)
|
||||
prevBG = segment.BG
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf(" #[fg=%s,bg=%s]%s", prevBG, statusBG, rightCap))
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func statusRightMinimumWidth() int {
|
||||
value := strings.TrimSpace(os.Getenv("TMUX_RIGHT_MIN_WIDTH"))
|
||||
if value == "" {
|
||||
return 90
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil || parsed < 1 {
|
||||
return 90
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func statusThemeColor() string {
|
||||
value := strings.TrimSpace(os.Getenv("TMUX_THEME_COLOR"))
|
||||
if value == "" {
|
||||
return "#b294bb"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func loadCPUStatusLabel() string {
|
||||
output, err := statusCommandOutput("top", "-l", "1", "-n", "0")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
total, ok := parseCPUUsageTotal(string(output))
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" %s %s ", statusIconCPU, formatUsagePercent(total))
|
||||
}
|
||||
|
||||
func parseCPUUsageTotal(output string) (float64, bool) {
|
||||
matches := cpuUsagePattern.FindStringSubmatch(output)
|
||||
if len(matches) != 3 {
|
||||
return 0, false
|
||||
}
|
||||
user, err := strconv.ParseFloat(matches[1], 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
system, err := strconv.ParseFloat(matches[2], 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
total := math.Max(0, user+system)
|
||||
if total > 100 {
|
||||
total = 100
|
||||
}
|
||||
return total, true
|
||||
}
|
||||
|
||||
func formatUsagePercent(value float64) string {
|
||||
if value < 10 && math.Abs(value-math.Round(value)) > 0.05 {
|
||||
return fmt.Sprintf("%.1f%%", value)
|
||||
}
|
||||
return fmt.Sprintf("%.0f%%", value)
|
||||
}
|
||||
|
||||
func loadNetworkStatusLabel() string {
|
||||
preferred := loadPrimaryNetworkInterface()
|
||||
output, err := statusCommandOutput("netstat", "-ibn")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
counters := parseNetworkCounters(string(output))
|
||||
iface, current, ok := pickNetworkCounter(counters, preferred)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
now := statusNow().UnixMilli()
|
||||
previous, _ := loadNetworkRateCache()
|
||||
rate := " ↓-- ↑-- "
|
||||
if previous.Interface == iface && previous.SampledAt > 0 && now > previous.SampledAt && current.InBytes >= previous.InBytes && current.OutBytes >= previous.OutBytes {
|
||||
seconds := float64(now-previous.SampledAt) / 1000
|
||||
if seconds >= 0.25 {
|
||||
down := float64(current.InBytes-previous.InBytes) / seconds
|
||||
up := float64(current.OutBytes-previous.OutBytes) / seconds
|
||||
rate = fmt.Sprintf(" %s ↓%s ↑%s ", statusIconNetwork, formatByteRate(down), formatByteRate(up))
|
||||
}
|
||||
}
|
||||
_ = saveNetworkRateCache(statusNetworkRateCache{
|
||||
Interface: iface,
|
||||
InBytes: current.InBytes,
|
||||
OutBytes: current.OutBytes,
|
||||
SampledAt: now,
|
||||
})
|
||||
if rate == " ↓-- ↑-- " {
|
||||
return fmt.Sprintf(" %s ↓-- ↑-- ", statusIconNetwork)
|
||||
}
|
||||
return rate
|
||||
}
|
||||
|
||||
func loadPrimaryNetworkInterface() string {
|
||||
output, err := statusCommandOutput("route", "-n", "get", "default")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(output)))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if !strings.HasPrefix(line, "interface:") {
|
||||
continue
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimPrefix(line, "interface:"))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseNetworkCounters(output string) map[string]statusNetworkCounter {
|
||||
counters := make(map[string]statusNetworkCounter)
|
||||
scanner := bufio.NewScanner(strings.NewReader(output))
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 10 {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(strings.TrimSpace(fields[0]), "*")
|
||||
if name == "" || strings.EqualFold(name, "Name") {
|
||||
continue
|
||||
}
|
||||
if _, exists := counters[name]; exists {
|
||||
continue
|
||||
}
|
||||
inBytes, err := strconv.ParseUint(fields[6], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
outBytes, err := strconv.ParseUint(fields[9], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
counters[name] = statusNetworkCounter{InBytes: inBytes, OutBytes: outBytes}
|
||||
}
|
||||
return counters
|
||||
}
|
||||
|
||||
func pickNetworkCounter(counters map[string]statusNetworkCounter, preferred string) (string, statusNetworkCounter, bool) {
|
||||
preferred = strings.TrimSpace(preferred)
|
||||
if preferred != "" {
|
||||
if counter, ok := counters[preferred]; ok {
|
||||
return preferred, counter, true
|
||||
}
|
||||
}
|
||||
var bestName string
|
||||
var bestCounter statusNetworkCounter
|
||||
var bestTotal uint64
|
||||
for name, counter := range counters {
|
||||
if ignoreNetworkInterface(name) {
|
||||
continue
|
||||
}
|
||||
total := counter.InBytes + counter.OutBytes
|
||||
if total <= bestTotal {
|
||||
continue
|
||||
}
|
||||
bestName = name
|
||||
bestCounter = counter
|
||||
bestTotal = total
|
||||
}
|
||||
if bestName == "" {
|
||||
return "", statusNetworkCounter{}, false
|
||||
}
|
||||
return bestName, bestCounter, true
|
||||
}
|
||||
|
||||
func ignoreNetworkInterface(name string) bool {
|
||||
for _, prefix := range []string{"lo", "awdl", "llw", "gif", "stf", "anpi", "ap", "bridge", "pktap"} {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func formatByteRate(bytesPerSecond float64) string {
|
||||
if bytesPerSecond < 0 {
|
||||
bytesPerSecond = 0
|
||||
}
|
||||
units := []string{"B", "K", "M", "G"}
|
||||
value := bytesPerSecond
|
||||
unit := units[0]
|
||||
for _, candidate := range units[1:] {
|
||||
if value < 1024 {
|
||||
break
|
||||
}
|
||||
value /= 1024
|
||||
unit = candidate
|
||||
}
|
||||
if value < 10 && unit != "B" {
|
||||
return fmt.Sprintf("%.1f%s", value, unit)
|
||||
}
|
||||
return fmt.Sprintf("%.0f%s", value, unit)
|
||||
}
|
||||
|
||||
func loadNetworkRateCache() (statusNetworkRateCache, error) {
|
||||
data, err := os.ReadFile(statusNetworkRateCachePath())
|
||||
if err != nil {
|
||||
return statusNetworkRateCache{}, err
|
||||
}
|
||||
var cache statusNetworkRateCache
|
||||
if err := json.Unmarshal(data, &cache); err != nil {
|
||||
return statusNetworkRateCache{}, err
|
||||
}
|
||||
return cache, nil
|
||||
}
|
||||
|
||||
func saveNetworkRateCache(cache statusNetworkRateCache) error {
|
||||
data, err := json.Marshal(cache)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := statusNetworkRateCachePath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := path + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpPath, path)
|
||||
}
|
||||
|
||||
func loadMemoryStatusLabel(paneID string) string {
|
||||
paneID = strings.TrimSpace(paneID)
|
||||
if paneID == "" {
|
||||
return ""
|
||||
}
|
||||
cache, ok := loadMemoryStatusCache()
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
value := strings.TrimSpace(cache.Pane[paneID])
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" %s %s ", statusIconMemory, value)
|
||||
}
|
||||
|
||||
func loadMemoryTotalsStatusSegments(args tmuxRightStatusArgs) []statusSegment {
|
||||
cache, ok := loadMemoryStatusCache()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
segments := make([]statusSegment, 0, 3)
|
||||
windowKey := strings.TrimSpace(args.SessionName)
|
||||
if windowKey != "" && strings.TrimSpace(args.WindowIndex) != "" {
|
||||
windowKey = windowKey + ":" + strings.TrimSpace(args.WindowIndex)
|
||||
}
|
||||
if value := strings.TrimSpace(cache.Window[windowKey]); value != "" {
|
||||
segments = append(segments, statusSegment{FG: "#eceff4", BG: "#4c566a", Text: fmt.Sprintf(" %s %s ", statusIconWindow, value)})
|
||||
}
|
||||
if value := strings.TrimSpace(cache.Session[strings.TrimSpace(args.SessionName)]); value != "" {
|
||||
segments = append(segments, statusSegment{FG: "#eceff4", BG: "#434c5e", Text: fmt.Sprintf(" %s %s ", statusIconSession, value)})
|
||||
}
|
||||
if value := strings.TrimSpace(cache.Total); value != "" {
|
||||
segments = append(segments, statusSegment{FG: "#eceff4", BG: "#3b4252", Text: fmt.Sprintf(" %s %s ", statusIconTotal, value)})
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
func loadMemoryStatusCache() (statusMemoryCache, bool) {
|
||||
refreshMemoryUsageCache()
|
||||
data, err := os.ReadFile(statusMemoryCachePath())
|
||||
if err != nil {
|
||||
return statusMemoryCache{}, false
|
||||
}
|
||||
var cache statusMemoryCache
|
||||
if err := json.Unmarshal(data, &cache); err != nil {
|
||||
return statusMemoryCache{}, false
|
||||
}
|
||||
return cache, true
|
||||
}
|
||||
|
||||
func loadAgentStatusLabel(windowID string) string {
|
||||
windowID = strings.TrimSpace(windowID)
|
||||
if windowID == "" {
|
||||
return ""
|
||||
}
|
||||
ref, err := statusDetectCurrentAgentFromTmux(windowID)
|
||||
if err != nil || strings.TrimSpace(ref.ID) == "" {
|
||||
return ""
|
||||
}
|
||||
reg, err := statusLoadRegistry()
|
||||
if err != nil || reg == nil {
|
||||
return ""
|
||||
}
|
||||
record := reg.Agents[strings.TrimSpace(ref.ID)]
|
||||
if record == nil {
|
||||
return ""
|
||||
}
|
||||
device := strings.TrimSpace(record.Device)
|
||||
if device == "" {
|
||||
device = "no device"
|
||||
}
|
||||
return fmt.Sprintf(" %s %s ", statusIconAgent, device)
|
||||
}
|
||||
|
||||
func refreshMemoryUsageCache() {
|
||||
script := statusMemoryCacheRefreshScript()
|
||||
if strings.TrimSpace(script) == "" || !fileExists(script) {
|
||||
return
|
||||
}
|
||||
_ = statusCommandStart("python3", script)
|
||||
}
|
||||
|
||||
func loadNotesStatusLabel(windowID string) string {
|
||||
windowID = strings.TrimSpace(windowID)
|
||||
if windowID == "" {
|
||||
return ""
|
||||
}
|
||||
data, err := os.ReadFile(statusTodoFilePath())
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var cache statusTodoCache
|
||||
if err := json.Unmarshal(data, &cache); err != nil {
|
||||
return ""
|
||||
}
|
||||
items := cache.Windows[windowID]
|
||||
count := 0
|
||||
for _, item := range items {
|
||||
if !item.Done {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" %s %d ", statusIconNotes, count)
|
||||
}
|
||||
|
||||
func loadFlashMoeStatusSegment() (statusSegment, bool) {
|
||||
metricsPath := statusFlashMoeMetricsPath()
|
||||
if strings.TrimSpace(metricsPath) == "" {
|
||||
return statusSegment{}, false
|
||||
}
|
||||
data, err := os.ReadFile(metricsPath)
|
||||
if err != nil {
|
||||
return statusSegment{}, false
|
||||
}
|
||||
values := map[string]string{}
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
values[strings.TrimSpace(key)] = strings.TrimSpace(value)
|
||||
}
|
||||
phase := values["phase"]
|
||||
if updated := strings.TrimSpace(values["updated_ms"]); updated != "" {
|
||||
if updatedMS, err := strconv.ParseInt(updated, 10, 64); err == nil {
|
||||
ageMS := statusNow().UnixMilli() - updatedMS
|
||||
if ageMS > 10000 && (phase == "gen" || phase == "prefill") {
|
||||
phase = "idle"
|
||||
}
|
||||
}
|
||||
}
|
||||
switch phase {
|
||||
case "prefill":
|
||||
promptTokens := strings.TrimSpace(values["prompt_tokens"])
|
||||
label := fmt.Sprintf(" %s prefill ", statusIconFlashMoe)
|
||||
if promptTokens != "" && promptTokens != "0" {
|
||||
label = fmt.Sprintf(" %s prefill:%s ", statusIconFlashMoe, promptTokens)
|
||||
}
|
||||
return statusSegment{FG: "#1d1f21", BG: "#ebcb8b", Text: label, Bold: true}, true
|
||||
case "gen":
|
||||
tokS := strings.TrimSpace(values["tok_s"])
|
||||
label := fmt.Sprintf(" %s gen ", statusIconFlashMoe)
|
||||
if tokS != "" && tokS != "0.00" {
|
||||
label = fmt.Sprintf(" %s %s tok/s ", statusIconFlashMoe, tokS)
|
||||
}
|
||||
return statusSegment{FG: "#1d1f21", BG: "#a3be8c", Text: label, Bold: true}, true
|
||||
default:
|
||||
return statusSegment{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func loadHostStatusLabel() string {
|
||||
host, err := statusHostname()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
if short, _, ok := strings.Cut(host, "."); ok {
|
||||
host = short
|
||||
}
|
||||
return fmt.Sprintf(" %s", host)
|
||||
}
|
||||
|
||||
func defaultStatusRightModuleEnabled(module string) bool {
|
||||
switch module {
|
||||
case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleAgent, statusRightModuleNotes, statusRightModuleFlashMoe, statusRightModuleHost:
|
||||
return true
|
||||
case statusRightModuleMemoryTotals:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isValidStatusRightModule(module string) bool {
|
||||
switch module {
|
||||
case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleMemoryTotals, statusRightModuleAgent, statusRightModuleNotes, statusRightModuleFlashMoe, statusRightModuleHost:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func statusRightModuleEnabled(module string) bool {
|
||||
if !isValidStatusRightModule(module) {
|
||||
return false
|
||||
}
|
||||
cfg := loadAppConfig()
|
||||
if cfg.StatusRight == nil {
|
||||
return defaultStatusRightModuleEnabled(module)
|
||||
}
|
||||
return cfg.StatusRight.moduleEnabled(module)
|
||||
}
|
||||
|
||||
func (cfg statusRightConfig) moduleEnabled(module string) bool {
|
||||
switch module {
|
||||
case statusRightModuleCPU:
|
||||
return derefBool(cfg.CPU, defaultStatusRightModuleEnabled(module))
|
||||
case statusRightModuleNetwork:
|
||||
return derefBool(cfg.Network, defaultStatusRightModuleEnabled(module))
|
||||
case statusRightModuleMemory:
|
||||
return derefBool(cfg.Memory, defaultStatusRightModuleEnabled(module))
|
||||
case statusRightModuleMemoryTotals:
|
||||
return derefBool(cfg.MemoryTotals, defaultStatusRightModuleEnabled(module))
|
||||
case statusRightModuleAgent:
|
||||
return derefBool(cfg.Agent, defaultStatusRightModuleEnabled(module))
|
||||
case statusRightModuleNotes:
|
||||
return derefBool(cfg.Notes, defaultStatusRightModuleEnabled(module))
|
||||
case statusRightModuleFlashMoe:
|
||||
return derefBool(cfg.FlashMoe, defaultStatusRightModuleEnabled(module))
|
||||
case statusRightModuleHost:
|
||||
return derefBool(cfg.Host, defaultStatusRightModuleEnabled(module))
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func toggleStatusRightModule(module string) error {
|
||||
if !isValidStatusRightModule(module) {
|
||||
return fmt.Errorf("unknown status-right module: %s", module)
|
||||
}
|
||||
enabled := !statusRightModuleEnabled(module)
|
||||
return updateAppConfig(func(cfg *appConfig) {
|
||||
if cfg.StatusRight == nil {
|
||||
cfg.StatusRight = &statusRightConfig{}
|
||||
}
|
||||
cfg.StatusRight.setModuleEnabled(module, enabled)
|
||||
if cfg.StatusRight.isDefault() {
|
||||
cfg.StatusRight = nil
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (cfg *statusRightConfig) setModuleEnabled(module string, enabled bool) {
|
||||
value := boolPtr(enabled)
|
||||
if enabled == defaultStatusRightModuleEnabled(module) {
|
||||
value = nil
|
||||
}
|
||||
switch module {
|
||||
case statusRightModuleCPU:
|
||||
cfg.CPU = value
|
||||
case statusRightModuleNetwork:
|
||||
cfg.Network = value
|
||||
case statusRightModuleMemory:
|
||||
cfg.Memory = value
|
||||
case statusRightModuleMemoryTotals:
|
||||
cfg.MemoryTotals = value
|
||||
case statusRightModuleAgent:
|
||||
cfg.Agent = value
|
||||
case statusRightModuleNotes:
|
||||
cfg.Notes = value
|
||||
case statusRightModuleFlashMoe:
|
||||
cfg.FlashMoe = value
|
||||
case statusRightModuleHost:
|
||||
cfg.Host = value
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *statusRightConfig) isDefault() bool {
|
||||
if cfg == nil {
|
||||
return true
|
||||
}
|
||||
return cfg.CPU == nil && cfg.Network == nil && cfg.Memory == nil && cfg.MemoryTotals == nil && cfg.Agent == nil && cfg.Notes == nil && cfg.FlashMoe == nil && cfg.Host == nil
|
||||
}
|
||||
|
||||
func derefBool(value *bool, fallback bool) bool {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func boolPtr(value bool) *bool {
|
||||
ptr := new(bool)
|
||||
*ptr = value
|
||||
return ptr
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
71
agent-tracker/cmd/agent/ui_helpers.go
Normal file
71
agent-tracker/cmd/agent/ui_helpers.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func wrapText(text string, width int) []string {
|
||||
if width <= 0 {
|
||||
return []string{""}
|
||||
}
|
||||
if text == "" {
|
||||
return []string{""}
|
||||
}
|
||||
words := strings.Fields(text)
|
||||
if len(words) == 0 {
|
||||
return []string{""}
|
||||
}
|
||||
var lines []string
|
||||
current := words[0]
|
||||
for _, word := range words[1:] {
|
||||
candidate := current + " " + word
|
||||
if len([]rune(candidate)) <= width {
|
||||
current = candidate
|
||||
continue
|
||||
}
|
||||
lines = append(lines, current)
|
||||
current = word
|
||||
}
|
||||
lines = append(lines, current)
|
||||
return lines
|
||||
}
|
||||
|
||||
func truncate(text string, width int) string {
|
||||
if width <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(text)
|
||||
if len(runes) <= width {
|
||||
return text
|
||||
}
|
||||
if width == 1 {
|
||||
return string(runes[:1])
|
||||
}
|
||||
return string(runes[:width-1]) + "…"
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func previousWordBoundary(runes []rune, cursor int) int {
|
||||
i := cursor
|
||||
for i > 0 && unicode.IsSpace(runes[i-1]) {
|
||||
i--
|
||||
}
|
||||
for i > 0 && !unicode.IsSpace(runes[i-1]) {
|
||||
i--
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue