Add idle-state classifier and Solo-parity timer tools
Classifies every running child as idle/working/thinking/permission/error using one of three pluggable strategies (output_activity, osc_title_stability, osc_title_status) plus optional regex promoters applied to the tail of recent output. State and last-match reason are exposed via MCP on ProcessInfo and get_process_status. Per-preset configuration lives on a new preset.IdleDetection block with bundled defaults for the first-party claude/codex/opencode presets. OSC title plumbing is exposed as Emulator.Title(), polled from the session pump after each emulator write so title-change activity feeds into the classifier without an extra cgo callback. The MCP timer surface expands to match Solo: timer_set, timer_fire_when_idle_any/all, timer_cancel, timer_pause, timer_resume, timer_list. timer_wait is now a thin wrapper that shares the same manager so it shows up in timer_list while pending. Timer bodies are delivered to the owner process through the existing InjectAsOrchestrator path. Top-level (non-agent) callers can attach timers to a specific process via owner_process_id; omitting it grants universal cancel/pause/resume/list privileges. The sidebar gains a state glyph per process row and appends a nearest-timer indicator when one is pending or paused. Tests: idle_test.go covers the classify() pure function across the three strategies and regex promotion; timers_test.go covers the manager. Harness scenarios cover output_activity, osc_title_stability, osc_title_status, and regex promotion, plus timer_set delivery, cancel, pause/resume, idle_any-on-transition, idle_all-pending, and idle_all-already-satisfied. A new wait_until_mcp harness step type polls an MCP method until an assertion holds.
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -11,6 +12,24 @@ const (
|
||||
statusRows = 1
|
||||
)
|
||||
|
||||
// formatShortDuration renders a duration as a short, sidebar-friendly
|
||||
// suffix: ms under 1s, "12s" under 60s, "3m" otherwise.
|
||||
func formatShortDuration(d time.Duration) string {
|
||||
if d <= 0 {
|
||||
return "0s"
|
||||
}
|
||||
if d < time.Second {
|
||||
return fmt.Sprintf("%dms", int(d/time.Millisecond))
|
||||
}
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%ds", int(d/time.Second))
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%dm", int(d/time.Minute))
|
||||
}
|
||||
return fmt.Sprintf("%dh", int(d/time.Hour))
|
||||
}
|
||||
|
||||
// drawSidebar paints the right-rail session tree + scratchpad list.
|
||||
// SPEC §4: the rail is the active session's child hierarchy on top and
|
||||
// the scratchpad list (with preview) on the bottom.
|
||||
@@ -62,14 +81,56 @@ func (st *uiState) drawSidebar() {
|
||||
write(" " + styleActive + text + styleReset)
|
||||
write(" " + styleBorder + strings.Repeat("─", width-2) + styleReset)
|
||||
}
|
||||
// timerIndicator returns a short " ⏱ 12s" or " ⏸ paused" suffix
|
||||
// when c has a pending or paused timer attached (owns or watches).
|
||||
// Empty string when no timer is in play.
|
||||
timerIndicator := func(c *Child) string {
|
||||
if st.timers == nil {
|
||||
return ""
|
||||
}
|
||||
info := st.timers.activeForChild(c.ID)
|
||||
if info == nil {
|
||||
return ""
|
||||
}
|
||||
if info.Status == timerStatusPaused {
|
||||
return " " + styleDim + "⏸" + styleReset
|
||||
}
|
||||
remaining := ""
|
||||
if info.FiresAtUnixMS > 0 {
|
||||
d := time.Until(time.UnixMilli(info.FiresAtUnixMS))
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
remaining = formatShortDuration(d)
|
||||
}
|
||||
return " " + styleDim + "⏱" + styleReset + " " + styleHint + remaining + styleReset
|
||||
}
|
||||
statusGlyph := func(c *Child, focused bool) string {
|
||||
if c.Status() != StatusRunning {
|
||||
return styleDim + "○" + styleReset
|
||||
}
|
||||
// Idle-detection states paint over the plain running glyph so
|
||||
// the rail communicates "running but waiting on you" vs "running
|
||||
// and busy" at a glance. Focused entries always use the accent
|
||||
// colour so the user's selection stays visible.
|
||||
style := styleHint
|
||||
if focused {
|
||||
return styleAccent + "●" + styleReset
|
||||
style = styleAccent
|
||||
}
|
||||
switch c.IdleState() {
|
||||
case StateError:
|
||||
return styleError + "✕" + styleReset
|
||||
case StatePermission:
|
||||
return styleAccent + "?" + styleReset
|
||||
case StateThinking:
|
||||
return style + "◐" + styleReset
|
||||
case StateIdle:
|
||||
return style + "○" + styleReset
|
||||
case StateWorking:
|
||||
return style + "●" + styleReset
|
||||
default:
|
||||
return style + "●" + styleReset
|
||||
}
|
||||
return styleHint + "●" + styleReset
|
||||
}
|
||||
|
||||
// Processes section — top-level command/terminal processes,
|
||||
@@ -92,9 +153,9 @@ func (st *uiState) drawSidebar() {
|
||||
var line string
|
||||
if focused {
|
||||
line = " " + styleAccent + "▎" + styleReset + " " + glyph + " " +
|
||||
styleBold + c.DisplayName() + styleReset + marker
|
||||
styleBold + c.DisplayName() + styleReset + marker + timerIndicator(c)
|
||||
} else {
|
||||
line = " " + glyph + " " + styleHint + c.DisplayName() + styleReset + marker
|
||||
line = " " + glyph + " " + styleHint + c.DisplayName() + styleReset + marker + timerIndicator(c)
|
||||
}
|
||||
write(line)
|
||||
}
|
||||
@@ -124,9 +185,9 @@ func (st *uiState) drawSidebar() {
|
||||
var line string
|
||||
if focused {
|
||||
line = " " + styleAccent + "▎" + styleReset + " " + indent + glyph + " " +
|
||||
styleBold + c.DisplayName() + styleReset
|
||||
styleBold + c.DisplayName() + styleReset + timerIndicator(c)
|
||||
} else {
|
||||
line = " " + indent + glyph + " " + styleHint + c.DisplayName() + styleReset
|
||||
line = " " + indent + glyph + " " + styleHint + c.DisplayName() + styleReset + timerIndicator(c)
|
||||
}
|
||||
write(line)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user