feat(tui,gemini): implement verbose transfer and url image fetch
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user