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

1
.gitignore vendored
View File

@@ -7,4 +7,5 @@ spike-report-*.txt
/bin/ /bin/
/spike /spike
/.worktrees/ /.worktrees/
/.claude/worktrees/
internal/harness/.artifacts/ internal/harness/.artifacts/

View File

@@ -6,6 +6,23 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Fixed
- Sidebar timer indicators now repaint as their visible countdown
value changes, so labels progress from minutes to seconds without
waiting for unrelated terminal output or focus changes.
- Raw terminal focused actions now show a single `Close` row instead
of separate stop/delete-style lifecycle choices that did the same
thing for ephemeral terminal panes.
- Restarting a process from the palette now restores the focused pane
and host chrome before waiting for the old process to exit, so the
tab bar and sidebar do not disappear during slow restarts.
- Deleting the focused scratchpad now moves focus to another
scratchpad when one exists, or back to a running terminal/agent
instead of dropping into the empty state.
- Multiline paste into raw terminal and command panes no longer pays
the agent-specific per-Enter delay, making large pasted input arrive
as one PTY write outside Claude/Codex/OpenCode panes.
## [0.0.7] - 2026-05-18 ## [0.0.7] - 2026-05-18
### Added ### Added

View File

@@ -0,0 +1,5 @@
- [ ] When opening a codex sub agent, the message gets input to the field, but the message is never submitted.
- This appears to be inconsistent. Sometimes it works, sometimes it doesn't. Might be because of popups on codex sub agents?
- Question: when it fails, is a Codex startup popup visible (trust/workspace, auth/model selection, permissions), or is the normal composer focused?
- Question: if the message is sitting in the composer, does pressing Enter once manually submit it, or does something else need to be dismissed first?
- Question: does this happen with short one-line prompts as well as long/multiline sub-agent instructions?

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 // Marquee ticker: while a focused sidebar row's name overflows the
// rail width, advance the pause-scroll-pause animation by marking // rail width, advance the pause-scroll-pause animation by marking
// the sidebar dirty every marqueeStep. The chrome ticker above does // the sidebar dirty every marqueeStep. The chrome ticker above does
@@ -671,6 +680,20 @@ func (st *uiState) clearViewportArea() {
_, _ = os.Stdout.WriteString(b.String()) _, _ = 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) { func (st *uiState) restartFocusedCommand(processID string) {
c := st.sess.FindChild(processID) c := st.sess.FindChild(processID)
if c == nil || c.Kind != KindCommand { if c == nil || c.Kind != KindCommand {
@@ -687,14 +710,18 @@ func (st *uiState) restartFocusedCommand(processID string) {
st.repaintNextPTYBudget = 2 st.repaintNextPTYBudget = 2
st.mu.Unlock() st.mu.Unlock()
st.outMu.Lock() st.repaintFocusedWithChrome()
_, _ = os.Stdout.Write(renderer.ClearViewport())
st.outMu.Unlock()
if err := st.sess.Restart(c.ID, syscall.SIGTERM, layout.childCols(), layout.childRows()); err != nil { 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.flashError(fmt.Sprintf("restart %s: %v", c.DisplayName(), err))
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
return return
} }
st.outMu.Lock()
_, _ = os.Stdout.Write(renderer.ClearViewport())
st.outMu.Unlock()
st.moveToViewportOrigin() st.moveToViewportOrigin()
st.drawTabBar() st.drawTabBar()
st.drawSidebar() st.drawSidebar()
@@ -741,12 +768,7 @@ func (st *uiState) notifyAttention(childID, reason string) {
} }
func (st *uiState) scratchpadsChanged() { func (st *uiState) scratchpadsChanged() {
st.padsCacheMu.Lock() st.invalidateScratchpadsCache()
st.padsCache = nil
st.padsCacheMu.Unlock()
st.chromeCacheMu.Lock()
st.sidebarCache = ""
st.chromeCacheMu.Unlock()
st.drawSidebar() st.drawSidebar()
st.mu.Lock() st.mu.Lock()
focusedPad := st.focusedPad 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 // OnChildSpawned auto-focuses the new child when the spawn came from
// the user (palette, persistence restore, or an external MCP client with // the user (palette, persistence restore, or an external MCP client with
// no resolved identity). When ParentID is set — meaning a patterm-managed // 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() { func (st *uiState) invalidateChromeCache() {
st.chromeCacheMu.Lock() st.chromeCacheMu.Lock()
st.tabBarCache = "" st.tabBarCache = ""
@@ -1433,9 +1513,10 @@ func (st *uiState) processStdin(chunk []byte) {
if st.focusedID != "" { if st.focusedID != "" {
if c := st.sess.FindChild(st.focusedID); c != nil && c.Status() == StatusRunning { if c := st.sess.FindChild(st.focusedID); c != nil && c.Status() == StatusRunning {
prev := c.Owner() prev := c.Owner()
// InjectAsUser splits Enter bytes onto their own // Agent panes split Enter bytes onto their own writes
// writes so claude / codex / opencode don't treat a // so claude / codex / opencode don't treat a
// "text\r" batch as a paste. // "text\r" batch as a paste. Raw terminals keep paste
// bytes batched.
_ = c.InjectAsUser(forward) _ = c.InjectAsUser(forward)
if st.summaries != nil { if st.summaries != nil {
st.summaries.ObserveHumanInput(c.ID, forward) st.summaries.ObserveHumanInput(c.ID, forward)
@@ -2131,20 +2212,47 @@ func (st *uiState) handlePadDelete(name string) {
st.repaintFocused() st.repaintFocused()
return return
} }
st.mu.Lock()
wasFocused := st.focusedPad == name
st.mu.Unlock()
if err := st.pads.Delete(name); err != nil { if err := st.pads.Delete(name); err != nil {
st.flashError(fmt.Sprintf("delete %s: %v", name, err)) st.flashError(fmt.Sprintf("delete %s: %v", name, err))
return return
} }
st.mu.Lock() if wasFocused {
if st.focusedPad == name { 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.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.scratchpadsChanged()
st.repaintFocused() st.repaintFocusedWithChrome()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
} }
func (st *uiState) handlePadRename(oldName, newName string) { func (st *uiState) handlePadRename(oldName, newName string) {
@@ -2293,8 +2401,19 @@ func (st *uiState) handleProcRestart(childID string) {
return return
} }
layout := st.layoutSnapshot() 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 { 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.flashError(fmt.Sprintf("restart %s: %v", c.DisplayName(), err))
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
return return
} }
st.repaintFocused() st.repaintFocused()

View File

@@ -625,18 +625,18 @@ func (c *Child) InjectAsOrchestrator(b []byte) error {
} }
// writeInput is the shared PTY write path used by both injection // writeInput is the shared PTY write path used by both injection
// flavours. Each Enter byte (CR or LF) is split onto its own write // flavours. Agent panes split each Enter byte (CR or LF) onto its own
// with a brief delay so TUI agents with paste-detection (claude, // write with a brief delay so TUI agents with paste-detection (claude,
// codex, opencode) don't coalesce a trailing CR into the text that // codex, opencode) don't coalesce a trailing CR into the text that
// preceded it. Without the split, `pty.Write([]byte("hello\r"))` // preceded it. Raw terminals and command panes receive the original
// arrives at the agent as one read() and gets treated as multi-line // byte stream in one write; otherwise a multiline paste pays the agent
// pasted content rather than "key Enter". // workaround's delay once per line.
func (c *Child) writeInput(b []byte) error { func (c *Child) writeInput(b []byte) error {
pty := c.PTY() pty := c.PTY()
if pty == nil { if pty == nil {
return errors.New("child has no pty") return errors.New("child has no pty")
} }
pieces := splitOnEnter(b) pieces := inputWritePieces(c.Kind, b)
if len(pieces) <= 1 { if len(pieces) <= 1 {
_, err := pty.Write(b) _, err := pty.Write(b)
return err return err
@@ -652,6 +652,13 @@ func (c *Child) writeInput(b []byte) error {
return nil return nil
} }
func inputWritePieces(kind ChildKind, b []byte) [][]byte {
if kind != KindAgent {
return [][]byte{b}
}
return splitOnEnter(b)
}
func mintIdentity() string { func mintIdentity() string {
var buf [12]byte var buf [12]byte
_, _ = rand.Read(buf[:]) _, _ = rand.Read(buf[:])

View File

@@ -0,0 +1,29 @@
package app
import (
"bytes"
"testing"
)
func TestInputWritePiecesOnlySplitAgentEnters(t *testing.T) {
in := []byte("alpha\nbeta\rgamma")
for _, kind := range []ChildKind{KindTerminal, KindCommand} {
t.Run(string(kind), func(t *testing.T) {
got := inputWritePieces(kind, in)
if len(got) != 1 || !bytes.Equal(got[0], in) {
t.Fatalf("inputWritePieces(%s) = %#v, want one original chunk", kind, got)
}
})
}
got := inputWritePieces(KindAgent, in)
if len(got) != 5 {
t.Fatalf("agent pieces len = %d, want 5 (%#v)", len(got), got)
}
want := [][]byte{[]byte("alpha"), []byte("\n"), []byte("beta"), []byte("\r"), []byte("gamma")}
for i := range want {
if !bytes.Equal(got[i], want[i]) {
t.Fatalf("agent piece %d = %q, want %q", i, got[i], want[i])
}
}
}

View File

@@ -270,6 +270,15 @@ func (p *paletteState) buildItems(macro string) []paletteItem {
paletteItem{label: "Close", hint: "close agent · " + name + " (SIGTERM)", paletteItem{label: "Close", hint: "close agent · " + name + " (SIGTERM)",
action: paletteAction{kind: "agent-close", childID: c.ID}, group: groupFocused}, action: paletteAction{kind: "agent-close", childID: c.ID}, group: groupFocused},
) )
case KindTerminal:
out = append(out,
paletteItem{label: "Rename", hint: "rename terminal · " + name,
action: paletteAction{kind: "proc-rename-form", childID: c.ID}, group: groupFocused},
paletteItem{label: "Close", hint: "close terminal · " + name + " (SIGTERM)",
action: paletteAction{kind: "proc-stop", childID: c.ID}, group: groupFocused},
paletteItem{label: "Restart", hint: "restart terminal · " + name,
action: paletteAction{kind: "proc-restart", childID: c.ID}, group: groupFocused},
)
default: default:
out = append(out, out = append(out,
paletteItem{label: "Rename", hint: "rename process · " + name, paletteItem{label: "Rename", hint: "rename process · " + name,

View File

@@ -83,6 +83,25 @@ func TestContextItemsProcess(t *testing.T) {
} }
} }
func TestContextItemsTerminalUsesCloseNotStop(t *testing.T) {
c := makeFakeChild("tid", "terminal", KindTerminal)
p := newPalette([]*Child{c}, "tid", "", preset.Set{})
if _, it := findItem(p, "proc-stop"); it == nil || it.label != "Close" {
t.Fatalf("terminal close row missing or mislabelled: %+v", it)
}
if _, it := findItem(p, "proc-restart"); it == nil {
t.Fatalf("terminal restart row missing")
}
if i, _ := findItem(p, "proc-delete"); i != -1 {
t.Fatalf("terminal should not show a separate delete/close row, found at %d", i)
}
for i, it := range p.items {
if it.label == "Stop" {
t.Fatalf("terminal should not show Stop row, found at %d", i)
}
}
}
func TestContextItemsAppearAboveSwitch(t *testing.T) { func TestContextItemsAppearAboveSwitch(t *testing.T) {
// Two children so there's still a non-focused switch entry to compare // Two children so there's still a non-focused switch entry to compare
// against (the focused child is suppressed from the Open section). // against (the focused child is suppressed from the Open section).

View File

@@ -0,0 +1,97 @@
package app
import (
"io"
"os"
"testing"
"github.com/hjbdev/patterm/internal/scratchpad"
)
func silenceStdout(t *testing.T) {
t.Helper()
old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe stdout: %v", err)
}
done := make(chan struct{})
go func() {
_, _ = io.Copy(io.Discard, r)
close(done)
}()
os.Stdout = w
t.Cleanup(func() {
os.Stdout = old
_ = w.Close()
<-done
_ = r.Close()
})
}
func newScratchpadDeleteTestState(t *testing.T) (*uiState, *scratchpad.Store) {
t.Helper()
t.Setenv("XDG_DATA_HOME", t.TempDir())
pads, err := scratchpad.Open("scratchpad-delete-test")
if err != nil {
t.Fatalf("scratchpad.Open: %v", err)
}
sess := NewSession(t.TempDir(), "scratchpad-delete-test")
t.Cleanup(sess.Shutdown)
st := &uiState{
sess: sess,
pads: pads,
hostCols: 120,
hostRows: 40,
chromeWake: make(chan struct{}, 1),
}
return st, pads
}
func TestDeletingFocusedScratchpadFocusesAnotherPad(t *testing.T) {
silenceStdout(t)
st, pads := newScratchpadDeleteTestState(t)
if _, err := pads.Write("alpha.md", "alpha", ""); err != nil {
t.Fatalf("write alpha: %v", err)
}
if _, err := pads.Write("beta.md", "beta", ""); err != nil {
t.Fatalf("write beta: %v", err)
}
st.focusedPad = "alpha.md"
st.focusedName = "alpha.md"
st.padOffsetName = "alpha.md"
st.padOffset = 3
st.handlePadDelete("alpha.md")
if st.focusedPad != "beta.md" {
t.Fatalf("focusedPad = %q, want beta.md", st.focusedPad)
}
if st.focusedID != "" {
t.Fatalf("focusedID = %q, want empty while another pad is focused", st.focusedID)
}
if st.padOffset != 0 || st.padOffsetName != "beta.md" {
t.Fatalf("pad offset = (%q,%d), want (beta.md,0)", st.padOffsetName, st.padOffset)
}
}
func TestDeletingLastFocusedScratchpadFocusesRunningChild(t *testing.T) {
silenceStdout(t)
st, pads := newScratchpadDeleteTestState(t)
if _, err := pads.Write("only.md", "only", ""); err != nil {
t.Fatalf("write only: %v", err)
}
child := makeFakeChild("pid", "devserver", KindCommand)
addChild(st.sess, child)
st.focusedPad = "only.md"
st.focusedName = "only.md"
st.handlePadDelete("only.md")
if st.focusedPad != "" {
t.Fatalf("focusedPad = %q, want empty after falling back to child", st.focusedPad)
}
if st.focusedID != "pid" {
t.Fatalf("focusedID = %q, want pid", st.focusedID)
}
}

View File

@@ -55,9 +55,10 @@ type pendingTimer struct {
type timerManager struct { type timerManager struct {
sess *Session sess *Session
mu sync.Mutex mu sync.Mutex
nextID int nextID int
timers map[string]*pendingTimer timers map[string]*pendingTimer
changes chan struct{}
// fireFn is the callback used to deliver the body to the owning // fireFn is the callback used to deliver the body to the owning
// process. Decoupled so tests can substitute a recorder. Defaults // process. Decoupled so tests can substitute a recorder. Defaults
@@ -67,13 +68,25 @@ type timerManager struct {
func newTimerManager(sess *Session) *timerManager { func newTimerManager(sess *Session) *timerManager {
m := &timerManager{ m := &timerManager{
sess: sess, sess: sess,
timers: make(map[string]*pendingTimer), timers: make(map[string]*pendingTimer),
changes: make(chan struct{}, 1),
} }
m.fireFn = defaultFireFn m.fireFn = defaultFireFn
return m return m
} }
func (m *timerManager) changeEvents() <-chan struct{} {
return m.changes
}
func (m *timerManager) notifyChanged() {
select {
case m.changes <- struct{}{}:
default:
}
}
func defaultFireFn(owner *Child, body, label string) { func defaultFireFn(owner *Child, body, label string) {
if owner == nil || !owner.IsLive() { if owner == nil || !owner.IsLive() {
return return
@@ -121,6 +134,7 @@ func (m *timerManager) TimerSet(ownerID string, body, label string, seconds floa
m.timers[id] = t m.timers[id] = t
m.mu.Unlock() m.mu.Unlock()
t.rt = time.AfterFunc(d, func() { m.fireDelay(id) }) t.rt = time.AfterFunc(d, func() { m.fireDelay(id) })
m.notifyChanged()
return id, nil return id, nil
} }
@@ -136,6 +150,7 @@ func (m *timerManager) fireDelay(id string) {
body, label := t.body, t.label body, label := t.body, t.label
delete(m.timers, id) delete(m.timers, id)
m.mu.Unlock() m.mu.Unlock()
m.notifyChanged()
m.fireFn(owner, body, label) m.fireFn(owner, body, label)
} }
@@ -214,6 +229,7 @@ func (m *timerManager) registerIdleTimer(kind pendingTimerKind, ownerID, body, l
} }
m.timers[id] = t m.timers[id] = t
m.mu.Unlock() m.mu.Unlock()
m.notifyChanged()
resp.ID = id resp.ID = id
resp.Status = "pending" resp.Status = "pending"
return resp, nil return resp, nil
@@ -231,6 +247,7 @@ func (m *timerManager) fireIdleMaxWait(id string) {
body, label := t.body, t.label body, label := t.body, t.label
delete(m.timers, id) delete(m.timers, id)
m.mu.Unlock() m.mu.Unlock()
m.notifyChanged()
m.fireFn(owner, body, label) m.fireFn(owner, body, label)
} }
@@ -291,6 +308,9 @@ func (m *timerManager) onChildStateChanged(childID string, state IdleState) {
delete(m.timers, id) delete(m.timers, id)
} }
m.mu.Unlock() m.mu.Unlock()
if len(firedIDs) > 0 {
m.notifyChanged()
}
for _, f := range fires { for _, f := range fires {
m.fireFn(f.owner, f.body, f.label) m.fireFn(f.owner, f.body, f.label)
} }
@@ -320,7 +340,7 @@ func (m *timerManager) onChildStateChanged(childID string, state IdleState) {
// legitimate fire and leave the parent never notified. // legitimate fire and leave the parent never notified.
func (m *timerManager) onChildClosed(childID string) { func (m *timerManager) onChildClosed(childID string) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() changed := false
for id, t := range m.timers { for id, t := range m.timers {
if t.ownerID == childID { if t.ownerID == childID {
if t.rt != nil { if t.rt != nil {
@@ -329,6 +349,7 @@ func (m *timerManager) onChildClosed(childID string) {
} }
t.status = timerStatusCanceled t.status = timerStatusCanceled
delete(m.timers, id) delete(m.timers, id)
changed = true
continue continue
} }
if !contains(t.watched, childID) { if !contains(t.watched, childID) {
@@ -344,6 +365,7 @@ func (m *timerManager) onChildClosed(childID string) {
if t.idleBaseline != nil { if t.idleBaseline != nil {
delete(t.idleBaseline, childID) delete(t.idleBaseline, childID)
} }
changed = true
if len(t.watched) == 0 { if len(t.watched) == 0 {
if t.rt != nil { if t.rt != nil {
t.rt.Stop() t.rt.Stop()
@@ -353,6 +375,10 @@ func (m *timerManager) onChildClosed(childID string) {
delete(m.timers, id) delete(m.timers, id)
} }
} }
m.mu.Unlock()
if changed {
m.notifyChanged()
}
} }
// allWatchedIdleLocked reports whether every watched child is now // allWatchedIdleLocked reports whether every watched child is now
@@ -374,19 +400,21 @@ func (m *timerManager) allWatchedIdleLocked(t *pendingTimer) bool {
// TimerCancel removes a pending or paused timer owned by ownerID. // TimerCancel removes a pending or paused timer owned by ownerID.
func (m *timerManager) TimerCancel(ownerID, id string) error { func (m *timerManager) TimerCancel(ownerID, id string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock()
t, ok := m.timers[id] t, ok := m.timers[id]
if !ok { if !ok {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id) return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id)
} }
// Empty ownerID = top-level orchestrator caller (e.g. a non-agent // Empty ownerID = top-level orchestrator caller (e.g. a non-agent
// MCP client); allow it to manage every timer in the session. // MCP client); allow it to manage every timer in the session.
// Otherwise the caller's own id must match the timer's owner. // Otherwise the caller's own id must match the timer's owner.
if ownerID != "" && t.ownerID != ownerID { if ownerID != "" && t.ownerID != ownerID {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id) return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id)
} }
if t.status == timerStatusFired || t.status == timerStatusCanceled { if t.status == timerStatusFired || t.status == timerStatusCanceled {
// Cancelling a fired/cancelled timer is idempotent. // Cancelling a fired/cancelled timer is idempotent.
m.mu.Unlock()
return nil return nil
} }
if t.rt != nil { if t.rt != nil {
@@ -395,6 +423,8 @@ func (m *timerManager) TimerCancel(ownerID, id string) error {
} }
t.status = timerStatusCanceled t.status = timerStatusCanceled
delete(m.timers, id) delete(m.timers, id)
m.mu.Unlock()
m.notifyChanged()
return nil return nil
} }
@@ -402,18 +432,20 @@ func (m *timerManager) TimerCancel(ownerID, id string) error {
// keeps the timer in the registry. // keeps the timer in the registry.
func (m *timerManager) TimerPause(ownerID, id string) error { func (m *timerManager) TimerPause(ownerID, id string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock()
t, ok := m.timers[id] t, ok := m.timers[id]
if !ok { if !ok {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id) return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id)
} }
// Empty ownerID = top-level orchestrator caller (e.g. a non-agent // Empty ownerID = top-level orchestrator caller (e.g. a non-agent
// MCP client); allow it to manage every timer in the session. // MCP client); allow it to manage every timer in the session.
// Otherwise the caller's own id must match the timer's owner. // Otherwise the caller's own id must match the timer's owner.
if ownerID != "" && t.ownerID != ownerID { if ownerID != "" && t.ownerID != ownerID {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id) return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id)
} }
if t.status != timerStatusPending { if t.status != timerStatusPending {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer %q is not pending", id) return mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer %q is not pending", id)
} }
if t.rt != nil { if t.rt != nil {
@@ -429,6 +461,8 @@ func (m *timerManager) TimerPause(ownerID, id string) error {
t.pausedWasMaxWait = t.kind != timerKindDelay t.pausedWasMaxWait = t.kind != timerKindDelay
} }
t.status = timerStatusPaused t.status = timerStatusPaused
m.mu.Unlock()
m.notifyChanged()
return nil return nil
} }
@@ -507,6 +541,7 @@ func (m *timerManager) TimerResume(ownerID, id string) error {
delete(m.timers, id) delete(m.timers, id)
} }
m.mu.Unlock() m.mu.Unlock()
m.notifyChanged()
if fireNow { if fireNow {
m.fireFn(owner, body, label) m.fireFn(owner, body, label)
} }
@@ -587,6 +622,56 @@ func (m *timerManager) activeForChild(id string) *mcp.TimerInfo {
return &info return &info
} }
const (
timerSidebarMinRefresh = 50 * time.Millisecond
timerSidebarSubsecondRefresh = 100 * time.Millisecond
)
func nextTimerSidebarLabelChange(d time.Duration) time.Duration {
if d <= 0 {
return 0
}
if d < time.Second {
if d < timerSidebarSubsecondRefresh {
return d
}
return timerSidebarSubsecondRefresh
}
step := time.Second
if d >= time.Hour {
step = time.Hour
} else if d >= time.Minute {
step = time.Minute
}
wait := d % step
if wait <= 0 || wait < timerSidebarMinRefresh {
return timerSidebarMinRefresh
}
return wait
}
func (m *timerManager) nextSidebarRefreshAfter(now time.Time) (time.Duration, bool) {
m.mu.Lock()
defer m.mu.Unlock()
var best time.Duration
found := false
for _, t := range m.timers {
if t.status != timerStatusPending || t.firesAt.IsZero() {
continue
}
wait := nextTimerSidebarLabelChange(t.firesAt.Sub(now))
if wait <= 0 {
wait = timerSidebarMinRefresh
}
if !found || wait < best {
best = wait
found = true
}
}
return best, found
}
func isIdleState(s IdleState) bool { func isIdleState(s IdleState) bool {
return s == StateIdle return s == StateIdle
} }

View File

@@ -65,6 +65,93 @@ func newTestManager(t *testing.T) (*Session, *timerManager, *recorderFire) {
return sess, mgr, rec return sess, mgr, rec
} }
func waitTimerChange(t *testing.T, mgr *timerManager) {
t.Helper()
select {
case <-mgr.changeEvents():
case <-time.After(time.Second):
t.Fatal("timed out waiting for timer change signal")
}
}
func TestNextTimerSidebarLabelChange(t *testing.T) {
tests := []struct {
name string
d time.Duration
want time.Duration
}{
{name: "minutes", d: 2*time.Minute + 10*time.Second, want: 10 * time.Second},
{name: "minute_to_seconds", d: time.Minute + 500*time.Millisecond, want: 500 * time.Millisecond},
{name: "seconds", d: 59*time.Second + 500*time.Millisecond, want: 500 * time.Millisecond},
{name: "subsecond", d: 500 * time.Millisecond, want: timerSidebarSubsecondRefresh},
{name: "nearly_done", d: 30 * time.Millisecond, want: 30 * time.Millisecond},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := nextTimerSidebarLabelChange(tt.d); got != tt.want {
t.Fatalf("nextTimerSidebarLabelChange(%s) = %s, want %s", tt.d, got, tt.want)
}
})
}
}
func TestTimerSidebarRefreshAfterUsesSoonestActiveBoundary(t *testing.T) {
_, mgr, _ := newTestManager(t)
now := time.Unix(123, 0)
mgr.mu.Lock()
mgr.timers["slow"] = &pendingTimer{
id: "slow",
status: timerStatusPending,
firesAt: now.Add(2*time.Minute + 10*time.Second),
}
mgr.timers["fast"] = &pendingTimer{
id: "fast",
status: timerStatusPending,
firesAt: now.Add(59*time.Second + 500*time.Millisecond),
}
mgr.timers["paused"] = &pendingTimer{
id: "paused",
status: timerStatusPaused,
firesAt: now.Add(100 * time.Millisecond),
}
mgr.mu.Unlock()
got, ok := mgr.nextSidebarRefreshAfter(now)
if !ok {
t.Fatal("nextSidebarRefreshAfter did not find active timers")
}
if got != 500*time.Millisecond {
t.Fatalf("nextSidebarRefreshAfter = %s, want 500ms", got)
}
}
func TestTimerManagerSignalsChangesForSidebar(t *testing.T) {
sess, mgr, _ := newTestManager(t)
owner := fakeChild("p_owner")
addChild(sess, owner)
id, err := mgr.TimerSet("p_owner", "x", "", 60)
if err != nil {
t.Fatalf("TimerSet: %v", err)
}
waitTimerChange(t, mgr)
if err := mgr.TimerPause("p_owner", id); err != nil {
t.Fatalf("TimerPause: %v", err)
}
waitTimerChange(t, mgr)
if err := mgr.TimerResume("p_owner", id); err != nil {
t.Fatalf("TimerResume: %v", err)
}
waitTimerChange(t, mgr)
if err := mgr.TimerCancel("p_owner", id); err != nil {
t.Fatalf("TimerCancel: %v", err)
}
waitTimerChange(t, mgr)
}
func TestTimerSetDelivers(t *testing.T) { func TestTimerSetDelivers(t *testing.T) {
sess, mgr, rec := newTestManager(t) sess, mgr, rec := newTestManager(t)
c := fakeChild("p_owner") c := fakeChild("p_owner")

View File

@@ -0,0 +1,32 @@
{
"name": "restart_process_keeps_chrome",
"cols": 120,
"rows": 40,
"scripts": [
{
"name": "slow-restart",
"body": "#!/bin/sh\ncount_file=\"$XDG_RUNTIME_DIR/slow-restart-count\"\nif [ -f \"$count_file\" ]; then\n n=$(cat \"$count_file\")\nelse\n n=0\nfi\nn=$((n + 1))\nprintf '%s\\n' \"$n\" > \"$count_file\"\nprintf 'SLOW READY %s\\n' \"$n\"\ntrap 'sleep 3; exit 0' TERM\nwhile true; do sleep 1; done\n"
}
],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": { "kind": "command", "argv": ["slow-restart"], "name": "slow-restart" },
"save_as": "spawned"
},
{
"type": "mcp_call",
"method": "select_process",
"params": { "process_id": "{{spawned.process_id}}" }
},
{ "type": "wait_text", "contains": "SLOW READY 1", "timeout_ms": 5000 },
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "assert_contains", "contains": "Processes" },
{ "type": "send_text", "text": "\u000brestart\r" },
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "assert_contains", "contains": "Processes" },
{ "type": "assert_contains", "contains": "slow-restart" },
{ "type": "wait_text", "contains": "SLOW READY 2", "timeout_ms": 7000 }
]
}