Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/components/chat/appUiCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -44,6 +45,7 @@ export type AppUiCommandActions = {
startNewChat: () => void;
openSettings: () => void;
openModelPicker: () => void;
openCostModal: () => void;
};

export const APP_UI_COMMANDS: readonly AppUiCommand[] = [
Expand Down Expand Up @@ -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(
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -184,10 +196,12 @@ export function getTuiOnlyCommandNotice(commandName: string): string | null {
* wins.
*/
export const APP_UNSUPPORTED_COMMAND_HINTS: Readonly<Record<string, string>> = {
'/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 {
Expand Down
53 changes: 33 additions & 20 deletions src/components/chat/hooks/useChatComposerState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,14 +287,6 @@ export function useChatComposerState({
});
break;

case 'cost': {
setCommandModalPayload({
kind: 'cost',
data: (data || {}) as CostCommandData,
});
break;
}

case 'status': {
setCommandModalPayload({
kind: 'status',
Expand Down Expand Up @@ -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<string, unknown>
: 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
Expand All @@ -495,9 +507,10 @@ export function useChatComposerState({
openModelPicker: () => {
setModelPickerTrigger((previous) => previous + 1);
},
openCostModal: showCostModal,
});
},
[onShowSettings, paletteOps],
[onShowSettings, paletteOps, showCostModal],
);
const handleAppCommand = useCallback(
(command: SlashCommand) => {
Expand Down
45 changes: 43 additions & 2 deletions src/components/chat/tests/appUiCommands.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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`);
}

Expand All @@ -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`);
}
});
Expand Down
1 change: 0 additions & 1 deletion src/components/chat/tests/commandGatePolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ test('reads run without asking, in every form', () => {
for (const form of [
'/dump',
'/jobs',
'/transcript',
'/context',
'/usage',
'/tools',
Expand Down