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
77 changes: 77 additions & 0 deletions packages/cli/src/__tests__/tui/console/ConsoleContext.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { PassThrough } from 'node:stream';
import React from 'react';
import { render, Text } from 'ink';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { AgentManager } from '@ai-devkit/agent-manager';
import type { ConfigStore } from '@ai-devkit/channel-connector';
import type { ChannelService } from '../../../services/channel/channel.service.js';
import {
ConsoleProvider,
useConsoleAgentContext,
} from '../../../tui/console/state/ConsoleContext.js';

describe('ConsoleProvider subscriptions', () => {
afterEach(() => {
vi.useRealTimers();
});

it('does not rerender an agent-only consumer when only channel state changes', async () => {
vi.useFakeTimers();
const manager = {
listAgents: vi.fn().mockResolvedValue([]),
} as unknown as AgentManager;
const channelService = {
getLiveBridges: vi.fn()
.mockResolvedValueOnce([])
.mockResolvedValue([{
channelName: 'telegram',
channelType: 'telegram',
agentName: 'agent-1',
bridgePid: 42,
}]),
} as unknown as ChannelService;
const configStore = {
getConfig: vi.fn().mockResolvedValue({ channels: {} }),
} as unknown as ConfigStore;
let agentConsumerRenders = 0;
let agentListLoading = true;
const AgentOnlyConsumer = () => {
const { agents, isLoading } = useConsoleAgentContext();
agentConsumerRenders += 1;
agentListLoading = isLoading;
return React.createElement(Text, null, agents.length);
};
const output = new PassThrough();
const instance = render(
React.createElement(
ConsoleProvider,
{ manager, inputFocused: false, channelService, configStore },
React.createElement(AgentOnlyConsumer),
),
{
stdout: output as unknown as NodeJS.WriteStream,
interactive: false,
patchConsole: false,
},
);

await vi.waitFor(() => {
expect(manager.listAgents).toHaveBeenCalledOnce();
expect(channelService.getLiveBridges).toHaveBeenCalledOnce();
expect(configStore.getConfig).toHaveBeenCalledOnce();
expect(agentListLoading).toBe(false);
});
await vi.runAllTicks();
const rendersAfterInitialLoad = agentConsumerRenders;

await vi.advanceTimersByTimeAsync(3000);
await vi.waitFor(() => {
expect(channelService.getLiveBridges).toHaveBeenCalledTimes(2);
});
await vi.runAllTicks();

expect(agentConsumerRenders).toBe(rendersAfterInitialLoad);
instance.unmount();
await instance.waitUntilExit();
});
});
10 changes: 8 additions & 2 deletions packages/cli/src/tui/console/ConsoleApp.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Box, useApp, useInput, type RenderOptions } from 'ink';
import type { AgentManager } from '@ai-devkit/agent-manager';
import { ConsoleProvider, useConsoleContext } from './state/ConsoleContext.js';
import {
ConsoleProvider,
useConsoleAgentContext,
useConsoleChannelContext,
} from './state/ConsoleContext.js';
import { useTerminalSize } from './hooks/useTerminalSize.js';
import { useStartAgentPane } from './hooks/useStartAgentPane.js';
import { useRenameAgentPane } from './hooks/useRenameAgentPane.js';
Expand Down Expand Up @@ -110,11 +114,13 @@ const ConsoleAppShell: React.FC<{
lastUpdated,
isLoading,
refresh,
} = useConsoleAgentContext();
const {
channelStatuses,
configuredChannels,
refreshConfiguredChannels,
refreshChannels,
} = useConsoleContext();
} = useConsoleChannelContext();
const agentsRef = useRef(agents);
agentsRef.current = agents;

Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/tui/console/HeaderBar.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import React from 'react';
import { Box, Text } from 'ink';
import { useConsoleContext } from './state/ConsoleContext.js';
import { useConsoleAgentContext } from './state/ConsoleContext.js';
import { TUI_COLORS } from '../design-system/index.js';

