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:
179
main.go
179
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 <name> 'pi -p \"your prompt\"'",
|
||||
" 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() {
|
||||
// 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
|
||||
|
||||
31
main_test.go
31
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user