Unify TUI slash commands and harden tool inventory responses
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <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)
|
||||
/research <task> Delegate a task to the configured research agent
|
||||
/council <task> 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<string, string> = {
|
||||
'/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',
|
||||
|
||||
@@ -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<Record<ModelProvider, ModelConfig>>;
|
||||
contextThresholdPct?: number;
|
||||
onTransfer?: (target: string) => string | void;
|
||||
onTools?: () => string;
|
||||
onResearch?: (task: string) => Promise<string> | string;
|
||||
onCouncil?: (task: string) => Promise<string> | 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 <question or task>');
|
||||
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 <question or task>');
|
||||
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 = [];
|
||||
|
||||
@@ -23,6 +23,9 @@ export interface FullscreenTuiConfig {
|
||||
modelProviderConfigs?: Partial<Record<ModelProvider, ModelConfig>>;
|
||||
contextThresholdPct?: number;
|
||||
onTransfer?: (target: string) => string | void;
|
||||
onTools?: () => string;
|
||||
onResearch?: (task: string) => Promise<string> | string;
|
||||
onCouncil?: (task: string) => Promise<string> | string;
|
||||
onExit?: () => void;
|
||||
}
|
||||
|
||||
@@ -51,6 +54,9 @@ export async function startFullscreenTui(config: FullscreenTuiConfig): Promise<v
|
||||
modelProviderConfigs: config.modelProviderConfigs,
|
||||
contextThresholdPct: config.contextThresholdPct,
|
||||
onTransfer: config.onTransfer,
|
||||
onTools: config.onTools,
|
||||
onResearch: config.onResearch,
|
||||
onCouncil: config.onCouncil,
|
||||
onExit: config.onExit,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -371,6 +371,32 @@ describe('MinimalTui backend command', () => {
|
||||
}
|
||||
});
|
||||
|
||||
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',
|
||||
|
||||
@@ -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> | string;
|
||||
onCouncil?: (task: string) => Promise<string> | string;
|
||||
localProviders?: Record<string, ModelConfig>;
|
||||
modelProviderConfigs?: Partial<Record<ModelProvider, ModelConfig>>;
|
||||
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<void> {
|
||||
if (!task.trim()) {
|
||||
console.log(`${colors.gray}Usage: /research <question or task>${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<void> {
|
||||
if (!task.trim()) {
|
||||
console.log(`${colors.gray}Usage: /council <question or task>${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;
|
||||
|
||||
Reference in New Issue
Block a user