feat: add OpenAI OAuth, strict model overrides, and Gmail pull mode
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Box, useApp, useInput } from 'ink';
|
||||
import { Box, Text, useApp, useInput } from 'ink';
|
||||
import { StatusBar } from './StatusBar.js';
|
||||
import { MessageList } from './MessageList.js';
|
||||
import { InputBar } from './InputBar.js';
|
||||
@@ -7,8 +7,11 @@ import { parseCommand, getHelpText, resolveModelAlias, getCommandCompletions } f
|
||||
import type { Message, ModelClient, TokenUsage } from '../../../models/types.js';
|
||||
import type { ModelRouter } from '../../../models/router.js';
|
||||
import type { ManagedSession } from '../../../session/index.js';
|
||||
import type { NativeAgent } from '../../../backends/native/agent.js';
|
||||
import type { ToolUseEvent } from '../../../backends/native/agent.js';
|
||||
import type { NativeAgent, ToolUseEvent } from '../../../backends/native/agent.js';
|
||||
import type { HookEngine, HookResult } from '../../../hooks/index.js';
|
||||
import type { ModelConfig, ModelProvider } from '../../../config/schema.js';
|
||||
import { MODEL_PROVIDERS } from '../../../config/schema.js';
|
||||
import { createClientFromConfig } from '../../../daemon/index.js';
|
||||
|
||||
/** Format a tool name like "gmail.list" -> "Gmail: List" */
|
||||
function formatToolName(name: string): string {
|
||||
@@ -44,6 +47,8 @@ export interface AppProps {
|
||||
systemPrompt: string;
|
||||
model: string;
|
||||
agent?: NativeAgent;
|
||||
hookEngine?: HookEngine;
|
||||
modelProviderConfigs?: Partial<Record<ModelProvider, ModelConfig>>;
|
||||
onExit?: () => void;
|
||||
}
|
||||
|
||||
@@ -54,6 +59,8 @@ export function App({
|
||||
systemPrompt,
|
||||
model,
|
||||
agent,
|
||||
hookEngine,
|
||||
modelProviderConfigs,
|
||||
onExit,
|
||||
}: AppProps): React.ReactElement {
|
||||
const { exit } = useApp();
|
||||
@@ -63,13 +70,20 @@ export function App({
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
const [tokenUsage, setTokenUsage] = useState<TokenUsage>({ inputTokens: 0, outputTokens: 0 });
|
||||
const [currentModel, setCurrentModel] = useState(model);
|
||||
const [currentModel, setCurrentModel] = useState(() => {
|
||||
if (!modelRouter) {return model;}
|
||||
return modelRouter.getLabel(modelRouter.getTier());
|
||||
});
|
||||
|
||||
const abortRef = useRef(false);
|
||||
const toolLinesRef = useRef<string[]>([]);
|
||||
|
||||
const confirmResolveRef = useRef<((result: HookResult) => void) | null>(null);
|
||||
const [confirmation, setConfirmation] = useState<{ tool: string; args: Record<string, unknown> } | null>(null);
|
||||
|
||||
// Set up an Ink-compatible onToolUse callback for the agent.
|
||||
// This replaces the process.stdout.write callback (which corrupts Ink rendering)
|
||||
// with one that updates React state to show tool activity in the streaming area.
|
||||
// This replaces process.stdout writes (which corrupt Ink rendering)
|
||||
// with one that updates React state to show tool activity.
|
||||
useEffect(() => {
|
||||
if (!agent) {return;}
|
||||
|
||||
@@ -79,7 +93,10 @@ export function App({
|
||||
const argsStr = event.args ? ` (${formatToolArgs(event.args)})` : '';
|
||||
toolLinesRef.current = [...toolLinesRef.current, `> ${label}${argsStr}`];
|
||||
setStreamingContent(toolLinesRef.current.join('\n'));
|
||||
} else if (event.type === 'end' && event.result) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'end' && event.result) {
|
||||
const icon = event.result.success ? 'done' : 'error';
|
||||
const detail = event.result.success
|
||||
? `(${event.result.output.split('\n').length} lines)`
|
||||
@@ -95,7 +112,43 @@ export function App({
|
||||
};
|
||||
}, [agent]);
|
||||
|
||||
// Inline confirmations for dangerous tools (e.g. shell.exec) in fullscreen mode.
|
||||
useEffect(() => {
|
||||
if (!hookEngine) {return;}
|
||||
|
||||
hookEngine.setInteractiveConfirmer(async (pending) => {
|
||||
return await new Promise<HookResult>((resolve) => {
|
||||
confirmResolveRef.current = resolve;
|
||||
setConfirmation({ tool: pending.tool, args: pending.args });
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
hookEngine.setInteractiveConfirmer(undefined);
|
||||
confirmResolveRef.current = null;
|
||||
setConfirmation(null);
|
||||
};
|
||||
}, [hookEngine]);
|
||||
|
||||
useInput((inputChar, key) => {
|
||||
// Confirmation prompt mode: capture y/n and ignore everything else.
|
||||
if (confirmation && confirmResolveRef.current) {
|
||||
const c = inputChar.toLowerCase();
|
||||
if (c === 'y') {
|
||||
confirmResolveRef.current({ approved: true });
|
||||
confirmResolveRef.current = null;
|
||||
setConfirmation(null);
|
||||
return;
|
||||
}
|
||||
if (c === 'n') {
|
||||
confirmResolveRef.current({ approved: false, reason: 'Denied by user' });
|
||||
confirmResolveRef.current = null;
|
||||
setConfirmation(null);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
if (isStreaming) {
|
||||
abortRef.current = true;
|
||||
@@ -120,7 +173,6 @@ export function App({
|
||||
return;
|
||||
}
|
||||
|
||||
// Scroll handling
|
||||
if (key.upArrow && scrollOffset > 0) {
|
||||
setScrollOffset(prev => Math.max(0, prev - 1));
|
||||
}
|
||||
@@ -136,12 +188,15 @@ export function App({
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(async (value: string) => {
|
||||
if (confirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const command = parseCommand(value);
|
||||
if (!command) {return;}
|
||||
|
||||
setInput('');
|
||||
|
||||
// Handle commands
|
||||
switch (command.type) {
|
||||
case 'quit':
|
||||
onExit?.();
|
||||
@@ -160,69 +215,124 @@ export function App({
|
||||
return;
|
||||
|
||||
case 'help': {
|
||||
// Show help as system message
|
||||
const helpMsg: Message = { role: 'assistant', content: getHelpText() };
|
||||
const helpWithTs = session.addMessage(helpMsg);
|
||||
setMessages(prev => [...prev, helpWithTs]);
|
||||
setMessages(prev => [...prev, session.addMessage(helpMsg)]);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'status': {
|
||||
const status = `Session: ${session.id}\nMessages: ${messages.length}\nTokens: ${tokenUsage.inputTokens} in / ${tokenUsage.outputTokens} out`;
|
||||
const statusMsg: Message = { role: 'assistant', content: status };
|
||||
const statusWithTs = session.addMessage(statusMsg);
|
||||
setMessages(prev => [...prev, statusWithTs]);
|
||||
setMessages(prev => [...prev, session.addMessage(statusMsg)]);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'model': {
|
||||
if (!modelRouter) {
|
||||
const errMsg: Message = { role: 'assistant', content: 'Model switching not available.' };
|
||||
const errWithTs = session.addMessage(errMsg);
|
||||
setMessages(prev => [...prev, errWithTs]);
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Model switching not available.' })]);
|
||||
return;
|
||||
}
|
||||
|
||||
// /model
|
||||
if (!command.name) {
|
||||
const info = `Current: ${modelRouter.getTier()}\nAvailable: ${modelRouter.getAvailableTiers().join(', ')}`;
|
||||
const infoMsg: Message = { role: 'assistant', content: info };
|
||||
const infoWithTs = session.addMessage(infoMsg);
|
||||
setMessages(prev => [...prev, infoWithTs]);
|
||||
const current = modelRouter.getTier();
|
||||
const available = modelRouter.getAvailableTiers();
|
||||
const labels = modelRouter.getAllLabels();
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`Active tier: ${current}`);
|
||||
for (const t of available) {
|
||||
const label = labels[t] ?? 'unknown';
|
||||
const strict = modelRouter.isTierStrict(t) ? ' (strict)' : '';
|
||||
lines.push(` ${t}: ${label}${strict}${t === current ? ' ←' : ''}`);
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: lines.join('\n') })]);
|
||||
return;
|
||||
}
|
||||
|
||||
// /model <tier> <provider/model>
|
||||
if (command.providerModel) {
|
||||
const tier = resolveModelAlias(command.name);
|
||||
const providerModel = command.providerModel;
|
||||
|
||||
const slashIdx = providerModel.indexOf('/');
|
||||
if (slashIdx === -1) {
|
||||
setMessages(prev => [...prev, session.addMessage({
|
||||
role: 'assistant',
|
||||
content: 'Invalid format. Use provider/model (e.g. anthropic/claude-sonnet-4)',
|
||||
})]);
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = providerModel.slice(0, slashIdx);
|
||||
const modelName = providerModel.slice(slashIdx + 1);
|
||||
|
||||
if (!MODEL_PROVIDERS.includes(provider as ModelProvider)) {
|
||||
setMessages(prev => [...prev, session.addMessage({
|
||||
role: 'assistant',
|
||||
content: `Unknown provider "${provider}". Known providers: ${MODEL_PROVIDERS.join(', ')}`,
|
||||
})]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const providerType = provider as ModelProvider;
|
||||
const template = modelProviderConfigs?.[providerType];
|
||||
const client = createClientFromConfig({
|
||||
...(template ?? {}),
|
||||
provider: providerType,
|
||||
model: modelName,
|
||||
});
|
||||
|
||||
modelRouter.setClient(tier, client, providerModel);
|
||||
modelRouter.setTierStrict(tier, true);
|
||||
|
||||
if (agent && tier === modelRouter.getTier()) {
|
||||
agent.setModelTier(tier);
|
||||
setCurrentModel(modelRouter.getLabel(tier));
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, session.addMessage({
|
||||
role: 'assistant',
|
||||
content: `Set ${tier} to: ${providerModel}\nFallbacks for ${tier} disabled (strict tier mode).`,
|
||||
})]);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Failed to create client: ${msg}` })]);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// /model <tier>
|
||||
const tier = resolveModelAlias(command.name);
|
||||
if (modelRouter.setTier(tier)) {
|
||||
// Also update the agent tier so chatWithRouter uses the correct client
|
||||
if (agent) {
|
||||
agent.setModelTier(tier);
|
||||
}
|
||||
setCurrentModel(tier);
|
||||
const successMsg: Message = { role: 'assistant', content: `Switched to model: ${tier}` };
|
||||
const successWithTs = session.addMessage(successMsg);
|
||||
setMessages(prev => [...prev, successWithTs]);
|
||||
setCurrentModel(modelRouter.getLabel(tier));
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Switched to model: ${tier}` })]);
|
||||
} else {
|
||||
const failMsg: Message = { role: 'assistant', content: `Model not available: ${command.name}` };
|
||||
const failWithTs = session.addMessage(failMsg);
|
||||
setMessages(prev => [...prev, failWithTs]);
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Model not available: ${command.name}` })]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
case 'fullscreen':
|
||||
// Already in fullscreen
|
||||
return;
|
||||
|
||||
case 'transfer': {
|
||||
const xferMsg: Message = { role: 'assistant', content: 'Transfer not supported in fullscreen mode.' };
|
||||
const xferWithTs = session.addMessage(xferMsg);
|
||||
setMessages(prev => [...prev, xferWithTs]);
|
||||
case 'transfer':
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Transfer not supported in fullscreen mode.' })]);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'message':
|
||||
break; // Continue to message handling
|
||||
break;
|
||||
}
|
||||
|
||||
if (command.type !== 'message' || isStreaming) {return;}
|
||||
if (command.type !== 'message' || isStreaming) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add user message to UI (and session if no agent — agent adds it internally)
|
||||
const userMessage: Message = { role: 'user', content: command.content };
|
||||
@@ -232,9 +342,8 @@ export function App({
|
||||
} else {
|
||||
setMessages(prev => [...prev, { ...userMessage, timestamp: Date.now() }]);
|
||||
}
|
||||
setScrollOffset(0); // Auto-scroll to bottom
|
||||
setScrollOffset(0);
|
||||
|
||||
// Process response
|
||||
setIsStreaming(true);
|
||||
setStreamingContent('');
|
||||
toolLinesRef.current = [];
|
||||
@@ -242,16 +351,11 @@ export function App({
|
||||
|
||||
try {
|
||||
if (agent) {
|
||||
// agent.process() handles session history internally
|
||||
const response = await agent.process(command.content);
|
||||
|
||||
await agent.process(command.content);
|
||||
const usage = agent.getUsage();
|
||||
setTokenUsage({ inputTokens: usage.inputTokens, outputTokens: usage.outputTokens });
|
||||
|
||||
// Sync UI with session history (agent already added messages to session)
|
||||
setMessages(session.getHistory());
|
||||
} else if (modelClient.chatStream) {
|
||||
// Fallback: direct streaming without tools
|
||||
let fullContent = '';
|
||||
|
||||
for await (const event of modelClient.chatStream({
|
||||
@@ -279,10 +383,8 @@ export function App({
|
||||
}
|
||||
|
||||
const assistantMessage: Message = { role: 'assistant', content: fullContent };
|
||||
const assistantWithTimestamp = session.addMessage(assistantMessage);
|
||||
setMessages(prev => [...prev, assistantWithTimestamp]);
|
||||
setMessages(prev => [...prev, session.addMessage(assistantMessage)]);
|
||||
} else {
|
||||
// Fallback: non-streaming without tools
|
||||
const response = await modelClient.chat({
|
||||
messages: session.getHistory(),
|
||||
system: systemPrompt,
|
||||
@@ -294,21 +396,30 @@ export function App({
|
||||
}));
|
||||
|
||||
const assistantMessage: Message = { role: 'assistant', content: response.content };
|
||||
const assistantWithTimestamp = session.addMessage(assistantMessage);
|
||||
setMessages(prev => [...prev, assistantWithTimestamp]);
|
||||
setMessages(prev => [...prev, session.addMessage(assistantMessage)]);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
};
|
||||
const errorWithTimestamp = session.addMessage(errorMessage);
|
||||
setMessages(prev => [...prev, errorWithTimestamp]);
|
||||
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Error: ${msg}` })]);
|
||||
} finally {
|
||||
setIsStreaming(false);
|
||||
setStreamingContent('');
|
||||
}
|
||||
}, [isStreaming, session, agent, modelClient, modelRouter, systemPrompt, exit, onExit, messages.length, tokenUsage]);
|
||||
}, [
|
||||
confirmation,
|
||||
session,
|
||||
agent,
|
||||
modelClient,
|
||||
modelRouter,
|
||||
systemPrompt,
|
||||
exit,
|
||||
onExit,
|
||||
isStreaming,
|
||||
messages.length,
|
||||
tokenUsage.inputTokens,
|
||||
tokenUsage.outputTokens,
|
||||
modelProviderConfigs,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" height="100%">
|
||||
@@ -317,13 +428,27 @@ export function App({
|
||||
scrollOffset={scrollOffset}
|
||||
streamingContent={isStreaming ? streamingContent : undefined}
|
||||
/>
|
||||
|
||||
{confirmation ? (
|
||||
<Box paddingX={1} paddingY={0} borderStyle="round" borderColor="yellow">
|
||||
<Text color="yellow">
|
||||
Confirmation required: {confirmation.tool}{' '}
|
||||
{Object.keys(confirmation.args).length > 0 ? JSON.stringify(confirmation.args) : ''}
|
||||
</Text>
|
||||
<Text color="yellow">Press y to approve, n to deny.</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<InputBar
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isStreaming}
|
||||
placeholder={isStreaming ? 'Flynn is typing... (Esc to cancel)' : 'Type a message... (Esc=exit, /help)'}
|
||||
isLoading={isStreaming || !!confirmation}
|
||||
placeholder={confirmation
|
||||
? 'Confirmation required (press y/n)'
|
||||
: (isStreaming ? 'Flynn is typing... (Esc to cancel)' : 'Type a message... (Esc=exit, /help)')}
|
||||
/>
|
||||
|
||||
<StatusBar
|
||||
sessionId={session.id}
|
||||
messageCount={messages.length}
|
||||
|
||||
Reference in New Issue
Block a user