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/
/spike
/.worktrees/
/.claude/worktrees/
internal/harness/.artifacts/

View File

@@ -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

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
// 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
}
if wasFocused {
st.invalidateScratchpadsCache()
if entries := st.padsList(); len(entries) > 0 {
next := entries[0].Name
st.mu.Lock()
if st.focusedPad == name {
st.focusedPad = ""
st.focusedPad = next
st.focusedID = ""
st.focusedName = next
if st.padOffsetName != next {
st.padOffset = 0
st.padOffsetName = next
}
st.mu.Unlock()
st.scratchpadsChanged()
st.repaintFocused()
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.scratchpadsChanged()
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()

View File

@@ -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[:])

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)",
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,

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) {
// Two children so there's still a non-focused switch entry to compare
// 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

@@ -58,6 +58,7 @@ type timerManager struct {
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
@@ -69,11 +70,23 @@ func newTimerManager(sess *Session) *timerManager {
m := &timerManager{
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
}

View File

@@ -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")

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 }
]
}