Skip to content

Commit 213ce5c

Browse files
authored
Merge pull request #146 from subsy/feat/subagent-panel-improvements
feat: subagent panel improvements with TAB navigation and OpenCode support
2 parents b57bcee + 2df2a6a commit 213ce5c

23 files changed

Lines changed: 2323 additions & 200 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ralph-tui",
3-
"version": "0.2.0",
3+
"version": "0.2.1",
44
"description": "Ralph TUI - AI Agent Loop Orchestrator",
55
"type": "module",
66
"main": "./dist/index.js",

src/engine/index.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,4 +193,43 @@ describe('ExecutionEngine', () => {
193193
expect(result3.success).toBe(true);
194194
});
195195
});
196+
197+
describe('getSubagentDetails', () => {
198+
test('returns undefined for non-existent subagent', () => {
199+
const engine = new ExecutionEngine(createMockConfig());
200+
const result = engine.getSubagentDetails('non-existent-id');
201+
expect(result).toBeUndefined();
202+
});
203+
204+
test('returns undefined when no subagents have been tracked', () => {
205+
const engine = new ExecutionEngine(createMockConfig());
206+
// No subagents tracked yet
207+
const result = engine.getSubagentDetails('any-id');
208+
expect(result).toBeUndefined();
209+
});
210+
});
211+
212+
describe('getSubagentOutput', () => {
213+
test('returns undefined for non-existent subagent', () => {
214+
const engine = new ExecutionEngine(createMockConfig());
215+
const result = engine.getSubagentOutput('non-existent-id');
216+
expect(result).toBeUndefined();
217+
});
218+
});
219+
220+
describe('getActiveSubagentId', () => {
221+
test('returns undefined when no subagents are active', () => {
222+
const engine = new ExecutionEngine(createMockConfig());
223+
const result = engine.getActiveSubagentId();
224+
expect(result).toBeUndefined();
225+
});
226+
});
227+
228+
describe('getSubagentTree', () => {
229+
test('returns empty array when no subagents', () => {
230+
const engine = new ExecutionEngine(createMockConfig());
231+
const result = engine.getSubagentTree();
232+
expect(result).toEqual([]);
233+
});
234+
});
196235
});

src/engine/index.ts

Lines changed: 106 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,12 @@ import { getAgentRegistry } from '../plugins/agents/registry.js';
3333
import { getTrackerRegistry } from '../plugins/trackers/registry.js';
3434
import { SubagentTraceParser } from '../plugins/agents/tracing/parser.js';
3535
import type { SubagentEvent } from '../plugins/agents/tracing/types.js';
36-
import { ClaudeAgentPlugin } from '../plugins/agents/builtin/claude.js';
36+
import type { ClaudeJsonlMessage } from '../plugins/agents/builtin/claude.js';
3737
import { createDroidStreamingJsonlParser, isDroidJsonlMessage, toClaudeJsonlMessages } from '../plugins/agents/droid/outputParser.js';
38+
import {
39+
isOpenCodeTaskTool,
40+
openCodeTaskToClaudeMessages,
41+
} from '../plugins/agents/opencode/outputParser.js';
3842
import { updateSessionIteration, updateSessionStatus, updateSessionMaxIterations } from '../session/index.js';
3943
import { saveIterationLog, buildSubagentTrace, createProgressEntry, appendProgress, getRecentProgressSummary, getCodebasePatternsForPrompt } from '../logs/index.js';
4044
import type { AgentSwitchEntry } from '../logs/index.js';
@@ -807,15 +811,13 @@ export class ExecutionEngine {
807811
flags.push('--model', this.config.model);
808812
}
809813

810-
// Check if agent supports subagent tracing
814+
// Check if agent declares subagent tracing support (used for agent-specific flags)
811815
const supportsTracing = this.agent!.meta.supportsSubagentTracing;
812816

813-
// Create streaming JSONL parser if tracing is enabled
814-
const jsonlParser = supportsTracing
815-
? this.agent?.meta.id === 'droid'
816-
? createDroidStreamingJsonlParser()
817-
: ClaudeAgentPlugin.createStreamingJsonlParser()
818-
: null;
817+
// For Droid agent, we need a JSONL parser since it uses a different output format.
818+
// For Claude and OpenCode, we use the onJsonlMessage callback which gets pre-parsed messages.
819+
const isDroidAgent = this.agent?.meta.id === 'droid';
820+
const droidJsonlParser = isDroidAgent ? createDroidStreamingJsonlParser() : null;
819821

