diff --git a/src/components/chat/appUiCommands.ts b/src/components/chat/appUiCommands.ts index 866c583..872409e 100644 --- a/src/components/chat/appUiCommands.ts +++ b/src/components/chat/appUiCommands.ts @@ -22,7 +22,8 @@ export type AppUiCommandActionId = | 'open-session-picker' | 'start-new-chat' | 'open-settings' - | 'open-model-picker'; + | 'open-model-picker' + | 'open-cost-modal'; export type AppUiCommand = { name: string; @@ -44,6 +45,7 @@ export type AppUiCommandActions = { startNewChat: () => void; openSettings: () => void; openModelPicker: () => void; + openCostModal: () => void; }; export const APP_UI_COMMANDS: readonly AppUiCommand[] = [ @@ -83,6 +85,13 @@ export const APP_UI_COMMANDS: readonly AppUiCommand[] = [ actionId: 'open-model-picker', interceptWithArgs: false, }, + { + name: '/cost', + description: 'Review token usage for the active session', + namespace: 'app', + type: 'app', + actionId: 'open-cost-modal', + }, ]; const APP_UI_COMMANDS_BY_NAME = new Map( @@ -109,6 +118,9 @@ export function runAppUiCommand(command: AppUiCommand, actions: AppUiCommandActi case 'open-model-picker': actions.openModelPicker(); break; + case 'open-cost-modal': + actions.openCostModal(); + break; } } @@ -184,10 +196,12 @@ export function getTuiOnlyCommandNotice(commandName: string): string | null { * wins. */ export const APP_UNSUPPORTED_COMMAND_HINTS: Readonly> = { + '/init': 'Generating AGENTS.md requires the GJC terminal until headless init is available.', '/move': 'Sessions follow the project you select — switch projects from the sidebar instead.', '/notify on': 'The app delivers its own notifications — manage them in Settings → Notifications.', '/notify off': 'The app delivers its own notifications — manage them in Settings → Notifications.', '/skill:team': 'It drives workers through tmux panes, which this app cannot show. Run `gjc` in a terminal for that, or use `/skill:ultragoal` here.', + '/transcript': 'Use the current chat and session history; the runtime transcript browser is TUI-only.', }; export function getAppUnsupportedCommandNotice(commandForm: string): string | null { diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index d697e08..892f5aa 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -287,14 +287,6 @@ export function useChatComposerState({ }); break; - case 'cost': { - setCommandModalPayload({ - kind: 'cost', - data: (data || {}) as CostCommandData, - }); - break; - } - case 'status': { setCommandModalPayload({ kind: 'status', @@ -461,17 +453,37 @@ export function useChatComposerState({ }, [onLogin, selectedProject]); const showCostModal = useCallback(() => { - executeCommand( - { - name: '/cost', - description: 'Display token usage information', - namespace: 'builtin', - metadata: { type: 'builtin' }, - } as SlashCommand, - '/cost', - { preserveInput: true }, - ); - }, [executeCommand]); + const breakdown = + tokenBudget?.breakdown && typeof tokenBudget.breakdown === 'object' + ? tokenBudget.breakdown as Record + : null; + const input = Number(tokenBudget?.inputTokens ?? breakdown?.input); + const output = Number(tokenBudget?.outputTokens ?? breakdown?.output); + const used = Number(tokenBudget?.used); + const total = Number(tokenBudget?.total); + + setCommandModalPayload({ + kind: 'cost', + data: { + tokenUsage: { + used: Number.isFinite(used) + ? used + : (Number.isFinite(input) ? input : 0) + (Number.isFinite(output) ? output : 0), + total: Number.isFinite(total) ? total : 0, + }, + ...(Number.isFinite(input) || Number.isFinite(output) + ? { + tokenBreakdown: { + input: Number.isFinite(input) ? input : 0, + output: Number.isFinite(output) ? output : 0, + }, + } + : {}), + provider: typeof tokenBudget?.provider === 'string' ? tokenBudget.provider : 'gjc', + model: typeof tokenBudget?.model === 'string' ? tokenBudget.model : gjcModel, + }, + }); + }, [gjcModel, tokenBudget]); // App-level slash commands (/resume, /sessions, /new, /settings) run local // UI actions instead of reaching the provider. Falls back to no-ops when no @@ -495,9 +507,10 @@ export function useChatComposerState({ openModelPicker: () => { setModelPickerTrigger((previous) => previous + 1); }, + openCostModal: showCostModal, }); }, - [onShowSettings, paletteOps], + [onShowSettings, paletteOps, showCostModal], ); const handleAppCommand = useCallback( (command: SlashCommand) => { diff --git a/src/components/chat/tests/appUiCommands.test.tsx b/src/components/chat/tests/appUiCommands.test.tsx index fc96788..54dab84 100644 --- a/src/components/chat/tests/appUiCommands.test.tsx +++ b/src/components/chat/tests/appUiCommands.test.tsx @@ -68,6 +68,7 @@ test('runAppUiCommand dispatches each action id to its action', () => { startNewChat: () => { calls.push('start-new-chat'); }, openSettings: () => { calls.push('open-settings'); }, openModelPicker: () => { calls.push('open-model-picker'); }, + openCostModal: () => { calls.push('open-cost-modal'); }, }; for (const command of APP_UI_COMMANDS) { @@ -121,6 +122,30 @@ test('typed /resume is intercepted as an app command and never sent to the model assert.deepEqual(addedMessages, []); }); +test('typed /cost is an app command and does not call legacy REST execution', async () => { + const sentMessages: unknown[] = []; + const addedMessages: unknown[] = []; + const composer = captureComposer(sentMessages, addedMessages); + const originalFetch = globalThis.fetch; + let fetchCalls = 0; + globalThis.fetch = async () => { + fetchCalls += 1; + throw new Error('app command must not fetch'); + }; + + try { + composer.handleVoiceTranscript('/cost'); + await composer.handleSubmit(submitEvent); + } finally { + globalThis.fetch = originalFetch; + } + + assert.equal(findAppUiCommand('/cost')?.actionId, 'open-cost-modal'); + assert.equal(fetchCalls, 0); + assert.deepEqual(sentMessages, []); + assert.deepEqual(addedMessages, []); +}); + test('typed TUI-only command gets a local notice instead of a model prompt', async () => { const sentMessages: unknown[] = []; const addedMessages: unknown[] = []; @@ -213,6 +238,22 @@ test('/move is declined locally and names the app affordance', async () => { } }); +test('/init and /transcript are declined locally without exposing upstream session data', async () => { + for (const form of ['/init', '/init extra', '/transcript', '/transcript extra']) { + const sentMessages: unknown[] = []; + const addedMessages: unknown[] = []; + const composer = captureComposer(sentMessages, addedMessages); + + composer.handleVoiceTranscript(form); + await composer.handleSubmit(submitEvent); + + assert.deepEqual(sentMessages, [], `${form} must not be sent`); + assert.equal(addedMessages.length, 1); + const notice = (addedMessages[0] as { content: string }).content; + assert.match(notice, /not available in the app/); + } +}); + test('aliases of TUI-only commands answer exactly like their canonical name', async () => { for (const [alias, canonical] of [['/bg', '/background'], ['/quit', '/exit']] as const) { const sentMessages: unknown[] = []; @@ -313,7 +354,7 @@ test('/notify on and off are answered locally, other verbs still dispatch', asyn */ test('isAppUsableCommand hides exactly what the composer answers locally', () => { // Advertised: everything the app really runs. - for (const name of ['/model', '/export', '/clear', '/memory', '/init', '/resume', '/settings']) { + for (const name of ['/model', '/export', '/clear', '/memory', '/resume', '/settings', '/cost']) { assert.equal(isAppUsableCommand(name), true, `${name} should stay in the menu`); } @@ -322,7 +363,7 @@ test('isAppUsableCommand hides exactly what the composer answers locally', () => for (const name of Object.keys(TUI_ONLY_COMMAND_HINTS)) { assert.equal(isAppUsableCommand(name), false, `${name} should be hidden`); } - for (const name of ['/move', '/skill:team', '/bg', '/quit']) { + for (const name of ['/init', '/move', '/transcript', '/skill:team', '/bg', '/quit']) { assert.equal(isAppUsableCommand(name), false, `${name} should be hidden`); } }); diff --git a/src/components/chat/tests/commandGatePolicy.test.ts b/src/components/chat/tests/commandGatePolicy.test.ts index 3318573..417f1b1 100644 --- a/src/components/chat/tests/commandGatePolicy.test.ts +++ b/src/components/chat/tests/commandGatePolicy.test.ts @@ -90,7 +90,6 @@ test('reads run without asking, in every form', () => { for (const form of [ '/dump', '/jobs', - '/transcript', '/context', '/usage', '/tools',