Work through TODO fixes

This commit is contained in:
2026-05-21 15:45:01 +01:00
parent c1b66f9f8a
commit f61788eff2
12 changed files with 540 additions and 33 deletions

View File

@@ -326,6 +326,15 @@ func Run(ctx context.Context, opts Options) error {
}
}()
// Timer sidebar refresher: countdown labels are computed at draw
// time, so wake the sidebar when the next visible timer bucket is
// due to change even if no child PTY output arrives.
wg.Add(1)
go func() {
defer wg.Done()
st.runTimerSidebarRefresher(ctx)
}()
// Marquee ticker: while a focused sidebar row's name overflows the
// rail width, advance the pause-scroll-pause animation by marking
// the sidebar dirty every marqueeStep. The chrome ticker above does
@@ -671,6 +680,20 @@ func (st *uiState) clearViewportArea() {
_, _ = os.Stdout.WriteString(b.String())
}
func (st *uiState) repaintFocusedWithChrome() {
st.mu.Lock()
padFocused := st.focusedPad != ""
st.mu.Unlock()
if padFocused {
st.repaintFocusedPad()
} else {
st.repaintFocused()
}
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
func (st *uiState) restartFocusedCommand(processID string) {
c := st.sess.FindChild(processID)
if c == nil || c.Kind != KindCommand {
@@ -687,14 +710,18 @@ func (st *uiState) restartFocusedCommand(processID string) {
st.repaintNextPTYBudget = 2
st.mu.Unlock()
st.outMu.Lock()
_, _ = os.Stdout.Write(renderer.ClearViewport())
st.outMu.Unlock()
st.repaintFocusedWithChrome()
if err := st.sess.Restart(c.ID, syscall.SIGTERM, layout.childCols(), layout.childRows()); err != nil {
st.flashError(fmt.Sprintf("restart %s: %v", c.DisplayName(), err))
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
return
}
st.outMu.Lock()
_, _ = os.Stdout.Write(renderer.ClearViewport())
st.outMu.Unlock()
st.moveToViewportOrigin()
st.drawTabBar()
st.drawSidebar()
@@ -741,12 +768,7 @@ func (st *uiState) notifyAttention(childID, reason string) {
}
func (st *uiState) scratchpadsChanged() {
st.padsCacheMu.Lock()
st.padsCache = nil
st.padsCacheMu.Unlock()
st.chromeCacheMu.Lock()
st.sidebarCache = ""
st.chromeCacheMu.Unlock()
st.invalidateScratchpadsCache()
st.drawSidebar()
st.mu.Lock()
focusedPad := st.focusedPad
@@ -756,6 +778,15 @@ func (st *uiState) scratchpadsChanged() {
}
}
func (st *uiState) invalidateScratchpadsCache() {
st.padsCacheMu.Lock()
st.padsCache = nil
st.padsCacheMu.Unlock()
st.chromeCacheMu.Lock()
st.sidebarCache = ""
st.chromeCacheMu.Unlock()
}
// OnChildSpawned auto-focuses the new child when the spawn came from
// the user (palette, persistence restore, or an external MCP client with
// no resolved identity). When ParentID is set — meaning a patterm-managed
@@ -1143,6 +1174,55 @@ func (st *uiState) markSidebarDirty() {
}
}
func (st *uiState) runTimerSidebarRefresher(ctx context.Context) {
if st.timers == nil {
<-ctx.Done()
return
}
changes := st.timers.changeEvents()
var timer *time.Timer
var timerC <-chan time.Time
stop := func() {
if timer == nil {
return
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer = nil
timerC = nil
}
arm := func() {
stop()
wait, ok := st.timers.nextSidebarRefreshAfter(time.Now())
if !ok {
return
}
if wait < timerSidebarMinRefresh {
wait = timerSidebarMinRefresh
}
timer = time.NewTimer(wait)
timerC = timer.C
}
defer stop()
arm()
for {
select {
case <-ctx.Done():
return
case <-changes:
st.markSidebarDirty()
arm()
case <-timerC:
st.markSidebarDirty()
arm()
}
}
}
func (st *uiState) invalidateChromeCache() {
st.chromeCacheMu.Lock()
st.tabBarCache = ""
@@ -1433,9 +1513,10 @@ func (st *uiState) processStdin(chunk []byte) {
if st.focusedID != "" {
if c := st.sess.FindChild(st.focusedID); c != nil && c.Status() == StatusRunning {
prev := c.Owner()
// InjectAsUser splits Enter bytes onto their own
// writes so claude / codex / opencode don't treat a
// "text\r" batch as a paste.
// Agent panes split Enter bytes onto their own writes
// so claude / codex / opencode don't treat a
// "text\r" batch as a paste. Raw terminals keep paste
// bytes batched.
_ = c.InjectAsUser(forward)
if st.summaries != nil {
st.summaries.ObserveHumanInput(c.ID, forward)
@@ -2131,20 +2212,47 @@ func (st *uiState) handlePadDelete(name string) {
st.repaintFocused()
return
}
st.mu.Lock()
wasFocused := st.focusedPad == name
st.mu.Unlock()
if err := st.pads.Delete(name); err != nil {
st.flashError(fmt.Sprintf("delete %s: %v", name, err))
return
}
st.mu.Lock()
if st.focusedPad == name {
if wasFocused {
st.invalidateScratchpadsCache()
if entries := st.padsList(); len(entries) > 0 {
next := entries[0].Name
st.mu.Lock()
st.focusedPad = next
st.focusedID = ""
st.focusedName = next
if st.padOffsetName != next {
st.padOffset = 0
st.padOffsetName = next
}
st.mu.Unlock()
st.repaintFocusedWithChrome()
return
}
if next := firstRunningTopLevel(st.sess.Children()); next != nil {
st.focusProcess(next.ID)
return
}
st.mu.Lock()
st.focusedPad = ""
st.focusedName = ""
st.padOffset = 0
st.padOffsetName = ""
st.mu.Unlock()
st.renderEmptyState()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
return
}
st.mu.Unlock()
st.scratchpadsChanged()
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
st.repaintFocusedWithChrome()
}
func (st *uiState) handlePadRename(oldName, newName string) {
@@ -2293,8 +2401,19 @@ func (st *uiState) handleProcRestart(childID string) {
return
}
layout := st.layoutSnapshot()
st.mu.Lock()
if c.ID == st.focusedID {
st.renderer = newViewportRenderer(layout)
st.repaintNextPTY = c.ID
st.repaintNextPTYBudget = 2
}
st.mu.Unlock()
st.repaintFocusedWithChrome()
if err := st.sess.Restart(childID, syscall.SIGTERM, layout.childCols(), layout.childRows()); err != nil {
st.flashError(fmt.Sprintf("restart %s: %v", c.DisplayName(), err))
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
return
}
st.repaintFocused()