820822
try {
821823
// Execute agent with subagent tracing if supported
@@ -824,6 +826,42 @@ export class ExecutionEngine {
824826
flags,
825827
sandbox: this.config.sandbox,
826828
subagentTracing: supportsTracing,
829+
// Callback for pre-parsed JSONL messages (used by Claude and OpenCode plugins)
830+
// This receives raw JSON objects directly from the agent's parsed JSONL output.
831+
onJsonlMessage: (message: Record<string, unknown>) => {
832+
// Check if this is OpenCode format (has 'part' with 'tool' property)
833+
const part = message.part as Record<string, unknown> | undefined;
834+
if (message.type === 'tool_use' && part?.tool) {
835+
// OpenCode format - convert using OpenCode parser
836+
const openCodeMessage = {
837+
source: 'opencode' as const,
838+
type: message.type as string,
839+
timestamp: message.timestamp as number | undefined,
840+
sessionID: message.sessionID as string | undefined,
841+
part: part as import('../plugins/agents/opencode/outputParser.js').OpenCodePart,
842+
raw: message,
843+
};
844+
// Check if it's a Task tool and convert to Claude format
845+
if (isOpenCodeTaskTool(openCodeMessage)) {
846+
for (const claudeMessage of openCodeTaskToClaudeMessages(openCodeMessage)) {
847+
this.subagentParser.processMessage(claudeMessage);
848+
}
849+
}
850+
return;
851+
}
852+
853+
// Claude format - convert raw JSON to ClaudeJsonlMessage format for SubagentParser
854+
const claudeMessage: ClaudeJsonlMessage = {
855+
type: message.type as string | undefined,
856+
message: message.message as string | undefined,
857+
tool: message.tool as { name?: string; input?: Record<string, unknown> } | undefined,
858+
result: message.result,
859+
cost: message.cost as { inputTokens?: number; outputTokens?: number; totalUSD?: number } | undefined,
860+
sessionId: message.sessionId as string | undefined,
861+
raw: message,
862+
};
863+
this.subagentParser.processMessage(claudeMessage);
864+
},
827865
onStdout: (data) => {
828866
this.state.currentOutput += data;
829867
this.emit({
@@ -834,9 +872,10 @@ export class ExecutionEngine {
834872
iteration,
835873
});
836874

837-
// Parse JSONL output for subagent events if tracing is enabled
838-
if (jsonlParser) {
839-
const results = jsonlParser.push(data);
875+
// For Droid agent, parse JSONL output for subagent events
876+
// (Claude uses onJsonlMessage callback instead)
877+
if (droidJsonlParser && isDroidAgent) {
878+
const results = droidJsonlParser.push(data);
840879
for (const result of results) {
841880
if (result.success) {
842881
if (isDroidJsonlMessage(result.message)) {
@@ -849,6 +888,7 @@ export class ExecutionEngine {
849888
}
850889
}
851890
}
891+
852892
},
853893
onStderr: (data) => {
854894
this.state.currentStderr += data;
@@ -868,9 +908,9 @@ export class ExecutionEngine {
868908
const agentResult = await handle.promise;
869909
this.currentExecution = null;
870910

871-
// Flush any remaining buffered JSONL data
872-
if (jsonlParser) {
873-
const remaining = jsonlParser.flush();
911+
// Flush any remaining buffered JSONL data for Droid agent
912+
if (droidJsonlParser && isDroidAgent) {
913+
const remaining = droidJsonlParser.flush();
874914
for (const result of remaining) {
875915
if (result.success) {
876916
if (isDroidJsonlMessage(result.message)) {
@@ -1371,6 +1411,58 @@ export class ExecutionEngine {
13711411
return depth;
13721412
}
13731413

1414+
/**
1415+
* Get output/result for a specific subagent by ID.
1416+
* For completed subagents, returns their result content.
1417+
* For running subagents, returns undefined (use currentOutput for live streaming).
1418+
*
1419+
* @param id - Subagent ID to get output for
1420+
* @returns Subagent result content, or undefined if not found or still running
1421+
*/
1422+
getSubagentOutput(id: string): string | undefined {
1423+
const state = this.subagentParser.getSubagent(id);
1424+
if (!state) return undefined;
1425+
// Return result only for completed/errored subagents
1426+
if (state.status === 'completed' || state.status === 'error') {
1427+
return state.result;
1428+
}
1429+
return undefined;
1430+
}
1431+
1432+
/**
1433+
* Get detailed information about a subagent for display.
1434+
* Returns the prompt, result, and timing information.
1435+
*
1436+
* @param id - Subagent ID to get details for
1437+
* @returns Subagent details or undefined if not found
1438+
*/
1439+
getSubagentDetails(id: string): {
1440+
prompt?: string;
1441+
result?: string;
1442+
spawnedAt: string;
1443+
endedAt?: string;
1444+
childIds: string[];
1445+
} | undefined {
1446+
const state = this.subagentParser.getSubagent(id);
1447+
if (!state) return undefined;
1448+
return {
1449+
prompt: state.prompt,
1450+
result: state.result,
1451+
spawnedAt: state.spawnedAt,
1452+
endedAt: state.endedAt,
1453+
childIds: state.childIds,
1454+
};
1455+
}
1456+
1457+
/**
1458+
* Get the currently active subagent ID (deepest in the hierarchy).
1459+
* Returns undefined if no subagent is currently active.
1460+
*/
1461+
getActiveSubagentId(): string | undefined {
1462+
const stack = this.subagentParser.getActiveStack();
1463+
return stack.length > 0 ? stack[0] : undefined;
1464+
}
1465+
13741466
/**
13751467
* Get the subagent tree for TUI rendering.
13761468
* Returns an array of root-level subagent tree nodes with their children nested.

src/plugins/agents/builtin/claude.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -444,9 +444,26 @@ export class ClaudeAgentPlugin extends BaseAgentPlugin {
444444
// This callback is set up but actual segments come from wrapping onStdout below
445445
}
446446
: options?.onStdoutSegments,
447-
// Legacy string callback or wrapper that calls both callbacks
448-
onStdout: isStreamingJson && (options?.onStdout || options?.onStdoutSegments)
447+
// Legacy string callback or wrapper that calls both callbacks and JSONL message callback
448+
onStdout: isStreamingJson && (options?.onStdout || options?.onStdoutSegments || options?.onJsonlMessage)
449449
? (data: string) => {
450+
// Parse each line for JSONL messages and display events
451+
for (const line of data.split('\n')) {
452+
const trimmed = line.trim();
453+
if (!trimmed) continue;
454+
455+
// Try to parse as JSON and call the raw JSONL message callback
456+
if (options?.onJsonlMessage) {
457+
try {
458+
const rawJson = JSON.parse(trimmed) as Record<string, unknown>;
459+
options.onJsonlMessage(rawJson);
460+
} catch {
461+
// Not valid JSON, skip for JSONL callback
462+
}
463+
}
464+
}
465+
466+
// Also parse for display events
450467
const events = this.parseClaudeOutputToEvents(data);
451468
if (events.length > 0) {
452469
// Call TUI-native segments callback if provided

src/plugins/agents/builtin/opencode.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export class OpenCodeAgentPlugin extends BaseAgentPlugin {
126126
supportsStreaming: true,
127127
supportsInterrupt: true,
128128
supportsFileContext: true,
129-
supportsSubagentTracing: false,
129+
supportsSubagentTracing: true,
130130
skillsPaths: {
131131
personal: '~/.opencode/skill',
132132
repo: '.opencode/skill',
@@ -367,6 +367,7 @@ export class OpenCodeAgentPlugin extends BaseAgentPlugin {
367367
/**
368368
* Override execute to parse opencode JSON output.
369369
* Wraps the onStdout/onStdoutSegments callbacks to parse JSONL events and extract displayable content.
370+
* Also forwards raw JSONL messages to onJsonlMessage for subagent tracing.
370371
*/
371372
override execute(
372373
prompt: string,
@@ -376,8 +377,24 @@ export class OpenCodeAgentPlugin extends BaseAgentPlugin {
376377
// Wrap callbacks to parse JSON events
377378
const parsedOptions: AgentExecuteOptions = {
378379
...options,
379-
onStdout: (options?.onStdout || options?.onStdoutSegments)
380+
onStdout: (options?.onStdout || options?.onStdoutSegments || options?.onJsonlMessage)
380381
? (data: string) => {
382+
// Parse raw JSONL lines and forward to onJsonlMessage for subagent tracing
383+
if (options?.onJsonlMessage) {
384+
for (const line of data.split('\n')) {
385+
const trimmed = line.trim();
386+
if (trimmed && trimmed.startsWith('{')) {
387+
try {
388+
const parsed = JSON.parse(trimmed);
389+
options.onJsonlMessage(parsed);
390+
} catch {
391+
// Not valid JSON, skip
392+
}
393+
}
394+
}
395+
}
396+
397+
// Process for display events
381398
const events = parseOpenCodeOutputToEvents(data);
382399
if (events.length > 0) {
383400
// Call TUI-native segments callback if provided

src/plugins/agents/droid/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export class DroidAgentPlugin extends BaseAgentPlugin {
3030
supportsStreaming: true,
3131
supportsInterrupt: true,
3232
supportsFileContext: false,
33-
supportsSubagentTracing: true,
33+
supportsSubagentTracing: false,
3434
structuredOutputFormat: 'jsonl',
3535
skillsPaths: {
3636
personal: '~/.factory/skills',
@@ -43,8 +43,8 @@ export class DroidAgentPlugin extends BaseAgentPlugin {
4343
// Default to true: droid exec cannot show interactive prompts without a TTY
4444
private skipPermissions = true;
4545
private enableTracing = true;
46-
// Track effective subagent tracing support (can be disabled via config)
47-
private effectiveSupportsSubagentTracing = true;
46+
// Subagent tracing is not currently supported for Factory Droid
47+
private effectiveSupportsSubagentTracing = false;
4848

4949
/**
5050
* Returns meta with effectiveSupportsSubagentTracing applied.

0 commit comments

Comments
 (0)