From e22ddb43d431d343d257ee9fb503e1f350efc067 Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Thu, 6 Aug 2026 15:23:06 +1000 Subject: [PATCH] =?UTF-8?q?TUI:=20Del=20key=20deletes=20closed/lost=20stat?= =?UTF-8?q?e=20files=20(local+SSH,=20live=20refused),=20sort-column=20?= =?UTF-8?q?=E2=96=BC=20marker,=20delete/shellquote/sort-column=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.go | 179 +++++++++++++++++++++++++++++++++++++++++++-------- main_test.go | 31 +++++++++ 2 files changed, 184 insertions(+), 26 deletions(-) diff --git a/main.go b/main.go index 3aaa7c9..d788b78 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "os" "os/exec" @@ -63,6 +64,39 @@ var sortNames = map[int]string{ sortFolder: "folder", } +// sortColumn maps a sort mode to the table column it primarily sorts by +// (-1 = no single column, e.g. activity). +func sortColumn(mode int) int { + switch mode { + case sortSmart: + return 3 // Status (live/running first) + case sortStarted: + return 4 // Started + case sortFolder: + return 2 // Folder + default: + return -1 // activity + } +} + +func buildColumns(sortMode int) []table.Column { + cols := []table.Column{ + {Title: "Type", Width: 5}, + {Title: "Session", Width: 16}, + {Title: "Folder", Width: 16}, + {Title: "Status", Width: 10}, + {Title: "Started", Width: 10}, + {Title: "Closed", Width: 10}, + {Title: "Tools", Width: 5}, + {Title: "Task", Width: 20}, + } + if idx := sortColumn(sortMode); idx >= 0 { + marker := lipgloss.NewStyle().Foreground(lipgloss.Color("#00d4ff")).Bold(true).Render("▼") + cols[idx].Title = marker + " " + cols[idx].Title + } + return cols +} + // Model type model struct { table table.Model @@ -76,6 +110,8 @@ type model struct { polling bool // a fetch is in flight (prevents concurrent polls) remoteCooldown map[string]time.Time // per-remote failure cooldown showHelp bool + notice string // transient status line (e.g. "deleted …") + colsSortMode int // sort mode the current column headers were built for lastPoll time.Time err error ready bool @@ -89,17 +125,14 @@ type pollResultMsg struct { err error } +type deleteResultMsg struct { + session string + machine string + err error +} + func initialModel(cfg Config) model { - columns := []table.Column{ - {Title: "Type", Width: 5}, - {Title: "Session", Width: 16}, - {Title: "Folder", Width: 16}, - {Title: "Status", Width: 10}, - {Title: "Started", Width: 10}, - {Title: "Closed", Width: 10}, - {Title: "Tools", Width: 5}, - {Title: "Task", Width: 20}, - } + columns := buildColumns(sortSmart) t := table.New( table.WithColumns(columns), @@ -128,6 +161,7 @@ func initialModel(cfg Config) model { hideSubagents: false, sortMode: sortSmart, remoteCooldown: map[string]time.Time{}, + colsSortMode: -1, } } @@ -211,6 +245,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, keys.Help): m.showHelp = !m.showHelp return m, nil + case key.Matches(msg, keys.Delete): + return m, deleteAgent(m) } case pollMsg: @@ -227,12 +263,22 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = msg.err } else { m.err = nil + m.notice = "" m.agents = msg.agents m.lastPoll = time.Now() m.updateTable() } return m, nil + case deleteResultMsg: + if msg.err != nil { + m.err = msg.err + } else { + m.err = nil + m.notice = "deleted " + msg.session + } + return m, pollNow() + case errMsg: m.err = msg.err return m, nil @@ -284,6 +330,13 @@ func (m model) View() string { Render(fmt.Sprintf("⚠ %v\n\n", m.err))) } + // Transient notice (e.g. "deleted …") + if m.notice != "" { + b.WriteString(lipgloss.NewStyle(). + Foreground(lipgloss.Color("#00ff88")). + Render("✓ " + m.notice + "\n\n")) + } + // Table b.WriteString(m.table.View()) b.WriteString("\n") @@ -302,7 +355,7 @@ func (m model) View() string { // Keybindings help b.WriteString(footerStyle.Render( - "↑↓ select · Enter attach · d hunk · Tab tuxedo · s sort · x hide closed · z hide sub · r refresh · ? help · q quit", + "↑↓ select · Enter attach · d hunk · Tab tuxedo · s sort · x hide closed · z hide sub · Del delete closed · r refresh · ? help · q quit", )) return b.String() @@ -317,10 +370,12 @@ func (m model) helpText() string { "s cycle sort: smart → activity → started → folder", "x toggle hiding closed sessions", "z toggle hiding sub-agent rows", + "Del delete the selected closed/lost row's state file (local or SSH)", "r force refresh", "? toggle this help", "q / Ctrl+C quit", "", + "List growth: closed/lost rows are hidden with x; Del removes them permanently.", "Background agents (long-running, survive terminal close):", " tmux new -ds 'pi -p \"your prompt\"'", " ssh 'tmux new -ds \"pi -p \\\"your prompt\\\"\"' # runs while laptop is closed", @@ -419,6 +474,13 @@ func strval(s *string) string { } func (m *model) updateTable() { + // Rebuild column headers when the sort mode changes (marker on the + // column being sorted by) + if m.sortMode != m.colsSortMode { + m.colsSortMode = m.sortMode + m.table.SetColumns(buildColumns(m.sortMode)) + } + // Filter var view []AgentState for _, a := range m.agents { @@ -619,11 +681,16 @@ func attachToAgent(m model) tea.Cmd { } // Remote agent: run the attach command over SSH - sshHost, sshUser := findRemote(m, a) - if sshHost == "" { + rc, ok := findRemote(m, a) + if !ok { m.err = fmt.Errorf("no SSH config for remote machine: %s", a.Machine) return nil } + sshUser := rc.User + if sshUser == "" { + sshUser = "sam" + } + sshHost := rc.Host remoteCmd := strings.Join(append([]string{bin}, args...), " ") return tea.ExecProcess( exec.Command("ssh", "-t", fmt.Sprintf("%s@%s", sshUser, sshHost), remoteCmd), @@ -668,11 +735,16 @@ func openHunk(m model) tea.Cmd { } // Remote: run hunk over SSH in the agent's folder - sshHost, sshUser := findRemote(m, a) - if sshHost == "" { + rc, ok := findRemote(m, a) + if !ok { m.err = fmt.Errorf("no SSH config for remote machine: %s", a.Machine) return nil } + sshUser := rc.User + if sshUser == "" { + sshUser = "sam" + } + sshHost := rc.Host remoteCmd := fmt.Sprintf("cd %s && hunk diff --watch", a.Cwd) return tea.ExecProcess( exec.Command("ssh", "-t", fmt.Sprintf("%s@%s", sshUser, sshHost), remoteCmd), @@ -729,11 +801,16 @@ func openTuxedo(m model) tea.Cmd { } // Remote: run tuxedo over SSH on the agent's project todo - sshHost, sshUser := findRemote(m, agent) - if sshHost == "" { + rc, ok := findRemote(m, agent) + if !ok { m.err = fmt.Errorf("no SSH config for remote machine: %s", agent.Machine) return nil } + sshUser := rc.User + if sshUser == "" { + sshUser = "sam" + } + sshHost := rc.Host remoteCmd := fmt.Sprintf("mkdir -p %s && touch %s && tuxedo %s", filepath.Dir(todoPath), todoPath, todoPath) return tea.ExecProcess( @@ -747,19 +824,66 @@ func openTuxedo(m model) tea.Cmd { ) } -// findRemote returns the (host, user) for the SSH config matching a machine, -// or ("", "") if none matches. -func findRemote(m model, a AgentState) (string, string) { - for _, r := range m.config.Remote { - if strings.Contains(a.Machine, r.Host) || strings.Contains(r.Host, a.Machine) { - user := r.User +// deleteAgent deletes the selected closed/lost row's state file (local or over +// SSH). Live sessions are refused — their extension would just rewrite the file. +func deleteAgent(m model) tea.Cmd { + if len(m.view) == 0 { + return nil + } + row := m.table.Cursor() + if row < 0 || row >= len(m.view) { + return nil + } + a := m.view[row] + if !a.IsClosed(m.staleAfter()) { + return func() tea.Msg { + return deleteResultMsg{session: a.Session, machine: a.Machine, + err: fmt.Errorf("%s is still live — only closed/lost rows can be deleted", a.Session)} + } + } + return func() tea.Msg { + var err error + if a.IsLocal() { + p := filepath.Join(m.config.Local.Path, a.Session+".json") + err = os.Remove(p) + if os.IsNotExist(err) { + err = nil + } + } else { + rc, ok := findRemote(m, a) + if !ok { + return deleteResultMsg{session: a.Session, machine: a.Machine, + err: fmt.Errorf("no SSH config for remote machine: %s", a.Machine)} + } + user := rc.User if user == "" { user = "sam" } - return r.Host, user + path := filepath.Join(rc.Path, a.Session+".json") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "ssh", "-o", "BatchMode=yes", + fmt.Sprintf("%s@%s", user, rc.Host), "rm -f -- "+shellQuote(path)) + if out, e := cmd.Output(); e != nil { + err = fmt.Errorf("ssh %s: %s %w", rc.Host, strings.TrimSpace(string(out)), e) + } + } + return deleteResultMsg{session: a.Session, machine: a.Machine, err: err} + } +} + +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// findRemote returns the SSH config entry matching an agent's machine. +func findRemote(m model, a AgentState) (RemoteConfig, bool) { + for _, r := range m.config.Remote { + if strings.Contains(a.Machine, r.Host) || strings.Contains(r.Host, a.Machine) { + return r, true } } - return "", "" + return RemoteConfig{}, false } // ── Keybindings ────────────────────────────────────────────────── @@ -774,6 +898,7 @@ type keyMap struct { HideClosed key.Binding HideSubagents key.Binding Help key.Binding + Delete key.Binding } var keys = keyMap{ @@ -785,7 +910,8 @@ var keys = keyMap{ Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")), HideClosed: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "hide closed")), HideSubagents: key.NewBinding(key.WithKeys("z"), key.WithHelp("z", "hide subagents")), - Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("", "help")), + Delete: key.NewBinding(key.WithKeys("delete"), key.WithHelp("del", "delete closed row")), } // ── Helpers ────────────────────────────────────────────────────── @@ -861,6 +987,7 @@ Keybindings (TUI mode): s Cycle sort: smart > activity > started > folder x Toggle hiding closed sessions z Toggle hiding sub-agent sessions + Del Delete the selected closed/lost row's state file (local or SSH) ? Toggle in-TUI help r Force refresh q Quit diff --git a/main_test.go b/main_test.go index e80959b..7d12a08 100644 --- a/main_test.go +++ b/main_test.go @@ -172,3 +172,34 @@ func TestLegacyWindow(t *testing.T) { } func strptr(s string) *string { return &s } + +// shellQuote must produce a POSIX-safe single-quoted argument. +func TestShellQuote(t *testing.T) { + if got := shellQuote("fix pi mcp subagent.json"); got != "'fix pi mcp subagent.json'" { + t.Fatalf("spaces: %s", got) + } + if got := shellQuote("it's a.json"); got != `'it'\''s a.json'` { + t.Fatalf("quote: %s", got) + } +} + +// buildColumns marks the correct header for each sort mode. +func TestSortColumnMarker(t *testing.T) { + cols := buildColumns(sortStarted) + if cols[4].Title == "Started" || cols[4].Title == "" { + t.Fatalf("expected Started column to be marked, got %q", cols[4].Title) + } + if !strings.Contains(cols[4].Title, "▼") { + t.Fatalf("expected ▼ marker, got %q", cols[4].Title) + } + // other columns untouched + if cols[2].Title != "Folder" { + t.Fatalf("Folder column should be unmarked, got %q", cols[2].Title) + } + // activity mode has no column + for _, c := range buildColumns(sortActivity) { + if strings.Contains(c.Title, "▼") { + t.Fatalf("activity mode should not mark any column, got %q", c.Title) + } + } +}