From b7b772c35776ce68f89512e5f44c33045f90aaad Mon Sep 17 00:00:00 2001 From: notgitika Date: Fri, 15 May 2026 15:14:04 -0400 Subject: [PATCH 1/7] feat(tui): add dedicated Logs screen for streaming runtime logs Adds a TUI screen for streaming deployed agent CloudWatch logs, replacing the previous CLI-only help page. Users can now select a deployed agent and stream logs in real-time from the TUI menu. Closes #565 --- src/cli/tui/App.tsx | 8 + src/cli/tui/copy.ts | 9 - src/cli/tui/hooks/index.ts | 1 + src/cli/tui/hooks/useLogsStream.ts | 83 ++++++++ src/cli/tui/screens/logs/LogsScreen.tsx | 191 +++++++++++++++++++ src/cli/tui/screens/logs/index.ts | 1 + src/cli/tui/utils/__tests__/commands.test.ts | 4 +- src/cli/tui/utils/commands.ts | 2 +- 8 files changed, 287 insertions(+), 12 deletions(-) create mode 100644 src/cli/tui/hooks/useLogsStream.ts create mode 100644 src/cli/tui/screens/logs/LogsScreen.tsx create mode 100644 src/cli/tui/screens/logs/index.ts diff --git a/src/cli/tui/App.tsx b/src/cli/tui/App.tsx index 96b5c7f85..322d0b5a8 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -15,6 +15,7 @@ import { FetchAccessScreen } from './screens/fetch-access'; import { HelpScreen, HomeScreen } from './screens/home'; import { ImportFlow } from './screens/import'; import { InvokeScreen } from './screens/invoke'; +import { LogsScreen } from './screens/logs'; import { OnlineEvalDashboard } from './screens/online-eval'; import { PackageScreen } from './screens/package'; import { RecommendationFlow, RecommendationHistoryScreen, RecommendationsHubScreen } from './screens/recommendation'; @@ -35,6 +36,7 @@ type Route = | { name: 'help'; initialQuery?: string } | { name: 'deploy' } | { name: 'invoke' } + | { name: 'logs' } | { name: 'create' } | { name: 'add' } | { name: 'status' } @@ -103,6 +105,8 @@ function AppContent() { setRoute({ name: 'deploy' }); } else if (id === 'invoke') { setRoute({ name: 'invoke' }); + } else if (id === 'logs') { + setRoute({ name: 'logs' }); } else if (id === 'status') { setRoute({ name: 'status' }); } else if (id === 'create') { @@ -183,6 +187,10 @@ function AppContent() { return setRoute({ name: 'help' })} />; } + if (route.name === 'logs') { + return setRoute({ name: 'help' })} />; + } + if (route.name === 'status') { return setRoute({ name: 'help' })} />; } diff --git a/src/cli/tui/copy.ts b/src/cli/tui/copy.ts index 81185f394..71c08df89 100644 --- a/src/cli/tui/copy.ts +++ b/src/cli/tui/copy.ts @@ -62,15 +62,6 @@ export const COMMAND_DESCRIPTIONS = { * These commands must run in the terminal, not in the TUI. */ export const CLI_ONLY_EXAMPLES: Record = { - logs: { - description: 'Stream or search agent runtime logs. This command runs in the terminal.', - examples: [ - 'agentcore logs', - 'agentcore logs --since 30m --level error', - 'agentcore logs --runtime MyAgent --query "timeout"', - 'agentcore logs evals --since 1h', - ], - }, traces: { description: 'View and download agent traces. This command runs in the terminal.', examples: [ diff --git a/src/cli/tui/hooks/index.ts b/src/cli/tui/hooks/index.ts index a7eba80f1..ef420ccc6 100644 --- a/src/cli/tui/hooks/index.ts +++ b/src/cli/tui/hooks/index.ts @@ -8,6 +8,7 @@ export { useMultiSelectNavigation } from './useMultiSelectNavigation'; export { useResponsive } from './useResponsive'; export { useAvailableAgents, useCreateGateway, useExistingGateways } from './useCreateMcp'; export { useDevServer } from './useDevServer'; +export { useLogsStream } from './useLogsStream'; export { useProject } from './useProject'; export type { UseProjectResult, ProjectContext } from './useProject'; export { useTextInput } from './useTextInput'; diff --git a/src/cli/tui/hooks/useLogsStream.ts b/src/cli/tui/hooks/useLogsStream.ts new file mode 100644 index 000000000..02be08089 --- /dev/null +++ b/src/cli/tui/hooks/useLogsStream.ts @@ -0,0 +1,83 @@ +import { streamLogs } from '../../aws/cloudwatch'; +import type { AgentContext } from '../../commands/logs/action'; +import type { LogEntry } from '../components/LogPanel'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +const MAX_LOG_ENTRIES = 1000; + +interface UseLogsStreamResult { + logs: LogEntry[]; + isStreaming: boolean; + error?: string; +} + +function detectLevel(message: string): LogEntry['level'] { + const lower = message.toLowerCase(); + if (lower.includes('"level":"error"') || lower.includes('[error]') || lower.startsWith('error')) { + return 'error'; + } + if (lower.includes('"level":"warn"') || lower.includes('[warn]') || lower.startsWith('warn')) { + return 'warn'; + } + return 'info'; +} + +export function useLogsStream(agentContext: AgentContext | undefined): UseLogsStreamResult { + const [logs, setLogs] = useState([]); + const [streamState, setStreamState] = useState<'idle' | 'streaming' | 'done'>('idle'); + const [error, setError] = useState(); + const abortRef = useRef(null); + + const isStreaming = useMemo(() => streamState === 'streaming', [streamState]); + + useEffect(() => { + if (!agentContext) return; + + const ac = new AbortController(); + abortRef.current = ac; + + const run = async () => { + setStreamState('streaming'); + setError(undefined); + setLogs([]); + + try { + for await (const event of streamLogs({ + logGroupName: agentContext.logGroupName, + region: agentContext.region, + accountId: agentContext.accountId, + abortSignal: ac.signal, + })) { + if (ac.signal.aborted) break; + const entry: LogEntry = { + level: detectLevel(event.message), + message: `${new Date(event.timestamp).toISOString()} ${event.message}`, + }; + setLogs(prev => { + const next = [...prev, entry]; + return next.length > MAX_LOG_ENTRIES ? next.slice(-MAX_LOG_ENTRIES) : next; + }); + } + } catch (err: unknown) { + if (ac.signal.aborted) return; + const errorName = (err as { name?: string })?.name; + if (errorName === 'ResourceNotFoundException') { + setError(`No logs found for agent '${agentContext.agentName}'. Has the agent been invoked?`); + } else { + setError((err as Error).message ?? 'Failed to stream logs'); + } + } finally { + setStreamState('done'); + } + }; + + void run(); + + return () => { + ac.abort(); + abortRef.current = null; + }; + }, [agentContext]); + + return { logs, isStreaming, error }; +} diff --git a/src/cli/tui/screens/logs/LogsScreen.tsx b/src/cli/tui/screens/logs/LogsScreen.tsx new file mode 100644 index 000000000..2ca66be7d --- /dev/null +++ b/src/cli/tui/screens/logs/LogsScreen.tsx @@ -0,0 +1,191 @@ +import type { AgentContext } from '../../../commands/logs/action'; +import { resolveAgentContext } from '../../../commands/logs/action'; +import { loadDeployedProjectConfig } from '../../../operations/resolve-agent'; +import { FullScreenLogView, Screen, SelectList } from '../../components'; +import { useLogsStream } from '../../hooks/useLogsStream'; +import { Box, Text, useInput } from 'ink'; +import React, { useEffect, useState } from 'react'; + +interface LogsScreenProps { + isInteractive: boolean; + onExit: () => void; +} + +type Phase = 'loading' | 'select-agent' | 'streaming' | 'error'; + +export function LogsScreen({ onExit }: LogsScreenProps) { + const [phase, setPhase] = useState('loading'); + const [agents, setAgents] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(0); + const [selectedAgent, setSelectedAgent] = useState(); + const [loadError, setLoadError] = useState(); + const [showFullScreen, setShowFullScreen] = useState(false); + + const { logs, isStreaming, error: streamError } = useLogsStream(selectedAgent); + + useEffect(() => { + const load = async () => { + try { + const context = await loadDeployedProjectConfig(); + const runtimeNames = context.project.runtimes.map(r => r.name); + + if (runtimeNames.length === 0) { + setLoadError('No runtimes defined in agentcore.json'); + setPhase('error'); + return; + } + + const resolved: AgentContext[] = []; + for (const name of runtimeNames) { + const result = resolveAgentContext(context, { runtime: name }); + if (result.success) { + resolved.push(result.agentContext); + } + } + + if (resolved.length === 0) { + setLoadError('No deployed agents found. Run `agentcore deploy` first.'); + setPhase('error'); + return; + } + + setAgents(resolved); + + if (resolved.length === 1) { + setSelectedAgent(resolved[0]); + setPhase('streaming'); + } else { + setPhase('select-agent'); + } + } catch (err) { + setLoadError((err as Error).message ?? 'Failed to load project config'); + setPhase('error'); + } + }; + + void load(); + }, []); + + useInput( + (input, key) => { + if (phase === 'select-agent') { + if (key.upArrow || input === 'k') { + setSelectedIndex(prev => (prev - 1 + agents.length) % agents.length); + } + if (key.downArrow || input === 'j') { + setSelectedIndex(prev => (prev + 1) % agents.length); + } + if (key.return) { + const agent = agents[selectedIndex]; + if (agent) { + setSelectedAgent(agent); + setPhase('streaming'); + } + } + } + + if (phase === 'streaming' && !showFullScreen) { + if (input === 'l') { + setShowFullScreen(true); + } + } + }, + { isActive: !showFullScreen } + ); + + if (showFullScreen) { + return setShowFullScreen(false)} />; + } + + if (phase === 'loading') { + return ( + + Loading deployed agents... + + ); + } + + if (phase === 'error') { + return ( + + {loadError} + + ); + } + + if (phase === 'select-agent') { + const items = agents.map(a => ({ + id: a.agentId, + title: a.agentName, + description: `${a.region}`, + })); + + return ( + + + Select an agent to stream logs: + + + + + + ); + } + + const helpText = isStreaming + ? '↑↓ scroll · l full-screen · Esc back' + : streamError + ? 'Esc back' + : '↑↓ scroll · l full-screen · Esc back'; + + return ( + + + Agent: + {selectedAgent?.agentName} + + + Region: + {selectedAgent?.region} + + + Status: + {isStreaming ? ( + Streaming... + ) : streamError ? ( + Error + ) : ( + Disconnected + )} + + {streamError && ( + + {streamError} + + )} + + } + > + + {logs.length === 0 && isStreaming && Waiting for logs...} + {logs.length > 0 && ( + + {logs.slice(-20).map((log, idx) => ( + + {log.message} + + ))} + {logs.length > 20 && ( + Showing last 20 of {logs.length} entries. Press l for full-screen view. + )} + + )} + + + ); +} diff --git a/src/cli/tui/screens/logs/index.ts b/src/cli/tui/screens/logs/index.ts new file mode 100644 index 000000000..afbe3eee9 --- /dev/null +++ b/src/cli/tui/screens/logs/index.ts @@ -0,0 +1 @@ +export { LogsScreen } from './LogsScreen'; diff --git a/src/cli/tui/utils/__tests__/commands.test.ts b/src/cli/tui/utils/__tests__/commands.test.ts index 3b120ab41..7894581ed 100644 --- a/src/cli/tui/utils/__tests__/commands.test.ts +++ b/src/cli/tui/utils/__tests__/commands.test.ts @@ -49,9 +49,9 @@ describe('getCommandsForUI', () => { expect(pkg!.cliOnly).toBe(false); }); - it('marks logs, traces, pause, resume as cliOnly', () => { + it('marks traces, pause, resume as cliOnly', () => { const cmds = getCommandsForUI(program); - for (const name of ['logs', 'traces', 'pause', 'resume']) { + for (const name of ['traces', 'pause', 'resume']) { const cmd = cmds.find(c => c.id === name); expect(cmd, `${name} should be in results`).toBeDefined(); expect(cmd!.cliOnly, `${name} should be cliOnly`).toBe(true); diff --git a/src/cli/tui/utils/commands.ts b/src/cli/tui/utils/commands.ts index 8014e5d98..7d48eae79 100644 --- a/src/cli/tui/utils/commands.ts +++ b/src/cli/tui/utils/commands.ts @@ -17,7 +17,7 @@ const HIDDEN_FROM_TUI = ['help', 'telemetry', 'promote'] as const; /** * Commands that are CLI-only (shown but marked as requiring CLI invocation). */ -const CLI_ONLY_COMMANDS = ['logs', 'traces', 'pause', 'resume', 'stop', 'archive'] as const; +const CLI_ONLY_COMMANDS = ['traces', 'pause', 'resume', 'stop', 'archive'] as const; /** * Commands hidden from TUI when inside an existing project. From 1bc807a2f6bfab1ef3fd428df31f6c377e4c830b Mon Sep 17 00:00:00 2001 From: notgitika Date: Fri, 15 May 2026 17:06:36 -0400 Subject: [PATCH 2/7] fix: address review feedback - fix help text and add unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove misleading "↑↓ scroll" from inline streaming view help text since scrolling is only available in the full-screen view (press l) - Add unit tests for useLogsStream hook (7 tests covering idle state, streaming, error handling, abort cleanup, and buffer cap) - Add unit tests for LogsScreen (5 tests covering all phase transitions) --- .../hooks/__tests__/useLogsStream.test.tsx | 165 ++++++++++++++++++ src/cli/tui/screens/logs/LogsScreen.tsx | 6 +- .../logs/__tests__/LogsScreen.test.tsx | 134 ++++++++++++++ 3 files changed, 300 insertions(+), 5 deletions(-) create mode 100644 src/cli/tui/hooks/__tests__/useLogsStream.test.tsx create mode 100644 src/cli/tui/screens/logs/__tests__/LogsScreen.test.tsx diff --git a/src/cli/tui/hooks/__tests__/useLogsStream.test.tsx b/src/cli/tui/hooks/__tests__/useLogsStream.test.tsx new file mode 100644 index 000000000..5fc499903 --- /dev/null +++ b/src/cli/tui/hooks/__tests__/useLogsStream.test.tsx @@ -0,0 +1,165 @@ +import type { AgentContext } from '../../../commands/logs/action.js'; +import { useLogsStream } from '../useLogsStream.js'; +import { Text } from 'ink'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mockStreamLogs } = vi.hoisted(() => ({ + mockStreamLogs: vi.fn(), +})); + +vi.mock('../../../aws/cloudwatch.js', () => ({ + streamLogs: mockStreamLogs, +})); + +const AGENT_CONTEXT: AgentContext = { + agentId: 'test-runtime-id', + agentName: 'TestAgent', + accountId: '123456789012', + region: 'us-east-1', + endpointName: 'default', + logGroupName: '/aws/bedrock-agentcore/runtimes/test-runtime-id-default', +}; + +function Harness({ agentContext }: { agentContext: AgentContext | undefined }) { + const { logs, isStreaming, error } = useLogsStream(agentContext); + return ( + + streaming:{String(isStreaming)} logs:{logs.length} error:{error ?? 'null'} + + ); +} + +describe('useLogsStream', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('stays idle when agentContext is undefined', () => { + // eslint-disable-next-line require-yield + mockStreamLogs.mockImplementation(async function* () { + await Promise.resolve(); + }); + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('streaming:false'); + expect(lastFrame()).toContain('logs:0'); + expect(lastFrame()).toContain('error:null'); + expect(mockStreamLogs).not.toHaveBeenCalled(); + }); + + it('transitions to streaming and appends log entries', async () => { + let resolve: () => void; + const done = new Promise(r => { + resolve = r; + }); + + mockStreamLogs.mockImplementation(async function* () { + await Promise.resolve(); + yield { timestamp: 1000, message: 'hello world' }; + yield { timestamp: 2000, message: '[ERROR] something broke' }; + resolve!(); + }); + + const { lastFrame } = render(); + + await done; + await new Promise(r => setTimeout(r, 50)); + + const frame = lastFrame(); + expect(frame).toContain('logs:2'); + }); + + it('detects error level from message content', async () => { + let resolve: () => void; + const done = new Promise(r => { + resolve = r; + }); + + mockStreamLogs.mockImplementation(async function* () { + await Promise.resolve(); + yield { timestamp: 1000, message: '[error] bad thing' }; + resolve!(); + }); + + const { lastFrame } = render(); + await done; + await new Promise(r => setTimeout(r, 50)); + + expect(lastFrame()).toContain('logs:1'); + }); + + it('shows friendly message for ResourceNotFoundException', async () => { + // eslint-disable-next-line require-yield + mockStreamLogs.mockImplementation(async function* () { + await Promise.resolve(); + const err = new Error('Not found'); + err.name = 'ResourceNotFoundException'; + throw err; + }); + + const { lastFrame } = render(); + await new Promise(r => setTimeout(r, 50)); + + const frame = lastFrame(); + expect(frame).toContain('error:No logs found'); + expect(frame).toContain('Has the agent been invoked?'); + expect(frame).toContain('streaming:false'); + }); + + it('shows generic error message for other errors', async () => { + // eslint-disable-next-line require-yield + mockStreamLogs.mockImplementation(async function* () { + await Promise.resolve(); + throw new Error('Connection refused'); + }); + + const { lastFrame } = render(); + await new Promise(r => setTimeout(r, 50)); + + const frame = lastFrame(); + expect(frame).toContain('error:Connection refused'); + expect(frame).toContain('streaming:false'); + }); + + it('does not surface abort errors when unmounted', async () => { + // eslint-disable-next-line require-yield + mockStreamLogs.mockImplementation(async function* ({ abortSignal }: { abortSignal: AbortSignal }) { + await new Promise((_, reject) => { + abortSignal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))); + }); + }); + + const { lastFrame, unmount } = render(); + await new Promise(r => setTimeout(r, 50)); + + expect(lastFrame()).toContain('streaming:true'); + + unmount(); + await new Promise(r => setTimeout(r, 50)); + }); + + it('caps log buffer at 1000 entries', async () => { + let resolve: () => void; + const done = new Promise(r => { + resolve = r; + }); + + mockStreamLogs.mockImplementation(async function* () { + await Promise.resolve(); + for (let i = 0; i < 1050; i++) { + yield { timestamp: i, message: `log ${i}` }; + } + resolve!(); + }); + + const { lastFrame } = render(); + await done; + await new Promise(r => setTimeout(r, 100)); + + const frame = lastFrame(); + expect(frame).toContain('logs:1000'); + }); +}); diff --git a/src/cli/tui/screens/logs/LogsScreen.tsx b/src/cli/tui/screens/logs/LogsScreen.tsx index 2ca66be7d..ded775aeb 100644 --- a/src/cli/tui/screens/logs/LogsScreen.tsx +++ b/src/cli/tui/screens/logs/LogsScreen.tsx @@ -132,11 +132,7 @@ export function LogsScreen({ onExit }: LogsScreenProps) { ); } - const helpText = isStreaming - ? '↑↓ scroll · l full-screen · Esc back' - : streamError - ? 'Esc back' - : '↑↓ scroll · l full-screen · Esc back'; + const helpText = isStreaming ? 'l full-screen · Esc back' : streamError ? 'Esc back' : 'l full-screen · Esc back'; return ( ({ + mockLoadDeployedProjectConfig: vi.fn(), + mockResolveAgentContext: vi.fn(), +})); + +vi.mock('../../../../commands/logs/action.js', () => ({ + resolveAgentContext: mockResolveAgentContext, +})); + +vi.mock('../../../../operations/resolve-agent.js', () => ({ + loadDeployedProjectConfig: mockLoadDeployedProjectConfig, +})); + +vi.mock('../../../../aws/cloudwatch.js', () => ({ + // eslint-disable-next-line require-yield + async *streamLogs() { + await new Promise(() => { + /* never resolves */ + }); + }, +})); + +const noop = () => undefined; + +describe('LogsScreen', () => { + afterEach(() => vi.clearAllMocks()); + + it('shows error when no runtimes are defined', async () => { + mockLoadDeployedProjectConfig.mockResolvedValue({ + project: { runtimes: [] }, + deployedState: { targets: {} }, + awsTargets: [], + }); + + const { lastFrame } = render(); + await new Promise(r => setTimeout(r, 50)); + + expect(lastFrame()).toContain('No runtimes defined'); + }); + + it('shows error when no agents are deployed', async () => { + mockLoadDeployedProjectConfig.mockResolvedValue({ + project: { runtimes: [{ name: 'Agent1' }] }, + deployedState: { targets: {} }, + awsTargets: [], + }); + mockResolveAgentContext.mockReturnValue({ success: false, error: 'Not deployed' }); + + const { lastFrame } = render(); + await new Promise(r => setTimeout(r, 50)); + + expect(lastFrame()).toContain('No deployed agents found'); + }); + + it('auto-selects single agent and shows streaming view', async () => { + mockLoadDeployedProjectConfig.mockResolvedValue({ + project: { runtimes: [{ name: 'MyAgent' }] }, + deployedState: { targets: { default: { resources: { runtimes: {} } } } }, + awsTargets: [{ name: 'default', region: 'us-east-1', account: '123' }], + }); + mockResolveAgentContext.mockReturnValue({ + success: true, + agentContext: { + agentId: 'rt-123', + agentName: 'MyAgent', + accountId: '123', + region: 'us-east-1', + endpointName: 'default', + logGroupName: '/aws/logs/test', + }, + }); + + const { lastFrame } = render(); + await new Promise(r => setTimeout(r, 50)); + + const frame = lastFrame(); + expect(frame).toContain('Agent:'); + expect(frame).toContain('MyAgent'); + expect(frame).toContain('us-east-1'); + }); + + it('shows agent selection when multiple agents exist', async () => { + mockLoadDeployedProjectConfig.mockResolvedValue({ + project: { runtimes: [{ name: 'Agent1' }, { name: 'Agent2' }] }, + deployedState: { targets: { default: { resources: { runtimes: {} } } } }, + awsTargets: [{ name: 'default', region: 'us-west-2', account: '456' }], + }); + mockResolveAgentContext + .mockReturnValueOnce({ + success: true, + agentContext: { + agentId: 'rt-1', + agentName: 'Agent1', + accountId: '456', + region: 'us-west-2', + endpointName: 'default', + logGroupName: '/aws/logs/1', + }, + }) + .mockReturnValueOnce({ + success: true, + agentContext: { + agentId: 'rt-2', + agentName: 'Agent2', + accountId: '456', + region: 'us-west-2', + endpointName: 'default', + logGroupName: '/aws/logs/2', + }, + }); + + const { lastFrame } = render(); + await new Promise(r => setTimeout(r, 50)); + + const frame = lastFrame(); + expect(frame).toContain('Select an agent'); + expect(frame).toContain('Agent1'); + expect(frame).toContain('Agent2'); + }); + + it('shows error when config loading throws', async () => { + mockLoadDeployedProjectConfig.mockRejectedValue(new Error('File not found')); + + const { lastFrame } = render(); + await new Promise(r => setTimeout(r, 50)); + + expect(lastFrame()).toContain('File not found'); + }); +}); From 45e20dfed8a626a8bd4d3602a791584ce44b4612 Mon Sep 17 00:00:00 2001 From: notgitika Date: Fri, 15 May 2026 17:27:04 -0400 Subject: [PATCH 3/7] feat: add scrolling and level filtering to Logs screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace manual slice with LogPanel component (built-in ↑↓ scrolling, auto-scroll to bottom, page up/down) - Add level filter shortcuts: 1=all, 2=errors, 3=warn+, 4=info+ - Change full-screen key from l to f (l conflicts with scroll) - Show filter status and count in header --- src/cli/tui/screens/logs/LogsScreen.tsx | 77 ++++++++++++++++--------- 1 file changed, 50 insertions(+), 27 deletions(-) diff --git a/src/cli/tui/screens/logs/LogsScreen.tsx b/src/cli/tui/screens/logs/LogsScreen.tsx index ded775aeb..32cf10951 100644 --- a/src/cli/tui/screens/logs/LogsScreen.tsx +++ b/src/cli/tui/screens/logs/LogsScreen.tsx @@ -1,10 +1,11 @@ import type { AgentContext } from '../../../commands/logs/action'; import { resolveAgentContext } from '../../../commands/logs/action'; import { loadDeployedProjectConfig } from '../../../operations/resolve-agent'; -import { FullScreenLogView, Screen, SelectList } from '../../components'; +import { FullScreenLogView, LogPanel, Screen, SelectList } from '../../components'; +import type { LogEntry } from '../../components/LogPanel'; import { useLogsStream } from '../../hooks/useLogsStream'; -import { Box, Text, useInput } from 'ink'; -import React, { useEffect, useState } from 'react'; +import { Box, Text, useInput, useStdout } from 'ink'; +import React, { useEffect, useMemo, useState } from 'react'; interface LogsScreenProps { isInteractive: boolean; @@ -12,6 +13,21 @@ interface LogsScreenProps { } type Phase = 'loading' | 'select-agent' | 'streaming' | 'error'; +type LevelFilter = 'all' | 'error' | 'warn' | 'info'; + +const FILTER_LABELS: Record = { + all: 'All', + error: 'Errors', + warn: 'Warnings+', + info: 'Info+', +}; + +function filterLogs(logs: LogEntry[], filter: LevelFilter): LogEntry[] { + if (filter === 'all') return logs; + if (filter === 'error') return logs.filter(l => l.level === 'error'); + if (filter === 'warn') return logs.filter(l => l.level === 'error' || l.level === 'warn'); + return logs; +} export function LogsScreen({ onExit }: LogsScreenProps) { const [phase, setPhase] = useState('loading'); @@ -20,9 +36,15 @@ export function LogsScreen({ onExit }: LogsScreenProps) { const [selectedAgent, setSelectedAgent] = useState(); const [loadError, setLoadError] = useState(); const [showFullScreen, setShowFullScreen] = useState(false); + const [levelFilter, setLevelFilter] = useState('all'); + const { stdout } = useStdout(); + const terminalHeight = stdout?.rows ?? 24; + const maxLines = Math.max(5, terminalHeight - 14); const { logs, isStreaming, error: streamError } = useLogsStream(selectedAgent); + const filteredLogs = useMemo(() => filterLogs(logs, levelFilter), [logs, levelFilter]); + useEffect(() => { const load = async () => { try { @@ -85,16 +107,28 @@ export function LogsScreen({ onExit }: LogsScreenProps) { } if (phase === 'streaming' && !showFullScreen) { - if (input === 'l') { + if (input === 'f') { setShowFullScreen(true); } + if (input === '1') { + setLevelFilter('all'); + } + if (input === '2') { + setLevelFilter('error'); + } + if (input === '3') { + setLevelFilter('warn'); + } + if (input === '4') { + setLevelFilter('info'); + } } }, { isActive: !showFullScreen } ); if (showFullScreen) { - return setShowFullScreen(false)} />; + return setShowFullScreen(false)} />; } if (phase === 'loading') { @@ -132,22 +166,27 @@ export function LogsScreen({ onExit }: LogsScreenProps) { ); } - const helpText = isStreaming ? 'l full-screen · Esc back' : streamError ? 'Esc back' : 'l full-screen · Esc back'; + const helpText = '↑↓ scroll · f full-screen · 1 all · 2 errors · 3 warn+ · 4 info+ · Esc back'; return ( Agent: {selectedAgent?.agentName} - - - Region: + Region: {selectedAgent?.region} + Filter: + {FILTER_LABELS[levelFilter]} + + {' '} + ({filteredLogs.length}/{logs.length}) + Status: @@ -159,28 +198,12 @@ export function LogsScreen({ onExit }: LogsScreenProps) { Disconnected )} - {streamError && ( - - {streamError} - - )} + {streamError && {streamError}} } > - {logs.length === 0 && isStreaming && Waiting for logs...} - {logs.length > 0 && ( - - {logs.slice(-20).map((log, idx) => ( - - {log.message} - - ))} - {logs.length > 20 && ( - Showing last 20 of {logs.length} entries. Press l for full-screen view. - )} - - )} + ); From 06bf658ab9b04e035ffbcd6dc866f7c58a3c40b5 Mon Sep 17 00:00:00 2001 From: notgitika Date: Fri, 15 May 2026 17:35:19 -0400 Subject: [PATCH 4/7] fix: constrain log panel height and simplify filters - Set fixed height on LogPanel container to prevent header from being pushed off screen when logs accumulate - Remove redundant info+ filter (same as all), keep 1=all 2=errors 3=warn+ --- src/cli/tui/screens/logs/LogsScreen.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/cli/tui/screens/logs/LogsScreen.tsx b/src/cli/tui/screens/logs/LogsScreen.tsx index 32cf10951..21cd1d16c 100644 --- a/src/cli/tui/screens/logs/LogsScreen.tsx +++ b/src/cli/tui/screens/logs/LogsScreen.tsx @@ -13,13 +13,12 @@ interface LogsScreenProps { } type Phase = 'loading' | 'select-agent' | 'streaming' | 'error'; -type LevelFilter = 'all' | 'error' | 'warn' | 'info'; +type LevelFilter = 'all' | 'error' | 'warn'; const FILTER_LABELS: Record = { all: 'All', error: 'Errors', - warn: 'Warnings+', - info: 'Info+', + warn: 'Warn+Errors', }; function filterLogs(logs: LogEntry[], filter: LevelFilter): LogEntry[] { @@ -119,9 +118,6 @@ export function LogsScreen({ onExit }: LogsScreenProps) { if (input === '3') { setLevelFilter('warn'); } - if (input === '4') { - setLevelFilter('info'); - } } }, { isActive: !showFullScreen } @@ -166,7 +162,7 @@ export function LogsScreen({ onExit }: LogsScreenProps) { ); } - const helpText = '↑↓ scroll · f full-screen · 1 all · 2 errors · 3 warn+ · 4 info+ · Esc back'; + const helpText = '↑↓ scroll · f full-screen · 1 all · 2 errors · 3 warn+ · Esc back'; return ( } > - + From ec4dfc4b5cfc5deb564b9cb2cb53bbc5a4c82453 Mon Sep 17 00:00:00 2001 From: notgitika Date: Fri, 15 May 2026 17:45:17 -0400 Subject: [PATCH 5/7] fix: prevent log content from bleeding into footer Add overflow="hidden" to the LogPanel container so long wrapped log lines don't overflow past the fixed height and corrupt the help text. --- src/cli/tui/screens/logs/LogsScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/tui/screens/logs/LogsScreen.tsx b/src/cli/tui/screens/logs/LogsScreen.tsx index 21cd1d16c..cd7a220ac 100644 --- a/src/cli/tui/screens/logs/LogsScreen.tsx +++ b/src/cli/tui/screens/logs/LogsScreen.tsx @@ -198,7 +198,7 @@ export function LogsScreen({ onExit }: LogsScreenProps) { } > - + From d472a328761ea25134958f305318a2830a6bdb04 Mon Sep 17 00:00:00 2001 From: notgitika Date: Fri, 15 May 2026 17:49:31 -0400 Subject: [PATCH 6/7] fix: improve log level colors - red errors, yellow warnings, readable default Explicitly detect info level (gray/dim) separately from unclassified logs which now render as system level (cyan, readable). Errors stay red, warnings stay yellow. --- src/cli/tui/hooks/useLogsStream.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cli/tui/hooks/useLogsStream.ts b/src/cli/tui/hooks/useLogsStream.ts index 02be08089..7b82f507f 100644 --- a/src/cli/tui/hooks/useLogsStream.ts +++ b/src/cli/tui/hooks/useLogsStream.ts @@ -19,7 +19,10 @@ function detectLevel(message: string): LogEntry['level'] { if (lower.includes('"level":"warn"') || lower.includes('[warn]') || lower.startsWith('warn')) { return 'warn'; } - return 'info'; + if (lower.includes('"level":"info"') || lower.includes('[info]') || lower.startsWith('info')) { + return 'info'; + } + return 'system'; } export function useLogsStream(agentContext: AgentContext | undefined): UseLogsStreamResult { From c8bb614e8b2df416bb43a22ee9458dccd90ecaa6 Mon Sep 17 00:00:00 2001 From: notgitika Date: Mon, 18 May 2026 16:37:19 -0400 Subject: [PATCH 7/7] refactor(tui): address PR review feedback for Logs screen - Extract multi-step workflow into useLogsFlow hook - Use useListNavigation for agent selection - Wire up withCommandRunTelemetry for logs command - Use HELP_TEXT constants instead of inline strings - Fix misleading scroll help text in streaming view --- src/cli/tui/screens/logs/LogsScreen.tsx | 94 +++++-------------------- src/cli/tui/screens/logs/useLogsFlow.ts | 84 ++++++++++++++++++++++ 2 files changed, 100 insertions(+), 78 deletions(-) create mode 100644 src/cli/tui/screens/logs/useLogsFlow.ts diff --git a/src/cli/tui/screens/logs/LogsScreen.tsx b/src/cli/tui/screens/logs/LogsScreen.tsx index cd7a220ac..0d091d472 100644 --- a/src/cli/tui/screens/logs/LogsScreen.tsx +++ b/src/cli/tui/screens/logs/LogsScreen.tsx @@ -1,18 +1,16 @@ -import type { AgentContext } from '../../../commands/logs/action'; -import { resolveAgentContext } from '../../../commands/logs/action'; -import { loadDeployedProjectConfig } from '../../../operations/resolve-agent'; import { FullScreenLogView, LogPanel, Screen, SelectList } from '../../components'; import type { LogEntry } from '../../components/LogPanel'; -import { useLogsStream } from '../../hooks/useLogsStream'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation } from '../../hooks/useListNavigation'; +import { useLogsFlow } from './useLogsFlow'; import { Box, Text, useInput, useStdout } from 'ink'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; interface LogsScreenProps { isInteractive: boolean; onExit: () => void; } -type Phase = 'loading' | 'select-agent' | 'streaming' | 'error'; type LevelFilter = 'all' | 'error' | 'warn'; const FILTER_LABELS: Record = { @@ -29,82 +27,24 @@ function filterLogs(logs: LogEntry[], filter: LevelFilter): LogEntry[] { } export function LogsScreen({ onExit }: LogsScreenProps) { - const [phase, setPhase] = useState('loading'); - const [agents, setAgents] = useState([]); - const [selectedIndex, setSelectedIndex] = useState(0); - const [selectedAgent, setSelectedAgent] = useState(); - const [loadError, setLoadError] = useState(); + const { phase, agents, selectedAgent, loadError, selectAgent, logs, isStreaming, streamError } = useLogsFlow(); const [showFullScreen, setShowFullScreen] = useState(false); const [levelFilter, setLevelFilter] = useState('all'); const { stdout } = useStdout(); const terminalHeight = stdout?.rows ?? 24; const maxLines = Math.max(5, terminalHeight - 14); - const { logs, isStreaming, error: streamError } = useLogsStream(selectedAgent); - const filteredLogs = useMemo(() => filterLogs(logs, levelFilter), [logs, levelFilter]); - useEffect(() => { - const load = async () => { - try { - const context = await loadDeployedProjectConfig(); - const runtimeNames = context.project.runtimes.map(r => r.name); - - if (runtimeNames.length === 0) { - setLoadError('No runtimes defined in agentcore.json'); - setPhase('error'); - return; - } - - const resolved: AgentContext[] = []; - for (const name of runtimeNames) { - const result = resolveAgentContext(context, { runtime: name }); - if (result.success) { - resolved.push(result.agentContext); - } - } - - if (resolved.length === 0) { - setLoadError('No deployed agents found. Run `agentcore deploy` first.'); - setPhase('error'); - return; - } - - setAgents(resolved); - - if (resolved.length === 1) { - setSelectedAgent(resolved[0]); - setPhase('streaming'); - } else { - setPhase('select-agent'); - } - } catch (err) { - setLoadError((err as Error).message ?? 'Failed to load project config'); - setPhase('error'); - } - }; - - void load(); - }, []); + const { selectedIndex } = useListNavigation({ + items: agents, + onSelect: agent => selectAgent(agent), + onExit, + isActive: phase === 'select-agent' && !showFullScreen, + }); useInput( - (input, key) => { - if (phase === 'select-agent') { - if (key.upArrow || input === 'k') { - setSelectedIndex(prev => (prev - 1 + agents.length) % agents.length); - } - if (key.downArrow || input === 'j') { - setSelectedIndex(prev => (prev + 1) % agents.length); - } - if (key.return) { - const agent = agents[selectedIndex]; - if (agent) { - setSelectedAgent(agent); - setPhase('streaming'); - } - } - } - + (input, _key) => { if (phase === 'streaming' && !showFullScreen) { if (input === 'f') { setShowFullScreen(true); @@ -120,7 +60,7 @@ export function LogsScreen({ onExit }: LogsScreenProps) { } } }, - { isActive: !showFullScreen } + { isActive: phase === 'streaming' && !showFullScreen } ); if (showFullScreen) { @@ -137,7 +77,7 @@ export function LogsScreen({ onExit }: LogsScreenProps) { if (phase === 'error') { return ( - + {loadError} ); @@ -151,7 +91,7 @@ export function LogsScreen({ onExit }: LogsScreenProps) { })); return ( - + Select an agent to stream logs: @@ -162,13 +102,11 @@ export function LogsScreen({ onExit }: LogsScreenProps) { ); } - const helpText = '↑↓ scroll · f full-screen · 1 all · 2 errors · 3 warn+ · Esc back'; - return ( diff --git a/src/cli/tui/screens/logs/useLogsFlow.ts b/src/cli/tui/screens/logs/useLogsFlow.ts new file mode 100644 index 000000000..fc7537027 --- /dev/null +++ b/src/cli/tui/screens/logs/useLogsFlow.ts @@ -0,0 +1,84 @@ +import type { AgentContext } from '../../../commands/logs/action'; +import { resolveAgentContext } from '../../../commands/logs/action'; +import { loadDeployedProjectConfig } from '../../../operations/resolve-agent'; +import { withCommandRunTelemetry } from '../../../telemetry/cli-command-run.js'; +import type { LogEntry } from '../../components/LogPanel'; +import { useLogsStream } from '../../hooks/useLogsStream'; +import { useEffect, useState } from 'react'; + +type Phase = 'loading' | 'select-agent' | 'streaming' | 'error'; + +interface UseLogsFlowResult { + phase: Phase; + agents: AgentContext[]; + selectedAgent: AgentContext | undefined; + loadError: string | undefined; + selectAgent: (agent: AgentContext) => void; + logs: LogEntry[]; + isStreaming: boolean; + streamError: string | undefined; +} + +export function useLogsFlow(): UseLogsFlowResult { + const [phase, setPhase] = useState('loading'); + const [agents, setAgents] = useState([]); + const [selectedAgent, setSelectedAgent] = useState(); + const [loadError, setLoadError] = useState(); + + const { logs, isStreaming, error: streamError } = useLogsStream(selectedAgent); + + useEffect(() => { + const load = async () => { + const result = await withCommandRunTelemetry('logs', { has_query: false, has_level_filter: false }, async () => { + const context = await loadDeployedProjectConfig(); + const runtimeNames = context.project.runtimes.map(r => r.name); + + if (runtimeNames.length === 0) { + return { success: false as const, error: new Error('No runtimes defined in agentcore.json') }; + } + + const resolved: AgentContext[] = []; + for (const name of runtimeNames) { + const res = resolveAgentContext(context, { runtime: name }); + if (res.success) { + resolved.push(res.agentContext); + } + } + + if (resolved.length === 0) { + return { + success: false as const, + error: new Error('No deployed agents found. Run `agentcore deploy` first.'), + }; + } + + return { success: true as const, agents: resolved }; + }); + + if (!result.success) { + setLoadError(result.error.message); + setPhase('error'); + return; + } + + const resolved = (result as { success: true; agents: AgentContext[] }).agents; + setAgents(resolved); + + if (resolved.length === 1) { + setSelectedAgent(resolved[0]); + setPhase('streaming'); + } else { + setPhase('select-agent'); + } + }; + + void load(); + }, []); + + const selectAgent = (agent: AgentContext) => { + setSelectedAgent(agent); + setPhase('streaming'); + }; + + return { phase, agents, selectedAgent, loadError, selectAgent, logs, isStreaming, streamError }; +}