Simplify session lifecycle and MCP cleanup

This commit is contained in:
2026-05-14 20:51:37 +01:00
parent 27361f79c4
commit cc4bf9e904
16 changed files with 439 additions and 255 deletions

View File

@@ -105,6 +105,7 @@ func Run(ctx context.Context, opts Options) error {
host.attention = st
host.focus = st
host.prompter = st
host.scratch = st
st.lastExit.Store(-1)
sess.Subscribe(st)
@@ -241,10 +242,10 @@ type uiState struct {
// usually doesn't change between calls — caching the rendered
// output and skipping a write when it matches eliminates the
// flicker (especially in the sidebar's session tree).
chromeCacheMu sync.Mutex
tabBarCache string
sidebarCache string
statusLineCache string
chromeCacheMu sync.Mutex
tabBarCache string
sidebarCache string
statusLineCache string
lastExit atomic.Int32
}
@@ -278,10 +279,11 @@ func (st *uiState) focusProcess(processID string) {
if c == nil {
return
}
layout := st.layoutSnapshot()
st.mu.Lock()
st.focusedID = c.ID
st.focusedName = c.DisplayName()
st.renderer = newViewportRenderer(st.layoutSnapshot())
st.renderer = newViewportRenderer(layout)
st.mu.Unlock()
st.repaintFocused()
st.drawTabBar()
@@ -297,7 +299,7 @@ func (st *uiState) notifyAttention(childID, reason string) {
c := st.sess.FindChild(childID)
name := childID
if c != nil {
name = c.Name
name = c.DisplayName()
}
st.mu.Lock()
st.attentionText = fmt.Sprintf("attention: %s — %s", name, reason)
@@ -306,12 +308,20 @@ func (st *uiState) notifyAttention(childID, reason string) {
st.drawStatusLine()
}
func (st *uiState) scratchpadsChanged() {
st.chromeCacheMu.Lock()
st.sidebarCache = ""
st.chromeCacheMu.Unlock()
st.drawSidebar()
}
// OnChildSpawned auto-focuses the new child.
func (st *uiState) OnChildSpawned(c *Child) {
layout := st.layoutSnapshot()
st.mu.Lock()
st.focusedID = c.ID
st.focusedName = c.Name
renderer := newViewportRenderer(st.layoutSnapshot())
st.focusedName = c.DisplayName()
renderer := newViewportRenderer(layout)
st.renderer = renderer
palOpen := st.palette != nil
if palOpen {
@@ -343,17 +353,19 @@ func (st *uiState) OnChildSpawned(c *Child) {
// focused child.
func (st *uiState) OnChildExited(c *Child) {
st.lastExit.Store(int32(c.ExitCode()))
layout := st.layoutSnapshot()
renderEmpty := false
st.mu.Lock()
if c.ID == st.focusedID {
next := firstRunningTopLevel(st.sess.Children())
if next == nil {
st.focusedID = ""
st.focusedName = ""
st.renderEmptyStateLocked()
renderEmpty = true
} else {
st.focusedID = next.ID
st.focusedName = next.Name
st.renderer = newViewportRenderer(st.layoutSnapshot())
st.focusedName = next.DisplayName()
st.renderer = newViewportRenderer(layout)
}
}
if st.palette != nil {
@@ -362,8 +374,12 @@ func (st *uiState) OnChildExited(c *Child) {
st.palette.rebuild()
st.renderPaletteLocked()
}
repaint := st.focusedID != ""
st.mu.Unlock()
if st.focusedID != "" {
if renderEmpty {
st.renderEmptyState()
}
if repaint {
st.repaintFocused()
}
st.drawTabBar()
@@ -417,13 +433,16 @@ func (st *uiState) OnPTYOut(childID string, chunk []byte) {
// contained one of those escapes; when set, drop the sidebar cache
// so the next drawSidebar repaints over the clobber instead of
// hitting the cache and leaving the gap visible.
if renderer.TookScrollAction() {
scrolled := renderer.TookScrollAction()
if scrolled {
st.chromeCacheMu.Lock()
st.sidebarCache = ""
st.chromeCacheMu.Unlock()
}
st.drawTabBar()
st.drawSidebar()
if scrolled {
st.drawSidebar()
}
st.drawStatusLine()
}
@@ -559,15 +578,9 @@ func (st *uiState) drawStatusLine() {
// renderEmptyState is the SPEC §4 blank-canvas hint. Drawn whenever no
// child is focused.
func (st *uiState) renderEmptyState() {
st.mu.Lock()
defer st.mu.Unlock()
st.renderEmptyStateLocked()
}
func (st *uiState) renderEmptyStateLocked() {
layout := st.layoutSnapshot()
st.outMu.Lock()
defer st.outMu.Unlock()
layout := st.layoutSnapshot()
line := "Press Ctrl-K to spawn an agent or process"
row := int(layout.mainTop) + (int(layout.childRows()) / 2)
col := int(layout.mainLeft) + ((int(layout.childCols()) - len(line)) / 2)
@@ -897,10 +910,11 @@ func (st *uiState) closePalette(action paletteAction) {
st.repaintFocused()
return
}
layout := st.layoutSnapshot()
st.mu.Lock()
st.focusedID = action.childID
st.focusedName = c.Name
st.renderer = newViewportRenderer(st.layoutSnapshot())
st.focusedName = c.DisplayName()
st.renderer = newViewportRenderer(layout)
st.mu.Unlock()
st.repaintFocused()
st.drawTabBar()
@@ -953,10 +967,10 @@ func (st *uiState) flashTransient(msg string) {
// emulator grid; the padded snapshot is the source of truth for visible
// cells.
func (st *uiState) repaintFocused() {
layout := st.layoutSnapshot()
st.mu.Lock()
id := st.focusedID
renderer := st.renderer
layout := st.layoutLocked()
st.mu.Unlock()
if id == "" {
st.renderEmptyState()