Merge origin/main into deferred OpenCode restart branch.
Resolve ProvidersPage and lifecycle conflicts with custom providers and AppImage ARGV0 stripping. Address review follow-ups: OAuth index helper + tests, single auth-methods load trigger, shared Google env-alias module with VS Code parity coverage, and deferred restart for custom provider upsert. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
@@ -111,7 +111,15 @@ const EMPTY_QUEUE: QueuedMessage[] = [];
|
||||
export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: QueuedMessageChipsProps) => {
|
||||
const { t } = useI18n();
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
|
||||
// Must use the same resolution the composer used to build the queue key —
|
||||
// reading currentSessionDirectory raw can key the chips to a different
|
||||
// directory than the one the messages were queued under.
|
||||
const currentSessionDirectory = useSessionUIStore(
|
||||
React.useCallback(
|
||||
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
|
||||
[currentSessionId],
|
||||
),
|
||||
);
|
||||
const target = currentSessionId ? createMessageQueueTarget(currentSessionId, currentSessionDirectory) : null;
|
||||
const queueKey = target ? getMessageQueueKey(target) : null;
|
||||
const queuedMessages = useMessageQueueStore(
|
||||
|
||||
@@ -59,7 +59,7 @@ Use this doc when you ask an agent to change tool/header/description behavior.
|
||||
- Every other tool, including search/fetch, OpenCode built-ins, custom tools, plugins, and MCP tools, is **expandable** and renders through `ToolPart`.
|
||||
- The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card.
|
||||
- `ToolPart` defers expanded content after a user toggle, preventing large tool input/output payloads from mounting during the initial chat render.
|
||||
- Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its fixed-height output viewport follows new output until the user scrolls up, then resumes following when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output bypasses the throttle and receives the normal one-time highlighted rendering.
|
||||
- Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its fixed-height output viewport follows new output until the user scrolls up, then resumes following when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output normalizes ANSI terminal controls with a bounded synthetic-cell budget, bypasses the throttle, and receives the normal one-time highlighted rendering.
|
||||
- Thinking/Justification duration is hidden in `sorted` mode (handled in `ReasoningPart.tsx` + `JustificationBlock.tsx`).
|
||||
|
||||
## "I want to change description for Perplexity" (example recipe)
|
||||
|
||||
@@ -1,24 +1,109 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { getStreamingOutputAppend, getToolOutput, renderTerminalOutput } from './toolOutput';
|
||||
import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
|
||||
import { tryParseJsonOutput } from '../toolRenderers';
|
||||
import { getStreamingThrottleText } from '../../hooks/useStreamingTextThrottle';
|
||||
import { getStreamingOutputAppend, getToolOutput } from './toolOutput';
|
||||
import { getToolDescriptionFallback } from './toolRenderUtils';
|
||||
|
||||
describe('getToolOutput', () => {
|
||||
test('prefers authoritative state output', () => {
|
||||
expect(getToolOutput('bash', 'final output', 'streamed output')).toBe('final output');
|
||||
expect(getToolOutput('bash', '', 'streamed output')).toBe('');
|
||||
test('prefers state.output for completed tools', () => {
|
||||
expect(getToolOutput('bash', 'final output', 'partial output', 'completed')).toBe('final output');
|
||||
});
|
||||
|
||||
test('falls back to streamed metadata output for bash', () => {
|
||||
expect(getToolOutput('bash', undefined, 'streamed output')).toBe('streamed output');
|
||||
expect(getToolOutput('bash', undefined, '')).toBe(undefined);
|
||||
test('normalizes completed bash state output while preserving final-output precedence', () => {
|
||||
expect(getToolOutput('bash', '\u001B[32mFinal output\u001B[0m', 'partial output', 'completed')).toBe('Final output');
|
||||
});
|
||||
|
||||
test('does not expose metadata output for other tools', () => {
|
||||
expect(getToolOutput('read', undefined, 'metadata output')).toBe(undefined);
|
||||
test('falls back to metadata.output for bash tools without state output', () => {
|
||||
expect(getToolOutput('bash', undefined, 'partial output', 'completed')).toBe('partial output');
|
||||
});
|
||||
|
||||
test('normalizes bash metadata output for completed state', () => {
|
||||
expect(getToolOutput('bash', undefined, 'Progress 10%\r\u001B[2KProgress 90%', 'completed')).toBe('Progress 90%');
|
||||
});
|
||||
|
||||
test('does not normalize bash output while running', () => {
|
||||
expect(getToolOutput('bash', '\u001B[32mRunning\u001B[0m', undefined, 'running')).toBe('\u001B[32mRunning\u001B[0m');
|
||||
expect(getToolOutput('bash', undefined, 'Progress\r\u001B[2K', 'running')).toBe('Progress\r\u001B[2K');
|
||||
});
|
||||
|
||||
test('ignores metadata.output for non-bash tools', () => {
|
||||
expect(getToolOutput('read', undefined, 'partial output', 'completed')).toBe(undefined);
|
||||
expect(getToolOutput('read', 'final output', 'partial output', 'completed')).toBe('final output');
|
||||
});
|
||||
|
||||
test('returns undefined when bash has no output', () => {
|
||||
expect(getToolOutput('bash', undefined, undefined, 'completed')).toBe(undefined);
|
||||
});
|
||||
|
||||
test('ignores empty metadata.output for bash', () => {
|
||||
expect(getToolOutput('bash', undefined, '', 'completed')).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderTerminalOutput', () => {
|
||||
test('renders carriage-return progress updates as their latest value', () => {
|
||||
expect(renderTerminalOutput('Downloading 10%\r\u001B[2KDownloading 90%')).toBe('Downloading 90%');
|
||||
});
|
||||
|
||||
test('removes ANSI styles while preserving the output text', () => {
|
||||
expect(renderTerminalOutput('\u001B[32mComplete\u001B[0m\n')).toBe('Complete\n');
|
||||
});
|
||||
|
||||
test('applies cursor-up progress updates to the prior line', () => {
|
||||
expect(renderTerminalOutput('First\nWorking\u001B[1A\r\u001B[2KDone\n')).toBe('Done\nWorking');
|
||||
});
|
||||
|
||||
test('CSI K erases from cursor to end of line', () => {
|
||||
expect(renderTerminalOutput('Hello World\u001B[5G\u001B[K')).toBe('Hell');
|
||||
});
|
||||
|
||||
test('CSI 1 K erases from beginning of line through cursor, preserving suffix', () => {
|
||||
expect(renderTerminalOutput('Hello World\u001B[6G\u001B[1K')).toBe(' World');
|
||||
});
|
||||
|
||||
test('CSI 2 K erases entire line', () => {
|
||||
expect(renderTerminalOutput('Hello World\u001B[2K')).toBe('');
|
||||
});
|
||||
|
||||
test('handles large single-line output without quadratic slowdown', () => {
|
||||
const largeLine = 'A'.repeat(50000) + '\u001B[0m';
|
||||
const start = performance.now();
|
||||
const result = renderTerminalOutput(largeLine);
|
||||
const elapsed = performance.now() - start;
|
||||
expect(result).toBe('A'.repeat(50000));
|
||||
expect(elapsed).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
test('bounds synthetic rows from large cursor coordinates', () => {
|
||||
const result = renderTerminalOutput('\u001B[999999999Bdone');
|
||||
expect(result.endsWith('done')).toBe(true);
|
||||
expect(result.length <= 100_004).toBe(true);
|
||||
});
|
||||
|
||||
test('bounds synthetic columns from large cursor coordinates', () => {
|
||||
const result = renderTerminalOutput('\u001B[999999999Cdone');
|
||||
expect(result.endsWith('done')).toBe(true);
|
||||
expect(result.length <= 100_004).toBe(true);
|
||||
});
|
||||
|
||||
test('shares the synthetic allocation budget across cursor movements', () => {
|
||||
const result = renderTerminalOutput('\u001B[50001B\u001B[999999999Cdone');
|
||||
expect(result.endsWith('done')).toBe(true);
|
||||
expect(result.length <= 100_004).toBe(true);
|
||||
});
|
||||
|
||||
test('bounds absolute cursor row and column coordinates', () => {
|
||||
const result = renderTerminalOutput('\u001B[999999999;999999999Hdone');
|
||||
expect(result.endsWith('done')).toBe(true);
|
||||
expect(result.length <= 100_004).toBe(true);
|
||||
});
|
||||
|
||||
test('bounds absolute cursor columns', () => {
|
||||
const result = renderTerminalOutput('\u001B[999999999Gdone');
|
||||
expect(result.endsWith('done')).toBe(true);
|
||||
expect(result.length <= 100_004).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1362,7 +1362,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
const stateWithData = state as ToolStateWithMetadata;
|
||||
const metadata = stateWithData.metadata;
|
||||
const input = stateWithData.input;
|
||||
const rawOutput = getToolOutput(part.tool, stateWithData.output, metadata?.output);
|
||||
const rawOutput = getToolOutput(part.tool, stateWithData.output, metadata?.output, state.status);
|
||||
const hasStringOutput = typeof rawOutput === 'string' && rawOutput.length > 0;
|
||||
const rawOutputString = typeof rawOutput === 'string' ? rawOutput : '';
|
||||
const isStreamingBash = part.tool === 'bash' && state.status === 'running';
|
||||
|
||||
@@ -1,14 +1,153 @@
|
||||
const MAX_SYNTHETIC_TERMINAL_CELLS = 100_000;
|
||||
|
||||
interface TerminalRenderBudget {
|
||||
syntheticCells: number;
|
||||
}
|
||||
|
||||
const ensureLine = (lines: string[][], requestedRow: number, budget: TerminalRenderBudget): number => {
|
||||
const missingRows = Math.max(0, requestedRow - lines.length + 1);
|
||||
const availableCells = MAX_SYNTHETIC_TERMINAL_CELLS - budget.syntheticCells;
|
||||
const addedRows = Math.min(missingRows, availableCells);
|
||||
const row = Math.min(requestedRow, lines.length + addedRows - 1);
|
||||
|
||||
while (lines.length <= row) {
|
||||
lines.push([]);
|
||||
}
|
||||
budget.syntheticCells += addedRows;
|
||||
return row;
|
||||
};
|
||||
|
||||
const writeTerminalCharacter = (
|
||||
lines: string[][],
|
||||
row: number,
|
||||
requestedColumn: number,
|
||||
character: string,
|
||||
budget: TerminalRenderBudget,
|
||||
): number => {
|
||||
const line = lines[row];
|
||||
const availableCells = MAX_SYNTHETIC_TERMINAL_CELLS - budget.syntheticCells;
|
||||
const column = Math.min(requestedColumn, line.length + availableCells);
|
||||
const padding = Math.max(0, column - line.length);
|
||||
while (line.length < column) {
|
||||
line.push(' ');
|
||||
}
|
||||
budget.syntheticCells += padding;
|
||||
line[column] = character;
|
||||
return column;
|
||||
};
|
||||
|
||||
export const renderTerminalOutput = (output: string): string => {
|
||||
if (!output.includes('\u001B') && !output.includes('\r') && !output.includes('\b')) {
|
||||
return output;
|
||||
}
|
||||
|
||||
const lines: string[][] = [[]];
|
||||
const budget: TerminalRenderBudget = { syntheticCells: 0 };
|
||||
let row = 0;
|
||||
let column = 0;
|
||||
|
||||
for (let index = 0; index < output.length; index += 1) {
|
||||
const character = output[index];
|
||||
|
||||
if (character === '\n') {
|
||||
row += 1;
|
||||
column = 0;
|
||||
lines[row] ??= [];
|
||||
continue;
|
||||
}
|
||||
if (character === '\r') {
|
||||
column = 0;
|
||||
continue;
|
||||
}
|
||||
if (character === '\b') {
|
||||
column = Math.max(0, column - 1);
|
||||
continue;
|
||||
}
|
||||
if (character !== '\u001B') {
|
||||
column = writeTerminalCharacter(lines, row, column, character, budget) + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextCharacter = output[index + 1];
|
||||
if (nextCharacter === '[') {
|
||||
const sequenceStart = index + 2;
|
||||
let sequenceEnd = sequenceStart;
|
||||
while (sequenceEnd < output.length && !/[\x40-\x7E]/.test(output[sequenceEnd])) {
|
||||
sequenceEnd += 1;
|
||||
}
|
||||
if (sequenceEnd === output.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const command = output[sequenceEnd];
|
||||
const parameters = output.slice(sequenceStart, sequenceEnd).split(';').map((value) => Number.parseInt(value, 10) || 0);
|
||||
const count = parameters[0] || 1;
|
||||
if (command === 'A') {
|
||||
row = Math.max(0, row - count);
|
||||
} else if (command === 'B') {
|
||||
row = ensureLine(lines, row + count, budget);
|
||||
} else if (command === 'C') {
|
||||
column += count;
|
||||
} else if (command === 'D') {
|
||||
column = Math.max(0, column - count);
|
||||
} else if (command === 'G') {
|
||||
column = Math.max(0, count - 1);
|
||||
} else if (command === 'H' || command === 'f') {
|
||||
row = ensureLine(lines, Math.max(0, (parameters[0] || 1) - 1), budget);
|
||||
column = Math.max(0, (parameters[1] || 1) - 1);
|
||||
} else if (command === 'K') {
|
||||
const line = lines[row];
|
||||
const mode = parameters[0];
|
||||
if (mode === 1) {
|
||||
for (let i = 0; i <= column && i < line.length; i += 1) {
|
||||
line[i] = ' ';
|
||||
}
|
||||
} else if (mode === 2) {
|
||||
lines[row] = [];
|
||||
} else {
|
||||
line.length = Math.min(line.length, column);
|
||||
}
|
||||
}
|
||||
index = sequenceEnd;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nextCharacter === ']') {
|
||||
const terminator = output.indexOf('\u0007', index + 2);
|
||||
const stringTerminator = output.indexOf('\u001B\\', index + 2);
|
||||
const end = terminator === -1
|
||||
? stringTerminator
|
||||
: stringTerminator === -1
|
||||
? terminator
|
||||
: Math.min(terminator, stringTerminator);
|
||||
if (end === -1) {
|
||||
break;
|
||||
}
|
||||
index = output[end] === '\u0007' ? end : end + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return lines.map((line) => line.join('')).join('\n');
|
||||
};
|
||||
|
||||
export const getToolOutput = (
|
||||
tool: string,
|
||||
stateOutput: unknown,
|
||||
metadataOutput: unknown,
|
||||
status?: string,
|
||||
): string | undefined => {
|
||||
const isBash = tool === 'bash';
|
||||
const shouldNormalize = isBash && status !== 'running';
|
||||
|
||||
if (typeof stateOutput === 'string') {
|
||||
return stateOutput;
|
||||
return shouldNormalize ? renderTerminalOutput(stateOutput) : stateOutput;
|
||||
}
|
||||
|
||||
if (tool === 'bash' && typeof metadataOutput === 'string' && metadataOutput.length > 0) {
|
||||
return metadataOutput;
|
||||
if (isBash && typeof metadataOutput === 'string' && metadataOutput.length > 0) {
|
||||
return shouldNormalize ? renderTerminalOutput(metadataOutput) : metadataOutput;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsStackedField,
|
||||
SETTINGS_FIELDS_STACK_CLASS,
|
||||
SETTINGS_FIELD_LABEL_CLASS,
|
||||
SETTINGS_HELPER_CLASS,
|
||||
SETTINGS_ICON_BUTTON_CLASS,
|
||||
SETTINGS_CONTROL_CLUSTER_CLASS,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
createEmptyCustomProviderForm,
|
||||
createHeaderRow,
|
||||
createModelRow,
|
||||
validateCustomProvider,
|
||||
type CustomProviderFormState,
|
||||
type CustomProviderPersistPlan,
|
||||
type CustomProviderTranslator,
|
||||
type FieldErrors,
|
||||
type HeaderFieldErrors,
|
||||
type ModelFieldErrors,
|
||||
} from './custom-provider-form';
|
||||
|
||||
type CustomProviderFormProps = {
|
||||
existingProviderIDs: ReadonlySet<string>;
|
||||
disabledProviders?: readonly string[];
|
||||
busy?: boolean;
|
||||
mode?: 'create' | 'edit';
|
||||
initialValues?: CustomProviderFormState;
|
||||
allowExistingAuth?: boolean;
|
||||
authFailureHint?: string | null;
|
||||
onSubmit: (plan: CustomProviderPersistPlan) => void | Promise<void>;
|
||||
onCancel?: () => void;
|
||||
onDisconnect?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export const CustomProviderForm: React.FC<CustomProviderFormProps> = ({
|
||||
existingProviderIDs,
|
||||
disabledProviders = [],
|
||||
busy = false,
|
||||
mode = 'create',
|
||||
initialValues,
|
||||
allowExistingAuth = false,
|
||||
authFailureHint = null,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onDisconnect,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isEdit = mode === 'edit';
|
||||
const [form, setForm] = React.useState<CustomProviderFormState>(
|
||||
() => initialValues ?? createEmptyCustomProviderForm(),
|
||||
);
|
||||
const [err, setErr] = React.useState<FieldErrors>({});
|
||||
const [modelErrors, setModelErrors] = React.useState<ModelFieldErrors[]>([]);
|
||||
const [headerErrors, setHeaderErrors] = React.useState<HeaderFieldErrors[]>([]);
|
||||
const seededEditProviderIdRef = React.useRef<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!initialValues) {
|
||||
return;
|
||||
}
|
||||
// Edit mode: seed once per provider id so parent re-renders (new object
|
||||
// identity for the same snapshot) do not wipe in-progress edits.
|
||||
if (isEdit && seededEditProviderIdRef.current === initialValues.providerID) {
|
||||
return;
|
||||
}
|
||||
seededEditProviderIdRef.current = isEdit ? initialValues.providerID : null;
|
||||
setForm(initialValues);
|
||||
setErr({});
|
||||
setModelErrors([]);
|
||||
setHeaderErrors([]);
|
||||
}, [initialValues, isEdit]);
|
||||
|
||||
const setField = (key: keyof Pick<CustomProviderFormState, 'providerID' | 'name' | 'baseURL' | 'apiKey'>, value: string) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
setErr((prev) => ({ ...prev, [key]: undefined }));
|
||||
};
|
||||
|
||||
const setModel = (index: number, key: 'id' | 'name', value: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
models: prev.models.map((row, rowIndex) => (rowIndex === index ? { ...row, [key]: value } : row)),
|
||||
}));
|
||||
setModelErrors((prev) => {
|
||||
const next = [...prev];
|
||||
next[index] = { ...(next[index] ?? {}), [key]: undefined };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const setHeader = (index: number, key: 'key' | 'value', value: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
headers: prev.headers.map((row, rowIndex) => (rowIndex === index ? { ...row, [key]: value } : row)),
|
||||
}));
|
||||
setHeaderErrors((prev) => {
|
||||
const next = [...prev];
|
||||
next[index] = { ...(next[index] ?? {}), [key]: undefined };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const output = validateCustomProvider({
|
||||
form,
|
||||
t: ((key, vars) => t(key as Parameters<typeof t>[0], vars)) as CustomProviderTranslator,
|
||||
existingProviderIDs,
|
||||
disabledProviders,
|
||||
editingProviderID: isEdit ? form.providerID : undefined,
|
||||
allowExistingAuth: isEdit && allowExistingAuth,
|
||||
});
|
||||
setErr(output.err);
|
||||
setModelErrors(output.models);
|
||||
setHeaderErrors(output.headers);
|
||||
if (!output.result) {
|
||||
return;
|
||||
}
|
||||
await onSubmit(output.result);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-0">
|
||||
<SettingsSection
|
||||
title={isEdit ? t('settings.providers.page.custom.editTitle') : t('settings.providers.page.custom.title')}
|
||||
divider={false}
|
||||
settingsItem="providers.custom"
|
||||
contentClassName={SETTINGS_FIELDS_STACK_CLASS}
|
||||
>
|
||||
<p className={SETTINGS_HELPER_CLASS}>{t('settings.providers.page.custom.description')}</p>
|
||||
|
||||
{authFailureHint ? (
|
||||
<p className="typography-meta text-[var(--status-warning)]" role="status">
|
||||
{authFailureHint}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<SettingsStackedField
|
||||
label={t('settings.providers.page.custom.field.providerID.label')}
|
||||
info={t('settings.providers.page.custom.field.providerID.info')}
|
||||
>
|
||||
<Input
|
||||
value={form.providerID}
|
||||
onChange={(event) => setField('providerID', event.target.value)}
|
||||
placeholder={t('settings.providers.page.custom.field.providerID.placeholder')}
|
||||
className="h-8 rounded-md px-3 font-mono text-xs"
|
||||
autoFocus={!isEdit}
|
||||
disabled={isEdit || busy}
|
||||
aria-invalid={Boolean(err.providerID)}
|
||||
aria-label={t('settings.providers.page.custom.field.providerID.label')}
|
||||
/>
|
||||
{err.providerID ? <p className="mt-1 typography-meta text-[var(--status-error)]">{err.providerID}</p> : null}
|
||||
</SettingsStackedField>
|
||||
|
||||
<SettingsStackedField
|
||||
label={t('settings.providers.page.custom.field.name.label')}
|
||||
info={t('settings.providers.page.custom.field.name.info')}
|
||||
>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(event) => setField('name', event.target.value)}
|
||||
placeholder={t('settings.providers.page.custom.field.name.placeholder')}
|
||||
className="h-8 rounded-md px-3"
|
||||
aria-invalid={Boolean(err.name)}
|
||||
aria-label={t('settings.providers.page.custom.field.name.label')}
|
||||
/>
|
||||
{err.name ? <p className="mt-1 typography-meta text-[var(--status-error)]">{err.name}</p> : null}
|
||||
</SettingsStackedField>
|
||||
|
||||
<SettingsStackedField
|
||||
label={t('settings.providers.page.custom.field.baseURL.label')}
|
||||
info={t('settings.providers.page.custom.field.baseURL.info')}
|
||||
>
|
||||
<Input
|
||||
value={form.baseURL}
|
||||
onChange={(event) => setField('baseURL', event.target.value)}
|
||||
placeholder={t('settings.providers.page.custom.field.baseURL.placeholder')}
|
||||
className="h-8 rounded-md px-3 font-mono text-xs"
|
||||
aria-invalid={Boolean(err.baseURL)}
|
||||
aria-label={t('settings.providers.page.custom.field.baseURL.label')}
|
||||
/>
|
||||
{err.baseURL ? <p className="mt-1 typography-meta text-[var(--status-error)]">{err.baseURL}</p> : null}
|
||||
</SettingsStackedField>
|
||||
|
||||
<SettingsStackedField
|
||||
label={t('settings.providers.page.custom.field.apiKey.label')}
|
||||
info={
|
||||
isEdit && allowExistingAuth
|
||||
? t('settings.providers.page.custom.field.apiKey.editInfo')
|
||||
: t('settings.providers.page.custom.field.apiKey.info')
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.apiKey}
|
||||
onChange={(event) => setField('apiKey', event.target.value)}
|
||||
placeholder={
|
||||
isEdit && allowExistingAuth
|
||||
? t('settings.providers.page.custom.field.apiKey.editPlaceholder')
|
||||
: t('settings.providers.page.custom.field.apiKey.placeholder')
|
||||
}
|
||||
className="h-8 rounded-md px-3 font-mono text-xs"
|
||||
aria-invalid={Boolean(err.apiKey)}
|
||||
aria-label={t('settings.providers.page.custom.field.apiKey.label')}
|
||||
/>
|
||||
{err.apiKey ? <p className="mt-1 typography-meta text-[var(--status-error)]">{err.apiKey}</p> : null}
|
||||
</SettingsStackedField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.providers.page.custom.models.title')}
|
||||
contentClassName={SETTINGS_FIELDS_STACK_CLASS}
|
||||
>
|
||||
{form.models.map((model, index) => (
|
||||
<div key={model.row} className={`${SETTINGS_CONTROL_CLUSTER_CLASS} space-y-2`}>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div>
|
||||
<label className={SETTINGS_FIELD_LABEL_CLASS}>
|
||||
{t('settings.providers.page.custom.models.idLabel')}
|
||||
</label>
|
||||
<Input
|
||||
value={model.id}
|
||||
onChange={(event) => setModel(index, 'id', event.target.value)}
|
||||
placeholder={t('settings.providers.page.custom.models.idPlaceholder')}
|
||||
className="mt-1 h-8 rounded-md px-3 font-mono text-xs"
|
||||
aria-label={t('settings.providers.page.custom.models.idLabel')}
|
||||
/>
|
||||
{modelErrors[index]?.id ? (
|
||||
<p className="mt-1 typography-meta text-[var(--status-error)]">{modelErrors[index]?.id}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<label className={SETTINGS_FIELD_LABEL_CLASS}>
|
||||
{t('settings.providers.page.custom.models.nameLabel')}
|
||||
</label>
|
||||
<Input
|
||||
value={model.name}
|
||||
onChange={(event) => setModel(index, 'name', event.target.value)}
|
||||
placeholder={t('settings.providers.page.custom.models.namePlaceholder')}
|
||||
className="mt-1 h-8 rounded-md px-3"
|
||||
aria-label={t('settings.providers.page.custom.models.nameLabel')}
|
||||
/>
|
||||
{modelErrors[index]?.name ? (
|
||||
<p className="mt-1 typography-meta text-[var(--status-error)]">{modelErrors[index]?.name}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={SETTINGS_ICON_BUTTON_CLASS}
|
||||
disabled={form.models.length <= 1}
|
||||
onClick={() => {
|
||||
if (form.models.length <= 1) return;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
models: prev.models.filter((_, rowIndex) => rowIndex !== index),
|
||||
}));
|
||||
setModelErrors((prev) => prev.filter((_, rowIndex) => rowIndex !== index));
|
||||
}}
|
||||
aria-label={t('settings.providers.page.custom.models.remove')}
|
||||
>
|
||||
<Icon name="delete-bin" className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => {
|
||||
setForm((prev) => ({ ...prev, models: [...prev.models, createModelRow()] }));
|
||||
setModelErrors((prev) => [...prev, {}]);
|
||||
}}
|
||||
>
|
||||
{t('settings.providers.page.custom.models.add')}
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.providers.page.custom.headers.title')}
|
||||
contentClassName={SETTINGS_FIELDS_STACK_CLASS}
|
||||
>
|
||||
<p className={SETTINGS_HELPER_CLASS}>{t('settings.providers.page.custom.headers.description')}</p>
|
||||
{form.headers.map((header, index) => (
|
||||
<div key={header.row} className={`${SETTINGS_CONTROL_CLUSTER_CLASS} space-y-2`}>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div>
|
||||
<label className={SETTINGS_FIELD_LABEL_CLASS}>
|
||||
{t('settings.providers.page.custom.headers.keyLabel')}
|
||||
</label>
|
||||
<Input
|
||||
value={header.key}
|
||||
onChange={(event) => setHeader(index, 'key', event.target.value)}
|
||||
placeholder={t('settings.providers.page.custom.headers.keyPlaceholder')}
|
||||
className="mt-1 h-8 rounded-md px-3 font-mono text-xs"
|
||||
aria-label={t('settings.providers.page.custom.headers.keyLabel')}
|
||||
/>
|
||||
{headerErrors[index]?.key ? (
|
||||
<p className="mt-1 typography-meta text-[var(--status-error)]">{headerErrors[index]?.key}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<label className={SETTINGS_FIELD_LABEL_CLASS}>
|
||||
{t('settings.providers.page.custom.headers.valueLabel')}
|
||||
</label>
|
||||
<Input
|
||||
value={header.value}
|
||||
onChange={(event) => setHeader(index, 'value', event.target.value)}
|
||||
placeholder={t('settings.providers.page.custom.headers.valuePlaceholder')}
|
||||
className="mt-1 h-8 rounded-md px-3 font-mono text-xs"
|
||||
aria-label={t('settings.providers.page.custom.headers.valueLabel')}
|
||||
/>
|
||||
{headerErrors[index]?.value ? (
|
||||
<p className="mt-1 typography-meta text-[var(--status-error)]">{headerErrors[index]?.value}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={SETTINGS_ICON_BUTTON_CLASS}
|
||||
disabled={form.headers.length <= 1}
|
||||
onClick={() => {
|
||||
if (form.headers.length <= 1) return;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
headers: prev.headers.filter((_, rowIndex) => rowIndex !== index),
|
||||
}));
|
||||
setHeaderErrors((prev) => prev.filter((_, rowIndex) => rowIndex !== index));
|
||||
}}
|
||||
aria-label={t('settings.providers.page.custom.headers.remove')}
|
||||
>
|
||||
<Icon name="delete-bin" className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => {
|
||||
setForm((prev) => ({ ...prev, headers: [...prev.headers, createHeaderRow()] }));
|
||||
setHeaderErrors((prev) => [...prev, {}]);
|
||||
}}
|
||||
>
|
||||
{t('settings.providers.page.custom.headers.add')}
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 py-4">
|
||||
{onCancel ? (
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={onCancel} disabled={busy}>
|
||||
{t('settings.providers.page.custom.actions.back')}
|
||||
</Button>
|
||||
) : null}
|
||||
{onDisconnect ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => void onDisconnect()}
|
||||
disabled={busy}
|
||||
>
|
||||
{t('settings.providers.page.actions.disconnect')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="submit" size="xs" className="!font-normal" disabled={busy}>
|
||||
{busy
|
||||
? t('settings.providers.page.actions.saving')
|
||||
: isEdit
|
||||
? t('settings.providers.page.custom.actions.update')
|
||||
: t('settings.providers.page.custom.actions.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { shouldLoadAvailableProviders, shouldLoadProviderAuthMethods } from './providerAvailability';
|
||||
import { listOAuthMethods, normalizeAuthType } from './providerAuthMethods';
|
||||
|
||||
describe('ProvidersPage available provider loading', () => {
|
||||
test('loads available providers only in add-provider mode', () => {
|
||||
@@ -16,3 +17,27 @@ describe('ProvidersPage auth method loading', () => {
|
||||
expect(shouldLoadProviderAuthMethods(true, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProvidersPage OAuth method indexes', () => {
|
||||
test('preserves the original provider.auth() index after filtering', () => {
|
||||
const methods = listOAuthMethods([
|
||||
{ type: 'api' },
|
||||
{ type: 'oauth', label: 'Browser' },
|
||||
]);
|
||||
expect(methods).toEqual([{ method: { type: 'oauth', label: 'Browser' }, methodIndex: 1 }]);
|
||||
});
|
||||
|
||||
test('keeps multiple OAuth indexes relative to the full methods array', () => {
|
||||
const methods = listOAuthMethods([
|
||||
{ type: 'oauth', label: 'First' },
|
||||
{ type: 'api' },
|
||||
{ type: 'oauth', label: 'Second' },
|
||||
]);
|
||||
expect(methods.map((entry) => entry.methodIndex)).toEqual([0, 2]);
|
||||
});
|
||||
|
||||
test('detects oauth from labels when type is missing', () => {
|
||||
expect(normalizeAuthType({ label: 'Sign in with OAuth' })).toBe('oauth');
|
||||
expect(normalizeAuthType({ name: 'API Key' })).toBe('api');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,19 @@ import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { shouldLoadAvailableProviders, shouldLoadProviderAuthMethods } from './providerAvailability';
|
||||
import { listOAuthMethods } from './providerAuthMethods';
|
||||
import { CustomProviderForm } from './CustomProviderForm';
|
||||
import {
|
||||
buildAuthSetRequest,
|
||||
buildProviderUpsertRequest,
|
||||
CUSTOM_PROVIDER_ID,
|
||||
isConfigDefinedCustomProvider,
|
||||
providerToCustomFormState,
|
||||
resolveProviderConfigScope,
|
||||
type CustomProviderFormState,
|
||||
type CustomProviderPersistPlan,
|
||||
type ProviderConfigScope,
|
||||
} from './custom-provider-form';
|
||||
|
||||
const formatCompactNumber = (value: number) => new Intl.NumberFormat(getCurrentIntlLocale(), {
|
||||
notation: 'compact',
|
||||
@@ -77,21 +90,6 @@ interface ProviderSources {
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const normalizeAuthType = (method: AuthMethod) => {
|
||||
const raw = typeof method.type === 'string' ? method.type : '';
|
||||
const label = `${method.name ?? ''} ${method.label ?? ''}`.toLowerCase();
|
||||
const merged = `${raw} ${label}`.toLowerCase();
|
||||
if (merged.includes('oauth')) return 'oauth';
|
||||
if (merged.includes('api')) return 'api';
|
||||
return raw.toLowerCase();
|
||||
};
|
||||
|
||||
/** OAuth methods with the original provider.auth() method index OpenCode expects. */
|
||||
const listOAuthMethods = (methods: AuthMethod[]): Array<{ method: AuthMethod; methodIndex: number }> =>
|
||||
methods
|
||||
.map((method, methodIndex) => ({ method, methodIndex }))
|
||||
.filter(({ method }) => normalizeAuthType(method) === 'oauth');
|
||||
|
||||
const parseAuthPayload = (payload: unknown): Record<string, AuthMethod[]> => {
|
||||
if (!isRecord(payload)) {
|
||||
return {};
|
||||
@@ -178,7 +176,20 @@ export const ProvidersPage: React.FC = () => {
|
||||
const [providerDropdownOpen, setProviderDropdownOpen] = React.useState(false);
|
||||
const [providerSources, setProviderSources] = React.useState<Record<string, ProviderSources>>({});
|
||||
const [showAuthPanel, setShowAuthPanel] = React.useState(false);
|
||||
const [editingCustomProviderId, setEditingCustomProviderId] = React.useState<string | null>(null);
|
||||
const [editingCustomFormInitial, setEditingCustomFormInitial] = React.useState<CustomProviderFormState | null>(null);
|
||||
const [editingCustomScope, setEditingCustomScope] = React.useState<ProviderConfigScope | null>(null);
|
||||
const [customAuthFailureHint, setCustomAuthFailureHint] = React.useState<string | null>(null);
|
||||
const [lastCustomPersistId, setLastCustomPersistId] = React.useState<string | null>(null);
|
||||
const isAddMode = selectedProviderId === ADD_PROVIDER_ID;
|
||||
const loadAuthMethods = shouldLoadProviderAuthMethods(isAddMode, showAuthPanel);
|
||||
const isCustomCreateMode = isAddMode && candidateProviderId === CUSTOM_PROVIDER_ID;
|
||||
const isCustomEditMode = Boolean(
|
||||
editingCustomProviderId
|
||||
&& selectedProviderId
|
||||
&& editingCustomProviderId === selectedProviderId
|
||||
&& !isAddMode,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedProviderId && providers.length > 0) {
|
||||
@@ -187,13 +198,13 @@ export const ProvidersPage: React.FC = () => {
|
||||
}, [providers, selectedProviderId, setSelectedProvider]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!shouldLoadProviderAuthMethods(isAddMode, showAuthPanel)) {
|
||||
if (!loadAuthMethods) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const loadAuthMethods = async () => {
|
||||
const fetchAuthMethods = async () => {
|
||||
setAuthLoading(true);
|
||||
try {
|
||||
const result = await opencodeClient.getSdkClient().provider.auth();
|
||||
@@ -213,12 +224,12 @@ export const ProvidersPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
loadAuthMethods();
|
||||
void fetchAuthMethods();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [isAddMode, showAuthPanel, t]);
|
||||
}, [loadAuthMethods, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!shouldLoadAvailableProviders(isAddMode)) {
|
||||
@@ -277,7 +288,11 @@ export const ProvidersPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (candidateProviderId && !unconnectedProviders.some((provider) => provider.id === candidateProviderId)) {
|
||||
if (
|
||||
candidateProviderId
|
||||
&& candidateProviderId !== CUSTOM_PROVIDER_ID
|
||||
&& !unconnectedProviders.some((provider) => provider.id === candidateProviderId)
|
||||
) {
|
||||
setCandidateProviderId('');
|
||||
}
|
||||
}, [selectedProviderId, candidateProviderId, unconnectedProviders]);
|
||||
@@ -285,11 +300,21 @@ export const ProvidersPage: React.FC = () => {
|
||||
React.useEffect(() => {
|
||||
if (selectedProviderId === ADD_PROVIDER_ID) {
|
||||
setShowAuthPanel(true);
|
||||
setEditingCustomProviderId(null);
|
||||
setEditingCustomFormInitial(null);
|
||||
setEditingCustomScope(null);
|
||||
setCustomAuthFailureHint(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowAuthPanel(false);
|
||||
}, [selectedProviderId, t]);
|
||||
if (editingCustomProviderId && editingCustomProviderId !== selectedProviderId) {
|
||||
setEditingCustomProviderId(null);
|
||||
setEditingCustomFormInitial(null);
|
||||
setEditingCustomScope(null);
|
||||
setCustomAuthFailureHint(null);
|
||||
}
|
||||
}, [selectedProviderId, editingCustomProviderId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) {
|
||||
@@ -367,6 +392,68 @@ export const ProvidersPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveCustomProvider = async (plan: CustomProviderPersistPlan) => {
|
||||
const busyKey = `custom:${plan.providerID}`;
|
||||
setAuthBusyKey(busyKey);
|
||||
setLastCustomPersistId(plan.providerID);
|
||||
setCustomAuthFailureHint(null);
|
||||
|
||||
try {
|
||||
// Auth first so a failed key write cannot leave an orphan config that
|
||||
// blocks create validation, and so PUT can pass hasStoredAuth for literal keys.
|
||||
const authRequest = buildAuthSetRequest(plan);
|
||||
if (authRequest) {
|
||||
const authResult = await opencodeClient.getSdkClient().auth.set(authRequest);
|
||||
if (authResult.error) {
|
||||
throw new Error(t('settings.providers.page.toast.apiKeySaveFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
const upsertBody = buildProviderUpsertRequest(plan, {
|
||||
// Create defaults to user. Edit must rewrite the winning config layer
|
||||
// (custom > project > user) so project/custom providers are not copied
|
||||
// into a global user override.
|
||||
scope: editingCustomProviderId
|
||||
? (editingCustomScope ?? resolveProviderConfigScope(providerSources[editingCustomProviderId]))
|
||||
: 'user',
|
||||
});
|
||||
const response = await runtimeFetch('/api/provider', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(upsertBody),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
if (authRequest) {
|
||||
setCustomAuthFailureHint(t('settings.providers.page.custom.authFailure.configAfterAuth'));
|
||||
}
|
||||
throw new Error(payload?.error || t('settings.providers.page.toast.customProviderSaveFailed'));
|
||||
}
|
||||
|
||||
toast.success(t('settings.providers.page.toast.customProviderSaved', { provider: plan.name }));
|
||||
setCandidateProviderId('');
|
||||
setEditingCustomProviderId(null);
|
||||
setEditingCustomFormInitial(null);
|
||||
setEditingCustomScope(null);
|
||||
setCustomAuthFailureHint(null);
|
||||
setLastCustomPersistId(null);
|
||||
noteDeferredRestartFromPayload(payload, 'providers', { id: plan.providerID });
|
||||
setSelectedProvider(plan.providerID);
|
||||
} catch (error) {
|
||||
console.error('Failed to save custom provider:', error);
|
||||
toast.error(
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: t('settings.providers.page.toast.customProviderSaveFailed'),
|
||||
);
|
||||
} finally {
|
||||
setAuthBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOAuthStart = async (providerId: string, methodIndex: number) => {
|
||||
const busyKey = `oauth:${providerId}:${methodIndex}`;
|
||||
setAuthBusyKey(busyKey);
|
||||
@@ -507,6 +594,19 @@ export const ProvidersPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnectCustomProvider = async (providerId: string) => {
|
||||
if (!providerId) {
|
||||
return;
|
||||
}
|
||||
await handleDisconnectProvider(providerId);
|
||||
setEditingCustomProviderId(null);
|
||||
setEditingCustomFormInitial(null);
|
||||
setEditingCustomScope(null);
|
||||
setCustomAuthFailureHint(null);
|
||||
setLastCustomPersistId(null);
|
||||
setCandidateProviderId('');
|
||||
};
|
||||
|
||||
if (!isAddMode && providers.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
@@ -536,8 +636,6 @@ export const ProvidersPage: React.FC = () => {
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.state.loading')}</p>
|
||||
) : availableError ? (
|
||||
<p className="typography-meta text-muted-foreground">{availableError}</p>
|
||||
) : unconnectedProviders.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.connect.allProvidersConnected')}</p>
|
||||
) : (
|
||||
<DropdownMenu open={providerDropdownOpen} onOpenChange={(open) => {
|
||||
setProviderDropdownOpen(open);
|
||||
@@ -549,11 +647,15 @@ export const ProvidersPage: React.FC = () => {
|
||||
className={SETTINGS_CUSTOM_TRIGGER_CLASS}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
{candidateProviderId ? <ProviderLogo providerId={candidateProviderId} className="h-3.5 w-3.5 flex-shrink-0" /> : null}
|
||||
{candidateProviderId && candidateProviderId !== CUSTOM_PROVIDER_ID ? (
|
||||
<ProviderLogo providerId={candidateProviderId} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
) : null}
|
||||
<span className={cn("truncate typography-ui-label font-normal", candidateProviderId ? "text-foreground" : "text-muted-foreground")}>
|
||||
{candidateProviderId
|
||||
? (unconnectedProviders.find(p => p.id === candidateProviderId)?.name || candidateProviderId)
|
||||
: t('settings.providers.page.connect.selectProviderPlaceholder')}
|
||||
{candidateProviderId === CUSTOM_PROVIDER_ID
|
||||
? t('settings.providers.page.custom.optionLabel')
|
||||
: candidateProviderId
|
||||
? (unconnectedProviders.find(p => p.id === candidateProviderId)?.name || candidateProviderId)
|
||||
: t('settings.providers.page.connect.selectProviderPlaceholder')}
|
||||
</span>
|
||||
</span>
|
||||
<Icon name="arrow-down-s" className="h-4 w-4 flex-shrink-0 text-muted-foreground/50" />
|
||||
@@ -581,32 +683,60 @@ export const ProvidersPage: React.FC = () => {
|
||||
</div>
|
||||
<ScrollableOverlay outerClassName="max-h-[240px]" className="p-1">
|
||||
{(() => {
|
||||
const query = providerSearchQuery.toLowerCase();
|
||||
const customLabel = t('settings.providers.page.custom.optionLabel');
|
||||
const customMatches = !query
|
||||
|| customLabel.toLowerCase().includes(query)
|
||||
|| 'other'.includes(query)
|
||||
|| 'custom'.includes(query);
|
||||
const filtered = unconnectedProviders.filter(p => {
|
||||
const query = providerSearchQuery.toLowerCase();
|
||||
return (p.name || p.id).toLowerCase().includes(query) || p.id.toLowerCase().includes(query);
|
||||
});
|
||||
if (filtered.length === 0) {
|
||||
if (filtered.length === 0 && !customMatches) {
|
||||
return <p className="py-4 text-center typography-meta text-muted-foreground">{t('settings.providers.page.connect.noProvidersFound')}</p>;
|
||||
}
|
||||
return filtered.map((provider) => (
|
||||
<DropdownMenuItem
|
||||
key={provider.id}
|
||||
onSelect={() => {
|
||||
setCandidateProviderId(provider.id);
|
||||
setProviderDropdownOpen(false);
|
||||
setProviderSearchQuery('');
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<ProviderLogo providerId={provider.id} className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">{provider.name || provider.id}</span>
|
||||
</span>
|
||||
{candidateProviderId === provider.id && (
|
||||
<Icon name="check" className="h-4 w-4 text-[var(--primary-base)]" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
));
|
||||
return (
|
||||
<>
|
||||
{filtered.map((provider) => (
|
||||
<DropdownMenuItem
|
||||
key={provider.id}
|
||||
onSelect={() => {
|
||||
setCandidateProviderId(provider.id);
|
||||
setProviderDropdownOpen(false);
|
||||
setProviderSearchQuery('');
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<ProviderLogo providerId={provider.id} className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">{provider.name || provider.id}</span>
|
||||
</span>
|
||||
{candidateProviderId === provider.id && (
|
||||
<Icon name="check" className="h-4 w-4 text-[var(--primary-base)]" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{customMatches ? (
|
||||
<DropdownMenuItem
|
||||
key={CUSTOM_PROVIDER_ID}
|
||||
onSelect={() => {
|
||||
setCandidateProviderId(CUSTOM_PROVIDER_ID);
|
||||
setProviderDropdownOpen(false);
|
||||
setProviderSearchQuery('');
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<Icon name="add" className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">{customLabel}</span>
|
||||
</span>
|
||||
{candidateProviderId === CUSTOM_PROVIDER_ID && (
|
||||
<Icon name="check" className="h-4 w-4 text-[var(--primary-base)]" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</ScrollableOverlay>
|
||||
</DropdownMenuContent>
|
||||
@@ -615,7 +745,25 @@ export const ProvidersPage: React.FC = () => {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{candidateProviderId && (
|
||||
{isCustomCreateMode ? (
|
||||
<CustomProviderForm
|
||||
mode="create"
|
||||
existingProviderIDs={connectedProviderIds}
|
||||
busy={authBusyKey?.startsWith('custom:') ?? false}
|
||||
authFailureHint={customAuthFailureHint}
|
||||
onCancel={() => {
|
||||
setCandidateProviderId('');
|
||||
setCustomAuthFailureHint(null);
|
||||
setLastCustomPersistId(null);
|
||||
}}
|
||||
onDisconnect={
|
||||
customAuthFailureHint && lastCustomPersistId
|
||||
? () => void handleDisconnectCustomProvider(lastCustomPersistId)
|
||||
: undefined
|
||||
}
|
||||
onSubmit={handleSaveCustomProvider}
|
||||
/>
|
||||
) : candidateProviderId ? (
|
||||
<SettingsSection
|
||||
title={t('settings.providers.page.auth.title')}
|
||||
settingsItem="providers.auth"
|
||||
@@ -747,7 +895,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
) : null}
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -767,6 +915,16 @@ export const ProvidersPage: React.FC = () => {
|
||||
const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : [];
|
||||
const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? [];
|
||||
const oauthAuthMethods = listOAuthMethods(providerAuthMethods);
|
||||
const sourcesLoaded = Boolean(selectedSources);
|
||||
const isEditableCustomProvider = sourcesLoaded
|
||||
&& isConfigDefinedCustomProvider(selectedProvider, selectedSources);
|
||||
const providerEnv = Array.isArray(selectedProvider.env)
|
||||
? selectedProvider.env.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
|
||||
: [];
|
||||
const hasStoredAuth = Boolean(selectedSources?.auth.exists);
|
||||
const hasEnvCredentials = providerEnv.length > 0;
|
||||
const hasCredentials = hasStoredAuth || hasEnvCredentials;
|
||||
const authStatusIncomplete = isEditableCustomProvider && !hasCredentials;
|
||||
|
||||
const filteredModels = providerModels.filter((model) => {
|
||||
const name = typeof model?.name === 'string' ? model.name : '';
|
||||
@@ -776,6 +934,35 @@ export const ProvidersPage: React.FC = () => {
|
||||
return name.toLowerCase().includes(query) || id.toLowerCase().includes(query);
|
||||
});
|
||||
|
||||
if (isCustomEditMode && isEditableCustomProvider && editingCustomFormInitial) {
|
||||
return (
|
||||
<SettingsPageLayout
|
||||
title={selectedProvider.name || selectedProvider.id}
|
||||
titleLeading={<ProviderLogo providerId={selectedProvider.id} className="h-5 w-5 shrink-0" />}
|
||||
description={<span className="font-mono typography-settings-description text-muted-foreground">{selectedProvider.id}</span>}
|
||||
showSaveStatus={false}
|
||||
>
|
||||
<CustomProviderForm
|
||||
mode="edit"
|
||||
existingProviderIDs={connectedProviderIds}
|
||||
initialValues={editingCustomFormInitial}
|
||||
allowExistingAuth={hasCredentials || !sourcesLoaded}
|
||||
busy={authBusyKey?.startsWith('custom:') ?? false}
|
||||
authFailureHint={customAuthFailureHint}
|
||||
onCancel={() => {
|
||||
setEditingCustomProviderId(null);
|
||||
setEditingCustomFormInitial(null);
|
||||
setEditingCustomScope(null);
|
||||
setCustomAuthFailureHint(null);
|
||||
setLastCustomPersistId(null);
|
||||
}}
|
||||
onDisconnect={() => void handleDisconnectCustomProvider(selectedProvider.id)}
|
||||
onSubmit={handleSaveCustomProvider}
|
||||
/>
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsPageLayout
|
||||
title={selectedProvider.name || selectedProvider.id}
|
||||
@@ -787,23 +974,48 @@ export const ProvidersPage: React.FC = () => {
|
||||
title={t('settings.providers.page.auth.title')}
|
||||
divider={false}
|
||||
headerAction={(
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => setShowAuthPanel((prev) => !prev)}
|
||||
>
|
||||
{showAuthPanel ? t('settings.providers.page.actions.hide') : t('settings.providers.page.actions.reconnect')}
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{isEditableCustomProvider ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => {
|
||||
setCustomAuthFailureHint(null);
|
||||
setEditingCustomFormInitial(providerToCustomFormState(selectedProvider));
|
||||
setEditingCustomScope(resolveProviderConfigScope(selectedSources));
|
||||
setEditingCustomProviderId(selectedProvider.id);
|
||||
}}
|
||||
>
|
||||
{t('settings.providers.page.actions.edit')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => setShowAuthPanel((prev) => !prev)}
|
||||
>
|
||||
{showAuthPanel ? t('settings.providers.page.actions.hide') : t('settings.providers.page.actions.reconnect')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
settingsItem="providers.auth"
|
||||
>
|
||||
{!showAuthPanel ? (
|
||||
<div className="flex items-center gap-1.5 py-1.5">
|
||||
<Icon name="check" className="w-4 h-4 text-[var(--status-success)] shrink-0" />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.providers.page.auth.connected')}</span>
|
||||
<SettingsInfoHint>{t('settings.providers.page.auth.useReconnectHint')}</SettingsInfoHint>
|
||||
</div>
|
||||
authStatusIncomplete ? (
|
||||
<div className="flex items-center gap-1.5 py-1.5">
|
||||
<Icon name="alert" className="w-4 h-4 text-[var(--status-warning)] shrink-0" />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.providers.page.auth.incomplete')}</span>
|
||||
<SettingsInfoHint>{t('settings.providers.page.auth.incompleteHint')}</SettingsInfoHint>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 py-1.5">
|
||||
<Icon name="check" className="w-4 h-4 text-[var(--status-success)] shrink-0" />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.providers.page.auth.connected')}</span>
|
||||
<SettingsInfoHint>{t('settings.providers.page.auth.useReconnectHint')}</SettingsInfoHint>
|
||||
</div>
|
||||
)
|
||||
) : authLoading ? (
|
||||
<div className="py-1.5 typography-meta text-muted-foreground">{t('settings.providers.page.auth.loadingMethods')}</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
buildAuthSetRequest,
|
||||
buildProviderUpsertRequest,
|
||||
isConfigDefinedCustomProvider,
|
||||
isCustomOpenAICompatibleProvider,
|
||||
providerToCustomFormState,
|
||||
resolveProviderConfigScope,
|
||||
validateCustomProvider,
|
||||
type CustomProviderConfig,
|
||||
type CustomProviderFormState,
|
||||
} from './custom-provider-form';
|
||||
|
||||
const t = (key: string) => key;
|
||||
|
||||
const baseForm = (overrides: Partial<CustomProviderFormState> = {}): CustomProviderFormState => ({
|
||||
providerID: 'custom-provider',
|
||||
name: 'Custom Provider',
|
||||
baseURL: 'https://api.example.com/v1',
|
||||
apiKey: 'sk-test',
|
||||
models: [{ row: 'm0', id: 'model-a', name: 'Model A' }],
|
||||
headers: [{ row: 'h0', key: '', value: '' }],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** Mirrors server upsert semantics for request-construction tests. */
|
||||
function mergeProviderConfig(
|
||||
existing: Record<string, unknown>,
|
||||
providerID: string,
|
||||
config: CustomProviderConfig,
|
||||
): Record<string, unknown> {
|
||||
const providerSection = (
|
||||
typeof existing.provider === 'object' && existing.provider !== null && !Array.isArray(existing.provider)
|
||||
? { ...(existing.provider as Record<string, unknown>) }
|
||||
: {}
|
||||
);
|
||||
providerSection[providerID] = config;
|
||||
const next: Record<string, unknown> = {
|
||||
...existing,
|
||||
provider: providerSection,
|
||||
};
|
||||
if (Array.isArray(existing.disabled_providers)) {
|
||||
next.disabled_providers = existing.disabled_providers.filter((entry) => entry !== providerID);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
describe('validateCustomProvider', () => {
|
||||
test('builds trimmed config and auth payloads', () => {
|
||||
const result = validateCustomProvider({
|
||||
form: baseForm({
|
||||
providerID: ' custom-provider ',
|
||||
name: ' Custom Provider ',
|
||||
baseURL: ' https://api.example.com/v1 ',
|
||||
apiKey: ' sk-secret ',
|
||||
models: [{ row: 'm0', id: ' model-a ', name: ' Model A ' }],
|
||||
headers: [
|
||||
{ row: 'h0', key: ' X-Test ', value: ' enabled ' },
|
||||
{ row: 'h1', key: '', value: '' },
|
||||
],
|
||||
}),
|
||||
t,
|
||||
existingProviderIDs: new Set(),
|
||||
});
|
||||
|
||||
expect(result.result).toEqual({
|
||||
providerID: 'custom-provider',
|
||||
name: 'Custom Provider',
|
||||
apiKey: 'sk-secret',
|
||||
config: {
|
||||
npm: '@ai-sdk/openai-compatible',
|
||||
name: 'Custom Provider',
|
||||
options: {
|
||||
baseURL: 'https://api.example.com/v1',
|
||||
headers: {
|
||||
'X-Test': 'enabled',
|
||||
},
|
||||
},
|
||||
models: {
|
||||
'model-a': { name: 'Model A' },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('supports {env:VAR} credentials without writing an auth key', () => {
|
||||
const result = validateCustomProvider({
|
||||
form: baseForm({
|
||||
apiKey: '{env: CUSTOM_PROVIDER_KEY}',
|
||||
}),
|
||||
t,
|
||||
existingProviderIDs: new Set(),
|
||||
});
|
||||
|
||||
expect(result.result?.apiKey).toEqual(undefined);
|
||||
expect(result.result?.config.env).toEqual(['CUSTOM_PROVIDER_KEY']);
|
||||
});
|
||||
|
||||
test('rejects missing credentials', () => {
|
||||
const result = validateCustomProvider({
|
||||
form: baseForm({ apiKey: ' ' }),
|
||||
t,
|
||||
existingProviderIDs: new Set(),
|
||||
});
|
||||
|
||||
expect(result.result).toEqual(undefined);
|
||||
expect(result.err.apiKey).toBe('settings.providers.page.custom.error.apiKey.required');
|
||||
});
|
||||
|
||||
test('allows empty api key when editing with existing auth', () => {
|
||||
const result = validateCustomProvider({
|
||||
form: baseForm({ apiKey: '' }),
|
||||
t,
|
||||
existingProviderIDs: new Set(['custom-provider']),
|
||||
editingProviderID: 'custom-provider',
|
||||
allowExistingAuth: true,
|
||||
});
|
||||
|
||||
expect(result.result?.providerID).toBe('custom-provider');
|
||||
expect(result.err.apiKey).toEqual(undefined);
|
||||
expect(result.result?.apiKey).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('rejects invalid provider id, base URL, and duplicate rows', () => {
|
||||
const result = validateCustomProvider({
|
||||
form: baseForm({
|
||||
providerID: 'Bad ID',
|
||||
baseURL: 'ftp://example.com',
|
||||
models: [
|
||||
{ row: 'm0', id: 'model-a', name: 'Model A' },
|
||||
{ row: 'm1', id: 'model-a', name: 'Model A 2' },
|
||||
],
|
||||
headers: [
|
||||
{ row: 'h0', key: 'Authorization', value: 'one' },
|
||||
{ row: 'h1', key: 'authorization', value: 'two' },
|
||||
],
|
||||
}),
|
||||
t,
|
||||
existingProviderIDs: new Set(),
|
||||
});
|
||||
|
||||
expect(result.result).toEqual(undefined);
|
||||
expect(result.err.providerID).toBe('settings.providers.page.custom.error.providerID.format');
|
||||
expect(result.err.baseURL).toBe('settings.providers.page.custom.error.baseURL.format');
|
||||
expect(result.models[1]).toEqual({
|
||||
id: 'settings.providers.page.custom.error.duplicate',
|
||||
name: undefined,
|
||||
});
|
||||
expect(result.headers[1]).toEqual({
|
||||
key: 'settings.providers.page.custom.error.duplicate',
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('allows reconnecting a disabled provider id', () => {
|
||||
const result = validateCustomProvider({
|
||||
form: baseForm(),
|
||||
t,
|
||||
existingProviderIDs: new Set(['custom-provider']),
|
||||
disabledProviders: ['custom-provider'],
|
||||
});
|
||||
|
||||
expect(result.result?.providerID).toBe('custom-provider');
|
||||
expect(result.err.providerID).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('rejects an already-connected provider id on create', () => {
|
||||
const result = validateCustomProvider({
|
||||
form: baseForm(),
|
||||
t,
|
||||
existingProviderIDs: new Set(['custom-provider']),
|
||||
});
|
||||
|
||||
expect(result.result).toEqual(undefined);
|
||||
expect(result.err.providerID).toBe('settings.providers.page.custom.error.providerID.exists');
|
||||
});
|
||||
|
||||
test('allows updating the same provider id while editing', () => {
|
||||
const result = validateCustomProvider({
|
||||
form: baseForm({ apiKey: 'sk-updated' }),
|
||||
t,
|
||||
existingProviderIDs: new Set(['custom-provider']),
|
||||
editingProviderID: 'custom-provider',
|
||||
});
|
||||
|
||||
expect(result.result?.providerID).toBe('custom-provider');
|
||||
expect(result.err.providerID).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('request construction', () => {
|
||||
test('builds auth.set and provider upsert requests', () => {
|
||||
const validated = validateCustomProvider({
|
||||
form: baseForm(),
|
||||
t,
|
||||
existingProviderIDs: new Set(),
|
||||
});
|
||||
const plan = validated.result!;
|
||||
|
||||
expect(buildAuthSetRequest(plan)).toEqual({
|
||||
providerID: 'custom-provider',
|
||||
auth: { type: 'api', key: 'sk-test' },
|
||||
});
|
||||
expect(buildProviderUpsertRequest(plan)).toEqual({
|
||||
providerID: 'custom-provider',
|
||||
config: plan.config,
|
||||
scope: 'user',
|
||||
});
|
||||
});
|
||||
|
||||
test('includes explicit project/custom scope on upsert requests', () => {
|
||||
const validated = validateCustomProvider({
|
||||
form: baseForm(),
|
||||
t,
|
||||
existingProviderIDs: new Set(),
|
||||
});
|
||||
const plan = validated.result!;
|
||||
|
||||
expect(buildProviderUpsertRequest(plan, { scope: 'project' }).scope).toBe('project');
|
||||
expect(buildProviderUpsertRequest(plan, { scope: 'custom' }).scope).toBe('custom');
|
||||
});
|
||||
|
||||
test('omits auth.set when using env credentials', () => {
|
||||
const validated = validateCustomProvider({
|
||||
form: baseForm({ apiKey: '{env:MY_KEY}' }),
|
||||
t,
|
||||
existingProviderIDs: new Set(),
|
||||
});
|
||||
|
||||
expect(buildAuthSetRequest(validated.result!)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeProviderConfig persistence shape', () => {
|
||||
test('merges provider block and clears disabled_providers entry', () => {
|
||||
const validated = validateCustomProvider({
|
||||
form: baseForm(),
|
||||
t,
|
||||
existingProviderIDs: new Set(),
|
||||
});
|
||||
const plan = validated.result!;
|
||||
|
||||
const next = mergeProviderConfig(
|
||||
{
|
||||
model: 'openai/gpt-4o',
|
||||
provider: {
|
||||
openai: { name: 'OpenAI' },
|
||||
},
|
||||
disabled_providers: ['custom-provider', 'other'],
|
||||
},
|
||||
plan.providerID,
|
||||
plan.config,
|
||||
);
|
||||
|
||||
expect(next).toEqual({
|
||||
model: 'openai/gpt-4o',
|
||||
provider: {
|
||||
openai: { name: 'OpenAI' },
|
||||
'custom-provider': plan.config,
|
||||
},
|
||||
disabled_providers: ['other'],
|
||||
});
|
||||
});
|
||||
|
||||
test('creates provider section when missing', () => {
|
||||
const validated = validateCustomProvider({
|
||||
form: baseForm(),
|
||||
t,
|
||||
existingProviderIDs: new Set(),
|
||||
});
|
||||
const plan = validated.result!;
|
||||
|
||||
const next = mergeProviderConfig({}, plan.providerID, plan.config);
|
||||
expect(next.provider).toEqual({
|
||||
'custom-provider': plan.config,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider edit helpers', () => {
|
||||
test('detects openai-compatible custom providers and prefills form state', () => {
|
||||
expect(isCustomOpenAICompatibleProvider({
|
||||
id: 'campus-llm',
|
||||
options: { baseURL: 'https://llm.example.edu/v1' },
|
||||
models: [],
|
||||
})).toBe(true);
|
||||
|
||||
const state = providerToCustomFormState({
|
||||
id: 'campus-llm',
|
||||
name: 'Campus LLM',
|
||||
env: ['CAMPUS_KEY'],
|
||||
options: {
|
||||
baseURL: 'https://llm.example.edu/v1',
|
||||
headers: { 'X-Campus': '1' },
|
||||
},
|
||||
models: [{ id: 'fast', name: 'Fast' }],
|
||||
});
|
||||
|
||||
expect(state.providerID).toBe('campus-llm');
|
||||
expect(state.name).toBe('Campus LLM');
|
||||
expect(state.baseURL).toBe('https://llm.example.edu/v1');
|
||||
expect(state.apiKey).toBe('{env:CAMPUS_KEY}');
|
||||
expect(state.models[0]).toEqual({ row: state.models[0].row, id: 'fast', name: 'Fast' });
|
||||
expect(state.headers[0]).toEqual({ row: state.headers[0].row, key: 'X-Campus', value: '1' });
|
||||
});
|
||||
|
||||
test('requires a config-layer source before treating a provider as editable custom', () => {
|
||||
const catalogLike = {
|
||||
id: 'openai',
|
||||
options: { baseURL: 'https://api.openai.com/v1' },
|
||||
models: [{ id: 'gpt-4o', name: 'GPT-4o', api: { npm: '@ai-sdk/openai-compatible' } }],
|
||||
};
|
||||
|
||||
expect(isCustomOpenAICompatibleProvider(catalogLike)).toBe(true);
|
||||
expect(isConfigDefinedCustomProvider(catalogLike, undefined)).toBe(false);
|
||||
expect(isConfigDefinedCustomProvider(catalogLike, {
|
||||
user: { exists: false },
|
||||
project: { exists: false },
|
||||
custom: { exists: false },
|
||||
})).toBe(false);
|
||||
expect(isConfigDefinedCustomProvider(catalogLike, {
|
||||
user: { exists: true },
|
||||
project: { exists: false },
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
test('resolveProviderConfigScope follows custom > project > user precedence', () => {
|
||||
expect(resolveProviderConfigScope(undefined)).toBe('user');
|
||||
expect(resolveProviderConfigScope({
|
||||
user: { exists: true },
|
||||
project: { exists: false },
|
||||
custom: { exists: false },
|
||||
})).toBe('user');
|
||||
expect(resolveProviderConfigScope({
|
||||
user: { exists: true },
|
||||
project: { exists: true },
|
||||
custom: { exists: false },
|
||||
})).toBe('project');
|
||||
expect(resolveProviderConfigScope({
|
||||
user: { exists: true },
|
||||
project: { exists: true },
|
||||
custom: { exists: true },
|
||||
})).toBe('custom');
|
||||
expect(resolveProviderConfigScope({
|
||||
user: { exists: false },
|
||||
project: { exists: false },
|
||||
custom: { exists: true },
|
||||
})).toBe('custom');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* Custom / Other OpenAI-compatible provider form helpers.
|
||||
* Mirrors OpenCode web UI validation and request construction so a provider
|
||||
* can be defined from Settings without code changes.
|
||||
*/
|
||||
|
||||
export const CUSTOM_PROVIDER_NPM = '@ai-sdk/openai-compatible';
|
||||
export const CUSTOM_PROVIDER_ID = '__custom_provider__';
|
||||
export const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
|
||||
export const BASE_URL_PATTERN = /^https?:\/\//;
|
||||
export const ENV_KEY_PATTERN = /^\{env:([^}]+)\}$/;
|
||||
|
||||
export type CustomProviderTranslator = (
|
||||
key: string,
|
||||
vars?: Record<string, string | number | boolean>,
|
||||
) => string;
|
||||
|
||||
export type ModelRow = {
|
||||
row: string;
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type HeaderRow = {
|
||||
row: string;
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type CustomProviderFormState = {
|
||||
providerID: string;
|
||||
name: string;
|
||||
baseURL: string;
|
||||
apiKey: string;
|
||||
models: ModelRow[];
|
||||
headers: HeaderRow[];
|
||||
};
|
||||
|
||||
export type FieldErrors = {
|
||||
providerID?: string;
|
||||
name?: string;
|
||||
baseURL?: string;
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
export type ModelFieldErrors = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type HeaderFieldErrors = {
|
||||
key?: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
export type CustomProviderConfig = {
|
||||
npm: typeof CUSTOM_PROVIDER_NPM;
|
||||
name: string;
|
||||
env?: string[];
|
||||
options: {
|
||||
baseURL: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
models: Record<string, { name: string }>;
|
||||
};
|
||||
|
||||
export type CustomProviderPersistPlan = {
|
||||
providerID: string;
|
||||
name: string;
|
||||
/** Literal API key to send via auth.set; omitted when using {env:VAR} or empty. */
|
||||
apiKey?: string;
|
||||
config: CustomProviderConfig;
|
||||
};
|
||||
|
||||
export type ValidateCustomProviderInput = {
|
||||
form: CustomProviderFormState;
|
||||
t: CustomProviderTranslator;
|
||||
existingProviderIDs: ReadonlySet<string>;
|
||||
disabledProviders?: readonly string[];
|
||||
/** When editing this provider id, treat it as an allowed update target. */
|
||||
editingProviderID?: string;
|
||||
/**
|
||||
* When true, empty apiKey is allowed because auth.json already has a credential
|
||||
* (edit path). Still requires env or key when false.
|
||||
*/
|
||||
allowExistingAuth?: boolean;
|
||||
};
|
||||
|
||||
export type ValidateCustomProviderResult = {
|
||||
err: FieldErrors;
|
||||
models: ModelFieldErrors[];
|
||||
headers: HeaderFieldErrors[];
|
||||
result?: CustomProviderPersistPlan;
|
||||
};
|
||||
|
||||
export type ProviderLikeForCustomForm = {
|
||||
id: string;
|
||||
name?: string;
|
||||
env?: string[];
|
||||
options?: Record<string, unknown> | null;
|
||||
models?: Array<{ id?: string; name?: string; api?: { npm?: string } }> | Record<string, unknown>;
|
||||
};
|
||||
|
||||
let rowCounter = 0;
|
||||
|
||||
const nextRow = (): string => `row-${rowCounter++}`;
|
||||
|
||||
export const createModelRow = (): ModelRow => ({
|
||||
row: nextRow(),
|
||||
id: '',
|
||||
name: '',
|
||||
});
|
||||
|
||||
export const createHeaderRow = (): HeaderRow => ({
|
||||
row: nextRow(),
|
||||
key: '',
|
||||
value: '',
|
||||
});
|
||||
|
||||
export const createEmptyCustomProviderForm = (): CustomProviderFormState => ({
|
||||
providerID: '',
|
||||
name: '',
|
||||
baseURL: '',
|
||||
apiKey: '',
|
||||
models: [createModelRow()],
|
||||
headers: [createHeaderRow()],
|
||||
});
|
||||
|
||||
export function parseEnvApiKey(apiKey: string): { env?: string; key?: string } {
|
||||
const trimmed = apiKey.trim();
|
||||
if (!trimmed) {
|
||||
return {};
|
||||
}
|
||||
const envMatch = trimmed.match(ENV_KEY_PATTERN);
|
||||
const env = envMatch?.[1]?.trim();
|
||||
if (env) {
|
||||
return { env };
|
||||
}
|
||||
return { key: trimmed };
|
||||
}
|
||||
|
||||
export function isCustomOpenAICompatibleProvider(provider: ProviderLikeForCustomForm): boolean {
|
||||
const options = provider.options && typeof provider.options === 'object' ? provider.options : null;
|
||||
const baseURL = typeof options?.baseURL === 'string' ? options.baseURL.trim() : '';
|
||||
if (baseURL && BASE_URL_PATTERN.test(baseURL)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const models = Array.isArray(provider.models)
|
||||
? provider.models
|
||||
: (provider.models && typeof provider.models === 'object'
|
||||
? Object.values(provider.models)
|
||||
: []);
|
||||
|
||||
return models.some((model) => {
|
||||
if (!model || typeof model !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const api = 'api' in model && model.api && typeof model.api === 'object'
|
||||
? model.api as { npm?: unknown }
|
||||
: null;
|
||||
return typeof api?.npm === 'string' && api.npm === CUSTOM_PROVIDER_NPM;
|
||||
});
|
||||
}
|
||||
|
||||
export type ProviderConfigSourcesLike = {
|
||||
user?: { exists?: boolean };
|
||||
project?: { exists?: boolean };
|
||||
custom?: { exists?: boolean };
|
||||
};
|
||||
|
||||
export type ProviderConfigScope = 'user' | 'project' | 'custom';
|
||||
|
||||
/**
|
||||
* True when a provider both looks OpenAI-compatible-custom and is defined in a
|
||||
* user/project/custom OpenCode config layer. Catalog-only providers often share
|
||||
* the same npm/baseURL signals and must not get Edit / config overrides.
|
||||
*/
|
||||
export function isConfigDefinedCustomProvider(
|
||||
provider: ProviderLikeForCustomForm,
|
||||
sources: ProviderConfigSourcesLike | null | undefined,
|
||||
): boolean {
|
||||
if (!sources) {
|
||||
return false;
|
||||
}
|
||||
const inConfigLayer = Boolean(
|
||||
sources.user?.exists || sources.project?.exists || sources.custom?.exists,
|
||||
);
|
||||
return inConfigLayer && isCustomOpenAICompatibleProvider(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective writable config layer for a provider, matching OpenCode merge
|
||||
* precedence: custom > project > user.
|
||||
*/
|
||||
export function resolveProviderConfigScope(
|
||||
sources: ProviderConfigSourcesLike | null | undefined,
|
||||
): ProviderConfigScope {
|
||||
if (sources?.custom?.exists) {
|
||||
return 'custom';
|
||||
}
|
||||
if (sources?.project?.exists) {
|
||||
return 'project';
|
||||
}
|
||||
return 'user';
|
||||
}
|
||||
|
||||
export function providerToCustomFormState(provider: ProviderLikeForCustomForm): CustomProviderFormState {
|
||||
const options = provider.options && typeof provider.options === 'object' ? provider.options : {};
|
||||
const baseURL = typeof options.baseURL === 'string' ? options.baseURL : '';
|
||||
const headersRaw = options.headers && typeof options.headers === 'object' && !Array.isArray(options.headers)
|
||||
? options.headers as Record<string, unknown>
|
||||
: {};
|
||||
const headerRows = Object.entries(headersRaw)
|
||||
.filter((entry): entry is [string, string] => typeof entry[0] === 'string' && typeof entry[1] === 'string')
|
||||
.map(([key, value]) => ({ row: nextRow(), key, value }));
|
||||
|
||||
const modelEntries = Array.isArray(provider.models)
|
||||
? provider.models
|
||||
: (provider.models && typeof provider.models === 'object'
|
||||
? Object.entries(provider.models).map(([id, value]) => ({
|
||||
id,
|
||||
name: value && typeof value === 'object' && 'name' in value && typeof (value as { name?: unknown }).name === 'string'
|
||||
? (value as { name: string }).name
|
||||
: id,
|
||||
}))
|
||||
: []);
|
||||
|
||||
const models = modelEntries.length > 0
|
||||
? modelEntries.map((model) => ({
|
||||
row: nextRow(),
|
||||
id: typeof model?.id === 'string' ? model.id : '',
|
||||
name: typeof model?.name === 'string' ? model.name : (typeof model?.id === 'string' ? model.id : ''),
|
||||
}))
|
||||
: [createModelRow()];
|
||||
|
||||
const envName = Array.isArray(provider.env)
|
||||
? provider.env.find((entry) => typeof entry === 'string' && entry.trim().length > 0)?.trim()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
providerID: provider.id,
|
||||
name: typeof provider.name === 'string' && provider.name.trim() ? provider.name : provider.id,
|
||||
baseURL,
|
||||
apiKey: envName ? `{env:${envName}}` : '',
|
||||
models,
|
||||
headers: headerRows.length > 0 ? headerRows : [createHeaderRow()],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates form input and builds the auth + OpenCode provider config payloads.
|
||||
*/
|
||||
export function validateCustomProvider(input: ValidateCustomProviderInput): ValidateCustomProviderResult {
|
||||
const providerID = input.form.providerID.trim();
|
||||
const name = input.form.name.trim();
|
||||
const baseURL = input.form.baseURL.trim();
|
||||
const { env, key } = parseEnvApiKey(input.form.apiKey);
|
||||
const disabledProviders = input.disabledProviders ?? [];
|
||||
const editingProviderID = input.editingProviderID?.trim();
|
||||
|
||||
const idError = !providerID
|
||||
? input.t('settings.providers.page.custom.error.providerID.required')
|
||||
: !PROVIDER_ID_PATTERN.test(providerID)
|
||||
? input.t('settings.providers.page.custom.error.providerID.format')
|
||||
: undefined;
|
||||
|
||||
const nameError = !name
|
||||
? input.t('settings.providers.page.custom.error.name.required')
|
||||
: undefined;
|
||||
|
||||
const urlError = !baseURL
|
||||
? input.t('settings.providers.page.custom.error.baseURL.required')
|
||||
: !BASE_URL_PATTERN.test(baseURL)
|
||||
? input.t('settings.providers.page.custom.error.baseURL.format')
|
||||
: undefined;
|
||||
|
||||
const credentialsSatisfied = Boolean(env || key || (editingProviderID && input.allowExistingAuth && editingProviderID === providerID));
|
||||
const apiKeyError = credentialsSatisfied
|
||||
? undefined
|
||||
: input.t('settings.providers.page.custom.error.apiKey.required');
|
||||
|
||||
const disabled = disabledProviders.includes(providerID);
|
||||
const isSelfEdit = Boolean(editingProviderID && editingProviderID === providerID);
|
||||
const existsError = idError || isSelfEdit
|
||||
? undefined
|
||||
: input.existingProviderIDs.has(providerID) && !disabled
|
||||
? input.t('settings.providers.page.custom.error.providerID.exists')
|
||||
: undefined;
|
||||
|
||||
const seenModels = new Set<string>();
|
||||
const modelErrors = input.form.models.map((model) => {
|
||||
const id = model.id.trim();
|
||||
const modelIdError = !id
|
||||
? input.t('settings.providers.page.custom.error.required')
|
||||
: seenModels.has(id)
|
||||
? input.t('settings.providers.page.custom.error.duplicate')
|
||||
: (() => {
|
||||
seenModels.add(id);
|
||||
return undefined;
|
||||
})();
|
||||
const modelNameError = !model.name.trim()
|
||||
? input.t('settings.providers.page.custom.error.required')
|
||||
: undefined;
|
||||
return { id: modelIdError, name: modelNameError };
|
||||
});
|
||||
|
||||
const modelsValid = modelErrors.every((entry) => !entry.id && !entry.name);
|
||||
const modelConfig = Object.fromEntries(
|
||||
input.form.models.map((model) => [model.id.trim(), { name: model.name.trim() }]),
|
||||
);
|
||||
|
||||
const seenHeaders = new Set<string>();
|
||||
const headerErrors = input.form.headers.map((header) => {
|
||||
const headerKey = header.key.trim();
|
||||
const headerValue = header.value.trim();
|
||||
if (!headerKey && !headerValue) {
|
||||
return {};
|
||||
}
|
||||
const keyError = !headerKey
|
||||
? input.t('settings.providers.page.custom.error.required')
|
||||
: seenHeaders.has(headerKey.toLowerCase())
|
||||
? input.t('settings.providers.page.custom.error.duplicate')
|
||||
: (() => {
|
||||
seenHeaders.add(headerKey.toLowerCase());
|
||||
return undefined;
|
||||
})();
|
||||
const valueError = !headerValue
|
||||
? input.t('settings.providers.page.custom.error.required')
|
||||
: undefined;
|
||||
return { key: keyError, value: valueError };
|
||||
});
|
||||
|
||||
const headersValid = headerErrors.every((entry) => !entry.key && !entry.value);
|
||||
const headerConfig = Object.fromEntries(
|
||||
input.form.headers
|
||||
.map((header) => ({ key: header.key.trim(), value: header.value.trim() }))
|
||||
.filter((header) => header.key && header.value)
|
||||
.map((header) => [header.key, header.value]),
|
||||
);
|
||||
|
||||
const err: FieldErrors = {
|
||||
providerID: idError ?? existsError,
|
||||
name: nameError,
|
||||
baseURL: urlError,
|
||||
apiKey: apiKeyError,
|
||||
};
|
||||
|
||||
const ok = !idError && !existsError && !nameError && !urlError && !apiKeyError && modelsValid && headersValid;
|
||||
if (!ok) {
|
||||
return { err, models: modelErrors, headers: headerErrors };
|
||||
}
|
||||
|
||||
return {
|
||||
err,
|
||||
models: modelErrors,
|
||||
headers: headerErrors,
|
||||
result: {
|
||||
providerID,
|
||||
name,
|
||||
apiKey: key,
|
||||
config: {
|
||||
npm: CUSTOM_PROVIDER_NPM,
|
||||
name,
|
||||
...(env ? { env: [env] } : {}),
|
||||
options: {
|
||||
baseURL,
|
||||
...(Object.keys(headerConfig).length > 0 ? { headers: headerConfig } : {}),
|
||||
},
|
||||
models: modelConfig,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the OpenCode auth.set request body when a literal API key is present.
|
||||
*/
|
||||
export function buildAuthSetRequest(plan: CustomProviderPersistPlan): {
|
||||
providerID: string;
|
||||
auth: { type: 'api'; key: string };
|
||||
} | null {
|
||||
if (!plan.apiKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
providerID: plan.providerID,
|
||||
auth: { type: 'api', key: plan.apiKey },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the OpenChamber provider upsert request body (config persistence).
|
||||
* `scope` selects the OpenCode config layer (user/project/custom). Create
|
||||
* defaults to user; edit must pass the provider's effective existing layer.
|
||||
*/
|
||||
export function buildProviderUpsertRequest(
|
||||
plan: CustomProviderPersistPlan,
|
||||
options?: { scope?: ProviderConfigScope },
|
||||
): {
|
||||
providerID: string;
|
||||
config: CustomProviderConfig;
|
||||
scope: ProviderConfigScope;
|
||||
} {
|
||||
return {
|
||||
providerID: plan.providerID,
|
||||
config: plan.config,
|
||||
scope: options?.scope ?? 'user',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export type ProviderAuthMethod = {
|
||||
type?: string;
|
||||
name?: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
help?: string;
|
||||
};
|
||||
|
||||
export const normalizeAuthType = (method: ProviderAuthMethod): string => {
|
||||
const raw = typeof method.type === 'string' ? method.type : '';
|
||||
const label = `${method.name ?? ''} ${method.label ?? ''}`.toLowerCase();
|
||||
const merged = `${raw} ${label}`.toLowerCase();
|
||||
if (merged.includes('oauth')) return 'oauth';
|
||||
if (merged.includes('api')) return 'api';
|
||||
return raw.toLowerCase();
|
||||
};
|
||||
|
||||
/** OAuth methods with the original provider.auth() method index OpenCode expects. */
|
||||
export const listOAuthMethods = (
|
||||
methods: ProviderAuthMethod[],
|
||||
): Array<{ method: ProviderAuthMethod; methodIndex: number }> =>
|
||||
methods
|
||||
.map((method, methodIndex) => ({ method, methodIndex }))
|
||||
.filter(({ method }) => normalizeAuthType(method) === 'oauth');
|
||||
@@ -35,6 +35,9 @@ interface SkillsSidebarProps {
|
||||
const BUILT_IN_SKILL_LOCATION = '<built-in>';
|
||||
|
||||
const isBuiltInSkill = (skill: DiscoveredSkill | null | undefined): boolean => skill?.path === BUILT_IN_SKILL_LOCATION;
|
||||
const isRenamableSkill = (skill: DiscoveredSkill | null | undefined): boolean => (
|
||||
!!skill && !isBuiltInSkill(skill) && skill.renamable === true
|
||||
);
|
||||
|
||||
export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
@@ -49,16 +52,16 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
skills,
|
||||
setSelectedSkill,
|
||||
setSkillDraft,
|
||||
createSkill,
|
||||
deleteSkill,
|
||||
renameSkill,
|
||||
getSkillDetail,
|
||||
} = useSkillsStore(useShallow((s) => ({
|
||||
selectedSkillName: s.selectedSkillName,
|
||||
skills: s.skills,
|
||||
setSelectedSkill: s.setSelectedSkill,
|
||||
setSkillDraft: s.setSkillDraft,
|
||||
createSkill: s.createSkill,
|
||||
deleteSkill: s.deleteSkill,
|
||||
renameSkill: s.renameSkill,
|
||||
getSkillDetail: s.getSkillDetail,
|
||||
})));
|
||||
|
||||
@@ -140,14 +143,14 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
};
|
||||
|
||||
const handleOpenRenameDialog = (skill: DiscoveredSkill) => {
|
||||
if (isBuiltInSkill(skill)) return;
|
||||
if (!isRenamableSkill(skill)) return;
|
||||
setRenameNewName(skill.name);
|
||||
setRenameDialogSkill(skill);
|
||||
};
|
||||
|
||||
const handleRenameSkill = async () => {
|
||||
if (!renameDialogSkill) return;
|
||||
if (isBuiltInSkill(renameDialogSkill)) {
|
||||
if (!isRenamableSkill(renameDialogSkill)) {
|
||||
setRenameDialogSkill(null);
|
||||
return;
|
||||
}
|
||||
@@ -169,31 +172,11 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
return;
|
||||
}
|
||||
|
||||
// Get full detail to copy
|
||||
const detail = await getSkillDetail(renameDialogSkill.name);
|
||||
if (!detail) {
|
||||
toast.error(t('settings.skills.sidebar.toast.renameLoadFailed'));
|
||||
setRenameDialogSkill(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new skill with new name
|
||||
const success = await createSkill({
|
||||
name: sanitizedName,
|
||||
description: 'Renamed skill', // Will need proper description
|
||||
scope: renameDialogSkill.scope,
|
||||
source: renameDialogSkill.source,
|
||||
});
|
||||
|
||||
// Rename in place on disk so SKILL.md body and supporting files are preserved.
|
||||
const success = await renameSkill(renameDialogSkill.name, sanitizedName);
|
||||
if (success) {
|
||||
// Delete old skill
|
||||
const deleteSuccess = await deleteSkill(renameDialogSkill.name);
|
||||
if (deleteSuccess) {
|
||||
toast.success(`Skill renamed to "${sanitizedName}"`);
|
||||
setSelectedSkill(sanitizedName);
|
||||
} else {
|
||||
toast.error(t('settings.skills.sidebar.toast.removeOldAfterRenameFailed'));
|
||||
}
|
||||
toast.success(t('settings.skills.sidebar.toast.skillRenamed', { name: sanitizedName }));
|
||||
setSelectedSkill(sanitizedName);
|
||||
} else {
|
||||
toast.error(t('settings.skills.sidebar.toast.renameFailed'));
|
||||
}
|
||||
@@ -463,13 +446,16 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
||||
: t('settings.skills.sidebar.badge.opencode');
|
||||
const badgeClassName = 'typography-micro text-muted-foreground bg-[var(--surface-muted)] px-1 rounded flex-shrink-0 leading-none pb-px border border-[var(--interactive-border)]/50';
|
||||
const isBuiltIn = isBuiltInSkill(skill);
|
||||
const canRename = isRenamableSkill(skill);
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
|
||||
const renderMenuItems = (Item: React.ElementType) => (
|
||||
<>
|
||||
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onRename(); }}>
|
||||
<Icon name="edit" className="h-4 w-4 mr-px" />
|
||||
{t('settings.common.actions.rename')}
|
||||
</Item>
|
||||
{canRename ? (
|
||||
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onRename(); }}>
|
||||
<Icon name="edit" className="h-4 w-4 mr-px" />
|
||||
{t('settings.common.actions.rename')}
|
||||
</Item>
|
||||
) : null}
|
||||
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onDuplicate(); }}>
|
||||
<Icon name="file-copy" className="h-4 w-4 mr-px" />
|
||||
{t('settings.common.actions.duplicate')}
|
||||
|
||||
@@ -18,6 +18,42 @@ import type { TerminalChunk } from '@/stores/useTerminalStore';
|
||||
let ghosttyPromise: Promise<Ghostty> | null = null;
|
||||
const loadGhostty = (): Promise<Ghostty> => ghosttyPromise ??= Ghostty.load();
|
||||
|
||||
type TerminalSize = { cols: number; rows: number };
|
||||
|
||||
const getProvisionalTerminalSize = (
|
||||
container: HTMLDivElement,
|
||||
fontFamily: string,
|
||||
fontSize: number,
|
||||
): TerminalSize | null => {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return null;
|
||||
|
||||
const context = document.createElement('canvas').getContext('2d');
|
||||
if (!context || container.clientWidth < 24 || container.clientHeight < 24) return null;
|
||||
|
||||
context.font = `${fontSize}px ${fontFamily}`;
|
||||
const metrics = context.measureText('M');
|
||||
const cellWidth = Math.ceil(metrics.width);
|
||||
const cellHeight = Math.ceil(
|
||||
(metrics.actualBoundingBoxAscent || fontSize * 0.8) +
|
||||
(metrics.actualBoundingBoxDescent || fontSize * 0.2),
|
||||
) + 2;
|
||||
if (cellWidth < 1 || cellHeight < 1) return null;
|
||||
|
||||
const style = window.getComputedStyle(container);
|
||||
const horizontalPadding =
|
||||
(Number.parseInt(style.paddingLeft, 10) || 0) +
|
||||
(Number.parseInt(style.paddingRight, 10) || 0);
|
||||
const verticalPadding =
|
||||
(Number.parseInt(style.paddingTop, 10) || 0) +
|
||||
(Number.parseInt(style.paddingBottom, 10) || 0);
|
||||
|
||||
// Match Ghostty FitAddon's 15px scrollbar reservation and minimum dimensions.
|
||||
return {
|
||||
cols: Math.max(2, Math.floor((container.clientWidth - horizontalPadding - 15) / cellWidth)),
|
||||
rows: Math.max(1, Math.floor((container.clientHeight - verticalPadding) / cellHeight)),
|
||||
};
|
||||
};
|
||||
|
||||
export type TerminalController = {
|
||||
focus: () => void;
|
||||
fit: () => void;
|
||||
@@ -47,7 +83,8 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
||||
const fitRef = React.useRef<FitAddon | null>(null);
|
||||
const inputRef = React.useRef(onInput);
|
||||
const resizeRef = React.useRef(onResize);
|
||||
const lastSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
|
||||
const lastSizeRef = React.useRef<TerminalSize | null>(null);
|
||||
const provisionalSizeRef = React.useRef<TerminalSize | null>(null);
|
||||
const lastChunkRef = React.useRef<number | null>(null);
|
||||
const writeQueueRef = React.useRef('');
|
||||
const outputRewriteCarryRef = React.useRef('');
|
||||
@@ -65,6 +102,14 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
||||
visibleRef.current = isVisible;
|
||||
safeResetRef.current = getGhosttySafeResetSequence(theme.background);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const size = getProvisionalTerminalSize(container, fontFamily, fontSize);
|
||||
provisionalSizeRef.current = size;
|
||||
if (size) resizeRef.current(size.cols, size.rows);
|
||||
}, [fontFamily, fontSize]);
|
||||
|
||||
const fit = React.useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
const terminal = terminalRef.current;
|
||||
@@ -168,7 +213,10 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
||||
|
||||
loadGhostty().then((ghostty) => {
|
||||
if (disposed) return;
|
||||
terminal = new GhosttyTerminal(getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false));
|
||||
terminal = new GhosttyTerminal({
|
||||
...getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false),
|
||||
...(provisionalSizeRef.current ?? {}),
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(container);
|
||||
|
||||
@@ -26,6 +26,8 @@ type TerminalViewProps = {
|
||||
visible?: boolean;
|
||||
};
|
||||
|
||||
const FALLBACK_TERMINAL_SIZE = { cols: 80, rows: 24 } as const;
|
||||
|
||||
export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
const { t } = useI18n();
|
||||
const { terminal, runtime } = useRuntimeAPIs();
|
||||
@@ -109,7 +111,6 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
const [isReconnectPending, setIsReconnectPending] = React.useState(false);
|
||||
const [activeModifier, setActiveModifier] = React.useState<Modifier | null>(null);
|
||||
const [isRestarting, setIsRestarting] = React.useState(false);
|
||||
const [hasViewportSize, setHasViewportSize] = React.useState(false);
|
||||
|
||||
const streamCleanupRef = React.useRef<(() => void) | null>(null);
|
||||
const activeTerminalIdRef = React.useRef<string | null>(null);
|
||||
@@ -118,7 +119,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
const directoryRef = React.useRef<string | null>(effectiveDirectory);
|
||||
const terminalControllerRef = React.useRef<TerminalController | null>(null);
|
||||
const lastViewportSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
|
||||
const isTerminalVisibleRef = React.useRef(false);
|
||||
const pendingTerminalCreatesRef = React.useRef(new Set<string>());
|
||||
const previewScanTailRef = React.useRef('');
|
||||
const pendingPreviewProbeUrlsRef = React.useRef<Set<string>>(new Set());
|
||||
const previewProbeGenerationRef = React.useRef(0);
|
||||
@@ -157,10 +158,6 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
}
|
||||
}, [isTerminalVisible]);
|
||||
|
||||
React.useEffect(() => {
|
||||
isTerminalVisibleRef.current = isTerminalVisible;
|
||||
}, [isTerminalVisible]);
|
||||
|
||||
React.useEffect(() => {
|
||||
terminalIdRef.current = terminalSessionId;
|
||||
}, [terminalSessionId]);
|
||||
@@ -424,7 +421,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
}
|
||||
|
||||
const tab = state.tabs.find((t) => t.id === tabId) ?? state.tabs[0];
|
||||
let terminalId = tab?.terminalSessionId ?? null;
|
||||
const terminalId = tab?.terminalSessionId ?? null;
|
||||
const terminalLifecycle = tab?.lifecycle ?? 'idle';
|
||||
const isActionTab = Boolean(tab?.label?.startsWith('Action:'));
|
||||
const buffer = useTerminalStore.getState().getBuffer(directory, tabId);
|
||||
@@ -441,11 +438,17 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const size = lastViewportSizeRef.current;
|
||||
if (!size && isTerminalVisibleRef.current) {
|
||||
const createKey = `${directory}\u0000${tabId}`;
|
||||
if (pendingTerminalCreatesRef.current.has(createKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Launch the shell while Ghostty is still loading and fitting.
|
||||
// The backend accepts 80x24, then receives the measured size as
|
||||
// soon as the viewport is ready.
|
||||
const initialSize = lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE;
|
||||
pendingTerminalCreatesRef.current.add(createKey);
|
||||
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
@@ -454,8 +457,8 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
const session = await terminal.createSession({
|
||||
cwd: directory,
|
||||
sessionId: tabId,
|
||||
cols: size?.cols,
|
||||
rows: size?.rows,
|
||||
cols: initialSize.cols,
|
||||
rows: initialSize.rows,
|
||||
shell: terminalShell,
|
||||
loginShell: terminalLoginShell,
|
||||
...terminalAppearanceRef.current,
|
||||
@@ -476,19 +479,38 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
|
||||
setTabSessionId(directory, tabId, session.sessionId);
|
||||
if (!stillActive) return;
|
||||
terminalId = session.sessionId;
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setConnectionError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('terminalView.error.startSessionFailed')
|
||||
);
|
||||
setIsFatalError(true);
|
||||
setIsReconnectPending(false);
|
||||
setConnecting(directory, tabId, false);
|
||||
|
||||
const viewportSize = lastViewportSizeRef.current;
|
||||
if (
|
||||
viewportSize &&
|
||||
(viewportSize.cols !== initialSize.cols || viewportSize.rows !== initialSize.rows)
|
||||
) {
|
||||
void terminal.resize({ sessionId: session.sessionId, ...viewportSize }).catch(() => {});
|
||||
}
|
||||
// Storing the session ID reruns this effect. Let that next
|
||||
// effect own stream startup: starting here would be torn
|
||||
// down immediately by this effect's cleanup.
|
||||
return;
|
||||
} catch (error) {
|
||||
const owningTab = useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((entry) => entry.id === tabId);
|
||||
if (!owningTab || owningTab.terminalSessionId) return;
|
||||
|
||||
setConnecting(directory, tabId, false);
|
||||
// Strict Mode replaces the first effect while its create
|
||||
// request is pending. `cancelled` therefore does not mean
|
||||
// this tab stopped owning the request; use current store
|
||||
// ownership so a rejected create cannot leave it spinning.
|
||||
if (directoryRef.current !== directory || activeTabIdRef.current !== tabId) return;
|
||||
setConnectionError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('terminalView.error.startSessionFailed')
|
||||
);
|
||||
setIsFatalError(true);
|
||||
setIsReconnectPending(false);
|
||||
return;
|
||||
} finally {
|
||||
pendingTerminalCreatesRef.current.delete(createKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,7 +535,6 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
terminalLifecycle,
|
||||
activeTabId,
|
||||
hasOpenedTerminalViewport,
|
||||
hasViewportSize,
|
||||
enableTabs,
|
||||
terminalHydrated,
|
||||
ensureDirectory,
|
||||
@@ -568,7 +589,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
resetTerminalPreviewScan();
|
||||
|
||||
try {
|
||||
const size = lastViewportSizeRef.current ?? { cols: 80, rows: 24 };
|
||||
const size = lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE;
|
||||
const restarted = await terminal.restartSession(originalSessionId, { cwd: effectiveDirectory, shell: terminalShell, loginShell: terminalLoginShell, ...size, ...terminalAppearanceRef.current });
|
||||
const owningTab = useTerminalStore.getState().getDirectoryState(effectiveDirectory)?.tabs.find((tab) => tab.id === tabId);
|
||||
if (owningTab?.terminalSessionId !== originalSessionId) return;
|
||||
@@ -694,22 +715,17 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
const handleViewportResize = React.useCallback(
|
||||
(cols: number, rows: number) => {
|
||||
const previous = lastViewportSizeRef.current;
|
||||
if (!previous) {
|
||||
lastViewportSizeRef.current = { cols, rows };
|
||||
if (!terminalIdRef.current) setHasViewportSize(true);
|
||||
} else if (previous.cols !== cols || previous.rows !== rows) {
|
||||
if (!previous || previous.cols !== cols || previous.rows !== rows) {
|
||||
lastViewportSizeRef.current = { cols, rows };
|
||||
}
|
||||
if (!isTerminalVisibleRef.current) {
|
||||
if (!isTerminalVisible) {
|
||||
return;
|
||||
}
|
||||
const terminalId = terminalIdRef.current;
|
||||
if (!terminalId) return;
|
||||
void terminal.resize({ sessionId: terminalId, cols, rows }).catch(() => {
|
||||
|
||||
});
|
||||
void terminal.resize({ sessionId: terminalId, cols, rows }).catch(() => {});
|
||||
},
|
||||
[terminal]
|
||||
[isTerminalVisible, terminal]
|
||||
);
|
||||
|
||||
const handleModifierToggle = React.useCallback(
|
||||
@@ -801,11 +817,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
// here tore down and rebuilt the Ghostty terminal (WASM VT + canvas + font
|
||||
// atlas) a second time the moment `createSession` resolved, doubling the cost
|
||||
// of every terminal open. Session changes are handled by the chunk replay path.
|
||||
const terminalViewportKey = React.useMemo(() => {
|
||||
const directoryPart = effectiveDirectory ?? 'no-dir';
|
||||
const tabPart = activeTabId ?? 'no-tab';
|
||||
return `${directoryPart}::${tabPart}`;
|
||||
}, [effectiveDirectory, activeTabId]);
|
||||
const terminalViewportKey = `${effectiveDirectory ?? 'no-dir'}::${activeTabId ?? 'no-tab'}`;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isTerminalVisible || useTouchTerminalInput) {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Regression guard for slow terminal opening on Linux.
|
||||
*
|
||||
* `TerminalViewport` is keyed by `terminalViewportKey`. That key used to include
|
||||
* the PTY session id, which is null until `createSession` resolves. Because the
|
||||
* viewport must mount first to report its size before a session can be created,
|
||||
* the PTY session id, which is null until `createSession` resolves. Historically,
|
||||
* the viewport had to mount first to report its size before session creation, so
|
||||
* every terminal open built a Ghostty terminal (WASM VT + 2D canvas renderer +
|
||||
* font atlas), threw it away when the session id arrived, and built a second one.
|
||||
* The same churn repeated on reconnect and on every incidental session-id change,
|
||||
@@ -12,6 +12,8 @@
|
||||
*
|
||||
* Viewport identity must therefore be directory + tab only. Session changes are
|
||||
* handled by the chunk replay path, which resets the existing terminal in place.
|
||||
* New sessions start concurrently with a container-derived size (or 80x24) and
|
||||
* resize after their viewport fits.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -25,27 +27,15 @@ const terminalViewportSource = readFileSync(
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const viewportKeyBlock = (() => {
|
||||
const start = terminalViewSource.indexOf('const terminalViewportKey = React.useMemo(');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const end = terminalViewSource.indexOf('}, [', start);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
return terminalViewSource.slice(start, terminalViewSource.indexOf(');', end));
|
||||
})();
|
||||
const viewportKeyDeclaration = terminalViewSource
|
||||
.split('\n')
|
||||
.find((line) => line.includes('const terminalViewportKey =')) ?? '';
|
||||
|
||||
describe('terminal viewport remount guard', () => {
|
||||
test('viewport identity excludes the PTY session id', () => {
|
||||
expect(viewportKeyBlock).toContain('effectiveDirectory');
|
||||
expect(viewportKeyBlock).toContain('activeTabId');
|
||||
expect(viewportKeyBlock).not.toContain('terminalSessionId');
|
||||
});
|
||||
|
||||
test('viewport key memo does not depend on the PTY session id', () => {
|
||||
const dependencyStart = terminalViewSource.indexOf('}, [', terminalViewSource.indexOf('const terminalViewportKey'));
|
||||
const dependencies = terminalViewSource.slice(dependencyStart, terminalViewSource.indexOf(']', dependencyStart));
|
||||
expect(dependencies).toContain('effectiveDirectory');
|
||||
expect(dependencies).toContain('activeTabId');
|
||||
expect(dependencies).not.toContain('terminalSessionId');
|
||||
expect(viewportKeyDeclaration).toContain('effectiveDirectory');
|
||||
expect(viewportKeyDeclaration).toContain('activeTabId');
|
||||
expect(viewportKeyDeclaration).not.toContain('terminalSessionId');
|
||||
});
|
||||
|
||||
test('replay discontinuities reset the terminal in place instead of remounting it', () => {
|
||||
@@ -61,4 +51,53 @@ describe('terminal viewport remount guard', () => {
|
||||
expect(terminalViewSource).toContain('getBuffer(');
|
||||
expect(terminalViewSource).not.toContain('activeTab?.bufferChunks');
|
||||
});
|
||||
|
||||
test('starts the PTY before Ghostty reports its first viewport size', () => {
|
||||
expect(terminalViewSource).toContain('const FALLBACK_TERMINAL_SIZE = { cols: 80, rows: 24 } as const;');
|
||||
expect(terminalViewSource).toContain('const initialSize = lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE;');
|
||||
expect(terminalViewSource).not.toContain('if (!size && isTerminalVisibleRef.current)');
|
||||
expect(terminalViewSource).toContain('cols: initialSize.cols');
|
||||
expect(terminalViewSource).toContain('rows: initialSize.rows');
|
||||
expect(terminalViewSource).toContain('void terminal.resize({ sessionId: session.sessionId, ...viewportSize })');
|
||||
expect(terminalViewSource).toContain('if (!isTerminalVisible) {');
|
||||
expect(terminalViewSource).not.toContain('isTerminalVisibleRef');
|
||||
});
|
||||
|
||||
test('deduplicates create attempts while the viewport layout settles', () => {
|
||||
expect(terminalViewSource).toContain('pendingTerminalCreatesRef.current.has(createKey)');
|
||||
expect(terminalViewSource).toContain('pendingTerminalCreatesRef.current.delete(createKey)');
|
||||
});
|
||||
|
||||
test('lets the session-ID effect own stream startup after creating a tab', () => {
|
||||
const createStart = terminalViewSource.indexOf('if (!terminalId) {');
|
||||
const createEnd = terminalViewSource.indexOf('if (!terminalId || cancelled) return;', createStart);
|
||||
expect(createStart).toBeGreaterThan(-1);
|
||||
expect(createEnd).toBeGreaterThan(createStart);
|
||||
const createBlock = terminalViewSource.slice(createStart, createEnd);
|
||||
|
||||
expect(createBlock).toContain('setTabSessionId(directory, tabId, session.sessionId);');
|
||||
expect(createBlock).toContain('Let that next');
|
||||
expect(createBlock).not.toContain('startStream(');
|
||||
});
|
||||
|
||||
test('clears a current tab from connecting when a strict-mode create rejects', () => {
|
||||
const createStart = terminalViewSource.indexOf('if (!terminalId) {');
|
||||
const catchStart = terminalViewSource.indexOf('} catch (error) {', createStart);
|
||||
const catchEnd = terminalViewSource.indexOf('} finally {', catchStart);
|
||||
expect(catchStart).toBeGreaterThan(createStart);
|
||||
expect(catchEnd).toBeGreaterThan(catchStart);
|
||||
const catchBlock = terminalViewSource.slice(catchStart, catchEnd);
|
||||
|
||||
expect(catchBlock).toContain('owningTab.terminalSessionId');
|
||||
expect(catchBlock).toContain('activeTabIdRef.current !== tabId');
|
||||
expect(catchBlock).toContain('setConnecting(directory, tabId, false);');
|
||||
expect(catchBlock).not.toContain('if (!cancelled)');
|
||||
});
|
||||
|
||||
test('derives the initial PTY size before Ghostty mounts', () => {
|
||||
expect(terminalViewportSource).toContain('const getProvisionalTerminalSize');
|
||||
expect(terminalViewportSource).toContain('React.useLayoutEffect(() => {');
|
||||
expect(terminalViewportSource).toContain('resizeRef.current(size.cols, size.rows)');
|
||||
expect(terminalViewportSource).toContain('...(provisionalSizeRef.current ?? {})');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user