diff --git a/apps/tui/src/components/app.tsx b/apps/tui/src/components/app.tsx index da60228..8afe05e 100644 --- a/apps/tui/src/components/app.tsx +++ b/apps/tui/src/components/app.tsx @@ -8,12 +8,28 @@ import type { } from "@techatnyu/ralphd"; import { daemon } from "@techatnyu/ralphd"; import { useCallback, useEffect, useState } from "react"; +import { + filterJobsForSession, + flattenRows, + listSessions, + type Row, + type SessionSummary, +} from "../lib/sessions"; import { ralphStore, setModelAndRecent } from "../lib/store"; import { Chat } from "./chat"; type View = | { type: "dashboard" } - | { type: "chat"; instanceId: string; instanceName: string }; + | { + type: "chat"; + instanceId: string; + instanceName: string; + ralphSessionId?: string; + }; + +type Focus = + | { kind: "instance"; instanceId: string } + | { kind: "session"; instanceId: string; sessionId: string }; interface DashboardData { health: HealthResult; @@ -82,13 +98,6 @@ interface AppProps { onQuit(): void; } -function clampIndex(index: number, length: number): number { - if (length <= 0) { - return 0; - } - return Math.min(Math.max(index, 0), length - 1); -} - function countJobsByState( jobs: DaemonJob[], instanceId: string, @@ -103,17 +112,52 @@ function countJobsByState( return { running, queued }; } +function rowKey(row: Row): string { + return row.kind === "instance" + ? `i:${row.instance.id}` + : `s:${row.instance.id}:${row.session.sessionId}`; +} + +function findFocusIndex(rows: Row[], focus: Focus | undefined): number { + if (!focus) return -1; + return rows.findIndex((row) => { + if (focus.kind === "instance") { + return row.kind === "instance" && row.instance.id === focus.instanceId; + } + return ( + row.kind === "session" && + row.instance.id === focus.instanceId && + row.session.sessionId === focus.sessionId + ); + }); +} + +function focusFromRow(row: Row): Focus { + if (row.kind === "instance") { + return { kind: "instance", instanceId: row.instance.id }; + } + return { + kind: "session", + instanceId: row.instance.id, + sessionId: row.session.sessionId, + }; +} + function Dashboard({ onQuit, onSelectInstance, }: { onQuit(): void; - onSelectInstance(instance: ManagedInstance): void; + onSelectInstance(instance: ManagedInstance, ralphSessionId?: string): void; }) { const [loading, setLoading] = useState(true); const [error, setError] = useState(); const [data, setData] = useState(); - const [selectedIndex, setSelectedIndex] = useState(0); + const [focused, setFocused] = useState(); + const [expanded, setExpanded] = useState>(new Set()); + const [sessionsByInstance, setSessionsByInstance] = useState< + Record + >({}); const [currentModel, setCurrentModel] = useState(""); const [modelPicker, setModelPicker] = useState(false); const [modelOptions, setModelOptions] = useState([]); @@ -139,7 +183,7 @@ function Dashboard({ : modelOptions; const refresh = useCallback( - async (nextIndex = selectedIndex) => { + async (nextFocus?: Focus) => { setLoading(true); setError(undefined); try { @@ -149,15 +193,63 @@ function Dashboard({ ralphStore.read(), ]); setCurrentModel(storeState.model); - const safeIndex = clampIndex(nextIndex, instanceList.instances.length); - const selected = instanceList.instances[safeIndex]; - const jobs = await daemon.listJobs( - selected ? { instanceId: selected.id } : {}, + const instances = instanceList.instances; + + // Fetch sessions for every currently-expanded instance that still exists. + const expandedIds = [...expanded].filter((id) => + instances.some((inst) => inst.id === id), + ); + const sessionEntries = await Promise.all( + expandedIds.map(async (id) => { + try { + return [id, await listSessions(id)] as const; + } catch { + return [id, [] as SessionSummary[]] as const; + } + }), ); - setSelectedIndex(safeIndex); + const nextSessions: Record = {}; + for (const [id, list] of sessionEntries) { + nextSessions[id] = list; + } + + // Resolve next focus against the fresh row list. + const candidate = nextFocus ?? focused; + const rows = flattenRows(instances, new Set(expandedIds), nextSessions); + let resolvedFocus: Focus | undefined; + if (candidate) { + const idx = findFocusIndex(rows, candidate); + if (idx >= 0) { + resolvedFocus = candidate; + } else if ( + candidate.kind === "session" && + instances.some((inst) => inst.id === candidate.instanceId) + ) { + // Session disappeared — fall back to parent instance. + resolvedFocus = { + kind: "instance", + instanceId: candidate.instanceId, + }; + } + } + if (!resolvedFocus) { + const firstRow = rows[0]; + if (firstRow) { + resolvedFocus = focusFromRow(firstRow); + } + } + + const focusedInstanceId = resolvedFocus?.instanceId; + const jobs = focusedInstanceId + ? await daemon.listJobs({ instanceId: focusedInstanceId }) + : { jobs: [] as DaemonJob[] }; + + setFocused(resolvedFocus); + setExpanded(new Set(expandedIds)); + setSessionsByInstance(nextSessions); setData({ health, - instances: instanceList.instances, + instances, jobs: jobs.jobs, }); } catch (refreshError) { @@ -170,12 +262,63 @@ function Dashboard({ setLoading(false); } }, - [selectedIndex], + [expanded, focused], ); + // biome-ignore lint/correctness/useExhaustiveDependencies: initial load only; subsequent refreshes are user-driven useEffect(() => { void refresh(); - }, [refresh]); + }, []); + + const rows: Row[] = data + ? flattenRows(data.instances, expanded, sessionsByInstance) + : []; + const focusIndex = findFocusIndex(rows, focused); + + const toggleExpand = useCallback( + async (instanceId: string, expand: boolean) => { + setExpanded((prev) => { + const next = new Set(prev); + if (expand) next.add(instanceId); + else next.delete(instanceId); + return next; + }); + if (expand && !sessionsByInstance[instanceId]) { + try { + const sessions = await listSessions(instanceId); + setSessionsByInstance((prev) => ({ + ...prev, + [instanceId]: sessions, + })); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to list sessions", + ); + } + } + }, + [sessionsByInstance], + ); + + const moveFocus = useCallback( + (delta: number) => { + if (rows.length === 0) return; + const current = focusIndex < 0 ? 0 : focusIndex; + const nextIdx = Math.min(Math.max(current + delta, 0), rows.length - 1); + if (nextIdx === current) return; + const nextRow = rows[nextIdx]; + if (!nextRow) return; + const nextFocus = focusFromRow(nextRow); + // Only refetch jobs when the focused instance changes. + const prevInstanceId = focused?.instanceId; + if (prevInstanceId !== nextFocus.instanceId) { + void refresh(nextFocus); + } else { + setFocused(nextFocus); + } + }, + [focused, focusIndex, refresh, rows], + ); useKeyboard((key) => { if (modelPicker) { @@ -228,32 +371,58 @@ function Dashboard({ return; } - if (!data) { + if (!data || !focused) { return; } if (key.name === "down" || key.name === "j") { - const next = clampIndex(selectedIndex + 1, data.instances.length); - void refresh(next); + moveFocus(1); return; } if (key.name === "up" || key.name === "k") { - const next = clampIndex(selectedIndex - 1, data.instances.length); - void refresh(next); + moveFocus(-1); + return; + } + + if (key.name === "space") { + if (focused.kind === "session") { + setFocused({ kind: "instance", instanceId: focused.instanceId }); + void toggleExpand(focused.instanceId, false); + return; + } + void toggleExpand(focused.instanceId, !expanded.has(focused.instanceId)); return; } if (key.name === "return") { - const selected = data.instances[selectedIndex]; - if (selected) { - onSelectInstance(selected); + const focusedInstance = data.instances.find( + (inst) => inst.id === focused.instanceId, + ); + if (!focusedInstance) return; + if (focused.kind === "session") { + onSelectInstance(focusedInstance, focused.sessionId); + } else { + onSelectInstance(focusedInstance); } return; } }); - const selected = data?.instances[selectedIndex]; + const focusedInstance = focused + ? data?.instances.find((inst) => inst.id === focused.instanceId) + : undefined; + const focusedSession = + focused?.kind === "session" && focused + ? sessionsByInstance[focused.instanceId]?.find( + (s) => s.sessionId === focused.sessionId, + ) + : undefined; + + const jobsToDisplay: DaemonJob[] = + data && focused && focusedSession + ? filterJobsForSession(data.jobs, focusedSession) + : (data?.jobs ?? []); if (modelPicker) { return ( @@ -326,33 +495,65 @@ function Dashboard({ Instances - {data?.instances.length ? ( - data.instances.map((instance: ManagedInstance, index: number) => { - const focused = index === selectedIndex; - const counts = countJobsByState(data.jobs, instance.id); + {rows.length === 0 ? ( + No instances registered + ) : ( + rows.map((row) => { + const isFocused = + focused !== undefined && + ((focused.kind === "instance" && + row.kind === "instance" && + row.instance.id === focused.instanceId) || + (focused.kind === "session" && + row.kind === "session" && + row.instance.id === focused.instanceId && + row.session.sessionId === focused.sessionId)); + const attrs = isFocused + ? TextAttributes.BOLD + : TextAttributes.DIM; + const chevron = isFocused ? ">" : " "; + + if (row.kind === "instance") { + const counts = countJobsByState( + data?.jobs ?? [], + row.instance.id, + ); + const isExpanded = expanded.has(row.instance.id); + const hasKnownSessions = + sessionsByInstance[row.instance.id] !== undefined; + const marker = isExpanded ? "▾" : hasKnownSessions ? "▸" : "▸"; + return ( + + {`${chevron} ${marker} ${row.instance.name} [${row.instance.status}] ${basename(row.instance.directory)} (${counts.running}r/${counts.queued}q)`} + + ); + } + + const { total, completed } = row.session.progress; return ( - - {`${focused ? ">" : " "} ${instance.name} [${instance.status}] ${basename(instance.directory)} (${counts.running}r/${counts.queued}q)`} + + {` ${chevron} ${row.session.sessionId} ${row.session.title} (${completed}/${total} tasks)`} ); }) - ) : ( - No instances registered )} - {selected ? `Jobs for ${selected.name}` : "Jobs"} + {focusedInstance + ? focusedSession + ? `Jobs for ${focusedInstance.name} / ${focusedSession.sessionId}` + : `Jobs for ${focusedInstance.name}` + : "Jobs"} - {selected ? ( - data?.jobs.length ? ( - data.jobs.map((job: DaemonJob) => ( + {focusedInstance ? ( + focusedSession ? ( + + No jobs recorded for this session yet. + + ) : jobsToDisplay.length ? ( + jobsToDisplay.map((job: DaemonJob) => ( {`${job.id.slice(0, 8)} ${job.state} ${job.task.type === "prompt" ? job.task.prompt : ""}`} @@ -373,7 +574,7 @@ function Dashboard({ {error ?? - "j/k or arrows: select enter: chat m: model r: refresh q: quit"} + "space: expand/collapse j/k: move enter: open m: model r: refresh q: quit"} @@ -388,6 +589,7 @@ export function App({ onQuit }: AppProps) { setView({ type: "dashboard" })} onQuit={onQuit} /> @@ -397,11 +599,12 @@ export function App({ onQuit }: AppProps) { return ( + onSelectInstance={(instance, ralphSessionId) => setView({ type: "chat", instanceId: instance.id, instanceName: instance.name, + ralphSessionId, }) } /> diff --git a/apps/tui/src/components/chat.tsx b/apps/tui/src/components/chat.tsx index 7b7b04a..3b4da0b 100644 --- a/apps/tui/src/components/chat.tsx +++ b/apps/tui/src/components/chat.tsx @@ -44,11 +44,24 @@ function messagesFromJob(job: DaemonJob): ChatMessage[] { interface ChatProps { instanceId: string; instanceName: string; + /** + * Ralph session id (directory under RALPH_HOME/sessions//) + * when chat is opened from an expanded session row. Currently plumbed + * through but not yet consumed — will be used once a Ralph-session ↔ + * OpenCode-session mapping is persisted. + */ + ralphSessionId?: string; onBack(): void; onQuit(): void; } -export function Chat({ instanceId, instanceName, onBack, onQuit }: ChatProps) { +export function Chat({ + instanceId, + instanceName, + ralphSessionId: _ralphSessionId, + onBack, + onQuit, +}: ChatProps) { const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(""); const [isLoading, setIsLoading] = useState(false); diff --git a/apps/tui/src/lib/sessions.ts b/apps/tui/src/lib/sessions.ts new file mode 100644 index 0000000..dc51254 --- /dev/null +++ b/apps/tui/src/lib/sessions.ts @@ -0,0 +1,156 @@ +import type { Dirent } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { DaemonJob, ManagedInstance } from "@techatnyu/ralphd"; +import { resolveDaemonPaths } from "@techatnyu/ralphd"; + +const SPEC_FILENAME = "SPEC.md"; +const PRD_FILENAME = "prd.json"; +const SPEC_READ_LIMIT_BYTES = 4096; + +export interface SessionProgress { + total: number; + completed: number; +} + +export interface SessionSummary { + instanceId: string; + sessionId: string; + directory: string; + title: string; + progress: SessionProgress; +} + +export interface SessionProgressAdapter { + read(sessionDir: string): Promise; +} + +interface PrdTask { + done?: unknown; + status?: unknown; +} + +interface PrdShape { + tasks?: PrdTask[]; +} + +function isDoneTask(task: PrdTask): boolean { + return task.done === true || task.status === "done"; +} + +export const prdJsonProgressAdapter: SessionProgressAdapter = { + async read(sessionDir: string): Promise { + try { + const raw = await readFile(join(sessionDir, PRD_FILENAME), "utf8"); + const parsed = JSON.parse(raw) as PrdShape; + if (!parsed || !Array.isArray(parsed.tasks)) { + return { total: 0, completed: 0 }; + } + const total = parsed.tasks.length; + let completed = 0; + for (const task of parsed.tasks) { + if (task && typeof task === "object" && isDoneTask(task)) { + completed++; + } + } + return { total, completed }; + } catch { + return { total: 0, completed: 0 }; + } + }, +}; + +async function readSpecTitle( + sessionDir: string, + fallback: string, +): Promise { + try { + const handle = await readFile(join(sessionDir, SPEC_FILENAME), "utf8"); + const head = handle.slice(0, SPEC_READ_LIMIT_BYTES); + const match = head.match(/^#\s+(.+)$/m); + const captured = match?.[1]; + if (captured) { + const title = captured.trim(); + if (title.length > 0) { + return title; + } + } + return fallback; + } catch { + return fallback; + } +} + +export interface ListSessionsOptions { + ralphHome?: string; + progressAdapter?: SessionProgressAdapter; +} + +export async function listSessions( + instanceId: string, + opts: ListSessionsOptions = {}, +): Promise { + const ralphHome = opts.ralphHome ?? resolveDaemonPaths(process.env).ralphHome; + const adapter = opts.progressAdapter ?? prdJsonProgressAdapter; + const instanceDir = join(ralphHome, "sessions", instanceId); + + let entries: Dirent[]; + try { + entries = (await readdir(instanceDir, { withFileTypes: true })) as Dirent[]; + } catch { + return []; + } + + const sessionIds = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort((a, b) => a.localeCompare(b)); + + return Promise.all( + sessionIds.map(async (sessionId) => { + const directory = join(instanceDir, sessionId); + const [title, progress] = await Promise.all([ + readSpecTitle(directory, sessionId), + adapter.read(directory), + ]); + return { instanceId, sessionId, directory, title, progress }; + }), + ); +} + +export type Row = + | { kind: "instance"; instance: ManagedInstance } + | { kind: "session"; instance: ManagedInstance; session: SessionSummary }; + +export function flattenRows( + instances: ManagedInstance[], + expanded: Set, + sessionsByInstance: Record, +): Row[] { + const rows: Row[] = []; + for (const instance of instances) { + rows.push({ kind: "instance", instance }); + if (!expanded.has(instance.id)) continue; + const sessions = sessionsByInstance[instance.id]; + if (!sessions) continue; + for (const session of sessions) { + rows.push({ kind: "session", instance, session }); + } + } + return rows; +} + +/** + * Filter daemon jobs to those belonging to the given Ralph session. + * + * Current behaviour: returns `[]`. Daemon jobs carry an OpenCode chat session id + * (`job.sessionId`), not a Ralph session id, and no mapping is persisted yet. + * When that mapping lands (either a file inside the session directory or a + * `ralphSessionId` field on jobs), update this function in-place. + */ +export function filterJobsForSession( + _jobs: DaemonJob[], + _session: SessionSummary, +): DaemonJob[] { + return []; +}