feat: add OpenAI OAuth, strict model overrides, and Gmail pull mode

This commit is contained in:
William Valentin
2026-02-13 14:55:40 -08:00
parent 8f644d5e25
commit 955b9e28e0
50 changed files with 5955 additions and 160 deletions
+2 -2
View File
@@ -124,7 +124,7 @@ Commands:
/model [name] Show or switch model tier (local, default, fast, complex)
/model <tier> <p/m> Change tier's provider/model (e.g. /model default anthropic/claude-sonnet-4)
/backend [provider] Show or switch local backend (ollama, llamacpp)
/login [provider] Authenticate with GitHub
/login [provider] Authenticate with GitHub or OpenAI
/pair List pending pairing codes and approved senders
/pair generate [label] Generate a new DM pairing code
/pair revoke <ch> <id> Revoke an approved sender
@@ -178,7 +178,7 @@ export const COMMAND_TOOLTIPS: Record<string, string> = {
'/status': 'Show session info and token usage',
'/fullscreen': 'Switch to fullscreen mode',
'/fs': 'Switch to fullscreen mode',
'/login': 'Authenticate with GitHub (OAuth device flow)',
'/login': 'Authenticate with GitHub or OpenAI (OAuth device flow)',
'/pair': 'Generate/list/revoke DM pairing codes',
'/transfer': 'Transfer session to another frontend',
'/quit': 'Exit TUI',
+184 -59
View File
@@ -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}
+10
View File
@@ -5,6 +5,8 @@ import type { ManagedSession } from '../../session/index.js';
import type { ModelClient } from '../../models/types.js';
import type { ModelRouter } from '../../models/router.js';
import type { NativeAgent } from '../../backends/native/agent.js';
import type { HookEngine } from '../../hooks/index.js';
import type { ModelConfig, ModelProvider } from '../../config/index.js';
export interface FullscreenTuiConfig {
session: ManagedSession;
@@ -13,6 +15,8 @@ export interface FullscreenTuiConfig {
systemPrompt: string;
model: string;
agent?: NativeAgent;
hookEngine?: HookEngine;
modelProviderConfigs?: Partial<Record<ModelProvider, ModelConfig>>;
onExit?: () => void;
}
@@ -22,6 +26,10 @@ export async function startFullscreenTui(config: FullscreenTuiConfig): Promise<v
process.stdin.resume();
}
if (config.agent && config.modelRouter) {
config.agent.setModelTier(config.modelRouter.getTier());
}
const { waitUntilExit } = render(
React.createElement(App, {
session: config.session,
@@ -30,6 +38,8 @@ export async function startFullscreenTui(config: FullscreenTuiConfig): Promise<v
systemPrompt: config.systemPrompt,
model: config.model,
agent: config.agent,
hookEngine: config.hookEngine,
modelProviderConfigs: config.modelProviderConfigs,
onExit: config.onExit,
}),
);
+53
View File
@@ -118,4 +118,57 @@ describe('MinimalTui backend command', () => {
expect(mockRouter.setTier).toHaveBeenCalledWith('local');
expect(mockAgent.setModelTier).toHaveBeenCalledWith('local');
});
it('reuses configured provider credentials for /model <tier> <provider/model>', () => {
const prevOpenRouterKey = process.env.OPENROUTER_API_KEY;
delete process.env.OPENROUTER_API_KEY;
try {
const mockSession = {
id: 'test',
getHistory: () => [],
addMessage: vi.fn(),
clear: vi.fn(),
replaceHistory: vi.fn(),
};
const mockRouter = {
getTier: () => 'default' as const,
getAvailableTiers: () => ['default', 'local'],
setTier: vi.fn(() => true),
getLocalProviderName: () => 'ollama',
setLocalClient: vi.fn(),
setClient: vi.fn(),
setTierStrict: vi.fn(),
chat: vi.fn(),
getClient: vi.fn(),
};
const tui = new MinimalTui({
session: mockSession as any,
modelClient: mockRouter as any,
modelRouter: mockRouter as any,
systemPrompt: 'test',
modelProviderConfigs: {
openrouter: {
provider: 'openrouter',
model: 'seed-model',
api_key: 'test-key',
endpoint: 'https://openrouter.ai/api/v1',
},
},
});
(tui as any).handleModelCommand('default', 'openrouter/deepseek/deepseek-chat');
expect(mockRouter.setClient).toHaveBeenCalledOnce();
expect(mockRouter.setTierStrict).toHaveBeenCalledWith('default', true);
} finally {
if (prevOpenRouterKey) {
process.env.OPENROUTER_API_KEY = prevOpenRouterKey;
} else {
delete process.env.OPENROUTER_API_KEY;
}
}
});
});
+80 -18
View File
@@ -9,9 +9,10 @@ import type { ModelConfig, ModelProvider } from '../../config/schema.js';
import { MODEL_PROVIDERS } from '../../config/schema.js';
import { OllamaClient, LlamaCppClient } from '../../models/index.js';
import { createClientFromConfig } from '../../daemon/index.js';
import { loginGitHub } from '../../auth/index.js';
import { loginGitHub, loginOpenAI } from '../../auth/index.js';
import type { PairingManager } from '../../channels/pairing.js';
import { getColoredBanner } from './banner.js';
import type { HookEngine } from '../../hooks/index.js';
export { parseCommand, type Command };
@@ -42,8 +43,10 @@ export interface MinimalTuiConfig {
onFullscreen?: () => void;
onTransfer?: (target: string) => void;
localProviders?: Record<string, ModelConfig>;
modelProviderConfigs?: Partial<Record<ModelProvider, ModelConfig>>;
currentLocalProvider?: string;
pairingManager?: PairingManager;
hookEngine?: HookEngine;
}
export class MinimalTui {
@@ -99,6 +102,10 @@ export class MinimalTui {
async start(): Promise<void> {
this.running = true;
if (this.config.agent && this.config.modelRouter) {
this.config.agent.setModelTier(this.config.modelRouter.getTier());
}
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
@@ -108,6 +115,26 @@ export class MinimalTui {
},
});
// In minimal TUI we can prompt inline for tool confirmations.
// This avoids deadlocks when hooks are configured to require confirmation
// (e.g. shell.exec) and the tool loop is awaiting a decision.
if (this.config.hookEngine) {
this.config.hookEngine.setInteractiveConfirmer(async (pending) => {
const tool = pending.tool;
const args = pending.args;
const argsStr = Object.keys(args).length > 0 ? ` ${JSON.stringify(args)}` : '';
console.log(`\n${colors.bold}Confirmation required${colors.reset}`);
console.log(`${colors.gray}${tool}${colors.reset}${argsStr}`);
const answer = (await this.prompt(`${colors.orange}${colors.bold}Approve?${colors.reset} ${colors.gray}(y/N)${colors.reset} `))
.trim()
.toLowerCase();
const approved = answer === 'y' || answer === 'yes';
console.log(approved ? `${colors.gray}Approved.${colors.reset}\n` : `${colors.gray}Denied.${colors.reset}\n`);
return approved ? { approved: true } : { approved: false, reason: 'Denied by user' };
});
}
// Listen for line changes to show hints
process.stdin.on('keypress', () => {
// Small delay to let readline update the line
@@ -239,9 +266,22 @@ export class MinimalTui {
}
try {
const client = createClientFromConfig({ provider: provider as ModelProvider, model });
const providerType = provider as ModelProvider;
const template = this.config.modelProviderConfigs?.[providerType];
const client = createClientFromConfig({
...(template ?? {}),
provider: providerType,
model,
});
router.setClient(tier, client, providerModel);
console.log(`${colors.gray}Set ${tier} to:${colors.reset} ${providerModel}\n`);
router.setTierStrict(tier, true);
if (this.config.agent && tier === router.getTier()) {
this.config.agent.setModelTier(tier);
}
console.log(`${colors.gray}Set ${tier} to:${colors.reset} ${providerModel}`);
console.log(`${colors.gray}Fallbacks for ${tier} disabled (strict tier mode).${colors.reset}\n`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.log(`${colors.gray}Failed to create client:${colors.reset} ${message}\n`);
@@ -383,27 +423,49 @@ export class MinimalTui {
private async handleLoginCommand(provider?: string): Promise<void> {
const target = provider ?? 'github';
if (target !== 'github') {
console.log(`${colors.gray}Unknown login provider:${colors.reset} ${target}. Only 'github' is supported.\n`);
if (target === 'github') {
console.log(`${colors.gray}Starting GitHub OAuth device flow...${colors.reset}`);
try {
await loginGitHub((userCode, verificationUri) => {
console.log('');
console.log(`${colors.gray}Please visit:${colors.reset} ${verificationUri}`);
console.log(`${colors.gray}and enter code:${colors.reset} ${userCode}`);
console.log('');
console.log(`${colors.gray}Waiting for authorization...${colors.reset}`);
});
console.log(`${colors.gray}GitHub authentication successful! Token stored.${colors.reset}\n`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.log(`${colors.gray}GitHub login failed:${colors.reset} ${message}\n`);
}
return;
}
console.log(`${colors.gray}Starting GitHub OAuth device flow...${colors.reset}`);
if (target === 'openai') {
console.log(`${colors.gray}Starting OpenAI OAuth device flow...${colors.reset}`);
try {
await loginGitHub((userCode, verificationUri) => {
console.log('');
console.log(`${colors.gray}Please visit:${colors.reset} ${verificationUri}`);
console.log(`${colors.gray}and enter code:${colors.reset} ${userCode}`);
console.log('');
console.log(`${colors.gray}Waiting for authorization...${colors.reset}`);
});
try {
await loginOpenAI((userCode, verificationUri) => {
console.log('');
console.log(`${colors.gray}Please visit:${colors.reset} ${verificationUri}`);
console.log(`${colors.gray}and enter code:${colors.reset} ${userCode}`);
console.log('');
console.log(`${colors.gray}Waiting for authorization...${colors.reset}`);
});
console.log(`${colors.gray}GitHub authentication successful! Token stored.${colors.reset}\n`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.log(`${colors.gray}GitHub login failed:${colors.reset} ${message}\n`);
console.log(`${colors.gray}OpenAI authentication successful! Token stored.${colors.reset}\n`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.log(`${colors.gray}OpenAI login failed:${colors.reset} ${message}\n`);
}
return;
}
console.log(`${colors.gray}Unknown login provider:${colors.reset} ${target}. Supported: github, openai\n`);
}
private handlePairCommand(action?: 'generate' | 'list' | 'revoke', args?: string): void {