feat: add OpenAI OAuth, strict model overrides, and Gmail pull mode
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user