From 1d16cd54e6a53aa71486df30601ad5e852a474d3 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 16 Feb 2026 12:22:40 -0800 Subject: [PATCH] fix(tui): align slash command parsing and handlers --- src/frontends/tui/commands.test.ts | 6 ++ src/frontends/tui/commands.ts | 13 +++ src/frontends/tui/components/App.tsx | 31 +++++++ src/frontends/tui/minimal.ts | 126 +++++++++++++++++++++++++++ 4 files changed, 176 insertions(+) diff --git a/src/frontends/tui/commands.test.ts b/src/frontends/tui/commands.test.ts index 9535856..44039c3 100644 --- a/src/frontends/tui/commands.test.ts +++ b/src/frontends/tui/commands.test.ts @@ -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'); }); }); diff --git a/src/frontends/tui/commands.ts b/src/frontends/tui/commands.ts index 22874f2..597feff 100644 --- a/src/frontends/tui/commands.ts +++ b/src/frontends/tui/commands.ts @@ -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 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 = { '/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', diff --git a/src/frontends/tui/components/App.tsx b/src/frontends/tui/components/App.tsx index fd672e6..a64e7f9 100644 --- a/src/frontends/tui/components/App.tsx +++ b/src/frontends/tui/components/App.tsx @@ -70,6 +70,7 @@ export function App({ const [streamingContent, setStreamingContent] = useState(''); const [scrollOffset, setScrollOffset] = useState(0); const [tokenUsage, setTokenUsage] = useState({ 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) { diff --git a/src/frontends/tui/minimal.ts b/src/frontends/tui/minimal.ts index 4805d05..b6c14f8 100644 --- a/src/frontends/tui/minimal.ts +++ b/src/frontends/tui/minimal.ts @@ -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 { + 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 --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 --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) {