Skip to content

Commit 215a1b1

Browse files
committed
feat(timer): implement unified timer service for workflow and agents
- Add TimerService class to manage workflow and agent timing with pause/resume support - Replace scattered timing logic with centralized service in workflow components - Integrate timer service with OpenTUI adapter for event-based timing control - Provide SolidJS hook for reactive timer updates in UI components - Remove deprecated useTick hook and move formatting utilities to timer service
1 parent bb5ac54 commit 215a1b1

File tree

10 files changed

+568
-220
lines changed

10 files changed

+568
-220
lines changed

src/cli/tui/routes/workflow/adapters/opentui.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { debug } from "../../../../../shared/logging/logger.js"
1010
import type { AgentStatus, SubAgentState, LoopState, ChainedState, InputState, TriggeredAgentState } from "../state/types.js"
1111
import { BaseUIAdapter } from "./base.js"
1212
import type { UIAdapterOptions } from "./types.js"
13+
import { timerService } from "@tui/shared/services"
1314

1415
/**
1516
* Actions interface that the OpenTUI adapter calls to update UI state.
@@ -92,36 +93,55 @@ export class OpenTUIAdapter extends BaseUIAdapter {
9293
switch (event.type) {
9394
// Workflow events
9495
case "workflow:started":
96+
// Reset timer for new workflow (will auto-start on first agent)
97+
timerService.reset()
9598
this.actions.setWorkflowStatus("running")
9699
break
97100

98101
case "workflow:status":
99102
debug(`[DEBUG Adapter] Received workflow:status event with status=${event.status}`)
103+
// Handle timer service state transitions
104+
if (event.status === "stopped" || event.status === "completed" || event.status === "error") {
105+
timerService.stop()
106+
} else if (event.status === "paused") {
107+
timerService.pause("user")
108+
} else if (event.status === "running" && timerService.isPaused()) {
109+
timerService.resume()
110+
}
100111
this.actions.setWorkflowStatus(event.status)
101112
break
102113

103114
case "workflow:stopped":
115+
timerService.stop()
104116
this.actions.setWorkflowStatus("stopped")
105117
break
106118

107119
// Agent events
108-
case "agent:added":
120+
case "agent:added": {
121+
const startTime = Date.now()
122+
// Register with timer service (auto-starts workflow timer on first agent)
123+
timerService.registerAgent(event.agent.id, startTime)
109124
this.actions.addAgent({
110125
id: event.agent.id,
111126
name: event.agent.name,
112127
engine: event.agent.engine,
113128
model: event.agent.model,
114129
status: event.agent.status,
115130
telemetry: { tokensIn: 0, tokensOut: 0 },
116-
startTime: Date.now(),
131+
startTime,
117132
toolCount: 0,
118133
thinkingCount: 0,
119134
stepIndex: event.agent.stepIndex,
120135
totalSteps: event.agent.totalSteps,
121136
})
122137
break
138+
}
123139

124140
case "agent:status":
141+
// Update timer service for completion states
142+
if (event.status === "completed" || event.status === "failed" || event.status === "skipped") {
143+
timerService.completeAgent(event.agentId)
144+
}
125145
this.actions.updateAgentStatus(event.agentId, event.status)
126146
break
127147

@@ -148,14 +168,24 @@ export class OpenTUIAdapter extends BaseUIAdapter {
148168

149169
// Sub-agent events
150170
case "subagent:added":
171+
// Register sub-agent with timer service
172+
timerService.registerAgent(event.subAgent.id, event.subAgent.startTime)
151173
this.actions.addSubAgent(event.parentId, event.subAgent)
152174
break
153175

154176
case "subagent:batch":
177+
// Register all sub-agents with timer service
178+
for (const subAgent of event.subAgents) {
179+
timerService.registerAgent(subAgent.id, subAgent.startTime)
180+
}
155181
this.actions.batchAddSubAgents(event.parentId, event.subAgents)
156182
break
157183

158184
case "subagent:status":
185+
// Update timer service for completion states
186+
if (event.status === "completed" || event.status === "failed" || event.status === "skipped") {
187+
timerService.completeAgent(event.subAgentId)
188+
}
159189
this.actions.updateSubAgentStatus(event.subAgentId, event.status)
160190
break
161191

@@ -179,15 +209,27 @@ export class OpenTUIAdapter extends BaseUIAdapter {
179209

180210
// Checkpoint events
181211
case "checkpoint:state":
212+
if (event.checkpoint?.active) {
213+
timerService.pause("checkpoint")
214+
}
182215
this.actions.setCheckpointState(event.checkpoint)
183216
break
184217

185218
case "checkpoint:clear":
219+
// Resume timer if not in another pause state
220+
if (timerService.getPauseReason() === "checkpoint") {
221+
timerService.resume()
222+
}
186223
this.actions.setCheckpointState(null)
187224
break
188225

189226
// Input state events (unified pause/chained)
190227
case "input:state":
228+
if (event.inputState?.active) {
229+
timerService.pause("awaiting")
230+
} else if (timerService.getPauseReason() === "awaiting") {
231+
timerService.resume()
232+
}
191233
this.actions.setInputState(event.inputState)
192234
break
193235

src/cli/tui/routes/workflow/components/timeline/agent-timeline.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ export interface AgentTimelineProps {
2121
onToggleExpand: (agentId: string) => void
2222
availableHeight?: number
2323
availableWidth?: number
24-
isPaused?: boolean
2524
isPromptBoxFocused?: boolean
2625
}
2726

@@ -90,7 +89,7 @@ export function AgentTimeline(props: AgentTimelineProps) {
9089

9190
// Main agent
9291
if (item.type === "main") {
93-
return <MainAgentNode agent={item.agent} isSelected={isMainSelected(item.id)} isPaused={props.isPaused} availableWidth={props.availableWidth} />
92+
return <MainAgentNode agent={item.agent} isSelected={isMainSelected(item.id)} availableWidth={props.availableWidth} />
9493
}
9594

9695
// Sub-agent summary (collapsed)
@@ -116,7 +115,7 @@ export function AgentTimeline(props: AgentTimelineProps) {
116115

117116
// Sub-agent (expanded)
118117
if (item.type === "sub") {
119-
return <SubAgentNode agent={item.agent} isSelected={isSubSelected(item.id)} isPaused={props.isPaused} />
118+
return <SubAgentNode agent={item.agent} isSelected={isSubSelected(item.id)} />
120119
}
121120

122121
return null

src/cli/tui/routes/workflow/components/timeline/main-agent-node.tsx

Lines changed: 15 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,17 @@
66
* Display a single main agent with status, telemetry, and duration
77
*/
88

