feat(tui,gemini): implement verbose transfer and url image fetch

This commit is contained in:
William Valentin
2026-02-17 10:58:14 -08:00
parent 77ae15b3e2
commit e3b6f9df7c
8 changed files with 254 additions and 30 deletions
+18 -3
View File
@@ -51,6 +51,7 @@ export interface AppProps {
hookEngine?: HookEngine;
modelProviderConfigs?: Partial<Record<ModelProvider, ModelConfig>>;
contextThresholdPct?: number;
onTransfer?: (target: string) => string | void;
onExit?: () => void;
}
@@ -64,6 +65,7 @@ export function App({
hookEngine,
modelProviderConfigs,
contextThresholdPct,
onTransfer,
onExit,
}: AppProps): React.ReactElement {
const { exit } = useApp();
@@ -92,6 +94,9 @@ export function App({
if (!agent) {return;}
const handleToolEvent = (event: ToolUseEvent) => {
if (!verbose) {
return;
}
if (event.type === 'start') {
const label = formatToolName(event.tool);
const argsStr = event.args ? ` (${formatToolArgs(event.args)})` : '';
@@ -114,7 +119,7 @@ export function App({
return () => {
agent.setOnToolUse(undefined);
};
}, [agent]);
}, [agent, verbose]);
// Inline confirmations for dangerous tools (e.g. shell.exec) in fullscreen mode.
useEffect(() => {
@@ -367,9 +372,18 @@ export function App({
case 'fullscreen':
return;
case 'transfer':
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Transfer not supported in fullscreen mode.' })]);
case 'transfer': {
if (!onTransfer) {
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Transfer target is not available in fullscreen mode.' })]);
return;
}
const result = onTransfer(command.target);
const content = typeof result === 'string' && result.trim()
? result
: `Transfer requested: ${command.target}`;
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content })]);
return;
}
case 'queue': {
if (!command.action || command.action === 'show') {
@@ -572,6 +586,7 @@ export function App({
tokenUsage.inputTokens,
tokenUsage.outputTokens,
modelProviderConfigs,
onTransfer,
]);
return (
+2
View File
@@ -18,6 +18,7 @@ export interface FullscreenTuiConfig {
hookEngine?: HookEngine;
modelProviderConfigs?: Partial<Record<ModelProvider, ModelConfig>>;
contextThresholdPct?: number;
onTransfer?: (target: string) => string | void;
onExit?: () => void;
}
@@ -42,6 +43,7 @@ export async function startFullscreenTui(config: FullscreenTuiConfig): Promise<v
hookEngine: config.hookEngine,
modelProviderConfigs: config.modelProviderConfigs,
contextThresholdPct: config.contextThresholdPct,
onTransfer: config.onTransfer,
onExit: config.onExit,
}),
);
+63
View File
@@ -22,6 +22,10 @@ function asRouter(value: unknown): ModelClient & ModelRouter {
return value as ModelClient & ModelRouter;
}
function asModelClient(value: unknown): ModelClient {
return value as ModelClient;
}
function asAgent(value: unknown): NativeAgent {
return value as NativeAgent;
}
@@ -30,6 +34,9 @@ function minimalTuiPrivates(value: MinimalTui): {
handleBackendCommand: (provider: string) => Promise<void>;
handleModelCommand: (tier: string, providerModel?: string) => void;
handleContextCommand: () => void;
handleVerboseCommand: () => void;
handleToolEvent: (event: unknown) => void;
handleCommand: (command: unknown) => Promise<void>;
handleEscapeAction: () => boolean;
prompt: (text: string) => Promise<string>;
rl: {
@@ -45,6 +52,9 @@ function minimalTuiPrivates(value: MinimalTui): {
handleBackendCommand: (provider: string) => Promise<void>;
handleModelCommand: (tier: string, providerModel?: string) => void;
handleContextCommand: () => void;
handleVerboseCommand: () => void;
handleToolEvent: (event: unknown) => void;
handleCommand: (command: unknown) => Promise<void>;
handleEscapeAction: () => boolean;
prompt: (text: string) => Promise<string>;
rl: {
@@ -328,6 +338,59 @@ describe('MinimalTui backend command', () => {
}
}
});
it('prints transfer result text when /transfer is invoked', async () => {
const mockSession = {
id: 'test',
getHistory: () => [],
addMessage: vi.fn(),
clear: vi.fn(),
replaceHistory: vi.fn(),
};
const onTransfer = vi.fn(() => 'Session transferred to Telegram (12345)');
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
try {
const tui = new MinimalTui({
session: asSession(mockSession),
modelClient: asModelClient({}),
systemPrompt: 'test',
onTransfer,
});
await minimalTuiPrivates(tui).handleCommand({ type: 'transfer', target: 'telegram' });
expect(onTransfer).toHaveBeenCalledWith('telegram');
expect(logSpy).toHaveBeenCalledWith('Session transferred to Telegram (12345)\n');
} finally {
logSpy.mockRestore();
}
});
it('only renders tool activity when verbose mode is enabled', () => {
const mockSession = {
id: 'test',
getHistory: () => [],
addMessage: vi.fn(),
clear: vi.fn(),
replaceHistory: vi.fn(),
};
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
try {
const tui = new MinimalTui({
session: asSession(mockSession),
modelClient: asModelClient({}),
systemPrompt: 'test',
});
minimalTuiPrivates(tui).handleToolEvent({ type: 'start', tool: 'shell.exec', args: { command: 'ls' } });
expect(logSpy).not.toHaveBeenCalled();
minimalTuiPrivates(tui).handleVerboseCommand();
minimalTuiPrivates(tui).handleToolEvent({ type: 'start', tool: 'shell.exec', args: { command: 'ls' } });
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Shell: Exec'));
} finally {
logSpy.mockRestore();
}
});
});
describe('MinimalTui prompt cancellation', () => {
+62 -3
View File
@@ -2,7 +2,7 @@ import * as readline from 'node:readline';
import type { ManagedSession } from '../../session/index.js';
import type { ModelClient, TokenUsage } from '../../models/types.js';
import type { ModelRouter } from '../../models/router.js';
import type { NativeAgent } from '../../backends/native/agent.js';
import type { NativeAgent, ToolUseEvent } from '../../backends/native/agent.js';
import { parseCommand, getHelpText, resolveModelAlias, getCommandCompletions, getCommandTooltip, type Command } from './commands.js';
import { renderMarkdown } from './markdown.js';
import type { ModelConfig, ModelProvider } from '../../config/schema.js';
@@ -62,7 +62,7 @@ export interface MinimalTuiConfig {
systemPrompt: string;
agent?: NativeAgent;
onFullscreen?: () => void;
onTransfer?: (target: string) => void;
onTransfer?: (target: string) => string | void;
localProviders?: Record<string, ModelConfig>;
modelProviderConfigs?: Partial<Record<ModelProvider, ModelConfig>>;
currentLocalProvider?: string;
@@ -152,12 +152,62 @@ export class MinimalTui {
}
}
private formatToolName(name: string): string {
const parts = name.split('.');
return parts.map((p, i) => {
const capitalized = p.charAt(0).toUpperCase() + p.slice(1);
return i === 0 && parts.length > 1 ? capitalized + ':' : capitalized;
}).join(' ');
}
private formatToolArgs(args: unknown): string {
if (!args || typeof args !== 'object') {
return '';
}
const entries = Object.entries(args as Record<string, unknown>);
if (entries.length === 0) {
return '';
}
return entries.map(([key, value]) => {
if (typeof value === 'string') {
const display = value.length > 50 ? `${value.slice(0, 47)}...` : value;
return `${key}: "${display}"`;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return `${key}: ${value}`;
}
return `${key}: ${JSON.stringify(value)}`;
}).join(', ');
}
private handleToolEvent(event: ToolUseEvent): void {
if (!this.verbose) {
return;
}
if (event.type === 'start') {
const label = this.formatToolName(event.tool);
const argsStr = event.args ? ` (${this.formatToolArgs(event.args)})` : '';
console.log(`${colors.gray}> ${label}${argsStr}${colors.reset}`);
return;
}
if (event.type === 'end' && event.result) {
if (event.result.success) {
console.log(`${colors.gray} done (${event.result.output.split('\n').length} lines)${colors.reset}`);
} else {
console.log(`${colors.gray} error ${event.result.error ?? 'unknown error'}${colors.reset}`);
}
}
}
async start(): Promise<void> {
this.running = true;
if (this.config.agent && this.config.modelRouter) {
this.config.agent.setModelTier(this.config.modelRouter.getTier());
}
if (this.config.agent) {
this.config.agent.setOnToolUse(this.handleToolEvent.bind(this));
}
this.rl = readline.createInterface({
input: process.stdin,
@@ -366,8 +416,17 @@ export class MinimalTui {
break;
case 'transfer':
this.config.onTransfer?.(command.target);
{
if (!this.config.onTransfer) {
console.log(`${colors.gray}Transfer target is not available in this TUI mode.${colors.reset}\n`);
break;
}
const result = this.config.onTransfer(command.target);
if (typeof result === 'string' && result.trim()) {
console.log(`${result}\n`);
}
break;
}
case 'message':
await this.handleMessage(command.content);