fix(tui): align slash command parsing and handlers

This commit is contained in:
William Valentin
2026-02-16 12:22:40 -08:00
parent fd7ad7bfb0
commit 1d16cd54e6
4 changed files with 176 additions and 0 deletions
+6
View File
@@ -94,6 +94,11 @@ describe('parseCommand', () => {
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', () => {
expect(parseCommand('Hello Flynn')).toEqual({ type: 'message', content: 'Hello Flynn' });
});
@@ -114,6 +119,7 @@ describe('getHelpText', () => {
expect(help).toContain('/usage');
expect(help).toContain('/verbose');
expect(help).toContain('/queue');
expect(help).toContain('/elevate');
expect(help).toContain('/quit');
});
});
+13
View File
@@ -13,6 +13,7 @@ export type Command =
| { type: 'transfer'; target: string }
| { type: 'pair'; action?: 'generate' | 'list' | 'revoke'; args?: string }
| { type: 'queue'; action?: 'show' | 'set' | 'reset'; args?: string }
| { type: 'elevate'; args?: string }
| { type: 'message'; content: string };
export function parseCommand(input: string): Command | null {
@@ -130,6 +131,15 @@ export function parseCommand(input: string): Command | null {
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
return { type: 'message', content: trimmed };
}
@@ -148,6 +158,7 @@ Commands:
/queue Show queue policy for this session
/queue set <k> <v> Set queue override (mode/cap/overflow/debounce_ms/summarize_overflow)
/queue reset Clear queue overrides for this session
/elevate [args] Show or manage elevated mode
/reset, /clear, /new Clear conversation history
/compact Compact conversation history
/usage Show token usage and estimated cost
@@ -180,6 +191,7 @@ export const SLASH_COMMANDS = [
'/login',
'/pair',
'/queue',
'/elevate',
'/transfer',
'/quit',
'/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)',
'/pair': 'Generate/list/revoke DM pairing codes',
'/queue': 'Show or update per-session queue policy',
'/elevate': 'Show or manage elevated mode',
'/transfer': 'Transfer session to another frontend',
'/quit': 'Exit TUI',
'/exit': 'Exit TUI',
+31
View File
@@ -70,6 +70,7 @@ export function App({
const [streamingContent, setStreamingContent] = useState('');
const [scrollOffset, setScrollOffset] = useState(0);
const [tokenUsage, setTokenUsage] = useState<TokenUsage>({ inputTokens: 0, outputTokens: 0 });
const [verbose, setVerbose] = useState(false);
const [currentModel, setCurrentModel] = useState(() => {
if (!modelRouter) {return model;}
return modelRouter.getLabel(modelRouter.getTier());
@@ -227,6 +228,24 @@ export function App({
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': {
if (!modelRouter) {
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Model switching not available.' })]);
@@ -421,8 +440,20 @@ export function App({
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':
break;
default: {
const exhaustive: never = command;
throw new Error(`Unhandled command: ${JSON.stringify(exhaustive)}`);
}
}
if (command.type !== 'message' || isStreaming) {
+126
View File
@@ -78,6 +78,7 @@ export class MinimalTui {
private activePromptCancel: (() => void) | null = null;
private activeOperationCancel: (() => void) | null = null;
private keypressHandler: ((char: string, key: readline.Key) => void) | null = null;
private verbose = false;
constructor(private config: MinimalTuiConfig) {}
@@ -322,6 +323,18 @@ export class MinimalTui {
this.config.onFullscreen?.();
break;
case 'compact':
await this.handleCompactCommand();
break;
case 'usage':
this.handleUsageCommand();
break;
case 'verbose':
this.handleVerboseCommand();
break;
case 'model':
this.handleModelCommand(command.name, command.providerModel);
break;
@@ -342,6 +355,10 @@ export class MinimalTui {
this.handleQueueCommand(command.action, command.args);
break;
case 'elevate':
this.handleElevateCommand(command.args);
break;
case 'transfer':
this.config.onTransfer?.(command.target);
break;
@@ -349,9 +366,27 @@ export class MinimalTui {
case 'message':
await this.handleMessage(command.content);
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 {
if (!action || action === 'show') {
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`);
}
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 {
const router = this.config.modelRouter;
if (!router) {