Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/commands/agents/agents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import * as React from 'react';
import { AgentsMenu } from '../../components/agents/AgentsMenu.js';
import type { ToolUseContext } from '../../Tool.js';
import { getTools } from '../../tools.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
export async function call(onDone: LocalJSXCommandOnDone, context: ToolUseContext): Promise<React.ReactNode> {
import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js';
export async function call(onDone: LocalJSXCommandOnDone, context: ToolUseContext & LocalJSXCommandContext): Promise<React.ReactNode> {
const appState = context.getAppState();
const permissionContext = appState.toolPermissionContext;
const tools = getTools(permissionContext);
return <AgentsMenu tools={tools} onExit={onDone} />;
return <AgentsMenu tools={tools} onExit={onDone} onSetActiveAgent={context.setActiveSessionAgent} />;
}
15 changes: 9 additions & 6 deletions src/components/PromptInput/PromptInputQueuedCommands.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
releaseSharedMutationLock,
} from '../../test/sharedMutationLock.js'
import { renderToString } from '../../utils/staticRender.js'
import * as realCommandQueue from '../../hooks/useCommandQueue.js'
import { AppStateProvider } from 'src/state/AppState.js'

describe('PromptInputQueuedCommands', () => {
beforeEach(async () => {
Expand All @@ -18,16 +20,12 @@ describe('PromptInputQueuedCommands', () => {
],
}))

mock.module('src/state/AppState.js', () => ({
useAppState: (
selector: (state: { viewingAgentTaskId?: string; isBriefOnly: boolean }) => unknown,
) => selector({ viewingAgentTaskId: undefined, isBriefOnly: false }),
}))
})