const HeaderBarInner: React.FC = () => {
const { agents, isLoading } = useConsoleContext();
const { agents, isLoading } = useConsoleAgentContext();
const totalLabel = isLoading && agents.length === 0 ? 'scanning…' : `${agents.length} agent${agents.length === 1 ? '' : 's'}`;
return (
<Box paddingX={1}>
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/tui/console/PreviewSection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import React, { useMemo } from 'react';
import { PreviewPane } from './PreviewPane.js';
import { useConsoleContext } from './state/ConsoleContext.js';
import {
useConsoleAgentContext,
useConsoleChannelContext,
} from './state/ConsoleContext.js';
import { useAgentConversation } from './hooks/useAgentConversation.js';
import { Panel } from '../design-system/index.js';
import { getPreviewPanelTone } from './PreviewPane.js';
Expand All @@ -20,7 +23,8 @@ const PreviewSectionInner: React.FC<PreviewSectionProps> = ({
scrollOffset = 0,
onScrollOffsetClamp,
}) => {
const { agents, manager, inputFocused, channelStatuses } = useConsoleContext();
const { agents, manager, inputFocused } = useConsoleAgentContext();
const { channelStatuses } = useConsoleChannelContext();
const selectedAgent = useMemo(
() => agents.find(a => a.name === selectedName) ?? null,
[agents, selectedName],
Expand Down
69 changes: 59 additions & 10 deletions packages/cli/src/tui/console/state/ConsoleContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,37 @@ import {

export { buildAgentChannelStatuses, buildConfiguredChannels };

interface ConsoleContextValue extends UseAgentListResult, UseChannelStateResult {
interface ConsoleAgentContextValue extends UseAgentListResult {
manager: AgentManager;
inputFocused: boolean;
}

const ConsoleContext = createContext<ConsoleContextValue | null>(null);
type ConsoleChannelContextValue = UseChannelStateResult;

export const useConsoleContext = (): ConsoleContextValue => {
const ctx = useContext(ConsoleContext);
if (!ctx) throw new Error('useConsoleContext must be used inside <ConsoleProvider>');
const ConsoleAgentContext = createContext<ConsoleAgentContextValue | null>(null);
const ConsoleChannelContext = createContext<ConsoleChannelContextValue | null>(null);

export const useConsoleAgentContext = (): ConsoleAgentContextValue => {
const ctx = useContext(ConsoleAgentContext);
if (!ctx) throw new Error('useConsoleAgentContext must be used inside <ConsoleProvider>');
return ctx;
};

export const useConsoleChannelContext = (): ConsoleChannelContextValue => {
const ctx = useContext(ConsoleChannelContext);
if (!ctx) throw new Error('useConsoleChannelContext must be used inside <ConsoleProvider>');
return ctx;
};

export const useConsoleContext = (): ConsoleAgentContextValue & ConsoleChannelContextValue => {
const agentContext = useConsoleAgentContext();
const channelContext = useConsoleChannelContext();
return useMemo(
() => ({ ...agentContext, ...channelContext }),
[agentContext, channelContext],
);
};

interface ConsoleProviderProps {
manager: AgentManager;
inputFocused: boolean;
Expand All @@ -45,14 +63,45 @@ export const ConsoleProvider: React.FC<ConsoleProviderProps> = ({
const list = useAgentList(manager, undefined, inputFocused);
const channelState = useChannelState(channelService, configStore, undefined, inputFocused);

const value = useMemo<ConsoleContextValue>(
const agentValue = useMemo<ConsoleAgentContextValue>(
() => ({
...list,
...channelState,
agents: list.agents,
error: list.error,
lastUpdated: list.lastUpdated,
isLoading: list.isLoading,
refresh: list.refresh,
manager,
inputFocused,
}),
[list, channelState, manager, inputFocused],
[
list.agents,
list.error,
list.lastUpdated,
list.isLoading,
list.refresh,
manager,
inputFocused,
],
);
const channelValue = useMemo<ConsoleChannelContextValue>(
() => ({
channelStatuses: channelState.channelStatuses,
refreshChannels: channelState.refreshChannels,
configuredChannels: channelState.configuredChannels,
refreshConfiguredChannels: channelState.refreshConfiguredChannels,
}),
[
channelState.channelStatuses,
channelState.refreshChannels,
channelState.configuredChannels,
channelState.refreshConfiguredChannels,
],
);
return (
<ConsoleAgentContext.Provider value={agentValue}>
<ConsoleChannelContext.Provider value={channelValue}>
{children}
</ConsoleChannelContext.Provider>
</ConsoleAgentContext.Provider>
);
return <ConsoleContext.Provider value={value}>{children}</ConsoleContext.Provider>;
};
Loading