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
|
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
|
||||||
|
|||||||
31
main_test.go
31
main_test.go
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user