fix(tui): align slash command parsing and handlers
This commit is contained in:
@@ -94,6 +94,11 @@ describe('parseCommand', () => {
|
|||||||
expect(parseCommand('/queue set mode followup')).toEqual({ type: 'queue', action: 'set', args: 'mode followup' });
|
expect(parseCommand('/queue set mode followup')).toEqual({ type: 'queue', action: 'set', args: 'mode followup' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('parses /elevate command', () => {
|
||||||
|
expect(parseCommand('/elevate')).toEqual({ type: 'elevate' });
|
||||||
|
expect(parseCommand('/elevate 10m test --yes')).toEqual({ type: 'elevate', args: '10m test --yes' });
|
||||||
|
});
|
||||||
|
|
||||||
it('parses regular message', () => {
|
it('parses regular message', () => {
|
||||||
expect(parseCommand('Hello Flynn')).toEqual({ type: 'message', content: 'Hello Flynn' });
|
expect(parseCommand('Hello Flynn')).toEqual({ type: 'message', content: 'Hello Flynn' });
|
||||||
});
|
});
|
||||||
@@ -114,6 +119,7 @@ describe('getHelpText', () => {
|
|||||||
expect(help).toContain('/usage');
|
expect(help).toContain('/usage');
|
||||||
expect(help).toContain('/verbose');
|
expect(help).toContain('/verbose');
|
||||||
expect(help).toContain('/queue');
|
expect(help).toContain('/queue');
|
||||||
|
expect(help).toContain('/elevate');
|
||||||
expect(help).toContain('/quit');
|
expect(help).toContain('/quit');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type Command =
|
|||||||
| { type: 'transfer'; target: string }
|
| { type: 'transfer'; target: string }
|
||||||
| { type: 'pair'; action?: 'generate' | 'list' | 'revoke'; args?: string }
|
| { type: 'pair'; action?: 'generate' | 'list' | 'revoke'; args?: string }
|
||||||
| { type: 'queue'; action?: 'show' | 'set' | 'reset'; args?: string }
|
| { type: 'queue'; action?: 'show' | 'set' | 'reset'; args?: string }
|
||||||
|
| { type: 'elevate'; args?: string }
|
||||||
| { type: 'message'; content: string };
|
| { type: 'message'; content: string };
|
||||||
|
|
||||||
export function parseCommand(input: string): Command | null {
|
export function parseCommand(input: string): Command | null {
|
||||||
@@ -130,6 +131,15 @@ export function parseCommand(input: string): Command | null {
|
|||||||
return { type: 'queue', action: 'set', args };
|
return { type: 'queue', action: 'set', args };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Elevate
|
||||||
|
if (trimmed === '/elevate') {
|
||||||
|
return { type: 'elevate' };
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith('/elevate ')) {
|
||||||
|
const args = trimmed.slice('/elevate '.length).trim();
|
||||||
|
return { type: 'elevate', args };
|
||||||
|
}
|
||||||
|
|
||||||
// Regular message
|
// Regular message
|
||||||
return { type: 'message', content: trimmed };
|
return { type: 'message', content: trimmed };
|
||||||
}
|
}
|
||||||
@@ -148,6 +158,7 @@ Commands:
|
|||||||
/queue Show queue policy for this session
|
/queue Show queue policy for this session
|
||||||
/queue set <k> <v> Set queue override (mode/cap/overflow/debounce_ms/summarize_overflow)
|
/queue set <k> <v> Set queue override (mode/cap/overflow/debounce_ms/summarize_overflow)
|
||||||
/queue reset Clear queue overrides for this session
|
/queue reset Clear queue overrides for this session
|
||||||
|
/elevate [args] Show or manage elevated mode
|
||||||
/reset, /clear, /new Clear conversation history
|
/reset, /clear, /new Clear conversation history
|
||||||
/compact Compact conversation history
|
/compact Compact conversation history
|
||||||
/usage Show token usage and estimated cost
|
/usage Show token usage and estimated cost
|
||||||
@@ -180,6 +191,7 @@ export const SLASH_COMMANDS = [
|
|||||||
'/login',
|
'/login',
|
||||||
'/pair',
|
'/pair',
|
||||||
'/queue',
|
'/queue',
|
||||||
|
'/elevate',
|
||||||
'/transfer',
|
'/transfer',
|
||||||
'/quit',
|
'/quit',
|
||||||
'/exit',
|
'/exit',
|
||||||
@@ -202,6 +214,7 @@ export const COMMAND_TOOLTIPS: Record<string, string> = {
|
|||||||
'/login': 'Authenticate with GitHub/OpenAI/Anthropic (OAuth/token or API key) or Z.AI (API key store)',
|
'/login': 'Authenticate with GitHub/OpenAI/Anthropic (OAuth/token or API key) or Z.AI (API key store)',
|
||||||
'/pair': 'Generate/list/revoke DM pairing codes',
|
'/pair': 'Generate/list/revoke DM pairing codes',
|
||||||
'/queue': 'Show or update per-session queue policy',
|
'/queue': 'Show or update per-session queue policy',
|
||||||
|
'/elevate': 'Show or manage elevated mode',
|
||||||
'/transfer': 'Transfer session to another frontend',
|
'/transfer': 'Transfer session to another frontend',
|
||||||
'/quit': 'Exit TUI',
|
'/quit': 'Exit TUI',
|
||||||
'/exit': 'Exit TUI',
|
'/exit': 'Exit TUI',
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export function App({
|
|||||||
const [streamingContent, setStreamingContent] = useState('');
|
const [streamingContent, setStreamingContent] = useState('');
|
||||||
const [scrollOffset, setScrollOffset] = useState(0);
|
const [scrollOffset, setScrollOffset] = useState(0);
|
||||||
const [tokenUsage, setTokenUsage] = useState<TokenUsage>({ inputTokens: 0, outputTokens: 0 });
|
const [tokenUsage, setTokenUsage] = useState<TokenUsage>({ inputTokens: 0, outputTokens: 0 });
|
||||||
|
const [verbose, setVerbose] = useState(false);
|
||||||
const [currentModel, setCurrentModel] = useState(() => {
|
const [currentModel, setCurrentModel] = useState(() => {
|
||||||
if (!modelRouter) {return model;}
|
if (!modelRouter) {return model;}
|
||||||
return modelRouter.getLabel(modelRouter.getTier());
|
return modelRouter.getLabel(modelRouter.getTier());
|
||||||
@@ -227,6 +228,24 @@ export function App({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'compact': {
|
||||||
|
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Compact command is not available in fullscreen TUI mode.' })]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'usage': {
|
||||||
|
const text = `Token Usage\n\nTotal: ${tokenUsage.inputTokens} in / ${tokenUsage.outputTokens} out`;
|
||||||
|
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: text })]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'verbose': {
|
||||||
|
const next = !verbose;
|
||||||
|
setVerbose(next);
|
||||||
|
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Verbose mode: ${next ? 'on' : 'off'}` })]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
case 'model': {
|
case 'model': {
|
||||||
if (!modelRouter) {
|
if (!modelRouter) {
|
||||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Model switching not available.' })]);
|
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Model switching not available.' })]);
|
||||||
@@ -421,8 +440,20 @@ export function App({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'backend':
|
||||||
|
case 'login':
|
||||||
|
case 'pair':
|
||||||
|
case 'elevate':
|
||||||
|
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `/${command.type} is not supported in fullscreen mode.` })]);
|
||||||
|
return;
|
||||||
|
|
||||||
case 'message':
|
case 'message':
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
default: {
|
||||||
|
const exhaustive: never = command;
|
||||||
|
throw new Error(`Unhandled command: ${JSON.stringify(exhaustive)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.type !== 'message' || isStreaming) {
|
if (command.type !== 'message' || isStreaming) {
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export class MinimalTui {
|
|||||||
private activePromptCancel: (() => void) | null = null;
|
private activePromptCancel: (() => void) | null = null;
|
||||||
private activeOperationCancel: (() => void) | null = null;
|
private activeOperationCancel: (() => void) | null = null;
|
||||||
private keypressHandler: ((char: string, key: readline.Key) => void) | null = null;
|
private keypressHandler: ((char: string, key: readline.Key) => void) | null = null;
|
||||||
|
private verbose = false;
|
||||||
|
|
||||||
constructor(private config: MinimalTuiConfig) {}
|
constructor(private config: MinimalTuiConfig) {}
|
||||||
|
|
||||||
@@ -322,6 +323,18 @@ export class MinimalTui {
|
|||||||
this.config.onFullscreen?.();
|
this.config.onFullscreen?.();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'compact':
|
||||||
|
await this.handleCompactCommand();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'usage':
|
||||||
|
this.handleUsageCommand();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'verbose':
|
||||||
|
this.handleVerboseCommand();
|
||||||
|
break;
|
||||||
|
|
||||||
case 'model':
|
case 'model':
|
||||||
this.handleModelCommand(command.name, command.providerModel);
|
this.handleModelCommand(command.name, command.providerModel);
|
||||||
break;
|
break;
|
||||||
@@ -342,6 +355,10 @@ export class MinimalTui {
|
|||||||
this.handleQueueCommand(command.action, command.args);
|
this.handleQueueCommand(command.action, command.args);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'elevate':
|
||||||
|
this.handleElevateCommand(command.args);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'transfer':
|
case 'transfer':
|
||||||
this.config.onTransfer?.(command.target);
|
this.config.onTransfer?.(command.target);
|
||||||
break;
|
break;
|
||||||
@@ -349,9 +366,27 @@ export class MinimalTui {
|
|||||||
case 'message':
|
case 'message':
|
||||||
await this.handleMessage(command.content);
|
await this.handleMessage(command.content);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
default: {
|
||||||
|
const exhaustive: never = command;
|
||||||
|
throw new Error(`Unhandled command: ${JSON.stringify(exhaustive)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleCompactCommand(): Promise<void> {
|
||||||
|
console.log(`${colors.gray}Compact command is not available in this TUI mode.${colors.reset}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleUsageCommand(): void {
|
||||||
|
this.printStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleVerboseCommand(): void {
|
||||||
|
this.verbose = !this.verbose;
|
||||||
|
console.log(`${colors.gray}Verbose mode:${colors.reset} ${this.verbose ? 'on' : 'off'}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
private handleQueueCommand(action?: 'show' | 'set' | 'reset', args?: string): void {
|
private handleQueueCommand(action?: 'show' | 'set' | 'reset', args?: string): void {
|
||||||
if (!action || action === 'show') {
|
if (!action || action === 'show') {
|
||||||
const mode = this.config.session.getConfig('queue.mode') ?? 'collect';
|
const mode = this.config.session.getConfig('queue.mode') ?? 'collect';
|
||||||
@@ -451,6 +486,97 @@ export class MinimalTui {
|
|||||||
console.log(`${colors.gray}Unknown queue key. Use mode, cap, overflow, debounce_ms, summarize_overflow.${colors.reset}\n`);
|
console.log(`${colors.gray}Unknown queue key. Use mode, cap, overflow, debounce_ms, summarize_overflow.${colors.reset}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleElevateCommand(args?: string): void {
|
||||||
|
const untilRaw = this.config.session.getConfig('elevation.until_ms');
|
||||||
|
const reason = this.config.session.getConfig('elevation.reason') ?? '';
|
||||||
|
const id = this.config.session.getConfig('elevation.id') ?? '';
|
||||||
|
|
||||||
|
const showStatus = () => {
|
||||||
|
if (!untilRaw || !id) {
|
||||||
|
console.log(`${colors.gray}Elevated mode: off${colors.reset}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const untilMs = Number.parseInt(untilRaw, 10);
|
||||||
|
if (!Number.isFinite(untilMs) || untilMs <= Date.now()) {
|
||||||
|
this.config.session.deleteConfig('elevation.until_ms');
|
||||||
|
this.config.session.deleteConfig('elevation.reason');
|
||||||
|
this.config.session.deleteConfig('elevation.id');
|
||||||
|
console.log(`${colors.gray}Elevated mode: off${colors.reset}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const remainingSec = Math.ceil((untilMs - Date.now()) / 1000);
|
||||||
|
console.log(`${colors.gray}Elevated mode: on (${remainingSec}s remaining)${reason ? ` - ${reason}` : ''}${colors.reset}\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const raw = (args ?? '').trim();
|
||||||
|
if (!raw) {
|
||||||
|
showStatus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = raw.split(/\s+/);
|
||||||
|
const hasYes = parts.includes('--yes') || parts.includes('--confirm');
|
||||||
|
const filtered = parts.filter((p) => p !== '--yes' && p !== '--confirm');
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
console.log(`${colors.gray}Usage: /elevate <duration> <reason...> --yes | /elevate off --yes${colors.reset}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtered[0] === 'off') {
|
||||||
|
if (!hasYes) {
|
||||||
|
console.log(`${colors.gray}Refusing to disable elevation without explicit confirmation. Use: /elevate off --yes${colors.reset}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.config.session.deleteConfig('elevation.until_ms');
|
||||||
|
this.config.session.deleteConfig('elevation.reason');
|
||||||
|
this.config.session.deleteConfig('elevation.id');
|
||||||
|
console.log(`${colors.gray}Elevated mode: off${colors.reset}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasYes) {
|
||||||
|
console.log(`${colors.gray}Refusing to enable elevation without explicit confirmation. Use: /elevate <duration> <reason...> --yes${colors.reset}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttlMs = this.parseDurationToMs(filtered[0]);
|
||||||
|
if (!ttlMs) {
|
||||||
|
console.log(`${colors.gray}Invalid duration. Use one of: 30s, 10m, 1h, 1d${colors.reset}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reasonText = filtered.slice(1).join(' ').trim();
|
||||||
|
const untilMs = Date.now() + ttlMs;
|
||||||
|
const newId = `${untilMs}`;
|
||||||
|
this.config.session.setConfig('elevation.until_ms', String(untilMs));
|
||||||
|
this.config.session.setConfig('elevation.id', newId);
|
||||||
|
if (reasonText) {
|
||||||
|
this.config.session.setConfig('elevation.reason', reasonText);
|
||||||
|
} else {
|
||||||
|
this.config.session.deleteConfig('elevation.reason');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${colors.gray}Elevated mode: on until ${new Date(untilMs).toISOString()}${colors.reset}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseDurationToMs(value: string): number | null {
|
||||||
|
const m = value.match(/^(\d+)([smhd])$/i);
|
||||||
|
if (!m) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const n = Number.parseInt(m[1], 10);
|
||||||
|
if (!Number.isFinite(n) || n <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const unit = m[2].toLowerCase();
|
||||||
|
if (unit === 's') {return n * 1000;}
|
||||||
|
if (unit === 'm') {return n * 60_000;}
|
||||||
|
if (unit === 'h') {return n * 3_600_000;}
|
||||||
|
if (unit === 'd') {return n * 86_400_000;}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private handleModelCommand(name?: string, providerModel?: string): void {
|
private handleModelCommand(name?: string, providerModel?: string): void {
|
||||||
const router = this.config.modelRouter;
|
const router = this.config.modelRouter;
|
||||||
if (!router) {
|
if (!router) {
|
||||||
|
|||||||
Reference in New Issue
Block a user