TUI: Del key deletes closed/lost state files (local+SSH, live refused), sort-column ▼ marker, delete/shellquote/sort-column tests

This commit is contained in:
2026-08-06 15:23:06 +10:00
parent b24f9012d6
commit e22ddb43d4
2 changed files with 184 additions and 26 deletions

179
main.go
View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@@ -63,6 +64,39 @@ var sortNames = map[int]string{
sortFolder: "folder", 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 // Model
type model struct { type model struct {
table table.Model table table.Model
@@ -76,6 +110,8 @@ type model struct {
polling bool // a fetch is in flight (prevents concurrent polls) polling bool // a fetch is in flight (prevents concurrent polls)
remoteCooldown map[string]time.Time // per-remote failure cooldown remoteCooldown map[string]time.Time // per-remote failure cooldown
showHelp bool showHelp bool
notice string // transient status line (e.g. "deleted …")
colsSortMode int // sort mode the current column headers were built for
lastPoll time.Time lastPoll time.Time
err error err error
ready bool ready bool
@@ -89,17 +125,14 @@ type pollResultMsg struct {
err error err error
} }
type deleteResultMsg struct {
session string
machine string
err error
}
func initialModel(cfg Config) model { func initialModel(cfg Config) model {
columns := []table.Column{ columns := buildColumns(sortSmart)
{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},
}
t := table.New( t := table.New(
table.WithColumns(columns), table.WithColumns(columns),
@@ -128,6 +161,7 @@ func initialModel(cfg Config) model {
hideSubagents: false, hideSubagents: false,
sortMode: sortSmart, sortMode: sortSmart,
remoteCooldown: map[string]time.Time{}, 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): case key.Matches(msg, keys.Help):
m.showHelp = !m.showHelp m.showHelp = !m.showHelp
return m, nil return m, nil
case key.Matches(msg, keys.Delete):
return m, deleteAgent(m)
} }
case pollMsg: case pollMsg:
@@ -227,12 +263,22 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = msg.err m.err = msg.err
} else { } else {
m.err = nil m.err = nil
m.notice = ""
m.agents = msg.agents m.agents = msg.agents
m.lastPoll = time.Now() m.lastPoll = time.Now()
m.updateTable() m.updateTable()
} }
return m, nil 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: case errMsg:
m.err = msg.err m.err = msg.err
return m, nil return m, nil
@@ -284,6 +330,13 @@ func (m model) View() string {
Render(fmt.Sprintf("⚠ %v\n\n", m.err))) 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 // Table
b.WriteString(m.table.View()) b.WriteString(m.table.View())
b.WriteString("\n") b.WriteString("\n")
@@ -302,7 +355,7 @@ func (m model) View() string {
// Keybindings help // Keybindings help
b.WriteString(footerStyle.Render( 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() return b.String()
@@ -317,10 +370,12 @@ func (m model) helpText() string {
"s cycle sort: smart → activity → started → folder", "s cycle sort: smart → activity → started → folder",
"x toggle hiding closed sessions", "x toggle hiding closed sessions",
"z toggle hiding sub-agent rows", "z toggle hiding sub-agent rows",
"Del delete the selected closed/lost row's state file (local or SSH)",
"r force refresh", "r force refresh",
"? toggle this help", "? toggle this help",
"q / Ctrl+C quit", "q / Ctrl+C quit",
"", "",
"List growth: closed/lost rows are hidden with x; Del removes them permanently.",
"Background agents (long-running, survive terminal close):", "Background agents (long-running, survive terminal close):",
" tmux new -ds <name> 'pi -p \"your prompt\"'", " tmux new -ds <name> 'pi -p \"your prompt\"'",
" ssh <server> 'tmux new -ds <name> \"pi -p \\\"your prompt\\\"\"' # runs while laptop is closed", " ssh <server> 'tmux new -ds <name> \"pi -p \\\"your prompt\\\"\"' # runs while laptop is closed",
@@ -419,6 +474,13 @@ func strval(s *string) string {
} }
func (m *model) updateTable() { 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 // Filter
var view []AgentState var view []AgentState
for _, a := range m.agents { for _, a := range m.agents {
@@ -619,11 +681,16 @@ func attachToAgent(m model) tea.Cmd {
} }
// Remote agent: run the attach command over SSH // Remote agent: run the attach command over SSH
sshHost, sshUser := findRemote(m, a) rc, ok := findRemote(m, a)
if sshHost == "" { if !ok {
m.err = fmt.Errorf("no SSH config for remote machine: %s", a.Machine) m.err = fmt.Errorf("no SSH config for remote machine: %s", a.Machine)
return nil return nil
} }
sshUser := rc.User
if sshUser == "" {
sshUser = "sam"
}
sshHost := rc.Host
remoteCmd := strings.Join(append([]string{bin}, args...), " ") remoteCmd := strings.Join(append([]string{bin}, args...), " ")
return tea.ExecProcess( return tea.ExecProcess(
exec.Command("ssh", "-t", fmt.Sprintf("%s@%s", sshUser, sshHost), remoteCmd), 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 // Remote: run hunk over SSH in the agent's folder
sshHost, sshUser := findRemote(m, a) rc, ok := findRemote(m, a)
if sshHost == "" { if !ok {
m.err = fmt.Errorf("no SSH config for remote machine: %s", a.Machine) m.err = fmt.Errorf("no SSH config for remote machine: %s", a.Machine)
return nil return nil
} }
sshUser := rc.User
if sshUser == "" {
sshUser = "sam"
}
sshHost := rc.Host
remoteCmd := fmt.Sprintf("cd %s && hunk diff --watch", a.Cwd) remoteCmd := fmt.Sprintf("cd %s && hunk diff --watch", a.Cwd)
return tea.ExecProcess( return tea.ExecProcess(
exec.Command("ssh", "-t", fmt.Sprintf("%s@%s", sshUser, sshHost), remoteCmd), 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 // Remote: run tuxedo over SSH on the agent's project todo
sshHost, sshUser := findRemote(m, agent) rc, ok := findRemote(m, agent)
if sshHost == "" { if !ok {
m.err = fmt.Errorf("no SSH config for remote machine: %s", agent.Machine) m.err = fmt.Errorf("no SSH config for remote machine: %s", agent.Machine)
return nil return nil
} }
sshUser := rc.User
if sshUser == "" {
sshUser = "sam"
}
sshHost := rc.Host
remoteCmd := fmt.Sprintf("mkdir -p %s && touch %s && tuxedo %s", remoteCmd := fmt.Sprintf("mkdir -p %s && touch %s && tuxedo %s",
filepath.Dir(todoPath), todoPath, todoPath) filepath.Dir(todoPath), todoPath, todoPath)
return tea.ExecProcess( 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, // deleteAgent deletes the selected closed/lost row's state file (local or over
// or ("", "") if none matches. // SSH). Live sessions are refused — their extension would just rewrite the file.
func findRemote(m model, a AgentState) (string, string) { func deleteAgent(m model) tea.Cmd {
for _, r := range m.config.Remote { if len(m.view) == 0 {
if strings.Contains(a.Machine, r.Host) || strings.Contains(r.Host, a.Machine) { return nil
user := r.User }
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 == "" { if user == "" {
user = "sam" 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 ────────────────────────────────────────────────── // ── Keybindings ──────────────────────────────────────────────────
@@ -774,6 +898,7 @@ type keyMap struct {
HideClosed key.Binding HideClosed key.Binding
HideSubagents key.Binding HideSubagents key.Binding
Help key.Binding Help key.Binding
Delete key.Binding
} }
var keys = keyMap{ var keys = keyMap{
@@ -785,7 +910,8 @@ var keys = keyMap{
Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")), Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
HideClosed: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "hide closed")), HideClosed: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "hide closed")),
HideSubagents: key.NewBinding(key.WithKeys("z"), key.WithHelp("z", "hide subagents")), 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 ────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────
@@ -861,6 +987,7 @@ Keybindings (TUI mode):
s Cycle sort: smart > activity > started > folder s Cycle sort: smart > activity > started > folder
x Toggle hiding closed sessions x Toggle hiding closed sessions
z Toggle hiding sub-agent sessions z Toggle hiding sub-agent sessions
Del Delete the selected closed/lost row's state file (local or SSH)
? Toggle in-TUI help ? Toggle in-TUI help
r Force refresh r Force refresh
q Quit q Quit

View File

@@ -172,3 +172,34 @@ func TestLegacyWindow(t *testing.T) {
} }
func strptr(s string) *string { return &s } 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)
}
}
}