From f61788eff21e169127d838d9a21fe6fb8f6f24dd Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Thu, 21 May 2026 15:45:01 +0100 Subject: [PATCH] Work through TODO fixes --- .gitignore | 1 + CHANGELOG.md | 17 ++ TODO.md | 5 + internal/app/app.go | 157 +++++++++++++++--- internal/app/child.go | 19 ++- internal/app/child_input_test.go | 29 ++++ internal/app/palette.go | 9 + internal/app/palette_context_test.go | 19 +++ internal/app/scratchpad_delete_test.go | 97 +++++++++++ internal/app/timers.go | 101 ++++++++++- internal/app/timers_test.go | 87 ++++++++++ .../restart_process_keeps_chrome.json | 32 ++++ 12 files changed, 540 insertions(+), 33 deletions(-) create mode 100644 internal/app/child_input_test.go create mode 100644 internal/app/scratchpad_delete_test.go create mode 100644 internal/harness/scenarios/restart_process_keeps_chrome.json diff --git a/.gitignore b/.gitignore index e41b561..b800752 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ spike-report-*.txt /bin/ /spike /.worktrees/ +/.claude/worktrees/ internal/harness/.artifacts/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dae377..6e6c497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [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 ### Added diff --git a/TODO.md b/TODO.md index e69de29..628b485 100644 --- a/TODO.md +++ b/TODO.md @@ -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? diff --git a/internal/app/app.go b/internal/app/app.go index c3c23cc..92ce784 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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() diff --git a/internal/app/child.go b/internal/app/child.go index 17cc8f6..92df21b 100644 --- a/internal/app/child.go +++ b/internal/app/child.go @@ -625,18 +625,18 @@ func (c *Child) InjectAsOrchestrator(b []byte) error { } // writeInput is the shared PTY write path used by both injection -// flavours. Each Enter byte (CR or LF) is split onto its own write -// with a brief delay so TUI agents with paste-detection (claude, +// flavours. Agent panes split each Enter byte (CR or LF) onto its own +// write with a brief delay so TUI agents with paste-detection (claude, // codex, opencode) don't coalesce a trailing CR into the text that -// preceded it. Without the split, `pty.Write([]byte("hello\r"))` -// arrives at the agent as one read() and gets treated as multi-line -// pasted content rather than "key Enter". +// preceded it. Raw terminals and command panes receive the original +// byte stream in one write; otherwise a multiline paste pays the agent +// workaround's delay once per line. func (c *Child) writeInput(b []byte) error { pty := c.PTY() if pty == nil { return errors.New("child has no pty") } - pieces := splitOnEnter(b) + pieces := inputWritePieces(c.Kind, b) if len(pieces) <= 1 { _, err := pty.Write(b) return err @@ -652,6 +652,13 @@ func (c *Child) writeInput(b []byte) error { return nil } +func inputWritePieces(kind ChildKind, b []byte) [][]byte { + if kind != KindAgent { + return [][]byte{b} + } + return splitOnEnter(b) +} + func mintIdentity() string { var buf [12]byte _, _ = rand.Read(buf[:]) diff --git a/internal/app/child_input_test.go b/internal/app/child_input_test.go new file mode 100644 index 0000000..f834457 --- /dev/null +++ b/internal/app/child_input_test.go @@ -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]) + } + } +} diff --git a/internal/app/palette.go b/internal/app/palette.go index 73fc8bb..6cb96d8 100644 --- a/internal/app/palette.go +++ b/internal/app/palette.go @@ -270,6 +270,15 @@ func (p *paletteState) buildItems(macro string) []paletteItem { paletteItem{label: "Close", hint: "close agent · " + name + " (SIGTERM)", 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: out = append(out, paletteItem{label: "Rename", hint: "rename process · " + name, diff --git a/internal/app/palette_context_test.go b/internal/app/palette_context_test.go index 85ca443..9307323 100644 --- a/internal/app/palette_context_test.go +++ b/internal/app/palette_context_test.go @@ -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) { // Two children so there's still a non-focused switch entry to compare // against (the focused child is suppressed from the Open section). diff --git a/internal/app/scratchpad_delete_test.go b/internal/app/scratchpad_delete_test.go new file mode 100644 index 0000000..50d0f64 --- /dev/null +++ b/internal/app/scratchpad_delete_test.go @@ -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) + } +} diff --git a/internal/app/timers.go b/internal/app/timers.go index e1f5570..4e16761 100644 --- a/internal/app/timers.go +++ b/internal/app/timers.go @@ -55,9 +55,10 @@ type pendingTimer struct { type timerManager struct { sess *Session - mu sync.Mutex - nextID int - timers map[string]*pendingTimer + mu sync.Mutex + nextID int + timers map[string]*pendingTimer + changes chan struct{} // fireFn is the callback used to deliver the body to the owning // process. Decoupled so tests can substitute a recorder. Defaults @@ -67,13 +68,25 @@ type timerManager struct { func newTimerManager(sess *Session) *timerManager { m := &timerManager{ - sess: sess, - timers: make(map[string]*pendingTimer), + sess: sess, + timers: make(map[string]*pendingTimer), + changes: make(chan struct{}, 1), } m.fireFn = defaultFireFn 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) { if owner == nil || !owner.IsLive() { return @@ -121,6 +134,7 @@ func (m *timerManager) TimerSet(ownerID string, body, label string, seconds floa m.timers[id] = t m.mu.Unlock() t.rt = time.AfterFunc(d, func() { m.fireDelay(id) }) + m.notifyChanged() return id, nil } @@ -136,6 +150,7 @@ func (m *timerManager) fireDelay(id string) { body, label := t.body, t.label delete(m.timers, id) m.mu.Unlock() + m.notifyChanged() m.fireFn(owner, body, label) } @@ -214,6 +229,7 @@ func (m *timerManager) registerIdleTimer(kind pendingTimerKind, ownerID, body, l } m.timers[id] = t m.mu.Unlock() + m.notifyChanged() resp.ID = id resp.Status = "pending" return resp, nil @@ -231,6 +247,7 @@ func (m *timerManager) fireIdleMaxWait(id string) { body, label := t.body, t.label delete(m.timers, id) m.mu.Unlock() + m.notifyChanged() m.fireFn(owner, body, label) } @@ -291,6 +308,9 @@ func (m *timerManager) onChildStateChanged(childID string, state IdleState) { delete(m.timers, id) } m.mu.Unlock() + if len(firedIDs) > 0 { + m.notifyChanged() + } for _, f := range fires { 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. func (m *timerManager) onChildClosed(childID string) { m.mu.Lock() - defer m.mu.Unlock() + changed := false for id, t := range m.timers { if t.ownerID == childID { if t.rt != nil { @@ -329,6 +349,7 @@ func (m *timerManager) onChildClosed(childID string) { } t.status = timerStatusCanceled delete(m.timers, id) + changed = true continue } if !contains(t.watched, childID) { @@ -344,6 +365,7 @@ func (m *timerManager) onChildClosed(childID string) { if t.idleBaseline != nil { delete(t.idleBaseline, childID) } + changed = true if len(t.watched) == 0 { if t.rt != nil { t.rt.Stop() @@ -353,6 +375,10 @@ func (m *timerManager) onChildClosed(childID string) { delete(m.timers, id) } } + m.mu.Unlock() + if changed { + m.notifyChanged() + } } // 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. func (m *timerManager) TimerCancel(ownerID, id string) error { m.mu.Lock() - defer m.mu.Unlock() t, ok := m.timers[id] if !ok { + m.mu.Unlock() return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id) } // Empty ownerID = top-level orchestrator caller (e.g. a non-agent // MCP client); allow it to manage every timer in the session. // Otherwise the caller's own id must match the timer's owner. if ownerID != "" && t.ownerID != ownerID { + m.mu.Unlock() return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id) } if t.status == timerStatusFired || t.status == timerStatusCanceled { // Cancelling a fired/cancelled timer is idempotent. + m.mu.Unlock() return nil } if t.rt != nil { @@ -395,6 +423,8 @@ func (m *timerManager) TimerCancel(ownerID, id string) error { } t.status = timerStatusCanceled delete(m.timers, id) + m.mu.Unlock() + m.notifyChanged() return nil } @@ -402,18 +432,20 @@ func (m *timerManager) TimerCancel(ownerID, id string) error { // keeps the timer in the registry. func (m *timerManager) TimerPause(ownerID, id string) error { m.mu.Lock() - defer m.mu.Unlock() t, ok := m.timers[id] if !ok { + m.mu.Unlock() return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id) } // Empty ownerID = top-level orchestrator caller (e.g. a non-agent // MCP client); allow it to manage every timer in the session. // Otherwise the caller's own id must match the timer's owner. if ownerID != "" && t.ownerID != ownerID { + m.mu.Unlock() return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id) } if t.status != timerStatusPending { + m.mu.Unlock() return mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer %q is not pending", id) } if t.rt != nil { @@ -429,6 +461,8 @@ func (m *timerManager) TimerPause(ownerID, id string) error { t.pausedWasMaxWait = t.kind != timerKindDelay } t.status = timerStatusPaused + m.mu.Unlock() + m.notifyChanged() return nil } @@ -507,6 +541,7 @@ func (m *timerManager) TimerResume(ownerID, id string) error { delete(m.timers, id) } m.mu.Unlock() + m.notifyChanged() if fireNow { m.fireFn(owner, body, label) } @@ -587,6 +622,56 @@ func (m *timerManager) activeForChild(id string) *mcp.TimerInfo { 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 { return s == StateIdle } diff --git a/internal/app/timers_test.go b/internal/app/timers_test.go index f9f512c..adb25ed 100644 --- a/internal/app/timers_test.go +++ b/internal/app/timers_test.go @@ -65,6 +65,93 @@ func newTestManager(t *testing.T) (*Session, *timerManager, *recorderFire) { 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) { sess, mgr, rec := newTestManager(t) c := fakeChild("p_owner") diff --git a/internal/harness/scenarios/restart_process_keeps_chrome.json b/internal/harness/scenarios/restart_process_keeps_chrome.json new file mode 100644 index 0000000..cfb3138 --- /dev/null +++ b/internal/harness/scenarios/restart_process_keeps_chrome.json @@ -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 } + ] +}