afterEach(() => {
try {
mock.restore()
mock.module('../../hooks/useCommandQueue.js', () => realCommandQueue)
} finally {
releaseSharedMutationLock()
}
Expand All @@ -36,7 +34,12 @@ describe('PromptInputQueuedCommands', () => {
it('shows a next-turn guidance banner for queued prompt messages', async () => {
const { PromptInputQueuedCommands } = await import('./PromptInputQueuedCommands.js')

const output = await renderToString(<PromptInputQueuedCommands />, 100)
const output = await renderToString(
<AppStateProvider>
<PromptInputQueuedCommands />
</AppStateProvider>,
100,
)

expect(output).toContain('1 message queued for next turn')
expect(output).toContain('Use another library')
Expand Down
1 change: 1 addition & 0 deletions src/components/StartupScreen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ describe('detectProvider — modelOverride from --model flag', () => {
})

test('modelOverride alias is resolved for Anthropic', () => {
process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'claude-opus-4-6'
const result = detectProvider('opus')
expect(result.name).toBe('Anthropic')
expect(result.model).toContain('opus')
Expand Down
185 changes: 185 additions & 0 deletions src/components/agents/AgentsList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { PassThrough } from 'node:stream'

import { afterEach, beforeEach, expect, test } from 'bun:test'
import React from 'react'
import stripAnsi from 'strip-ansi'

import { createRoot } from '../../ink.js'
import {
acquireSharedMutationLock,
releaseSharedMutationLock,
} from '../../test/sharedMutationLock.js'
import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js'
import { AgentsList } from './AgentsList.js'

const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l'

function extractLastFrame(output: string): string {
let lastFrame: string | null = null
let cursor = 0

while (cursor < output.length) {
const start = output.indexOf(SYNC_START, cursor)
if (start === -1) break
const contentStart = start + SYNC_START.length
const end = output.indexOf(SYNC_END, contentStart)
if (end === -1) break
const frame = output.slice(contentStart, end)
if (frame.trim().length > 0) lastFrame = frame
cursor = end + SYNC_END.length
}

return lastFrame ?? output
}

function createTestStreams() {
let output = ''
const stdout = new PassThrough()
const stdin = new PassThrough() as PassThrough & {
isTTY: boolean
setRawMode: () => void
ref: () => void
unref: () => void
}
stdin.isTTY = true
stdin.setRawMode = () => {}
stdin.ref = () => {}
stdin.unref = () => {}
;(stdout as unknown as { columns: number }).columns = 120
stdout.on('data', chunk => {
output += chunk.toString()
})
return { stdout, stdin, getOutput: () => output }
}

async function waitForOutput(
getOutput: () => string,
predicate: (frame: string) => boolean,
): Promise<string> {
const startedAt = Date.now()

while (Date.now() - startedAt < 2500) {
const frame = stripAnsi(extractLastFrame(getOutput()))
if (predicate(frame)) return frame
await Bun.sleep(10)
}

throw new Error('Timed out waiting for agents list output')
}

function createAgent(
agentType: string,
source: AgentDefinition['source'] = 'userSettings',
): AgentDefinition {
return {
agentType,
whenToUse: `Use ${agentType}`,
source,
getSystemPrompt: () => `You are ${agentType}`,
}
}

beforeEach(async () => {
await acquireSharedMutationLock('components/agents/AgentsList.test.tsx')
})

afterEach(() => {
releaseSharedMutationLock()
})

test('shows and marks the active session agent', async () => {
const { stdout, stdin, getOutput } = createTestStreams()
const root = await createRoot({
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})

root.render(
<AgentsList
source="all"
agents={[createAgent('reviewer'), createAgent('planner')]}
activeAgentName="reviewer"
onBack={() => {}}
onSelect={() => {}}
/>,
)

try {
const output = await waitForOutput(
getOutput,
frame => frame.includes('Current session agent: reviewer'),
)
expect(output).toContain('reviewer')
expect(output).toContain('active')
} finally {
root.unmount()
stdin.end()
stdout.end()
}
})

test('shows none when no session agent is active', async () => {
const { stdout, stdin, getOutput } = createTestStreams()
const root = await createRoot({
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})

root.render(
<AgentsList
source="all"
agents={[]}
activeAgentName={undefined}
onBack={() => {}}
onSelect={() => {}}
/>,
)

try {
await waitForOutput(getOutput, frame =>
frame.includes('Current session agent: none'),
)
} finally {
root.unmount()
stdin.end()
stdout.end()
}
})

test('does not mark shadowed agent rows as active', async () => {
const { stdout, stdin, getOutput } = createTestStreams()
const root = await createRoot({
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})

root.render(
<AgentsList
source="all"
agents={[
{ ...createAgent('reviewer', 'built-in'), overriddenBy: 'userSettings' },
createAgent('reviewer'),
]}
activeAgentName="reviewer"
onBack={() => {}}
onSelect={() => {}}
/>,
)

try {
const output = await waitForOutput(
getOutput,
frame => frame.includes('shadowed by user') && frame.includes('active'),
)

expect(output.match(/\bactive\b/g) ?? []).toHaveLength(1)
} finally {
root.unmount()
stdin.end()
stdout.end()
}
})
65 changes: 23 additions & 42 deletions src/components/agents/AgentsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type Props = {
onSelect: (agent: AgentDefinition) => void;
onCreateNew?: () => void;
changes?: string[];
activeAgentName?: string;
};
export function AgentsList(t0) {
const $ = _c(96);
Expand All @@ -28,7 +29,8 @@ export function AgentsList(t0) {
onBack,
onSelect,
onCreateNew,
changes
changes,
activeAgentName
} = t0;
const [selectedAgent, setSelectedAgent] = React.useState(null);
const [isCreateNewSelected, setIsCreateNewSelected] = React.useState(true);
Expand All @@ -51,41 +53,35 @@ export function AgentsList(t0) {
t2 = $[3];
}
const renderCreateNewOption = t2;
let t3;
if ($[4] !== isCreateNewSelected || $[5] !== selectedAgent?.agentType || $[6] !== selectedAgent?.source) {
t3 = agent_0 => {
const isBuiltIn = agent_0.source === "built-in";
const isSelected = !isBuiltIn && !isCreateNewSelected && selectedAgent?.agentType === agent_0.agentType && selectedAgent?.source === agent_0.source;
const {
isOverridden,
overriddenBy
} = getOverrideInfo(agent_0);
const dimmed = isBuiltIn || isOverridden;
const textColor = !isBuiltIn && isSelected ? "suggestion" : undefined;
const resolvedModel = resolveAgentModelDisplay(agent_0);
return <Box key={`${agent_0.agentType}-${agent_0.source}`}><Text dimColor={dimmed && !isSelected} color={textColor}>{isBuiltIn ? "" : isSelected ? `${figures.pointer} ` : " "}</Text><Text dimColor={dimmed && !isSelected} color={textColor}>{agent_0.agentType}</Text>{resolvedModel && <Text dimColor={true} color={textColor}>{" \xB7 "}{resolvedModel}</Text>}{agent_0.memory && <Text dimColor={true} color={textColor}>{" \xB7 "}{agent_0.memory} memory</Text>}{overriddenBy && <Text dimColor={!isSelected} color={isSelected ? "warning" : undefined}>{" "}{figures.warning} shadowed by {getOverrideSourceLabel(overriddenBy)}</Text>}</Box>;
};
$[4] = isCreateNewSelected;
$[5] = selectedAgent?.agentType;
$[6] = selectedAgent?.source;
$[7] = t3;
} else {
t3 = $[7];
}
const renderAgent = t3;
const renderAgent = agent_0 => {
const isSelected = !isCreateNewSelected && selectedAgent?.agentType === agent_0.agentType && selectedAgent?.source === agent_0.source;
const isActive = agent_0.agentType === activeAgentName && !agent_0.overriddenBy;
const {
isOverridden,
overriddenBy
} = getOverrideInfo(agent_0);
const dimmed = agent_0.source === "built-in" || isOverridden;
const textColor = isSelected ? "suggestion" : undefined;
const resolvedModel = resolveAgentModelDisplay(agent_0);
return <Box key={`${agent_0.agentType}-${agent_0.source}`}><Text dimColor={dimmed && !isSelected} color={textColor}>{isSelected ? `${figures.pointer} ` : " "}</Text><Text dimColor={dimmed && !isSelected} color={textColor}>{agent_0.agentType}</Text>{resolvedModel && <Text dimColor={true} color={textColor}>{" \xB7 "}{resolvedModel}</Text>}{agent_0.memory && <Text dimColor={true} color={textColor}>{" \xB7 "}{agent_0.memory} memory</Text>}{isActive && <Text color="success"> {figures.tick} active</Text>}{overriddenBy && <Text dimColor={!isSelected} color={isSelected ? "warning" : undefined}>{" "}{figures.warning} shadowed by {getOverrideSourceLabel(overriddenBy)}</Text>}</Box>;
};
let t4;
if ($[8] !== sortedAgents || $[9] !== source) {
bb0: {
const nonBuiltIn = sortedAgents.filter(_temp2);
if (source === "all") {
t4 = AGENT_SOURCE_GROUPS.filter(_temp3).flatMap(t5 => {
t4 = AGENT_SOURCE_GROUPS.flatMap(t5 => {
const {
source: groupSource
} = t5;
return nonBuiltIn.filter(a_0 => a_0.source === groupSource);
return sortedAgents.filter(a_0 => a_0.source === groupSource);
});
break bb0;
}
if (source === "built-in") {
t4 = sortedAgents;
break bb0;
}
t4 = nonBuiltIn;
}
$[8] = sortedAgents;
Expand Down Expand Up @@ -264,16 +260,7 @@ export function AgentsList(t0) {
} else {
t27 = $[64];
}
let t28;
if ($[65] !== handleKeyDown || $[66] !== t23 || $[67] !== t27) {
t28 = <Box flexDirection="column" gap={1} tabIndex={0} autoFocus={true} onKeyDown={handleKeyDown}>{t23}{t24}{t25}{t26}{t27}</Box>;
$[65] = handleKeyDown;
$[66] = t23;
$[67] = t27;
$[68] = t28;
} else {
t28 = $[68];
}
const t28 = <Box flexDirection="column" gap={1} tabIndex={0} autoFocus={true} onKeyDown={handleKeyDown}><Text dimColor={true}>Current session agent: <Text bold={true}>{activeAgentName ?? "none"}</Text></Text>{t23}{t24}{t25}{t26}{t27}</Box>;
let t29;
if ($[69] !== onBack || $[70] !== sourceTitle || $[71] !== t28) {
t29 = <Dialog title={sourceTitle} subtitle="No agents found" onCancel={onBack} hideInputGuide={true}>{t28}</Dialog>;
Expand All @@ -300,13 +287,7 @@ export function AgentsList(t0) {
t18 = `${t23} agents`;
t19 = onBack;
t20 = true;
if ($[75] !== changes) {
t21 = changes && changes.length > 0 && <Box marginTop={1}><Text dimColor={true}>{changes[changes.length - 1]}</Text></Box>;
$[75] = changes;
$[76] = t21;
} else {
t21 = $[76];
}
t21 = <><Text dimColor={true}>Current session agent: <Text bold={true}>{activeAgentName ?? "none"}</Text></Text>{changes && changes.length > 0 && <Box marginTop={1}><Text dimColor={true}>{changes[changes.length - 1]}</Text></Box>}</>;
T0 = Box;
t11 = "column";
t12 = 0;
Expand Down
Loading