Skip to content

Commit 0efd997

Browse files
committed
feat(tui): implement workflow state management and UI components
add state management for workflow execution including agents, telemetry and navigation introduce new UI components for workflow visualization and interaction create utility functions for telemetry parsing and formatting
1 parent e010c74 commit 0efd997

File tree

11 files changed

+977
-15
lines changed

11 files changed

+977
-15
lines changed

src/cli/tui/context/ui-state.tsx

Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
/** @jsxImportSource @opentui/solid */
2+
import { type Accessor, createSignal, onCleanup } from "solid-js"
3+
import { createSimpleContext } from "./helper"
4+
import {
5+
type WorkflowState,
6+
type AgentStatus,
7+
type LoopState,
8+
type SubAgentState,
9+
type WorkflowStatus,
10+
} from "@tui/state/types"
11+
import {
12+
getCurrentSelection,
13+
getNextNavigableItem,
14+
getPreviousNavigableItem,
15+
getTimelineLayout,
16+
calculateScrollOffset,
17+
} from "@tui/state/navigation"
18+
import { updateAgentTelemetryInList } from "@tui/state/utils"
19+
import packageJson from "../../../../package.json" with { type: "json" }
20+
21+
type Listener = () => void
22+
23+
type UIActions = {
24+
getState(): WorkflowState
25+
subscribe(fn: Listener): () => void
26+
addAgent(agent: WorkflowState["agents"][number]): void
27+
updateAgentStatus(agentId: string, status: AgentStatus): void
28+
updateAgentTelemetry(agentId: string, telemetry: Partial<WorkflowState["agents"][number]["telemetry"]>): void
29+
setLoopState(loopState: LoopState | null): void
30+
clearLoopRound(agentId: string): void
31+
addSubAgent(parentId: string, subAgent: SubAgentState): void
32+
batchAddSubAgents(parentId: string, subAgents: SubAgentState[]): void
33+
updateSubAgentStatus(subAgentId: string, status: AgentStatus): void
34+
navigateDown(visibleItemCount?: number): void
35+
navigateUp(visibleItemCount?: number): void
36+
selectItem(itemId: string, itemType: "main" | "summary" | "sub", visibleItemCount?: number, immediate?: boolean): void
37+
toggleExpand(agentId: string): void
38+
setVisibleItemCount(count: number): void
39+
setScrollOffset(offset: number, visibleItemCount?: number): void
40+
setWorkflowStatus(status: WorkflowStatus): void
41+
setCheckpointState(checkpoint: { active: boolean; reason?: string } | null): void
42+
}
43+
44+
function createInitialState(workflowName: string, totalSteps = 0): WorkflowState {
45+
return {
46+
workflowName,
47+
version: packageJson.version,
48+
packageName: packageJson.name,
49+
startTime: Date.now(),
50+
agents: [],
51+
subAgents: new Map(),
52+
triggeredAgents: [],
53+
uiElements: [],
54+
executionHistory: [],
55+
loopState: null,
56+
checkpointState: null,
57+
expandedNodes: new Set(),
58+
showTelemetryView: false,
59+
selectedAgentId: null,
60+
selectedSubAgentId: null,
61+
selectedItemType: null,
62+
visibleItemCount: 10,
63+
scrollOffset: 0,
64+
totalSteps,
65+
workflowStatus: "running",
66+
agentIdMapVersion: 0,
67+
}
68+
}
69+
70+
function adjustScroll(
71+
state: WorkflowState,
72+
options: { visibleCount?: number; ensureSelectedVisible?: boolean; desiredScrollOffset?: number } = {},
73+
): WorkflowState {
74+
const resolvedVisible = options.visibleCount ?? state.visibleItemCount
75+
const visibleLines = Number.isFinite(resolvedVisible) ? Math.max(1, Math.floor(resolvedVisible)) : 1
76+
77+
const layout = getTimelineLayout(state)
78+
if (layout.length === 0) {
79+
const needsUpdate = state.scrollOffset !== 0 || state.visibleItemCount !== visibleLines
80+
return needsUpdate ? { ...state, scrollOffset: 0, visibleItemCount: visibleLines } : state
81+
}
82+
83+
const totalLines = layout[layout.length - 1].offset + layout[layout.length - 1].height
84+
const maxOffset = Math.max(0, totalLines - visibleLines)
85+
let desiredOffset = options.desiredScrollOffset ?? state.scrollOffset
86+
if (!Number.isFinite(desiredOffset)) desiredOffset = 0
87+
let nextOffset = Math.max(0, Math.min(Math.floor(desiredOffset), maxOffset))
88+
89+
if (options.ensureSelectedVisible !== false) {
90+
const selection = getCurrentSelection(state)
91+
if (selection.id && selection.type) {
92+
const selectedIndex = layout.findIndex(
93+
(entry) => entry.item.id === selection.id && entry.item.type === selection.type,
94+
)
95+
nextOffset = calculateScrollOffset(layout, selectedIndex, nextOffset, visibleLines)
96+
} else {
97+
nextOffset = Math.max(0, Math.min(nextOffset, maxOffset))
98+
}
99+
} else {
100+
nextOffset = Math.max(0, Math.min(nextOffset, maxOffset))
101+
}
102+
103+
if (nextOffset === state.scrollOffset && visibleLines === state.visibleItemCount) {
104+
return state
105+
}
106+
107+
return { ...state, scrollOffset: nextOffset, visibleItemCount: visibleLines }
108+
}
109+
110+
function createStore(workflowName: string): UIActions {
111+
let state = createInitialState(workflowName)
112+
const listeners = new Set<Listener>()
113+
let pending: NodeJS.Timeout | null = null
114+
const throttleMs = 16
115+
116+
const notify = () => {
117+
if (pending) return
118+
pending = setTimeout(() => {
119+
pending = null
120+
listeners.forEach((l) => l())
121+
}, throttleMs)
122+
}
123+
124+
const notifyImmediate = () => {
125+
if (pending) {
126+
clearTimeout(pending)
127+
pending = null
128+
}
129+
listeners.forEach((l) => l())
130+
}
131+
132+
function getState(): WorkflowState {
133+
return state
134+
}
135+
136+
function subscribe(fn: Listener): () => void {
137+
listeners.add(fn)
138+
return () => listeners.delete(fn)
139+
}
140+
141+
function addAgent(agent: WorkflowState["agents"][number]): void {
142+
state = { ...state, agents: [...state.agents, agent] }
143+
notify()
144+
}
145+
146+
function updateAgentStatus(agentId: string, status: AgentStatus): void {
147+
const shouldSetEndTime = status === "completed" || status === "failed" || status === "skipped"
148+
const shouldSetStartTime = status === "running"
149+
state = {
150+
...state,
151+
agents: state.agents.map((agent) =>
152+
agent.id === agentId
153+
? {
154+
...agent,
155+
status,
156+
startTime: shouldSetStartTime ? Date.now() : agent.startTime,
157+
endTime: shouldSetEndTime ? Date.now() : agent.endTime,
158+
}
159+
: agent,
160+
),
161+
}
162+
if (status === "running") {
163+
selectItem(agentId, "main", undefined, true)
164+
return
165+
}
166+
notify()
167+
}
168+
169+
function updateAgentTelemetry(
170+
agentId: string,
171+
telemetry: Partial<WorkflowState["agents"][number]["telemetry"]>,
172+
): void {
173+
state = { ...state, agents: updateAgentTelemetryInList(state.agents, agentId, telemetry) }
174+
notify()
175+
}
176+
177+
function setLoopState(loopState: LoopState | null): void {
178+
state = { ...state, loopState }
179+
if (loopState && loopState.active) {
180+
state = {
181+
...state,
182+
agents: state.agents.map((agent) =>
183+
agent.id === loopState.sourceAgent ? { ...agent, loopRound: loopState.iteration, loopReason: loopState.reason } : agent,
184+
),
185+
}
186+
}
187+
notify()
188+
}
189+
190+
function clearLoopRound(agentId: string): void {
191+
state = {
192+
...state,
193+
agents: state.agents.map((agent) =>
194+
agent.id === agentId ? { ...agent, loopRound: undefined, loopReason: undefined } : agent,
195+
),
196+
}
197+
notify()
198+
}
199+
200+
function addSubAgent(parentId: string, subAgent: SubAgentState): void {
201+
const newSubAgents = new Map(state.subAgents)
202+
const parentSubAgents = newSubAgents.get(parentId) || []
203+
const existingIndex = parentSubAgents.findIndex((sa) => sa.id === subAgent.id)
204+
if (existingIndex >= 0) {
205+
parentSubAgents[existingIndex] = subAgent
206+
} else {
207+
parentSubAgents.push(subAgent)
208+
}
209+
newSubAgents.set(parentId, parentSubAgents)
210+
state = { ...state, subAgents: newSubAgents }
211+
notify()
212+
}
213+
214+
function batchAddSubAgents(parentId: string, subAgents: SubAgentState[]): void {
215+
if (subAgents.length === 0) return
216+
const newSubAgents = new Map(state.subAgents)
217+
const parentSubAgents = newSubAgents.get(parentId) || []
218+
for (const subAgent of subAgents) {
219+
const existingIndex = parentSubAgents.findIndex((sa) => sa.id === subAgent.id)
220+
if (existingIndex >= 0) {
221+
parentSubAgents[existingIndex] = subAgent
222+
} else {
223+
parentSubAgents.push(subAgent)
224+
}
225+
}
226+
newSubAgents.set(parentId, parentSubAgents)
227+
state = { ...state, subAgents: newSubAgents }
228+
notify()
229+
}
230+
231+
function updateSubAgentStatus(subAgentId: string, status: AgentStatus): void {
232+
const newSubAgents = new Map(state.subAgents)
233+
let updated = false
234+
const shouldSetEndTime = status === "completed" || status === "failed" || status === "skipped"
235+
for (const [parentId, subAgents] of newSubAgents.entries()) {
236+
const index = subAgents.findIndex((sa) => sa.id === subAgentId)
237+
if (index >= 0) {
238+
const updatedSubAgents = [...subAgents]
239+
updatedSubAgents[index] = {
240+
...updatedSubAgents[index],
241+
status,
242+
endTime: shouldSetEndTime ? Date.now() : updatedSubAgents[index].endTime,
243+
}
244+
newSubAgents.set(parentId, updatedSubAgents)
245+
updated = true
246+
break
247+
}
248+
}
249+
if (updated) {
250+
state = { ...state, subAgents: newSubAgents }
251+
notify()
252+
}
253+
}
254+
255+
function navigateDown(visibleItemCount?: number): void {
256+
const current = getCurrentSelection(state)
257+
const next = getNextNavigableItem(current, state)
258+
if (next) {
259+
const viewport = visibleItemCount ?? state.visibleItemCount
260+
selectItem(next.id, next.type, viewport, true)
261+
notifyImmediate()
262+
}
263+
}
264+
265+
function navigateUp(visibleItemCount?: number): void {
266+
const current = getCurrentSelection(state)
267+
const prev = getPreviousNavigableItem(current, state)
268+
if (prev) {
269+
const viewport = visibleItemCount ?? state.visibleItemCount
270+
selectItem(prev.id, prev.type, viewport, true)
271+
notifyImmediate()
272+
}
273+
}
274+
275+
function selectItem(
276+
itemId: string,
277+
itemType: "main" | "summary" | "sub",
278+
visibleItemCount?: number,
279+
immediate?: boolean,
280+
): void {
281+
if (itemType === "main") {
282+
state = { ...state, selectedAgentId: itemId, selectedSubAgentId: null, selectedItemType: "main" }
283+
} else if (itemType === "summary") {
284+
state = { ...state, selectedAgentId: itemId, selectedSubAgentId: null, selectedItemType: "summary" }
285+
} else {
286+
state = { ...state, selectedSubAgentId: itemId, selectedItemType: "sub" }
287+
}
288+
289+
const adjusted = adjustScroll(state, { visibleCount: visibleItemCount })
290+
if (adjusted !== state) state = adjusted
291+
if (immediate) {
292+
notifyImmediate()
293+
} else {
294+
notify()
295+
}
296+
}
297+
298+
function toggleExpand(agentId: string): void {
299+
const expanded = new Set(state.expandedNodes)
300+
if (expanded.has(agentId)) expanded.delete(agentId)
301+
else expanded.add(agentId)
302+
state = { ...state, expandedNodes: expanded }
303+
state = adjustScroll(state)
304+
notify()
305+
}
306+
307+
function setVisibleItemCount(count: number): void {
308+
const sanitized = Number.isFinite(count) ? Math.max(1, Math.floor(count)) : 1
309+
const updated: WorkflowState = { ...state, visibleItemCount: sanitized }
310+
const adjusted = adjustScroll(updated, { visibleCount: sanitized, ensureSelectedVisible: false })
311+
if (adjusted !== state) {
312+
state = adjusted
313+
notify()
314+
}
315+
}
316+
317+
function setScrollOffset(offset: number, visibleItemCount?: number): void {
318+
const sanitized = Number.isFinite(offset) ? Math.max(0, Math.floor(offset)) : 0
319+
const adjusted = adjustScroll(state, { visibleCount: visibleItemCount, desiredScrollOffset: sanitized })
320+
if (adjusted !== state) {
321+
state = adjusted
322+
notify()
323+
}
324+
}
325+
326+
function setWorkflowStatus(status: WorkflowStatus): void {
327+
if (state.workflowStatus === status) return
328+
if (status === "completed" || status === "stopped" || status === "stopping") {
329+
state = { ...state, endTime: state.endTime ?? Date.now(), workflowStatus: status }
330+
} else {
331+
state = { ...state, workflowStatus: status }
332+
}
333+
notify()
334+
}
335+
336+
function setCheckpointState(checkpoint: { active: boolean; reason?: string } | null): void {
337+
state = { ...state, checkpointState: checkpoint }
338+
if (checkpoint && checkpoint.active) {
339+
setWorkflowStatus("checkpoint")
340+
} else {
341+
setWorkflowStatus("running")
342+
}
343+
}
344+
345+
return {
346+
getState,
347+
subscribe,
348+
addAgent,
349+
updateAgentStatus,
350+
updateAgentTelemetry,
351+
setLoopState,
352+
clearLoopRound,
353+
addSubAgent,
354+
batchAddSubAgents,
355+
updateSubAgentStatus,
356+
navigateDown,
357+
navigateUp,
358+
selectItem,
359+
toggleExpand,
360+
setVisibleItemCount,
361+
setScrollOffset,
362+
setWorkflowStatus,
363+
setCheckpointState,
364+
}
365+
}
366+
367+
export const { provider: UIStateProvider, use: useUIState } = createSimpleContext({
368+
name: "UIState",
369+
init: (props: { workflowName: string }) => {
370+
const store = createStore(props.workflowName)
371+
const [state, setState]: [Accessor<WorkflowState>, (v: WorkflowState) => void] = createSignal(store.getState())
372+
373+
const unsubscribe = store.subscribe(() => {
374+
setState(store.getState())
375+
})
376+
377+
onCleanup(() => unsubscribe())
378+
379+
return {
380+
state,
381+
actions: store,
382+
}
383+
},
384+
})

src/cli/tui/routes/home.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import { useSession } from "@tui/context/session"
1212
import { useRenderer } from "@opentui/solid"
1313
import { TextAttributes } from "@opentui/core"
1414
import { createRequire } from "node:module"
15-
import { fileURLToPath } from "node:url"
1615
import { resolvePackageJson } from "../../../shared/runtime/pkg.js"
1716
import { onMount } from "solid-js"
1817
import * as path from "node:path"

0 commit comments

Comments
 (0)