Compare commits

..

6 Commits

Author SHA1 Message Date
Simos Mikelatos
ff4f8d6ac7 fix: pass effort through external agent api 2026-07-01 04:44:27 +00:00
Simos Mikelatos
eb08aadaa0 fix: reconcile provider effort after model changes 2026-07-01 00:08:32 +00:00
Simos Mikelatos
0206a1f6aa refactor: generic provider effort handling 2026-06-30 23:56:01 +00:00
Simos Mikelatos
d618abb075 feat: add Claude and Codex effort controls 2026-06-30 23:29:57 +00:00
Haile
2ebe64f218 fix: preview video on new tab (#933) 2026-06-29 15:36:31 +02:00
Haile
b6cf33308d fix: resolve mobile shell issues (#923) 2026-06-29 14:19:01 +02:00
34 changed files with 2095 additions and 302 deletions

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
<title>CloudCLI UI</title>
<!-- PWA Manifest -->

View File

@@ -489,7 +489,7 @@
<span class="endpoint-path"><span class="api-url">http://localhost:3001</span>/api/agent</span>
</div>
<p>Trigger an AI agent (Claude, Cursor, or Codex) to work on a project.</p>
<p>Trigger an AI agent (Claude, Cursor, Codex, Gemini, or OpenCode) to work on a project.</p>
<h4>Request Body Parameters</h4>
<table>
@@ -524,7 +524,7 @@
<td><code>provider</code></td>
<td>string</td>
<td><span class="badge badge-optional">Optional</span></td>
<td><code>claude</code>, <code>cursor</code>, or <code>codex</code> (default: <code>claude</code>)</td>
<td><code>claude</code>, <code>cursor</code>, <code>codex</code>, <code>gemini</code>, or <code>opencode</code> (default: <code>claude</code>)</td>
</tr>
<tr>
<td><code>stream</code></td>
@@ -540,6 +540,12 @@
Model identifier for the AI provider (loading from constants...)
</td>
</tr>
<tr>
<td><code>effort</code></td>
<td>string</td>
<td><span class="badge badge-optional">Optional</span></td>
<td>Reasoning effort for Claude and Codex models that expose effort metadata. Use <code>default</code> or omit it to let the provider decide.</td>
</tr>
<tr>
<td><code>cleanup</code></td>
<td>boolean</td>

View File

@@ -12,11 +12,13 @@
* - WebSocket message streaming
*/
import { query } from '@anthropic-ai/claude-agent-sdk';
import crypto from 'crypto';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import path from 'path';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { CLAUDE_FALLBACK_MODELS } from './modules/providers/list/claude/claude-models.provider.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js';
@@ -41,6 +43,15 @@ const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEO
const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']);
function resolveClaudeEffort(model, effort, modelsDefinition = CLAUDE_FALLBACK_MODELS) {
const selectedModel = modelsDefinition?.OPTIONS?.find((option) => option.value === model) || null;
const allowedEfforts = selectedModel?.effort?.values
?.map((value) => value.value) || [];
return typeof effort === 'string' && effort !== 'default' && allowedEfforts.includes(effort)
? effort
: undefined;
}
function createRequestId() {
if (typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
@@ -145,13 +156,8 @@ function matchesToolPermission(entry, toolName, input) {
return false;
}
/**
* Maps CLI options to SDK-compatible options format
* @param {Object} options - CLI options
* @returns {Object} SDK-compatible options
*/
function mapCliOptionsToSDK(options = {}) {
const { sessionId, cwd, toolsSettings, permissionMode } = options;
const { sessionId, cwd, toolsSettings, permissionMode, effort } = options;
const sdkOptions = {};
@@ -163,32 +169,26 @@ function mapCliOptionsToSDK(options = {}) {
// which does not reliably follow npm's shell wrappers like cross-spawn does.
sdkOptions.pathToClaudeCodeExecutable = resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH);
// Map working directory
if (cwd) {
sdkOptions.cwd = cwd;
}
// Map permission mode
if (permissionMode && permissionMode !== 'default') {
sdkOptions.permissionMode = permissionMode;
}
// Map tool settings
const settings = toolsSettings || {
allowedTools: [],
disallowedTools: [],
skipPermissions: false
};
// Handle tool permissions
if (settings.skipPermissions && permissionMode !== 'plan') {
// When skipping permissions, use bypassPermissions mode
sdkOptions.permissionMode = 'bypassPermissions';
}
let allowedTools = [...(settings.allowedTools || [])];
// Add plan mode default tools
if (permissionMode === 'plan') {
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
for (const tool of planModeTools) {
@@ -207,22 +207,24 @@ function mapCliOptionsToSDK(options = {}) {
sdkOptions.disallowedTools = settings.disallowedTools || [];
// Map model (default to sonnet)
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m], fable
sdkOptions.model = options.model || CLAUDE_FALLBACK_MODELS.DEFAULT;
// Model logged at query start below
// Map system prompt configuration
const resolvedEffort = resolveClaudeEffort(
sdkOptions.model,
effort,
options.effortModels || CLAUDE_FALLBACK_MODELS,
);
if (resolvedEffort) {
sdkOptions.effort = resolvedEffort;
}
sdkOptions.systemPrompt = {
type: 'preset',
preset: 'claude_code' // Required to use CLAUDE.md
preset: 'claude_code'
};
// Map setting sources for CLAUDE.md loading
// This loads CLAUDE.md from project, user (~/.config/claude/CLAUDE.md), and local directories
sdkOptions.settingSources = ['project', 'user', 'local'];
// Map resume session
if (sessionId) {
sdkOptions.resume = sessionId;
}
@@ -533,20 +535,24 @@ async function queryClaudeSDK(command, options = {}, ws) {
sessionId,
options.model,
);
let effortModels = CLAUDE_FALLBACK_MODELS;
try {
effortModels = (await providerModelsService.getProviderModels('claude')).models;
} catch (error) {
console.warn('[Claude SDK] Unable to load provider models for effort validation:', error);
}
// Map CLI options to SDK format
const sdkOptions = mapCliOptionsToSDK({
...options,
model: resolvedModel || options.model,
effortModels,
});
// Load MCP configuration
const mcpServers = await loadMcpConfig(options.cwd);
if (mcpServers) {
sdkOptions.mcpServers = mcpServers;
}
// Handle images - save to temp files and modify prompt
const imageResult = await handleImages(command, options.images, options.cwd);
const finalCommand = imageResult.modifiedCommand;
tempImagePaths = imageResult.tempImagePaths;
@@ -650,7 +656,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
};
// Set stream-close timeout for interactive tools (Query constructor reads it synchronously). Claude Agent SDK has a default of 5s and this overrides it
// Query constructor reads this synchronously.
const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';

View File

@@ -5,6 +5,7 @@ import type { IProviderModels } from '@/shared/interfaces.js';
import type {
ProviderChangeActiveModelInput,
ProviderCurrentActiveModel,
ProviderModelOption,
ProviderModelsDefinition,
ProviderSessionActiveModelChange,
} from '@/shared/types.js';
@@ -18,27 +19,89 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
{
value: 'default',
label: 'Default (recommended)',
description: 'Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok',
description: 'Use the Claude Code default model (currently Sonnet 4.6)',
effort: {
default: 'high',
values: [
{ value: 'low' },
{ value: 'medium' },
{ value: 'high' },
{ value: 'max' },
],
},
},
{
value: 'fable',
label: 'Fable',
description: 'Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus',
effort: {
default: 'high',
values: [
{ value: 'low' },
{ value: 'medium' },
{ value: 'high' },
{ value: 'xhigh' },
{ value: 'max' },
],
},
},
{
value: "sonnet",
label: "Sonnet",
description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok",
effort: {
default: 'high',
values: [
{ value: 'low' },
{ value: 'medium' },
{ value: 'high' },
{ value: 'max' },
],
},
},
{
value: 'sonnet[1m]',
label: 'Sonnet (1M context)',
description: 'Sonnet 4.6 for long sessions · $3/$15 per Mtok',
effort: {
default: 'high',
values: [
{ value: 'low' },
{ value: 'medium' },
{ value: 'high' },
{ value: 'max' },
],
},
},
{
value: 'opus',
label: 'Opus',
description: 'Opus 4.8 · Best for everyday, complex tasks · ~2× usage vs Sonnet',
effort: {
default: 'high',
values: [
{ value: 'low' },
{ value: 'medium' },
{ value: 'high' },
{ value: 'xhigh' },
{ value: 'max' },
],
},
},
{
value: 'opus[1m]',
label: 'Opus 4.8 (1M context)',
description: 'Opus 4.8 with 1M context · Most capable for complex work · $5/$25 per Mtok',
effort: {
default: 'high',
values: [
{ value: 'low' },
{ value: 'medium' },
{ value: 'high' },
{ value: 'xhigh' },
{ value: 'max' },
],
},
},
{
value: 'haiku',
@@ -48,6 +111,15 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
],
DEFAULT: 'default',
};
export const findClaudeModelOption = (model: string | undefined | null): ProviderModelOption | null => {
const normalizedModel = typeof model === 'string' ? model.trim() : '';
if (!normalizedModel) {
return null;
}
return CLAUDE_FALLBACK_MODELS.OPTIONS.find((option) => option.value === normalizedModel) ?? null;
};
type ClaudeInitEvent = {
sessionId?: string;
session_id?: string;

View File

@@ -21,11 +21,46 @@ import {
export const CODEX_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [
{ value: 'gpt-5.5', label: 'gpt-5.5' },
{ value: 'gpt-5.4', label: 'gpt-5.4' },
{ value: 'gpt-5.4-mini', label: 'gpt-5.4-mini' },
{ value: 'gpt-5.3-codex', label: 'gpt-5.3-codex' },
{ value: 'gpt-5.2', label: 'gpt-5.2' },
{
value: 'gpt-5.5',
label: 'gpt-5.5',
effort: {
default: 'medium',
values: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }, { value: 'xhigh' }],
},
},
{
value: 'gpt-5.4',
label: 'gpt-5.4',
effort: {
default: 'medium',
values: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }, { value: 'xhigh' }],
},
},
{
value: 'gpt-5.4-mini',
label: 'gpt-5.4-mini',
effort: {
default: 'medium',
values: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }, { value: 'xhigh' }],
},
},
{
value: 'gpt-5.3-codex',
label: 'gpt-5.3-codex',
effort: {
default: 'medium',
values: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }, { value: 'xhigh' }],
},
},
{
value: 'gpt-5.2',
label: 'gpt-5.2',
effort: {
default: 'medium',
values: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }, { value: 'xhigh' }],
},
},
],
DEFAULT: 'gpt-5.4',
};
@@ -37,6 +72,11 @@ type CodexCachedModel = {
priority?: number;
visibility?: string;
supported_in_api?: boolean;
default_reasoning_level?: string;
supported_reasoning_levels?: Array<{
effort?: string;
description?: string;
}>;
};
const CODEX_MODELS_CACHE_PATH = path.join(os.homedir(), '.codex', 'models_cache.json');
@@ -51,15 +91,39 @@ const readCodexPriority = (value: unknown): number => (
typeof value === 'number' && Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER
);
const mapCodexModel = (model: CodexCachedModel): ProviderModelOption => ({
value: model.slug as string,
label: readOptionalString(model.display_name) ?? (model.slug as string),
description: readOptionalString(model.description),
});
const mapCodexModel = (model: CodexCachedModel): ProviderModelOption => {
const effortValues = Array.isArray(model.supported_reasoning_levels)
? model.supported_reasoning_levels
.map((level) => {
const value = readOptionalString(level?.effort);
if (!value) {
return null;
}
return {
value,
description: readOptionalString(level?.description),
};
})
.filter((level): level is NonNullable<typeof level> => Boolean(level))
: [];
return {
value: model.slug as string,
label: readOptionalString(model.display_name) ?? (model.slug as string),
description: readOptionalString(model.description),
effort: effortValues.length > 0
? {
default: readOptionalString(model.default_reasoning_level) ?? undefined,
values: effortValues,
}
: undefined,
};
};
const buildCodexModelsDefinition = (models: CodexCachedModel[]): ProviderModelsDefinition => {
const sortedModels = [...models]
.filter((model) => model.visibility !== 'hidden' && model.supported_in_api !== false)
.filter((model) => model.visibility === 'list' && model.supported_in_api !== false)
.sort((left, right) => readCodexPriority(left.priority) - readCodexPriority(right.priority));
const options: ProviderModelOption[] = [];

View File

@@ -21,6 +21,8 @@ type ProviderCapabilities = {
supportsPermissionRequests: boolean;
/** Whether the token-usage endpoint has data for this provider. */
supportsTokenUsage: boolean;
/** Whether the provider runtime can accept model-level reasoning effort. */
supportsEffort: boolean;
};
/**
@@ -38,6 +40,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
supportsAbort: true,
supportsPermissionRequests: true,
supportsTokenUsage: true,
supportsEffort: true,
},
cursor: {
provider: 'cursor',
@@ -47,6 +50,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: false,
supportsEffort: false,
},
codex: {
provider: 'codex',
@@ -56,6 +60,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: true,
supportsEffort: true,
},
gemini: {
provider: 'gemini',
@@ -65,6 +70,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: true,
supportsEffort: false,
},
opencode: {
provider: 'opencode',
@@ -74,6 +80,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: true,
supportsEffort: false,
},
};

View File

@@ -16,7 +16,7 @@ import type {
import { readProviderSessionActiveModelChange } from '@/shared/utils.js';
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
const PROVIDER_MODELS_CACHE_VERSION = 1;
const PROVIDER_MODELS_CACHE_VERSION = 2;
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude', 'gemini']);
type ProviderModelsServiceDependencies = {

View File

@@ -20,7 +20,6 @@ import { providerAuthService } from './modules/providers/services/provider-auth.
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
// Track active sessions
const activeCodexSessions = new Map();
function readUsageNumber(value) {
@@ -228,6 +227,7 @@ export async function queryCodex(command, options = {}, ws) {
cwd,
projectPath,
model,
effort,
permissionMode = 'default'
} = options;
@@ -239,6 +239,12 @@ export async function queryCodex(command, options = {}, ws) {
const workingDirectory = cwd || projectPath || process.cwd();
const { sandboxMode, approvalPolicy } = mapPermissionModeToCodexOptions(permissionMode);
const catalog = (await providerModelsService.getProviderModels('codex')).models;
const selectedModel = catalog.OPTIONS.find((option) => option.value === resolvedModel) || null;
const allowedEfforts = selectedModel?.effort?.values?.map((value) => value.value) || [];
const resolvedEffort = typeof effort === 'string' && effort !== 'default' && allowedEfforts.includes(effort)
? effort
: undefined;
let codex;
let thread;
@@ -248,19 +254,17 @@ export async function queryCodex(command, options = {}, ws) {
const abortController = new AbortController();
try {
// Initialize Codex SDK
codex = new Codex();
// Thread options with sandbox and approval settings
const threadOptions = {
workingDirectory,
skipGitRepoCheck: true,
sandboxMode,
approvalPolicy,
model: resolvedModel
model: resolvedModel,
modelReasoningEffort: resolvedEffort,
};
// Start or resume thread
if (sessionId) {
thread = codex.resumeThread(sessionId, threadOptions);
} else {
@@ -280,12 +284,10 @@ export async function queryCodex(command, options = {}, ws) {
});
};
// Existing sessions can be tracked immediately; new sessions are tracked after thread.started.
if (capturedSessionId) {
registerSession(capturedSessionId);
}
// Execute with streaming
const streamedTurn = await thread.runStreamed(command, {
signal: abortController.signal
});

View File

@@ -646,12 +646,17 @@ class ResponseCollector {
*
* @param {string} model - (Optional) Model identifier for providers.
*
* Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]', 'fable'
* Claude models: 'default', 'sonnet', 'opus', 'haiku', 'sonnet[1m]', 'opus[1m]', 'fable'
* Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5',
* 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high',
* 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',
* 'gpt-5.1-codex-max-high', 'opus-4.1', 'grok', and thinking variants
* Codex models: 'gpt-5.2' (default), 'gpt-5.1-codex-max', 'o3', 'o4-mini'
* Codex models: 'gpt-5.4' (default), 'gpt-5.5', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.2'
*
* @param {string} effort - (Optional) Reasoning effort for providers/models that support it.
* Claude supports: 'low', 'medium', 'high', 'xhigh', 'max' depending on model.
* Codex supports: 'low', 'medium', 'high', 'xhigh'.
* 'default' or omission lets the provider decide.
*
* @param {boolean} cleanup - (Optional) Auto-cleanup project directory after completion.
* Default: true
@@ -844,6 +849,9 @@ class ResponseCollector {
*/
router.post('/', validateExternalApiKey, async (req, res) => {
const { githubUrl, projectPath, message, provider = 'claude', model, githubToken, branchName, sessionId } = req.body;
const effort = typeof req.body.effort === 'string' && req.body.effort.trim()
? req.body.effort.trim()
: undefined;
// Parse stream and cleanup as booleans (handle string "true"/"false" from curl)
const stream = req.body.stream === undefined ? true : (req.body.stream === true || req.body.stream === 'true');
@@ -954,6 +962,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
cwd: finalProjectPath,
sessionId: sessionId || null,
model: model,
effort,
permissionMode: 'bypassPermissions' // Bypass all permissions for API calls
}, writer);
@@ -975,6 +984,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
cwd: finalProjectPath,
sessionId: sessionId || null,
model: model || codexModels.DEFAULT,
effort,
permissionMode: 'bypassPermissions'
}, writer);
} else if (provider === 'gemini') {

View File

@@ -74,6 +74,13 @@ export type ProviderModelOption = {
value: string;
label: string;
description?: string;
effort?: {
default?: string;
values: {
value: string;
description?: string;
}[];
};
};
/**

View File

@@ -0,0 +1,12 @@
import type { LLMProvider, ProviderModelOption } from '../../../types/app';
export const DEFAULT_EFFORT_VALUE = 'default';
export const FALLBACK_PROVIDER_EFFORT_VALUES: Partial<Record<LLMProvider, readonly string[]>> = {
claude: ['low', 'medium', 'high', 'xhigh', 'max'],
codex: ['low', 'medium', 'high', 'xhigh'],
};
export const toProviderEffortOptions = (
values: readonly string[],
): NonNullable<ProviderModelOption['effort']>['values'] => values.map((value) => ({ value }));

View File

@@ -34,9 +34,11 @@ interface UseChatComposerStateArgs {
provider: LLMProvider;
permissionMode: PermissionMode | string;
cyclePermissionMode: () => void;
resolvePermissionModeForProvider: (provider: LLMProvider, requestedMode: PermissionMode | string) => PermissionMode;
cursorModel: string;
claudeModel: string;
codexModel: string;
currentProviderEffort: string;
geminiModel: string;
opencodeModel: string;
isLoading: boolean;
@@ -168,9 +170,11 @@ export function useChatComposerState({
provider,
permissionMode,
cyclePermissionMode,
resolvePermissionModeForProvider,
cursorModel,
claudeModel,
codexModel,
currentProviderEffort,
geminiModel,
opencodeModel,
isLoading,
@@ -728,8 +732,9 @@ export function useChatComposerState({
: provider === 'gemini'
? geminiModel
: provider === 'opencode'
? opencodeModel
: claudeModel;
? opencodeModel
: claudeModel;
const effort = currentProviderEffort;
// One message shape for every provider. The backend resolves the
// provider, project path, and provider-native resume id from the
@@ -740,9 +745,8 @@ export function useChatComposerState({
content: messageContent,
options: {
model,
// Codex has no plan mode; downgrade rather than sending an
// unsupported value to its runtime.
permissionMode: provider === 'codex' && permissionMode === 'plan' ? 'default' : permissionMode,
effort,
permissionMode: resolvePermissionModeForProvider(provider, permissionMode),
toolsSettings,
skipPermissions: toolsSettings?.skipPermissions || false,
sessionSummary,
@@ -769,6 +773,7 @@ export function useChatComposerState({
attachedImages,
claudeModel,
codexModel,
currentProviderEffort,
currentSessionId,
cursorModel,
executeCommand,
@@ -779,6 +784,7 @@ export function useChatComposerState({
onSessionEstablished,
permissionMode,
provider,
resolvePermissionModeForProvider,
resetCommandMenuState,
scrollToBottom,
selectedProject,

View File

@@ -1,22 +1,31 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import type { PendingPermissionRequest, PermissionMode } from '../types/types';
import type {
ProjectSession,
LLMProvider,
Project,
ProviderModelOption,
ProviderModelsCacheInfo,
ProviderModelsDefinition,
} from '../../../types/app';
import {
DEFAULT_EFFORT_VALUE,
FALLBACK_PROVIDER_EFFORT_VALUES,
toProviderEffortOptions,
} from '../constants/providerEffort';
const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
claude: 'opus',
claude: 'default',
cursor: 'gpt-5.3-codex',
codex: 'gpt-5.4',
gemini: 'gemini-3.1-pro-preview',
opencode: 'anthropic/claude-sonnet-4-5',
};
const PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
/**
* Fallback permission-mode matrix used only until the backend capability
* matrix (`GET /api/providers/capabilities`) has loaded. The backend is the
@@ -39,6 +48,7 @@ type ProviderCapabilities = {
supportsAbort: boolean;
supportsPermissionRequests: boolean;
supportsTokenUsage: boolean;
supportsEffort?: boolean;
};
type ProviderCapabilitiesApiResponse = {
@@ -72,7 +82,7 @@ type ChangeActiveModelApiResponse = {
};
};
export function useChatProviderState({ selectedSession, selectedProject }: UseChatProviderStateArgs) {
export function useChatProviderState({ selectedSession, selectedProject: _selectedProject }: UseChatProviderStateArgs) {
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default');
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]);
const [provider, setProvider] = useState<LLMProvider>(() => {
@@ -87,6 +97,12 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const [codexModel, setCodexModel] = useState<string>(() => {
return localStorage.getItem('codex-model') || FALLBACK_DEFAULT_MODEL.codex;
});
const [providerEfforts, setProviderEfforts] = useState<Partial<Record<LLMProvider, string>>>(() => {
return PROVIDERS.reduce<Partial<Record<LLMProvider, string>>>((acc, targetProvider) => {
acc[targetProvider] = localStorage.getItem(`${targetProvider}-effort`) || DEFAULT_EFFORT_VALUE;
return acc;
}, {});
});
const [geminiModel, setGeminiModel] = useState<string>(() => {
return localStorage.getItem('gemini-model') || FALLBACK_DEFAULT_MODEL.gemini;
});
@@ -145,8 +161,16 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
localStorage.setItem('opencode-model', model);
}, []);
const setStoredProviderEffort = useCallback((targetProvider: LLMProvider, effort: string) => {
setProviderEfforts((previous) => (
previous[targetProvider] === effort
? previous
: { ...previous, [targetProvider]: effort }
));
localStorage.setItem(`${targetProvider}-effort`, effort);
}, []);
const loadProviderModels = useCallback(async (options: { bypassCache?: boolean } = {}) => {
const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
const requestId = providerModelsRequestIdRef.current + 1;
providerModelsRequestIdRef.current = requestId;
const isHardRefresh = options.bypassCache === true;
@@ -159,7 +183,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
try {
const results = await Promise.all(
providers.map(async (p) => {
PROVIDERS.map(async (p) => {
const params = new URLSearchParams();
if (options.bypassCache) {
params.set('bypassCache', 'true');
@@ -183,7 +207,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const nextCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>> = {};
const nextCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>> = {};
providers.forEach((p, i) => {
PROVIDERS.forEach((p, i) => {
const entry = results[i];
if (!entry) {
return;
@@ -244,6 +268,23 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
return FALLBACK_PERMISSION_MODES[targetProvider] ?? ['default'];
}, [providerCapabilities]);
const getDefaultPermissionModeForProvider = useCallback((targetProvider: LLMProvider): PermissionMode => {
const modes = getPermissionModesForProvider(targetProvider);
const capabilityDefault = providerCapabilities?.[targetProvider]?.defaultPermissionMode as PermissionMode | undefined;
if (capabilityDefault && modes.includes(capabilityDefault)) {
return capabilityDefault;
}
return modes[0] ?? 'default';
}, [getPermissionModesForProvider, providerCapabilities]);
const getSupportsEffortForProvider = useCallback((targetProvider: LLMProvider): boolean => {
const capabilitySupport = providerCapabilities?.[targetProvider]?.supportsEffort;
if (typeof capabilitySupport === 'boolean') {
return capabilitySupport;
}
return Boolean(FALLBACK_PROVIDER_EFFORT_VALUES[targetProvider]?.length);
}, [providerCapabilities]);
const pickStoredOrCurrent = (
storageKey: string,
current: string,
@@ -259,6 +300,70 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
return def.DEFAULT;
};
const getModelOption = useCallback((
targetProvider: LLMProvider,
model: string,
): ProviderModelOption | null => {
const definition = providerModelCatalog[targetProvider];
if (!definition) {
return null;
}
return definition.OPTIONS.find((option) => option.value === model) ?? null;
}, [providerModelCatalog]);
const getEffortOptionsForModel = useCallback((
targetProvider: LLMProvider,
model: string,
): NonNullable<ProviderModelOption['effort']>['values'] => {
if (!getSupportsEffortForProvider(targetProvider)) {
return [];
}
const option = getModelOption(targetProvider, model);
if (option) {
return option.effort?.values ?? [];
}
return toProviderEffortOptions(FALLBACK_PROVIDER_EFFORT_VALUES[targetProvider] ?? []);
}, [getModelOption, getSupportsEffortForProvider]);
const getAllowedEffortValues = useCallback((
targetProvider: LLMProvider,
model: string,
): string[] => (
getEffortOptionsForModel(targetProvider, model).map((value) => value.value)
), [getEffortOptionsForModel]);
const reconcileStoredEffort = useCallback((
targetProvider: LLMProvider,
model: string,
currentEffort: string,
): string => {
const allowedValues = getAllowedEffortValues(targetProvider, model);
if (allowedValues.length === 0) {
return DEFAULT_EFFORT_VALUE;
}
if (currentEffort === DEFAULT_EFFORT_VALUE || !currentEffort) {
return DEFAULT_EFFORT_VALUE;
}
if (allowedValues.includes(currentEffort)) {
return currentEffort;
}
return DEFAULT_EFFORT_VALUE;
}, [getAllowedEffortValues]);
const providerModels = useMemo<Record<LLMProvider, string>>(() => ({
claude: claudeModel,
cursor: cursorModel,
codex: codexModel,
gemini: geminiModel,
opencode: opencodeModel,
}), [claudeModel, cursorModel, codexModel, geminiModel, opencodeModel]);
useEffect(() => {
const claude = providerModelCatalog.claude;
if (claude) {
@@ -324,6 +429,27 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
}
}, [providerModelCatalog.opencode, opencodeModel]);
useEffect(() => {
const nextEfforts: Partial<Record<LLMProvider, string>> = {};
let hasUpdates = false;
for (const targetProvider of PROVIDERS) {
const currentEffort = providerEfforts[targetProvider] ?? DEFAULT_EFFORT_VALUE;
const nextEffort = reconcileStoredEffort(targetProvider, providerModels[targetProvider], currentEffort);
if (nextEffort === currentEffort) {
continue;
}
nextEfforts[targetProvider] = nextEffort;
localStorage.setItem(`${targetProvider}-effort`, nextEffort);
hasUpdates = true;
}
if (hasUpdates) {
setProviderEfforts((previous) => ({ ...previous, ...nextEfforts }));
}
}, [providerEfforts, providerModels, reconcileStoredEffort]);
useEffect(() => {
if (!selectedSession?.id) {
return;
@@ -331,8 +457,12 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null;
const validModes = getPermissionModesForProvider(provider);
setPermissionMode(savedMode && validModes.includes(savedMode) ? savedMode : 'default');
}, [selectedSession?.id, provider, getPermissionModesForProvider]);
setPermissionMode(
savedMode && validModes.includes(savedMode)
? savedMode
: getDefaultPermissionModeForProvider(provider),
);
}, [selectedSession?.id, provider, getDefaultPermissionModeForProvider, getPermissionModesForProvider]);
useEffect(() => {
if (!selectedSession?.__provider || selectedSession.__provider === provider) {
@@ -386,6 +516,16 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
}
}, [permissionMode, provider, selectedSession?.id, getPermissionModesForProvider]);
const resolvePermissionModeForProvider = useCallback((
targetProvider: LLMProvider,
requestedMode: PermissionMode | string,
): PermissionMode => {
const validModes = getPermissionModesForProvider(targetProvider);
return validModes.includes(requestedMode as PermissionMode)
? requestedMode as PermissionMode
: getDefaultPermissionModeForProvider(targetProvider);
}, [getDefaultPermissionModeForProvider, getPermissionModesForProvider]);
const selectProviderModel = useCallback(async (
targetProvider: LLMProvider,
model: string,
@@ -421,6 +561,17 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
};
}, [setStoredProviderModel]);
const currentProviderEffortOptions = useMemo(() => {
return getEffortOptionsForModel(provider, providerModels[provider]);
}, [getEffortOptionsForModel, provider, providerModels]);
const currentProviderEffort = useMemo(() => {
return reconcileStoredEffort(
provider,
providerModels[provider],
providerEfforts[provider] ?? DEFAULT_EFFORT_VALUE,
);
}, [provider, providerEfforts, providerModels, reconcileStoredEffort]);
return {
provider,
setProvider,
@@ -430,6 +581,8 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
setClaudeModel,
codexModel,
setCodexModel,
currentProviderEffort,
currentProviderEffortOptions,
geminiModel,
setGeminiModel,
opencodeModel,
@@ -445,5 +598,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
providerModelsRefreshing,
hardRefreshProviderModels: () => loadProviderModels({ bypassCache: true }),
selectProviderModel,
setStoredProviderEffort,
resolvePermissionModeForProvider,
};
}

View File

@@ -16,7 +16,6 @@ import ChatMessagesPane from './subcomponents/ChatMessagesPane';
import ChatComposer from './subcomponents/ChatComposer';
import CommandResultModal from './subcomponents/CommandResultModal';
function ChatInterface({
selectedProject,
selectedSession,
@@ -71,6 +70,8 @@ function ChatInterface({
setClaudeModel,
codexModel,
setCodexModel,
currentProviderEffort,
currentProviderEffortOptions,
geminiModel,
setGeminiModel,
opencodeModel,
@@ -85,6 +86,8 @@ function ChatInterface({
providerModelsRefreshing,
hardRefreshProviderModels,
selectProviderModel,
setStoredProviderEffort,
resolvePermissionModeForProvider,
} = useChatProviderState({
selectedSession,
selectedProject,
@@ -199,6 +202,7 @@ function ChatInterface({
cursorModel,
claudeModel,
codexModel,
currentProviderEffort,
geminiModel,
opencodeModel,
isLoading: isProcessing,
@@ -215,6 +219,7 @@ function ChatInterface({
addMessage,
setIsUserScrolledUp,
setPendingPermissionRequests,
resolvePermissionModeForProvider,
});
// On WebSocket reconnect, re-fetch the current session's messages from the
@@ -371,6 +376,9 @@ function ChatInterface({
onAbortSession={handleAbortSession}
permissionMode={permissionMode}
onModeSwitch={cyclePermissionMode}
effort={currentProviderEffort}
availableEffortOptions={currentProviderEffortOptions}
onSelectEffort={(nextEffort) => setStoredProviderEffort(provider, nextEffort)}
tokenBudget={tokenBudget}
onShowTokenUsage={showCostModal}
slashCommandsCount={slashCommandsCount}

View File

@@ -1,6 +1,5 @@
import { useTranslation } from 'react-i18next';
import { useMemo } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
ChangeEvent,
ClipboardEvent,
@@ -11,12 +10,13 @@ import type {
RefObject,
TouchEvent,
} from 'react';
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon, Loader2 } from 'lucide-react';
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon, Loader2, ChevronDown, Check } from 'lucide-react';
import { useVoiceInput } from '../../hooks/useVoiceInput';
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
import type { SessionActivity } from '../../../../hooks/useSessionProtection';
import type { PendingPermissionRequest, PermissionMode } from '../../types/types';
import type { ProviderModelOption } from '../../../../types/app';
import {
PromptInput,
PromptInputHeader,
@@ -62,6 +62,9 @@ interface ChatComposerProps {
onAbortSession: () => void;
permissionMode: PermissionMode | string;
onModeSwitch: () => void;
effort: string;
availableEffortOptions: NonNullable<ProviderModelOption['effort']>['values'];
onSelectEffort: (effort: string) => void;
tokenBudget: Record<string, unknown> | null;
onShowTokenUsage: () => void;
slashCommandsCount: number;
@@ -116,6 +119,9 @@ export default function ChatComposer({
onAbortSession,
permissionMode,
onModeSwitch,
effort,
availableEffortOptions,
onSelectEffort,
tokenBudget,
onShowTokenUsage,
slashCommandsCount,
@@ -171,7 +177,7 @@ export default function ChatComposer({
left: textareaRect ? textareaRect.left : 16,
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
};
}, [input, isCommandMenuOpen, textareaRef]);
}, [isCommandMenuOpen, textareaRef]);
// Voice state is hosted here (not in the mic button) so the main Send button can stop
// recording and send the transcript in one tap, the way the mic button drops it in the box.
@@ -193,6 +199,39 @@ export default function ChatComposer({
);
const isRecording = voiceState === 'recording';
const isTranscribing = voiceState === 'transcribing';
const [isEffortDropdownOpen, setIsEffortDropdownOpen] = useState(false);
const effortDropdownRef = useRef<HTMLDivElement | null>(null);
const effortOptions = useMemo(
() => [{ value: 'default' }, ...availableEffortOptions],
[availableEffortOptions],
);
const selectedEffortLabel = effort === 'default' ? 'Default' : effort;
useEffect(() => {
if (!isEffortDropdownOpen) return;
const handlePointerDown = (event: PointerEvent) => {
if (!effortDropdownRef.current?.contains(event.target as Node)) {
setIsEffortDropdownOpen(false);
}
};
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
setIsEffortDropdownOpen(false);
}
};
document.addEventListener('pointerdown', handlePointerDown);
window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => {
document.removeEventListener('pointerdown', handlePointerDown);
window.removeEventListener('keydown', handleKeyDown, { capture: true });
};
}, [isEffortDropdownOpen]);
// Detect if the AskUserQuestion interactive panel is active
const hasQuestionPanel = pendingPermissionRequests.some(
@@ -386,6 +425,55 @@ export default function ChatComposer({
</div>
</button>
{availableEffortOptions.length > 0 && (
<div ref={effortDropdownRef} className="relative">
<button
type="button"
onClick={() => setIsEffortDropdownOpen((current) => !current)}
className="flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-muted/40 px-2 text-xs font-medium text-foreground transition-all duration-200 hover:bg-muted"
aria-haspopup="menu"
aria-expanded={isEffortDropdownOpen}
aria-label="Select reasoning effort"
title="Select reasoning effort"
>
<span className="hidden text-[11px] text-muted-foreground sm:inline">Effort</span>
<span className="max-w-16 truncate capitalize sm:max-w-20">{selectedEffortLabel}</span>
<ChevronDown className={`h-3 w-3 text-muted-foreground transition-transform ${isEffortDropdownOpen ? 'rotate-180' : ''}`} />
</button>
{isEffortDropdownOpen && (
<div className="absolute bottom-full left-0 z-50 mb-2 min-w-36 overflow-hidden rounded-lg border border-border bg-card p-1 shadow-lg">
{effortOptions.map((option) => {
const isSelected = option.value === effort;
const label = option.value === 'default' ? 'Default' : option.value;
return (
<button
key={option.value}
type="button"
role="menuitemradio"
aria-checked={isSelected}
onClick={() => {
onSelectEffort(option.value);
setIsEffortDropdownOpen(false);
}}
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs capitalize transition-colors ${
isSelected
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/70 hover:text-foreground'
}`}
>
<span className="flex h-3 w-3 items-center justify-center">
{isSelected && <Check className="h-3 w-3 text-primary" />}
</span>
<span>{label}</span>
</button>
);
})}
</div>
)}
</div>
)}
<TokenUsageSummary usage={tokenBudget} onClick={onShowTokenUsage} />
<PromptInputButton

View File

@@ -263,7 +263,6 @@ function ModelsContent({
const availableModels = Array.isArray(data?.availableModels) ? data.availableModels : [];
return availableModels.map((model) => ({ value: model, label: model }));
}, [data, liveDefinition]);
const filteredOptions = useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) {

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { api } from '../../../utils/api';
import type { CodeEditorFile } from '../types/types';
import { isBinaryFile } from '../utils/binaryFile';
import { getPreviewKind } from '../utils/previewableFile';
type UseCodeEditorDocumentParams = {
file: CodeEditorFile;
@@ -23,6 +24,9 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
const [saveSuccess, setSaveSuccess] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [isBinary, setIsBinary] = useState(false);
// Some binaries (images, PDFs, audio, video) can be rendered natively, so the
// editor shows an inline preview instead of the generic binary placeholder.
const previewKind = getPreviewKind(file.name);
// `fileProjectId` is the DB primary key passed down from the editor sidebar;
// the fallback to `projectPath` preserves older callers that didn't yet
// propagate the identifier.
@@ -38,8 +42,19 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
setLoading(true);
setIsBinary(false);
// Natively previewable media (image/pdf/audio/video) is rendered by
// CodeEditorMediaPreview, so there is nothing to read as text here.
// Clear any buffer left over from a previously opened text file so a
// stray save can't write stale content over the binary file.
if (getPreviewKind(file.name)) {
setContent('');
setLoading(false);
return;
}
// Check if file is binary by extension
if (isBinaryFile(file.name)) {
setContent('');
setIsBinary(true);
setLoading(false);
return;
@@ -76,6 +91,12 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectId]);
const handleSave = useCallback(async () => {
// Preview-only and binary files have no editable text buffer; never write
// them back (e.g. via Cmd/Ctrl+S) or we'd corrupt the file on disk.
if (previewKind || isBinaryFile(fileName)) {
return;
}
setSaving(true);
setSaveError(null);
@@ -109,7 +130,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
} finally {
setSaving(false);
}
}, [content, filePath, fileProjectId]);
}, [content, filePath, fileProjectId, previewKind, fileName]);
const handleDownload = useCallback(() => {
const blob = new Blob([content], { type: 'text/plain' });
@@ -134,6 +155,8 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
saveSuccess,
saveError,
isBinary,
previewKind,
fileProjectId,
handleSave,
handleDownload,
};

View File

@@ -0,0 +1,63 @@
// Some binary files can't be edited as text, but the browser can still render
// them natively (images, PDFs, audio, video). For those we show an inline
// preview instead of the generic "binary file" placeholder. Anything not listed
// here (zip, exe, avi, mkv, fonts, ...) falls through to the binary message.
export type PreviewKind = 'image' | 'pdf' | 'video' | 'audio';
// Single source of truth: every extension the browser can preview, mapped to the
// MIME type we apply when the server response has a missing/generic Content-Type.
// The preview kind is derived from the MIME type so the two never drift apart.
// Formats browsers generally can't play (avi, mkv, flv, wmv) are intentionally
// absent and keep the binary fallback.
const EXTENSION_MIME: Record<string, string> = {
// Images
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
svg: 'image/svg+xml',
webp: 'image/webp',
ico: 'image/x-icon',
bmp: 'image/bmp',
avif: 'image/avif',
apng: 'image/apng',
// PDF
pdf: 'application/pdf',
// Video
mp4: 'video/mp4',
webm: 'video/webm',
ogv: 'video/ogg',
mov: 'video/quicktime',
m4v: 'video/x-m4v',
// Audio
mp3: 'audio/mpeg',
wav: 'audio/wav',
m4a: 'audio/mp4',
aac: 'audio/aac',
flac: 'audio/flac',
opus: 'audio/opus',
oga: 'audio/ogg',
ogg: 'audio/ogg',
weba: 'audio/webm',
};
const extensionOf = (filename: string): string => filename.split('.').pop()?.toLowerCase() ?? '';
const kindForMime = (mime: string): PreviewKind | null => {
if (mime === 'application/pdf') return 'pdf';
if (mime.startsWith('image/')) return 'image';
if (mime.startsWith('video/')) return 'video';
if (mime.startsWith('audio/')) return 'audio';
return null;
};
export const getPreviewKind = (filename: string): PreviewKind | null => {
const mime = EXTENSION_MIME[extensionOf(filename)];
return mime ? kindForMime(mime) : null;
};
// MIME type to fall back to when the server returns no/generic Content-Type.
// Returns undefined for non-previewable extensions.
export const getPreviewMimeType = (filename: string): string | undefined =>
EXTENSION_MIME[extensionOf(filename)];

View File

@@ -1,8 +1,9 @@
import { EditorView } from '@codemirror/view';
import { unifiedMergeView } from '@codemirror/merge';
import type { Extension } from '@codemirror/state';
import { useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';
import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';
@@ -11,11 +12,13 @@ import type { CodeEditorFile } from '../types/types';
import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions';
import { getEditorStyles } from '../utils/editorStyles';
import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel';
import CodeEditorFooter from './subcomponents/CodeEditorFooter';
import CodeEditorHeader from './subcomponents/CodeEditorHeader';
import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
import CodeEditorSurface from './subcomponents/CodeEditorSurface';
import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile';
import CodeEditorMediaPreview from './subcomponents/CodeEditorMediaPreview';
type CodeEditorProps = {
file: CodeEditorFile;
@@ -58,6 +61,8 @@ export default function CodeEditor({
saveSuccess,
saveError,
isBinary,
previewKind,
fileProjectId,
handleSave,
handleDownload,
} = useCodeEditorDocument({
@@ -70,6 +75,29 @@ export default function CodeEditor({
return extension === 'md' || extension === 'markdown';
}, [file.name]);
const isHtmlPreviewFile = useMemo(() => {
const extension = file.name.split('.').pop()?.toLowerCase();
return extension === 'html' || extension === 'htm';
}, [file.name]);
const openHtmlPreview = useCallback(() => {
const previewWindow = window.open('', '_blank');
if (!previewWindow) return;
previewWindow.opener = null;
previewWindow.document.title = file.name;
previewWindow.document.body.style.margin = '0';
const iframe = previewWindow.document.createElement('iframe');
iframe.title = file.name;
iframe.sandbox.add('allow-forms', 'allow-modals', 'allow-popups', 'allow-scripts');
iframe.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;border:0;background:white';
iframe.srcdoc = content;
previewWindow.document.body.appendChild(iframe);
}, [content, file.name]);
const minimapExtension = useMemo(
() => (
createMinimapExtension({
@@ -162,6 +190,30 @@ export default function CodeEditor({
);
}
// Natively previewable media (image/pdf/audio/video) is rendered inline
// instead of showing the generic "cannot be displayed" placeholder.
if (previewKind) {
return (
<CodeEditorMediaPreview
file={file}
kind={previewKind}
projectId={fileProjectId}
isSidebar={isSidebar}
isFullscreen={isFullscreen}
onClose={onClose}
onToggleFullscreen={() => setIsFullscreen((previous) => !previous)}
labels={{
loading: t('filePreview.loading', 'Loading preview...'),
error: t('filePreview.error', 'Unable to display this file.'),
openInNewTab: t('filePreview.openInNewTab', 'Open in new tab'),
fullscreen: t('actions.fullscreen', 'Fullscreen'),
exitFullscreen: t('actions.exitFullscreen', 'Exit fullscreen'),
close: t('actions.close', 'Close'),
}}
/>
);
}
// Binary file display
if (isBinary) {
return (
@@ -197,10 +249,12 @@ export default function CodeEditor({
isSidebar={isSidebar}
isFullscreen={isFullscreen}
isMarkdownFile={isMarkdownFile}
isHtmlPreviewFile={isHtmlPreviewFile}
markdownPreview={markdownPreview}
saving={saving}
saveSuccess={saveSuccess}
onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)}
onOpenHtmlPreview={openHtmlPreview}
onOpenSettings={() => paletteOps.openSettings('appearance')}
onDownload={handleDownload}
onSave={handleSave}
@@ -210,6 +264,7 @@ export default function CodeEditor({
showingChanges: t('header.showingChanges'),
editMarkdown: t('actions.editMarkdown'),
previewMarkdown: t('actions.previewMarkdown'),
previewHtml: t('actions.previewHtml', 'Open HTML preview in new tab'),
settings: t('toolbar.settings'),
download: t('actions.download'),
save: t('actions.save'),

View File

@@ -1,4 +1,5 @@
import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react';
import type { CodeEditorFile } from '../../types/types';
type CodeEditorHeaderProps = {
@@ -6,10 +7,12 @@ type CodeEditorHeaderProps = {
isSidebar: boolean;
isFullscreen: boolean;
isMarkdownFile: boolean;
isHtmlPreviewFile: boolean;
markdownPreview: boolean;
saving: boolean;
saveSuccess: boolean;
onToggleMarkdownPreview: () => void;
onOpenHtmlPreview: () => void;
onOpenSettings: () => void;
onDownload: () => void;
onSave: () => void;
@@ -19,6 +22,7 @@ type CodeEditorHeaderProps = {
showingChanges: string;
editMarkdown: string;
previewMarkdown: string;
previewHtml: string;
settings: string;
download: string;
save: string;
@@ -35,10 +39,12 @@ export default function CodeEditorHeader({
isSidebar,
isFullscreen,
isMarkdownFile,
isHtmlPreviewFile,
markdownPreview,
saving,
saveSuccess,
onToggleMarkdownPreview,
onOpenHtmlPreview,
onOpenSettings,
onDownload,
onSave,
@@ -82,6 +88,17 @@ export default function CodeEditorHeader({
</button>
)}
{isHtmlPreviewFile && (
<button
type="button"
onClick={onOpenHtmlPreview}
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
title={labels.previewHtml}
>
<Eye className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={onOpenSettings}

View File

@@ -0,0 +1,289 @@
import { useEffect, useState } from 'react';
import { authenticatedFetch } from '../../../../utils/api';
import type { CodeEditorFile } from '../../types/types';
import { getPreviewMimeType, type PreviewKind } from '../../utils/previewableFile';
type CodeEditorMediaPreviewProps = {
file: CodeEditorFile;
kind: PreviewKind;
// DB projectId used to build the raw-content URL; falls back to projectPath
// for older callers, mirroring useCodeEditorDocument.
projectId?: string;
isSidebar: boolean;
isFullscreen: boolean;
onClose: () => void;
onToggleFullscreen: () => void;
labels: {
loading: string;
error: string;
openInNewTab: string;
fullscreen: string;
exitFullscreen: string;
close: string;
};
};
// Reject a "PDF" whose bytes aren't actually a PDF before handing it to the
// same-origin iframe, so a mislabeled HTML/SVG file can't run in the app origin.
const PDF_HEADER_SCAN_BYTES = 1024;
const looksLikePdf = async (blob: Blob): Promise<boolean> => {
const header = await blob.slice(0, PDF_HEADER_SCAN_BYTES).arrayBuffer();
// PDFs must contain the "%PDF-" marker at the very start of the file.
return new TextDecoder('latin1').decode(header).includes('%PDF-');
};
export default function CodeEditorMediaPreview({
file,
kind,
projectId,
isSidebar,
isFullscreen,
onClose,
onToggleFullscreen,
labels,
}: CodeEditorMediaPreviewProps) {
const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
// Identifies which file the current `url` was loaded for. Rendering is gated on
// this so a blob from a previously-opened file can never show under the new
// file (the editor reuses this component instance across files).
const [loadedKey, setLoadedKey] = useState<string | null>(null);
const sourceKey = `${projectId ?? ''}:${file.path}:${kind}`;
useEffect(() => {
if (!projectId) {
setUrl(null);
setLoadedKey(null);
setError(labels.error);
setLoading(false);
return;
}
let objectUrl: string | null = null;
const controller = new AbortController();
const loadMedia = async () => {
try {
setLoading(true);
setError(null);
setUrl(null);
// The content endpoint requires the auth header, so we fetch the bytes
// ourselves and hand the media element a blob URL instead of a bare src.
// Fetching a blob (rather than streaming) also lets <video>/<audio> seek.
const contentUrl = `/api/projects/${projectId}/files/content?path=${encodeURIComponent(file.path)}`;
const response = await authenticatedFetch(contentUrl, { signal: controller.signal });
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const blob = await response.blob();
// Pick the MIME type to expose to the browser. Preserve a valid
// Content-Type from the server, but supply an extension-specific
// default when it is missing or generic (application/octet-stream),
// otherwise formats like webm/ogg/flac/svg won't render.
const fallbackMime = getPreviewMimeType(file.name);
const isGenericType = !blob.type || blob.type === 'application/octet-stream';
const isMislabeledVideo = kind === 'video' && Boolean(fallbackMime) && !blob.type.startsWith('video/');
let outType = isGenericType || isMislabeledVideo ? (fallbackMime ?? blob.type) : blob.type;
if (kind === 'pdf') {
// The PDF renders in a same-origin <iframe>, so verify the bytes are
// really a PDF and pin the type to application/pdf. That forces the
// browser's PDF handler and prevents a mislabeled HTML/SVG file from
// executing scripts in the app's origin.
if (!(await looksLikePdf(blob))) {
throw new Error('File is not a valid PDF');
}
outType = 'application/pdf';
}
const typed = outType && outType !== blob.type ? new Blob([blob], { type: outType }) : blob;
objectUrl = URL.createObjectURL(typed);
// The cleanup may have already run (deps changed during an await), in
// which case it revoked nothing because objectUrl was still null. Don't
// publish a URL the cleanup will never revoke — drop it ourselves.
if (controller.signal.aborted) {
URL.revokeObjectURL(objectUrl);
objectUrl = null;
return;
}
setUrl(objectUrl);
setLoadedKey(sourceKey);
} catch (loadError: unknown) {
if (loadError instanceof Error && loadError.name === 'AbortError') {
return;
}
console.error('Error loading preview:', loadError);
setError(labels.error);
} finally {
setLoading(false);
}
};
loadMedia();
return () => {
controller.abort();
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [file.path, file.name, projectId, kind, sourceKey, labels.error]);
// Only expose the blob once it matches the file currently being shown, so a
// stale URL from the previous file is never rendered during a switch.
const currentUrl = url && loadedKey === sourceKey ? url : null;
// SVGs render safely inline via <img> (scripts don't execute there), but the
// open-in-new-tab link is a top-level navigation. A blob URL inherits the
// app's origin, so a user-controlled SVG with an embedded <script> would run
// as same-origin script. Withhold the new-tab action for SVGs.
const isSvg = getPreviewMimeType(file.name) === 'image/svg+xml';
const canOpenInNewTab = Boolean(currentUrl) && !isSvg;
const renderMedia = () => {
if (!currentUrl) return null;
switch (kind) {
case 'image':
return (
<img
src={currentUrl}
alt={file.name}
className="max-h-full max-w-full object-contain"
/>
);
case 'pdf':
// Not sandboxed on purpose: the browser's built-in PDF viewer refuses to
// load inside a sandboxed frame (any `sandbox` value yields a broken
// viewer). Script execution is instead prevented upstream by validating
// the PDF magic bytes and pinning the blob's MIME type to application/pdf.
return <iframe src={currentUrl} title={file.name} className="h-full w-full border-0 bg-white" />;
case 'video':
return (
<video src={currentUrl} controls className="max-h-full max-w-full" autoPlay={false}>
{labels.error}
</video>
);
case 'audio':
return (
<div className="flex w-full max-w-xl flex-col items-center gap-4 px-6">
<p className="max-w-full truncate text-sm text-muted-foreground">{file.name}</p>
<audio src={currentUrl} controls className="w-full">
{labels.error}
</audio>
</div>
);
default:
return null;
}
};
const previewBody = (
<div className="relative flex h-full w-full flex-col items-center justify-center bg-muted/30 p-2">
{loading && (
<div className="text-sm text-muted-foreground">{labels.loading}</div>
)}
{!loading && currentUrl && renderMedia()}
{!loading && !currentUrl && (
<div className="flex flex-col items-center gap-3 p-8 text-center text-muted-foreground">
<p className="text-sm">{error || labels.error}</p>
<p className="break-all text-xs">{file.path}</p>
</div>
)}
</div>
);
const headerActions = (
<div className="flex shrink-0 items-center gap-0.5">
{canOpenInNewTab && currentUrl && (
<a
href={currentUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
aria-label={labels.openInNewTab}
title={labels.openInNewTab}
>
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
{!isSidebar && (
<button
type="button"
onClick={onToggleFullscreen}
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
aria-label={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
title={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
>
{isFullscreen ? (
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9V4.5M9 9H4.5M9 9L3.5 3.5M9 15v4.5M9 15H4.5M9 15l-5.5 5.5M15 9h4.5M15 9V4.5M15 9l5.5-5.5M15 15h4.5M15 15v4.5m0-4.5l5.5 5.5" />
</svg>
) : (
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
)}
</button>
)}
<button
type="button"
onClick={onClose}
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
aria-label={labels.close}
title={labels.close}
>
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
const header = (
<div className="flex flex-shrink-0 items-center justify-between border-b border-border px-3 py-1.5">
<div className="flex min-w-0 flex-1 items-center gap-2">
<h3 className="truncate text-sm font-medium text-gray-900 dark:text-white">{file.name}</h3>
</div>
{headerActions}
</div>
);
if (isSidebar) {
return (
<div className="flex h-full w-full flex-col bg-background">
{header}
{previewBody}
</div>
);
}
const containerClassName = isFullscreen
? 'fixed inset-0 z-[9999] bg-background flex flex-col'
: 'fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4';
const innerClassName = isFullscreen
? 'bg-background flex flex-col w-full h-full'
: 'bg-background shadow-2xl flex flex-col w-full h-full md:rounded-lg md:shadow-2xl md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]';
return (
<div className={containerClassName}>
<div className={innerClassName}>
{header}
{previewBody}
</div>
</div>
);
}

View File

@@ -1,6 +1,5 @@
import type { ITerminalOptions } from '@xterm/xterm';
export const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device';
export const SHELL_RESTART_DELAY_MS = 200;
export const TERMINAL_INIT_DELAY_MS = 100;
export const TERMINAL_RESIZE_DELAY_MS = 50;

View File

@@ -24,7 +24,6 @@ type UseShellConnectionOptions = {
autoConnect: boolean;
closeSocket: () => void;
clearTerminalScreen: () => void;
setAuthUrl: (nextAuthUrl: string) => void;
onOutputRef?: MutableRefObject<(() => void) | null>;
};
@@ -49,7 +48,6 @@ export function useShellConnection({
autoConnect,
closeSocket,
clearTerminalScreen,
setAuthUrl,
onOutputRef,
}: UseShellConnectionOptions): UseShellConnectionResult {
const [isConnected, setIsConnected] = useState(false);
@@ -100,14 +98,8 @@ export function useShellConnection({
return;
}
if (message.type === 'auth_url' || message.type === 'url_open') {
const nextAuthUrl = typeof message.url === 'string' ? message.url : '';
if (nextAuthUrl) {
setAuthUrl(nextAuthUrl);
}
}
},
[handleProcessCompletion, onOutputRef, setAuthUrl, terminalRef],
[handleProcessCompletion, onOutputRef, terminalRef],
);
const connectWebSocket = useCallback(
@@ -133,7 +125,6 @@ export function useShellConnection({
setIsConnected(true);
setIsConnecting(false);
connectingRef.current = false;
setAuthUrl('');
window.setTimeout(() => {
const currentTerminal = terminalRef.current;
@@ -196,7 +187,6 @@ export function useShellConnection({
isPlainShellRef,
selectedProjectRef,
selectedSessionRef,
setAuthUrl,
terminalRef,
wsRef,
],
@@ -225,8 +215,7 @@ export function useShellConnection({
setIsConnecting(false);
connectingRef.current = false;
forceRestartOnInitRef.current = false;
setAuthUrl('');
}, [clearTerminalScreen, closeSocket, setAuthUrl]);
}, [clearTerminalScreen, closeSocket]);
useEffect(() => {
if (

View File

@@ -1,8 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import type { FitAddon } from '@xterm/addon-fit';
import type { Terminal } from '@xterm/xterm';
import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types';
import { copyTextToClipboard } from '../../../utils/clipboard';
import { useShellConnection } from './useShellConnection';
import { useShellTerminal } from './useShellTerminal';
@@ -22,15 +23,11 @@ export function useShellRuntime({
const fitAddonRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const [authUrl, setAuthUrl] = useState('');
const [authUrlVersion, setAuthUrlVersion] = useState(0);
const selectedProjectRef = useRef(selectedProject);
const selectedSessionRef = useRef(selectedSession);
const initialCommandRef = useRef(initialCommand);
const isPlainShellRef = useRef(isPlainShell);
const onProcessCompleteRef = useRef(onProcessComplete);
const authUrlRef = useRef('');
const lastSessionIdRef = useRef<string | null>(selectedSession?.id ?? null);
// Keep mutable values in refs so websocket handlers always read current data.
@@ -42,12 +39,6 @@ export function useShellRuntime({
onProcessCompleteRef.current = onProcessComplete;
}, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]);
const setCurrentAuthUrl = useCallback((nextAuthUrl: string) => {
authUrlRef.current = nextAuthUrl;
setAuthUrl(nextAuthUrl);
setAuthUrlVersion((previous) => previous + 1);
}, []);
const closeSocket = useCallback(() => {
const activeSocket = wsRef.current;
if (!activeSocket) {
@@ -64,32 +55,6 @@ export function useShellRuntime({
wsRef.current = null;
}, []);
const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => {
if (!url) {
return false;
}
const popup = window.open(url, '_blank');
if (popup) {
try {
popup.opener = null;
} catch {
// Ignore cross-origin restrictions when trying to null opener.
}
return true;
}
return false;
}, []);
const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => {
if (!url) {
return false;
}
return copyTextToClipboard(url);
}, []);
const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({
terminalContainerRef,
terminalRef,
@@ -98,10 +63,6 @@ export function useShellRuntime({
selectedProject,
minimal,
isRestarting,
initialCommandRef,
isPlainShellRef,
authUrlRef,
copyAuthUrlToClipboard,
closeSocket,
});
@@ -118,7 +79,6 @@ export function useShellRuntime({
autoConnect,
closeSocket,
clearTerminalScreen,
setAuthUrl: setCurrentAuthUrl,
onOutputRef,
});
@@ -156,11 +116,7 @@ export function useShellRuntime({
isConnected,
isInitialized,
isConnecting,
authUrl,
authUrlVersion,
connectToShell,
disconnectFromShell,
openAuthUrlInBrowser,
copyAuthUrlToClipboard,
};
}

View File

@@ -4,15 +4,18 @@ import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { WebglAddon } from '@xterm/addon-webgl';
import { Terminal } from '@xterm/xterm';
import type { Project } from '../../../types/app';
import { copyTextToClipboard } from '../../../utils/clipboard';
import {
CODEX_DEVICE_AUTH_URL,
TERMINAL_INIT_DELAY_MS,
TERMINAL_OPTIONS,
TERMINAL_RESIZE_DELAY_MS,
} from '../constants/constants';
import { copyTextToClipboard } from '../../../utils/clipboard';
import { isCodexLoginCommand } from '../utils/auth';
import {
installMobileTerminalSelection,
type MobileTerminalSelectionManager,
} from '../utils/mobileTerminalSelection';
import { sendSocketMessage } from '../utils/socket';
import { ensureXtermFocusStyles } from '../utils/terminalStyles';
@@ -24,10 +27,6 @@ type UseShellTerminalOptions = {
selectedProject: Project | null | undefined;
minimal: boolean;
isRestarting: boolean;
initialCommandRef: MutableRefObject<string | null | undefined>;
isPlainShellRef: MutableRefObject<boolean>;
authUrlRef: MutableRefObject<string>;
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
closeSocket: () => void;
};
@@ -45,14 +44,11 @@ export function useShellTerminal({
selectedProject,
minimal,
isRestarting,
initialCommandRef,
isPlainShellRef,
authUrlRef,
copyAuthUrlToClipboard,
closeSocket,
}: UseShellTerminalOptions): UseShellTerminalResult {
const [isInitialized, setIsInitialized] = useState(false);
const resizeTimeoutRef = useRef<number | null>(null);
const mobileSelectionRef = useRef<MobileTerminalSelectionManager | null>(null);
const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || '';
const hasSelectedProject = Boolean(selectedProject);
@@ -70,6 +66,11 @@ export function useShellTerminal({
}, [terminalRef]);
const disposeTerminal = useCallback(() => {
if (mobileSelectionRef.current) {
mobileSelectionRef.current.dispose();
mobileSelectionRef.current = null;
}
if (terminalRef.current) {
terminalRef.current.dispose();
terminalRef.current = null;
@@ -80,7 +81,8 @@ export function useShellTerminal({
}, [fitAddonRef, terminalRef]);
useEffect(() => {
if (!terminalContainerRef.current || !hasSelectedProject || isRestarting || terminalRef.current) {
const terminalContainer = terminalContainerRef.current;
if (!terminalContainer || !hasSelectedProject || isRestarting || terminalRef.current) {
return;
}
@@ -102,7 +104,28 @@ export function useShellTerminal({
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
}
nextTerminal.open(terminalContainerRef.current);
nextTerminal.open(terminalContainer);
mobileSelectionRef.current = installMobileTerminalSelection(
nextTerminal,
terminalContainer,
{
onFontSizeChange: (fontSize) => {
nextTerminal.options.fontSize = fontSize;
const currentFitAddon = fitAddonRef.current;
if (currentFitAddon) {
currentFitAddon.fit();
sendSocketMessage(wsRef.current, {
type: 'resize',
cols: nextTerminal.cols,
rows: nextTerminal.rows,
});
} else {
nextTerminal.refresh(0, nextTerminal.rows - 1);
}
},
},
);
const copyTerminalSelection = async () => {
const selection = nextTerminal.getSelection();
@@ -133,29 +156,9 @@ export function useShellTerminal({
void copyTextToClipboard(selection);
};
terminalContainerRef.current.addEventListener('copy', handleTerminalCopy);
terminalContainer.addEventListener('copy', handleTerminalCopy);
nextTerminal.attachCustomKeyEventHandler((event) => {
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
? CODEX_DEVICE_AUTH_URL
: authUrlRef.current;
if (
event.type === 'keydown' &&
minimal &&
isPlainShellRef.current &&
activeAuthUrl &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
event.key?.toLowerCase() === 'c'
) {
event.preventDefault();
event.stopPropagation();
void copyAuthUrlToClipboard(activeAuthUrl);
return false;
}
if (
event.type === 'keydown' &&
(event.ctrlKey || event.metaKey) &&
@@ -240,10 +243,10 @@ export function useShellTerminal({
}, TERMINAL_RESIZE_DELAY_MS);
});
resizeObserver.observe(terminalContainerRef.current);
resizeObserver.observe(terminalContainer);
return () => {
terminalContainerRef.current?.removeEventListener('copy', handleTerminalCopy);
terminalContainer.removeEventListener('copy', handleTerminalCopy);
resizeObserver.disconnect();
if (resizeTimeoutRef.current !== null) {
window.clearTimeout(resizeTimeoutRef.current);
@@ -254,16 +257,12 @@ export function useShellTerminal({
disposeTerminal();
};
}, [
authUrlRef,
closeSocket,
copyAuthUrlToClipboard,
disposeTerminal,
fitAddonRef,
initialCommandRef,
isPlainShellRef,
isRestarting,
minimal,
hasSelectedProject,
minimal,
selectedProjectKey,
terminalContainerRef,
terminalRef,

View File

@@ -4,8 +4,6 @@ import type { Terminal } from '@xterm/xterm';
import type { Project, ProjectSession } from '../../../types/app';
export type AuthCopyStatus = 'idle' | 'copied' | 'failed';
export type ShellInitMessage = {
type: 'init';
projectPath: string;
@@ -54,7 +52,6 @@ export type ShellSharedRefs = {
wsRef: MutableRefObject<WebSocket | null>;
terminalRef: MutableRefObject<Terminal | null>;
fitAddonRef: MutableRefObject<FitAddon | null>;
authUrlRef: MutableRefObject<string>;
selectedProjectRef: MutableRefObject<Project | null | undefined>;
selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>;
initialCommandRef: MutableRefObject<string | null | undefined>;
@@ -69,10 +66,6 @@ export type UseShellRuntimeResult = {
isConnected: boolean;
isInitialized: boolean;
isConnecting: boolean;
authUrl: string;
authUrlVersion: number;
connectToShell: (options?: { forceRestart?: boolean }) => void;
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
openAuthUrlInBrowser: (url?: string) => boolean;
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
};

View File

@@ -1,17 +1,4 @@
import type { ProjectSession } from '../../../types/app';
import { CODEX_DEVICE_AUTH_URL } from '../constants/constants';
export function isCodexLoginCommand(command: string | null | undefined): boolean {
return typeof command === 'string' && /\bcodex\s+login\b/i.test(command);
}
export function resolveAuthUrlForDisplay(command: string | null | undefined, authUrl: string): string {
if (isCodexLoginCommand(command)) {
return CODEX_DEVICE_AUTH_URL;
}
return authUrl;
}
export function getSessionDisplayName(session: ProjectSession | null | undefined): string | null {
if (!session) {
@@ -21,4 +8,4 @@ export function getSessionDisplayName(session: ProjectSession | null | undefined
return session.__provider === 'cursor'
? session.name || 'Untitled Session'
: session.summary || 'New Session';
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -59,12 +59,8 @@ export default function Shell({
isConnected,
isInitialized,
isConnecting,
authUrl,
authUrlVersion,
connectToShell,
disconnectFromShell,
openAuthUrlInBrowser,
copyAuthUrlToClipboard,
} = useShellRuntime({
selectedProject,
selectedSession,
@@ -243,15 +239,7 @@ export default function Shell({
if (minimal) {
return (
<>
<ShellMinimalView
terminalContainerRef={terminalContainerRef}
authUrl={authUrl}
authUrlVersion={authUrlVersion}
initialCommand={initialCommand}
isConnected={isConnected}
openAuthUrlInBrowser={openAuthUrlInBrowser}
copyAuthUrlToClipboard={copyAuthUrlToClipboard}
/>
<ShellMinimalView terminalContainerRef={terminalContainerRef} />
<TerminalShortcutsPanel
wsRef={wsRef}
terminalRef={terminalRef}

View File

@@ -1,45 +1,12 @@
import { useEffect, useMemo, useState } from 'react';
import type { RefObject } from 'react';
import type { AuthCopyStatus } from '../../types/types';
import { resolveAuthUrlForDisplay } from '../../utils/auth';
type ShellMinimalViewProps = {
terminalContainerRef: RefObject<HTMLDivElement>;
authUrl: string;
authUrlVersion: number;
initialCommand: string | null | undefined;
isConnected: boolean;
openAuthUrlInBrowser: (url: string) => boolean;
copyAuthUrlToClipboard: (url: string) => Promise<boolean>;
};
export default function ShellMinimalView({
terminalContainerRef,
authUrl,
authUrlVersion,
initialCommand,
isConnected,
openAuthUrlInBrowser,
copyAuthUrlToClipboard,
}: ShellMinimalViewProps) {
const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState<AuthCopyStatus>('idle');
const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false);
const displayAuthUrl = useMemo(
() => resolveAuthUrlForDisplay(initialCommand, authUrl),
[authUrl, initialCommand],
);
// Keep auth panel UI state local to minimal mode and reset it when connection/url changes.
useEffect(() => {
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
}, [authUrlVersion, displayAuthUrl, isConnected]);
const hasAuthUrl = Boolean(displayAuthUrl);
const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden;
const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden;
return (
<div className="relative h-full w-full bg-gray-900">
<div
@@ -47,67 +14,6 @@ export default function ShellMinimalView({
className="h-full w-full focus:outline-none"
style={{ outline: 'none' }}
/>
{showMobileAuthPanel && (
<div className="absolute inset-x-0 bottom-14 z-20 border-t border-gray-700/80 bg-gray-900/95 p-3 backdrop-blur-sm md:hidden">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-gray-300">Open or copy the login URL:</p>
<button
type="button"
onClick={() => setIsAuthPanelHidden(true)}
className="rounded bg-gray-700 px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-gray-100 hover:bg-gray-600"
>
Hide
</button>
</div>
<input
type="text"
value={displayAuthUrl}
readOnly
onClick={(event) => event.currentTarget.select()}
className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
aria-label="Authentication URL"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
openAuthUrlInBrowser(displayAuthUrl);
}}
className="flex-1 rounded bg-blue-600 px-3 py-2 text-xs font-medium text-white hover:bg-blue-700"
>
Open URL
</button>
<button
type="button"
onClick={async () => {
const copied = await copyAuthUrlToClipboard(displayAuthUrl);
setAuthUrlCopyStatus(copied ? 'copied' : 'failed');
}}
className="flex-1 rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-600"
>
{authUrlCopyStatus === 'copied' ? 'Copied' : 'Copy URL'}
</button>
</div>
</div>
</div>
)}
{showMobileAuthPanelToggle && (
<div className="absolute bottom-14 right-3 z-20 md:hidden">
<button
type="button"
onClick={() => setIsAuthPanelHidden(false)}
className="rounded bg-gray-800/95 px-3 py-2 text-xs font-medium text-gray-100 shadow-lg backdrop-blur-sm hover:bg-gray-700"
>
Show login URL
</button>
</div>
)}
</div>
);
}

View File

@@ -26,6 +26,7 @@ const MOBILE_KEYS: Shortcut[] = [
{ type: 'arrow', id: 'arrow-down', sequence: '\x1b[B', icon: 'down' },
{ type: 'arrow', id: 'arrow-left', sequence: '\x1b[D', icon: 'left' },
{ type: 'arrow', id: 'arrow-right', sequence: '\x1b[C', icon: 'right' },
{ type: 'key', id: 'ctrl-c', label: 'Ctrl+C', sequence: '\x03' },
];
const ARROW_ICONS = {

View File

@@ -32,5 +32,10 @@
"binaryFile": {
"title": "Binary File",
"message": "The file \"{{fileName}}\" cannot be displayed in the text editor because it is a binary file."
},
"filePreview": {
"loading": "Loading preview...",
"error": "Unable to display this file.",
"openInNewTab": "Open in new tab"
}
}

View File

@@ -139,6 +139,12 @@
height: 100%;
margin: 0;
padding: 0;
/* The app shell is a fixed inset-0 container (see AppContent), so the
document itself never needs to scroll. Clipping it removes the phantom
full-height page scrollbar and disables the browser pull-to-refresh
gesture that reloads the page when scrolling up on mobile. */
overflow: hidden;
overscroll-behavior-y: contain;
}
/* Root element with safe area padding for PWA */

View File

@@ -4,6 +4,13 @@ export type ProviderModelOption = {
value: string;
label: string;
description?: string;
effort?: {
default?: string;
values: {
value: string;
description?: string;
}[];
};
};
export type ProviderModelsDefinition = {