9-
import { Show, createSignal, createEffect, on } from "solid-js"
9+
import { Show } from "solid-js"
1010
import { useTheme } from "@tui/shared/context/theme"
11-
import { useTick } from "@tui/shared/hooks/tick"
11+
import { useTimer, formatDuration } from "@tui/shared/services"
1212
import { Spinner } from "@tui/shared/components/spinner"
1313
import type { AgentState } from "../../state/types"
14-
import { formatDuration, truncate } from "../../state/formatters"
14+
import { truncate } from "../../state/formatters"
1515
import { getStatusIcon, getStatusColor } from "./status-utils"
1616

1717
export interface MainAgentNodeProps {
1818
agent: AgentState
1919
isSelected: boolean
20-
isPaused?: boolean
2120
availableWidth?: number
2221
}
2322

@@ -28,59 +27,32 @@ const MIN_WIDTH_FOR_ENGINE = 45
2827

2928
export function MainAgentNode(props: MainAgentNodeProps) {
3029
const themeCtx = useTheme()
31-
const now = useTick()
30+
const timer = useTimer()
3231

3332
// Only show engine if timeline section is wide enough
3433
const showEngine = () => (props.availableWidth ?? 80) >= MIN_WIDTH_FOR_ENGINE
3534

3635
const color = () => props.agent.error ? themeCtx.theme.error : getStatusColor(props.agent.status, themeCtx.theme)
3736

38-
// Store pause state for timer freeze/resume
39-
const [pauseStartTime, setPauseStartTime] = createSignal<number | null>(null)
40-
const [totalPausedTime, setTotalPausedTime] = createSignal<number>(0)
41-
42-
// Handle pause/resume transitions
43-
createEffect(on(
44-
() => props.isPaused,
45-
(isPaused, wasPaused) => {
46-
if (isPaused && !wasPaused) {
47-
// Just paused - record the time
48-
setPauseStartTime(Date.now())
49-
} else if (!isPaused && wasPaused) {
50-
// Just resumed - add pause duration to total
51-
const pauseStart = pauseStartTime()
52-
if (pauseStart !== null) {
53-
setTotalPausedTime(prev => prev + (Date.now() - pauseStart))
54-
}
55-
setPauseStartTime(null)
56-
}
57-
},
58-
{ defer: false }
59-
))
60-
37+
// Duration calculation:
38+
// - Running agents: live timer from timer service
39+
// - Completed agents: frozen duration from UI state
40+
// - Queued agents: show 00:00
6141
const duration = () => {
6242
const { startTime, endTime, status } = props.agent
6343

44+
// Completed/failed/skipped - show frozen duration
6445
if (endTime) {
6546
return formatDuration((endTime - startTime) / 1000)
6647
}
6748

68-
if (status !== "running" || startTime <= 0) {
69-
return ""
70-
}
71-
72-
const pauseStart = pauseStartTime()
73-
const totalPaused = totalPausedTime()
74-
75-
// If currently paused, use pauseStartTime (don't call now())
76-
if (props.isPaused && pauseStart !== null) {
77-
const elapsed = (pauseStart - startTime - totalPaused) / 1000
78-
return formatDuration(Math.max(0, elapsed))
49+
// Running agent - use timer service for live updates
50+
if (status === "running" && startTime > 0) {
51+
return timer.agentDuration(props.agent.id)
7952
}
8053

81-
// Running - use live time minus total paused time
82-
const elapsed = (now() - startTime - totalPaused) / 1000
83-
return formatDuration(Math.max(0, elapsed))
54+
// Queued/pending - show 00:00
55+
return "00:00"
8456
}
8557

8658
const hasLoopRound = () => props.agent.loopRound && props.agent.loopRound > 0
@@ -98,7 +70,7 @@ export function MainAgentNode(props: MainAgentNodeProps) {
9870
<Show when={props.agent.status === "running"} fallback={
9971
<text wrapMode="none" fg={color()}>{getStatusIcon(props.agent.status)} </text>
10072
}>
101-
<Show when={props.isPaused} fallback={
73+
<Show when={timer.isPaused()} fallback={
10274
<>
10375
<Spinner color={color()} />
10476
<text wrapMode="none"> </text>

src/cli/tui/routes/workflow/components/timeline/sub-agent-node.tsx

Lines changed: 14 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -6,72 +6,43 @@
66
* Styled similarly to MainAgentNode but with proper indentation for hierarchy
77
*/
88

9-
import { Show, createSignal, createEffect, on } from "solid-js"
9+
import { Show } from "solid-js"
1010
import { useTheme } from "@tui/shared/context/theme"
11-
import { useTick } from "@tui/shared/hooks/tick"
11+
import { useTimer, formatDuration } from "@tui/shared/services"
1212
import { Spinner } from "@tui/shared/components/spinner"
1313
import type { SubAgentState } from "../../state/types"
14-
import { formatDuration } from "../../state/formatters"
1514
import { getStatusIcon, getStatusColor } from "./status-utils"
1615

1716
export interface SubAgentNodeProps {
1817
agent: SubAgentState
1918
isSelected: boolean
20-
isPaused?: boolean
2119
}
2220

2321
export function SubAgentNode(props: SubAgentNodeProps) {
2422
const themeCtx = useTheme()
25-
const now = useTick()
23+
const timer = useTimer()
2624

2725
const color = () => props.agent.error ? themeCtx.theme.error : getStatusColor(props.agent.status, themeCtx.theme)
2826

29-
// Store pause state for timer freeze/resume
30-
const [pauseStartTime, setPauseStartTime] = createSignal<number | null>(null)
31-
const [totalPausedTime, setTotalPausedTime] = createSignal<number>(0)
32-
33-
// Handle pause/resume transitions
34-
createEffect(on(
35-
() => props.isPaused,
36-
(isPaused, wasPaused) => {
37-
if (isPaused && !wasPaused) {
38-
// Just paused - record the time
39-
setPauseStartTime(Date.now())
40-
} else if (!isPaused && wasPaused) {
41-
// Just resumed - add pause duration to total
42-
const pauseStart = pauseStartTime()
43-
if (pauseStart !== null) {
44-
setTotalPausedTime(prev => prev + (Date.now() - pauseStart))
45-
}
46-
setPauseStartTime(null)
47-
}
48-
},
49-
{ defer: false }
50-
))
51-
27+
// Duration calculation:
28+
// - Running agents: live timer from timer service
29+
// - Completed agents: frozen duration from UI state
30+
// - Queued agents: show 00:00
5231
const duration = () => {
5332
const { startTime, endTime, status } = props.agent
5433

34+
// Completed/failed/skipped - show frozen duration
5535
if (endTime) {
5636
return formatDuration((endTime - startTime) / 1000)
5737
}
5838

59-
if (status !== "running" || startTime <= 0) {
60-
return ""
61-
}
62-
63-
const pauseStart = pauseStartTime()
64-
const totalPaused = totalPausedTime()
65-
66-
// If currently paused, use pauseStartTime (don't call now())
67-
if (props.isPaused && pauseStart !== null) {
68-
const elapsed = (pauseStart - startTime - totalPaused) / 1000
69-
return formatDuration(Math.max(0, elapsed))
39+
// Running agent - use timer service for live updates
40+
if (status === "running" && startTime > 0) {
41+
return timer.agentDuration(props.agent.id)
7042
}
7143

72-
// Running - use live time minus total paused time
73-
const elapsed = (now() - startTime - totalPaused) / 1000
74-
return formatDuration(Math.max(0, elapsed))
44+
// Queued/pending - show 00:00
45+
return "00:00"
7546
}
7647

7748
// Selection indicator
@@ -83,7 +54,7 @@ export function SubAgentNode(props: SubAgentNodeProps) {
8354
<Show when={props.agent.status === "running"} fallback={
8455
<text fg={color()}>{getStatusIcon(props.agent.status)} </text>
8556
}>
86-
<Show when={props.isPaused} fallback={
57+
<Show when={timer.isPaused()} fallback={
8758
<>
8859
<Spinner color={color()} />
8960
<text> </text>

src/cli/tui/routes/workflow/state/formatters.ts

Lines changed: 4 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,9 @@
1-
import type { AgentStatus } from "./types"
2-
3-
export function formatDuration(seconds: number): string {
4-
const hours = Math.floor(seconds / 3600)
5-
const minutes = Math.floor((seconds % 3600) / 60)
6-
const secs = Math.floor(seconds % 60)
7-
8-
if (hours > 0) {
9-
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${secs
10-
.toString()
11-
.padStart(2, "0")}`
12-
}
13-
14-
return `${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`
15-
}
16-
171
/**
18-
* Calculate display-friendly duration for an agent based on timestamps/status.
19-
* Ported from: src/ui/utils/calculateDuration.ts
2+
* Formatting utilities for workflow display
3+
*
4+
* Note: Time/duration formatting has been moved to the unified TimerService
5+
* at @tui/shared/services/timer.ts
206
*/
21-
export interface DurationInput {
22-
startTime: number
23-
endTime?: number
24-
status: AgentStatus
25-
}
26-
27-
export function calculateDuration(
28-
{ startTime, endTime, status }: DurationInput,
29-
nowProvider: () => number = Date.now
30-
): string {
31-
if (endTime) {
32-
return formatDuration((endTime - startTime) / 1000)
33-
}
34-
35-
if (status === "running") {
36-
return formatDuration((nowProvider() - startTime) / 1000)
37-
}
38-
39-
return ""
40-
}
41-
42-
export function formatRuntime(startTime: number, endTime?: number): string {
43-
const now = endTime ?? Date.now()
44-
const elapsed = Math.floor((now - startTime) / 1000)
45-
return formatDuration(elapsed)
46-
}
477

488
export function formatNumber(num: number): string {
499
return num.toLocaleString()

0 commit comments

Comments
 (0)