diff --git a/src/cli/commands/interactive/shared.ts b/src/cli/commands/interactive/shared.ts index 2d3939e4..2a0e49c4 100644 --- a/src/cli/commands/interactive/shared.ts +++ b/src/cli/commands/interactive/shared.ts @@ -512,6 +512,13 @@ export interface InteractiveCtx { * the ghost toggle. */ suggestGhostConfig?: boolean; + /** + * When set, the REPL is in task-view mode and this field holds the ID of + * the subagent being viewed. `/tasks:view ` sets this field; pressing + * Esc (via `setSoftStopHandler`) clears it and restores the main view. + * Absent (undefined) when in normal REPL mode. + */ + viewingTaskId?: string; /** * Hook registry for dispatching harness lifecycle events from the REPL loop. * Absent in test stubs that do not exercise hooks. Set by bootstrap.ts from diff --git a/src/cli/commands/interactive/task-view-mode.ts b/src/cli/commands/interactive/task-view-mode.ts new file mode 100644 index 00000000..acf046ce --- /dev/null +++ b/src/cli/commands/interactive/task-view-mode.ts @@ -0,0 +1,261 @@ +/** + * Live task-view mode for the REPL. + * + * Entered when `/tasks:view ` is invoked. While active, the REPL + * shows the subagent's conversation history and tails live output from a + * running subagent in real-time. Pressing Esc returns to the normal prompt. + * + * Architecture: + * - `enterTaskViewMode` — called by `/tasks:view`. Renders history, + * starts live tailing if the subagent is still running, and wires Esc + * to exit via `ctx.setSoftStopHandler`. + * - `renderTaskViewHeader` — builds the status header for a given task. + * - `buildTaskFooterLine` — footer line shown under the conversation. + * - `exitTaskViewMode` — clears `ctx.viewingTaskId`, restores the status + * line, and emits a return banner. + * + * @module cli/commands/interactive/task-view-mode + */ + +import { palette } from '../../palette.js'; +import { formatOutputEvent } from '../../output-event-format.js'; +import { renderMessagesView } from './task-view.js'; +import { SubagentLogReader } from '../../../agent/subagent/log.js'; +import type { SubagentManager } from '../../../agent/subagent.js'; +import type { SlashContext } from '../../slash/types.js'; +import type { InteractiveCtx } from './shared.js'; +import type { OutputEvent } from '../../../agent/types/session-types.js'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** Narrow seam passed from the slash context into the view mode entry point. */ +export interface TaskViewEntry { + /** Resolved subagent ID to view. */ + id: string; + /** Subagent manager to look up running handles. */ + manager: SubagentManager; + /** Session label for disk-log fallback. */ + sessionLabel: string; + /** SlashContext for ui/out/setSoftStopHandler access. */ + ctx: SlashContext; + /** + * InteractiveCtx used to set/clear `viewingTaskId`. Optional — absent in + * pure-slash test environments where InteractiveCtx is not wired. + */ + ictx?: InteractiveCtx; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const FOOTER_RUNNING = palette.dim(' Task running… (press Esc to return)'); +const FOOTER_COMPLETE = palette.dim(' Task complete (press Esc to return)'); +const FOOTER_RETURN = palette.dim(' Returned to main conversation.'); + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** Format the status badge for a given subagent status string. */ +function statusBadge(status: string): string { + if (status === 'succeeded' || status === 'completed') return palette.success(status); + if (status === 'failed') return palette.error(status); + if (status === 'running') return palette.info(status); + if (status === 'cancelled') return palette.dim(status); + return palette.dim(status); +} + +/** Separator line sized to terminal width (max 100). */ +function sep(width = 80): string { + const w = Math.min(width, 100); + return palette.dim('─'.repeat(w)); +} + +/** Render the header for a task view panel. */ +export function renderTaskViewHeader( + id: string, + status: string, + agentType?: string, +): string { + const parts: string[] = [ + palette.bold(`Subagent: ${id.slice(0, 20)}`), + ...(agentType ? [palette.dim(`type: ${agentType}`)] : []), + `status: ${statusBadge(status)}`, + ]; + return [sep(), parts.join(' '), sep()].join('\n'); +} + +/** Build the footer line shown under the conversation body. */ +export function buildTaskFooterLine(isRunning: boolean): string { + return isRunning ? FOOTER_RUNNING : FOOTER_COMPLETE; +} + +// --------------------------------------------------------------------------- +// Exit helper +// --------------------------------------------------------------------------- + +/** + * Exit task-view mode: clears `viewingTaskId`, repaint the status line, + * and emits a brief return notice via `ctx.out`. + */ +export function exitTaskViewMode(entry: TaskViewEntry): void { + if (entry.ictx) { + entry.ictx.viewingTaskId = undefined; + } + entry.ctx.ui.repaintStatusLine(); + entry.ctx.out.line(''); + entry.ctx.out.line(FOOTER_RETURN); +} + +// --------------------------------------------------------------------------- +// Disk-replay helper +// --------------------------------------------------------------------------- + +/** Render all disk-log events for a subagent and return the line count. */ +async function replayDiskEvents( + entry: TaskViewEntry, + limit = 500, +): Promise { + let count = 0; + for await (const event of SubagentLogReader.readEvents(entry.sessionLabel, entry.id)) { + if (count >= limit) break; + const text = formatOutputEvent(event); + if (text !== null) { + entry.ctx.out.line(text); + count++; + } + } + return count; +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +/** + * Enter live task-view mode for a given subagent. + * + * 1. Clears the screen and renders the history header. + * 2. Replays disk events (history path) OR renders in-memory history. + * 3. If the subagent is still running, tails live output events until it + * finishes or the user presses Esc. + * 4. Wires Esc via `ctx.setSoftStopHandler` to call `exitTaskViewMode`. + * + * Returns once the subagent has completed (or Esc was pressed). + */ +export async function enterTaskViewMode(entry: TaskViewEntry): Promise { + const { id, manager, ctx, ictx } = entry; + + // Mark as viewing so the REPL loop knows we are in task-view mode. + if (ictx) ictx.viewingTaskId = id; + + // Clear the screen before rendering the task view. + ctx.ui.clearScreen(); + + const handle = manager.get(id); + const status = handle ? handle.status : 'completed'; + const agentType = (handle as unknown as { _agentType?: string })?._agentType; + + // ── Memory-first: render from handle.session.getHistory() ──────────────── + // getHistory is optional on the interface — some session implementations + // don't expose it. Fall through to disk replay when absent. + // Invariant: the memory path renders via renderMessagesView (task-view.ts) + // which truncates tool_use/tool_result content — never raw JSON.stringify. + if (handle && typeof handle.session.getHistory === 'function') { + const history = handle.session.getHistory(); + ctx.out.line(renderMessagesView({ id, status, agentType }, history)); + if (history.length === 0) { + ctx.out.line(palette.dim(' (no history yet)')); + } + ctx.out.line(''); + } else { + ctx.out.line(renderTaskViewHeader(id, status, agentType)); + ctx.out.line(''); + // ── Disk fallback: replay JSONL events ────────────────────────────────── + const count = await replayDiskEvents(entry); + if (count === 0) { + ctx.out.line(palette.dim(' (no events recorded)')); + } + ctx.out.line(''); + // Disk-only means already completed — show footer and wire Esc. + ctx.out.line(buildTaskFooterLine(false)); + wireEscapeToExit(entry); + // Clear viewingTaskId since we are not blocking in a tail loop. + if (ictx) ictx.viewingTaskId = undefined; + return; + } + + const isRunning = status === 'running' || status === 'idle'; + ctx.out.line(buildTaskFooterLine(isRunning)); + + // Wire Esc → exit regardless of running/completed state. + wireEscapeToExit(entry); + + // ── Completed subagents: render and return. ────────────────────────────── + // viewingTaskId is cleared immediately since there's no blocking tail loop. + if (!isRunning) { + if (ictx) ictx.viewingTaskId = undefined; + return; + } + + // Abort controller so we can stop tailing when Esc is pressed. + const abort = new AbortController(); + const { signal } = abort; + + // Override the Esc handler to also abort the tail loop. + ctx.setSoftStopHandler?.(() => { + abort.abort(); + exitTaskViewMode(entry); + ctx.setSoftStopHandler?.(null); + }); + + try { + await tailOutputStream(handle.session.getOutputStream(), ctx, signal); + } catch { + // Abort or any stream error — exit cleanly. + } finally { + // The subagent finished naturally (not Esc). Update footer and exit view. + if (!signal.aborted) { + ctx.out.line(''); + ctx.out.line(FOOTER_COMPLETE); + exitTaskViewMode(entry); + ctx.setSoftStopHandler?.(null); + } + } +} + +// --------------------------------------------------------------------------- +// Live tail helpers +// --------------------------------------------------------------------------- + +/** + * Wire Esc to call `exitTaskViewMode` and clear the soft-stop handler. + * Safe to call even when `setSoftStopHandler` is not wired (non-TTY surfaces). + */ +function wireEscapeToExit(entry: TaskViewEntry): void { + entry.ctx.setSoftStopHandler?.(() => { + exitTaskViewMode(entry); + entry.ctx.setSoftStopHandler?.(null); + }); +} + +/** + * Iterate over a live output stream, rendering each event to `ctx.out`. + * Stops when the stream is exhausted OR `signal` is aborted. + */ +async function tailOutputStream( + stream: AsyncIterable, + ctx: SlashContext, + signal: AbortSignal, +): Promise { + for await (const event of stream) { + if (signal.aborted) break; + const text = formatOutputEvent(event); + if (text !== null) { + ctx.out.line(text); + } + } +} diff --git a/src/cli/commands/interactive/task-view.test.ts b/src/cli/commands/interactive/task-view.test.ts new file mode 100644 index 00000000..fe658945 --- /dev/null +++ b/src/cli/commands/interactive/task-view.test.ts @@ -0,0 +1,273 @@ +/** + * Tests for v2 live task-view mode. + * + * Covers: + * - renderTaskViewHeader — header string construction + * - buildTaskFooterLine — footer message for running/completed states + * - exitTaskViewMode — clears viewingTaskId, repaint, emits return notice + * - enterTaskViewMode (disk path) — renders events from disk replay + * - enterTaskViewMode (memory path) — renders messages from handle.getHistory() + * - enterTaskViewMode (completed handle) — shows completed footer, no tail loop + * - enterTaskViewMode (missing handle + no disk) — falls back gracefully + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as os from 'node:os'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +// Temp AFK_HOME so disk lookups don't touch real ~/.afk +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'afk-tv-test-')); +process.env['AFK_HOME'] = tmpDir; + +import { + renderTaskViewHeader, + buildTaskFooterLine, + exitTaskViewMode, + enterTaskViewMode, + type TaskViewEntry, +} from './task-view-mode.js'; +import type { SubagentManager } from '../../../agent/subagent.js'; +import type { SubagentHandle } from '../../../agent/subagent/handle.js'; +import type { SubagentStatus } from '../../../agent/subagent/result.js'; +import type { SlashContext, SessionStats } from '../../slash/types.js'; +import type { InteractiveCtx } from './shared.js'; +import { CompletedCache } from '../../../agent/subagent/completed-cache.js'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function makeStats(): SessionStats { + return { + totalTurns: 0, + totalCostUsd: 0, + totalTokens: 0, + totalDurationMs: 0, + sessionStartTime: Date.now(), + turnCosts: [], + turnTokens: [], + turns: [], + model: 'sonnet', + permissionMode: 'default', + }; +} + +function makeCtx(overrides: Partial = {}): { ctx: SlashContext; lines: string[] } { + const lines: string[] = []; + const ctx: SlashContext = { + session: { current: {} } as unknown as SlashContext['session'], + stats: makeStats(), + out: { + line: (t = ''): void => { lines.push(t); }, + raw: (t): void => { lines.push(t); }, + success: (t): void => { lines.push(`SUCCESS:${t}`); }, + info: (t): void => { lines.push(`INFO:${t}`); }, + warn: (t): void => { lines.push(`WARN:${t}`); }, + error: (t): void => { lines.push(`ERROR:${t}`); }, + }, + ui: { clearScreen: vi.fn(), repaintStatusLine: vi.fn() }, + setSoftStopHandler: vi.fn(), + ...overrides, + }; + return { ctx, lines }; +} + +function makeHandle(id: string, status: SubagentStatus = 'succeeded'): SubagentHandle { + return { + id, + status, + cancel: vi.fn().mockResolvedValue(undefined), + teardown: vi.fn().mockResolvedValue(undefined), + run: vi.fn(), + runToResult: vi.fn(), + runInBackground: vi.fn(), + session: { + getHistory: vi.fn().mockReturnValue([]), + getOutputStream: vi.fn().mockImplementation(async function* () {}), + sessionId: `sess-${id}`, + }, + } as unknown as SubagentHandle; +} + +function makeManager( + active: SubagentHandle[] = [], + completedEntries: { id: string; handle: SubagentHandle }[] = [], +): SubagentManager { + const completedCache = new CompletedCache(); + for (const { id, handle } of completedEntries) { + completedCache.add(id, handle, { + id, + status: 'succeeded', + } as unknown as import('../../../agent/subagent/result.js').SubagentResult); + } + return { + list: () => active.map(h => ({ id: h.id, status: h.status })), + get: (id: string) => { + const found = active.find(h => h.id === id); + if (found) return found; + return completedCache.get(id)?.handle; + }, + completed: completedCache, + } as unknown as SubagentManager; +} + +function makeICtx(overrides: Partial = {}): InteractiveCtx { + return { viewingTaskId: undefined, ...overrides } as unknown as InteractiveCtx; +} + +// --------------------------------------------------------------------------- +// renderTaskViewHeader +// --------------------------------------------------------------------------- + +describe('renderTaskViewHeader', () => { + it('includes the subagent id', () => { + const header = renderTaskViewHeader('abc-123', 'succeeded'); + expect(header).toContain('abc-123'); + }); + + it('includes the agent type when provided', () => { + const header = renderTaskViewHeader('abc-123', 'running', 'background'); + expect(header).toContain('background'); + }); + + it('includes status text', () => { + const header = renderTaskViewHeader('abc-123', 'failed'); + expect(header).toContain('failed'); + }); +}); + +// --------------------------------------------------------------------------- +// buildTaskFooterLine +// --------------------------------------------------------------------------- + +describe('buildTaskFooterLine', () => { + it('shows "running" footer when isRunning=true', () => { + const line = buildTaskFooterLine(true); + expect(line).toContain('running'); + }); + + it('shows "complete" footer when isRunning=false', () => { + const line = buildTaskFooterLine(false); + expect(line).toContain('complete'); + }); + + it('always includes Esc hint', () => { + expect(buildTaskFooterLine(true)).toContain('Esc'); + expect(buildTaskFooterLine(false)).toContain('Esc'); + }); +}); + +// --------------------------------------------------------------------------- +// exitTaskViewMode +// --------------------------------------------------------------------------- + +describe('exitTaskViewMode', () => { + it('clears viewingTaskId on ictx', () => { + const { ctx } = makeCtx(); + const ictx = makeICtx({ viewingTaskId: 'some-id' }); + exitTaskViewMode({ id: 'some-id', manager: makeManager(), sessionLabel: 'lbl', ctx, ictx }); + expect(ictx.viewingTaskId).toBeUndefined(); + }); + + it('calls repaintStatusLine', () => { + const { ctx } = makeCtx(); + const ictx = makeICtx(); + exitTaskViewMode({ id: 'x', manager: makeManager(), sessionLabel: 'lbl', ctx, ictx }); + expect(ctx.ui.repaintStatusLine).toHaveBeenCalledOnce(); + }); + + it('emits a return notice line', () => { + const { ctx, lines } = makeCtx(); + exitTaskViewMode({ id: 'x', manager: makeManager(), sessionLabel: 'lbl', ctx }); + expect(lines.some(l => l.includes('Returned'))).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// enterTaskViewMode — completed handle (memory path) +// --------------------------------------------------------------------------- + +describe('enterTaskViewMode — completed handle', () => { + it('clears screen on entry', async () => { + const h = makeHandle('h1', 'succeeded'); + const manager = makeManager([h]); + const { ctx } = makeCtx(); + await enterTaskViewMode({ id: 'h1', manager, sessionLabel: 'lbl', ctx }); + expect(ctx.ui.clearScreen).toHaveBeenCalled(); + }); + + it('renders header containing the subagent id', async () => { + const h = makeHandle('h-mem-1', 'succeeded'); + const manager = makeManager([h]); + const { ctx, lines } = makeCtx(); + await enterTaskViewMode({ id: 'h-mem-1', manager, sessionLabel: 'lbl', ctx }); + const flat = lines.join('\n'); + expect(flat).toContain('h-mem-1'); + }); + + it('renders "(no history yet)" when getHistory returns []', async () => { + const h = makeHandle('h-empty', 'succeeded'); + const manager = makeManager([h]); + const { ctx, lines } = makeCtx(); + await enterTaskViewMode({ id: 'h-empty', manager, sessionLabel: 'lbl', ctx }); + expect(lines.some(l => l.includes('no history'))).toBe(true); + }); + + it('renders messages from history when present', async () => { + const h = makeHandle('h-hist', 'succeeded'); + (h.session as unknown as { getHistory: ReturnType }).getHistory = + vi.fn().mockReturnValue([ + { role: 'user', content: 'hello user' }, + { role: 'assistant', content: 'hello assistant' }, + ]); + const manager = makeManager([h]); + const { ctx, lines } = makeCtx(); + await enterTaskViewMode({ id: 'h-hist', manager, sessionLabel: 'lbl', ctx }); + const flat = lines.join('\n'); + expect(flat).toContain('hello user'); + expect(flat).toContain('hello assistant'); + }); + + it('shows completed footer (not running)', async () => { + const h = makeHandle('h-done', 'succeeded'); + const manager = makeManager([h]); + const { ctx, lines } = makeCtx(); + await enterTaskViewMode({ id: 'h-done', manager, sessionLabel: 'lbl', ctx }); + expect(lines.some(l => l.includes('complete') && l.includes('Esc'))).toBe(true); + }); + + it('sets viewingTaskId on ictx during view', async () => { + const h = makeHandle('h-ictx', 'succeeded'); + const manager = makeManager([h]); + const { ctx } = makeCtx(); + const ictx = makeICtx(); + await enterTaskViewMode({ id: 'h-ictx', manager, sessionLabel: 'lbl', ctx, ictx }); + // After completion, viewingTaskId is cleared (completed path) + expect(ictx.viewingTaskId).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// enterTaskViewMode — disk fallback (no handle in memory) +// --------------------------------------------------------------------------- + +describe('enterTaskViewMode — disk fallback', () => { + it('renders disk events when handle is not in memory', async () => { + const manager = makeManager([]); // no active handles + const { ctx, lines } = makeCtx(); + // sessionLabel that doesn't match any real file → empty replay + await enterTaskViewMode({ id: 'ghost-id', manager, sessionLabel: 'no-such-session', ctx }); + const flat = lines.join('\n'); + // Should have header + "(no events recorded)" + footer separator + expect(flat).toContain('ghost-id'); + expect(flat).toContain('no events'); + }); + + it('shows completed footer for disk-only path', async () => { + const manager = makeManager([]); + const { ctx, lines } = makeCtx(); + await enterTaskViewMode({ id: 'disk-id', manager, sessionLabel: 'x', ctx }); + expect(lines.some(l => l.includes('complete') && l.includes('Esc'))).toBe(true); + }); +}); diff --git a/src/cli/commands/interactive/task-view.ts b/src/cli/commands/interactive/task-view.ts new file mode 100644 index 00000000..45234a78 --- /dev/null +++ b/src/cli/commands/interactive/task-view.ts @@ -0,0 +1,278 @@ +/** + * Task-view replay renderer. + * + * Renders a subagent's conversation history to a terminal string via two + * input paths: + * + * - Memory path — takes a `Message[]` array already held in memory and + * renders each message using renderMarkdownToTerminal. + * + * - Disk path — takes an `AsyncIterable` (produced by + * BgJobLogReader.readEvents()) and renders events with formatOutputEvent. + * + * Both paths emit a header box, the body, and a footer separator, and both + * return a fully-assembled string so callers can page, write, or stream it + * as they see fit. + * + * @module cli/commands/interactive/task-view + */ + +import { palette } from '../../palette.js'; +import { renderMarkdownToTerminal } from '../../formatter.js'; +import { formatOutputEvent } from '../../output-event-format.js'; +import { getTerminalWidth } from '../../terminal-size.js'; +import type { Message } from '../../../agent/types/message-types.js'; +import type { OutputEvent } from '../../../agent/types/session-types.js'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export interface TaskViewHeader { + /** Background job / subagent ID. */ + id: string; + /** Human-readable agent type label (e.g. "background", "worktree"). */ + agentType?: string; + /** Model identifier used by this subagent. */ + model?: string; + /** Job status string (e.g. "completed", "running", "failed"). */ + status: string; + /** Wall-clock duration in milliseconds. */ + durationMs?: number; + /** Number of tool-use calls recorded. */ + toolCount?: number; + /** Number of conversation turns. */ + turnCount?: number; +} + +export interface TaskViewOptions { + /** Maximum number of disk events to render (default 500). */ + maxEvents?: number; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEFAULT_MAX_EVENTS = 500; +/** Truncation limit for tool input / result previews (characters). */ +const CONTENT_PREVIEW_CHARS = 200; + +// --------------------------------------------------------------------------- +// Header / footer helpers +// --------------------------------------------------------------------------- + +/** + * Build the header box string for a task-view panel. + * Width is clamped to the current terminal width. + */ +function buildHeader(h: TaskViewHeader): string { + const width = Math.min(getTerminalWidth(), 100); + const sep = palette.dim('─'.repeat(width)); + + const statusColor = + h.status === 'completed' ? palette.success : + h.status === 'failed' ? palette.error : + h.status === 'running' ? palette.info : + palette.dim; + + const parts: string[] = []; + parts.push(palette.bold(`Subagent: ${h.id}`)); + if (h.agentType) parts.push(palette.dim(`type: ${h.agentType}`)); + if (h.model) parts.push(palette.dim(`model: ${h.model}`)); + parts.push(`status: ${statusColor(h.status)}`); + if (h.durationMs !== undefined) { + parts.push(palette.dim(`duration: ${formatMs(h.durationMs)}`)); + } + if (h.toolCount !== undefined) { + parts.push(palette.dim(`tools: ${h.toolCount}`)); + } + if (h.turnCount !== undefined) { + parts.push(palette.dim(`turns: ${h.turnCount}`)); + } + + return [sep, parts.join(' '), sep].join('\n'); +} + +/** Build the footer separator. */ +function buildFooter(): string { + const width = Math.min(getTerminalWidth(), 100); + return palette.dim('─'.repeat(width)); +} + +/** Format milliseconds as a compact human duration. */ +function formatMs(ms: number): string { + if (ms < 1000) return `${ms}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; + const mins = Math.floor(ms / 60_000); + const secs = Math.round((ms % 60_000) / 1000); + return `${mins}m${secs}s`; +} + +// --------------------------------------------------------------------------- +// Message-block renderers (memory path) +// --------------------------------------------------------------------------- + +/** Truncate a string to `max` chars, appending ellipsis when trimmed. */ +function truncate(text: string, max: number): string { + if (text.length <= max) return text; + return text.slice(0, max) + palette.dim('…'); +} + +/** Render a single content block from an assistant message. */ +function renderContentBlock(block: unknown): string { + // Contract: block is a raw Anthropic SDK content block object or a string + // when the message content is already serialized. The SDK types are not + // imported here to avoid adding a direct SDK dependency to this module. + if (typeof block === 'string') { + return renderMarkdownToTerminal(block); + } + if (typeof block !== 'object' || block === null) { + return palette.dim(String(block)); + } + const b = block as Record; + + if (b['type'] === 'text') { + const text = typeof b['text'] === 'string' ? b['text'] : ''; + return renderMarkdownToTerminal(text); + } + + if (b['type'] === 'tool_use') { + const name = typeof b['name'] === 'string' ? b['name'] : '?'; + const input = b['input'] !== undefined ? JSON.stringify(b['input']) : ''; + return [ + palette.tool(` ▶ tool_use: ${name}`), + input ? palette.dim(` ${truncate(input, CONTENT_PREVIEW_CHARS)}`) : '', + ].filter(Boolean).join('\n'); + } + + if (b['type'] === 'tool_result') { + const isError = b['is_error'] === true; + const label = isError ? palette.error('✗ tool_result') : palette.success('✓ tool_result'); + const content = b['content']; + let preview = ''; + if (typeof content === 'string') { + preview = truncate(content, CONTENT_PREVIEW_CHARS); + } else if (Array.isArray(content)) { + const first = (content[0] as Record | undefined); + const text = first && typeof first['text'] === 'string' ? first['text'] : ''; + preview = truncate(text, CONTENT_PREVIEW_CHARS); + } + return [ + ` ${label}`, + preview ? palette.dim(` ${preview}`) : '', + ].filter(Boolean).join('\n'); + } + + // Fallback: emit the block type as a dim badge. + const typeLabel = typeof b['type'] === 'string' ? b['type'] : 'unknown'; + return palette.dim(` [${typeLabel}]`); +} + +/** + * Render a single Message (user or assistant) to a terminal string block. + * The returned string includes a role header and all content blocks. + */ +function renderMessage(msg: Message): string { + const roleLabel = + msg.role === 'user' + ? palette.user('User') + : palette.heading('Assistant'); + + const lines: string[] = [palette.dim('·') + ' ' + roleLabel]; + + // Message.content is always a string (per message-types.ts). + // It may hold raw markdown prose, or JSON-serialized content blocks from + // the assistant turn. Attempt block-array parse; fall back to plain text. + const raw = msg.content; + let parsed: unknown = null; + try { parsed = JSON.parse(raw); } catch { /* not JSON — render as text */ } + + if (Array.isArray(parsed)) { + for (const block of parsed) { + lines.push(renderContentBlock(block)); + } + } else { + lines.push(renderMarkdownToTerminal(raw)); + } + + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Render a subagent conversation from an in-memory Message[] history. + * + * Contract: returns a fully assembled terminal string (header + body + footer) + * with a trailing newline. Never throws; rendering errors for individual + * messages are caught and emitted as dim error lines. + */ +export function renderMessagesView( + header: TaskViewHeader, + messages: readonly Message[], +): string { + const sections: string[] = [buildHeader(header), '']; + + for (const msg of messages) { + try { + sections.push(renderMessage(msg)); + sections.push(''); + } catch (err) { + const label = err instanceof Error ? err.message : String(err); + sections.push(palette.dim(` [render error: ${label}]`), ''); + } + } + + sections.push(buildFooter()); + return sections.join('\n'); +} + +/** + * Render a subagent conversation from a disk OutputEvent replay stream. + * + * Contract: reads up to `options.maxEvents` (default 500) events, formats + * each with formatOutputEvent (null returns are skipped), and returns a + * fully assembled terminal string (header + body + footer) with a trailing + * newline. + */ +export async function renderEventsView( + header: TaskViewHeader, + events: AsyncIterable, + options?: TaskViewOptions, +): Promise { + const limit = options?.maxEvents ?? DEFAULT_MAX_EVENTS; + const lines: string[] = []; + let count = 0; + let truncated = false; + + for await (const event of events) { + if (count >= limit) { + truncated = true; + break; + } + const formatted = formatOutputEvent(event); + if (formatted !== null) { + lines.push(formatted); + count++; + } + } + + const sections: string[] = [buildHeader(header), '']; + + if (lines.length > 0) { + sections.push(lines.join('\n')); + } else { + sections.push(palette.dim(' (no events recorded)')); + } + + if (truncated) { + sections.push(''); + sections.push(palette.dim(` [truncated — showing first ${limit} events]`)); + } + + sections.push('', buildFooter()); + return sections.join('\n'); +} diff --git a/src/cli/slash/commands/tasks.ts b/src/cli/slash/commands/tasks.ts index 3da06d7f..16f21e25 100644 --- a/src/cli/slash/commands/tasks.ts +++ b/src/cli/slash/commands/tasks.ts @@ -2,8 +2,8 @@ * /tasks — list and view subagent conversations from the REPL. * * Commands: - * /tasks list all subagents (active + recently completed + disk) - * /tasks:view render a subagent's conversation (memory-first, then disk) + * /tasks list all subagents with cursor navigation + * /tasks:view enter live view mode for a subagent conversation * /tasks:cancel cancel a still-running subagent * * Data sources (in resolution order for /tasks): @@ -11,6 +11,11 @@ * 2. manager.completed.list() — recently-completed handles (LRU cache) * 3. SubagentLogReader.list(label) — disk-persisted logs not in memory * + * v2 additions: + * - `/tasks` shows a cursor-navigable list; Enter opens the view, Esc returns. + * - `/tasks:view ` enters live view mode via enterTaskViewMode(). + * - Live tailing streams new output events until completion or Esc. + * * Wiring: call `setTasksRegistry` once from bootstrapSession after the * SubagentManager is constructed. The manager reference is kept as a * module-scope var (same pattern as bgsub.ts). @@ -20,12 +25,14 @@ import { palette } from '../../palette.js'; import { formatDuration } from '../../format-utils.js'; -import { truncateDisplayWidth } from '../../display.js'; -import { formatOutputEvent } from '../../output-event-format.js'; import type { SlashCommand } from '../types.js'; import type { SubagentManager } from '../../../agent/subagent.js'; import type { SubagentStatus } from '../../../agent/subagent/result.js'; import { SubagentLogReader } from '../../../agent/subagent/log.js'; +import { + enterTaskViewMode, + type TaskViewEntry, +} from '../../commands/interactive/task-view-mode.js'; // --------------------------------------------------------------------------- // Module-scope registry refs @@ -104,23 +111,21 @@ function formatHandleLine( id: string, status: SubagentStatus, agentType: string | undefined, - promptHead: string | undefined, durationMs: number | undefined, toolCount: number, + cursor = false, ): string { - const glyph = STATUS_GLYPHS[status]; - const shortId = id.slice(0, 12); + const glyph = STATUS_GLYPHS[status]; + const shortId = id.slice(0, 12); const typeLabel = agentType ? palette.dim(`[${agentType}]`) : ''; - const prompt = promptHead - ? truncateDisplayWidth(promptHead, 60) - : palette.dim('(no prompt)'); - const dur = durationMs !== undefined ? palette.dim(`${formatDuration(durationMs)}`) : ''; - const tools = palette.dim(`${toolCount} calls`); + const dur = durationMs !== undefined ? palette.dim(`${formatDuration(durationMs)}`) : ''; + const tools = palette.dim(`${toolCount} calls`); + const cursorGlyph = cursor ? '▶' : ' '; + const idFormatted = cursor ? palette.bold(shortId) : shortId; const parts = [ - ` ${glyph}`, - palette.bold(shortId), + ` ${cursorGlyph} ${glyph}`, + idFormatted, typeLabel, - prompt, dur, tools, ].filter(Boolean); @@ -128,95 +133,189 @@ function formatHandleLine( } // --------------------------------------------------------------------------- -// /tasks +// All-IDs collector (shared by /tasks and tasksViewCmd) +// --------------------------------------------------------------------------- + +interface TaskEntry { + id: string; + status: SubagentStatus; + agentType?: string; + durationMs?: number; + toolCount: number; +} + +async function collectAllTasks( + manager: SubagentManager, + sessionLabel: string, +): Promise { + const entries: TaskEntry[] = []; + + // Active handles. + for (const { id, status } of manager.list()) { + const handle = manager.get(id); + const impl = handle as unknown as { _agentType?: string; _currentTrace?: { toolCalls: unknown[] } }; + entries.push({ + id, + status, + agentType: impl._agentType, + toolCount: impl._currentTrace?.toolCalls.length ?? 0, + }); + } + + // Completed handles. + const activeIds = new Set(manager.list().map(h => h.id)); + for (const entry of manager.completed.list()) { + if (activeIds.has(entry.handle.id)) continue; + const impl = entry.handle as unknown as { + _agentType?: string; + _currentTrace?: { toolCalls: unknown[] }; + _lastDurationMs?: number; + }; + entries.push({ + id: entry.handle.id, + status: entry.handle.status, + agentType: impl._agentType, + durationMs: impl._lastDurationMs, + toolCount: impl._currentTrace?.toolCalls.length ?? 0, + }); + } + + // Disk-only. + const memoryIds = new Set(entries.map(e => e.id)); + const diskIds = (await SubagentLogReader.list(sessionLabel)).filter(id => !memoryIds.has(id)); + for (const id of diskIds) { + // v1 limitation: terminal status is not persisted to the log filename or + // header, so we cannot distinguish succeeded/failed/cancelled from disk + // alone. Use 'idle' (renders as '·') as a neutral/unknown indicator. + entries.push({ id, status: 'idle', toolCount: 0 }); + } + + return entries; +} + +// --------------------------------------------------------------------------- +// /tasks — cursor-navigable list // --------------------------------------------------------------------------- export const tasksCmd: SlashCommand = { name: '/tasks', - summary: 'List all subagents (active + recently completed)', + summary: 'List all subagents (active + recently completed) with cursor navigation', usage: '/tasks', - hint: 'When you want to see what subagents this session has spawned, their status, and prompt heads.', + hint: 'When you want to see what subagents this session has spawned. Use ↑/↓ to navigate, Enter to view, Esc to return.', async handler(ctx) { - const manager = ensureManager(ctx); + const manager = ensureManager(ctx); if (!manager) return 'continue'; const sessionLabel = ensureSessionLabel(ctx); if (!sessionLabel) return 'continue'; - // Collect active handles. - const activeRows = manager.list().map(({ id, status }) => { - // Access @internal fields via casting through unknown. - const handle = manager.get(id); - const impl = handle as unknown as { - _agentType?: string; - _currentTrace?: { toolCalls: unknown[] }; - _lastDurationMs?: number; - }; - return formatHandleLine( - id, - status, - impl._agentType, - undefined, // prompt head not retained on the handle - undefined, // duration unknown for still-running - impl._currentTrace?.toolCalls.length ?? 0, - ); - }); + const tasks = await collectAllTasks(manager, sessionLabel); + if (tasks.length === 0) { + ctx.out.info('No subagents in this session yet.'); + return 'continue'; + } - // Collect recently-completed entries. - const completedIds = new Set(manager.list().map(h => h.id)); - const completedRows = manager.completed.list().map(entry => { - if (completedIds.has(entry.handle.id)) return null; // skip duplicates - const impl = entry.handle as unknown as { - _agentType?: string; - _currentTrace?: { toolCalls: unknown[] }; - _lastDurationMs?: number; - }; - return formatHandleLine( - entry.handle.id, - entry.handle.status, - impl._agentType, - undefined, - impl._lastDurationMs, - impl._currentTrace?.toolCalls.length ?? 0, - ); - }).filter((r): r is string => r !== null); - - // Collect disk-only entries (not in memory at all). - const memoryIds = new Set([ - ...manager.list().map(h => h.id), - ...manager.completed.list().map(e => e.handle.id), - ]); - const diskIds = (await SubagentLogReader.list(sessionLabel)) - .filter(id => !memoryIds.has(id)); - // v1 limitation: terminal status is not persisted to the log filename or - // header, so we cannot distinguish succeeded/failed/cancelled from disk - // alone. Use 'idle' (renders as '·') as a neutral/unknown indicator. - const diskRows = diskIds.map(id => - formatHandleLine(id, 'idle' as SubagentStatus, undefined, undefined, undefined, 0), - ); + // ── Cursor navigation state ────────────────────────────────────────────── + let cursor = 0; - const allRows = [...activeRows, ...completedRows, ...diskRows]; - if (allRows.length === 0) { - ctx.out.info('No subagents in this session yet.'); + const renderList = (): void => { + ctx.ui.clearScreen(); + ctx.out.line(palette.dim(` Subagent list — ↑/↓ navigate Enter view Esc return`)); + ctx.out.line(''); + for (let i = 0; i < tasks.length; i++) { + const t = tasks[i]!; + const line = formatHandleLine(t.id, t.status, t.agentType, t.durationMs, t.toolCount, i === cursor); + ctx.out.line(line); + } + ctx.out.line(''); + }; + + // ── If setSoftStopHandler is not available, fall back to plain list ────── + if (!ctx.setSoftStopHandler) { + ctx.out.line(palette.dim(` ${'STATUS'.padEnd(2)} ${'ID'.padEnd(12)} DETAILS`)); + for (const t of tasks) { + ctx.out.line(formatHandleLine(t.id, t.status, t.agentType, t.durationMs, t.toolCount)); + } + ctx.out.line(palette.dim(' Use /tasks:view to open a task.')); return 'continue'; } - ctx.out.line(palette.dim(` ${'STATUS'.padEnd(2)} ${'ID'.padEnd(12)} DETAILS`)); - for (const row of allRows) ctx.out.line(row); + // ── Interactive mode ────────────────────────────────────────────────────── + // Invariant: the TerminalCompositor holds the stdin claim during slash + // dispatch. We must suspend it before attaching our own 'data' listener + // to avoid the dual-consumer phantom-turn bug (#511 class). Resume on + // every exit path (Esc, Ctrl-C, Enter→view, soft-stop). + const compositor = ctx.getCompositor?.() ?? null; + compositor?.suspendInput(); + + renderList(); + + await new Promise((resolve) => { + const cleanup = (): void => { + process.stdin.off('data', onKeypress); + compositor?.resumeInput(); + }; + + const onKeypress = (chunk: Buffer | string): void => { + const str = typeof chunk === 'string' ? chunk : chunk.toString(); + if (str === '\x1b[A' || str === '\x1bOA') { + // Up arrow + cursor = Math.max(0, cursor - 1); + renderList(); + } else if (str === '\x1b[B' || str === '\x1bOB') { + // Down arrow + cursor = Math.min(tasks.length - 1, cursor + 1); + renderList(); + } else if (str === '\r' || str === '\n') { + // Enter — open the selected task in view mode. + const selected = tasks[cursor]; + if (selected) { + ctx.setSoftStopHandler?.(null); + cleanup(); + const entry: TaskViewEntry = { + id: selected.id, + manager, + sessionLabel, + ctx, + }; + void enterTaskViewMode(entry).then(resolve).catch(() => resolve()); + } + } else if (str === '\x1b' || str === '\x03') { + // Esc or Ctrl-C — exit to main prompt. + ctx.setSoftStopHandler?.(null); + cleanup(); + ctx.ui.clearScreen(); + ctx.ui.repaintStatusLine(); + resolve(); + } + }; + + // Wire Esc handler via the surface-level soft-stop. + ctx.setSoftStopHandler?.(() => { + ctx.setSoftStopHandler?.(null); + cleanup(); + ctx.ui.clearScreen(); + ctx.ui.repaintStatusLine(); + resolve(); + }); + + process.stdin.on('data', onKeypress); + }); + return 'continue'; }, }; // --------------------------------------------------------------------------- -// /tasks:view +// /tasks:view — live view mode // --------------------------------------------------------------------------- export const tasksViewCmd: SlashCommand = { name: '/tasks:view', - summary: 'View a subagent\'s conversation', + summary: 'View a subagent\'s conversation (live view mode)', usage: '/tasks:view ', - hint: 'When you want to replay what a subagent said and which tools it called.', + hint: 'When you want to replay what a subagent said and which tools it called. Tails live output for running subagents.', async handler(ctx, args) { - const manager = ensureManager(ctx); + const manager = ensureManager(ctx); if (!manager) return 'continue'; const sessionLabel = ensureSessionLabel(ctx); if (!sessionLabel) return 'continue'; @@ -229,55 +328,27 @@ export const tasksViewCmd: SlashCommand = { // Resolve partial-id prefix. const resolvedId = resolveId(manager, raw) ?? raw; - const handle = manager.get(resolvedId); - - // Memory-first: handle exists AND has getHistory — render conversation. - // When getHistory is absent (some session implementations don't expose it), - // fall through to the disk-based replay path below so logs are still shown. - if (handle && typeof handle.session.getHistory === 'function') { - const history = handle.session.getHistory(); - if (history.length === 0) { - // getHistory exists but returned nothing — "no history yet" is correct. - ctx.out.info(`Subagent ${resolvedId} has no history yet.`); + const handle = manager.get(resolvedId); + + // No handle in memory and no disk events — nothing to view. + if (!handle) { + // Attempt disk replay as a quick check — if nothing there, emit info. + const diskIds = await SubagentLogReader.list(sessionLabel); + const found = diskIds.some(id => id === resolvedId || id.startsWith(raw)); + if (!found) { + ctx.out.info(`No events found for subagent "${raw}".`); return 'continue'; } - ctx.out.line(palette.dim(`─── Subagent ${resolvedId} (${history.length} messages) ───`)); - for (const msg of history) { - const role = msg.role === 'user' ? palette.bold('user') : palette.bold('assistant'); - ctx.out.line(''); - ctx.out.line(`${role}:`); - const content = msg.content; - const text = typeof content === 'string' - ? content - : Array.isArray(content) - ? (content as unknown[]) - .map(b => - b !== null && typeof b === 'object' && 'text' in b - ? String((b as { text: unknown }).text) - : JSON.stringify(b), - ) - .join('\n') - : JSON.stringify(content); - for (const line of text.split('\n')) ctx.out.line(` ${line}`); - } - ctx.out.line(''); - return 'continue'; } - // Disk fallback: stream events from JSONL log. - let eventCount = 0; - ctx.out.line(palette.dim(`─── Subagent ${resolvedId} (disk replay) ───`)); - for await (const event of SubagentLogReader.readEvents(sessionLabel, resolvedId)) { - const text = formatOutputEvent(event); - if (text !== null) { - ctx.out.line(text); - eventCount++; - } - } - if (eventCount === 0) { - ctx.out.info(`No events found for subagent "${resolvedId}".`); - } - ctx.out.line(''); + const entry: TaskViewEntry = { + id: resolvedId, + manager, + sessionLabel, + ctx, + }; + + await enterTaskViewMode(entry); return 'continue'; }, }; @@ -302,7 +373,7 @@ export const tasksCancelCmd: SlashCommand = { } const resolvedId = resolveId(manager, raw) ?? raw; - const handle = manager.get(resolvedId); + const handle = manager.get(resolvedId); if (!handle) { ctx.out.error(`No subagent found with ID "${raw}".`);