Bundles the in-flight work into the first tagged release. See CHANGELOG.md `[0.0.1] - 2026-05-14` for the full per-change list. Highlights: - Sidebar / chrome stability: clamp absolute cursor positioning and printable bytes to the viewport so long-running TUIs (claude, codex) can't spray into the right rail; bound tab bar's row clear to the viewport width so the rail isn't wiped on every tab redraw; flag scroll escapes (RI/IND/NEL/SU/SD/IL/DL) and clamp `CSI 0/1/2 J`/`K` to viewport columns. - Palette: "Spawn process…" form, macros (`sw `, `k `, `sp `), kill entries mark the focused tab, dead agents drop out of the switch list. - Sidebar: split into Processes (session-wide) + Agent Tree (per-active-agent) sections; relaunch indicator; Ctrl+W/S walks the combined list, Ctrl+A/D steps tabs. - MCP: protocol handshake (`initialize`, `tools/list`, `tools/call`, `ping`), `mcp_injection.kind = cli_override / config_env` so codex and opencode pick up the server with no file writes, `lifecycle` help topic and tool-description cleanup-duty pointers. - Lifecycle: orchestrator-spawned children cascade-killed when the parent dies; orchestrator-injected prompts end with CR + delayed Enter so claude submits cleanly.
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
cpty "github.com/creack/pty"
|
||||
"golang.org/x/term"
|
||||
@@ -213,6 +214,11 @@ type uiState struct {
|
||||
palette *paletteState
|
||||
focusedID string
|
||||
focusedName string
|
||||
// activeAgentID tracks which top-level agent tab "owns" the agent
|
||||
// tree section of the sidebar. It only updates when focus lands on
|
||||
// an agent (or one of its sub-agents), so the agent tree stays
|
||||
// visible even when the user steps into the Processes pane.
|
||||
activeAgentID string
|
||||
// renderer confines focused-child live output to the main viewport.
|
||||
// A fresh renderer is allocated per focused child so partial-escape
|
||||
// state cannot bleed between panes.
|
||||
@@ -283,6 +289,7 @@ func (st *uiState) focusProcess(processID string) {
|
||||
st.mu.Lock()
|
||||
st.focusedID = c.ID
|
||||
st.focusedName = c.DisplayName()
|
||||
st.updateActiveAgentLocked(c)
|
||||
st.renderer = newViewportRenderer(layout)
|
||||
st.mu.Unlock()
|
||||
st.repaintFocused()
|
||||
@@ -291,6 +298,33 @@ func (st *uiState) focusProcess(processID string) {
|
||||
st.drawStatusLine()
|
||||
}
|
||||
|
||||
// updateActiveAgentLocked records the active agent root for the agent
|
||||
// tree section whenever focus lands on an agent or one of its
|
||||
// sub-agents. Focusing a top-level command process leaves the previous
|
||||
// active agent intact, so the user can hop between the Processes pane
|
||||
// and the agent tree without losing context. Caller holds st.mu.
|
||||
func (st *uiState) updateActiveAgentLocked(c *Child) {
|
||||
if c.Kind != KindAgent {
|
||||
return
|
||||
}
|
||||
if c.ParentID == "" {
|
||||
st.activeAgentID = c.ID
|
||||
return
|
||||
}
|
||||
// Walk up to the top-level agent.
|
||||
root := c
|
||||
for root.ParentID != "" {
|
||||
parent := st.sess.FindChild(root.ParentID)
|
||||
if parent == nil {
|
||||
break
|
||||
}
|
||||
root = parent
|
||||
}
|
||||
if root.Kind == KindAgent && root.ParentID == "" {
|
||||
st.activeAgentID = root.ID
|
||||
}
|
||||
}
|
||||
|
||||
// notifyAttention is the request_human_attention sink (SPEC §7). We
|
||||
// surface a one-line toast in the status row and remember the most
|
||||
// recent ask so the status line keeps showing it. The sidebar-blink is
|
||||
@@ -321,6 +355,7 @@ func (st *uiState) OnChildSpawned(c *Child) {
|
||||
st.mu.Lock()
|
||||
st.focusedID = c.ID
|
||||
st.focusedName = c.DisplayName()
|
||||
st.updateActiveAgentLocked(c)
|
||||
renderer := newViewportRenderer(layout)
|
||||
st.renderer = renderer
|
||||
palOpen := st.palette != nil
|
||||
@@ -377,9 +412,15 @@ func (st *uiState) OnChildExited(c *Child) {
|
||||
} else {
|
||||
st.focusedID = next.ID
|
||||
st.focusedName = next.DisplayName()
|
||||
st.updateActiveAgentLocked(next)
|
||||
st.renderer = newViewportRenderer(layout)
|
||||
}
|
||||
}
|
||||
if c.ID == st.activeAgentID {
|
||||
// The active agent died; pin the agent tree to whatever agent
|
||||
// root is still running, or clear it if none remain.
|
||||
st.activeAgentID = firstRunningAgentID(st.sess.Children())
|
||||
}
|
||||
if st.palette != nil {
|
||||
st.palette.children = st.sess.Children()
|
||||
st.palette.focused = st.focusedID
|
||||
@@ -397,6 +438,41 @@ func (st *uiState) OnChildExited(c *Child) {
|
||||
st.drawTabBar()
|
||||
st.drawSidebar()
|
||||
st.drawStatusLine()
|
||||
|
||||
// Auto-restart kicks in for command entries the user marked "relaunch
|
||||
// on exit". A short backoff (1s) avoids hot-spinning on processes
|
||||
// that fail immediately. The user can clear the flag by killing the
|
||||
// process from the palette.
|
||||
if c.Kind == KindCommand && c.AutoRestart() {
|
||||
go st.scheduleAutoRestart(c)
|
||||
}
|
||||
}
|
||||
|
||||
// scheduleAutoRestart re-Starts a command entry after a brief backoff.
|
||||
// Bails out if the user cleared the flag, closed the process, or the
|
||||
// entry came back to life through some other path while we were
|
||||
// waiting. Called as a goroutine from OnChildExited.
|
||||
func (st *uiState) scheduleAutoRestart(c *Child) {
|
||||
time.Sleep(1 * time.Second)
|
||||
if !c.AutoRestart() {
|
||||
return
|
||||
}
|
||||
if st.sess.FindChild(c.ID) == nil {
|
||||
return
|
||||
}
|
||||
if c.IsLive() {
|
||||
return
|
||||
}
|
||||
l := st.layoutSnapshot()
|
||||
if err := st.sess.Start(c.ID, l.childCols(), l.childRows()); err != nil {
|
||||
st.dbgf("auto-restart %s: %v", c.ID, err)
|
||||
return
|
||||
}
|
||||
// Start doesn't fire emitSpawn, so we have to nudge the chrome
|
||||
// ourselves — the status flipped from exited back to running and
|
||||
// the sidebar's cached frame still shows the exited glyph.
|
||||
st.drawSidebar()
|
||||
st.drawStatusLine()
|
||||
}
|
||||
|
||||
// OnPTYOut writes live output for the focused child when the palette is
|
||||
@@ -563,7 +639,18 @@ func (st *uiState) drawStatusLine() {
|
||||
if trustMsg != "" {
|
||||
left = "[trust] " + trustMsg
|
||||
}
|
||||
right := "Ctrl-K · palette"
|
||||
// Hints decay shortest-first when the host is narrow so the focused
|
||||
// child name + ownership note on the left side never get clipped.
|
||||
hints := []string{
|
||||
"Ctrl-A/D · tabs",
|
||||
"Ctrl-W/S · tree",
|
||||
"Ctrl-K · palette",
|
||||
}
|
||||
right := strings.Join(hints, " · ")
|
||||
for len(hints) > 1 && int(cols)-len(left)-len(right) < 1 {
|
||||
hints = hints[1:]
|
||||
right = strings.Join(hints, " · ")
|
||||
}
|
||||
|
||||
pad := int(cols) - len(left) - len(right)
|
||||
if pad < 1 {
|
||||
@@ -831,13 +918,13 @@ func (st *uiState) processStdin(chunk []byte) {
|
||||
}
|
||||
if hit, adv := matchCtrlChar(chunk, i, 'w'); hit {
|
||||
flushForward()
|
||||
pendingNavID = nextChildID(st.sess.Children(), st.focusedID, -1)
|
||||
pendingNavID = nextChildID(st.sess.Children(), st.focusedID, st.activeAgentID, -1)
|
||||
i += adv
|
||||
break
|
||||
}
|
||||
if hit, adv := matchCtrlChar(chunk, i, 's'); hit {
|
||||
flushForward()
|
||||
pendingNavID = nextChildID(st.sess.Children(), st.focusedID, +1)
|
||||
pendingNavID = nextChildID(st.sess.Children(), st.focusedID, st.activeAgentID, +1)
|
||||
i += adv
|
||||
break
|
||||
}
|
||||
@@ -916,6 +1003,36 @@ func (st *uiState) closePalette(action paletteAction) {
|
||||
st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
|
||||
}
|
||||
|
||||
case "spawn-process-submit":
|
||||
if action.command == "" {
|
||||
st.repaintFocused()
|
||||
return
|
||||
}
|
||||
l := st.layoutSnapshot()
|
||||
st.launcher.SetSize(l.childCols(), l.childRows())
|
||||
display := action.command
|
||||
if len(display) > 32 {
|
||||
display = display[:31] + "…"
|
||||
}
|
||||
// shell=true so multi-word commands like "bun run dev" pass
|
||||
// through `sh -lc` and the user's PATH resolves binaries the
|
||||
// way they expect from an interactive shell.
|
||||
c, err := st.launcher.LaunchCommandArgv([]string{action.command}, display, "", "", nil, true)
|
||||
if err != nil {
|
||||
st.flashError(fmt.Sprintf("spawn: %v", err))
|
||||
return
|
||||
}
|
||||
c.SetAutoRestart(action.relaunch)
|
||||
// LaunchCommandArgv fires OnChildSpawned synchronously, which
|
||||
// drew the sidebar before AutoRestart was set. Invalidate so the
|
||||
// ⟳ marker shows up on the next paint.
|
||||
if action.relaunch {
|
||||
st.chromeCacheMu.Lock()
|
||||
st.sidebarCache = ""
|
||||
st.chromeCacheMu.Unlock()
|
||||
st.drawSidebar()
|
||||
}
|
||||
|
||||
case "switch":
|
||||
c := st.sess.FindChild(action.childID)
|
||||
if c == nil || c.Status() != StatusRunning {
|
||||
@@ -926,6 +1043,7 @@ func (st *uiState) closePalette(action paletteAction) {
|
||||
st.mu.Lock()
|
||||
st.focusedID = action.childID
|
||||
st.focusedName = c.DisplayName()
|
||||
st.updateActiveAgentLocked(c)
|
||||
st.renderer = newViewportRenderer(layout)
|
||||
st.mu.Unlock()
|
||||
st.repaintFocused()
|
||||
@@ -934,6 +1052,11 @@ func (st *uiState) closePalette(action paletteAction) {
|
||||
st.drawStatusLine()
|
||||
|
||||
case "kill":
|
||||
// User-initiated kill cancels any pending auto-restart so the
|
||||
// process doesn't immediately come back.
|
||||
if c := st.sess.FindChild(action.childID); c != nil {
|
||||
c.SetAutoRestart(false)
|
||||
}
|
||||
_ = st.sess.Kill(action.childID, syscall.SIGTERM)
|
||||
st.repaintFocused()
|
||||
st.drawTabBar()
|
||||
|
||||
Reference in New Issue
Block a user