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' });
|
||||
});
|
||||
|
||||
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,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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user