diff --git a/README.md b/README.md index f8222d7..732cfd7 100644 --- a/README.md +++ b/README.md @@ -520,6 +520,7 @@ pnpm tui:fs | `/login [provider]` | Authenticate with GitHub (OAuth device flow) | | `/reset` | Clear history | | `/status` | Show session info | +| `/tools` | Show authoritative runtime tool list for this session | | `/research ` | Delegate a task to `agent_configs.research` | | `/council ` | Run dual D/P councils pipeline with bridge+meta merge | | `/compact` | Compact conversation context | diff --git a/docs/plans/state.json b/docs/plans/state.json index 10adcec..6e7d267 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -3,6 +3,31 @@ "updated_at": "2026-02-21", "description": "Tracks the status of all Flynn plans and implementation phases", "plans": { + "slash-command-parity-and-authoritative-tools": { + "status": "completed", + "date": "2026-02-21", + "updated": "2026-02-21", + "summary": "Unified slash command behavior across minimal and fullscreen TUI by adding `/tools`, `/research`, and `/council` command parity, wiring shared callbacks, and registering `council.run` in TUI startup when councils are enabled. Added deterministic capability-query handling so tool-inventory prompts return authoritative runtime tool lists instead of model-invented answers.", + "files_modified": [ + "src/frontends/tui/commands.ts", + "src/frontends/tui/commands.test.ts", + "src/frontends/tui/minimal.ts", + "src/frontends/tui/minimal.test.ts", + "src/frontends/tui/fullscreen.ts", + "src/frontends/tui/components/App.tsx", + "src/commands/types.ts", + "src/commands/builtin/index.ts", + "src/commands/builtin/index.test.ts", + "src/commands/index.ts", + "src/daemon/routing.ts", + "src/daemon/routing.test.ts", + "src/cli/tui.ts", + "src/backends/native/agent.ts", + "README.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/commands/builtin/index.test.ts src/daemon/routing.test.ts src/frontends/tui/commands.test.ts src/frontends/tui/minimal.test.ts + pnpm typecheck passing" + }, "faster-inflight-cancel-propagation": { "status": "completed", "date": "2026-02-19", diff --git a/src/backends/native/agent.ts b/src/backends/native/agent.ts index 0c058c0..4de6262 100644 --- a/src/backends/native/agent.ts +++ b/src/backends/native/agent.ts @@ -567,6 +567,16 @@ export class NativeAgent { }; } + getAvailableToolNames(): string[] { + if (!this.toolRegistry) { + return []; + } + return this.toolRegistry + .filteredList(this._toolPolicyContext) + .map((tool) => tool.name) + .sort(); + } + setAttachmentCollector(collector: OutboundAttachmentCollector | undefined): void { this._attachmentCollector = collector; } diff --git a/src/cli/tui.ts b/src/cli/tui.ts index c4fe7c6..8d508e2 100644 --- a/src/cli/tui.ts +++ b/src/cli/tui.ts @@ -1,5 +1,5 @@ import type { Command } from 'commander'; -import type { Config, ModelConfig, ModelProvider } from '../config/index.js'; +import type { Config, CouncilsConfig, ModelConfig, ModelProvider } from '../config/index.js'; import { loadConfigSafe, getConfigPath } from './shared.js'; import { existsSync, mkdirSync, readFileSync } from 'fs'; import { resolve } from 'path'; @@ -110,12 +110,14 @@ export function registerTuiCommand(program: Command): void { createGtasksTools, createAgentsListTool, createAgentDelegateTool, + createCouncilRunTool, } = await import('../tools/index.js'); const { HookEngine } = await import('../hooks/index.js'); const { Lifecycle } = await import('../daemon/lifecycle.js'); const { initTools } = await import('../daemon/tools.js'); const { createModelRouter } = await import('../daemon/index.js'); const { AgentConfigRegistry } = await import('../agents/index.js'); + const { loadCouncilScaffoldSafe } = await import('../councils/scaffold.js'); const dataDir = process.env.FLYNN_DATA_DIR ?? resolve(homedir(), '.local/share/flynn'); mkdirSync(dataDir, { recursive: true }); @@ -182,25 +184,39 @@ export function registerTuiCommand(program: Command): void { const agentConfigRegistry = new AgentConfigRegistry(); agentConfigRegistry.loadFromConfig(config.agent_configs); + const delegateRunner = { + async delegate(request: { + tier: 'fast' | 'default' | 'complex' | 'local'; + systemPrompt: string; + message: string; + maxTokens?: number; + }) { + const response = await modelRouter.chat({ + messages: [{ role: 'user', content: request.message }], + system: request.systemPrompt, + maxTokens: request.maxTokens, + }, request.tier); + return { + content: response.content, + usage: response.usage, + tier: request.tier, + }; + }, + }; if (agentConfigRegistry.list().length > 0) { toolRegistry.register(createAgentsListTool(agentConfigRegistry)); toolRegistry.register(createAgentDelegateTool({ registry: agentConfigRegistry, - orchestrator: { - async delegate(request) { - const response = await modelRouter.chat({ - messages: [{ role: 'user', content: request.message }], - system: request.systemPrompt, - maxTokens: request.maxTokens, - }, request.tier); - return { - content: response.content, - usage: response.usage, - tier: request.tier, - }; - }, - }, + orchestrator: delegateRunner, })); + if (config.councils?.enabled) { + toolRegistry.register(createCouncilRunTool({ + registry: agentConfigRegistry, + orchestrator: delegateRunner, + config: config.councils as CouncilsConfig, + scaffold: loadCouncilScaffoldSafe(config.councils.scaffold_path), + })); + } } const session = sessionManager.getSession('tui', 'local'); @@ -296,6 +312,54 @@ export function registerTuiCommand(program: Command): void { return `Unknown transfer target: ${target}. Supported targets: tui, telegram`; }; + const listAvailableTools = (): string => { + const names = toolRegistry.filteredList().map((tool) => tool.name).sort(); + return [ + `Available tools (${names.length}):`, + ...names.map((name) => `- ${name}`), + ].join('\n'); + }; + + const delegateToResearchAgent = async (task: string): Promise => { + const message = task.trim(); + if (!message) { + return 'Usage: /research '; + } + const agentConfig = agentConfigRegistry.get('research'); + if (!agentConfig) { + return 'Agent "research" not found. Configure agent_configs.research first.'; + } + const tier = agentConfig.modelTier ?? 'default'; + const systemPrompt = agentConfig.systemPrompt + ?? 'You are a research sub-agent. Produce concise, source-grounded findings.'; + const result = await delegateRunner.delegate({ + tier, + systemPrompt, + message, + maxTokens: 4096, + }); + return `[Agent: research | Tier: ${result.tier} | Tokens: ${result.usage.inputTokens}+${result.usage.outputTokens}]\n\n${result.content}`; + }; + + const runCouncilTask = async (task: string): Promise => { + const message = task.trim(); + if (!message) { + return 'Usage: /council '; + } + if (!config.councils?.enabled) { + return 'Councils are disabled. Set councils.enabled: true in config.'; + } + const tool = toolRegistry.get('council.run'); + if (!tool) { + return 'Council tool is not registered. Verify councils config and restart Flynn.'; + } + const result = await tool.execute({ task: message }); + if (!result.success) { + return `Council run failed: ${result.error ?? 'unknown error'}`; + } + return result.output; + }; + if (opts.fullscreen) { await startFullscreenTui({ session, @@ -311,6 +375,9 @@ export function registerTuiCommand(program: Command): void { modelProviderConfigs, contextThresholdPct: config.compaction.threshold_pct, onTransfer: transferSessionToTarget, + onTools: listAvailableTools, + onResearch: delegateToResearchAgent, + onCouncil: runCouncilTask, onExit: () => { void cleanup(); }, @@ -326,6 +393,9 @@ export function registerTuiCommand(program: Command): void { agent, hookEngine, pairingManager, + onTools: listAvailableTools, + onResearch: delegateToResearchAgent, + onCouncil: runCouncilTask, localProviders: config.models.local_providers, modelProviderConfigs, contextThresholdPct: config.compaction.threshold_pct, @@ -357,6 +427,9 @@ export function registerTuiCommand(program: Command): void { modelProviderConfigs, contextThresholdPct: config.compaction.threshold_pct, onTransfer: transferSessionToTarget, + onTools: listAvailableTools, + onResearch: delegateToResearchAgent, + onCouncil: runCouncilTask, onExit: () => { void cleanup(); }, diff --git a/src/commands/builtin/index.test.ts b/src/commands/builtin/index.test.ts index 898f8b0..1c6c7c7 100644 --- a/src/commands/builtin/index.test.ts +++ b/src/commands/builtin/index.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; -import { createApproveCommand, createApprovalsCommand, createContextCommand, createCouncilCommand, createDenyCommand, createElevateCommand, createModelCommand, createQueueCommand, createResearchCommand, createSkillCommand, createStopCommand, createTransferCommand } from './index.js'; +import { createApproveCommand, createApprovalsCommand, createContextCommand, createCouncilCommand, createDenyCommand, createElevateCommand, createModelCommand, createQueueCommand, createResearchCommand, createSkillCommand, createStopCommand, createToolsCommand, createTransferCommand } from './index.js'; describe('builtin /model command', () => { it('passes through the full argument string', async () => { @@ -198,6 +198,34 @@ describe('builtin /context command', () => { }); }); +describe('builtin /tools command', () => { + it('returns tools text from services', async () => { + const cmd = createToolsCommand(); + const getTools = vi.fn(() => 'Available tools (2):\n- file.read\n- web.fetch'); + const result = await cmd.execute([], { + channel: 'test', + senderId: 'user', + sessionId: 's1', + rawInput: '/tools', + services: { getTools }, + }); + expect(getTools).toHaveBeenCalledOnce(); + expect(result).toEqual({ handled: true, text: 'Available tools (2):\n- file.read\n- web.fetch' }); + }); + + it('returns not-available when service is missing', async () => { + const cmd = createToolsCommand(); + const result = await cmd.execute([], { + channel: 'test', + senderId: 'user', + sessionId: 's1', + rawInput: '/tools', + services: {}, + }); + expect(result).toEqual({ handled: true, text: 'Tools command is not available in this session.' }); + }); +}); + describe('builtin /transfer command', () => { it('passes through the full target argument string', async () => { const cmd = createTransferCommand(); diff --git a/src/commands/builtin/index.ts b/src/commands/builtin/index.ts index 0e51018..a36196e 100644 --- a/src/commands/builtin/index.ts +++ b/src/commands/builtin/index.ts @@ -48,6 +48,22 @@ export function createStatusCommand(): CommandDefinition { }; } +export function createToolsCommand(): CommandDefinition { + return { + name: 'tools', + description: 'Show available tools in this session', + execute: async (_args, ctx) => { + if (!ctx.services?.getTools) { + return notAvailable('Tools command'); + } + return { + handled: true, + text: await ctx.services.getTools(), + }; + }, + }; +} + export function createUsageCommand(): CommandDefinition { return { name: 'usage', @@ -343,6 +359,7 @@ export function createSkillCommand(): CommandDefinition { export function registerBuiltinCommands(registry: CommandRegistry): void { registry.register(createHelpCommand(registry)); registry.register(createStatusCommand()); + registry.register(createToolsCommand()); registry.register(createUsageCommand()); registry.register(createContextCommand()); registry.register(createResearchCommand()); diff --git a/src/commands/index.ts b/src/commands/index.ts index bb89560..2ac29b2 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -3,6 +3,7 @@ export type { CommandContext, CommandDefinition, CommandResult, CommandServices export { createHelpCommand, createStatusCommand, + createToolsCommand, createUsageCommand, createContextCommand, createModelCommand, diff --git a/src/commands/types.ts b/src/commands/types.ts index 3c5d0a6..c71b69d 100644 --- a/src/commands/types.ts +++ b/src/commands/types.ts @@ -20,6 +20,7 @@ export interface CommandDefinition { export interface CommandServices { getStatus?: () => Promise | string; + getTools?: () => Promise | string; getUsage?: () => Promise | string; getContext?: () => Promise | string; getModel?: () => Promise | string; diff --git a/src/daemon/routing.test.ts b/src/daemon/routing.test.ts index f90e2c3..48ab970 100644 --- a/src/daemon/routing.test.ts +++ b/src/daemon/routing.test.ts @@ -1062,6 +1062,78 @@ describe('daemon external backend integration', () => { expect(reply).toHaveBeenCalledWith(expect.objectContaining({ text: 'external backend response' })); }); + it('forces native processing for capability/tool inventory queries', async () => { + const processSpy = vi.spyOn(AgentOrchestrator.prototype, 'process') + .mockResolvedValue('Available tools (authoritative):\n- file.read'); + const history: Array<{ role: 'user' | 'assistant'; content: string }> = []; + const session = { + id: 'telegram:external-tools-query', + addMessage: vi.fn((msg: { role: 'user' | 'assistant'; content: string }) => { + history.push(msg); + return msg; + }), + getHistory: vi.fn(() => [...history]), + clear: vi.fn(), + replaceHistory: vi.fn(), + getConfig: vi.fn(() => undefined), + setConfig: vi.fn(), + deleteConfig: vi.fn(), + }; + + const externalBackend = { + name: 'codex', + process: vi.fn(async () => 'external backend response'), + }; + + const router = createMessageRouter({ + sessionManager: { + getSession: vi.fn(() => session), + } as unknown as MessageRouterDeps['sessionManager'], + modelRouter: { + getAvailableTiers: () => ['fast', 'default', 'complex', 'local'], + getAllLabels: () => ({ fast: 'fast', default: 'default', complex: 'complex', local: 'local' }), + getLabel: (tier: string) => tier, + } as unknown as MessageRouterDeps['modelRouter'], + systemPrompt: 'test prompt', + toolRegistry: { + clone() { return this; }, + register: vi.fn(), + } as unknown as MessageRouterDeps['toolRegistry'], + toolExecutor: {} as unknown as MessageRouterDeps['toolExecutor'], + config: { + agents: { + primary_tier: 'default', + delegation: { + compaction: 'fast', + memory_extraction: 'fast', + classification: 'fast', + tool_summarisation: 'fast', + complex_reasoning: 'complex', + }, + max_delegation_depth: 3, + max_iterations: 10, + }, + compaction: { enabled: false }, + models: { default: { provider: 'anthropic', model: 'claude' } }, + } as unknown as MessageRouterDeps['config'], + externalBackends: { codex: externalBackend } as unknown as MessageRouterDeps['externalBackends'], + defaultName: 'codex', + }); + + const reply = vi.fn(async (_message: OutboundMessage) => {}); + await router.handler({ + id: 'm-external-tools-query', + channel: 'telegram', + senderId: 'external-tools-query', + text: 'check your available tools', + timestamp: Date.now(), + } as MessageRouterInput, reply); + + expect(externalBackend.process).not.toHaveBeenCalled(); + expect(processSpy).toHaveBeenCalled(); + expect(reply).toHaveBeenCalledWith(expect.objectContaining({ text: 'Available tools (authoritative):\n- file.read' })); + }); + it('falls back to native processing when external backend fails', async () => { const processSpy = vi.spyOn(AgentOrchestrator.prototype, 'process') .mockResolvedValue('native fallback response'); diff --git a/src/daemon/routing.ts b/src/daemon/routing.ts index d6f0d45..ba4577b 100644 --- a/src/daemon/routing.ts +++ b/src/daemon/routing.ts @@ -140,6 +140,24 @@ function parseResearchPrefix(text: string): string | undefined { return undefined; } +function shouldForceNativeForCapabilityQuery(text: string): boolean { + const normalized = text.trim().toLowerCase(); + if (!normalized) { + return false; + } + return ( + normalized.includes('available tools') + || normalized.includes('what tools') + || normalized.includes('which tools') + || normalized.includes('tool list') + || normalized.includes('list tools') + || normalized.includes('your tools') + || normalized.includes('what can you do') + || normalized.includes('can you do') + || normalized.includes('capabilities') + ); +} + function isTtsEnabledForChannel(config: Config, channel: string): boolean { if (!config.tts?.enabled) { return false; @@ -649,6 +667,21 @@ export function createMessageRouter(deps: { : 'native'; return `Flynn is running. Active model tier: ${agent.getModelTier()}. Backend: ${backend}`; }, + getTools: () => { + const names = new Set(deps.toolRegistry.list().map((tool: Tool) => tool.name)); + names.add('media.send'); + if (deps.agentConfigRegistry && deps.agentConfigRegistry.list().length > 0) { + names.add('agent.delegate'); + if (deps.config.councils?.enabled) { + names.add('council.run'); + } + } + const sorted = [...names].sort(); + return [ + `Available tools (${sorted.length}):`, + ...sorted.map((name) => `- ${name}`), + ].join('\n'); + }, getUsage: () => { const usage = agent.getUsage(); const lines = [ @@ -1260,11 +1293,12 @@ export function createMessageRouter(deps: { // buildUserMessage() in the agent will create native audio content parts const requestedBackend = agentConfig?.backend ?? deps.defaultName; + const forceNativeForCapabilityQuery = shouldForceNativeForCapabilityQuery(messageText); const sessionIdForAudit = `${msg.channel}:${msg.senderId}`; const selectedBackend = requestedBackend && requestedBackend !== 'native' ? deps.externalBackends?.[requestedBackend] : undefined; - const selectedBackendForAudit: 'native' | ExternalBackendName = selectedBackend && requestedBackend + const selectedBackendForAudit: 'native' | ExternalBackendName = selectedBackend && requestedBackend && !forceNativeForCapabilityQuery ? requestedBackend : 'native'; @@ -1280,7 +1314,7 @@ export function createMessageRouter(deps: { : 'native', }); - if (selectedBackend && (!attachments || attachments.length === 0)) { + if (selectedBackend && (!attachments || attachments.length === 0) && !forceNativeForCapabilityQuery) { try { const history = toExternalHistory(session.getHistory()); session.addMessage({ role: 'user', content: messageText }); diff --git a/src/frontends/tui/commands.test.ts b/src/frontends/tui/commands.test.ts index 6c52c4c..3919c54 100644 --- a/src/frontends/tui/commands.test.ts +++ b/src/frontends/tui/commands.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseCommand, getHelpText, getCommandCompletions, PROVIDER_NAMES } from './commands.js'; +import { parseCommand, getHelpText, getCommandCompletions, isToolInventoryQuery, PROVIDER_NAMES } from './commands.js'; import { MODEL_PROVIDERS } from '../../config/index.js'; describe('parseCommand', () => { @@ -22,6 +22,20 @@ describe('parseCommand', () => { expect(parseCommand('/status')).toEqual({ type: 'status' }); }); + it('parses /tools command', () => { + expect(parseCommand('/tools')).toEqual({ type: 'tools' }); + }); + + it('parses /research command', () => { + expect(parseCommand('/research compare k3s and k0s')).toEqual({ type: 'research', task: 'compare k3s and k0s' }); + expect(parseCommand('/research')).toEqual({ type: 'research', task: '' }); + }); + + it('parses /council command', () => { + expect(parseCommand('/council design backup plan')).toEqual({ type: 'council', task: 'design backup plan' }); + expect(parseCommand('/council')).toEqual({ type: 'council', task: '' }); + }); + it('parses /fullscreen command', () => { expect(parseCommand('/fullscreen')).toEqual({ type: 'fullscreen' }); expect(parseCommand('/fs')).toEqual({ type: 'fullscreen' }); @@ -119,6 +133,9 @@ describe('getHelpText', () => { const help = getHelpText(); expect(help).toContain('/help'); expect(help).toContain('/model'); + expect(help).toContain('/tools'); + expect(help).toContain('/research'); + expect(help).toContain('/council'); expect(help).toContain('/reset'); expect(help).toContain('/compact'); expect(help).toContain('/usage'); @@ -181,3 +198,16 @@ describe('getCommandCompletions', () => { expect(completions).toEqual(['/model fast xai']); }); }); + +describe('isToolInventoryQuery', () => { + it('detects common capability/tool-list prompts', () => { + expect(isToolInventoryQuery('Check out your new tools')).toBe(true); + expect(isToolInventoryQuery('what tools do you have?')).toBe(true); + expect(isToolInventoryQuery('show capabilities')).toBe(true); + }); + + it('does not match unrelated prompts', () => { + expect(isToolInventoryQuery('write a shell script')).toBe(false); + expect(isToolInventoryQuery('summarize this doc')).toBe(false); + }); +}); diff --git a/src/frontends/tui/commands.ts b/src/frontends/tui/commands.ts index 01063d0..2770478 100644 --- a/src/frontends/tui/commands.ts +++ b/src/frontends/tui/commands.ts @@ -3,6 +3,9 @@ export type Command = | { type: 'reset' } | { type: 'help' } | { type: 'status' } + | { type: 'tools' } + | { type: 'research'; task: string } + | { type: 'council'; task: string } | { type: 'fullscreen' } | { type: 'compact' } | { type: 'usage' } @@ -17,6 +20,28 @@ export type Command = | { type: 'elevate'; args?: string } | { type: 'message'; content: string }; +export function isToolInventoryQuery(input: string): boolean { + const normalized = input.trim().toLowerCase(); + if (!normalized) { + return false; + } + const hasToolsWord = /\btools?\b/.test(normalized); + const hasInventoryIntent = /\b(check|show|list|what|which|available|new|have)\b/.test(normalized); + return ( + normalized.includes('available tools') + || normalized.includes('what tools') + || normalized.includes('which tools') + || normalized.includes('tool list') + || normalized.includes('list tools') + || normalized.includes('new tools') + || normalized.includes('your tools') + || normalized.includes('what can you do') + || normalized.includes('can you do') + || normalized.includes('capabilities') + || (hasToolsWord && hasInventoryIntent) + ); +} + export function parseCommand(input: string): Command | null { const trimmed = input.trim(); if (!trimmed) {return null;} @@ -41,6 +66,29 @@ export function parseCommand(input: string): Command | null { return { type: 'status' }; } + // Tools + if (trimmed === '/tools') { + return { type: 'tools' }; + } + + // Research + if (trimmed.startsWith('/research ')) { + const task = trimmed.slice('/research '.length).trim(); + return { type: 'research', task }; + } + if (trimmed === '/research') { + return { type: 'research', task: '' }; + } + + // Council + if (trimmed.startsWith('/council ')) { + const task = trimmed.slice('/council '.length).trim(); + return { type: 'council', task }; + } + if (trimmed === '/council') { + return { type: 'council', task: '' }; + } + // Fullscreen if (trimmed === '/fullscreen' || trimmed === '/fs') { return { type: 'fullscreen' }; @@ -157,9 +205,12 @@ export function getHelpText(): string { return ` Commands: /help, /? Show this help + /tools Show available tools in this session /model [name] Show or switch model tier (local, default, fast, complex) /model

Change tier's provider/model (e.g. /model default anthropic/claude-sonnet-4) /backend [provider] Show or switch local backend (ollama, llamacpp) + /research Delegate a task to the configured research agent + /council Run the councils pipeline for a task /login [provider] Authenticate with GitHub, OpenAI, Anthropic, or Z.AI /pair List pending pairing codes and approved senders /pair generate [label] Generate a new DM pairing code @@ -190,8 +241,11 @@ export type ModelAlias = 'local' | 'default' | 'fast' | 'complex' | 'opus' | 'so // List of all slash commands for autocompletion export const SLASH_COMMANDS = [ '/help', + '/tools', '/model', '/backend', + '/research', + '/council', '/reset', '/clear', '/new', @@ -217,8 +271,11 @@ export const SLASH_COMMANDS = [ // Command descriptions for tooltips export const COMMAND_TOOLTIPS: Record = { '/help': 'Show available commands', + '/tools': 'Show authoritative runtime tool list for this session', '/model': 'Show or switch model (local, default, fast, complex)', '/backend': 'Show or switch local backend (ollama, llamacpp)', + '/research': 'Delegate a task to the configured research agent', + '/council': 'Run the councils pipeline for a task', '/reset': 'Clear conversation history', '/clear': 'Clear conversation history', '/new': 'Start a new conversation', diff --git a/src/frontends/tui/components/App.tsx b/src/frontends/tui/components/App.tsx index 9b8068e..ec6df0e 100644 --- a/src/frontends/tui/components/App.tsx +++ b/src/frontends/tui/components/App.tsx @@ -3,7 +3,7 @@ import { Box, Text, useApp, useInput } from 'ink'; import { StatusBar } from './StatusBar.js'; import { MessageList } from './MessageList.js'; import { InputBar } from './InputBar.js'; -import { parseCommand, getHelpText, resolveModelAlias, getCommandCompletions } from '../commands.js'; +import { parseCommand, getHelpText, resolveModelAlias, getCommandCompletions, isToolInventoryQuery } from '../commands.js'; import type { Message, ModelClient, TokenUsage } from '../../../models/types.js'; import type { ModelRouter } from '../../../models/router.js'; import type { ManagedSession } from '../../../session/index.js'; @@ -59,6 +59,9 @@ export interface AppProps { modelProviderConfigs?: Partial>; contextThresholdPct?: number; onTransfer?: (target: string) => string | void; + onTools?: () => string; + onResearch?: (task: string) => Promise | string; + onCouncil?: (task: string) => Promise | string; onExit?: () => void; } @@ -76,6 +79,9 @@ export function App({ modelProviderConfigs, contextThresholdPct, onTransfer, + onTools, + onResearch, + onCouncil, onExit, }: AppProps): React.ReactElement { const ensureTimestamp = useCallback((message: Message): Message => ({ @@ -603,6 +609,41 @@ export function App({ return; } + case 'tools': { + if (!onTools) { + pushAssistantMessage('Tools command is not available in this TUI mode.'); + return; + } + pushAssistantMessage(onTools()); + return; + } + + case 'research': { + if (!command.task.trim()) { + pushAssistantMessage('Usage: /research '); + return; + } + if (!onResearch) { + pushAssistantMessage('Research command is not available in this TUI mode.'); + return; + } + pushAssistantMessage(await onResearch(command.task)); + return; + } + + case 'council': { + if (!command.task.trim()) { + pushAssistantMessage('Usage: /council '); + return; + } + if (!onCouncil) { + pushAssistantMessage('Council command is not available in this TUI mode.'); + return; + } + pushAssistantMessage(await onCouncil(command.task)); + return; + } + case 'pair': { if (!pairingManager) { pushAssistantMessage('Pairing not enabled. Set pairing.enabled: true in config.'); @@ -695,6 +736,11 @@ export function App({ } setScrollOffset(0); + if (onTools && isToolInventoryQuery(command.content)) { + pushAssistantMessage(onTools()); + return; + } + setIsStreaming(true); setStreamingContent(''); toolLinesRef.current = []; diff --git a/src/frontends/tui/fullscreen.ts b/src/frontends/tui/fullscreen.ts index bb93a71..c0c8560 100644 --- a/src/frontends/tui/fullscreen.ts +++ b/src/frontends/tui/fullscreen.ts @@ -23,6 +23,9 @@ export interface FullscreenTuiConfig { modelProviderConfigs?: Partial>; contextThresholdPct?: number; onTransfer?: (target: string) => string | void; + onTools?: () => string; + onResearch?: (task: string) => Promise | string; + onCouncil?: (task: string) => Promise | string; onExit?: () => void; } @@ -51,6 +54,9 @@ export async function startFullscreenTui(config: FullscreenTuiConfig): Promise { } }); + it('prints tools output when /tools is invoked', async () => { + const mockSession = { + id: 'test', + getHistory: () => [], + addMessage: vi.fn(), + clear: vi.fn(), + replaceHistory: vi.fn(), + }; + const onTools = vi.fn(() => 'Available tools (2):\n- file.read\n- council.run'); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + const tui = new MinimalTui({ + session: asSession(mockSession), + modelClient: asModelClient({}), + systemPrompt: 'test', + onTools, + }); + + await minimalTuiPrivates(tui).handleCommand({ type: 'tools' }); + expect(onTools).toHaveBeenCalledOnce(); + expect(logSpy).toHaveBeenCalledWith('Available tools (2):\n- file.read\n- council.run\n'); + } finally { + logSpy.mockRestore(); + } + }); + it('only renders tool activity when verbose mode is enabled', () => { const mockSession = { id: 'test', diff --git a/src/frontends/tui/minimal.ts b/src/frontends/tui/minimal.ts index df8a472..81d4f58 100644 --- a/src/frontends/tui/minimal.ts +++ b/src/frontends/tui/minimal.ts @@ -3,7 +3,7 @@ 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, ToolUseEvent } from '../../backends/native/agent.js'; -import { parseCommand, getHelpText, resolveModelAlias, getCommandCompletions, getCommandTooltip, type Command } from './commands.js'; +import { parseCommand, getHelpText, resolveModelAlias, getCommandCompletions, getCommandTooltip, isToolInventoryQuery, type Command } from './commands.js'; import { renderMarkdown } from './markdown.js'; import type { ModelConfig, ModelProvider } from '../../config/schema.js'; import { MODEL_PROVIDERS } from '../../config/schema.js'; @@ -68,6 +68,9 @@ export interface MinimalTuiConfig { agent?: NativeAgent; onFullscreen?: () => void; onTransfer?: (target: string) => string | void; + onTools?: () => string; + onResearch?: (task: string) => Promise | string; + onCouncil?: (task: string) => Promise | string; localProviders?: Record; modelProviderConfigs?: Partial>; currentLocalProvider?: string; @@ -444,6 +447,18 @@ export class MinimalTui { this.printStatus(); break; + case 'tools': + this.handleToolsCommand(); + break; + + case 'research': + await this.handleResearchCommand(command.task); + break; + + case 'council': + await this.handleCouncilCommand(command.task); + break; + case 'fullscreen': this.config.onFullscreen?.(); break; @@ -520,6 +535,41 @@ export class MinimalTui { this.printStatus(); } + private handleToolsCommand(): void { + if (!this.config.onTools) { + console.log(`${colors.gray}Tools command is not available in this TUI mode.${colors.reset}\n`); + return; + } + const output = this.config.onTools(); + console.log(`${output}\n`); + } + + private async handleResearchCommand(task: string): Promise { + if (!task.trim()) { + console.log(`${colors.gray}Usage: /research ${colors.reset}\n`); + return; + } + if (!this.config.onResearch) { + console.log(`${colors.gray}Research command is not available in this TUI mode.${colors.reset}\n`); + return; + } + const output = await this.config.onResearch(task); + console.log(`${output}\n`); + } + + private async handleCouncilCommand(task: string): Promise { + if (!task.trim()) { + console.log(`${colors.gray}Usage: /council ${colors.reset}\n`); + return; + } + if (!this.config.onCouncil) { + console.log(`${colors.gray}Council command is not available in this TUI mode.${colors.reset}\n`); + return; + } + const output = await this.config.onCouncil(task); + console.log(`${output}\n`); + } + private handleContextCommand(): void { const history = this.config.session.getHistory(); const estimated = estimateMessageTokens(history); @@ -1255,6 +1305,13 @@ export class MinimalTui { this.startBusyIndicator(); try { + if (this.config.onTools && isToolInventoryQuery(content)) { + this.stopBusyIndicator(); + console.log(this.config.onTools()); + console.log(); + return; + } + // Use agent if available (supports tool loop) if (this.config.agent) { let cancelRequested = false;