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
318 changes: 248 additions & 70 deletions src/cli/tui/screens/invoke/InvokeScreen.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { GradientText, LogLink, Panel, Screen, ScrollableText, SelectList, TextInput } from '../../components';
import { GradientText, LogLink, Panel, Screen, SelectList, TextInput } from '../../components';
import { useInvokeFlow } from './useInvokeFlow';
import { Box, Text, useInput } from 'ink';
import React, { useEffect, useState } from 'react';
import { Box, Text, useInput, useStdout } from 'ink';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';

interface InvokeScreenProps {
/** Whether running in interactive TUI mode (from App.tsx) vs CLI mode */
Expand All@@ -13,6 +13,81 @@ interface InvokeScreenProps {

type Mode = 'select-agent' | 'chat' | 'input';

/**
* Render conversation messages as a single string for scrolling.
*/
function formatConversation(messages: { role: 'user' | 'assistant'; content: string }[]): string {
const lines: string[] = [];

for (const msg of messages) {
// Skip empty assistant messages (placeholder before streaming starts)
if (msg.role === 'assistant' && !msg.content) continue;

if (msg.role === 'user') {
lines.push(`> ${msg.content}`);
} else {
lines.push(msg.content);
}
lines.push(''); // blank line between messages
}

return lines.join('\n');
}

/**
* Word-wrap a single line to fit within maxWidth.
*/
function wrapLine(line: string, maxWidth: number): string[] {
if (!line) return [''];
if (line.length <= maxWidth) return [line];

const wrapped: string[] = [];
const words = line.split(' ');
let currentLine = '';

for (const word of words) {
if (word.length > maxWidth) {
if (currentLine) {
wrapped.push(currentLine);
currentLine = '';
}
for (let i = 0; i < word.length; i += maxWidth) {
wrapped.push(word.slice(i, i + maxWidth));
}
continue;
}

const testLine = currentLine ? `${currentLine} ${word}` : word;
if (testLine.length <= maxWidth) {
currentLine = testLine;
} else {
if (currentLine) {
wrapped.push(currentLine);
}
currentLine = word;
}
}

if (currentLine) {
wrapped.push(currentLine);
}

return wrapped.length > 0 ? wrapped : [''];
}

/**
* Wrap multi-line text to fit within maxWidth.
*/
function wrapText(text: string, maxWidth: number): string[] {
if (!text) return [];
const lines = text.split('\n');
const wrapped: string[] = [];
for (const line of lines) {
wrapped.push(...wrapLine(line, maxWidth));
}
return wrapped;
}

export function InvokeScreen({
isInteractive: _isInteractive,
onExit,
Expand All@@ -22,6 +97,10 @@ export function InvokeScreen({
const { phase, config, selectedAgent, messages, error, logFilePath, sessionId, selectAgent, invoke, newSession } =
useInvokeFlow({ initialSessionId });
const [mode, setMode] = useState<Mode>('select-agent');
const [scrollOffset, setScrollOffset] = useState(0);
const [userScrolled, setUserScrolled] = useState(false);
const { stdout } = useStdout();
const justCancelledRef = useRef(false);

// Handle initial prompt - skip agent selection if only one agent
useEffect(() => {
Expand All@@ -46,42 +125,120 @@ export function InvokeScreen({
}, [initialPrompt, phase, messages.length, onExit]);

// Return to input mode after invoke completes
const prevPhaseRef = React.useRef(phase);
const prevPhaseRef = useRef(phase);
useEffect(() => {
if (prevPhaseRef.current === 'invoking' && phase === 'ready' && !initialPrompt) {
queueMicrotask(() => setMode('input'));
}
prevPhaseRef.current = phase;
}, [phase, initialPrompt]);

useInput((input, key) => {
if (phase === 'loading' || phase === 'error' || !config) return;
// Calculate available height for conversation display
const terminalHeight = stdout?.rows ?? 24;
const terminalWidth = stdout?.columns ?? 80;
Comment thread
notgitika marked this conversation as resolved.
const baseHeight = Math.max(5, terminalHeight - 12);
const displayHeight = mode === 'input' ? Math.max(3, baseHeight - 2) : baseHeight;
const contentWidth = Math.max(40, terminalWidth - 4);

// Format conversation content
const conversationText = useMemo(() => formatConversation(messages), [messages]);

// Wrap text for display
const lines = useMemo(() => wrapText(conversationText, contentWidth), [conversationText, contentWidth]);

const totalLines = lines.length;
const maxScroll = Math.max(0, totalLines - displayHeight);
const needsScroll = totalLines > displayHeight;

// Auto-scroll to bottom when user hasn't manually scrolled up
const effectiveOffset = useMemo(() => {
if (totalLines === 0) return 0;
if (!userScrolled && totalLines > displayHeight) return maxScroll;
return Math.min(scrollOffset, maxScroll);
}, [totalLines, userScrolled, scrollOffset, maxScroll, displayHeight]);

if (key.escape || (key.ctrl && input === 'q')) {
if (mode === 'input') {
setMode('chat');
} else if (mode === 'chat' && config.agents.length > 1) {
setMode('select-agent');
} else {
onExit();
const scrollUp = useCallback(
(amount = 1) => {
if (!needsScroll) return;
setUserScrolled(true);
setScrollOffset(prev => {
const current = userScrolled ? prev : maxScroll;
return Math.max(0, current - amount);
});
},
[needsScroll, userScrolled, maxScroll]
);

const scrollDown = useCallback(
(amount = 1) => {
if (!needsScroll) return;
setScrollOffset(prev => {
const next = Math.min(maxScroll, prev + amount);
if (next >= maxScroll) {
setUserScrolled(false);
}
return next;
});
},
[needsScroll, maxScroll]
);

useInput(
(input, key) => {
if (phase === 'loading' || phase === 'error' || !config) return;

// Agent selection mode
if (mode === 'select-agent') {
if (key.escape || (key.ctrl && input === 'q')) {
onExit();
return;
}
if (key.upArrow) selectAgent((selectedAgent - 1 + config.agents.length) % config.agents.length);
if (key.downArrow) selectAgent((selectedAgent + 1) % config.agents.length);
if (key.return) setMode('input');
return;
}
return;
}

if (mode === 'select-agent') {
if (key.upArrow) selectAgent((selectedAgent - 1 + config.agents.length) % config.agents.length);
if (key.downArrow) selectAgent((selectedAgent + 1) % config.agents.length);
if (key.return) setMode('input');
}
// Chat mode
if (mode === 'chat') {
if (key.escape || (key.ctrl && input === 'q') || (key.ctrl && input === 'c')) {
if (justCancelledRef.current) {
justCancelledRef.current = false;
return;
}
if (config.agents.length > 1) {
setMode('select-agent');
return;
}
onExit();
return;
}

if (mode === 'chat' && input === 'i' && phase === 'ready') {
setMode('input');
}
justCancelledRef.current = false;

if (mode === 'chat' && input === 'n' && phase === 'ready') {
newSession();
}
});
// Enter or 'i' to start typing (only when not invoking)
if ((key.return || input === 'i') && phase === 'ready') {
setMode('input');
return;
}

// New session
if (input === 'n' && phase === 'ready') {
newSession();
setScrollOffset(0);
setUserScrolled(false);
return;
}

// Scroll controls
if (key.upArrow) scrollUp(1);
else if (key.downArrow) scrollDown(1);
else if (key.pageUp) scrollUp(displayHeight);
else if (key.pageDown) scrollDown(displayHeight);
}
},
{ isActive: mode === 'chat' || mode === 'select-agent' }
);

// Error state - show error in main screen
if (phase === 'error') {
Expand All@@ -105,11 +262,16 @@ export function InvokeScreen({
}));

// Dynamic help text
const helpText = {
'select-agent': '↑↓ select · Enter confirm · Esc quit',
chat: '↑↓ scroll · Esc back',
input: 'Enter send · Esc cancel',
}[mode];
const helpText =
mode === 'select-agent'
? '↑↓ select · Enter confirm · Esc quit'
: mode === 'input'
? 'Enter send · Esc cancel'
: phase === 'invoking'
? '↑↓ scroll'
: messages.length > 0
? `↑↓ scroll · Enter invoke · N new session · ${config.agents.length > 1 ? 'Esc back' : 'Esc quit'}`
: `Enter to send a message · ${config.agents.length > 1 ? 'Esc back' : 'Esc quit'}`;

const headerContent = (
<Box flexDirection="column">
Expand All@@ -133,6 +295,7 @@ export function InvokeScreen({
<Text color="magenta">{sessionId?.slice(0, 8) ?? 'none'}</Text>
</Box>
)}
{logFilePath && <LogLink filePath={logFilePath} />}
</Box>
);

Expand All@@ -147,57 +310,72 @@ export function InvokeScreen({
);
}

// Get the current message pair (user prompt and assistant response)
const userMessage = messages.find(m => m.role === 'user');
const assistantMessage = messages.find(m => m.role === 'assistant');
// Visible lines for display
const visibleLines = lines.slice(effectiveOffset, effectiveOffset + displayHeight);

// Show messages in both chat and input modes so user can see conversation while typing
const showMessages = mode === 'chat' || mode === 'input';
// Check if the last assistant message is empty (streaming hasn't started yet)
const lastMessage = messages[messages.length - 1];
const showThinking = phase === 'invoking' && lastMessage?.role === 'assistant' && !lastMessage.content;

return (
<Screen title="AgentCore Invoke" onExit={onExit} helpText={helpText} headerContent={headerContent}>
<Box flexDirection="column" flexGrow={1}>
{messages.length === 0 && mode === 'chat' && <Text dimColor>Press &apos;i&apos; to send a message</Text>}

{/* User prompt */}
{showMessages && userMessage && (
<Box marginBottom={1}>
<Text color="blue">&gt; {userMessage.content}</Text>
{/* Conversation display - always visible when there's content */}
{messages.length > 0 && (
<Box flexDirection="column" height={needsScroll ? displayHeight : undefined}>
{visibleLines.map((line, idx) => {
// Detect user messages (start with "> ")
const isUserMessage = line.startsWith('> ');
return (
<Text key={effectiveOffset + idx} color={isUserMessage ? 'blue' : 'green'} wrap="truncate">
{line || ' '}
</Text>
);
})}
{/* Thinking indicator - shows while waiting for response to start */}
{showThinking && <GradientText text="Thinking..." />}
</Box>
)}

{/* Assistant response with scrolling */}
{showMessages && assistantMessage?.content && (
<Box marginBottom={1} flexDirection="column">
<ScrollableText
content={assistantMessage.content}
color="green"
isStreaming={phase === 'invoking'}
isActive={mode === 'chat' || mode === 'input'}
/>
</Box>
{/* Scroll indicator */}
{needsScroll && (
<Text dimColor>
[{effectiveOffset + 1}-{Math.min(effectiveOffset + displayHeight, totalLines)} of {totalLines}]
</Text>
)}

{/* Invoking indicator */}
{phase === 'invoking' && <GradientText text="Invoking..." />}

{/* Log file link after response */}
{logFilePath && messages.length > 0 && phase === 'ready' && (
<Box marginTop={1}>
<LogLink filePath={logFilePath} />
{/* Input area */}
{mode === 'chat' && phase === 'ready' && messages.length > 0 && (
<Box>
<Text dimColor>&gt; </Text>
</Box>
)}

{/* Input prompt */}
{mode === 'chat' && phase === 'ready' && messages.length === 0 && (
<Text dimColor>Press Enter to send a message</Text>
)}
{mode === 'input' && phase === 'ready' && (
<TextInput
prompt=""
onSubmit={text => {
setMode('chat');
void invoke(text);
}}
onCancel={() => setMode('chat')}
/>
<Box>
<Text color="blue">&gt; </Text>
<TextInput
prompt=""
hideArrow
onSubmit={text => {
if (text.trim()) {
setMode('chat');
setUserScrolled(false);
void invoke(text);
} else {
setMode('chat');
}
}}
onCancel={() => {
justCancelledRef.current = true;
setMode('chat');
}}
onUpArrow={() => scrollUp(1)}
onDownArrow={() => scrollDown(1)}
/>
</Box>
)}
</Box>
</Screen>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
318 changes: 248 additions & 70 deletions src/cli/tui/screens/invoke/InvokeScreen.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { GradientText, LogLink, Panel, Screen, ScrollableText, SelectList, TextInput } from '../../components';
import { GradientText, LogLink, Panel, Screen, SelectList, TextInput } from '../../components';
import { useInvokeFlow } from './useInvokeFlow';
import { Box, Text, useInput } from 'ink';
import React, { useEffect, useState } from 'react';
import { Box, Text, useInput, useStdout } from 'ink';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';

interface InvokeScreenProps {
/** Whether running in interactive TUI mode (from App.tsx) vs CLI mode */
Expand All@@ -13,6 +13,81 @@ interface InvokeScreenProps {

type Mode = 'select-agent' | 'chat' | 'input';

/**
* Render conversation messages as a single string for scrolling.
*/
function formatConversation(messages: { role: 'user' | 'assistant'; content: string }[]): string {
const lines: string[] = [];

for (const msg of messages) {
// Skip empty assistant messages (placeholder before streaming starts)
if (msg.role === 'assistant' && !msg.content) continue;

if (msg.role === 'user') {
lines.push(`> ${msg.content}`);
} else {
lines.push(msg.content);
}
lines.push(''); // blank line between messages
}

return lines.join('\n');
}

/**
* Word-wrap a single line to fit within maxWidth.
*/
function wrapLine(line: string, maxWidth: number): string[] {
if (!line) return [''];
if (line.length <= maxWidth) return [line];

const wrapped: string[] = [];
const words = line.split(' ');
let currentLine = '';

for (const word of words) {
if (word.length > maxWidth) {
if (currentLine) {
wrapped.push(currentLine);
currentLine = '';
}
for (let i = 0; i < word.length; i += maxWidth) {
wrapped.push(word.slice(i, i + maxWidth));
}
continue;
}

const testLine = currentLine ? `${currentLine} ${word}` : word;
if (testLine.length <= maxWidth) {
currentLine = testLine;
} else {
if (currentLine) {
wrapped.push(currentLine);
}
currentLine = word;
}
}

if (currentLine) {
wrapped.push(currentLine);
}

return wrapped.length > 0 ? wrapped : [''];
}

/**
* Wrap multi-line text to fit within maxWidth.
*/
function wrapText(text: string, maxWidth: number): string[] {
if (!text) return [];
const lines = text.split('\n');
const wrapped: string[] = [];
for (const line of lines) {
wrapped.push(...wrapLine(line, maxWidth));
}
return wrapped;
}

export function InvokeScreen({
isInteractive: _isInteractive,
onExit,
Expand All@@ -22,6 +97,10 @@ export function InvokeScreen({
const { phase, config, selectedAgent, messages, error, logFilePath, sessionId, selectAgent, invoke, newSession } =
useInvokeFlow({ initialSessionId });
const [mode, setMode] = useState<Mode>('select-agent');
const [scrollOffset, setScrollOffset] = useState(0);
const [userScrolled, setUserScrolled] = useState(false);
const { stdout } = useStdout();
const justCancelledRef = useRef(false);

// Handle initial prompt - skip agent selection if only one agent
useEffect(() => {
Expand All@@ -46,42 +125,120 @@ export function InvokeScreen({
}, [initialPrompt, phase, messages.length, onExit]);

// Return to input mode after invoke completes
const prevPhaseRef = React.useRef(phase);
const prevPhaseRef = useRef(phase);
useEffect(() => {
if (prevPhaseRef.current === 'invoking' && phase === 'ready' && !initialPrompt) {
queueMicrotask(() => setMode('input'));
}
prevPhaseRef.current = phase;
}, [phase, initialPrompt]);

useInput((input, key) => {
if (phase === 'loading' || phase === 'error' || !config) return;
// Calculate available height for conversation display
const terminalHeight = stdout?.rows ?? 24;
const terminalWidth = stdout?.columns ?? 80;
Comment thread
notgitika marked this conversation as resolved.
const baseHeight = Math.max(5, terminalHeight - 12);
const displayHeight = mode === 'input' ? Math.max(3, baseHeight - 2) : baseHeight;
const contentWidth = Math.max(40, terminalWidth - 4);

// Format conversation content
const conversationText = useMemo(() => formatConversation(messages), [messages]);

// Wrap text for display
const lines = useMemo(() => wrapText(conversationText, contentWidth), [conversationText, contentWidth]);

const totalLines = lines.length;
const maxScroll = Math.max(0, totalLines - displayHeight);
const needsScroll = totalLines > displayHeight;

// Auto-scroll to bottom when user hasn't manually scrolled up
const effectiveOffset = useMemo(() => {
if (totalLines === 0) return 0;
if (!userScrolled && totalLines > displayHeight) return maxScroll;
return Math.min(scrollOffset, maxScroll);
}, [totalLines, userScrolled, scrollOffset, maxScroll, displayHeight]);

if (key.escape || (key.ctrl && input === 'q')) {
if (mode === 'input') {
setMode('chat');
} else if (mode === 'chat' && config.agents.length > 1) {
setMode('select-agent');
} else {
onExit();
const scrollUp = useCallback(
(amount = 1) => {
if (!needsScroll) return;
setUserScrolled(true);
setScrollOffset(prev => {
const current = userScrolled ? prev : maxScroll;
return Math.max(0, current - amount);
});
},
[needsScroll, userScrolled, maxScroll]
);

const scrollDown = useCallback(
(amount = 1) => {
if (!needsScroll) return;
setScrollOffset(prev => {
const next = Math.min(maxScroll, prev + amount);
if (next >= maxScroll) {
setUserScrolled(false);
}
return next;
});
},
[needsScroll, maxScroll]
);

useInput(
(input, key) => {
if (phase === 'loading' || phase === 'error' || !config) return;

// Agent selection mode
if (mode === 'select-agent') {
if (key.escape || (key.ctrl && input === 'q')) {
onExit();
return;
}
if (key.upArrow) selectAgent((selectedAgent - 1 + config.agents.length) % config.agents.length);
if (key.downArrow) selectAgent((selectedAgent + 1) % config.agents.length);
if (key.return) setMode('input');
return;
}
return;
}

if (mode === 'select-agent') {
if (key.upArrow) selectAgent((selectedAgent - 1 + config.agents.length) % config.agents.length);
if (key.downArrow) selectAgent((selectedAgent + 1) % config.agents.length);
if (key.return) setMode('input');
}
// Chat mode
if (mode === 'chat') {
if (key.escape || (key.ctrl && input === 'q') || (key.ctrl && input === 'c')) {
if (justCancelledRef.current) {
justCancelledRef.current = false;
return;
}
if (config.agents.length > 1) {
setMode('select-agent');
return;
}
onExit();
return;
}

if (mode === 'chat' && input === 'i' && phase === 'ready') {
setMode('input');
}
justCancelledRef.current = false;

if (mode === 'chat' && input === 'n' && phase === 'ready') {
newSession();
}
});
// Enter or 'i' to start typing (only when not invoking)
if ((key.return || input === 'i') && phase === 'ready') {
setMode('input');
return;
}

// New session
if (input === 'n' && phase === 'ready') {
newSession();
setScrollOffset(0);
setUserScrolled(false);
return;
}

// Scroll controls
if (key.upArrow) scrollUp(1);
else if (key.downArrow) scrollDown(1);
else if (key.pageUp) scrollUp(displayHeight);
else if (key.pageDown) scrollDown(displayHeight);
}
},
{ isActive: mode === 'chat' || mode === 'select-agent' }
);

// Error state - show error in main screen
if (phase === 'error') {
Expand All@@ -105,11 +262,16 @@ export function InvokeScreen({
}));

// Dynamic help text
const helpText = {
'select-agent': '↑↓ select · Enter confirm · Esc quit',
chat: '↑↓ scroll · Esc back',
input: 'Enter send · Esc cancel',
}[mode];
const helpText =
mode === 'select-agent'
? '↑↓ select · Enter confirm · Esc quit'
: mode === 'input'
? 'Enter send · Esc cancel'
: phase === 'invoking'
? '↑↓ scroll'
: messages.length > 0
? `↑↓ scroll · Enter invoke · N new session · ${config.agents.length > 1 ? 'Esc back' : 'Esc quit'}`
: `Enter to send a message · ${config.agents.length > 1 ? 'Esc back' : 'Esc quit'}`;

const headerContent = (
<Box flexDirection="column">
Expand All@@ -133,6 +295,7 @@ export function InvokeScreen({
<Text color="magenta">{sessionId?.slice(0, 8) ?? 'none'}</Text>
</Box>
)}
{logFilePath && <LogLink filePath={logFilePath} />}
</Box>
);

Expand All@@ -147,57 +310,72 @@ export function InvokeScreen({
);
}

// Get the current message pair (user prompt and assistant response)
const userMessage = messages.find(m => m.role === 'user');
const assistantMessage = messages.find(m => m.role === 'assistant');
// Visible lines for display
const visibleLines = lines.slice(effectiveOffset, effectiveOffset + displayHeight);

// Show messages in both chat and input modes so user can see conversation while typing
const showMessages = mode === 'chat' || mode === 'input';
// Check if the last assistant message is empty (streaming hasn't started yet)
const lastMessage = messages[messages.length - 1];
const showThinking = phase === 'invoking' && lastMessage?.role === 'assistant' && !lastMessage.content;

return (
<Screen title="AgentCore Invoke" onExit={onExit} helpText={helpText} headerContent={headerContent}>
<Box flexDirection="column" flexGrow={1}>
{messages.length === 0 && mode === 'chat' && <Text dimColor>Press &apos;i&apos; to send a message</Text>}

{/* User prompt */}
{showMessages && userMessage && (
<Box marginBottom={1}>
<Text color="blue">&gt; {userMessage.content}</Text>
{/* Conversation display - always visible when there's content */}
{messages.length > 0 && (
<Box flexDirection="column" height={needsScroll ? displayHeight : undefined}>
{visibleLines.map((line, idx) => {
// Detect user messages (start with "> ")
const isUserMessage = line.startsWith('> ');
return (
<Text key={effectiveOffset + idx} color={isUserMessage ? 'blue' : 'green'} wrap="truncate">
{line || ' '}
</Text>
);
})}
{/* Thinking indicator - shows while waiting for response to start */}
{showThinking && <GradientText text="Thinking..." />}
</Box>
)}

{/* Assistant response with scrolling */}
{showMessages && assistantMessage?.content && (
<Box marginBottom={1} flexDirection="column">
<ScrollableText
content={assistantMessage.content}
color="green"
isStreaming={phase === 'invoking'}
isActive={mode === 'chat' || mode === 'input'}
/>
</Box>
{/* Scroll indicator */}
{needsScroll && (
<Text dimColor>
[{effectiveOffset + 1}-{Math.min(effectiveOffset + displayHeight, totalLines)} of {totalLines}]
</Text>
)}

{/* Invoking indicator */}
{phase === 'invoking' && <GradientText text="Invoking..." />}

{/* Log file link after response */}
{logFilePath && messages.length > 0 && phase === 'ready' && (
<Box marginTop={1}>
<LogLink filePath={logFilePath} />
{/* Input area */}
{mode === 'chat' && phase === 'ready' && messages.length > 0 && (
<Box>
<Text dimColor>&gt; </Text>
</Box>
)}

{/* Input prompt */}
{mode === 'chat' && phase === 'ready' && messages.length === 0 && (
<Text dimColor>Press Enter to send a message</Text>
)}
{mode === 'input' && phase === 'ready' && (
<TextInput
prompt=""
onSubmit={text => {
setMode('chat');
void invoke(text);
}}
onCancel={() => setMode('chat')}
/>
<Box>
<Text color="blue">&gt; </Text>
<TextInput
prompt=""
hideArrow
onSubmit={text => {
if (text.trim()) {
setMode('chat');
setUserScrolled(false);
void invoke(text);
} else {
setMode('chat');
}
}}
onCancel={() => {
justCancelledRef.current = true;
setMode('chat');
}}
onUpArrow={() => scrollUp(1)}
onDownArrow={() => scrollDown(1)}
/>
</Box>
)}
</Box>
</Screen>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
318 changes: 248 additions & 70 deletions src/cli/tui/screens/invoke/InvokeScreen.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { GradientText, LogLink, Panel, Screen, ScrollableText, SelectList, TextInput } from '../../components';
import { GradientText, LogLink, Panel, Screen, SelectList, TextInput } from '../../components';
import { useInvokeFlow } from './useInvokeFlow';
import { Box, Text, useInput } from 'ink';
import React, { useEffect, useState } from 'react';
import { Box, Text, useInput, useStdout } from 'ink';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';

interface InvokeScreenProps {
/** Whether running in interactive TUI mode (from App.tsx) vs CLI mode */
Expand All@@ -13,6 +13,81 @@ interface InvokeScreenProps {

type Mode = 'select-agent' | 'chat' | 'input';

/**
* Render conversation messages as a single string for scrolling.
*/
function formatConversation(messages: { role: 'user' | 'assistant'; content: string }[]): string {
const lines: string[] = [];

for (const msg of messages) {
// Skip empty assistant messages (placeholder before streaming starts)
if (msg.role === 'assistant' && !msg.content) continue;

if (msg.role === 'user') {
lines.push(`> ${msg.content}`);
} else {
lines.push(msg.content);
}
lines.push(''); // blank line between messages
}

return lines.join('\n');
}

/**
* Word-wrap a single line to fit within maxWidth.
*/
function wrapLine(line: string, maxWidth: number): string[] {
if (!line) return [''];
if (line.length <= maxWidth) return [line];

const wrapped: string[] = [];
const words = line.split(' ');
let currentLine = '';

for (const word of words) {
if (word.length > maxWidth) {
if (currentLine) {
wrapped.push(currentLine);
currentLine = '';
}
for (let i = 0; i < word.length; i += maxWidth) {
wrapped.push(word.slice(i, i + maxWidth));
}
continue;
}

const testLine = currentLine ? `${currentLine} ${word}` : word;
if (testLine.length <= maxWidth) {
currentLine = testLine;
} else {
if (currentLine) {
wrapped.push(currentLine);
}
currentLine = word;
}
}

if (currentLine) {
wrapped.push(currentLine);
}

return wrapped.length > 0 ? wrapped : [''];
}

/**
* Wrap multi-line text to fit within maxWidth.
*/
function wrapText(text: string, maxWidth: number): string[] {
if (!text) return [];
const lines = text.split('\n');
const wrapped: string[] = [];
for (const line of lines) {
wrapped.push(...wrapLine(line, maxWidth));
}
return wrapped;
}

export function InvokeScreen({
isInteractive: _isInteractive,
onExit,
Expand All@@ -22,6 +97,10 @@ export function InvokeScreen({
const { phase, config, selectedAgent, messages, error, logFilePath, sessionId, selectAgent, invoke, newSession } =
useInvokeFlow({ initialSessionId });
const [mode, setMode] = useState<Mode>('select-agent');
const [scrollOffset, setScrollOffset] = useState(0);
const [userScrolled, setUserScrolled] = useState(false);
const { stdout } = useStdout();
const justCancelledRef = useRef(false);

// Handle initial prompt - skip agent selection if only one agent
useEffect(() => {
Expand All@@ -46,42 +125,120 @@ export function InvokeScreen({
}, [initialPrompt, phase, messages.length, onExit]);

// Return to input mode after invoke completes
const prevPhaseRef = React.useRef(phase);
const prevPhaseRef = useRef(phase);
useEffect(() => {
if (prevPhaseRef.current === 'invoking' && phase === 'ready' && !initialPrompt) {
queueMicrotask(() => setMode('input'));
}
prevPhaseRef.current = phase;
}, [phase, initialPrompt]);

useInput((input, key) => {
if (phase === 'loading' || phase === 'error' || !config) return;
// Calculate available height for conversation display
const terminalHeight = stdout?.rows ?? 24;
const terminalWidth = stdout?.columns ?? 80;
Comment thread
notgitika marked this conversation as resolved.
const baseHeight = Math.max(5, terminalHeight - 12);
const displayHeight = mode === 'input' ? Math.max(3, baseHeight - 2) : baseHeight;
const contentWidth = Math.max(40, terminalWidth - 4);

// Format conversation content
const conversationText = useMemo(() => formatConversation(messages), [messages]);

// Wrap text for display
const lines = useMemo(() => wrapText(conversationText, contentWidth), [conversationText, contentWidth]);

const totalLines = lines.length;
const maxScroll = Math.max(0, totalLines - displayHeight);
const needsScroll = totalLines > displayHeight;

// Auto-scroll to bottom when user hasn't manually scrolled up
const effectiveOffset = useMemo(() => {
if (totalLines === 0) return 0;
if (!userScrolled && totalLines > displayHeight) return maxScroll;
return Math.min(scrollOffset, maxScroll);
}, [totalLines, userScrolled, scrollOffset, maxScroll, displayHeight]);

if (key.escape || (key.ctrl && input === 'q')) {
if (mode === 'input') {
setMode('chat');
} else if (mode === 'chat' && config.agents.length > 1) {
setMode('select-agent');
} else {
onExit();
const scrollUp = useCallback(
(amount = 1) => {
if (!needsScroll) return;
setUserScrolled(true);
setScrollOffset(prev => {
const current = userScrolled ? prev : maxScroll;
return Math.max(0, current - amount);
});
},
[needsScroll, userScrolled, maxScroll]
);

const scrollDown = useCallback(
(amount = 1) => {
if (!needsScroll) return;
setScrollOffset(prev => {
const next = Math.min(maxScroll, prev + amount);
if (next >= maxScroll) {
setUserScrolled(false);
}
return next;
});
},
[needsScroll, maxScroll]
);

useInput(
(input, key) => {
if (phase === 'loading' || phase === 'error' || !config) return;

// Agent selection mode
if (mode === 'select-agent') {
if (key.escape || (key.ctrl && input === 'q')) {
onExit();
return;
}
if (key.upArrow) selectAgent((selectedAgent - 1 + config.agents.length) % config.agents.length);
if (key.downArrow) selectAgent((selectedAgent + 1) % config.agents.length);
if (key.return) setMode('input');
return;
}
return;
}

if (mode === 'select-agent') {
if (key.upArrow) selectAgent((selectedAgent - 1 + config.agents.length) % config.agents.length);
if (key.downArrow) selectAgent((selectedAgent + 1) % config.agents.length);
if (key.return) setMode('input');
}
// Chat mode
if (mode === 'chat') {
if (key.escape || (key.ctrl && input === 'q') || (key.ctrl && input === 'c')) {
if (justCancelledRef.current) {
justCancelledRef.current = false;
return;
}
if (config.agents.length > 1) {
setMode('select-agent');
return;
}
onExit();
return;
}

if (mode === 'chat' && input === 'i' && phase === 'ready') {
setMode('input');
}
justCancelledRef.current = false;

if (mode === 'chat' && input === 'n' && phase === 'ready') {
newSession();
}
});
// Enter or 'i' to start typing (only when not invoking)
if ((key.return || input === 'i') && phase === 'ready') {
setMode('input');
return;
}

// New session
if (input === 'n' && phase === 'ready') {
newSession();
setScrollOffset(0);
setUserScrolled(false);
return;
}

// Scroll controls
if (key.upArrow) scrollUp(1);
else if (key.downArrow) scrollDown(1);
else if (key.pageUp) scrollUp(displayHeight);
else if (key.pageDown) scrollDown(displayHeight);
}
},
{ isActive: mode === 'chat' || mode === 'select-agent' }
);

// Error state - show error in main screen
if (phase === 'error') {
Expand All@@ -105,11 +262,16 @@ export function InvokeScreen({
}));

// Dynamic help text
const helpText = {
'select-agent': '↑↓ select · Enter confirm · Esc quit',
chat: '↑↓ scroll · Esc back',
input: 'Enter send · Esc cancel',
}[mode];
const helpText =
mode === 'select-agent'
? '↑↓ select · Enter confirm · Esc quit'
: mode === 'input'
? 'Enter send · Esc cancel'
: phase === 'invoking'
? '↑↓ scroll'
: messages.length > 0
? `↑↓ scroll · Enter invoke · N new session · ${config.agents.length > 1 ? 'Esc back' : 'Esc quit'}`
: `Enter to send a message · ${config.agents.length > 1 ? 'Esc back' : 'Esc quit'}`;

const headerContent = (
<Box flexDirection="column">
Expand All@@ -133,6 +295,7 @@ export function InvokeScreen({
<Text color="magenta">{sessionId?.slice(0, 8) ?? 'none'}</Text>
</Box>
)}
{logFilePath && <LogLink filePath={logFilePath} />}
</Box>
);

Expand All@@ -147,57 +310,72 @@ export function InvokeScreen({
);
}

// Get the current message pair (user prompt and assistant response)
const userMessage = messages.find(m => m.role === 'user');
const assistantMessage = messages.find(m => m.role === 'assistant');
// Visible lines for display
const visibleLines = lines.slice(effectiveOffset, effectiveOffset + displayHeight);

// Show messages in both chat and input modes so user can see conversation while typing
const showMessages = mode === 'chat' || mode === 'input';
// Check if the last assistant message is empty (streaming hasn't started yet)
const lastMessage = messages[messages.length - 1];
const showThinking = phase === 'invoking' && lastMessage?.role === 'assistant' && !lastMessage.content;

return (
<Screen title="AgentCore Invoke" onExit={onExit} helpText={helpText} headerContent={headerContent}>
<Box flexDirection="column" flexGrow={1}>
{messages.length === 0 && mode === 'chat' && <Text dimColor>Press &apos;i&apos; to send a message</Text>}

{/* User prompt */}
{showMessages && userMessage && (
<Box marginBottom={1}>
<Text color="blue">&gt; {userMessage.content}</Text>
{/* Conversation display - always visible when there's content */}
{messages.length > 0 && (
<Box flexDirection="column" height={needsScroll ? displayHeight : undefined}>
{visibleLines.map((line, idx) => {
// Detect user messages (start with "> ")
const isUserMessage = line.startsWith('> ');
return (
<Text key={effectiveOffset + idx} color={isUserMessage ? 'blue' : 'green'} wrap="truncate">
{line || ' '}
</Text>
);
})}
{/* Thinking indicator - shows while waiting for response to start */}
{showThinking && <GradientText text="Thinking..." />}
</Box>
)}

{/* Assistant response with scrolling */}
{showMessages && assistantMessage?.content && (
<Box marginBottom={1} flexDirection="column">
<ScrollableText
content={assistantMessage.content}
color="green"
isStreaming={phase === 'invoking'}
isActive={mode === 'chat' || mode === 'input'}
/>
</Box>
{/* Scroll indicator */}
{needsScroll && (
<Text dimColor>
[{effectiveOffset + 1}-{Math.min(effectiveOffset + displayHeight, totalLines)} of {totalLines}]
</Text>
)}

{/* Invoking indicator */}
{phase === 'invoking' && <GradientText text="Invoking..." />}

{/* Log file link after response */}
{logFilePath && messages.length > 0 && phase === 'ready' && (
<Box marginTop={1}>
<LogLink filePath={logFilePath} />
{/* Input area */}
{mode === 'chat' && phase === 'ready' && messages.length > 0 && (
<Box>
<Text dimColor>&gt; </Text>
</Box>
)}

{/* Input prompt */}
{mode === 'chat' && phase === 'ready' && messages.length === 0 && (
<Text dimColor>Press Enter to send a message</Text>
)}
{mode === 'input' && phase === 'ready' && (
<TextInput
prompt=""
onSubmit={text => {
setMode('chat');
void invoke(text);
}}
onCancel={() => setMode('chat')}
/>
<Box>
<Text color="blue">&gt; </Text>
<TextInput
prompt=""
hideArrow
onSubmit={text => {
if (text.trim()) {
setMode('chat');
setUserScrolled(false);
void invoke(text);
} else {
setMode('chat');
}
}}
onCancel={() => {
justCancelledRef.current = true;
setMode('chat');
}}
onUpArrow={() => scrollUp(1)}
onDownArrow={() => scrollDown(1)}
/>
</Box>
)}
</Box>
</Screen>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
318 changes: 248 additions & 70 deletions src/cli/tui/screens/invoke/InvokeScreen.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { GradientText, LogLink, Panel, Screen, ScrollableText, SelectList, TextInput } from '../../components';
import { GradientText, LogLink, Panel, Screen, SelectList, TextInput } from '../../components';
import { useInvokeFlow } from './useInvokeFlow';
import { Box, Text, useInput } from 'ink';
import React, { useEffect, useState } from 'react';
import { Box, Text, useInput, useStdout } from 'ink';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';

interface InvokeScreenProps {
/** Whether running in interactive TUI mode (from App.tsx) vs CLI mode */
Expand All@@ -13,6 +13,81 @@ interface InvokeScreenProps {

type Mode = 'select-agent' | 'chat' | 'input';

/**
* Render conversation messages as a single string for scrolling.
*/
function formatConversation(messages: { role: 'user' | 'assistant'; content: string }[]): string {
const lines: string[] = [];

for (const msg of messages) {
// Skip empty assistant messages (placeholder before streaming starts)
if (msg.role === 'assistant' && !msg.content) continue;

if (msg.role === 'user') {
lines.push(`> ${msg.content}`);
} else {
lines.push(msg.content);
}
lines.push(''); // blank line between messages
}

return lines.join('\n');
}

/**
* Word-wrap a single line to fit within maxWidth.
*/
function wrapLine(line: string, maxWidth: number): string[] {
if (!line) return [''];
if (line.length <= maxWidth) return [line];

const wrapped: string[] = [];
const words = line.split(' ');
let currentLine = '';

for (const word of words) {
if (word.length > maxWidth) {
if (currentLine) {
wrapped.push(currentLine);
currentLine = '';
}
for (let i = 0; i < word.length; i += maxWidth) {
wrapped.push(word.slice(i, i + maxWidth));
}
continue;
}

const testLine = currentLine ? `${currentLine} ${word}` : word;
if (testLine.length <= maxWidth) {
currentLine = testLine;
} else {
if (currentLine) {
wrapped.push(currentLine);
}
currentLine = word;
}
}

if (currentLine) {
wrapped.push(currentLine);
}

return wrapped.length > 0 ? wrapped : [''];
}

/**
* Wrap multi-line text to fit within maxWidth.
*/
function wrapText(text: string, maxWidth: number): string[] {
if (!text) return [];
const lines = text.split('\n');
const wrapped: string[] = [];
for (const line of lines) {
wrapped.push(...wrapLine(line, maxWidth));
}
return wrapped;
}

export function InvokeScreen({
isInteractive: _isInteractive,
onExit,
Expand All@@ -22,6 +97,10 @@ export function InvokeScreen({
const { phase, config, selectedAgent, messages, error, logFilePath, sessionId, selectAgent, invoke, newSession } =
useInvokeFlow({ initialSessionId });
const [mode, setMode] = useState<Mode>('select-agent');
const [scrollOffset, setScrollOffset] = useState(0);
const [userScrolled, setUserScrolled] = useState(false);
const { stdout } = useStdout();
const justCancelledRef = useRef(false);

// Handle initial prompt - skip agent selection if only one agent
useEffect(() => {
Expand All@@ -46,42 +125,120 @@ export function InvokeScreen({
}, [initialPrompt, phase, messages.length, onExit]);

// Return to input mode after invoke completes
const prevPhaseRef = React.useRef(phase);
const prevPhaseRef = useRef(phase);
useEffect(() => {
if (prevPhaseRef.current === 'invoking' && phase === 'ready' && !initialPrompt) {
queueMicrotask(() => setMode('input'));
}
prevPhaseRef.current = phase;
}, [phase, initialPrompt]);

useInput((input, key) => {
if (phase === 'loading' || phase === 'error' || !config) return;
// Calculate available height for conversation display
const terminalHeight = stdout?.rows ?? 24;
const terminalWidth = stdout?.columns ?? 80;
Comment thread
notgitika marked this conversation as resolved.
const baseHeight = Math.max(5, terminalHeight - 12);
const displayHeight = mode === 'input' ? Math.max(3, baseHeight - 2) : baseHeight;
const contentWidth = Math.max(40, terminalWidth - 4);

// Format conversation content
const conversationText = useMemo(() => formatConversation(messages), [messages]);

// Wrap text for display
const lines = useMemo(() => wrapText(conversationText, contentWidth), [conversationText, contentWidth]);

const totalLines = lines.length;
const maxScroll = Math.max(0, totalLines - displayHeight);
const needsScroll = totalLines > displayHeight;

// Auto-scroll to bottom when user hasn't manually scrolled up
const effectiveOffset = useMemo(() => {
if (totalLines === 0) return 0;
if (!userScrolled && totalLines > displayHeight) return maxScroll;
return Math.min(scrollOffset, maxScroll);
}, [totalLines, userScrolled, scrollOffset, maxScroll, displayHeight]);

if (key.escape || (key.ctrl && input === 'q')) {
if (mode === 'input') {
setMode('chat');
} else if (mode === 'chat' && config.agents.length > 1) {
setMode('select-agent');
} else {
onExit();
const scrollUp = useCallback(
(amount = 1) => {
if (!needsScroll) return;
setUserScrolled(true);
setScrollOffset(prev => {
const current = userScrolled ? prev : maxScroll;
return Math.max(0, current - amount);
});
},
[needsScroll, userScrolled, maxScroll]
);

const scrollDown = useCallback(
(amount = 1) => {
if (!needsScroll) return;
setScrollOffset(prev => {
const next = Math.min(maxScroll, prev + amount);
if (next >= maxScroll) {
setUserScrolled(false);
}
return next;
});
},
[needsScroll, maxScroll]
);

useInput(
(input, key) => {
if (phase === 'loading' || phase === 'error' || !config) return;

// Agent selection mode
if (mode === 'select-agent') {
if (key.escape || (key.ctrl && input === 'q')) {
onExit();
return;
}
if (key.upArrow) selectAgent((selectedAgent - 1 + config.agents.length) % config.agents.length);
if (key.downArrow) selectAgent((selectedAgent + 1) % config.agents.length);
if (key.return) setMode('input');
return;
}
return;
}

if (mode === 'select-agent') {
if (key.upArrow) selectAgent((selectedAgent - 1 + config.agents.length) % config.agents.length);
if (key.downArrow) selectAgent((selectedAgent + 1) % config.agents.length);
if (key.return) setMode('input');
}
// Chat mode
if (mode === 'chat') {
if (key.escape || (key.ctrl && input === 'q') || (key.ctrl && input === 'c')) {
if (justCancelledRef.current) {
justCancelledRef.current = false;
return;
}
if (config.agents.length > 1) {
setMode('select-agent');
return;
}
onExit();
return;
}

if (mode === 'chat' && input === 'i' && phase === 'ready') {
setMode('input');
}
justCancelledRef.current = false;

if (mode === 'chat' && input === 'n' && phase === 'ready') {
newSession();
}
});
// Enter or 'i' to start typing (only when not invoking)
if ((key.return || input === 'i') && phase === 'ready') {
setMode('input');
return;
}

// New session
if (input === 'n' && phase === 'ready') {
newSession();
setScrollOffset(0);
setUserScrolled(false);
return;
}

// Scroll controls
if (key.upArrow) scrollUp(1);
else if (key.downArrow) scrollDown(1);
else if (key.pageUp) scrollUp(displayHeight);
else if (key.pageDown) scrollDown(displayHeight);
}
},
{ isActive: mode === 'chat' || mode === 'select-agent' }
);

// Error state - show error in main screen
if (phase === 'error') {
Expand All@@ -105,11 +262,16 @@ export function InvokeScreen({
}));

// Dynamic help text
const helpText = {
'select-agent': '↑↓ select · Enter confirm · Esc quit',
chat: '↑↓ scroll · Esc back',
input: 'Enter send · Esc cancel',
}[mode];
const helpText =
mode === 'select-agent'
? '↑↓ select · Enter confirm · Esc quit'
: mode === 'input'
? 'Enter send · Esc cancel'
: phase === 'invoking'
? '↑↓ scroll'
: messages.length > 0
? `↑↓ scroll · Enter invoke · N new session · ${config.agents.length > 1 ? 'Esc back' : 'Esc quit'}`
: `Enter to send a message · ${config.agents.length > 1 ? 'Esc back' : 'Esc quit'}`;

const headerContent = (
<Box flexDirection="column">
Expand All@@ -133,6 +295,7 @@ export function InvokeScreen({
<Text color="magenta">{sessionId?.slice(0, 8) ?? 'none'}</Text>
</Box>
)}
{logFilePath && <LogLink filePath={logFilePath} />}
</Box>
);

Expand All@@ -147,57 +310,72 @@ export function InvokeScreen({
);
}

// Get the current message pair (user prompt and assistant response)
const userMessage = messages.find(m => m.role === 'user');
const assistantMessage = messages.find(m => m.role === 'assistant');
// Visible lines for display
const visibleLines = lines.slice(effectiveOffset, effectiveOffset + displayHeight);

// Show messages in both chat and input modes so user can see conversation while typing
const showMessages = mode === 'chat' || mode === 'input';
// Check if the last assistant message is empty (streaming hasn't started yet)
const lastMessage = messages[messages.length - 1];
const showThinking = phase === 'invoking' && lastMessage?.role === 'assistant' && !lastMessage.content;

return (
<Screen title="AgentCore Invoke" onExit={onExit} helpText={helpText} headerContent={headerContent}>
<Box flexDirection="column" flexGrow={1}>
{messages.length === 0 && mode === 'chat' && <Text dimColor>Press &apos;i&apos; to send a message</Text>}

{/* User prompt */}
{showMessages && userMessage && (
<Box marginBottom={1}>
<Text color="blue">&gt; {userMessage.content}</Text>
{/* Conversation display - always visible when there's content */}
{messages.length > 0 && (
<Box flexDirection="column" height={needsScroll ? displayHeight : undefined}>
{visibleLines.map((line, idx) => {
// Detect user messages (start with "> ")
const isUserMessage = line.startsWith('> ');
return (
<Text key={effectiveOffset + idx} color={isUserMessage ? 'blue' : 'green'} wrap="truncate">
{line || ' '}
</Text>
);
})}
{/* Thinking indicator - shows while waiting for response to start */}
{showThinking && <GradientText text="Thinking..." />}
</Box>
)}

{/* Assistant response with scrolling */}
{showMessages && assistantMessage?.content && (
<Box marginBottom={1} flexDirection="column">
<ScrollableText
content={assistantMessage.content}
color="green"
isStreaming={phase === 'invoking'}
isActive={mode === 'chat' || mode === 'input'}
/>
</Box>
{/* Scroll indicator */}
{needsScroll && (
<Text dimColor>
[{effectiveOffset + 1}-{Math.min(effectiveOffset + displayHeight, totalLines)} of {totalLines}]
</Text>
)}

{/* Invoking indicator */}
{phase === 'invoking' && <GradientText text="Invoking..." />}

{/* Log file link after response */}
{logFilePath && messages.length > 0 && phase === 'ready' && (
<Box marginTop={1}>
<LogLink filePath={logFilePath} />
{/* Input area */}
{mode === 'chat' && phase === 'ready' && messages.length > 0 && (
<Box>
<Text dimColor>&gt; </Text>
</Box>
)}

{/* Input prompt */}
{mode === 'chat' && phase === 'ready' && messages.length === 0 && (
<Text dimColor>Press Enter to send a message</Text>
)}
{mode === 'input' && phase === 'ready' && (
<TextInput
prompt=""
onSubmit={text => {
setMode('chat');
void invoke(text);
}}
onCancel={() => setMode('chat')}
/>
<Box>
<Text color="blue">&gt; </Text>
<TextInput
prompt=""
hideArrow
onSubmit={text => {
if (text.trim()) {
setMode('chat');
setUserScrolled(false);
void invoke(text);
} else {
setMode('chat');
}
}}
onCancel={() => {
justCancelledRef.current = true;
setMode('chat');
}}
onUpArrow={() => scrollUp(1)}
onDownArrow={() => scrollDown(1)}
/>
</Box>
)}
</Box>
</Screen>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
318 changes: 248 additions & 70 deletions src/cli/tui/screens/invoke/InvokeScreen.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { GradientText, LogLink, Panel, Screen, ScrollableText, SelectList, TextInput } from '../../components';
import { GradientText, LogLink, Panel, Screen, SelectList, TextInput } from '../../components';
import { useInvokeFlow } from './useInvokeFlow';
import { Box, Text, useInput } from 'ink';
import React, { useEffect, useState } from 'react';
import { Box, Text, useInput, useStdout } from 'ink';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';

interface InvokeScreenProps {
/** Whether running in interactive TUI mode (from App.tsx) vs CLI mode */
Expand All@@ -13,6 +13,81 @@ interface InvokeScreenProps {

type Mode = 'select-agent' | 'chat' | 'input';

/**
* Render conversation messages as a single string for scrolling.
*/
function formatConversation(messages: { role: 'user' | 'assistant'; content: string }[]): string {
const lines: string[] = [];

for (const msg of messages) {
// Skip empty assistant messages (placeholder before streaming starts)
if (msg.role === 'assistant' && !msg.content) continue;

if (msg.role === 'user') {
lines.push(`> ${msg.content}`);
} else {
lines.push(msg.content);
}
lines.push(''); // blank line between messages
}

return lines.join('\n');
}

/**
* Word-wrap a single line to fit within maxWidth.
*/
function wrapLine(line: string, maxWidth: number): string[] {
if (!line) return [''];
if (line.length <= maxWidth) return [line];

const wrapped: string[] = [];
const words = line.split(' ');
let currentLine = '';

for (const word of words) {
if (word.length > maxWidth) {
if (currentLine) {
wrapped.push(currentLine);
currentLine = '';
}
for (let i = 0; i < word.length; i += maxWidth) {
wrapped.push(word.slice(i, i + maxWidth));
}
continue;
}

const testLine = currentLine ? `${currentLine} ${word}` : word;
if (testLine.length <= maxWidth) {
currentLine = testLine;
} else {
if (currentLine) {
wrapped.push(currentLine);
}
currentLine = word;
}
}

if (currentLine) {
wrapped.push(currentLine);
}

return wrapped.length > 0 ? wrapped : [''];
}

/**
* Wrap multi-line text to fit within maxWidth.
*/
function wrapText(text: string, maxWidth: number): string[] {
if (!text) return [];
const lines = text.split('\n');
const wrapped: string[] = [];
for (const line of lines) {
wrapped.push(...wrapLine(line, maxWidth));
}
return wrapped;
}

export function InvokeScreen({
isInteractive: _isInteractive,
onExit,
Expand All@@ -22,6 +97,10 @@ export function InvokeScreen({
const { phase, config, selectedAgent, messages, error, logFilePath, sessionId, selectAgent, invoke, newSession } =
useInvokeFlow({ initialSessionId });
const [mode, setMode] = useState<Mode>('select-agent');
const [scrollOffset, setScrollOffset] = useState(0);
const [userScrolled, setUserScrolled] = useState(false);
const { stdout } = useStdout();
const justCancelledRef = useRef(false);

// Handle initial prompt - skip agent selection if only one agent
useEffect(() => {
Expand All@@ -46,42 +125,120 @@ export function InvokeScreen({
}, [initialPrompt, phase, messages.length, onExit]);

// Return to input mode after invoke completes
const prevPhaseRef = React.useRef(phase);
const prevPhaseRef = useRef(phase);
useEffect(() => {
if (prevPhaseRef.current === 'invoking' && phase === 'ready' && !initialPrompt) {
queueMicrotask(() => setMode('input'));
}
prevPhaseRef.current = phase;
}, [phase, initialPrompt]);

useInput((input, key) => {
if (phase === 'loading' || phase === 'error' || !config) return;
// Calculate available height for conversation display
const terminalHeight = stdout?.rows ?? 24;
const terminalWidth = stdout?.columns ?? 80;
Comment thread
notgitika marked this conversation as resolved.
const baseHeight = Math.max(5, terminalHeight - 12);
const displayHeight = mode === 'input' ? Math.max(3, baseHeight - 2) : baseHeight;
const contentWidth = Math.max(40, terminalWidth - 4);

// Format conversation content
const conversationText = useMemo(() => formatConversation(messages), [messages]);

// Wrap text for display
const lines = useMemo(() => wrapText(conversationText, contentWidth), [conversationText, contentWidth]);

const totalLines = lines.length;
const maxScroll = Math.max(0, totalLines - displayHeight);
const needsScroll = totalLines > displayHeight;

// Auto-scroll to bottom when user hasn't manually scrolled up
const effectiveOffset = useMemo(() => {
if (totalLines === 0) return 0;
if (!userScrolled && totalLines > displayHeight) return maxScroll;
return Math.min(scrollOffset, maxScroll);
}, [totalLines, userScrolled, scrollOffset, maxScroll, displayHeight]);

if (key.escape || (key.ctrl && input === 'q')) {
if (mode === 'input') {
setMode('chat');
} else if (mode === 'chat' && config.agents.length > 1) {
setMode('select-agent');
} else {
onExit();
const scrollUp = useCallback(
(amount = 1) => {
if (!needsScroll) return;
setUserScrolled(true);
setScrollOffset(prev => {
const current = userScrolled ? prev : maxScroll;
return Math.max(0, current - amount);
});
},
[needsScroll, userScrolled, maxScroll]
);

const scrollDown = useCallback(
(amount = 1) => {
if (!needsScroll) return;
setScrollOffset(prev => {
const next = Math.min(maxScroll, prev + amount);
if (next >= maxScroll) {
setUserScrolled(false);
}
return next;
});
},
[needsScroll, maxScroll]
);

useInput(
(input, key) => {
if (phase === 'loading' || phase === 'error' || !config) return;

// Agent selection mode
if (mode === 'select-agent') {
if (key.escape || (key.ctrl && input === 'q')) {
onExit();
return;
}
if (key.upArrow) selectAgent((selectedAgent - 1 + config.agents.length) % config.agents.length);
if (key.downArrow) selectAgent((selectedAgent + 1) % config.agents.length);
if (key.return) setMode('input');
return;
}
return;
}

if (mode === 'select-agent') {
if (key.upArrow) selectAgent((selectedAgent - 1 + config.agents.length) % config.agents.length);
if (key.downArrow) selectAgent((selectedAgent + 1) % config.agents.length);
if (key.return) setMode('input');
}
// Chat mode
if (mode === 'chat') {
if (key.escape || (key.ctrl && input === 'q') || (key.ctrl && input === 'c')) {
if (justCancelledRef.current) {
justCancelledRef.current = false;
return;
}
if (config.agents.length > 1) {
setMode('select-agent');
return;
}
onExit();
return;
}

if (mode === 'chat' && input === 'i' && phase === 'ready') {
setMode('input');
}
justCancelledRef.current = false;

if (mode === 'chat' && input === 'n' && phase === 'ready') {
newSession();
}
});
// Enter or 'i' to start typing (only when not invoking)
if ((key.return || input === 'i') && phase === 'ready') {
setMode('input');
return;
}

// New session
if (input === 'n' && phase === 'ready') {
newSession();
setScrollOffset(0);
setUserScrolled(false);
return;
}

// Scroll controls
if (key.upArrow) scrollUp(1);
else if (key.downArrow) scrollDown(1);
else if (key.pageUp) scrollUp(displayHeight);
else if (key.pageDown) scrollDown(displayHeight);
}
},
{ isActive: mode === 'chat' || mode === 'select-agent' }
);

// Error state - show error in main screen
if (phase === 'error') {
Expand All@@ -105,11 +262,16 @@ export function InvokeScreen({
}));

// Dynamic help text
const helpText = {
'select-agent': '↑↓ select · Enter confirm · Esc quit',
chat: '↑↓ scroll · Esc back',
input: 'Enter send · Esc cancel',
}[mode];
const helpText =
mode === 'select-agent'
? '↑↓ select · Enter confirm · Esc quit'
: mode === 'input'
? 'Enter send · Esc cancel'
: phase === 'invoking'
? '↑↓ scroll'
: messages.length > 0
? `↑↓ scroll · Enter invoke · N new session · ${config.agents.length > 1 ? 'Esc back' : 'Esc quit'}`
: `Enter to send a message · ${config.agents.length > 1 ? 'Esc back' : 'Esc quit'}`;

const headerContent = (
<Box flexDirection="column">
Expand All@@ -133,6 +295,7 @@ export function InvokeScreen({
<Text color="magenta">{sessionId?.slice(0, 8) ?? 'none'}</Text>
</Box>
)}
{logFilePath && <LogLink filePath={logFilePath} />}
</Box>
);

Expand All@@ -147,57 +310,72 @@ export function InvokeScreen({
);
}

// Get the current message pair (user prompt and assistant response)
const userMessage = messages.find(m => m.role === 'user');
const assistantMessage = messages.find(m => m.role === 'assistant');
// Visible lines for display
const visibleLines = lines.slice(effectiveOffset, effectiveOffset + displayHeight);

// Show messages in both chat and input modes so user can see conversation while typing
const showMessages = mode === 'chat' || mode === 'input';
// Check if the last assistant message is empty (streaming hasn't started yet)
const lastMessage = messages[messages.length - 1];
const showThinking = phase === 'invoking' && lastMessage?.role === 'assistant' && !lastMessage.content;

return (
<Screen title="AgentCore Invoke" onExit={onExit} helpText={helpText} headerContent={headerContent}>
<Box flexDirection="column" flexGrow={1}>
{messages.length === 0 && mode === 'chat' && <Text dimColor>Press &apos;i&apos; to send a message</Text>}

{/* User prompt */}
{showMessages && userMessage && (
<Box marginBottom={1}>
<Text color="blue">&gt; {userMessage.content}</Text>
{/* Conversation display - always visible when there's content */}
{messages.length > 0 && (
<Box flexDirection="column" height={needsScroll ? displayHeight : undefined}>
{visibleLines.map((line, idx) => {
// Detect user messages (start with "> ")
const isUserMessage = line.startsWith('> ');
return (
<Text key={effectiveOffset + idx} color={isUserMessage ? 'blue' : 'green'} wrap="truncate">
{line || ' '}
</Text>
);
})}
{/* Thinking indicator - shows while waiting for response to start */}
{showThinking && <GradientText text="Thinking..." />}
</Box>
)}

{/* Assistant response with scrolling */}
{showMessages && assistantMessage?.content && (
<Box marginBottom={1} flexDirection="column">
<ScrollableText
content={assistantMessage.content}
color="green"
isStreaming={phase === 'invoking'}
isActive={mode === 'chat' || mode === 'input'}
/>
</Box>
{/* Scroll indicator */}
{needsScroll && (
<Text dimColor>
[{effectiveOffset + 1}-{Math.min(effectiveOffset + displayHeight, totalLines)} of {totalLines}]
</Text>
)}

{/* Invoking indicator */}
{phase === 'invoking' && <GradientText text="Invoking..." />}

{/* Log file link after response */}
{logFilePath && messages.length > 0 && phase === 'ready' && (
<Box marginTop={1}>
<LogLink filePath={logFilePath} />
{/* Input area */}
{mode === 'chat' && phase === 'ready' && messages.length > 0 && (
<Box>
<Text dimColor>&gt; </Text>
</Box>
)}

{/* Input prompt */}
{mode === 'chat' && phase === 'ready' && messages.length === 0 && (
<Text dimColor>Press Enter to send a message</Text>
)}
{mode === 'input' && phase === 'ready' && (
<TextInput
prompt=""
onSubmit={text => {
setMode('chat');
void invoke(text);
}}
onCancel={() => setMode('chat')}
/>
<Box>
<Text color="blue">&gt; </Text>
<TextInput
prompt=""
hideArrow
onSubmit={text => {
if (text.trim()) {
setMode('chat');
setUserScrolled(false);
void invoke(text);
} else {
setMode('chat');
}
}}
onCancel={() => {
justCancelledRef.current = true;
setMode('chat');
}}
onUpArrow={() => scrollUp(1)}
onDownArrow={() => scrollDown(1)}
/>
</Box>
)}
</Box>
</Screen>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
318 changes: 248 additions & 70 deletions src/cli/tui/screens/invoke/InvokeScreen.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { GradientText, LogLink, Panel, Screen, ScrollableText, SelectList, TextInput } from '../../components';
import { GradientText, LogLink, Panel, Screen, SelectList, TextInput } from '../../components';
import { useInvokeFlow } from './useInvokeFlow';
import { Box, Text, useInput } from 'ink';
import React, { useEffect, useState } from 'react';
import { Box, Text, useInput, useStdout } from 'ink';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';

interface InvokeScreenProps {
/** Whether running in interactive TUI mode (from App.tsx) vs CLI mode */
Expand All@@ -13,6 +13,81 @@ interface InvokeScreenProps {

type Mode = 'select-agent' | 'chat' | 'input';

/**
* Render conversation messages as a single string for scrolling.
*/
function formatConversation(messages: { role: 'user' | 'assistant'; content: string }[]): string {
const lines: string[] = [];

for (const msg of messages) {
// Skip empty assistant messages (placeholder before streaming starts)
if (msg.role === 'assistant' && !msg.content) continue;

if (msg.role === 'user') {
lines.push(`> ${msg.content}`);
} else {
lines.push(msg.content);
}
lines.push(''); // blank line between messages
}

return lines.join('\n');
}

/**
* Word-wrap a single line to fit within maxWidth.
*/
function wrapLine(line: string, maxWidth: number): string[] {
if (!line) return [''];
if (line.length <= maxWidth) return [line];

const wrapped: string[] = [];
const words = line.split(' ');
let currentLine = '';

for (const word of words) {
if (word.length > maxWidth) {
if (currentLine) {
wrapped.push(currentLine);
currentLine = '';
}
for (let i = 0; i < word.length; i += maxWidth) {
wrapped.push(word.slice(i, i + maxWidth));
}
continue;
}

const testLine = currentLine ? `${currentLine} ${word}` : word;
if (testLine.length <= maxWidth) {
currentLine = testLine;
} else {
if (currentLine) {
wrapped.push(currentLine);
}
currentLine = word;
}
}

if (currentLine) {
wrapped.push(currentLine);
}

return wrapped.length > 0 ? wrapped : [''];
}

/**
* Wrap multi-line text to fit within maxWidth.
*/
function wrapText(text: string, maxWidth: number): string[] {
if (!text) return [];
const lines = text.split('\n');
const wrapped: string[] = [];
for (const line of lines) {
wrapped.push(...wrapLine(line, maxWidth));
}
return wrapped;
}

export function InvokeScreen({
isInteractive: _isInteractive,
onExit,
Expand All@@ -22,6 +97,10 @@ export function InvokeScreen({
const { phase, config, selectedAgent, messages, error, logFilePath, sessionId, selectAgent, invoke, newSession } =
useInvokeFlow({ initialSessionId });
const [mode, setMode] = useState<Mode>('select-agent');
const [scrollOffset, setScrollOffset] = useState(0);
const [userScrolled, setUserScrolled] = useState(false);
const { stdout } = useStdout();
const justCancelledRef = useRef(false);

// Handle initial prompt - skip agent selection if only one agent
useEffect(() => {
Expand All@@ -46,42 +125,120 @@ export function InvokeScreen({
}, [initialPrompt, phase, messages.length, onExit]);

// Return to input mode after invoke completes
const prevPhaseRef = React.useRef(phase);
const prevPhaseRef = useRef(phase);
useEffect(() => {
if (prevPhaseRef.current === 'invoking' && phase === 'ready' && !initialPrompt) {
queueMicrotask(() => setMode('input'));
}
prevPhaseRef.current = phase;
}, [phase, initialPrompt]);

useInput((input, key) => {
if (phase === 'loading' || phase === 'error' || !config) return;
// Calculate available height for conversation display
const terminalHeight = stdout?.rows ?? 24;
const terminalWidth = stdout?.columns ?? 80;
Comment thread
notgitika marked this conversation as resolved.
const baseHeight = Math.max(5, terminalHeight - 12);
const displayHeight = mode === 'input' ? Math.max(3, baseHeight - 2) : baseHeight;
const contentWidth = Math.max(40, terminalWidth - 4);

// Format conversation content
const conversationText = useMemo(() => formatConversation(messages), [messages]);

// Wrap text for display
const lines = useMemo(() => wrapText(conversationText, contentWidth), [conversationText, contentWidth]);

const totalLines = lines.length;
const maxScroll = Math.max(0, totalLines - displayHeight);
const needsScroll = totalLines > displayHeight;

// Auto-scroll to bottom when user hasn't manually scrolled up
const effectiveOffset = useMemo(() => {
if (totalLines === 0) return 0;
if (!userScrolled && totalLines > displayHeight) return maxScroll;
return Math.min(scrollOffset, maxScroll);
}, [totalLines, userScrolled, scrollOffset, maxScroll, displayHeight]);

if (key.escape || (key.ctrl && input === 'q')) {
if (mode === 'input') {
setMode('chat');
} else if (mode === 'chat' && config.agents.length > 1) {
setMode('select-agent');
} else {
onExit();
const scrollUp = useCallback(
(amount = 1) => {
if (!needsScroll) return;
setUserScrolled(true);
setScrollOffset(prev => {
const current = userScrolled ? prev : maxScroll;
return Math.max(0, current - amount);
});
},
[needsScroll, userScrolled, maxScroll]
);

const scrollDown = useCallback(
(amount = 1) => {
if (!needsScroll) return;
setScrollOffset(prev => {
const next = Math.min(maxScroll, prev + amount);
if (next >= maxScroll) {
setUserScrolled(false);
}
return next;
});
},
[needsScroll, maxScroll]
);

useInput(
(input, key) => {
if (phase === 'loading' || phase === 'error' || !config) return;

// Agent selection mode
if (mode === 'select-agent') {
if (key.escape || (key.ctrl && input === 'q')) {
onExit();
return;
}
if (key.upArrow) selectAgent((selectedAgent - 1 + config.agents.length) % config.agents.length);
if (key.downArrow) selectAgent((selectedAgent + 1) % config.agents.length);
if (key.return) setMode('input');
return;
}
return;
}

if (mode === 'select-agent') {
if (key.upArrow) selectAgent((selectedAgent - 1 + config.agents.length) % config.agents.length);
if (key.downArrow) selectAgent((selectedAgent + 1) % config.agents.length);
if (key.return) setMode('input');
}
// Chat mode
if (mode === 'chat') {
if (key.escape || (key.ctrl && input === 'q') || (key.ctrl && input === 'c')) {
if (justCancelledRef.current) {
justCancelledRef.current = false;
return;
}
if (config.agents.length > 1) {
setMode('select-agent');
return;
}
onExit();
return;
}

if (mode === 'chat' && input === 'i' && phase === 'ready') {
setMode('input');
}
justCancelledRef.current = false;

if (mode === 'chat' && input === 'n' && phase === 'ready') {
newSession();
}
});
// Enter or 'i' to start typing (only when not invoking)
if ((key.return || input === 'i') && phase === 'ready') {
setMode('input');
return;
}

// New session
if (input === 'n' && phase === 'ready') {
newSession();
setScrollOffset(0);
setUserScrolled(false);
return;
}

// Scroll controls
if (key.upArrow) scrollUp(1);
else if (key.downArrow) scrollDown(1);
else if (key.pageUp) scrollUp(displayHeight);
else if (key.pageDown) scrollDown(displayHeight);
}
},
{ isActive: mode === 'chat' || mode === 'select-agent' }
);

// Error state - show error in main screen
if (phase === 'error') {
Expand All@@ -105,11 +262,16 @@ export function InvokeScreen({
}));

// Dynamic help text
const helpText = {
'select-agent': '↑↓ select · Enter confirm · Esc quit',
chat: '↑↓ scroll · Esc back',
input: 'Enter send · Esc cancel',
}[mode];
const helpText =
mode === 'select-agent'
? '↑↓ select · Enter confirm · Esc quit'
: mode === 'input'
? 'Enter send · Esc cancel'
: phase === 'invoking'
? '↑↓ scroll'
: messages.length > 0
? `↑↓ scroll · Enter invoke · N new session · ${config.agents.length > 1 ? 'Esc back' : 'Esc quit'}`
: `Enter to send a message · ${config.agents.length > 1 ? 'Esc back' : 'Esc quit'}`;

const headerContent = (
<Box flexDirection="column">
Expand All@@ -133,6 +295,7 @@ export function InvokeScreen({
<Text color="magenta">{sessionId?.slice(0, 8) ?? 'none'}</Text>
</Box>
)}
{logFilePath && <LogLink filePath={logFilePath} />}
</Box>
);

Expand All@@ -147,57 +310,72 @@ export function InvokeScreen({
);
}

// Get the current message pair (user prompt and assistant response)
const userMessage = messages.find(m => m.role === 'user');
const assistantMessage = messages.find(m => m.role === 'assistant');
// Visible lines for display
const visibleLines = lines.slice(effectiveOffset, effectiveOffset + displayHeight);

// Show messages in both chat and input modes so user can see conversation while typing
const showMessages = mode === 'chat' || mode === 'input';
// Check if the last assistant message is empty (streaming hasn't started yet)
const lastMessage = messages[messages.length - 1];
const showThinking = phase === 'invoking' && lastMessage?.role === 'assistant' && !lastMessage.content;

return (
<Screen title="AgentCore Invoke" onExit={onExit} helpText={helpText} headerContent={headerContent}>
<Box flexDirection="column" flexGrow={1}>
{messages.length === 0 && mode === 'chat' && <Text dimColor>Press &apos;i&apos; to send a message</Text>}

{/* User prompt */}
{showMessages && userMessage && (
<Box marginBottom={1}>
<Text color="blue">&gt; {userMessage.content}</Text>
{/* Conversation display - always visible when there's content */}
{messages.length > 0 && (
<Box flexDirection="column" height={needsScroll ? displayHeight : undefined}>
{visibleLines.map((line, idx) => {
// Detect user messages (start with "> ")
const isUserMessage = line.startsWith('> ');
return (
<Text key={effectiveOffset + idx} color={isUserMessage ? 'blue' : 'green'} wrap="truncate">
{line || ' '}
</Text>
);
})}
{/* Thinking indicator - shows while waiting for response to start */}
{showThinking && <GradientText text="Thinking..." />}
</Box>
)}

{/* Assistant response with scrolling */}
{showMessages && assistantMessage?.content && (
<Box marginBottom={1} flexDirection="column">
<ScrollableText
content={assistantMessage.content}
color="green"
isStreaming={phase === 'invoking'}
isActive={mode === 'chat' || mode === 'input'}
/>
</Box>
{/* Scroll indicator */}
{needsScroll && (
<Text dimColor>
[{effectiveOffset + 1}-{Math.min(effectiveOffset + displayHeight, totalLines)} of {totalLines}]
</Text>
)}

{/* Invoking indicator */}
{phase === 'invoking' && <GradientText text="Invoking..." />}

{/* Log file link after response */}
{logFilePath && messages.length > 0 && phase === 'ready' && (
<Box marginTop={1}>
<LogLink filePath={logFilePath} />
{/* Input area */}
{mode === 'chat' && phase === 'ready' && messages.length > 0 && (
<Box>
<Text dimColor>&gt; </Text>
</Box>
)}

{/* Input prompt */}
{mode === 'chat' && phase === 'ready' && messages.length === 0 && (
<Text dimColor>Press Enter to send a message</Text>
)}
{mode === 'input' && phase === 'ready' && (
<TextInput
prompt=""
onSubmit={text => {
setMode('chat');
void invoke(text);
}}
onCancel={() => setMode('chat')}
/>
<Box>
<Text color="blue">&gt; </Text>
<TextInput
prompt=""
hideArrow
onSubmit={text => {
if (text.trim()) {
setMode('chat');
setUserScrolled(false);
void invoke(text);
} else {
setMode('chat');
}
}}
onCancel={() => {
justCancelledRef.current = true;
setMode('chat');
}}
onUpArrow={() => scrollUp(1)}
onDownArrow={() => scrollDown(1)}
/>
</Box>
)}
</Box>
</Screen>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
318 changes: 248 additions & 70 deletions src/cli/tui/screens/invoke/InvokeScreen.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { GradientText, LogLink, Panel, Screen, ScrollableText, SelectList, TextInput } from '../../components';
import { GradientText, LogLink, Panel, Screen, SelectList, TextInput } from '../../components';
import { useInvokeFlow } from './useInvokeFlow';
import { Box, Text, useInput } from 'ink';
import React, { useEffect, useState } from 'react';
import { Box, Text, useInput, useStdout } from 'ink';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';

interface InvokeScreenProps {
/** Whether running in interactive TUI mode (from App.tsx) vs CLI mode */
Expand All@@ -13,6 +13,81 @@ interface InvokeScreenProps {

type Mode = 'select-agent' | 'chat' | 'input';

/**
* Render conversation messages as a single string for scrolling.
*/
function formatConversation(messages: { role: 'user' | 'assistant'; content: string }[]): string {
const lines: string[] = [];

for (const msg of messages) {
// Skip empty assistant messages (placeholder before streaming starts)
if (msg.role === 'assistant' && !msg.content) continue;

if (msg.role === 'user') {
lines.push(`> ${msg.content}`);
} else {
lines.push(msg.content);
}
lines.push(''); // blank line between messages
}

return lines.join('\n');
}

/**
* Word-wrap a single line to fit within maxWidth.
*/
function wrapLine(line: string, maxWidth: number): string[] {
if (!line) return [''];
if (line.length <= maxWidth) return [line];

const wrapped: string[] = [];
const words = line.split(' ');
let currentLine = '';

for (const word of words) {
if (word.length > maxWidth) {
if (currentLine) {
wrapped.push(currentLine);
currentLine = '';
}
for (let i = 0; i < word.length; i += maxWidth) {
wrapped.push(word.slice(i, i + maxWidth));
}
continue;
}

const testLine = currentLine ? `${currentLine} ${word}` : word;
if (testLine.length <= maxWidth) {
currentLine = testLine;
} else {
if (currentLine) {
wrapped.push(currentLine);
}
currentLine = word;
}
}

if (currentLine) {
wrapped.push(currentLine);
}

return wrapped.length > 0 ? wrapped : [''];
}

/**
* Wrap multi-line text to fit within maxWidth.
*/
function wrapText(text: string, maxWidth: number): string[] {
if (!text) return [];
const lines = text.split('\n');
const wrapped: string[] = [];
for (const line of lines) {
wrapped.push(...wrapLine(line, maxWidth));
}
return wrapped;
}

export function InvokeScreen({
isInteractive: _isInteractive,
onExit,
Expand All@@ -22,6 +97,10 @@ export function InvokeScreen({
const { phase, config, selectedAgent, messages, error, logFilePath, sessionId, selectAgent, invoke, newSession } =
useInvokeFlow({ initialSessionId });
const [mode, setMode] = useState<Mode>('select-agent');
const [scrollOffset, setScrollOffset] = useState(0);
const [userScrolled, setUserScrolled] = useState(false);
const { stdout } = useStdout();
const justCancelledRef = useRef(false);

// Handle initial prompt - skip agent selection if only one agent
useEffect(() => {
Expand All@@ -46,42 +125,120 @@ export function InvokeScreen({
}, [initialPrompt, phase, messages.length, onExit]);

// Return to input mode after invoke completes
const prevPhaseRef = React.useRef(phase);
const prevPhaseRef = useRef(phase);
useEffect(() => {
if (prevPhaseRef.current === 'invoking' && phase === 'ready' && !initialPrompt) {
queueMicrotask(() => setMode('input'));
}
prevPhaseRef.current = phase;
}, [phase, initialPrompt]);

useInput((input, key) => {
if (phase === 'loading' || phase === 'error' || !config) return;
// Calculate available height for conversation display
const terminalHeight = stdout?.rows ?? 24;
const terminalWidth = stdout?.columns ?? 80;
Comment thread
notgitika marked this conversation as resolved.
const baseHeight = Math.max(5, terminalHeight - 12);
const displayHeight = mode === 'input' ? Math.max(3, baseHeight - 2) : baseHeight;
const contentWidth = Math.max(40, terminalWidth - 4);

// Format conversation content
const conversationText = useMemo(() => formatConversation(messages), [messages]);

// Wrap text for display
const lines = useMemo(() => wrapText(conversationText, contentWidth), [conversationText, contentWidth]);

const totalLines = lines.length;
const maxScroll = Math.max(0, totalLines - displayHeight);
const needsScroll = totalLines > displayHeight;

// Auto-scroll to bottom when user hasn't manually scrolled up
const effectiveOffset = useMemo(() => {
if (totalLines === 0) return 0;
if (!userScrolled && totalLines > displayHeight) return maxScroll;
return Math.min(scrollOffset, maxScroll);
}, [totalLines, userScrolled, scrollOffset, maxScroll, displayHeight]);

if (key.escape || (key.ctrl && input === 'q')) {
if (mode === 'input') {
setMode('chat');
} else if (mode === 'chat' && config.agents.length > 1) {
setMode('select-agent');
} else {
onExit();
const scrollUp = useCallback(
(amount = 1) => {
if (!needsScroll) return;
setUserScrolled(true);
setScrollOffset(prev => {
const current = userScrolled ? prev : maxScroll;
return Math.max(0, current - amount);
});
},
[needsScroll, userScrolled, maxScroll]
);

const scrollDown = useCallback(
(amount = 1) => {
if (!needsScroll) return;
setScrollOffset(prev => {
const next = Math.min(maxScroll, prev + amount);
if (next >= maxScroll) {
setUserScrolled(false);
}
return next;
});
},
[needsScroll, maxScroll]
);

useInput(
(input, key) => {
if (phase === 'loading' || phase === 'error' || !config) return;

// Agent selection mode
if (mode === 'select-agent') {
if (key.escape || (key.ctrl && input === 'q')) {
onExit();
return;
}
if (key.upArrow) selectAgent((selectedAgent - 1 + config.agents.length) % config.agents.length);
if (key.downArrow) selectAgent((selectedAgent + 1) % config.agents.length);
if (key.return) setMode('input');
return;
}
return;
}

if (mode === 'select-agent') {
if (key.upArrow) selectAgent((selectedAgent - 1 + config.agents.length) % config.agents.length);
if (key.downArrow) selectAgent((selectedAgent + 1) % config.agents.length);
if (key.return) setMode('input');
}
// Chat mode
if (mode === 'chat') {
if (key.escape || (key.ctrl && input === 'q') || (key.ctrl && input === 'c')) {
if (justCancelledRef.current) {
justCancelledRef.current = false;
return;
}
if (config.agents.length > 1) {
setMode('select-agent');
return;
}
onExit();
return;
}

if (mode === 'chat' && input === 'i' && phase === 'ready') {
setMode('input');
}
justCancelledRef.current = false;

if (mode === 'chat' && input === 'n' && phase === 'ready') {
newSession();
}
});
// Enter or 'i' to start typing (only when not invoking)
if ((key.return || input === 'i') && phase === 'ready') {
setMode('input');
return;
}

// New session
if (input === 'n' && phase === 'ready') {
newSession();
setScrollOffset(0);
setUserScrolled(false);
return;
}

// Scroll controls
if (key.upArrow) scrollUp(1);
else if (key.downArrow) scrollDown(1);
else if (key.pageUp) scrollUp(displayHeight);
else if (key.pageDown) scrollDown(displayHeight);
}
},
{ isActive: mode === 'chat' || mode === 'select-agent' }
);

// Error state - show error in main screen
if (phase === 'error') {
Expand All@@ -105,11 +262,16 @@ export function InvokeScreen({
}));

// Dynamic help text
const helpText = {
'select-agent': '↑↓ select · Enter confirm · Esc quit',
chat: '↑↓ scroll · Esc back',
input: 'Enter send · Esc cancel',
}[mode];
const helpText =
mode === 'select-agent'
? '↑↓ select · Enter confirm · Esc quit'
: mode === 'input'
? 'Enter send · Esc cancel'
: phase === 'invoking'
? '↑↓ scroll'
: messages.length > 0
? `↑↓ scroll · Enter invoke · N new session · ${config.agents.length > 1 ? 'Esc back' : 'Esc quit'}`
: `Enter to send a message · ${config.agents.length > 1 ? 'Esc back' : 'Esc quit'}`;

const headerContent = (
<Box flexDirection="column">
Expand All@@ -133,6 +295,7 @@ export function InvokeScreen({
<Text color="magenta">{sessionId?.slice(0, 8) ?? 'none'}</Text>
</Box>
)}
{logFilePath && <LogLink filePath={logFilePath} />}
</Box>
);

Expand All@@ -147,57 +310,72 @@ export function InvokeScreen({
);
}

// Get the current message pair (user prompt and assistant response)
const userMessage = messages.find(m => m.role === 'user');
const assistantMessage = messages.find(m => m.role === 'assistant');
// Visible lines for display
const visibleLines = lines.slice(effectiveOffset, effectiveOffset + displayHeight);

// Show messages in both chat and input modes so user can see conversation while typing
const showMessages = mode === 'chat' || mode === 'input';
// Check if the last assistant message is empty (streaming hasn't started yet)
const lastMessage = messages[messages.length - 1];
const showThinking = phase === 'invoking' && lastMessage?.role === 'assistant' && !lastMessage.content;

return (
<Screen title="AgentCore Invoke" onExit={onExit} helpText={helpText} headerContent={headerContent}>
<Box flexDirection="column" flexGrow={1}>
{messages.length === 0 && mode === 'chat' && <Text dimColor>Press &apos;i&apos; to send a message</Text>}

{/* User prompt */}
{showMessages && userMessage && (
<Box marginBottom={1}>
<Text color="blue">&gt; {userMessage.content}</Text>
{/* Conversation display - always visible when there's content */}
{messages.length > 0 && (
<Box flexDirection="column" height={needsScroll ? displayHeight : undefined}>
{visibleLines.map((line, idx) => {
// Detect user messages (start with "> ")
const isUserMessage = line.startsWith('> ');
return (
<Text key={effectiveOffset + idx} color={isUserMessage ? 'blue' : 'green'} wrap="truncate">
{line || ' '}
</Text>
);
})}
{/* Thinking indicator - shows while waiting for response to start */}
{showThinking && <GradientText text="Thinking..." />}
</Box>
)}

{/* Assistant response with scrolling */}
{showMessages && assistantMessage?.content && (
<Box marginBottom={1} flexDirection="column">
<ScrollableText
content={assistantMessage.content}
color="green"
isStreaming={phase === 'invoking'}
isActive={mode === 'chat' || mode === 'input'}
/>
</Box>
{/* Scroll indicator */}
{needsScroll && (
<Text dimColor>
[{effectiveOffset + 1}-{Math.min(effectiveOffset + displayHeight, totalLines)} of {totalLines}]
</Text>
)}

{/* Invoking indicator */}
{phase === 'invoking' && <GradientText text="Invoking..." />}

{/* Log file link after response */}
{logFilePath && messages.length > 0 && phase === 'ready' && (
<Box marginTop={1}>
<LogLink filePath={logFilePath} />
{/* Input area */}
{mode === 'chat' && phase === 'ready' && messages.length > 0 && (
<Box>
<Text dimColor>&gt; </Text>
</Box>
)}

{/* Input prompt */}
{mode === 'chat' && phase === 'ready' && messages.length === 0 && (
<Text dimColor>Press Enter to send a message</Text>
)}
{mode === 'input' && phase === 'ready' && (
<TextInput
prompt=""
onSubmit={text => {
setMode('chat');
void invoke(text);
}}
onCancel={() => setMode('chat')}
/>
<Box>
<Text color="blue">&gt; </Text>
<TextInput
prompt=""
hideArrow
onSubmit={text => {
if (text.trim()) {
setMode('chat');
setUserScrolled(false);
void invoke(text);
} else {
setMode('chat');
}
}}
onCancel={() => {
justCancelledRef.current = true;
setMode('chat');
}}
onUpArrow={() => scrollUp(1)}
onDownArrow={() => scrollDown(1)}
/>
</Box>
)}
</Box>
</Screen>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
318 changes: 248 additions & 70 deletions src/cli/tui/screens/invoke/InvokeScreen.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { GradientText, LogLink, Panel, Screen, ScrollableText, SelectList, TextInput } from '../../components';
import { GradientText, LogLink, Panel, Screen, SelectList, TextInput } from '../../components';
import { useInvokeFlow } from './useInvokeFlow';
import { Box, Text, useInput } from 'ink';
import React, { useEffect, useState } from 'react';
import { Box, Text, useInput, useStdout } from 'ink';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';

interface InvokeScreenProps {
/** Whether running in interactive TUI mode (from App.tsx) vs CLI mode */
Expand All@@ -13,6 +13,81 @@ interface InvokeScreenProps {

type Mode = 'select-agent' | 'chat' | 'input';

/**
* Render conversation messages as a single string for scrolling.
*/
function formatConversation(messages: { role: 'user' | 'assistant'; content: string }[]): string {
const lines: string[] = [];

for (const msg of messages) {
// Skip empty assistant messages (placeholder before streaming starts)
if (msg.role === 'assistant' && !msg.content) continue;

if (msg.role === 'user') {
lines.push(`> ${msg.content}`);
} else {
lines.push(msg.content);
}
lines.push(''); // blank line between messages
}

return lines.join('\n');
}

/**
* Word-wrap a single line to fit within maxWidth.
*/
function wrapLine(line: string, maxWidth: number): string[] {
if (!line) return [''];
if (line.length <= maxWidth) return [line];

const wrapped: string[] = [];
const words = line.split(' ');
let currentLine = '';

for (const word of words) {
if (word.length > maxWidth) {
if (currentLine) {
wrapped.push(currentLine);
currentLine = '';
}
for (let i = 0; i < word.length; i += maxWidth) {
wrapped.push(word.slice(i, i + maxWidth));
}
continue;
}

const testLine = currentLine ? `${currentLine} ${word}` : word;
if (testLine.length <= maxWidth) {
currentLine = testLine;
} else {
if (currentLine) {
wrapped.push(currentLine);
}
currentLine = word;
}
}

if (currentLine) {
wrapped.push(currentLine);
}

return wrapped.length > 0 ? wrapped : [''];
}

/**
* Wrap multi-line text to fit within maxWidth.
*/
function wrapText(text: string, maxWidth: number): string[] {
if (!text) return [];
const lines = text.split('\n');
const wrapped: string[] = [];
for (const line of lines) {
wrapped.push(...wrapLine(line, maxWidth));
}
return wrapped;
}

export function InvokeScreen({
isInteractive: _isInteractive,
onExit,
Expand All@@ -22,6 +97,10 @@ export function InvokeScreen({
const { phase, config, selectedAgent, messages, error, logFilePath, sessionId, selectAgent, invoke, newSession } =
useInvokeFlow({ initialSessionId });
const [mode, setMode] = useState<Mode>('select-agent');
const [scrollOffset, setScrollOffset] = useState(0);
const [userScrolled, setUserScrolled] = useState(false);
const { stdout } = useStdout();
const justCancelledRef = useRef(false);

// Handle initial prompt - skip agent selection if only one agent
useEffect(() => {
Expand All@@ -46,42 +125,120 @@ export function InvokeScreen({
}, [initialPrompt, phase, messages.length, onExit]);

// Return to input mode after invoke completes
const prevPhaseRef = React.useRef(phase);
const prevPhaseRef = useRef(phase);
useEffect(() => {
if (prevPhaseRef.current === 'invoking' && phase === 'ready' && !initialPrompt) {
queueMicrotask(() => setMode('input'));
}
prevPhaseRef.current = phase;
}, [phase, initialPrompt]);

useInput((input, key) => {
if (phase === 'loading' || phase === 'error' || !config) return;
// Calculate available height for conversation display
const terminalHeight = stdout?.rows ?? 24;
const terminalWidth = stdout?.columns ?? 80;
Comment thread
notgitika marked this conversation as resolved.
const baseHeight = Math.max(5, terminalHeight - 12);
const displayHeight = mode === 'input' ? Math.max(3, baseHeight - 2) : baseHeight;
const contentWidth = Math.max(40, terminalWidth - 4);

// Format conversation content
const conversationText = useMemo(() => formatConversation(messages), [messages]);

// Wrap text for display
const lines = useMemo(() => wrapText(conversationText, contentWidth), [conversationText, contentWidth]);

const totalLines = lines.length;
const maxScroll = Math.max(0, totalLines - displayHeight);
const needsScroll = totalLines > displayHeight;

// Auto-scroll to bottom when user hasn't manually scrolled up
const effectiveOffset = useMemo(() => {
if (totalLines === 0) return 0;
if (!userScrolled && totalLines > displayHeight) return maxScroll;
return Math.min(scrollOffset, maxScroll);
}, [totalLines, userScrolled, scrollOffset, maxScroll, displayHeight]);

if (key.escape || (key.ctrl && input === 'q')) {
if (mode === 'input') {
setMode('chat');
} else if (mode === 'chat' && config.agents.length > 1) {
setMode('select-agent');
} else {
onExit();
const scrollUp = useCallback(
(amount = 1) => {
if (!needsScroll) return;
setUserScrolled(true);
setScrollOffset(prev => {
const current = userScrolled ? prev : maxScroll;
return Math.max(0, current - amount);
});
},
[needsScroll, userScrolled, maxScroll]
);

const scrollDown = useCallback(
(amount = 1) => {
if (!needsScroll) return;
setScrollOffset(prev => {
const next = Math.min(maxScroll, prev + amount);
if (next >= maxScroll) {
setUserScrolled(false);
}
return next;
});
},
[needsScroll, maxScroll]
);

useInput(
(input, key) => {
if (phase === 'loading' || phase === 'error' || !config) return;

// Agent selection mode
if (mode === 'select-agent') {
if (key.escape || (key.ctrl && input === 'q')) {
onExit();
return;
}
if (key.upArrow) selectAgent((selectedAgent - 1 + config.agents.length) % config.agents.length);
if (key.downArrow) selectAgent((selectedAgent + 1) % config.agents.length);
if (key.return) setMode('input');
return;
}
return;
}

if (mode === 'select-agent') {
if (key.upArrow) selectAgent((selectedAgent - 1 + config.agents.length) % config.agents.length);
if (key.downArrow) selectAgent((selectedAgent + 1) % config.agents.length);
if (key.return) setMode('input');
}
// Chat mode
if (mode === 'chat') {
if (key.escape || (key.ctrl && input === 'q') || (key.ctrl && input === 'c')) {
if (justCancelledRef.current) {
justCancelledRef.current = false;
return;
}
if (config.agents.length > 1) {
setMode('select-agent');
return;
}
onExit();
return;
}

if (mode === 'chat' && input === 'i' && phase === 'ready') {
setMode('input');
}
justCancelledRef.current = false;

if (mode === 'chat' && input === 'n' && phase === 'ready') {
newSession();
}
});
// Enter or 'i' to start typing (only when not invoking)
if ((key.return || input === 'i') && phase === 'ready') {
setMode('input');
return;
}

// New session
if (input === 'n' && phase === 'ready') {
newSession();
setScrollOffset(0);
setUserScrolled(false);
return;
}

// Scroll controls
if (key.upArrow) scrollUp(1);
else if (key.downArrow) scrollDown(1);
else if (key.pageUp) scrollUp(displayHeight);
else if (key.pageDown) scrollDown(displayHeight);
}
},
{ isActive: mode === 'chat' || mode === 'select-agent' }
);

// Error state - show error in main screen
if (phase === 'error') {
Expand All@@ -105,11 +262,16 @@ export function InvokeScreen({
}));

// Dynamic help text
const helpText = {
'select-agent': '↑↓ select · Enter confirm · Esc quit',
chat: '↑↓ scroll · Esc back',
input: 'Enter send · Esc cancel',
}[mode];
const helpText =
mode === 'select-agent'
? '↑↓ select · Enter confirm · Esc quit'
: mode === 'input'
? 'Enter send · Esc cancel'
: phase === 'invoking'
? '↑↓ scroll'
: messages.length > 0
? `↑↓ scroll · Enter invoke · N new session · ${config.agents.length > 1 ? 'Esc back' : 'Esc quit'}`
: `Enter to send a message · ${config.agents.length > 1 ? 'Esc back' : 'Esc quit'}`;

const headerContent = (
<Box flexDirection="column">
Expand All@@ -133,6 +295,7 @@ export function InvokeScreen({
<Text color="magenta">{sessionId?.slice(0, 8) ?? 'none'}</Text>
</Box>
)}
{logFilePath && <LogLink filePath={logFilePath} />}
</Box>
);

Expand All@@ -147,57 +310,72 @@ export function InvokeScreen({
);
}

// Get the current message pair (user prompt and assistant response)
const userMessage = messages.find(m => m.role === 'user');
const assistantMessage = messages.find(m => m.role === 'assistant');
// Visible lines for display
const visibleLines = lines.slice(effectiveOffset, effectiveOffset + displayHeight);

// Show messages in both chat and input modes so user can see conversation while typing
const showMessages = mode === 'chat' || mode === 'input';
// Check if the last assistant message is empty (streaming hasn't started yet)
const lastMessage = messages[messages.length - 1];
const showThinking = phase === 'invoking' && lastMessage?.role === 'assistant' && !lastMessage.content;

return (
<Screen title="AgentCore Invoke" onExit={onExit} helpText={helpText} headerContent={headerContent}>
<Box flexDirection="column" flexGrow={1}>
{messages.length === 0 && mode === 'chat' && <Text dimColor>Press &apos;i&apos; to send a message</Text>}

{/* User prompt */}
{showMessages && userMessage && (
<Box marginBottom={1}>
<Text color="blue">&gt; {userMessage.content}</Text>
{/* Conversation display - always visible when there's content */}
{messages.length > 0 && (
<Box flexDirection="column" height={needsScroll ? displayHeight : undefined}>
{visibleLines.map((line, idx) => {
// Detect user messages (start with "> ")
const isUserMessage = line.startsWith('> ');
return (
<Text key={effectiveOffset + idx} color={isUserMessage ? 'blue' : 'green'} wrap="truncate">
{line || ' '}
</Text>
);
})}
{/* Thinking indicator - shows while waiting for response to start */}
{showThinking && <GradientText text="Thinking..." />}
</Box>
)}

{/* Assistant response with scrolling */}
{showMessages && assistantMessage?.content && (
<Box marginBottom={1} flexDirection="column">
<ScrollableText
content={assistantMessage.content}
color="green"
isStreaming={phase === 'invoking'}
isActive={mode === 'chat' || mode === 'input'}
/>
</Box>
{/* Scroll indicator */}
{needsScroll && (
<Text dimColor>
[{effectiveOffset + 1}-{Math.min(effectiveOffset + displayHeight, totalLines)} of {totalLines}]
</Text>
)}

{/* Invoking indicator */}
{phase === 'invoking' && <GradientText text="Invoking..." />}

{/* Log file link after response */}
{logFilePath && messages.length > 0 && phase === 'ready' && (
<Box marginTop={1}>
<LogLink filePath={logFilePath} />
{/* Input area */}
{mode === 'chat' && phase === 'ready' && messages.length > 0 && (
<Box>
<Text dimColor>&gt; </Text>
</Box>
)}

{/* Input prompt */}
{mode === 'chat' && phase === 'ready' && messages.length === 0 && (
<Text dimColor>Press Enter to send a message</Text>
)}
{mode === 'input' && phase === 'ready' && (
<TextInput
prompt=""
onSubmit={text => {
setMode('chat');
void invoke(text);
}}
onCancel={() => setMode('chat')}
/>
<Box>
<Text color="blue">&gt; </Text>
<TextInput
prompt=""
hideArrow
onSubmit={text => {
if (text.trim()) {
setMode('chat');
setUserScrolled(false);
void invoke(text);
} else {
setMode('chat');
}
}}
onCancel={() => {
justCancelledRef.current = true;
setMode('chat');
}}
onUpArrow={() => scrollUp(1)}
onDownArrow={() => scrollDown(1)}
/>
</Box>
)}
</Box>
</Screen>
Expand Down
Loading
Loading