Clear TODO backlog: --debug/--profile, codex selection, MCP orientation, perf
- Add --debug[=DIR] / --profile[=DIR] flags that write run artefacts
(patterm.log, events.jsonl, per-child raw PTY captures, CPU + heap
+ goroutine pprof) to a dir without polluting stdout/stderr.
- Strengthen vendor-TUI orientation in three places (MCP
initialize.instructions, the spawn_agent tool description, and
help('spawning')) to head off codex's habits of poking the Unix
socket via perl and shelling out to launch peers — both bypass
caller identity and produce orphaned top-level tabs.
- Fix click-and-drag text selection from alt-screen TUIs. Host SGR
mouse reporting now follows the focused child's screen side
instead of being permanently armed; alt-screen TUIs that need
mouse re-enable it themselves and the toggle is forwarded.
- Move drawSidebar() off the per-PTY-chunk hot path. Long claude
session resume was paying a full sidebar rebuild for every
scrolled chunk; the chrome ticker now drains a dirty flag at 60 Hz.
- Gate the per-chunk Title() CGO poll on a containsOSC scan so
codex/ratatui's many SGR-only chunks no longer pay a CGO call each.
This commit is contained in:
@@ -29,6 +29,11 @@ import (
|
||||
type Options struct {
|
||||
ProjectDir string
|
||||
ProjectKey string
|
||||
// DebugDir, when non-empty, enables verbose debug logging to
|
||||
// <DebugDir>/patterm.log and per-child raw PTY output capture to
|
||||
// <DebugDir>/<child-id>.raw. The dir is created if missing. Events
|
||||
// (spawn / exit / state change) land in <DebugDir>/events.jsonl.
|
||||
DebugDir string
|
||||
}
|
||||
|
||||
const keyCtrlK byte = 0x0b
|
||||
@@ -77,6 +82,22 @@ func Run(ctx context.Context, opts Options) error {
|
||||
|
||||
sess := NewSession(opts.ProjectDir, opts.ProjectKey)
|
||||
defer sess.Shutdown()
|
||||
|
||||
// Debug capture: when --debug=<dir> is set, write a verbose log
|
||||
// (patterm.log), per-child raw PTY output (<id>.raw), and a
|
||||
// JSONL event stream (events.jsonl). Installed before the TUI
|
||||
// listener so the very first OnChildSpawned / OnPTYOut event
|
||||
// is captured.
|
||||
if opts.DebugDir != "" {
|
||||
dc, err := openDebugCapture(opts.DebugDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("app: debug capture: %w", err)
|
||||
}
|
||||
os.Setenv("PATTERM_DEBUG_LOG", dc.LogPath())
|
||||
sess.Subscribe(dc)
|
||||
defer dc.Close()
|
||||
logf("debug capture enabled at %s", opts.DebugDir)
|
||||
}
|
||||
// Snapshot persisted processes BEFORE attaching the store: Spawn
|
||||
// mints fresh ids, so the old records would otherwise linger
|
||||
// alongside the new ones. Drop them up front; the restore loop
|
||||
@@ -248,11 +269,18 @@ func Run(ctx context.Context, opts Options) error {
|
||||
case <-st.chromeWake:
|
||||
case <-ticker.C:
|
||||
}
|
||||
if !st.chromeDirty.Swap(false) {
|
||||
chromeChanged := st.chromeDirty.Swap(false)
|
||||
sidebarChanged := st.sidebarDirty.Swap(false)
|
||||
if !chromeChanged && !sidebarChanged {
|
||||
continue
|
||||
}
|
||||
st.drawTabBar()
|
||||
st.drawStatusLine()
|
||||
if chromeChanged {
|
||||
st.drawTabBar()
|
||||
st.drawStatusLine()
|
||||
}
|
||||
if sidebarChanged {
|
||||
st.drawSidebar()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -372,7 +400,14 @@ type uiState struct {
|
||||
// sensitive paths (owner flip, attention, trust, focus change)
|
||||
// continue to call drawStatusLine / drawTabBar synchronously.
|
||||
chromeDirty atomic.Bool
|
||||
chromeWake chan struct{}
|
||||
// sidebarDirty defers sidebar repaints off the per-chunk hot path
|
||||
// in the same way. A long claude session resume — where every PTY
|
||||
// chunk scrolls the viewport — used to call drawSidebar()
|
||||
// synchronously per chunk, which dominated the resume's wall time
|
||||
// (hundreds of full-sidebar rebuilds for a frame that was almost
|
||||
// always cache-equal).
|
||||
sidebarDirty atomic.Bool
|
||||
chromeWake chan struct{}
|
||||
|
||||
// padsCacheMu guards the cached scratchpad listing. The sidebar
|
||||
// and palette/sidebar nav helpers read it on every chunk-driven
|
||||
@@ -415,14 +450,18 @@ func (st *uiState) focusProcess(processID string) {
|
||||
return
|
||||
}
|
||||
layout := st.layoutSnapshot()
|
||||
onAlt := childIsOnAlt(c)
|
||||
st.mu.Lock()
|
||||
leavingPad := st.focusedPad != ""
|
||||
st.focusedPad = ""
|
||||
st.focusedID = c.ID
|
||||
st.focusedName = c.DisplayName()
|
||||
st.updateActiveAgentLocked(c)
|
||||
st.renderer = newViewportRenderer(layout)
|
||||
r := newViewportRenderer(layout)
|
||||
r.SetChildOnAlt(onAlt)
|
||||
st.renderer = r
|
||||
st.mu.Unlock()
|
||||
st.syncHostMouseForChild(onAlt)
|
||||
// Wipe whatever the previous focus (PTY child or pad view) left in
|
||||
// the viewport before painting the new child's snapshot.
|
||||
if leavingPad {
|
||||
@@ -434,6 +473,41 @@ func (st *uiState) focusProcess(processID string) {
|
||||
st.drawStatusLine()
|
||||
}
|
||||
|
||||
// childIsOnAlt reports whether the child's emulator is currently on
|
||||
// its alternate screen. Returns false if the emulator is gone or the
|
||||
// query fails.
|
||||
func childIsOnAlt(c *Child) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
em := c.Emulator()
|
||||
if em == nil {
|
||||
return false
|
||||
}
|
||||
sc, err := em.ActiveScreen()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return sc == vt.ScreenAlternate
|
||||
}
|
||||
|
||||
// syncHostMouseForChild emits the host mouse-reporting toggle that
|
||||
// matches a newly-focused child's screen side. Primary-screen children
|
||||
// want host mouse armed so the wheel drives inline scrollback; alt-
|
||||
// screen children get host mouse disabled by default so click-and-drag
|
||||
// selection works. Alt-screen TUIs that need mouse (vim, ranger, etc.)
|
||||
// re-enable it themselves, and the viewport renderer forwards those
|
||||
// toggles back to the host.
|
||||
func (st *uiState) syncHostMouseForChild(onAlt bool) {
|
||||
st.outMu.Lock()
|
||||
defer st.outMu.Unlock()
|
||||
if onAlt {
|
||||
_, _ = os.Stdout.WriteString("\x1b[?1000l\x1b[?1006l")
|
||||
} else {
|
||||
_, _ = os.Stdout.WriteString("\x1b[?1000h\x1b[?1006h")
|
||||
}
|
||||
}
|
||||
|
||||
// focusScratchpad shifts focus to a scratchpad. The main viewport
|
||||
// renders the pad's text instead of any child PTY; PTY output for the
|
||||
// previously focused child is dropped until focus moves back to a
|
||||
@@ -572,12 +646,14 @@ func (st *uiState) scratchpadsChanged() {
|
||||
// OnChildSpawned auto-focuses the new child.
|
||||
func (st *uiState) OnChildSpawned(c *Child) {
|
||||
layout := st.layoutSnapshot()
|
||||
onAlt := childIsOnAlt(c)
|
||||
st.mu.Lock()
|
||||
st.focusedPad = ""
|
||||
st.focusedID = c.ID
|
||||
st.focusedName = c.DisplayName()
|
||||
st.updateActiveAgentLocked(c)
|
||||
renderer := newViewportRenderer(layout)
|
||||
renderer.SetChildOnAlt(onAlt)
|
||||
st.renderer = renderer
|
||||
palOpen := st.palette != nil
|
||||
if palOpen {
|
||||
@@ -611,6 +687,7 @@ func (st *uiState) OnChildSpawned(c *Child) {
|
||||
st.outMu.Unlock()
|
||||
}
|
||||
|
||||
st.syncHostMouseForChild(onAlt)
|
||||
st.moveToViewportOrigin()
|
||||
st.drawTabBar()
|
||||
st.drawSidebar()
|
||||
@@ -760,9 +837,14 @@ func (st *uiState) OnPTYOut(childID string, chunk []byte) {
|
||||
st.chromeCacheMu.Lock()
|
||||
st.sidebarCache = ""
|
||||
st.chromeCacheMu.Unlock()
|
||||
// Scrolled chunks can clobber the sidebar columns; repaint
|
||||
// synchronously so the gap fills before the next chunk lands.
|
||||
st.drawSidebar()
|
||||
// Defer the sidebar repaint to the chrome ticker. On a long
|
||||
// session resume every PTY chunk scrolls, and a synchronous
|
||||
// drawSidebar() per chunk dominates wall time even when the
|
||||
// frame ends up cache-equal — the rebuild work is unconditional.
|
||||
// The chrome ticker drains the dirty flag at ~60 Hz, so the
|
||||
// visible gap a scrolled chunk can leave in the sidebar columns
|
||||
// is bounded by one frame.
|
||||
st.markSidebarDirty()
|
||||
}
|
||||
// Defer the tab bar + status line repaint to the chrome ticker.
|
||||
// The cached frame already short-circuits the wire write, but
|
||||
@@ -866,6 +948,18 @@ func (st *uiState) markChromeDirty() {
|
||||
}
|
||||
}
|
||||
|
||||
// markSidebarDirty schedules a sidebar repaint on the next ticker
|
||||
// frame. Hot path — every scrolled PTY chunk lands here. Synchronous
|
||||
// repaints from latency-sensitive sites (spawn, exit, focus, state
|
||||
// change, trust) keep calling drawSidebar directly.
|
||||
func (st *uiState) markSidebarDirty() {
|
||||
st.sidebarDirty.Store(true)
|
||||
select {
|
||||
case st.chromeWake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (st *uiState) invalidateChromeCache() {
|
||||
st.chromeCacheMu.Lock()
|
||||
st.tabBarCache = ""
|
||||
|
||||
Reference in New Issue
Block a user