Rename Kill to Close, add New Terminal palette entry, clean up exited terminals

- Palette's per-child "Kill <name>" action is now labelled "Close <name>"
  (action kind unchanged; still SIGTERM). Matches the existing "Close
  agent: …" context entry and reads less violent for a graceful term.
- New "New Terminal" palette entry spawns a bare interactive $SHELL pane
  via LaunchTerminal (kind=terminal). Replaces the default "shell"
  process preset that was seeded on first run.
- Exited KindTerminal entries are now dropped from the session in
  reapChild — terminals have no restart path, so leaving them behind as
  greyed rows in the Processes sidebar was just clutter. processList
  also filters defensively.
This commit was merged in pull request #2.
This commit is contained in:
2026-05-15 01:07:57 +01:00
parent 24696305d6
commit 01fc108086
7 changed files with 75 additions and 33 deletions

View File

@@ -7,6 +7,13 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Added ### Added
- "New Terminal" entry in the command palette spawns a bare interactive
`$SHELL` pane (kind `terminal`). Unlike "Run process: …" presets,
which are session-persistent and reachable via `restart_process`,
terminals are ephemeral — once they exit they vanish from the
Processes sidebar instead of lingering as a dead row. The default
`shell` process preset that previously seeded on first run has been
removed; this entry replaces it.
- Per-child idle-state classifier with five states (`idle`, `working`, - Per-child idle-state classifier with five states (`idle`, `working`,
`thinking`, `permission`, `error`) and three pluggable strategies: `thinking`, `permission`, `error`) and three pluggable strategies:
`output_activity` (claude / opencode defaults), `osc_title_stability` `output_activity` (claude / opencode defaults), `osc_title_stability`
@@ -98,6 +105,11 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
after a child program disables mouse tracking. after a child program disables mouse tracking.
### Changed ### Changed
- The palette's per-child "Kill <name>" action is now labelled
"Close <name>". The underlying signal (SIGTERM) and behaviour are
unchanged; the new label matches the existing "Close agent: …"
context entry and reads less violent for what is really just a
graceful termination.
- `timer_wait` is now a thin wrapper over the shared timer manager - `timer_wait` is now a thin wrapper over the shared timer manager
(`timer_set` semantics). Existing callers see no behavioural change; (`timer_set` semantics). Existing callers see no behavioural change;
the timer is visible in `timer_list` while it's pending. the timer is visible in `timer_list` while it's pending.
@@ -108,6 +120,12 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
renders the canonical `--flag` form. renders the canonical `--flag` form.
### Fixed ### Fixed
- Exited terminal panes (kind `terminal`, including those launched via
the new "New Terminal" palette entry or MCP `spawn_process` with
`kind=terminal`) are now removed from the session and the Processes
sidebar as soon as they exit. Previously they stuck around as a
greyed-out row indistinguishable from an exited command process,
even though terminals have no restart path.
- `whoami` and `help("timers")` now advertise the full Solo-parity timer - `whoami` and `help("timers")` now advertise the full Solo-parity timer
surface (`timer_set`, `timer_fire_when_idle_any`, surface (`timer_set`, `timer_fire_when_idle_any`,
`timer_fire_when_idle_all`, `timer_cancel`, `timer_pause`, `timer_fire_when_idle_all`, `timer_cancel`, `timer_pause`,

View File

@@ -1,6 +1,3 @@
- [ ] We should probably rename the Kill <Process> terminology to Close <Process> instead, across processes and agents.
- [ ] Exited shells are still being treated as active processes. They should be removed from the process list when they exit.
- [ ] Shells should be renamed to terminals. "New Terminal" etc.
- [ ] Codex seemed to think that it needed to launch patterm itself to get the mcp working - [ ] Codex seemed to think that it needed to launch patterm itself to get the mcp working
- [ ] I cant click and drag to select text from codex - [ ] I cant click and drag to select text from codex
- [ ] codex uses perl to interact with the socket rather than calling mcp tools - [ ] codex uses perl to interact with the socket rather than calling mcp tools
@@ -12,6 +9,7 @@
- [ ] Resuming a long claude session takes a couple of seconds for the entire buffer to load in, it looks like it's scrolling down for a couple seconds. - [ ] Resuming a long claude session takes a couple of seconds for the entire buffer to load in, it looks like it's scrolling down for a couple seconds.
- In raw alacritty this is instant, so there's some sort of performance issue with patterm's terminal emulation. - In raw alacritty this is instant, so there's some sort of performance issue with patterm's terminal emulation.
# On Hold # On Hold
- [ ] There's a unicode <?> being displayed in opencode [ON HOLD] - [ ] There's a unicode <?> being displayed in opencode [ON HOLD]
- Investigated 2026-05-14: patterm passes ghostty grapheme codepoints - Investigated 2026-05-14: patterm passes ghostty grapheme codepoints

View File

@@ -1622,6 +1622,13 @@ func (st *uiState) closePalette(action paletteAction) {
st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err)) st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
} }
case "spawn-terminal":
l := st.layoutSnapshot()
st.launcher.SetSize(l.childCols(), l.childRows())
if _, err := st.launcher.LaunchTerminal(nil, "terminal", "", "", nil); err != nil {
st.flashError(fmt.Sprintf("spawn terminal: %v", err))
}
case "spawn-process-submit": case "spawn-process-submit":
if action.command == "" { if action.command == "" {
restoreView() restoreView()

View File

@@ -11,12 +11,13 @@ import (
// paletteAction is what the palette returns when the user picks an item. // paletteAction is what the palette returns when the user picks an item.
type paletteAction struct { type paletteAction struct {
// kind: "spawn-agent" | "spawn-process" | "spawn-process-form" | // kind: "spawn-agent" | "spawn-process" | "spawn-process-form" |
// "spawn-process-submit" | "switch" | "kill" | "quit" | // "spawn-process-submit" | "spawn-terminal" | "switch" |
// "cancel" | "pad-delete" | "pad-rename" | "pad-rename-form" | // "kill" | "quit" | "cancel" | "pad-delete" | "pad-rename" |
// "pad-rename-submit" | "pad-edit" | "agent-rename" | // "pad-rename-form" | "pad-rename-submit" | "pad-edit" |
// "agent-rename-form" | "agent-rename-submit" | "agent-close" | // "agent-rename" | "agent-rename-form" | "agent-rename-submit" |
// "proc-rename" | "proc-rename-form" | "proc-rename-submit" | // "agent-close" | "proc-rename" | "proc-rename-form" |
// "proc-delete" | "proc-stop" | "proc-restart" // "proc-rename-submit" | "proc-delete" | "proc-stop" |
// "proc-restart"
kind string kind string
// For spawn-agent / spawn-process, the preset to launch. // For spawn-agent / spawn-process, the preset to launch.
@@ -276,6 +277,16 @@ func (p *paletteState) allItems() []paletteItem {
}) })
} }
// "New Terminal" — bare interactive $SHELL pane. Distinct from
// "Run process: …" presets in that it spawns a KindTerminal (which
// disappears from the sidebar on exit rather than sticking around
// for restart). One quick keystroke; no form.
out = append(out, paletteItem{
label: "New Terminal",
hint: "bare interactive $SHELL · removed on exit",
action: paletteAction{kind: "spawn-terminal"},
})
// Freeform "Spawn process…" entry. Opens a sub-form for typing an // Freeform "Spawn process…" entry. Opens a sub-form for typing an
// arbitrary command line and ticking "relaunch on exit". The action // arbitrary command line and ticking "relaunch on exit". The action
// kind is intercepted by acceptOrEnterForm so accept switches the // kind is intercepted by acceptOrEnterForm so accept switches the
@@ -288,14 +299,14 @@ func (p *paletteState) allItems() []paletteItem {
action: paletteAction{kind: "spawn-process-form"}, action: paletteAction{kind: "spawn-process-form"},
}) })
// Kill entries last among the action rows, before Quit. Mirror the // Close entries last among the action rows, before Quit. Mirror the
// "(current)" marker from switch entries so the focused tab is // "(current)" marker from switch entries so the focused tab is
// obvious when scanning the kill list. // obvious when scanning the close list.
for _, c := range p.children { for _, c := range p.children {
if c.Status() != StatusRunning { if c.Status() != StatusRunning {
continue continue
} }
label := "Kill " + c.DisplayName() label := "Close " + c.DisplayName()
if c.ID == p.focused { if c.ID == p.focused {
label = "• " + label + " (current)" label = "• " + label + " (current)"
} }

View File

@@ -433,6 +433,23 @@ func (s *Session) reapChild(c *Child, runID uint64) {
if !c.restarting.Load() { if !c.restarting.Load() {
c.cleanupOwnedPaths() c.cleanupOwnedPaths()
} }
// Terminals are ephemeral: unlike command entries (kept around for
// restart_process) and agents (which the user clears via close_process
// once they're done with the corpse), an exited terminal has nothing
// useful left to do. Drop it from the session so it disappears from
// the Processes sidebar / switch list immediately.
if c.Kind == KindTerminal && !c.restarting.Load() {
c.teardownPTY()
s.mu.Lock()
delete(s.children, c.ID)
for i, oid := range s.order {
if oid == c.ID {
s.order = append(s.order[:i], s.order[i+1:]...)
break
}
}
s.mu.Unlock()
}
} }
// killDescendantsOf terminates every still-live direct child of // killDescendantsOf terminates every still-live direct child of

View File

@@ -96,17 +96,24 @@ func firstRunningAgentID(children []*Child) string {
} }
// processList returns every top-level command/terminal entry in spawn // processList returns every top-level command/terminal entry in spawn
// order, regardless of running state. The Processes sidebar section // order. Exited KindCommand entries remain visible so the user can see
// keeps showing exited entries so the user can see what just died (and // what just died and reach restart_process; exited KindTerminal entries
// because Session retains KindCommand entries for restart). // are filtered out because terminals are ephemeral and have no restart
// path (Session also drops them in reapChild — this filter is defensive
// for any window between exit and deletion).
func processList(children []*Child) []*Child { func processList(children []*Child) []*Child {
out := make([]*Child, 0, len(children)) out := make([]*Child, 0, len(children))
for _, c := range children { for _, c := range children {
if c.ParentID != "" { if c.ParentID != "" {
continue continue
} }
if c.Kind == KindCommand || c.Kind == KindTerminal { switch c.Kind {
case KindCommand:
out = append(out, c) out = append(out, c)
case KindTerminal:
if c.Status() == StatusRunning {
out = append(out, c)
}
} }
} }
return out return out

View File

@@ -300,14 +300,6 @@ func ensureDefaults(base string) error {
"^\\s*>_" "^\\s*>_"
] ]
} }
`,
},
{
"presets/processes/shell.json",
`{
"name": "shell",
"argv": ["__SHELL__"]
}
`, `,
}, },
} }
@@ -319,15 +311,7 @@ func ensureDefaults(base string) error {
if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil { if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil {
return err return err
} }
body := d.body if err := os.WriteFile(full, []byte(d.body), 0o600); err != nil {
if strings.Contains(body, "__SHELL__") {
shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/sh"
}
body = strings.ReplaceAll(body, "__SHELL__", shell)
}
if err := os.WriteFile(full, []byte(body), 0o600); err != nil {
return err return err
} }
} }