diff --git a/.opencode/screenshots/terminal-final.png b/.opencode/screenshots/terminal-final.png new file mode 100644 index 00000000..ab8d0cbb Binary files /dev/null and b/.opencode/screenshots/terminal-final.png differ diff --git a/.opencode/screenshots/terminal-parallel-start.png b/.opencode/screenshots/terminal-parallel-start.png new file mode 100644 index 00000000..84c137ba Binary files /dev/null and b/.opencode/screenshots/terminal-parallel-start.png differ diff --git a/.opencode/screenshots/terminal-reset.png b/.opencode/screenshots/terminal-reset.png new file mode 100644 index 00000000..447a9da0 Binary files /dev/null and b/.opencode/screenshots/terminal-reset.png differ diff --git a/.opencode/screenshots/terminal-startup.png b/.opencode/screenshots/terminal-startup.png new file mode 100644 index 00000000..39296cd3 Binary files /dev/null and b/.opencode/screenshots/terminal-startup.png differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 350a8175..281df160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,19 @@ All notable changes to this project will be documented in this file. - **Walkthrough:** a new guided walkthrough reorders a diff into a sequence of stops — the model groups related changes, explains what each one does, and orders them so each builds on the last. Start one from the Changes and pull-request views for uncommitted work, a branch against its base, or a pull request; nothing runs on its own. Walkthroughs are written in your interface language by default, and the panel can generate one in any other supported language. - **Mobile/Tablet:** reworked the tablet and foldable layout around the phone's navigation — a persistent resizable sessions sidebar on the left, the workspace (Changes, Files, Terminal, Notes, MCP) as a resizable right sidebar, and app pages like settings and instances shown as centered dialogs. An open diff, edited file, or attached terminal now survives rotation. +- **Providers:** custom OpenAI-compatible providers can now be added and edited from Settings, including their endpoint, models, credentials, headers, and configuration scope (thanks to @makeittech). - Performance: fixed Bun dependency chunking so the web app no longer downloads a single 18.5 MB vendor bundle at startup; heavy syntax highlighting, screenshot, diagram, editor, and image-conversion libraries now load only when needed (thanks to @makeittech). +- UI/Localization: added German interface translations and German documentation (thanks to @SGD-DEV). - Mobile/Android: pairing QR codes can now be scanned on devices without Google Play Services; the camera closes as soon as a code is recognized, followed by a connection-in-progress screen. - Mobile/Android: left and right drawer swipes can now start farther from the screen edge, outside Android's system Back gesture area. - Sessions: launching OpenChamber from a directory other than your project (for example your home folder) no longer produces repeated "not a git repository" errors that could stop sessions and projects from loading (thanks to @makeittech). - Sidebar: a worktree shared by more than one project no longer appears twice (thanks to @makeittech). - Sidebar: session titles no longer clip at the ends of their rows. - Git/Diff: opening a changed file now jumps its header directly to the top, and live updates refresh only files that actually changed while preserving the current review position. Saves from the built-in file editor update the diff too. +- Terminal: opening a terminal no longer waits for the terminal view to finish loading, and startup output is retained if it arrives before the view appears (thanks to @makeittech). +- Chat/Tools: Bash output now applies terminal control characters and strips ANSI formatting, preventing progress output and rewritten lines from appearing as raw escape sequences (thanks to @catan271). +- Chat: queued messages now retry after a temporary send failure or an interrupted turn instead of remaining stuck until another session update. +- Settings/Skills: repository-local `.agents/skills` now appear for the active project (thanks to @makeittech). - Sessions: archiving and unarchiving now stays scoped to the current instance and workspace (thanks to @alexandrereyes). - Chat: assistant messages no longer render active HTML. - VSCode: clicking an apply_patch tool result now opens each changed file at its correct path instead of always opening the first file (thanks to @nabsiddiqui). diff --git a/bun-patches/bun-pty@0.4.8.patch b/bun-patches/bun-pty@0.4.8.patch new file mode 100644 index 00000000..f65b3bba --- /dev/null +++ b/bun-patches/bun-pty@0.4.8.patch @@ -0,0 +1,77 @@ +diff --git a/src/terminal.ts b/src/terminal.ts +index ec248d46a939f8a09cd669e853cefb126922c80a..c0473bc625edda7be2ade987e8aa3bd99160ce67 100644 +--- a/src/terminal.ts ++++ b/src/terminal.ts +@@ -11,6 +11,7 @@ export const DEFAULT_COLS = 80; + export const DEFAULT_ROWS = 24; + export const DEFAULT_FILE = "sh"; + export const DEFAULT_NAME = "xterm"; ++const INITIAL_OUTPUT_BUFFER_LIMIT = 512 * 1024; + + /** + * Quote a string for shell-words compatible splitting on the Rust side. +@@ -136,6 +137,8 @@ export class Terminal implements IPty { + + private _readLoop = false; + private _closing = false; ++ private _hasDataSubscriber = false; ++ private _initialOutput = ""; + + // TextDecoder with streaming mode to properly handle UTF-8 across chunk boundaries + // Without this, multi-byte characters (like box-drawing ─) that span chunks become � +@@ -191,12 +194,29 @@ export class Terminal implements IPty { + } + + get onData() { +- return this._onData.event; ++ return (listener: (data: string) => void) => { ++ const disposable = this._onData.event(listener); ++ if (!this._hasDataSubscriber) { ++ this._hasDataSubscriber = true; ++ const initialOutput = this._initialOutput; ++ this._initialOutput = ""; ++ if (initialOutput) listener(initialOutput); ++ } ++ return disposable; ++ }; + } + get onExit() { + return this._onExit.event; + } + ++ private _emitData(data: string) { ++ if (this._hasDataSubscriber) { ++ this._onData.fire(data); ++ } else { ++ this._initialOutput = `${this._initialOutput}${data}`.slice(-INITIAL_OUTPUT_BUFFER_LIMIT); ++ } ++ } ++ + /* ------------- IO methods ------------- */ + + write(data: string) { +@@ -235,13 +255,13 @@ export class Terminal implements IPty { + // This prevents corruption when multi-byte chars span chunk boundaries + const decoded = this._decoder.decode(buf.subarray(0, n), { stream: true }); + if (decoded) { +- this._onData.fire(decoded); ++ this._emitData(decoded); + } + } else if (n === -2) { + // CHILD_EXITED - flush any remaining bytes in the decoder + const remaining = this._decoder.decode(); + if (remaining) { +- this._onData.fire(remaining); ++ this._emitData(remaining); + } + const exitCode = lib.symbols.bun_pty_get_exit_code(this.handle); + this._onExit.fire({ exitCode }); +@@ -250,7 +270,7 @@ export class Terminal implements IPty { + // error - flush decoder before breaking + const remaining = this._decoder.decode(); + if (remaining) { +- this._onData.fire(remaining); ++ this._emitData(remaining); + } + break; + } else { diff --git a/bun.lock b/bun.lock index 15b89095..2209fc6d 100644 --- a/bun.lock +++ b/bun.lock @@ -95,7 +95,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.17.1", + "version": "1.17.2", "dependencies": { "@openchamber/web": "workspace:*", "better-sqlite3": "^12.10.0", @@ -132,7 +132,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.17.1", + "version": "1.17.2", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", @@ -237,7 +237,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.17.1", + "version": "1.17.2", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "1.18.11", @@ -260,7 +260,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.17.1", + "version": "1.17.2", "bin": { "openchamber": "./bin/cli.js", }, @@ -352,6 +352,7 @@ ], "patchedDependencies": { "@tanstack/virtual-core@3.17.3": "bun-patches/@tanstack+virtual-core+3.17.3.patch", + "bun-pty@0.4.8": "bun-patches/bun-pty@0.4.8.patch", }, "overrides": { "@codemirror/language": "6.12.2", diff --git a/package.json b/package.json index 3561555d..030fc1d0 100644 --- a/package.json +++ b/package.json @@ -176,6 +176,7 @@ "vite": "^7.1.2" }, "patchedDependencies": { - "@tanstack/virtual-core@3.17.3": "bun-patches/@tanstack+virtual-core+3.17.3.patch" + "@tanstack/virtual-core@3.17.3": "bun-patches/@tanstack+virtual-core+3.17.3.patch", + "bun-pty@0.4.8": "bun-patches/bun-pty@0.4.8.patch" } } diff --git a/packages/docs/content/docs/providers.mdx b/packages/docs/content/docs/providers.mdx index 3ca47e22..6b8a8e83 100644 --- a/packages/docs/content/docs/providers.mdx +++ b/packages/docs/content/docs/providers.mdx @@ -10,11 +10,23 @@ Before OpenChamber can do anything, it needs at least one AI provider connected. ## Connect a provider 1. Open **Settings → Providers**. -2. Open the **Add provider** menu and pick a provider that isn't connected yet. +2. Open the **Add provider** menu and pick a provider that isn't connected yet, or choose **Other / Custom** for an OpenAI-compatible endpoint. 3. Sign in one of two ways, depending on the provider: - **API key** — paste your key and save. - **Sign-in (device flow)** — OpenChamber shows a link and a short code. Open the link, enter the code, and approve. OpenChamber finishes connecting on its own. +### Custom / Other providers + +For gateways, campus LLMs, Ollama, LiteLLM, and similar OpenAI-compatible APIs: + +1. Choose **Other / Custom** in the provider list. +2. Enter a provider ID, display name, base URL (`http://` or `https://`), API key (or `{env:VAR_NAME}`), and at least one model id/name. +3. Optionally add request headers. +4. Save — OpenChamber writes the provider block to OpenCode config and stores the key in OpenCode auth (literal keys) or records an `{env:VAR}` reference. +5. To change an existing custom provider, open it and choose **Edit**. + +Custom providers require an API key or `{env:VAR_NAME}` before they show as fully connected. Without credentials, models may appear but chat calls will fail. + When a provider shows as connected, its models become available in chat. To disconnect, open the provider and choose to remove its sign-in. diff --git a/packages/electron/README.md b/packages/electron/README.md index 2e052833..45722457 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -85,6 +85,8 @@ After packaging, run `bun run --cwd packages/electron verify:linux-appimage`. Th Running a packaged Linux AppImage requires FUSE (`libfuse.so.2`, typically `libfuse2` / `libfuse2t64` on Debian/Ubuntu). Without FUSE, start with `APPIMAGE_EXTRACT_AND_RUN=1`. Keep the AppImage on a writable path so in-app updates can replace it. +Desktop clears AppImage `ARGV0` from `process.env` before probing the login shell and starting the in-process server. Leaving it set makes zsh rewrite argv[0] for integrated-terminal and managed-OpenCode child commands to the AppImage path. + Linux updates are supported only when the packaged app is running from a writable AppImage. Update checks, downloads, and installation report an actionable error when `APPIMAGE` is missing, invalid, or read-only; a missing release feed (`latest-linux.yml` 404 before the first Linux publish) is treated as “no update available”. macOS and Windows updater behavior is unchanged. Release builds keep `latest-linux.yml` (x64) and `latest-linux-arm64.yml` separate and validate each manifest against its AppImage before upload. Linux AppImages download full updates (no `.blockmap` differential channel yet). ### Updater End-to-End Fixture diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index d82fbb3b..ca7a493e 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -1374,11 +1374,16 @@ const loadShellEnv = () => { // Merge the user's login-shell env (PATH, etc.) into this process before we import { pathLooksUserConfigured, mergePathValues } from '@openchamber/web/server/lib/opencode/path-utils.js'; +import { clearAppImageArgv0FromProcessEnv } from '@openchamber/web/server/lib/inherited-env.js'; // import/start the server in-process. The server and its children (opencode // CLI, git, etc.) inherit process.env directly now — there is no sidecar // subprocess to hand a custom env to. const inheritUserShellEnv = () => { + // Clear before probing/merging so login-shell snapshots and children never + // inherit the AppImage path as argv[0] via zsh's ARGV0 parameter (#2588). + clearAppImageArgv0FromProcessEnv(); + const shellEnv = loadShellEnv(); if (!shellEnv) return; @@ -1388,7 +1393,7 @@ const inheritUserShellEnv = () => { const currentPathLooksUserConfigured = pathLooksUserConfigured(currentPath, homeDir, delimiter); for (const [key, value] of Object.entries(shellEnv)) { - if (key === 'PATH') continue; + if (key === 'PATH' || key === 'ARGV0') continue; if (typeof process.env[key] === 'undefined') { process.env[key] = value; } diff --git a/packages/ui/src/components/chat/QueuedMessageChips.tsx b/packages/ui/src/components/chat/QueuedMessageChips.tsx index d49b1ae5..b64a3f4f 100644 --- a/packages/ui/src/components/chat/QueuedMessageChips.tsx +++ b/packages/ui/src/components/chat/QueuedMessageChips.tsx @@ -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( diff --git a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md index aaf0b2b4..466942f3 100644 --- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md @@ -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) diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.test.ts b/packages/ui/src/components/chat/message/parts/ToolPart.test.ts index d31d076e..2faf34c3 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.test.ts +++ b/packages/ui/src/components/chat/message/parts/ToolPart.test.ts @@ -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); }); }); diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index eb3e0cf8..ee4983de 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -1362,7 +1362,7 @@ const ToolExpandedContent: React.FC = 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'; diff --git a/packages/ui/src/components/chat/message/parts/toolOutput.ts b/packages/ui/src/components/chat/message/parts/toolOutput.ts index 88d1d0ab..0c30b811 100644 --- a/packages/ui/src/components/chat/message/parts/toolOutput.ts +++ b/packages/ui/src/components/chat/message/parts/toolOutput.ts @@ -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; diff --git a/packages/ui/src/components/sections/providers/CustomProviderForm.tsx b/packages/ui/src/components/sections/providers/CustomProviderForm.tsx new file mode 100644 index 00000000..6fce317e --- /dev/null +++ b/packages/ui/src/components/sections/providers/CustomProviderForm.tsx @@ -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; + disabledProviders?: readonly string[]; + busy?: boolean; + mode?: 'create' | 'edit'; + initialValues?: CustomProviderFormState; + allowExistingAuth?: boolean; + authFailureHint?: string | null; + onSubmit: (plan: CustomProviderPersistPlan) => void | Promise; + onCancel?: () => void; + onDisconnect?: () => void | Promise; +}; + +export const CustomProviderForm: React.FC = ({ + 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( + () => initialValues ?? createEmptyCustomProviderForm(), + ); + const [err, setErr] = React.useState({}); + const [modelErrors, setModelErrors] = React.useState([]); + const [headerErrors, setHeaderErrors] = React.useState([]); + const seededEditProviderIdRef = React.useRef(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, 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[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 ( +
+ +

{t('settings.providers.page.custom.description')}

+ + {authFailureHint ? ( +

+ {authFailureHint} +

+ ) : null} + + + 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 ?

{err.providerID}

: null} +
+ + + 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 ?

{err.name}

: null} +
+ + + 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 ?

{err.baseURL}

: null} +
+ + + 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 ?

{err.apiKey}

: null} +
+
+ + + {form.models.map((model, index) => ( +
+
+
+
+ + 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 ? ( +

{modelErrors[index]?.id}

+ ) : null} +
+
+ + 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 ? ( +

{modelErrors[index]?.name}

+ ) : null} +
+
+ +
+
+ ))} + +
+ + +

{t('settings.providers.page.custom.headers.description')}

+ {form.headers.map((header, index) => ( +
+
+
+
+ + 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 ? ( +

{headerErrors[index]?.key}

+ ) : null} +
+
+ + 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 ? ( +

{headerErrors[index]?.value}

+ ) : null} +
+
+ +
+
+ ))} + +
+ +
+ {onCancel ? ( + + ) : null} + {onDisconnect ? ( + + ) : null} + +
+
+ ); +}; diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.test.ts b/packages/ui/src/components/sections/providers/ProvidersPage.test.ts index fb9f258a..afb9775a 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.test.ts +++ b/packages/ui/src/components/sections/providers/ProvidersPage.test.ts @@ -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'); + }); +}); diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index d28f5f29..d7591b14 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -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 => 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 => { if (!isRecord(payload)) { return {}; @@ -178,7 +176,20 @@ export const ProvidersPage: React.FC = () => { const [providerDropdownOpen, setProviderDropdownOpen] = React.useState(false); const [providerSources, setProviderSources] = React.useState>({}); const [showAuthPanel, setShowAuthPanel] = React.useState(false); + const [editingCustomProviderId, setEditingCustomProviderId] = React.useState(null); + const [editingCustomFormInitial, setEditingCustomFormInitial] = React.useState(null); + const [editingCustomScope, setEditingCustomScope] = React.useState(null); + const [customAuthFailureHint, setCustomAuthFailureHint] = React.useState(null); + const [lastCustomPersistId, setLastCustomPersistId] = React.useState(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 (
@@ -536,8 +636,6 @@ export const ProvidersPage: React.FC = () => {

{t('settings.providers.page.state.loading')}

) : availableError ? (

{availableError}

- ) : unconnectedProviders.length === 0 ? ( -

{t('settings.providers.page.connect.allProvidersConnected')}

) : ( { setProviderDropdownOpen(open); @@ -549,11 +647,15 @@ export const ProvidersPage: React.FC = () => { className={SETTINGS_CUSTOM_TRIGGER_CLASS} > - {candidateProviderId ? : null} + {candidateProviderId && candidateProviderId !== CUSTOM_PROVIDER_ID ? ( + + ) : null} - {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')} @@ -581,32 +683,60 @@ export const ProvidersPage: React.FC = () => {
{(() => { + 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

{t('settings.providers.page.connect.noProvidersFound')}

; } - return filtered.map((provider) => ( - { - setCandidateProviderId(provider.id); - setProviderDropdownOpen(false); - setProviderSearchQuery(''); - }} - className="flex items-center justify-between" - > - - - {provider.name || provider.id} - - {candidateProviderId === provider.id && ( - - )} - - )); + return ( + <> + {filtered.map((provider) => ( + { + setCandidateProviderId(provider.id); + setProviderDropdownOpen(false); + setProviderSearchQuery(''); + }} + className="flex items-center justify-between" + > + + + {provider.name || provider.id} + + {candidateProviderId === provider.id && ( + + )} + + ))} + {customMatches ? ( + { + setCandidateProviderId(CUSTOM_PROVIDER_ID); + setProviderDropdownOpen(false); + setProviderSearchQuery(''); + }} + className="flex items-center justify-between" + > + + + {customLabel} + + {candidateProviderId === CUSTOM_PROVIDER_ID && ( + + )} + + ) : null} + + ); })()}
@@ -615,7 +745,25 @@ export const ProvidersPage: React.FC = () => { - {candidateProviderId && ( + {isCustomCreateMode ? ( + { + setCandidateProviderId(''); + setCustomAuthFailureHint(null); + setLastCustomPersistId(null); + }} + onDisconnect={ + customAuthFailureHint && lastCustomPersistId + ? () => void handleDisconnectCustomProvider(lastCustomPersistId) + : undefined + } + onSubmit={handleSaveCustomProvider} + /> + ) : candidateProviderId ? ( { )} - )} + ) : null} ); } @@ -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 ( + } + description={{selectedProvider.id}} + showSaveStatus={false} + > + { + setEditingCustomProviderId(null); + setEditingCustomFormInitial(null); + setEditingCustomScope(null); + setCustomAuthFailureHint(null); + setLastCustomPersistId(null); + }} + onDisconnect={() => void handleDisconnectCustomProvider(selectedProvider.id)} + onSubmit={handleSaveCustomProvider} + /> + + ); + } + return ( { title={t('settings.providers.page.auth.title')} divider={false} headerAction={( - +
+ {isEditableCustomProvider ? ( + + ) : null} + +
)} settingsItem="providers.auth" > {!showAuthPanel ? ( -
- - {t('settings.providers.page.auth.connected')} - {t('settings.providers.page.auth.useReconnectHint')} -
+ authStatusIncomplete ? ( +
+ + {t('settings.providers.page.auth.incomplete')} + {t('settings.providers.page.auth.incompleteHint')} +
+ ) : ( +
+ + {t('settings.providers.page.auth.connected')} + {t('settings.providers.page.auth.useReconnectHint')} +
+ ) ) : authLoading ? (
{t('settings.providers.page.auth.loadingMethods')}
) : ( diff --git a/packages/ui/src/components/sections/providers/custom-provider-form.test.ts b/packages/ui/src/components/sections/providers/custom-provider-form.test.ts new file mode 100644 index 00000000..a8b5c930 --- /dev/null +++ b/packages/ui/src/components/sections/providers/custom-provider-form.test.ts @@ -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 => ({ + 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, + providerID: string, + config: CustomProviderConfig, +): Record { + const providerSection = ( + typeof existing.provider === 'object' && existing.provider !== null && !Array.isArray(existing.provider) + ? { ...(existing.provider as Record) } + : {} + ); + providerSection[providerID] = config; + const next: Record = { + ...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'); + }); +}); diff --git a/packages/ui/src/components/sections/providers/custom-provider-form.ts b/packages/ui/src/components/sections/providers/custom-provider-form.ts new file mode 100644 index 00000000..d734288f --- /dev/null +++ b/packages/ui/src/components/sections/providers/custom-provider-form.ts @@ -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; + +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; + }; + models: Record; +}; + +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; + 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 | null; + models?: Array<{ id?: string; name?: string; api?: { npm?: string } }> | Record; +}; + +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 + : {}; + 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(); + 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(); + 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', + }; +} diff --git a/packages/ui/src/components/sections/providers/providerAuthMethods.ts b/packages/ui/src/components/sections/providers/providerAuthMethods.ts new file mode 100644 index 00000000..cac233b0 --- /dev/null +++ b/packages/ui/src/components/sections/providers/providerAuthMethods.ts @@ -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'); diff --git a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx index 0b13c6e0..f440b9a5 100644 --- a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx +++ b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx @@ -35,6 +35,9 @@ interface SkillsSidebarProps { const BUILT_IN_SKILL_LOCATION = ''; 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 = ({ onItemSelect }) => { const { t } = useI18n(); @@ -49,16 +52,16 @@ export const SkillsSidebar: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ : 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) => ( <> - { e.stopPropagation(); onRename(); }}> - - {t('settings.common.actions.rename')} - + {canRename ? ( + { e.stopPropagation(); onRename(); }}> + + {t('settings.common.actions.rename')} + + ) : null} { e.stopPropagation(); onDuplicate(); }}> {t('settings.common.actions.duplicate')} diff --git a/packages/ui/src/components/terminal/TerminalViewport.tsx b/packages/ui/src/components/terminal/TerminalViewport.tsx index 90b834d3..cd86497f 100644 --- a/packages/ui/src/components/terminal/TerminalViewport.tsx +++ b/packages/ui/src/components/terminal/TerminalViewport.tsx @@ -18,6 +18,42 @@ import type { TerminalChunk } from '@/stores/useTerminalStore'; let ghosttyPromise: Promise | null = null; const loadGhostty = (): Promise => 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(({ const fitRef = React.useRef(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(null); + const provisionalSizeRef = React.useRef(null); const lastChunkRef = React.useRef(null); const writeQueueRef = React.useRef(''); const outputRewriteCarryRef = React.useRef(''); @@ -65,6 +102,14 @@ const TerminalViewport = React.forwardRef(({ 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(({ 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); diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index a373f880..c97f608e 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -26,6 +26,8 @@ type TerminalViewProps = { visible?: boolean; }; +const FALLBACK_TERMINAL_SIZE = { cols: 80, rows: 24 } as const; + export const TerminalView: React.FC = ({ visible }) => { const { t } = useI18n(); const { terminal, runtime } = useRuntimeAPIs(); @@ -109,7 +111,6 @@ export const TerminalView: React.FC = ({ visible }) => { const [isReconnectPending, setIsReconnectPending] = React.useState(false); const [activeModifier, setActiveModifier] = React.useState(null); const [isRestarting, setIsRestarting] = React.useState(false); - const [hasViewportSize, setHasViewportSize] = React.useState(false); const streamCleanupRef = React.useRef<(() => void) | null>(null); const activeTerminalIdRef = React.useRef(null); @@ -118,7 +119,7 @@ export const TerminalView: React.FC = ({ visible }) => { const directoryRef = React.useRef(effectiveDirectory); const terminalControllerRef = React.useRef(null); const lastViewportSizeRef = React.useRef<{ cols: number; rows: number } | null>(null); - const isTerminalVisibleRef = React.useRef(false); + const pendingTerminalCreatesRef = React.useRef(new Set()); const previewScanTailRef = React.useRef(''); const pendingPreviewProbeUrlsRef = React.useRef>(new Set()); const previewProbeGenerationRef = React.useRef(0); @@ -157,10 +158,6 @@ export const TerminalView: React.FC = ({ visible }) => { } }, [isTerminalVisible]); - React.useEffect(() => { - isTerminalVisibleRef.current = isTerminalVisible; - }, [isTerminalVisible]); - React.useEffect(() => { terminalIdRef.current = terminalSessionId; }, [terminalSessionId]); @@ -424,7 +421,7 @@ export const TerminalView: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ visible }) => { terminalLifecycle, activeTabId, hasOpenedTerminalViewport, - hasViewportSize, enableTabs, terminalHydrated, ensureDirectory, @@ -568,7 +589,7 @@ export const TerminalView: React.FC = ({ 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 = ({ 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 = ({ 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) { diff --git a/packages/ui/src/components/views/__tests__/terminalViewportRemount.test.ts b/packages/ui/src/components/views/__tests__/terminalViewportRemount.test.ts index f33bd2cd..d03379c1 100644 --- a/packages/ui/src/components/views/__tests__/terminalViewportRemount.test.ts +++ b/packages/ui/src/components/views/__tests__/terminalViewportRemount.test.ts @@ -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 ?? {})'); + }); }); diff --git a/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts b/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts index 6675b2a5..70c5eb43 100644 --- a/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts +++ b/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts @@ -29,12 +29,61 @@ mock.module('@/sync/session-ui-store', () => ({ import { buildQueuedAutoSendPayload, + createQueuedAutoSendRetryScheduler, getQueuedAutoSendRetryDelayMs, isQueuedAutoSendBackedOff, sendQueuedAutoSendPayload, shouldDispatchQueuedAutoSend, } from './useQueuedMessageAutoSend'; +describe('queued auto-send retry scheduler', () => { + test('wakes the queue when backoff expires', () => { + const callbacks = new Map void>(); + let nextTimer = 0; + let wakeups = 0; + const scheduler = createQueuedAutoSendRetryScheduler( + () => { wakeups += 1; }, + () => 1_000, + (callback, delay) => { + callbacks.set(++nextTimer, callback); + expect(delay).toBe(500); + return nextTimer as unknown as ReturnType; + }, + (timer) => { callbacks.delete(timer as unknown as number); }, + ); + + scheduler.schedule(1_500); + expect(callbacks.size).toBe(1); + callbacks.values().next().value?.(); + expect(wakeups).toBe(1); + }); + + test('keeps the earliest retry and cancels it on dispose', () => { + const callbacks = new Map void>(); + let nextTimer = 0; + const delays: number[] = []; + const scheduler = createQueuedAutoSendRetryScheduler( + () => undefined, + () => 1_000, + (callback, delay) => { + callbacks.set(++nextTimer, callback); + delays.push(delay); + return nextTimer as unknown as ReturnType; + }, + (timer) => { callbacks.delete(timer as unknown as number); }, + ); + + scheduler.schedule(3_000); + scheduler.schedule(4_000); + scheduler.schedule(2_000); + + expect(delays).toEqual([2_000, 1_000]); + expect(callbacks.size).toBe(1); + scheduler.dispose(); + expect(callbacks.size).toBe(0); + }); +}); + describe('shouldDispatchQueuedAutoSend', () => { test('dispatches only after an active session becomes idle', () => { expect(shouldDispatchQueuedAutoSend('busy', 'idle', false)).toBe(true); diff --git a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts index ecb53e6b..bc327ebe 100644 --- a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts +++ b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts @@ -33,12 +33,47 @@ export const isQueuedAutoSendBackedOff = ( now: number, ): boolean => failure !== undefined && failure.messageId === messageId && now < failure.nextAttemptAt; -const hasRecentAbort = (sessionId: string): boolean => { +export const createQueuedAutoSendRetryScheduler = ( + onWake: () => void, + now: () => number = Date.now, + scheduleTimeout: (callback: () => void, delay: number) => ReturnType = setTimeout, + cancelTimeout: (timer: ReturnType) => void = clearTimeout, +) => { + let timer: ReturnType | null = null; + let scheduledAt: number | null = null; + + return { + schedule(retryAt: number) { + if (scheduledAt !== null && scheduledAt <= retryAt) return; + if (timer !== null) cancelTimeout(timer); + scheduledAt = retryAt; + timer = scheduleTimeout(() => { + timer = null; + scheduledAt = null; + onWake(); + }, Math.max(0, retryAt - now())); + }, + dispose() { + if (timer !== null) cancelTimeout(timer); + timer = null; + scheduledAt = null; + }, + }; +}; + +/** + * When the abort window is still open, returns the time it expires so the + * caller can wake the queue then. Returns `null` once sending is allowed + * again — a queued item must not wait for an unrelated state change to be + * retried after the window closes. + */ +const getAbortHoldUntil = (sessionId: string): number | null => { const abortRecord = useSessionUIStore.getState().sessionAbortFlags.get(sessionId); if (!abortRecord) { - return false; + return null; } - return Date.now() - abortRecord.timestamp < RECENT_ABORT_WINDOW_MS; + const holdUntil = abortRecord.timestamp + RECENT_ABORT_WINDOW_MS; + return Date.now() < holdUntil ? holdUntil : null; }; export const buildQueuedAutoSendPayload = (queue: QueuedMessage[]) => { @@ -149,6 +184,13 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? const sendFailuresRef = React.useRef>(new Map()); const previousStatusRef = React.useRef>(new Map()); const autoReviewBlockedSessionsRef = React.useRef>(new Set()); + const [retryTick, setRetryTick] = React.useState(0); + const retryScheduler = React.useMemo( + () => createQueuedAutoSendRetryScheduler(() => setRetryTick((value) => value + 1)), + [], + ); + + React.useEffect(() => () => retryScheduler.dispose(), [retryScheduler]); React.useEffect(() => { if (!enabled) { @@ -164,7 +206,9 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? if (inFlightSessionsRef.current.has(targetKey)) { return; } - if (hasRecentAbort(sessionId)) { + const abortHoldUntil = getAbortHoldUntil(sessionId); + if (abortHoldUntil !== null) { + retryScheduler.schedule(abortHoldUntil); return; } if (useAutoReviewStore.getState().isRunningForSession(sessionId)) { @@ -185,7 +229,8 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? const failure = sendFailuresRef.current.get(targetKey); if (failure && failure.messageId !== payload.queuedMessageId) { sendFailuresRef.current.delete(targetKey); - } else if (isQueuedAutoSendBackedOff(failure, payload.queuedMessageId, Date.now())) { + } else if (failure && isQueuedAutoSendBackedOff(failure, payload.queuedMessageId, Date.now())) { + retryScheduler.schedule(failure.nextAttemptAt); return; } @@ -195,6 +240,10 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? ? captured : resolveSessionSendConfig(sessionId); if (!resolved.providerID || !resolved.modelID) { + // Legacy queues may predate captured send configuration. Config + // hydration is asynchronous, so retry instead of stranding the item + // until an unrelated status or directory update happens. + retryScheduler.schedule(Date.now() + AUTO_SEND_RETRY_BASE_DELAY_MS); return; } @@ -213,11 +262,13 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? console.warn('[queue] queued auto-send failed:', error); const priorFailures = failure?.messageId === payload.queuedMessageId ? failure.failures : 0; const failures = priorFailures + 1; + const nextAttemptAt = Date.now() + getQueuedAutoSendRetryDelayMs(failures); sendFailuresRef.current.set(targetKey, { messageId: payload.queuedMessageId, failures, - nextAttemptAt: Date.now() + getQueuedAutoSendRetryDelayMs(failures), + nextAttemptAt, }); + retryScheduler.schedule(nextAttemptAt); } finally { inFlightSessionsRef.current.delete(targetKey); } @@ -257,5 +308,5 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? }); previousStatusRef.current = nextStatusMap; - }, [enabled, queuedMessages, sessionStatusRecord, autoReviewRuns, currentDirectory]); + }, [enabled, queuedMessages, sessionStatusRecord, autoReviewRuns, currentDirectory, retryTick, retryScheduler]); } diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 05a8f63f..b6462769 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -930,21 +930,15 @@ html:not(.dark) .chat-scroll { } } -/* Status row: collapse optional text when narrow to keep both sides in one line. - * - * The todo text goes first, and it goes early. The changed-files summary on the - * left has a floor it cannot shrink past — the file count, the +/- counts and - * the chevron are all fixed width — so once the two sides no longer fit, they - * collide rather than politely truncating. 30rem was measured against the todo - * text alone; with a changed-files summary beside it the collision starts around - * 36rem, so this hides one step before that. */ +/* Hide the long active todo before it can collide with the changed-files summary. */ @container status-row (max-width: 38rem) { .status-row__active-todo { display: none; } } -@container status-row (max-width: 24rem) { +/* Hide the secondary changed-files label on narrow mobile layouts. */ +@container status-row (max-width: 30rem) { .status-row__changed-label { display: none; } diff --git a/packages/ui/src/lib/debug.ts b/packages/ui/src/lib/debug.ts index bafb8272..cece345d 100644 --- a/packages/ui/src/lib/debug.ts +++ b/packages/ui/src/lib/debug.ts @@ -1,12 +1,19 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessionUIStore, getRememberedSessionDirectory } from '@/sync/session-ui-store'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { opencodeClient } from '@/lib/opencode/client'; import { checkIsGitRepository } from '@/lib/gitApi'; import { streamDebugEnabled } from '@/stores/utils/streamDebug'; import { copyTextToClipboard as copyPlainTextToClipboard } from '@/lib/clipboard'; -import { getSyncSessions, getSyncMessages, getSyncParts } from '@/sync/sync-refs'; +import { getSyncSessions, getSyncMessages, getSyncParts, getAllSyncSessions, getSyncSessionDirectory } from '@/sync/sync-refs'; +import { + describeSessionDirectorySources, + resolveSessionDirectoryFromSources, +} from '@/sync/session-directory-resolution'; +import { useSessionWorktreeStore } from '@/sync/session-worktree-store'; +import { getRecentSendFailures } from '@/sync/send-failure-log'; +import { getAttachedSessionDirectory } from '@/sync/session-worktree-contract'; import { useStreamingStore } from '@/sync/streaming'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; @@ -375,12 +382,107 @@ export const debugUtils = { openchamber: { settingsInfo, }, + // Empty is a meaningful answer here: it means no prompt was rejected in + // this session, so a "my message disappeared" report is not a rejected + // send and needs a different explanation. + recentSendFailures: getRecentSendFailures(), + currentSessionDirectoryResolution: sessionState.currentSessionId + ? this.diagnoseSessionDirectory(sessionState.currentSessionId) + : null, }; console.log('[DEBUG] App status snapshot:', report); return report; }, + /** + * Prompt sends that were rejected and rolled back in this app session. + * Newest first; empty means no send was rejected. + */ + getRecentSendFailures() { + const failures = getRecentSendFailures(); + if (failures.length === 0) { + console.log('[OK] No prompt sends were rejected in this session.'); + } else { + console.warn(`[ALERT] ${failures.length} rejected prompt send(s):`); + console.table(failures); + } + return failures; + }, + + /** + * Report how a session's directory is resolved, from every source, in + * precedence order. A send is routed by the winning value, so a disagreement + * here explains a prompt that vanishes without an error: it was posted + * against a directory that does not own the session. + */ + diagnoseSessionDirectory(sessionId?: string) { + const sessionState = useSessionUIStore.getState(); + const targetSessionId = sessionId ?? sessionState.currentSessionId; + + if (!targetSessionId) { + console.log('[ERROR] No session selected and no session id passed'); + return null; + } + + const attachment = getAttachedSessionDirectory( + useSessionWorktreeStore.getState().getAttachment(targetSessionId), + ); + const worktreeMetadata = sessionState.worktreeMetadata.get(targetSessionId)?.path ?? null; + const owningStoreDirectory = getSyncSessionDirectory(targetSessionId); + const sessionRecord = getAllSyncSessions().find((session) => session.id === targetSessionId); + const recordDirectory = (sessionRecord as { directory?: string | null } | undefined)?.directory ?? null; + const selected = targetSessionId === sessionState.currentSessionId + ? sessionState.currentSessionDirectory + : null; + + const remembered = getRememberedSessionDirectory(targetSessionId); + + const sources = { + attachment, + worktreeMetadata, + authoritative: owningStoreDirectory ?? recordDirectory, + selected, + remembered: remembered.runtime, + }; + + const resolution = resolveSessionDirectoryFromSources(sources); + const routedDirectory = sessionState.getDirectoryForSession(targetSessionId); + + const report = { + sessionId: targetSessionId, + isCurrentSession: targetSessionId === sessionState.currentSessionId, + routedDirectory, + resolvedFrom: resolution.source, + conflict: resolution.conflict, + sources: describeSessionDirectorySources(sources), + details: { + owningChildStore: owningStoreDirectory, + sessionRecordDirectory: recordDirectory, + sessionIndexed: Boolean(sessionRecord), + currentSessionDirectory: sessionState.currentSessionDirectory, + rememberedForRuntime: remembered.runtime, + persistedAcrossRestarts: remembered.persisted, + activeDirectory: useDirectoryStore.getState().currentDirectory ?? null, + opencodeClientDirectory: opencodeClient.getDirectory() ?? null, + }, + }; + + console.log('[DEBUG] Session directory resolution:', report); + if (resolution.conflict) { + console.warn( + `[ALERT] Directory sources disagree: using "${resolution.directory}" (${resolution.source}) ` + + `while "${resolution.conflict.directory}" came from ${resolution.conflict.source}.`, + ); + } else if (!routedDirectory) { + console.warn('[ALERT] No directory resolved for this session — sends fall back to the active directory.'); + } else { + console.log('[OK] All known sources agree on the session directory.'); + } + + return report; + }, + async buildDiagnosticsReport() { const report = await this.getAppStatus(); return JSON.stringify(report, null, 2); @@ -697,6 +799,8 @@ if (typeof window !== 'undefined') { console.log(' __opencodeDebug.getAllMessages(truncate?) - List all messages (truncate=true for short preview)'); console.log(' __opencodeDebug.truncateMessages(messages) - Truncate long fields in messages array'); console.log(' __opencodeDebug.getAppStatus() - Show app status snapshot'); + console.log(' __opencodeDebug.diagnoseSessionDirectory(sessionId?) - Show how the session directory is resolved'); + console.log(' __opencodeDebug.getRecentSendFailures() - List prompt sends that were rejected and rolled back'); console.log(' __opencodeDebug.checkLastMessage() - Check if last message is problematic'); console.log(' __opencodeDebug.findEmptyMessages() - Find all empty assistant messages'); console.log(' __opencodeDebug.showRetryHelp() - Show instructions for handling empty responses'); diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 4797e1b8..7e7dd7ab 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -678,9 +678,8 @@ export const settingsDict = { 'settings.skills.sidebar.toast.skillDeleted': 'Skill "{name}" erfolgreich gelöscht', 'settings.skills.sidebar.toast.deleteSkillFailed': 'Skill konnte nicht gelöscht werden', 'settings.skills.sidebar.toast.duplicateLoadFailed': 'Skill-Details für Duplizierung konnten nicht geladen werden', - 'settings.skills.sidebar.toast.renameLoadFailed': 'Skill-Details konnten nicht geladen werden', - 'settings.skills.sidebar.toast.removeOldAfterRenameFailed': 'Alter Skill konnte nach Umbenennung nicht entfernt werden', 'settings.skills.sidebar.toast.renameFailed': 'Skill konnte nicht umbenannt werden', + 'settings.skills.sidebar.toast.skillRenamed': 'Skill umbenannt in "{name}"', 'settings.skills.sidebar.deleteDialog.title': 'Skill löschen', 'settings.skills.sidebar.deleteDialog.description': 'Möchten Sie den Skill "{name}" wirklich löschen?', 'settings.skills.sidebar.renameDialog.title': 'Skill umbenennen', @@ -1280,7 +1279,52 @@ export const settingsDict = { 'settings.providers.page.connect.selectProviderPlaceholder': 'Anbieter auswählen', 'settings.providers.page.connect.searchProvidersPlaceholder': 'Suche...', 'settings.providers.page.connect.noProvidersFound': 'Keine Anbieter gefunden', - 'settings.providers.page.connect.allProvidersConnected': 'Alle Anbieter verbunden.', + 'settings.providers.page.custom.optionLabel': 'Andere / Benutzerdefiniert', + 'settings.providers.page.custom.title': 'Benutzerdefinierter Anbieter', + 'settings.providers.page.custom.editTitle': 'Benutzerdefinierten Anbieter bearbeiten', + 'settings.providers.page.custom.description': 'Fügen Sie einen OpenAI-kompatiblen Anbieter mit Basis-URL, Anmeldedaten und Modellliste hinzu. Wird in der OpenCode-Konfiguration gespeichert und steht im Chat wie jeder andere Anbieter zur Verfügung.', + 'settings.providers.page.custom.field.providerID.label': 'Anbieter-ID', + 'settings.providers.page.custom.field.providerID.placeholder': 'mein-anbieter', + 'settings.providers.page.custom.field.providerID.info': 'Kleinbuchstaben, Zahlen, Bindestriche und Unterstriche. Wird als OpenCode-Anbieter-ID verwendet.', + 'settings.providers.page.custom.field.name.label': 'Anzeigename', + 'settings.providers.page.custom.field.name.placeholder': 'Mein Anbieter', + 'settings.providers.page.custom.field.name.info': 'Wird in den Anbieter- und Modellauswahlen angezeigt.', + 'settings.providers.page.custom.field.baseURL.label': 'Basis-URL', + 'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1', + 'settings.providers.page.custom.field.baseURL.info': 'OpenAI-kompatible API-Basis-URL. Muss mit http:// oder https:// beginnen.', + 'settings.providers.page.custom.field.apiKey.label': 'API-Schlüssel', + 'settings.providers.page.custom.field.apiKey.placeholder': 'sk-... oder {env:VAR_NAME}', + 'settings.providers.page.custom.field.apiKey.info': 'Wird in der OpenCode-Authentifizierung gespeichert, nicht von OpenChamber. Verwenden Sie {env:VAR_NAME}, um einen Schlüssel aus der Umgebung zu lesen.', + 'settings.providers.page.custom.field.apiKey.editInfo': 'Leer lassen, um die vorhandenen Anmeldedaten zu behalten, oder einen neuen Schlüssel / {env:VAR_NAME} eingeben.', + 'settings.providers.page.custom.field.apiKey.editPlaceholder': 'Leer lassen, um den vorhandenen Schlüssel zu behalten', + 'settings.providers.page.custom.models.title': 'Modelle', + 'settings.providers.page.custom.models.idLabel': 'Modell-ID', + 'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o', + 'settings.providers.page.custom.models.nameLabel': 'Modellname', + 'settings.providers.page.custom.models.namePlaceholder': 'GPT-4o', + 'settings.providers.page.custom.models.add': 'Modell hinzufügen', + 'settings.providers.page.custom.models.remove': 'Modell entfernen', + 'settings.providers.page.custom.headers.title': 'Header', + 'settings.providers.page.custom.headers.description': 'Optionale Anfrage-Header, die bei jedem Aufruf gesendet werden.', + 'settings.providers.page.custom.headers.keyLabel': 'Header-Name', + 'settings.providers.page.custom.headers.keyPlaceholder': 'X-Custom-Header', + 'settings.providers.page.custom.headers.valueLabel': 'Header-Wert', + 'settings.providers.page.custom.headers.valuePlaceholder': 'Wert', + 'settings.providers.page.custom.headers.add': 'Header hinzufügen', + 'settings.providers.page.custom.headers.remove': 'Header entfernen', + 'settings.providers.page.custom.actions.back': 'Zurück', + 'settings.providers.page.custom.actions.save': 'Anbieter speichern', + 'settings.providers.page.custom.actions.update': 'Anbieter aktualisieren', + 'settings.providers.page.custom.error.providerID.required': 'Anbieter-ID ist erforderlich', + 'settings.providers.page.custom.error.providerID.format': 'Verwenden Sie Kleinbuchstaben, Zahlen, Bindestriche oder Unterstriche', + 'settings.providers.page.custom.error.providerID.exists': 'Ein Anbieter mit dieser ID ist bereits verbunden', + 'settings.providers.page.custom.error.name.required': 'Anzeigename ist erforderlich', + 'settings.providers.page.custom.error.baseURL.required': 'Basis-URL ist erforderlich', + 'settings.providers.page.custom.error.baseURL.format': 'Basis-URL muss mit http:// oder https:// beginnen', + 'settings.providers.page.custom.error.required': 'Erforderlich', + 'settings.providers.page.custom.error.duplicate': 'Duplikat', + 'settings.providers.page.custom.error.apiKey.required': 'API-Schlüssel oder {env:VAR_NAME} ist erforderlich', + 'settings.providers.page.custom.authFailure.configAfterAuth': 'Anmeldedaten wurden gespeichert, aber die Anbieterkonfiguration nicht. Beheben Sie den Fehler und versuchen Sie es erneut, oder trennen Sie die Verbindung, um den teilweisen Speichervorgang zu löschen.', 'settings.providers.page.auth.title': 'Authentifizierung', 'settings.providers.page.auth.loadingMethods': 'Lade Authentifizierungsmethoden...', 'settings.providers.page.auth.apiKeyLabel': 'API-Schlüssel', @@ -1289,6 +1333,8 @@ export const settingsDict = { 'settings.providers.page.auth.oauthMethodFallback': 'OAuth-Methode {index}', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Autorisierungscode einfügen', 'settings.providers.page.auth.connected': 'Verbunden', + 'settings.providers.page.auth.incomplete': 'Anmeldedaten fehlen', + 'settings.providers.page.auth.incompleteHint': '· Fügen Sie einen API-Schlüssel oder {env:VAR} hinzu, bevor Sie diesen Anbieter im Chat verwenden', 'settings.providers.page.auth.useReconnectHint': '· Verwenden Sie „Erneut verbinden“, um Anmeldedaten zu aktualisieren', 'settings.providers.page.connectionDetails.title': 'Verbindungsdetails', 'settings.providers.page.connectionDetails.configuredIn': 'Konfiguriert in:', @@ -1318,6 +1364,7 @@ export const settingsDict = { 'settings.providers.page.actions.complete': 'Vervollständigen', 'settings.providers.page.actions.hide': 'Ausblenden', 'settings.providers.page.actions.reconnect': 'Erneut verbinden', + 'settings.providers.page.actions.edit': 'Bearbeiten', 'settings.providers.page.actions.disconnecting': 'Trennen...', 'settings.providers.page.actions.disconnect': 'Trennen', 'settings.providers.page.actions.hideAll': 'Alle ausblenden', @@ -1338,6 +1385,8 @@ export const settingsDict = { 'settings.providers.page.toast.deviceCodeCopyFailed': 'Fehler beim Kopieren des Gerätecodes', 'settings.providers.page.toast.providerDisconnected': 'Anbieter getrennt', 'settings.providers.page.toast.providerDisconnectFailed': 'Fehler beim Trennen des Anbieters', + 'settings.providers.page.toast.customProviderSaved': '{provider} verbunden', + 'settings.providers.page.toast.customProviderSaveFailed': 'Benutzerdefinierter Anbieter konnte nicht gespeichert werden', 'settings.mcp.page.empty.selectServer': 'Wählen Sie einen MCP-Server aus der Seitenleiste', 'settings.mcp.page.empty.addNewOne': 'oder fügen Sie einen neuen hinzu', 'settings.mcp.page.header.newServer': 'Neuer MCP-Server', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 9be36acd..825e4f17 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -730,9 +730,8 @@ export const settingsDict = { 'settings.skills.sidebar.toast.skillDeleted': 'Skill "{name}" deleted successfully', 'settings.skills.sidebar.toast.deleteSkillFailed': 'Failed to delete skill', 'settings.skills.sidebar.toast.duplicateLoadFailed': 'Failed to load skill details for duplication', - 'settings.skills.sidebar.toast.renameLoadFailed': 'Failed to load skill details', - 'settings.skills.sidebar.toast.removeOldAfterRenameFailed': 'Failed to remove old skill after rename', 'settings.skills.sidebar.toast.renameFailed': 'Failed to rename skill', + 'settings.skills.sidebar.toast.skillRenamed': 'Skill renamed to "{name}"', 'settings.skills.sidebar.deleteDialog.title': 'Delete Skill', 'settings.skills.sidebar.deleteDialog.description': 'Are you sure you want to delete skill "{name}"?', 'settings.skills.sidebar.renameDialog.title': 'Rename Skill', @@ -1345,7 +1344,52 @@ export const settingsDict = { 'settings.providers.page.connect.selectProviderPlaceholder': 'Select provider', 'settings.providers.page.connect.searchProvidersPlaceholder': 'Search...', 'settings.providers.page.connect.noProvidersFound': 'No providers found', - 'settings.providers.page.connect.allProvidersConnected': 'All providers connected.', + 'settings.providers.page.custom.optionLabel': 'Other / Custom', + 'settings.providers.page.custom.title': 'Custom provider', + 'settings.providers.page.custom.editTitle': 'Edit custom provider', + 'settings.providers.page.custom.description': 'Add an OpenAI-compatible provider with a base URL, credentials, and model list. Saved to OpenCode config so it works in chat like any other provider.', + 'settings.providers.page.custom.field.providerID.label': 'Provider ID', + 'settings.providers.page.custom.field.providerID.placeholder': 'my-provider', + 'settings.providers.page.custom.field.providerID.info': 'Lowercase letters, numbers, hyphens, and underscores. Used as the OpenCode provider id.', + 'settings.providers.page.custom.field.name.label': 'Display name', + 'settings.providers.page.custom.field.name.placeholder': 'My Provider', + 'settings.providers.page.custom.field.name.info': 'Shown in the provider and model pickers.', + 'settings.providers.page.custom.field.baseURL.label': 'Base URL', + 'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1', + 'settings.providers.page.custom.field.baseURL.info': 'OpenAI-compatible API base URL. Must start with http:// or https://.', + 'settings.providers.page.custom.field.apiKey.label': 'API key', + 'settings.providers.page.custom.field.apiKey.placeholder': 'sk-... or {env:VAR_NAME}', + 'settings.providers.page.custom.field.apiKey.info': 'Stored in OpenCode auth, not by OpenChamber. Use {env:VAR_NAME} to read a key from the environment instead.', + 'settings.providers.page.custom.field.apiKey.editInfo': 'Leave blank to keep the existing credential, or enter a new key / {env:VAR_NAME}.', + 'settings.providers.page.custom.field.apiKey.editPlaceholder': 'Leave blank to keep existing key', + 'settings.providers.page.custom.models.title': 'Models', + 'settings.providers.page.custom.models.idLabel': 'Model ID', + 'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o', + 'settings.providers.page.custom.models.nameLabel': 'Model name', + 'settings.providers.page.custom.models.namePlaceholder': 'GPT-4o', + 'settings.providers.page.custom.models.add': 'Add model', + 'settings.providers.page.custom.models.remove': 'Remove model', + 'settings.providers.page.custom.headers.title': 'Headers', + 'settings.providers.page.custom.headers.description': 'Optional request headers sent with every call.', + 'settings.providers.page.custom.headers.keyLabel': 'Header name', + 'settings.providers.page.custom.headers.keyPlaceholder': 'X-Custom-Header', + 'settings.providers.page.custom.headers.valueLabel': 'Header value', + 'settings.providers.page.custom.headers.valuePlaceholder': 'value', + 'settings.providers.page.custom.headers.add': 'Add header', + 'settings.providers.page.custom.headers.remove': 'Remove header', + 'settings.providers.page.custom.actions.back': 'Back', + 'settings.providers.page.custom.actions.save': 'Save provider', + 'settings.providers.page.custom.actions.update': 'Update provider', + 'settings.providers.page.custom.error.providerID.required': 'Provider ID is required', + 'settings.providers.page.custom.error.providerID.format': 'Use lowercase letters, numbers, hyphens, or underscores', + 'settings.providers.page.custom.error.providerID.exists': 'A provider with this ID is already connected', + 'settings.providers.page.custom.error.name.required': 'Display name is required', + 'settings.providers.page.custom.error.baseURL.required': 'Base URL is required', + 'settings.providers.page.custom.error.baseURL.format': 'Base URL must start with http:// or https://', + 'settings.providers.page.custom.error.required': 'Required', + 'settings.providers.page.custom.error.duplicate': 'Duplicate', + 'settings.providers.page.custom.error.apiKey.required': 'API key or {env:VAR_NAME} is required', + 'settings.providers.page.custom.authFailure.configAfterAuth': 'Credentials were saved, but the provider config was not. Fix the error and try again, or disconnect to clear the partial save.', 'settings.providers.page.auth.title': 'Authentication', 'settings.providers.page.auth.loadingMethods': 'Loading authentication methods...', 'settings.providers.page.auth.apiKeyLabel': 'API Key', @@ -1354,6 +1398,8 @@ export const settingsDict = { 'settings.providers.page.auth.oauthMethodFallback': 'OAuth method {index}', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Paste authorization code', 'settings.providers.page.auth.connected': 'Connected', + 'settings.providers.page.auth.incomplete': 'Credentials missing', + 'settings.providers.page.auth.incompleteHint': '· Add an API key or {env:VAR} before using this provider in chat', 'settings.providers.page.auth.useReconnectHint': '· Use Reconnect to update credentials', 'settings.providers.page.connectionDetails.title': 'Connection Details', 'settings.providers.page.connectionDetails.configuredIn': 'Configured in:', @@ -1383,6 +1429,7 @@ export const settingsDict = { 'settings.providers.page.actions.complete': 'Complete', 'settings.providers.page.actions.hide': 'Hide', 'settings.providers.page.actions.reconnect': 'Reconnect', + 'settings.providers.page.actions.edit': 'Edit', 'settings.providers.page.actions.disconnecting': 'Disconnecting...', 'settings.providers.page.actions.disconnect': 'Disconnect', 'settings.providers.page.actions.hideAll': 'Hide all', @@ -1403,6 +1450,8 @@ export const settingsDict = { 'settings.providers.page.toast.deviceCodeCopyFailed': 'Failed to copy device code', 'settings.providers.page.toast.providerDisconnected': 'Provider disconnected', 'settings.providers.page.toast.providerDisconnectFailed': 'Failed to disconnect provider', + 'settings.providers.page.toast.customProviderSaved': '{provider} connected', + 'settings.providers.page.toast.customProviderSaveFailed': 'Failed to save custom provider', 'settings.mcp.page.empty.selectServer': 'Select an MCP server from the sidebar', 'settings.mcp.page.empty.addNewOne': 'or add a new one', 'settings.mcp.page.header.newServer': 'New MCP Server', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 9b566d10..4fdbda7d 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -698,9 +698,8 @@ export const settingsDict = { "settings.skills.sidebar.toast.skillDeleted": "Habilidad \"{name}\" eliminada con éxito", "settings.skills.sidebar.toast.deleteSkillFailed": "No se pudo eliminar la habilidad", "settings.skills.sidebar.toast.duplicateLoadFailed": "No se pudo cargar la información de la habilidad para duplicarla", - "settings.skills.sidebar.toast.renameLoadFailed": "No se pudo cargar la información de la habilidad", - "settings.skills.sidebar.toast.removeOldAfterRenameFailed": "No se pudo eliminar la habilidad antigua después del cambio de nombre", "settings.skills.sidebar.toast.renameFailed": "No se pudo cambiar el nombre de la habilidad", + "settings.skills.sidebar.toast.skillRenamed": "Habilidad renombrada a \"{name}\"", "settings.skills.sidebar.deleteDialog.title": "Eliminar habilidad", "settings.skills.sidebar.deleteDialog.description": "¿Estás seguro de que quieres eliminar la habilidad \"{name}\"?", "settings.skills.sidebar.renameDialog.title": "Cambiar nombre habilidad", @@ -1313,7 +1312,58 @@ export const settingsDict = { "settings.providers.page.connect.selectProviderPlaceholder": "Seleccionar proveedor", "settings.providers.page.connect.searchProvidersPlaceholder": "Buscar...", "settings.providers.page.connect.noProvidersFound": "No se encontraron proveedores", - "settings.providers.page.connect.allProvidersConnected": "Todos los proveedores están conectados.", + "settings.providers.page.custom.optionLabel": "Otro / Personalizado", + "settings.providers.page.custom.title": "Proveedor personalizado", + "settings.providers.page.custom.editTitle": "Editar proveedor personalizado", + + "settings.providers.page.custom.description": "Añade un proveedor compatible con OpenAI con URL base, credenciales y lista de modelos. Se guarda en la configuración de OpenCode para usarlo en el chat como cualquier otro proveedor.", + "settings.providers.page.custom.field.providerID.label": "ID del proveedor", + "settings.providers.page.custom.field.providerID.placeholder": "mi-proveedor", + "settings.providers.page.custom.field.providerID.info": "Minúsculas, números, guiones y guiones bajos. Se usa como ID de proveedor de OpenCode.", + "settings.providers.page.custom.field.name.label": "Nombre visible", + "settings.providers.page.custom.field.name.placeholder": "Mi proveedor", + "settings.providers.page.custom.field.name.info": "Se muestra en los selectores de proveedor y modelo.", + "settings.providers.page.custom.field.baseURL.label": "URL base", + "settings.providers.page.custom.field.baseURL.placeholder": "https://api.example.com/v1", + "settings.providers.page.custom.field.baseURL.info": "URL base de la API compatible con OpenAI. Debe empezar por http:// o https://.", + "settings.providers.page.custom.field.apiKey.label": "Clave API", + "settings.providers.page.custom.field.apiKey.placeholder": "sk-... o {env:VAR_NAME}", + "settings.providers.page.custom.field.apiKey.info": "Se guarda en la autenticación de OpenCode, no en OpenChamber. Usa {env:VAR_NAME} para leer una clave del entorno.", + "settings.providers.page.custom.field.apiKey.editInfo": "Déjalo en blanco para conservar la credencial existente, o introduce una clave nueva / {env:VAR_NAME}.", + "settings.providers.page.custom.field.apiKey.editPlaceholder": "Déjalo en blanco para conservar la clave existente", + + + "settings.providers.page.custom.models.title": "Modelos", + "settings.providers.page.custom.models.idLabel": "ID del modelo", + "settings.providers.page.custom.models.idPlaceholder": "gpt-4o", + "settings.providers.page.custom.models.nameLabel": "Nombre del modelo", + "settings.providers.page.custom.models.namePlaceholder": "GPT-4o", + "settings.providers.page.custom.models.add": "Añadir modelo", + "settings.providers.page.custom.models.remove": "Quitar modelo", + "settings.providers.page.custom.headers.title": "Encabezados", + "settings.providers.page.custom.headers.description": "Encabezados de solicitud opcionales enviados en cada llamada.", + "settings.providers.page.custom.headers.keyLabel": "Nombre del encabezado", + "settings.providers.page.custom.headers.keyPlaceholder": "X-Custom-Header", + "settings.providers.page.custom.headers.valueLabel": "Valor del encabezado", + "settings.providers.page.custom.headers.valuePlaceholder": "valor", + "settings.providers.page.custom.headers.add": "Añadir encabezado", + "settings.providers.page.custom.headers.remove": "Quitar encabezado", + "settings.providers.page.custom.actions.back": "Atrás", + "settings.providers.page.custom.actions.save": "Guardar proveedor", + "settings.providers.page.custom.actions.update": "Actualizar proveedor", + + "settings.providers.page.custom.error.providerID.required": "El ID del proveedor es obligatorio", + "settings.providers.page.custom.error.providerID.format": "Usa minúsculas, números, guiones o guiones bajos", + "settings.providers.page.custom.error.providerID.exists": "Ya hay un proveedor conectado con este ID", + "settings.providers.page.custom.error.name.required": "El nombre visible es obligatorio", + "settings.providers.page.custom.error.baseURL.required": "La URL base es obligatoria", + "settings.providers.page.custom.error.baseURL.format": "La URL base debe empezar por http:// o https://", + "settings.providers.page.custom.error.required": "Obligatorio", + "settings.providers.page.custom.error.duplicate": "Duplicado", + "settings.providers.page.custom.error.apiKey.required": "Se requiere una clave API o {env:VAR_NAME}", + "settings.providers.page.custom.authFailure.configAfterAuth": "Las credenciales se guardaron, pero no la configuración del proveedor. Corrige el error e inténtalo de nuevo, o desconéctalo para eliminar el guardado parcial.", + + "settings.providers.page.auth.title": "Autenticación", "settings.providers.page.auth.loadingMethods": "Cargando métodos de autenticación...", "settings.providers.page.auth.apiKeyLabel": "Clave API", @@ -1322,6 +1372,10 @@ export const settingsDict = { "settings.providers.page.auth.oauthMethodFallback": "Método OAuth {index}", "settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Pegar código de autorización", "settings.providers.page.auth.connected": "Conectado", + "settings.providers.page.auth.incomplete": "Faltan credenciales", + "settings.providers.page.auth.incompleteHint": "· Añade una clave API o {env:VAR} antes de usar este proveedor en el chat", + + "settings.providers.page.auth.useReconnectHint": "· Usar Reconnect para actualizar credenciales", "settings.providers.page.connectionDetails.title": "Detalles de conexión", "settings.providers.page.connectionDetails.configuredIn": "Configurado en:", @@ -1351,6 +1405,8 @@ export const settingsDict = { "settings.providers.page.actions.complete": "Completar", "settings.providers.page.actions.hide": "Ocultar", "settings.providers.page.actions.reconnect": "Reconectar", + "settings.providers.page.actions.edit": "Editar", + "settings.providers.page.actions.disconnecting": "Desconectando...", "settings.providers.page.actions.disconnect": "Desconectar", "settings.providers.page.actions.hideAll": "Ocultar todo", @@ -1371,6 +1427,8 @@ export const settingsDict = { "settings.providers.page.toast.deviceCodeCopyFailed": "No se pudo copiar el código de dispositivo", "settings.providers.page.toast.providerDisconnected": "Proveedor desconectado", "settings.providers.page.toast.providerDisconnectFailed": "No se pudo desconectar el proveedor", + "settings.providers.page.toast.customProviderSaved": "{provider} conectado", + "settings.providers.page.toast.customProviderSaveFailed": "No se pudo guardar el proveedor personalizado", "settings.mcp.page.empty.selectServer": "Selecciona un servidor MCP desde el panel lateral", "settings.mcp.page.empty.addNewOne": "o añade uno nuevo", "settings.mcp.page.header.newServer": "Nuevo servidor MCP", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index cb12ec9b..333ba9cd 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -619,9 +619,8 @@ export const settingsDict = { 'settings.skills.sidebar.toast.skillDeleted': 'Skill "{name}" supprimé avec succès', 'settings.skills.sidebar.toast.deleteSkillFailed': 'Échec de la suppression du skill', 'settings.skills.sidebar.toast.duplicateLoadFailed': 'Échec du chargement des détails du skill pour la duplication', - 'settings.skills.sidebar.toast.renameLoadFailed': 'Échec du chargement des détails du skill', - 'settings.skills.sidebar.toast.removeOldAfterRenameFailed': 'Échec de la suppression de l\'ancien skill après le renommage', 'settings.skills.sidebar.toast.renameFailed': 'Échec du renommage du skill', + 'settings.skills.sidebar.toast.skillRenamed': 'Skill renommé en "{name}"', 'settings.skills.sidebar.deleteDialog.title': 'Supprimer le skill', 'settings.skills.sidebar.deleteDialog.description': 'Êtes-vous sûr de vouloir supprimer le skill « {name} » ?', 'settings.skills.sidebar.renameDialog.title': 'Renommer le skill', @@ -1234,7 +1233,58 @@ export const settingsDict = { 'settings.providers.page.connect.selectProviderPlaceholder': 'Sélectionnez le fournisseur', 'settings.providers.page.connect.searchProvidersPlaceholder': 'Recherche...', 'settings.providers.page.connect.noProvidersFound': 'Aucun fournisseur trouvé', - 'settings.providers.page.connect.allProvidersConnected': 'Tous les fournisseurs connectés.', + 'settings.providers.page.custom.optionLabel': 'Autre / Personnalisé', + 'settings.providers.page.custom.title': 'Fournisseur personnalisé', + 'settings.providers.page.custom.editTitle': 'Modifier le fournisseur personnalisé', + + 'settings.providers.page.custom.description': 'Ajoutez un fournisseur compatible OpenAI avec une URL de base, des identifiants et une liste de modèles. Enregistré dans la configuration OpenCode pour l’utiliser dans le chat comme les autres fournisseurs.', + 'settings.providers.page.custom.field.providerID.label': 'ID du fournisseur', + 'settings.providers.page.custom.field.providerID.placeholder': 'mon-fournisseur', + 'settings.providers.page.custom.field.providerID.info': 'Minuscules, chiffres, tirets et underscores. Utilisé comme ID de fournisseur OpenCode.', + 'settings.providers.page.custom.field.name.label': 'Nom affiché', + 'settings.providers.page.custom.field.name.placeholder': 'Mon fournisseur', + 'settings.providers.page.custom.field.name.info': 'Affiché dans les sélecteurs de fournisseur et de modèle.', + 'settings.providers.page.custom.field.baseURL.label': 'URL de base', + 'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1', + 'settings.providers.page.custom.field.baseURL.info': 'URL de base de l’API compatible OpenAI. Doit commencer par http:// ou https://.', + 'settings.providers.page.custom.field.apiKey.label': 'Clé API', + 'settings.providers.page.custom.field.apiKey.placeholder': 'sk-... ou {env:VAR_NAME}', + 'settings.providers.page.custom.field.apiKey.info': 'Stockée dans l’auth OpenCode, pas par OpenChamber. Utilisez {env:VAR_NAME} pour lire une clé depuis l’environnement.', + 'settings.providers.page.custom.field.apiKey.editInfo': 'Laissez vide pour conserver l\'identifiant existant, ou saisissez une nouvelle clé / {env:VAR_NAME}.', + 'settings.providers.page.custom.field.apiKey.editPlaceholder': 'Laissez vide pour conserver la clé existante', + + + 'settings.providers.page.custom.models.title': 'Modèles', + 'settings.providers.page.custom.models.idLabel': 'ID du modèle', + 'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o', + 'settings.providers.page.custom.models.nameLabel': 'Nom du modèle', + 'settings.providers.page.custom.models.namePlaceholder': 'GPT-4o', + 'settings.providers.page.custom.models.add': 'Ajouter un modèle', + 'settings.providers.page.custom.models.remove': 'Supprimer le modèle', + 'settings.providers.page.custom.headers.title': 'En-têtes', + 'settings.providers.page.custom.headers.description': 'En-têtes de requête optionnels envoyés à chaque appel.', + 'settings.providers.page.custom.headers.keyLabel': 'Nom de l’en-tête', + 'settings.providers.page.custom.headers.keyPlaceholder': 'X-Custom-Header', + 'settings.providers.page.custom.headers.valueLabel': 'Valeur de l’en-tête', + 'settings.providers.page.custom.headers.valuePlaceholder': 'valeur', + 'settings.providers.page.custom.headers.add': 'Ajouter un en-tête', + 'settings.providers.page.custom.headers.remove': 'Supprimer l’en-tête', + 'settings.providers.page.custom.actions.back': 'Retour', + 'settings.providers.page.custom.actions.save': 'Enregistrer le fournisseur', + 'settings.providers.page.custom.actions.update': 'Mettre à jour le fournisseur', + + 'settings.providers.page.custom.error.providerID.required': 'L’ID du fournisseur est obligatoire', + 'settings.providers.page.custom.error.providerID.format': 'Utilisez des minuscules, chiffres, tirets ou underscores', + 'settings.providers.page.custom.error.providerID.exists': 'Un fournisseur avec cet ID est déjà connecté', + 'settings.providers.page.custom.error.name.required': 'Le nom affiché est obligatoire', + 'settings.providers.page.custom.error.baseURL.required': 'L’URL de base est obligatoire', + 'settings.providers.page.custom.error.baseURL.format': 'L’URL de base doit commencer par http:// ou https://', + 'settings.providers.page.custom.error.required': 'Obligatoire', + 'settings.providers.page.custom.error.duplicate': 'Doublon', + 'settings.providers.page.custom.error.apiKey.required': 'Une clé API ou {env:VAR_NAME} est requise', + 'settings.providers.page.custom.authFailure.configAfterAuth': 'Les identifiants ont été enregistrés, mais pas la configuration du fournisseur. Corrigez l\'erreur et réessayez, ou déconnectez pour effacer l\'enregistrement partiel.', + + 'settings.providers.page.auth.title': 'Authentification', 'settings.providers.page.auth.loadingMethods': 'Chargement des méthodes d\'authentification...', 'settings.providers.page.auth.apiKeyLabel': 'Clé API', @@ -1243,6 +1293,10 @@ export const settingsDict = { 'settings.providers.page.auth.oauthMethodFallback': 'Méthode OAuth {index}', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Coller le code d\'autorisation', 'settings.providers.page.auth.connected': 'Connecté', + 'settings.providers.page.auth.incomplete': 'Identifiants manquants', + 'settings.providers.page.auth.incompleteHint': '· Ajoutez une clé API ou {env:VAR} avant d’utiliser ce fournisseur dans le chat', + + 'settings.providers.page.auth.useReconnectHint': '· Utilisez Reconnect pour mettre à jour les informations d\'identification', 'settings.providers.page.connectionDetails.title': 'Détails de connexion', 'settings.providers.page.connectionDetails.configuredIn': 'Configuré dans :', @@ -1272,6 +1326,8 @@ export const settingsDict = { 'settings.providers.page.actions.complete': 'Complet', 'settings.providers.page.actions.hide': 'Cacher', 'settings.providers.page.actions.reconnect': 'Reconnecter', + 'settings.providers.page.actions.edit': 'Modifier', + 'settings.providers.page.actions.disconnecting': 'Déconnexion...', 'settings.providers.page.actions.disconnect': 'Déconnecter', 'settings.providers.page.actions.hideAll': 'Tout cacher', @@ -1292,6 +1348,8 @@ export const settingsDict = { 'settings.providers.page.toast.deviceCodeCopyFailed': 'Échec de la copie du code de l\'appareil', 'settings.providers.page.toast.providerDisconnected': 'Fournisseur déconnecté', 'settings.providers.page.toast.providerDisconnectFailed': 'Échec de la déconnexion du fournisseur', + 'settings.providers.page.toast.customProviderSaved': '{provider} connecté', + 'settings.providers.page.toast.customProviderSaveFailed': 'Échec de l’enregistrement du fournisseur personnalisé', 'settings.mcp.page.empty.selectServer': 'Sélectionnez un serveur MCP dans la barre latérale', 'settings.mcp.page.empty.addNewOne': 'ou ajoutez-en un nouveau', 'settings.mcp.page.header.newServer': 'Nouveau serveur MCP', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 9c39c92e..31b7ce30 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -731,9 +731,8 @@ export const settingsDict = { 'settings.skills.sidebar.toast.skillDeleted': 'Skill "{name}" を削除しました', 'settings.skills.sidebar.toast.deleteSkillFailed': 'Skill の削除に失敗しました', 'settings.skills.sidebar.toast.duplicateLoadFailed': '複製用の Skill 詳細の読み込みに失敗しました', - 'settings.skills.sidebar.toast.renameLoadFailed': 'Skill 詳細の読み込みに失敗しました', - 'settings.skills.sidebar.toast.removeOldAfterRenameFailed': '名前変更後に古い Skill の削除に失敗しました', 'settings.skills.sidebar.toast.renameFailed': 'Skill の名前変更に失敗しました', + 'settings.skills.sidebar.toast.skillRenamed': 'Skill の名前を「{name}」に変更しました', 'settings.skills.sidebar.deleteDialog.title': 'Skill を削除', 'settings.skills.sidebar.deleteDialog.description': 'Skill "{name}" を削除してもよろしいですか?', 'settings.skills.sidebar.renameDialog.title': 'Skill の名前変更', @@ -1346,7 +1345,58 @@ export const settingsDict = { 'settings.providers.page.connect.selectProviderPlaceholder': 'Provider を選択', 'settings.providers.page.connect.searchProvidersPlaceholder': '検索...', 'settings.providers.page.connect.noProvidersFound': 'Provider が見つかりません', - 'settings.providers.page.connect.allProvidersConnected': 'すべての Provider が接続されています。', + 'settings.providers.page.custom.optionLabel': 'その他 / カスタム', + 'settings.providers.page.custom.title': 'カスタムプロバイダー', + 'settings.providers.page.custom.editTitle': 'カスタムプロバイダーを編集', + + 'settings.providers.page.custom.description': 'ベース URL・認証情報・モデル一覧を指定して、OpenAI 互換プロバイダーを追加します。OpenCode 設定に保存され、他のプロバイダーと同様にチャットで使えます。', + 'settings.providers.page.custom.field.providerID.label': 'プロバイダー ID', + 'settings.providers.page.custom.field.providerID.placeholder': 'my-provider', + 'settings.providers.page.custom.field.providerID.info': '小文字・数字・ハイフン・アンダースコア。OpenCode のプロバイダー ID として使われます。', + 'settings.providers.page.custom.field.name.label': '表示名', + 'settings.providers.page.custom.field.name.placeholder': 'My Provider', + 'settings.providers.page.custom.field.name.info': 'プロバイダーおよびモデル選択に表示されます。', + 'settings.providers.page.custom.field.baseURL.label': 'ベース URL', + 'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1', + 'settings.providers.page.custom.field.baseURL.info': 'OpenAI 互換 API のベース URL。http:// または https:// で始めてください。', + 'settings.providers.page.custom.field.apiKey.label': 'API キー', + 'settings.providers.page.custom.field.apiKey.placeholder': 'sk-... または {env:VAR_NAME}', + 'settings.providers.page.custom.field.apiKey.info': 'OpenChamber ではなく OpenCode の認証に保存されます。環境変数から読む場合は {env:VAR_NAME} を使います。', + 'settings.providers.page.custom.field.apiKey.editInfo': '空のままにすると既存の認証情報を保持します。新しいキーまたは {env:VAR_NAME} を入力することもできます。', + 'settings.providers.page.custom.field.apiKey.editPlaceholder': '空のままにすると既存のキーを保持', + + + 'settings.providers.page.custom.models.title': 'モデル', + 'settings.providers.page.custom.models.idLabel': 'モデル ID', + 'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o', + 'settings.providers.page.custom.models.nameLabel': 'モデル名', + 'settings.providers.page.custom.models.namePlaceholder': 'GPT-4o', + 'settings.providers.page.custom.models.add': 'モデルを追加', + 'settings.providers.page.custom.models.remove': 'モデルを削除', + 'settings.providers.page.custom.headers.title': 'ヘッダー', + 'settings.providers.page.custom.headers.description': '各リクエストに付ける任意のヘッダーです。', + 'settings.providers.page.custom.headers.keyLabel': 'ヘッダー名', + 'settings.providers.page.custom.headers.keyPlaceholder': 'X-Custom-Header', + 'settings.providers.page.custom.headers.valueLabel': 'ヘッダー値', + 'settings.providers.page.custom.headers.valuePlaceholder': 'value', + 'settings.providers.page.custom.headers.add': 'ヘッダーを追加', + 'settings.providers.page.custom.headers.remove': 'ヘッダーを削除', + 'settings.providers.page.custom.actions.back': '戻る', + 'settings.providers.page.custom.actions.save': 'プロバイダーを保存', + 'settings.providers.page.custom.actions.update': 'プロバイダーを更新', + + 'settings.providers.page.custom.error.providerID.required': 'プロバイダー ID は必須です', + 'settings.providers.page.custom.error.providerID.format': '小文字・数字・ハイフン・アンダースコアを使ってください', + 'settings.providers.page.custom.error.providerID.exists': 'この ID のプロバイダーは既に接続されています', + 'settings.providers.page.custom.error.name.required': '表示名は必須です', + 'settings.providers.page.custom.error.baseURL.required': 'ベース URL は必須です', + 'settings.providers.page.custom.error.baseURL.format': 'ベース URL は http:// または https:// で始めてください', + 'settings.providers.page.custom.error.required': '必須', + 'settings.providers.page.custom.error.duplicate': '重複', + 'settings.providers.page.custom.error.apiKey.required': 'API キーまたは {env:VAR_NAME} が必要です', + 'settings.providers.page.custom.authFailure.configAfterAuth': '認証情報は保存されましたが、プロバイダー設定は保存されませんでした。エラーを修正して再試行するか、切断して不完全な保存を削除してください。', + + 'settings.providers.page.auth.title': '認証', 'settings.providers.page.auth.loadingMethods': '認証方法を読み込み中...', 'settings.providers.page.auth.apiKeyLabel': 'API キー', @@ -1355,6 +1405,10 @@ export const settingsDict = { 'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方法 {index}', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '認証コードを貼り付け', 'settings.providers.page.auth.connected': '接続済み', + 'settings.providers.page.auth.incomplete': '認証情報が不足しています', + 'settings.providers.page.auth.incompleteHint': '· チャットでこのプロバイダーを使う前に API キーまたは {env:VAR} を追加してください', + + 'settings.providers.page.auth.useReconnectHint': '· 認証情報を更新するには再接続を使用', 'settings.providers.page.connectionDetails.title': '接続詳細', 'settings.providers.page.connectionDetails.configuredIn': '設定場所:', @@ -1384,6 +1438,8 @@ export const settingsDict = { 'settings.providers.page.actions.complete': '完了', 'settings.providers.page.actions.hide': '非表示', 'settings.providers.page.actions.reconnect': '再接続', + 'settings.providers.page.actions.edit': '編集', + 'settings.providers.page.actions.disconnecting': '切断中...', 'settings.providers.page.actions.disconnect': '切断', 'settings.providers.page.actions.hideAll': 'すべて非表示', @@ -1404,6 +1460,8 @@ export const settingsDict = { 'settings.providers.page.toast.deviceCodeCopyFailed': 'デバイスコードのコピーに失敗しました', 'settings.providers.page.toast.providerDisconnected': 'Provider を切断しました', 'settings.providers.page.toast.providerDisconnectFailed': 'Provider の切断に失敗しました', + 'settings.providers.page.toast.customProviderSaved': '{provider} を接続しました', + 'settings.providers.page.toast.customProviderSaveFailed': 'カスタムプロバイダーの保存に失敗しました', 'settings.mcp.page.empty.selectServer': 'サイドバーから MCP サーバーを選択してください', 'settings.mcp.page.empty.addNewOne': 'または新しいものを追加', 'settings.mcp.page.header.newServer': '新しい MCP サーバー', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index b0982a40..32297cc5 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -698,9 +698,8 @@ export const settingsDict = { 'settings.skills.sidebar.toast.skillDeleted': '스킬 "{name}"을 삭제했습니다', 'settings.skills.sidebar.toast.deleteSkillFailed': '스킬을 삭제하지 못했습니다', 'settings.skills.sidebar.toast.duplicateLoadFailed': '복제를 위한 스킬 세부 정보를 로드하지 못했습니다', - 'settings.skills.sidebar.toast.renameLoadFailed': '스킬 세부 정보를 로드하지 못했습니다', - 'settings.skills.sidebar.toast.removeOldAfterRenameFailed': '이름 변경 후 이전 스킬을 제거하지 못했습니다', 'settings.skills.sidebar.toast.renameFailed': '스킬 이름을 변경하지 못했습니다', + 'settings.skills.sidebar.toast.skillRenamed': '스킬 이름이 "{name}"(으)로 변경되었습니다', 'settings.skills.sidebar.deleteDialog.title': '스킬 삭제', 'settings.skills.sidebar.deleteDialog.description': '스킬 "{name}"을 삭제하시겠습니까?', 'settings.skills.sidebar.renameDialog.title': '스킬 이름 변경', @@ -1313,7 +1312,58 @@ export const settingsDict = { 'settings.providers.page.connect.selectProviderPlaceholder': '프로바이더 선택', 'settings.providers.page.connect.searchProvidersPlaceholder': '검색...', 'settings.providers.page.connect.noProvidersFound': '프로바이더를 찾을 수 없습니다', - 'settings.providers.page.connect.allProvidersConnected': '모든 프로바이더가 연결되었습니다.', + 'settings.providers.page.custom.optionLabel': '기타 / 사용자 정의', + 'settings.providers.page.custom.title': '사용자 정의 제공자', + 'settings.providers.page.custom.editTitle': '사용자 지정 공급자 편집', + + 'settings.providers.page.custom.description': '기본 URL, 자격 증명, 모델 목록으로 OpenAI 호환 제공자를 추가합니다. OpenCode 설정에 저장되어 다른 제공자와 같이 채팅에서 사용할 수 있습니다.', + 'settings.providers.page.custom.field.providerID.label': '제공자 ID', + 'settings.providers.page.custom.field.providerID.placeholder': 'my-provider', + 'settings.providers.page.custom.field.providerID.info': '소문자, 숫자, 하이픈, 밑줄. OpenCode 제공자 ID로 사용됩니다.', + 'settings.providers.page.custom.field.name.label': '표시 이름', + 'settings.providers.page.custom.field.name.placeholder': '내 제공자', + 'settings.providers.page.custom.field.name.info': '제공자 및 모델 선택기에 표시됩니다.', + 'settings.providers.page.custom.field.baseURL.label': '기본 URL', + 'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1', + 'settings.providers.page.custom.field.baseURL.info': 'OpenAI 호환 API 기본 URL. http:// 또는 https://로 시작해야 합니다.', + 'settings.providers.page.custom.field.apiKey.label': 'API 키', + 'settings.providers.page.custom.field.apiKey.placeholder': 'sk-... 또는 {env:VAR_NAME}', + 'settings.providers.page.custom.field.apiKey.info': 'OpenChamber가 아니라 OpenCode 인증에 저장됩니다. 환경 변수에서 읽으려면 {env:VAR_NAME}을 사용하세요.', + 'settings.providers.page.custom.field.apiKey.editInfo': '비워 두면 기존 자격 증명을 유지합니다. 새 키 또는 {env:VAR_NAME}을(를) 입력할 수도 있습니다.', + 'settings.providers.page.custom.field.apiKey.editPlaceholder': '비워 두면 기존 키 유지', + + + 'settings.providers.page.custom.models.title': '모델', + 'settings.providers.page.custom.models.idLabel': '모델 ID', + 'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o', + 'settings.providers.page.custom.models.nameLabel': '모델 이름', + 'settings.providers.page.custom.models.namePlaceholder': 'GPT-4o', + 'settings.providers.page.custom.models.add': '모델 추가', + 'settings.providers.page.custom.models.remove': '모델 제거', + 'settings.providers.page.custom.headers.title': '헤더', + 'settings.providers.page.custom.headers.description': '매 호출에 전송되는 선택적 요청 헤더입니다.', + 'settings.providers.page.custom.headers.keyLabel': '헤더 이름', + 'settings.providers.page.custom.headers.keyPlaceholder': 'X-Custom-Header', + 'settings.providers.page.custom.headers.valueLabel': '헤더 값', + 'settings.providers.page.custom.headers.valuePlaceholder': 'value', + 'settings.providers.page.custom.headers.add': '헤더 추가', + 'settings.providers.page.custom.headers.remove': '헤더 제거', + 'settings.providers.page.custom.actions.back': '뒤로', + 'settings.providers.page.custom.actions.save': '제공자 저장', + 'settings.providers.page.custom.actions.update': '공급자 업데이트', + + 'settings.providers.page.custom.error.providerID.required': '제공자 ID는 필수입니다', + 'settings.providers.page.custom.error.providerID.format': '소문자, 숫자, 하이픈, 밑줄을 사용하세요', + 'settings.providers.page.custom.error.providerID.exists': '이 ID의 제공자가 이미 연결되어 있습니다', + 'settings.providers.page.custom.error.name.required': '표시 이름은 필수입니다', + 'settings.providers.page.custom.error.baseURL.required': '기본 URL은 필수입니다', + 'settings.providers.page.custom.error.baseURL.format': '기본 URL은 http:// 또는 https://로 시작해야 합니다', + 'settings.providers.page.custom.error.required': '필수', + 'settings.providers.page.custom.error.duplicate': '중복', + 'settings.providers.page.custom.error.apiKey.required': 'API 키 또는 {env:VAR_NAME}이(가) 필요합니다', + 'settings.providers.page.custom.authFailure.configAfterAuth': '자격 증명은 저장되었지만 공급자 구성은 저장되지 않았습니다. 오류를 수정한 뒤 다시 시도하거나, 연결을 해제하여 부분 저장을 지우세요.', + + 'settings.providers.page.auth.title': '인증', 'settings.providers.page.auth.loadingMethods': '인증 방식 로딩 중...', 'settings.providers.page.auth.apiKeyLabel': 'API Key', @@ -1322,6 +1372,10 @@ export const settingsDict = { 'settings.providers.page.auth.oauthMethodFallback': 'OAuth 방식 {index}', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'authorization code 붙여넣기', 'settings.providers.page.auth.connected': '연결됨', + 'settings.providers.page.auth.incomplete': '자격 증명 없음', + 'settings.providers.page.auth.incompleteHint': '· 채팅에서 이 공급자를 사용하기 전에 API 키 또는 {env:VAR}을(를) 추가하세요', + + 'settings.providers.page.auth.useReconnectHint': '· 인증 정보를 업데이트하려면 Reconnect를 사용하세요', 'settings.providers.page.connectionDetails.title': '연결 세부 정보', 'settings.providers.page.connectionDetails.configuredIn': '설정 위치:', @@ -1351,6 +1405,8 @@ export const settingsDict = { 'settings.providers.page.actions.complete': '완료', 'settings.providers.page.actions.hide': '숨기기', 'settings.providers.page.actions.reconnect': '재연결', + 'settings.providers.page.actions.edit': '편집', + 'settings.providers.page.actions.disconnecting': '연결 해제 중...', 'settings.providers.page.actions.disconnect': '연결 해제', 'settings.providers.page.actions.hideAll': '모두 숨기기', @@ -1371,6 +1427,8 @@ export const settingsDict = { 'settings.providers.page.toast.deviceCodeCopyFailed': '기기 코드를 복사하지 못했습니다', 'settings.providers.page.toast.providerDisconnected': '프로바이더 연결이 해제되었습니다', 'settings.providers.page.toast.providerDisconnectFailed': '프로바이더 연결을 해제하지 못했습니다', + 'settings.providers.page.toast.customProviderSaved': '{provider} 연결됨', + 'settings.providers.page.toast.customProviderSaveFailed': '사용자 정의 제공자를 저장하지 못했습니다', 'settings.mcp.page.empty.selectServer': '사이드바에서 MCP 서버를 선택하세요', 'settings.mcp.page.empty.addNewOne': '또는 새로 추가하세요', 'settings.mcp.page.header.newServer': '새 MCP 서버', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 563f96c0..1187698d 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1368,6 +1368,8 @@ export const settingsDict = { 'settings.providers.page.actions.hideAll': 'Ukryj wszystko', 'settings.providers.page.actions.open': 'Otwórz', 'settings.providers.page.actions.reconnect': 'Połącz ponownie', + 'settings.providers.page.actions.edit': 'Edytuj', + 'settings.providers.page.actions.saveKey': 'Zapisz klucz', 'settings.providers.page.actions.saving': 'Zapisywanie...', 'settings.providers.page.actions.showAll': 'Pokaż wszystko', @@ -1375,12 +1377,67 @@ export const settingsDict = { 'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...', 'settings.providers.page.auth.apiKeyTooltip': 'Klucze są wysyłane bezpośrednio do OpenCode i nigdy nie są przechowywane przez OpenChamber.', 'settings.providers.page.auth.connected': 'Połączono', + 'settings.providers.page.auth.incomplete': 'Brak poświadczeń', + 'settings.providers.page.auth.incompleteHint': '· Dodaj klucz API lub {env:VAR} przed użyciem tego dostawcy w czacie', + + 'settings.providers.page.auth.loadingMethods': 'Ładowanie metod uwierzytelniania...', 'settings.providers.page.auth.oauthMethodFallback': 'Metoda OAuth {index}', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Wklej kod autoryzacyjny', 'settings.providers.page.auth.title': 'Uwierzytelnianie', 'settings.providers.page.auth.useReconnectHint': '· Użyj Połącz ponownie, aby zaktualizować dane logowania', - 'settings.providers.page.connect.allProvidersConnected': 'Wszyscy dostawcy są połączeni.', + 'settings.providers.page.custom.optionLabel': 'Inny / Niestandardowy', + 'settings.providers.page.custom.title': 'Niestandardowy dostawca', + 'settings.providers.page.custom.editTitle': 'Edytuj niestandardowego dostawcę', + + 'settings.providers.page.custom.description': 'Dodaj dostawcę zgodnego z OpenAI, podając adres bazowy, poświadczenia i listę modeli. Zapisuje się w konfiguracji OpenCode i działa w czacie jak każdy inny dostawca.', + 'settings.providers.page.custom.field.providerID.label': 'ID dostawcy', + 'settings.providers.page.custom.field.providerID.placeholder': 'moj-dostawca', + 'settings.providers.page.custom.field.providerID.info': 'Małe litery, cyfry, myślniki i podkreślenia. Używane jako ID dostawcy OpenCode.', + 'settings.providers.page.custom.field.name.label': 'Nazwa wyświetlana', + 'settings.providers.page.custom.field.name.placeholder': 'Mój dostawca', + 'settings.providers.page.custom.field.name.info': 'Widoczna w selektorach dostawcy i modelu.', + 'settings.providers.page.custom.field.baseURL.label': 'Adres bazowy', + 'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1', + 'settings.providers.page.custom.field.baseURL.info': 'Bazowy URL API zgodnego z OpenAI. Musi zaczynać się od http:// lub https://.', + 'settings.providers.page.custom.field.apiKey.label': 'Klucz API', + 'settings.providers.page.custom.field.apiKey.placeholder': 'sk-... lub {env:VAR_NAME}', + 'settings.providers.page.custom.field.apiKey.info': 'Przechowywany w auth OpenCode, nie przez OpenChamber. Użyj {env:VAR_NAME}, aby odczytać klucz ze zmiennej środowiskowej.', + 'settings.providers.page.custom.field.apiKey.editInfo': 'Pozostaw puste, aby zachować istniejące poświadczenie, albo wpisz nowy klucz / {env:VAR_NAME}.', + 'settings.providers.page.custom.field.apiKey.editPlaceholder': 'Pozostaw puste, aby zachować istniejący klucz', + + + 'settings.providers.page.custom.models.title': 'Modele', + 'settings.providers.page.custom.models.idLabel': 'ID modelu', + 'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o', + 'settings.providers.page.custom.models.nameLabel': 'Nazwa modelu', + 'settings.providers.page.custom.models.namePlaceholder': 'GPT-4o', + 'settings.providers.page.custom.models.add': 'Dodaj model', + 'settings.providers.page.custom.models.remove': 'Usuń model', + 'settings.providers.page.custom.headers.title': 'Nagłówki', + 'settings.providers.page.custom.headers.description': 'Opcjonalne nagłówki żądania wysyłane przy każdym wywołaniu.', + 'settings.providers.page.custom.headers.keyLabel': 'Nazwa nagłówka', + 'settings.providers.page.custom.headers.keyPlaceholder': 'X-Custom-Header', + 'settings.providers.page.custom.headers.valueLabel': 'Wartość nagłówka', + 'settings.providers.page.custom.headers.valuePlaceholder': 'wartość', + 'settings.providers.page.custom.headers.add': 'Dodaj nagłówek', + 'settings.providers.page.custom.headers.remove': 'Usuń nagłówek', + 'settings.providers.page.custom.actions.back': 'Wstecz', + 'settings.providers.page.custom.actions.save': 'Zapisz dostawcę', + 'settings.providers.page.custom.actions.update': 'Zaktualizuj dostawcę', + + 'settings.providers.page.custom.error.providerID.required': 'ID dostawcy jest wymagane', + 'settings.providers.page.custom.error.providerID.format': 'Użyj małych liter, cyfr, myślników lub podkreśleń', + 'settings.providers.page.custom.error.providerID.exists': 'Dostawca o tym ID jest już połączony', + 'settings.providers.page.custom.error.name.required': 'Nazwa wyświetlana jest wymagana', + 'settings.providers.page.custom.error.baseURL.required': 'Adres bazowy jest wymagany', + 'settings.providers.page.custom.error.baseURL.format': 'Adres bazowy musi zaczynać się od http:// lub https://', + 'settings.providers.page.custom.error.required': 'Wymagane', + 'settings.providers.page.custom.error.duplicate': 'Duplikat', + 'settings.providers.page.custom.error.apiKey.required': 'Wymagany jest klucz API lub {env:VAR_NAME}', + 'settings.providers.page.custom.authFailure.configAfterAuth': 'Poświadczenia zostały zapisane, ale konfiguracja dostawcy nie. Napraw błąd i spróbuj ponownie albo rozłącz, aby usunąć częściowy zapis.', + + 'settings.providers.page.connect.noProvidersFound': 'Nie znaleziono dostawców', 'settings.providers.page.connect.providerField': 'Dostawca', 'settings.providers.page.connect.searchProvidersPlaceholder': 'Szukaj...', @@ -1426,6 +1483,8 @@ export const settingsDict = { 'settings.providers.page.toast.oauthLinkCopyFailed': 'Nie udało się skopiować linku OAuth', 'settings.providers.page.toast.oauthStartFailed': 'Nie udało się rozpocząć procesu OAuth', 'settings.providers.page.toast.providerDisconnectFailed': 'Nie udało się odłączyć dostawcy', + 'settings.providers.page.toast.customProviderSaved': 'Połączono {provider}', + 'settings.providers.page.toast.customProviderSaveFailed': 'Nie udało się zapisać niestandardowego dostawcy', 'settings.providers.page.toast.providerDisconnected': 'Dostawca został odłączony', 'settings.providers.page.toast.providerSourcesLoadFailed': 'Nie udało się załadować źródeł dostawcy', 'settings.providers.sidebar.actions.connectProviderAria': 'Połącz dostawcę', @@ -1863,10 +1922,9 @@ export const settingsDict = { 'settings.skills.sidebar.title': 'Umiejętności', 'settings.skills.sidebar.toast.deleteSkillFailed': 'Nie udało się usunąć umiejętności', 'settings.skills.sidebar.toast.duplicateLoadFailed': 'Nie udało się załadować szczegółów umiejętności do duplikacji', - 'settings.skills.sidebar.toast.removeOldAfterRenameFailed': 'Nie udało się usunąć starej umiejętności po zmianie nazwy', 'settings.skills.sidebar.toast.renameFailed': 'Nie udało się zmienić nazwy umiejętności', - 'settings.skills.sidebar.toast.renameLoadFailed': 'Nie udało się załadować szczegółów umiejętności', 'settings.skills.sidebar.toast.skillDeleted': 'Umiejętność „{name}” została usunięta', + 'settings.skills.sidebar.toast.skillRenamed': 'Zmieniono nazwę umiejętności na „{name}”', 'settings.skills.sidebar.total': 'Suma: {count}', 'settings.usage.pace.prediction': 'Prognoza: {prediction}', 'settings.usage.pace.predictionLabel': 'Prognoza: ', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 10d0819a..76ff416f 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -698,9 +698,8 @@ export const settingsDict = { "settings.skills.sidebar.toast.skillDeleted": "Habilidade \"{name}\" excluída com sucesso", "settings.skills.sidebar.toast.deleteSkillFailed": "Não foi possível excluir a habilidade", "settings.skills.sidebar.toast.duplicateLoadFailed": "Não foi possível carregar as informações da habilidade para duplicá-la", - "settings.skills.sidebar.toast.renameLoadFailed": "Não foi possível carregar as informações da habilidade", - "settings.skills.sidebar.toast.removeOldAfterRenameFailed": "Não foi possível excluir a habilidade antiga depois da renomeação", "settings.skills.sidebar.toast.renameFailed": "Não foi possível renomear da habilidade", + "settings.skills.sidebar.toast.skillRenamed": "Habilidade renomeada para \"{name}\"", "settings.skills.sidebar.deleteDialog.title": "Excluir habilidade", "settings.skills.sidebar.deleteDialog.description": "Tem certeza de que deseja excluir a habilidade \"{name}\"?", "settings.skills.sidebar.renameDialog.title": "Renomear habilidade", @@ -1313,7 +1312,58 @@ export const settingsDict = { "settings.providers.page.connect.selectProviderPlaceholder": "Selecionar provedor", "settings.providers.page.connect.searchProvidersPlaceholder": "Buscar...", "settings.providers.page.connect.noProvidersFound": "Nenhum provedores", - "settings.providers.page.connect.allProvidersConnected": "Todos os provedores estão conectados.", + "settings.providers.page.custom.optionLabel": "Outro / Personalizado", + "settings.providers.page.custom.title": "Provedor personalizado", + "settings.providers.page.custom.editTitle": "Editar provedor personalizado", + + "settings.providers.page.custom.description": "Adicione um provedor compatível com OpenAI com URL base, credenciais e lista de modelos. Salvo na configuração do OpenCode para uso no chat como qualquer outro provedor.", + "settings.providers.page.custom.field.providerID.label": "ID do provedor", + "settings.providers.page.custom.field.providerID.placeholder": "meu-provedor", + "settings.providers.page.custom.field.providerID.info": "Letras minúsculas, números, hífens e sublinhados. Usado como ID de provedor do OpenCode.", + "settings.providers.page.custom.field.name.label": "Nome de exibição", + "settings.providers.page.custom.field.name.placeholder": "Meu provedor", + "settings.providers.page.custom.field.name.info": "Mostrado nos seletores de provedor e modelo.", + "settings.providers.page.custom.field.baseURL.label": "URL base", + "settings.providers.page.custom.field.baseURL.placeholder": "https://api.example.com/v1", + "settings.providers.page.custom.field.baseURL.info": "URL base da API compatível com OpenAI. Deve começar com http:// ou https://.", + "settings.providers.page.custom.field.apiKey.label": "Chave de API", + "settings.providers.page.custom.field.apiKey.placeholder": "sk-... ou {env:VAR_NAME}", + "settings.providers.page.custom.field.apiKey.info": "Armazenada na autenticação do OpenCode, não pelo OpenChamber. Use {env:VAR_NAME} para ler uma chave do ambiente.", + "settings.providers.page.custom.field.apiKey.editInfo": "Deixe em branco para manter a credencial existente, ou informe uma nova chave / {env:VAR_NAME}.", + "settings.providers.page.custom.field.apiKey.editPlaceholder": "Deixe em branco para manter a chave existente", + + + "settings.providers.page.custom.models.title": "Modelos", + "settings.providers.page.custom.models.idLabel": "ID do modelo", + "settings.providers.page.custom.models.idPlaceholder": "gpt-4o", + "settings.providers.page.custom.models.nameLabel": "Nome do modelo", + "settings.providers.page.custom.models.namePlaceholder": "GPT-4o", + "settings.providers.page.custom.models.add": "Adicionar modelo", + "settings.providers.page.custom.models.remove": "Remover modelo", + "settings.providers.page.custom.headers.title": "Cabeçalhos", + "settings.providers.page.custom.headers.description": "Cabeçalhos de solicitação opcionais enviados em cada chamada.", + "settings.providers.page.custom.headers.keyLabel": "Nome do cabeçalho", + "settings.providers.page.custom.headers.keyPlaceholder": "X-Custom-Header", + "settings.providers.page.custom.headers.valueLabel": "Valor do cabeçalho", + "settings.providers.page.custom.headers.valuePlaceholder": "valor", + "settings.providers.page.custom.headers.add": "Adicionar cabeçalho", + "settings.providers.page.custom.headers.remove": "Remover cabeçalho", + "settings.providers.page.custom.actions.back": "Voltar", + "settings.providers.page.custom.actions.save": "Salvar provedor", + "settings.providers.page.custom.actions.update": "Atualizar provedor", + + "settings.providers.page.custom.error.providerID.required": "O ID do provedor é obrigatório", + "settings.providers.page.custom.error.providerID.format": "Use letras minúsculas, números, hífens ou sublinhados", + "settings.providers.page.custom.error.providerID.exists": "Já existe um provedor conectado com este ID", + "settings.providers.page.custom.error.name.required": "O nome de exibição é obrigatório", + "settings.providers.page.custom.error.baseURL.required": "A URL base é obrigatória", + "settings.providers.page.custom.error.baseURL.format": "A URL base deve começar com http:// ou https://", + "settings.providers.page.custom.error.required": "Obrigatório", + "settings.providers.page.custom.error.duplicate": "Duplicado", + "settings.providers.page.custom.error.apiKey.required": "É necessária uma chave de API ou {env:VAR_NAME}", + "settings.providers.page.custom.authFailure.configAfterAuth": "As credenciais foram salvas, mas a configuração do provedor não. Corrija o erro e tente novamente, ou desconecte para limpar o salvamento parcial.", + + "settings.providers.page.auth.title": "Autenticação", "settings.providers.page.auth.loadingMethods": "Carregando métodos de autenticação...", "settings.providers.page.auth.apiKeyLabel": "Chave API", @@ -1322,6 +1372,10 @@ export const settingsDict = { "settings.providers.page.auth.oauthMethodFallback": "Método OAuth {index}", "settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Colar código de autorização", "settings.providers.page.auth.connected": "Conectado", + "settings.providers.page.auth.incomplete": "Credenciais ausentes", + "settings.providers.page.auth.incompleteHint": "· Adicione uma chave de API ou {env:VAR} antes de usar este provedor no chat", + + "settings.providers.page.auth.useReconnectHint": "· Usar Reconnect para atualizar credenciais", "settings.providers.page.connectionDetails.title": "Detalhes de conexão", "settings.providers.page.connectionDetails.configuredIn": "Configuredo en:", @@ -1351,6 +1405,8 @@ export const settingsDict = { "settings.providers.page.actions.complete": "Completar", "settings.providers.page.actions.hide": "Ocultar", "settings.providers.page.actions.reconnect": "Reconectar", + "settings.providers.page.actions.edit": "Editar", + "settings.providers.page.actions.disconnecting": "Desconectando...", "settings.providers.page.actions.disconnect": "Desconectar", "settings.providers.page.actions.hideAll": "Ocultar todo", @@ -1371,6 +1427,8 @@ export const settingsDict = { "settings.providers.page.toast.deviceCodeCopyFailed": "Não foi possível copiar o código de dispositivo", "settings.providers.page.toast.providerDisconnected": "Provedor desconectado", "settings.providers.page.toast.providerDisconnectFailed": "Não foi possível desconectar o provedor", + "settings.providers.page.toast.customProviderSaved": "{provider} conectado", + "settings.providers.page.toast.customProviderSaveFailed": "Falha ao salvar o provedor personalizado", "settings.mcp.page.empty.selectServer": "Selecione um servidor MCP de o painel lateral", "settings.mcp.page.empty.addNewOne": "o añade um novo", "settings.mcp.page.header.newServer": "Novo servidor MCP", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index cfbd1767..d56cce54 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -698,9 +698,8 @@ export const settingsDict = { "settings.skills.sidebar.toast.skillDeleted": "Навичку \"{name}\" успішно видалено", "settings.skills.sidebar.toast.deleteSkillFailed": "Не вдалося видалити навичку", "settings.skills.sidebar.toast.duplicateLoadFailed": "Не вдалося завантажити деталі навичок для дублювання", - "settings.skills.sidebar.toast.renameLoadFailed": "Не вдалося завантажити деталі навичок", - "settings.skills.sidebar.toast.removeOldAfterRenameFailed": "Не вдалося видалити стару навичку після перейменування", "settings.skills.sidebar.toast.renameFailed": "Не вдалося перейменувати навичку", + "settings.skills.sidebar.toast.skillRenamed": "Навичку перейменовано на \"{name}\"", "settings.skills.sidebar.deleteDialog.title": "Видалити навичку", "settings.skills.sidebar.deleteDialog.description": "Ви впевнені, що бажаєте видалити навичку «{name}»?", "settings.skills.sidebar.renameDialog.title": "Перейменувати навичку", @@ -1313,7 +1312,58 @@ export const settingsDict = { "settings.providers.page.connect.selectProviderPlaceholder": "Виберіть провайдера", "settings.providers.page.connect.searchProvidersPlaceholder": "Пошук...", "settings.providers.page.connect.noProvidersFound": "Немає провайдерів", - "settings.providers.page.connect.allProvidersConnected": "Усі провайдери підключені.", + "settings.providers.page.custom.optionLabel": "Інший / Власний", + "settings.providers.page.custom.title": "Власний провайдер", + "settings.providers.page.custom.editTitle": "Редагувати власного провайдера", + + "settings.providers.page.custom.description": "Додайте OpenAI-сумісного провайдера з базовою URL-адресою, обліковими даними та списком моделей. Зберігається в конфігурації OpenCode й працює в чаті як будь-який інший провайдер.", + "settings.providers.page.custom.field.providerID.label": "ID провайдера", + "settings.providers.page.custom.field.providerID.placeholder": "mij-provider", + "settings.providers.page.custom.field.providerID.info": "Малі літери, цифри, дефіси та підкреслення. Використовується як ID провайдера OpenCode.", + "settings.providers.page.custom.field.name.label": "Відображувана назва", + "settings.providers.page.custom.field.name.placeholder": "Мій провайдер", + "settings.providers.page.custom.field.name.info": "Показується у виборі провайдера та моделі.", + "settings.providers.page.custom.field.baseURL.label": "Базова URL-адреса", + "settings.providers.page.custom.field.baseURL.placeholder": "https://api.example.com/v1", + "settings.providers.page.custom.field.baseURL.info": "Базова URL-адреса OpenAI-сумісного API. Має починатися з http:// або https://.", + "settings.providers.page.custom.field.apiKey.label": "API-ключ", + "settings.providers.page.custom.field.apiKey.placeholder": "sk-... або {env:VAR_NAME}", + "settings.providers.page.custom.field.apiKey.info": "Зберігається в автентифікації OpenCode, не OpenChamber. Використовуйте {env:VAR_NAME}, щоб читати ключ зі змінної середовища.", + "settings.providers.page.custom.field.apiKey.editInfo": "Залиште порожнім, щоб зберегти наявні облікові дані, або введіть новий ключ / {env:VAR_NAME}.", + "settings.providers.page.custom.field.apiKey.editPlaceholder": "Залиште порожнім, щоб зберегти наявний ключ", + + + "settings.providers.page.custom.models.title": "Моделі", + "settings.providers.page.custom.models.idLabel": "ID моделі", + "settings.providers.page.custom.models.idPlaceholder": "gpt-4o", + "settings.providers.page.custom.models.nameLabel": "Назва моделі", + "settings.providers.page.custom.models.namePlaceholder": "GPT-4o", + "settings.providers.page.custom.models.add": "Додати модель", + "settings.providers.page.custom.models.remove": "Видалити модель", + "settings.providers.page.custom.headers.title": "Заголовки", + "settings.providers.page.custom.headers.description": "Необов’язкові заголовки запиту, що надсилаються з кожним викликом.", + "settings.providers.page.custom.headers.keyLabel": "Назва заголовка", + "settings.providers.page.custom.headers.keyPlaceholder": "X-Custom-Header", + "settings.providers.page.custom.headers.valueLabel": "Значення заголовка", + "settings.providers.page.custom.headers.valuePlaceholder": "значення", + "settings.providers.page.custom.headers.add": "Додати заголовок", + "settings.providers.page.custom.headers.remove": "Видалити заголовок", + "settings.providers.page.custom.actions.back": "Назад", + "settings.providers.page.custom.actions.save": "Зберегти провайдера", + "settings.providers.page.custom.actions.update": "Оновити провайдера", + + "settings.providers.page.custom.error.providerID.required": "ID провайдера обов’язковий", + "settings.providers.page.custom.error.providerID.format": "Використовуйте малі літери, цифри, дефіси або підкреслення", + "settings.providers.page.custom.error.providerID.exists": "Провайдер із цим ID уже підключено", + "settings.providers.page.custom.error.name.required": "Відображувана назва обов’язкова", + "settings.providers.page.custom.error.baseURL.required": "Базова URL-адреса обов’язкова", + "settings.providers.page.custom.error.baseURL.format": "Базова URL-адреса має починатися з http:// або https://", + "settings.providers.page.custom.error.required": "Обов’язково", + "settings.providers.page.custom.error.duplicate": "Дублікат", + "settings.providers.page.custom.error.apiKey.required": "Потрібен API-ключ або {env:VAR_NAME}", + "settings.providers.page.custom.authFailure.configAfterAuth": "Облікові дані збережено, але конфігурацію провайдера — ні. Виправте помилку й спробуйте знову або від’єднайте, щоб очистити часткове збереження.", + + "settings.providers.page.auth.title": "Аутентифікація", "settings.providers.page.auth.loadingMethods": "Завантаження методів автентифікації...", "settings.providers.page.auth.apiKeyLabel": "API ключ", @@ -1322,6 +1372,10 @@ export const settingsDict = { "settings.providers.page.auth.oauthMethodFallback": "OAuth метод {index}", "settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Вставити код авторизації", "settings.providers.page.auth.connected": "Підключено", + "settings.providers.page.auth.incomplete": "Облікові дані відсутні", + "settings.providers.page.auth.incompleteHint": "· Додайте API-ключ або {env:VAR} перед використанням цього провайдера в чаті", + + "settings.providers.page.auth.useReconnectHint": "· Скористайтеся повторним підключенням, щоб оновити облікові дані", "settings.providers.page.connectionDetails.title": "Деталі підключення", "settings.providers.page.connectionDetails.configuredIn": "Налаштовано в:", @@ -1351,6 +1405,8 @@ export const settingsDict = { "settings.providers.page.actions.complete": "Завершити", "settings.providers.page.actions.hide": "Сховати", "settings.providers.page.actions.reconnect": "Перепідключити", + "settings.providers.page.actions.edit": "Редагувати", + "settings.providers.page.actions.disconnecting": "Відключення...", "settings.providers.page.actions.disconnect": "Відключити", "settings.providers.page.actions.hideAll": "Сховати все", @@ -1371,6 +1427,8 @@ export const settingsDict = { "settings.providers.page.toast.deviceCodeCopyFailed": "Не вдалося скопіювати код пристрою", "settings.providers.page.toast.providerDisconnected": "Провайдера відключено", "settings.providers.page.toast.providerDisconnectFailed": "Не вдалося відключити провайдера", + "settings.providers.page.toast.customProviderSaved": "{provider} підключено", + "settings.providers.page.toast.customProviderSaveFailed": "Не вдалося зберегти власного провайдера", "settings.mcp.page.empty.selectServer": "Виберіть MCP сервер на бічній панелі", "settings.mcp.page.empty.addNewOne": "або додати новий", "settings.mcp.page.header.newServer": "Новий сервер MCP", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index cfa67fc2..3ac73b6f 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -698,9 +698,8 @@ export const settingsDict = { 'settings.skills.sidebar.toast.skillDeleted': '技能“{name}”已删除', 'settings.skills.sidebar.toast.deleteSkillFailed': '删除技能失败', 'settings.skills.sidebar.toast.duplicateLoadFailed': '加载技能详情以复制失败', - 'settings.skills.sidebar.toast.renameLoadFailed': '加载技能详情失败', - 'settings.skills.sidebar.toast.removeOldAfterRenameFailed': '重命名后移除旧技能失败', 'settings.skills.sidebar.toast.renameFailed': '重命名技能失败', + 'settings.skills.sidebar.toast.skillRenamed': '技能已重命名为“{name}”', 'settings.skills.sidebar.deleteDialog.title': '删除技能', 'settings.skills.sidebar.deleteDialog.description': '确定要删除技能“{name}”吗?', 'settings.skills.sidebar.renameDialog.title': '重命名技能', @@ -1313,7 +1312,58 @@ export const settingsDict = { 'settings.providers.page.connect.selectProviderPlaceholder': '选择提供商', 'settings.providers.page.connect.searchProvidersPlaceholder': '搜索...', 'settings.providers.page.connect.noProvidersFound': '未找到提供商', - 'settings.providers.page.connect.allProvidersConnected': '所有提供商均已连接。', + 'settings.providers.page.custom.optionLabel': '其他 / 自定义', + 'settings.providers.page.custom.title': '自定义提供商', + 'settings.providers.page.custom.editTitle': '编辑自定义提供商', + + 'settings.providers.page.custom.description': '通过指定基础 URL、凭据和模型列表,添加兼容 OpenAI 的提供商。会写入 OpenCode 配置,可像其他提供商一样在聊天中使用。', + 'settings.providers.page.custom.field.providerID.label': '提供商 ID', + 'settings.providers.page.custom.field.providerID.placeholder': 'my-provider', + 'settings.providers.page.custom.field.providerID.info': '小写字母、数字、连字符和下划线。用作 OpenCode 提供商 ID。', + 'settings.providers.page.custom.field.name.label': '显示名称', + 'settings.providers.page.custom.field.name.placeholder': '我的提供商', + 'settings.providers.page.custom.field.name.info': '显示在提供商和模型选择器中。', + 'settings.providers.page.custom.field.baseURL.label': '基础 URL', + 'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1', + 'settings.providers.page.custom.field.baseURL.info': '兼容 OpenAI 的 API 基础 URL。必须以 http:// 或 https:// 开头。', + 'settings.providers.page.custom.field.apiKey.label': 'API 密钥', + 'settings.providers.page.custom.field.apiKey.placeholder': 'sk-... 或 {env:VAR_NAME}', + 'settings.providers.page.custom.field.apiKey.info': '保存在 OpenCode 认证中,而非 OpenChamber。使用 {env:VAR_NAME} 可从环境变量读取密钥。', + 'settings.providers.page.custom.field.apiKey.editInfo': '留空以保留现有凭据,或输入新密钥 / {env:VAR_NAME}。', + 'settings.providers.page.custom.field.apiKey.editPlaceholder': '留空以保留现有密钥', + + + 'settings.providers.page.custom.models.title': '模型', + 'settings.providers.page.custom.models.idLabel': '模型 ID', + 'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o', + 'settings.providers.page.custom.models.nameLabel': '模型名称', + 'settings.providers.page.custom.models.namePlaceholder': 'GPT-4o', + 'settings.providers.page.custom.models.add': '添加模型', + 'settings.providers.page.custom.models.remove': '移除模型', + 'settings.providers.page.custom.headers.title': '请求头', + 'settings.providers.page.custom.headers.description': '每次调用可选发送的请求头。', + 'settings.providers.page.custom.headers.keyLabel': '请求头名称', + 'settings.providers.page.custom.headers.keyPlaceholder': 'X-Custom-Header', + 'settings.providers.page.custom.headers.valueLabel': '请求头值', + 'settings.providers.page.custom.headers.valuePlaceholder': 'value', + 'settings.providers.page.custom.headers.add': '添加请求头', + 'settings.providers.page.custom.headers.remove': '移除请求头', + 'settings.providers.page.custom.actions.back': '返回', + 'settings.providers.page.custom.actions.save': '保存提供商', + 'settings.providers.page.custom.actions.update': '更新提供商', + + 'settings.providers.page.custom.error.providerID.required': '提供商 ID 为必填项', + 'settings.providers.page.custom.error.providerID.format': '请使用小写字母、数字、连字符或下划线', + 'settings.providers.page.custom.error.providerID.exists': '已连接具有此 ID 的提供商', + 'settings.providers.page.custom.error.name.required': '显示名称为必填项', + 'settings.providers.page.custom.error.baseURL.required': '基础 URL 为必填项', + 'settings.providers.page.custom.error.baseURL.format': '基础 URL 必须以 http:// 或 https:// 开头', + 'settings.providers.page.custom.error.required': '必填', + 'settings.providers.page.custom.error.duplicate': '重复', + 'settings.providers.page.custom.error.apiKey.required': '需要 API 密钥或 {env:VAR_NAME}', + 'settings.providers.page.custom.authFailure.configAfterAuth': '凭据已保存,但提供商配置未保存。请修复错误后重试,或断开连接以清除部分保存。', + + 'settings.providers.page.auth.title': '认证', 'settings.providers.page.auth.loadingMethods': '正在加载认证方式...', 'settings.providers.page.auth.apiKeyLabel': 'API Key', @@ -1322,6 +1372,10 @@ export const settingsDict = { 'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方式 {index}', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '粘贴授权码', 'settings.providers.page.auth.connected': '已连接', + 'settings.providers.page.auth.incomplete': '缺少凭据', + 'settings.providers.page.auth.incompleteHint': '· 在聊天中使用此提供商之前,请添加 API 密钥或 {env:VAR}', + + 'settings.providers.page.auth.useReconnectHint': '· 使用“重新连接”以更新凭据', 'settings.providers.page.connectionDetails.title': '连接详情', 'settings.providers.page.connectionDetails.configuredIn': '配置来源:', @@ -1351,6 +1405,8 @@ export const settingsDict = { 'settings.providers.page.actions.complete': '完成', 'settings.providers.page.actions.hide': '隐藏', 'settings.providers.page.actions.reconnect': '重新连接', + 'settings.providers.page.actions.edit': '编辑', + 'settings.providers.page.actions.disconnecting': '断开连接中...', 'settings.providers.page.actions.disconnect': '断开连接', 'settings.providers.page.actions.hideAll': '全部隐藏', @@ -1371,6 +1427,8 @@ export const settingsDict = { 'settings.providers.page.toast.deviceCodeCopyFailed': '复制设备代码失败', 'settings.providers.page.toast.providerDisconnected': '提供商已断开连接', 'settings.providers.page.toast.providerDisconnectFailed': '断开提供商连接失败', + 'settings.providers.page.toast.customProviderSaved': '已连接 {provider}', + 'settings.providers.page.toast.customProviderSaveFailed': '保存自定义提供商失败', 'settings.mcp.page.empty.selectServer': '请从侧边栏选择一个 MCP 服务器', 'settings.mcp.page.empty.addNewOne': '或添加一个新的', 'settings.mcp.page.header.newServer': '新建 MCP 服务器', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 58b4e7ae..a4302cfa 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -695,9 +695,8 @@ 'settings.skills.sidebar.toast.skillDeleted': 'skill「{name}」已刪除', 'settings.skills.sidebar.toast.deleteSkillFailed': '刪除 skill 失敗', 'settings.skills.sidebar.toast.duplicateLoadFailed': '複製 skill 的詳細資訊載入失敗', - 'settings.skills.sidebar.toast.renameLoadFailed': '載入 skill 詳情失敗', - 'settings.skills.sidebar.toast.removeOldAfterRenameFailed': '重新命名後移除舊 skill 失敗', 'settings.skills.sidebar.toast.renameFailed': '重新命名 skill 失敗', + 'settings.skills.sidebar.toast.skillRenamed': 'skill 已重新命名為「{name}」', 'settings.skills.sidebar.deleteDialog.title': '刪除 Skill', 'settings.skills.sidebar.deleteDialog.description': '確定要刪除 skill「{name}」嗎?', 'settings.skills.sidebar.renameDialog.title': '重新命名 Skill', @@ -1219,7 +1218,58 @@ 'settings.providers.page.connect.selectProviderPlaceholder': '選擇供應商', 'settings.providers.page.connect.searchProvidersPlaceholder': '搜尋...', 'settings.providers.page.connect.noProvidersFound': '找不到供應商', - 'settings.providers.page.connect.allProvidersConnected': '所有供應商均已連線。', + 'settings.providers.page.custom.optionLabel': '其他 / 自訂', + 'settings.providers.page.custom.title': '自訂供應商', + 'settings.providers.page.custom.editTitle': '編輯自訂提供者', + + 'settings.providers.page.custom.description': '透過指定基礎 URL、憑證與模型清單,新增相容 OpenAI 的供應商。會寫入 OpenCode 設定,可像其他供應商一樣在聊天中使用。', + 'settings.providers.page.custom.field.providerID.label': '供應商 ID', + 'settings.providers.page.custom.field.providerID.placeholder': 'my-provider', + 'settings.providers.page.custom.field.providerID.info': '小寫字母、數字、連字號與底線。用作 OpenCode 供應商 ID。', + 'settings.providers.page.custom.field.name.label': '顯示名稱', + 'settings.providers.page.custom.field.name.placeholder': '我的供應商', + 'settings.providers.page.custom.field.name.info': '顯示於供應商與模型選擇器。', + 'settings.providers.page.custom.field.baseURL.label': '基礎 URL', + 'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1', + 'settings.providers.page.custom.field.baseURL.info': '相容 OpenAI 的 API 基礎 URL。必須以 http:// 或 https:// 開頭。', + 'settings.providers.page.custom.field.apiKey.label': 'API 金鑰', + 'settings.providers.page.custom.field.apiKey.placeholder': 'sk-... 或 {env:VAR_NAME}', + 'settings.providers.page.custom.field.apiKey.info': '儲存在 OpenCode 驗證中,而非 OpenChamber。使用 {env:VAR_NAME} 可從環境變數讀取金鑰。', + 'settings.providers.page.custom.field.apiKey.editInfo': '留空以保留現有憑證,或輸入新金鑰 / {env:VAR_NAME}。', + 'settings.providers.page.custom.field.apiKey.editPlaceholder': '留空以保留現有金鑰', + + + 'settings.providers.page.custom.models.title': '模型', + 'settings.providers.page.custom.models.idLabel': '模型 ID', + 'settings.providers.page.custom.models.idPlaceholder': 'gpt-4o', + 'settings.providers.page.custom.models.nameLabel': '模型名稱', + 'settings.providers.page.custom.models.namePlaceholder': 'GPT-4o', + 'settings.providers.page.custom.models.add': '新增模型', + 'settings.providers.page.custom.models.remove': '移除模型', + 'settings.providers.page.custom.headers.title': '標頭', + 'settings.providers.page.custom.headers.description': '每次呼叫可選擇傳送的請求標頭。', + 'settings.providers.page.custom.headers.keyLabel': '標頭名稱', + 'settings.providers.page.custom.headers.keyPlaceholder': 'X-Custom-Header', + 'settings.providers.page.custom.headers.valueLabel': '標頭值', + 'settings.providers.page.custom.headers.valuePlaceholder': 'value', + 'settings.providers.page.custom.headers.add': '新增標頭', + 'settings.providers.page.custom.headers.remove': '移除標頭', + 'settings.providers.page.custom.actions.back': '返回', + 'settings.providers.page.custom.actions.save': '儲存供應商', + 'settings.providers.page.custom.actions.update': '更新提供者', + + 'settings.providers.page.custom.error.providerID.required': '供應商 ID 為必填', + 'settings.providers.page.custom.error.providerID.format': '請使用小寫字母、數字、連字號或底線', + 'settings.providers.page.custom.error.providerID.exists': '已連線具有此 ID 的供應商', + 'settings.providers.page.custom.error.name.required': '顯示名稱為必填', + 'settings.providers.page.custom.error.baseURL.required': '基礎 URL 為必填', + 'settings.providers.page.custom.error.baseURL.format': '基礎 URL 必須以 http:// 或 https:// 開頭', + 'settings.providers.page.custom.error.required': '必填', + 'settings.providers.page.custom.error.duplicate': '重複', + 'settings.providers.page.custom.error.apiKey.required': '需要 API 金鑰或 {env:VAR_NAME}', + 'settings.providers.page.custom.authFailure.configAfterAuth': '憑證已儲存,但提供者設定未儲存。請修正錯誤後再試,或中斷連線以清除部分儲存。', + + 'settings.providers.page.auth.title': '驗證', 'settings.providers.page.auth.loadingMethods': '正在載入驗證方式...', 'settings.providers.page.auth.apiKeyLabel': 'API Key', @@ -1228,6 +1278,10 @@ 'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方式 {index}', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '貼上授權碼', 'settings.providers.page.auth.connected': '已連線', + 'settings.providers.page.auth.incomplete': '缺少憑證', + 'settings.providers.page.auth.incompleteHint': '· 在聊天中使用此提供者之前,請新增 API 金鑰或 {env:VAR}', + + 'settings.providers.page.auth.useReconnectHint': '· 使用「重新連線」以更新憑證', 'settings.providers.page.connectionDetails.title': '連線詳情', 'settings.providers.page.connectionDetails.configuredIn': '設定來源:', @@ -1257,6 +1311,8 @@ 'settings.providers.page.actions.complete': '完成', 'settings.providers.page.actions.hide': '隱藏', 'settings.providers.page.actions.reconnect': '重新連線', + 'settings.providers.page.actions.edit': '編輯', + 'settings.providers.page.actions.disconnecting': '中斷連線中...', 'settings.providers.page.actions.disconnect': '中斷連線', 'settings.providers.page.actions.hideAll': '全部隱藏', @@ -1277,6 +1333,8 @@ 'settings.providers.page.toast.deviceCodeCopyFailed': '複製裝置程式碼失敗', 'settings.providers.page.toast.providerDisconnected': '供應商已中斷連線', 'settings.providers.page.toast.providerDisconnectFailed': '中斷供應商連線失敗', + 'settings.providers.page.toast.customProviderSaved': '已連線 {provider}', + 'settings.providers.page.toast.customProviderSaveFailed': '無法儲存自訂供應商', 'settings.mcp.page.empty.selectServer': '請從側邊欄選擇一個 MCP 伺服器', 'settings.mcp.page.empty.addNewOne': '或新增一個新的', 'settings.mcp.page.header.newServer': '新建 MCP 伺服器', diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 50288fc7..eecca22c 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -761,6 +761,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ titleKey: 'settings.providers.page.connect.title', keywords: ['add provider', 'connect provider', 'credentials'], }, + { + id: 'providers.custom', + page: 'providers', + titleKey: 'settings.providers.page.custom.title', + descriptionKey: 'settings.providers.page.custom.description', + keywords: ['other', 'custom', 'openai-compatible', 'base url', 'api key'], + }, { id: 'providers.auth', page: 'providers', diff --git a/packages/ui/src/lib/terminalApi.test.ts b/packages/ui/src/lib/terminalApi.test.ts index b02aa0ac..7573984a 100644 --- a/packages/ui/src/lib/terminalApi.test.ts +++ b/packages/ui/src/lib/terminalApi.test.ts @@ -93,6 +93,157 @@ describe('terminal transport', () => { transport.dispose(); }); + test('invalidates URL auth when the current socket closes before opening', async () => { + const socket = new FakeSocket(); + let cleared = 0; + const transport = new TerminalTransport({ + refreshAuth: async () => '', + openSocket: () => socket, + clearUrlAuthToken: () => { cleared += 1; }, + }); + + const unsubscribe = transport.subscribe('term-1', { onEvent: () => {} }); + await tick(); + socket.close(); + await tick(); + + expect(cleared).toBe(1); + unsubscribe(); + transport.dispose(); + }); + + test('invalidates URL auth before retrying a pre-open socket error', async () => { + const socket = new FakeSocket(); + let cleared = 0; + const transport = new TerminalTransport({ + refreshAuth: async () => '', + openSocket: () => socket, + clearUrlAuthToken: () => { cleared += 1; }, + }); + + const unsubscribe = transport.subscribe('term-1', { onEvent: () => {} }); + await tick(); + socket.onerror?.(); + + expect(cleared).toBe(1); + unsubscribe(); + transport.dispose(); + }); + + test('does not let a cancelled opening reconnect a replacement subscription', async () => { + const sockets = [new FakeSocket(), new FakeSocket()]; + let socketIndex = 0; + const replacementEvents: string[] = []; + const transport = new TerminalTransport({ + refreshAuth: async () => '', + openSocket: () => sockets[socketIndex++]!, + }); + + const unsubscribeFirst = transport.subscribe('term-1', { onEvent: () => {} }); + await tick(); + unsubscribeFirst(); + + const unsubscribeReplacement = transport.subscribe('term-1', { + onEvent: (event) => replacementEvents.push(event.type), + }); + await tick(); + sockets[1]?.open(); + await tick(); + + expect(replacementEvents).not.toContain('reconnecting'); + unsubscribeReplacement(); + transport.dispose(); + }); + + test('starts a fresh reconnect sequence after every terminal has detached', async () => { + const firstEvents: number[] = []; + const replacementEvents: number[] = []; + const transport = new TerminalTransport({ + refreshAuth: async () => '', + openSocket: () => { throw new Error('offline'); }, + }); + + const unsubscribeFirst = transport.subscribe('term-1', { + onEvent: (event) => { + if (event.type === 'reconnecting' && typeof event.attempt === 'number') firstEvents.push(event.attempt); + }, + }); + await tick(); + await tick(); + expect(firstEvents).toEqual([1]); + + unsubscribeFirst(); + const unsubscribeReplacement = transport.subscribe('term-2', { + onEvent: (event) => { + if (event.type === 'reconnecting' && typeof event.attempt === 'number') replacementEvents.push(event.attempt); + }, + }); + await tick(); + await tick(); + + expect(replacementEvents).toEqual([1]); + unsubscribeReplacement(); + transport.dispose(); + }); + + test('waits a minute before reconnecting while hidden', async () => { + const originalSetTimeout = globalThis.setTimeout; + const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); + const delays: number[] = []; + let transport: TerminalTransport | null = null; + + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + visibilityState: 'hidden', + addEventListener: () => {}, + removeEventListener: () => {}, + }, + }); + globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => { + delays.push(Number(timeout ?? 0)); + if (timeout === 0) return originalSetTimeout(handler, 0, ...args); + return 0 as unknown as ReturnType; + }) as typeof setTimeout; + + try { + transport = new TerminalTransport({ + refreshAuth: async () => '', + openSocket: () => { throw new Error('offline'); }, + }); + transport.subscribe('term-1', { onEvent: () => {} }); + await tick(); + await tick(); + + expect(delays).toContain(60_000); + } finally { + transport?.dispose(); + globalThis.setTimeout = originalSetTimeout; + if (originalDocument) Object.defineProperty(globalThis, 'document', originalDocument); + else delete (globalThis as { document?: unknown }).document; + } + }); + + test('attaches a remaining same-terminal subscriber after the first one leaves', async () => { + const socket = new FakeSocket(); + const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket }); + + const unsubscribeOther = transport.subscribe('term-other', { onEvent: () => {} }); + await tick(); + socket.open(); + await tick(); + + const unsubscribeFirst = transport.subscribe('term-1', { onEvent: () => {} }); + const unsubscribeRemaining = transport.subscribe('term-1', { onEvent: () => {} }); + unsubscribeFirst(); + await tick(); + + expect(socket.sent.filter((message) => message.t === 'attach' && message.s === 'term-1')).toHaveLength(1); + unsubscribeRemaining(); + unsubscribeOther(); + transport.dispose(); + }); + test('releases replay projections when the last subscriber detaches', async () => { const socket = new FakeSocket(); const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket }); diff --git a/packages/ui/src/lib/terminalApi.ts b/packages/ui/src/lib/terminalApi.ts index 3e555bf7..4bfce9d5 100644 --- a/packages/ui/src/lib/terminalApi.ts +++ b/packages/ui/src/lib/terminalApi.ts @@ -3,7 +3,7 @@ import { openRuntimeWebSocket } from './relay/runtime-socket'; import type { RelayTunnelWebSocket } from './relay/tunnel-client'; import { runtimeFetch } from './runtime-fetch'; import { getRuntimeUrlResolver } from './runtime-url'; -import { refreshRuntimeUrlAuthToken } from './runtime-auth'; +import { clearRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken } from './runtime-auth'; import { isTerminalShell } from './terminalShell'; type Message = Record & { t: string; s?: string; q?: number }; @@ -66,12 +66,12 @@ const trimProjection = (value: string): string => { type TerminalTransportDependencies = { refreshAuth: () => Promise; openSocket: () => RelayTunnelWebSocket; + clearUrlAuthToken?: () => void; }; export class TerminalTransport { private socket: RelayTunnelWebSocket | null = null; private opening: Promise | null = null; - private openingGeneration: number | null = null; private subscribers = new Map>(); private projections = new Map(); private reconnectTimer: ReturnType | null = null; @@ -85,6 +85,7 @@ export class TerminalTransport { constructor(private readonly dependencies: TerminalTransportDependencies = { refreshAuth: refreshRuntimeUrlAuthToken, openSocket: () => openRuntimeWebSocket(getRuntimeUrlResolver().websocket('/api/terminal/ws')), + clearUrlAuthToken: clearRuntimeUrlAuthToken, }) {} subscribe(sessionId: string, handlers: TerminalHandlers): () => void { @@ -100,7 +101,13 @@ export class TerminalTransport { handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, status: projection.status, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend }); } const socketWasOpen = this.socket?.readyState === SOCKET_OPEN; - this.ensureConnected().then(() => { if (first && socketWasOpen && set.has(subscriber)) this.send({ t: 'attach', v: 3, s: sessionId }); }).catch((error) => { + this.ensureConnected().then(() => { + const current = this.subscribers.get(sessionId); + if (first && socketWasOpen && current === set && current.size > 0) { + this.send({ t: 'attach', v: 3, s: sessionId }); + } + }).catch((error) => { + if (!set.has(subscriber)) return; handlers.onError?.(error, false); this.scheduleReconnect(); }); @@ -114,6 +121,7 @@ export class TerminalTransport { } if (this.subscribers.size === 0) { this.cancelReconnect(); + this.failures = 0; if (this.socket?.readyState === SOCKET_OPEN) { // Healthy socket: hold it briefly so a tab switch can reattach to it. this.scheduleIdleClose(); @@ -121,6 +129,7 @@ export class TerminalTransport { } // Nothing to reuse, so abandon any dial that is still in flight. this.generation += 1; + this.opening = null; this.closeSocket(); } }; @@ -138,6 +147,7 @@ export class TerminalTransport { dispose(): void { this.disposed = true; this.generation += 1; + this.opening = null; this.subscribers.clear(); this.projections.clear(); if (this.reconnectTimer) clearTimeout(this.reconnectTimer); @@ -155,71 +165,89 @@ export class TerminalTransport { private async ensureConnected(): Promise { if (this.disposed) throw new Error('Terminal runtime changed'); if (this.socket?.readyState === SOCKET_OPEN) return; - if (this.opening && this.openingGeneration === this.generation) { + if (this.opening) { await this.opening; if (this.socket?.readyState === SOCKET_OPEN) return; return this.ensureConnected(); } - if (this.openingGeneration !== this.generation) { - this.opening = null; - this.openingGeneration = null; - } const generation = this.generation; const opening = (async () => { await this.dependencies.refreshAuth(); if (generation !== this.generation || this.disposed) throw new Error('Terminal runtime changed'); await new Promise((resolve, reject) => { - let settled = false; - let pendingSocket: RelayTunnelWebSocket | null = null; - const finish = (error?: Error) => { - if (settled) return; - settled = true; - clearTimeout(timeout); - if (error) reject(error); - else resolve(); - }; - const timeout = setTimeout(() => { - pendingSocket?.close(); - finish(new Error('Terminal connection timed out')); - }, 10_000); - try { - const socket = this.dependencies.openSocket(); - pendingSocket = socket; - socket.binaryType = 'arraybuffer'; - this.socket = socket; - socket.onopen = () => { - if (generation !== this.generation || this.disposed) { socket.close(); finish(new Error('Terminal runtime changed')); return; } - this.failures = 0; - this.send({ t: 'hello', v: 3 }); - for (const sessionId of this.subscribers.keys()) this.send({ t: 'attach', v: 3, s: sessionId }); - this.startKeepalive(); - finish(); + let settled = false; + let opened = false; + let authInvalidated = false; + let pendingSocket: RelayTunnelWebSocket | null = null; + const isCurrentSocket = () => ( + generation === this.generation && + !this.disposed && + pendingSocket !== null && + this.socket === pendingSocket + ); + const invalidatePreOpenAuth = () => { + if (authInvalidated || opened || !isCurrentSocket()) return; + authInvalidated = true; + this.dependencies.clearUrlAuthToken?.(); }; - socket.onmessage = (event) => void this.handleMessage(event.data); - socket.onerror = () => { - finish(new Error('Terminal WebSocket failed')); + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (error) reject(error); + else resolve(); + }; + const timeout = setTimeout(() => { + invalidatePreOpenAuth(); + pendingSocket?.close(); + finish(new Error('Terminal connection timed out')); + }, 10_000); + try { + const socket = this.dependencies.openSocket(); + pendingSocket = socket; + socket.binaryType = 'arraybuffer'; + this.socket = socket; + socket.onopen = () => { + if (!isCurrentSocket()) { socket.close(); finish(new Error('Terminal runtime changed')); return; } + opened = true; + this.failures = 0; + this.send({ t: 'hello', v: 3 }); + for (const sessionId of this.subscribers.keys()) this.send({ t: 'attach', v: 3, s: sessionId }); + this.startKeepalive(); + finish(); + }; + socket.onmessage = (event) => void this.handleMessage(event.data); + socket.onerror = () => { + const current = isCurrentSocket(); + if (current) invalidatePreOpenAuth(); + finish(new Error('Terminal WebSocket failed')); + if (current && this.subscribers.size > 0) this.scheduleReconnect(); + }; + socket.onclose = () => { + const current = isCurrentSocket(); + if (current) { + this.stopKeepalive(); + // An upgrade rejected before `open` commonly means the cached + // URL-scoped auth token is stale. Retrying it reaches the 8s + // backoff cap instead of minting a fresh token. + invalidatePreOpenAuth(); + } + if (this.socket === socket) this.socket = null; + finish(new Error('Terminal WebSocket closed')); + if (current && this.subscribers.size > 0) this.scheduleReconnect(); + }; + } catch (error) { + finish(error instanceof Error ? error : new Error('Terminal WebSocket failed')); if (!this.disposed && this.subscribers.size > 0) this.scheduleReconnect(); - }; - socket.onclose = () => { - if (this.socket === socket) this.socket = null; - this.stopKeepalive(); - finish(new Error('Terminal WebSocket closed')); - if (!this.disposed && this.subscribers.size > 0) this.scheduleReconnect(); - }; - } catch (error) { - finish(error instanceof Error ? error : new Error('Terminal WebSocket failed')); - if (!this.disposed && this.subscribers.size > 0) this.scheduleReconnect(); - } + } }); })(); this.opening = opening; - this.openingGeneration = generation; try { await opening; } finally { if (this.opening === opening) { this.opening = null; - this.openingGeneration = null; } } } @@ -279,7 +307,7 @@ export class TerminalTransport { if (this.reconnectTimer || this.disposed || this.subscribers.size === 0) return; this.failures += 1; const slow = (typeof document !== 'undefined' && document.visibilityState === 'hidden') || (typeof navigator !== 'undefined' && !navigator.onLine); - const delay = Math.min(500 * 2 ** Math.min(this.failures - 1, 10), slow ? 60_000 : 8_000); + const delay = slow ? 60_000 : Math.min(500 * 2 ** Math.min(this.failures - 1, 10), 8_000); for (const set of this.subscribers.values()) for (const sub of set) sub.handlers.onEvent({ type: 'reconnecting', attempt: this.failures, maxAttempts: Number.POSITIVE_INFINITY }); const wake = () => { if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return; @@ -304,6 +332,7 @@ export class TerminalTransport { this.idleCloseTimer = null; if (this.disposed || this.subscribers.size > 0) return; this.generation += 1; + this.opening = null; this.closeSocket(); }, IDLE_SOCKET_GRACE_MS); } diff --git a/packages/ui/src/stores/useMultiRunStore.test.ts b/packages/ui/src/stores/useMultiRunStore.test.ts index b2a1bbb6..c01295f1 100644 --- a/packages/ui/src/stores/useMultiRunStore.test.ts +++ b/packages/ui/src/stores/useMultiRunStore.test.ts @@ -127,6 +127,7 @@ mock.module('./useGlobalSessionsStore', () => ({ })); mock.module('@/sync/sync-refs', () => ({ + getSyncSessionDirectory: () => null, registerSessionDirectory: (sessionID: string, directory: string) => { registeredDirectories.push({ sessionID, directory }); }, diff --git a/packages/ui/src/stores/useSkillsStore.test.ts b/packages/ui/src/stores/useSkillsStore.test.ts index f75fb711..3c569d98 100644 --- a/packages/ui/src/stores/useSkillsStore.test.ts +++ b/packages/ui/src/stores/useSkillsStore.test.ts @@ -98,9 +98,94 @@ describe('useSkillsStore directory resolution', () => { source: 'agents', description: 'Repository local', group: undefined, + renamable: false, }]); }); + test('loadSkills maps authoritative renamable from the list response', async () => { + runtimeFetchImpl = async () => new Response(JSON.stringify({ + skills: [ + { + name: 'managed-skill', + path: `${activeProjectPath}/.opencode/skills/managed-skill/SKILL.md`, + scope: 'project', + source: 'opencode', + renamable: true, + sources: { md: { description: 'Managed' } }, + }, + { + name: 'cache-skill', + path: '/home/ubuntu/.cache/opencode/skills/hash/cache-skill/SKILL.md', + scope: 'user', + source: 'opencode', + renamable: false, + sources: { md: { description: 'Cache' } }, + }, + ], + }), { + headers: { 'Content-Type': 'application/json' }, + }); + + expect(await useSkillsStore.getState().loadSkills()).toBe(true); + expect(useSkillsStore.getState().skills).toEqual([ + { + name: 'managed-skill', + path: `${activeProjectPath}/.opencode/skills/managed-skill/SKILL.md`, + scope: 'project', + source: 'opencode', + description: 'Managed', + group: undefined, + renamable: true, + }, + { + name: 'cache-skill', + path: '/home/ubuntu/.cache/opencode/skills/hash/cache-skill/SKILL.md', + scope: 'user', + source: 'opencode', + description: 'Cache', + group: 'hash', + renamable: false, + }, + ]); + }); + + test('renameSkill uses getRequestDirectory query and x-opencode-directory header', async () => { + runtimeFetchImpl = async (_url, init) => { + if (init?.method === 'PATCH') { + return new Response(JSON.stringify({ + success: true, + requiresReload: false, + }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response(JSON.stringify({ + skills: [{ + name: 'new-skill', + path: `${activeProjectPath}/.opencode/skills/new-skill/SKILL.md`, + scope: 'project', + source: 'opencode', + renamable: true, + sources: { md: { description: 'Renamed' } }, + }], + }), { + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const renamed = await useSkillsStore.getState().renameSkill('old-skill', 'new-skill'); + expect(renamed).toBe(true); + + const renameCall = runtimeFetchCalls.find((call) => String(call.url).includes('/api/config/skills/old-skill')); + expect(renameCall).toBeTruthy(); + expect(renameCall?.url).toContain(`directory=${encodeURIComponent(activeProjectPath)}`); + + const headers = new Headers(renameCall?.headers); + expect(headers.get('content-type')).toBe('application/json'); + expect(headers.get('x-opencode-directory')).toBe(activeProjectPath); + }); + test('invalidateSkillsLoadCache() with no argument clears the active-project cache key used by loadSkills', async () => { expect(await useSkillsStore.getState().loadSkills()).toBe(true); expect(runtimeFetchCalls.length).toBe(1); diff --git a/packages/ui/src/stores/useSkillsStore.ts b/packages/ui/src/stores/useSkillsStore.ts index 29815499..dc1b3563 100644 --- a/packages/ui/src/stores/useSkillsStore.ts +++ b/packages/ui/src/stores/useSkillsStore.ts @@ -77,6 +77,8 @@ export interface DiscoveredSkill { description?: string; /** Domain folder parsed from file path, e.g. "automation-ai", "lark-ecosystem" */ group?: string; + /** Authoritative server flag: skill lives under a managed root and can be renamed in place. */ + renamable?: boolean; } /** Parse the domain group folder from a skill file path. @@ -100,6 +102,7 @@ interface RawSkillResponse { path: string; scope?: SkillScope; source?: SkillSource; + renamable?: boolean; sources?: { md?: { description?: string; @@ -150,6 +153,7 @@ interface SkillsStore { getSkillDetail: (name: string) => Promise; createSkill: (config: SkillConfig) => Promise; updateSkill: (name: string, config: Partial) => Promise; + renameSkill: (name: string, newName: string) => Promise; deleteSkill: (name: string) => Promise; getSkillByName: (name: string) => DiscoveredSkill | undefined; @@ -284,6 +288,7 @@ export const useSkillsStore = create()( source: s.source ?? 'opencode', description: s.sources?.md?.description || '', group: parseSkillGroup(s.path), + renamable: s.renamable === true, })); set({ skills: configSkills, isLoading: false }); @@ -448,6 +453,53 @@ export const useSkillsStore = create()( } }, + renameSkill: async (name: string, newName: string) => { + startConfigUpdate("Renaming skill..."); + let requiresReload = false; + try { + const directory = getRequestDirectory(); + const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; + + const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + ...(directory ? { 'x-opencode-directory': directory } : {}), + }, + body: JSON.stringify({ renameTo: newName }), + }); + + const payload = await response.json().catch(() => null); + if (!response.ok) { + const message = payload?.error || 'Failed to rename skill'; + throw new Error(message); + } + + const needsReload = payload?.requiresReload ?? false; + invalidateSkillsLoadCache(directory); + if (needsReload) { + requiresReload = true; + await refreshSkillsAfterOpenCodeRestart({ + message: payload?.message, + delayMs: payload?.reloadDelayMs, + }); + return true; + } + + const loaded = await get().loadSkills(); + if (loaded) { + emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE }); + } + return loaded; + } catch { + return false; + } finally { + if (!requiresReload) { + finishConfigUpdate(); + } + } + }, + deleteSkill: async (name: string) => { try { const directory = getRequestDirectory(); diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index 93a475ff..78152f32 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -197,6 +197,30 @@ Directory stores also own session-keyed sidecar notification channels for permis Message sidecar consumers also filter targeted updates by purpose before notifying React. Suspended live-tail text/reasoning changes do not rebuild visible message records, but structural Task session identity changes bypass suspension so a parent can link a newly created subagent immediately. Assistant-only part changes do not rebuild user input history, and targeted updates that preserve authoritative part buckets do not recheck a session that is already renderable. Message replacements, removed final part buckets, and conservative resets always notify. +## Session directory resolution + +`session-directory-resolution.ts` owns the precedence used to answer "which directory does this session belong to". Every send, message fetch, message-queue key, and send-confirmation lookup is routed by that answer, so a wrong value is not a display problem: the prompt is posted against a directory that does not own the session, the request is rejected, and the optimistic message is rolled back with no visible error. + +Precedence, highest authority first: + +The discriminator is whether the server confirmed the path, not whether the value is local or synced. + +| Source | Meaning | +|---|---| +| `authoritative` | The child store that actually holds the session, then its own record | +| `selected` | Server-confirmed directory captured at selection; a guessed one is never passed | +| `attachment` | Worktree attachment recorded by this client; the *requested* path | +| `worktree-metadata` | Worktree captured when the session was created in one; the *requested* path | +| `remembered` | Per-runtime directory persisted across restarts | + +Rules: + +1. `getSyncSessionDirectory()` is the authoritative session→directory mapping: a session lives in exactly the child store for its directory, whether or not the server populated `session.directory`. `null` means "not indexed yet", never "no directory". +2. `attachment` and `worktreeMetadata` hold the worktree path this client asked for, before the server canonicalized it. They are a hint for a session sync has not indexed yet, never a correction of a confirmed directory — otherwise a stale local path re-creates the very mismatch this precedence exists to prevent. +3. Never persist or rank a guessed directory. `selectSession` may fall back to the active directory to keep routing usable, but that value is not written to runtime memory, not written to the last-active snapshot, and not passed as `selected` — a persisted guess outlives the race that produced it and survives reloads and restarts. +4. Components must not read `currentSessionDirectory` to build request or queue keys; use `getDirectoryForSession()` so every consumer resolves identically. +5. A disagreement between sources is logged once per session, and `__opencodeDebug.diagnoseSessionDirectory()` reports every source in precedence order. + ## Session action rules Session actions live in `session-actions.ts` and are the canonical place for SDK-calling session mutations that affect global session lists. @@ -207,6 +231,7 @@ Rules: 2. If an action targets a session by ID, resolve the **session's own directory**. Do not assume the current directory is correct. 3. `session-ui-store.ts` should delegate to `session-actions.ts` for these mutations instead of duplicating SDK calls. 4. Sending after a revert commits the new branch optimistically: remove the reverted tail and marker before inserting the new message, and restore both if the send is rejected. +5. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session. Examples of global-store updates performed in `session-actions.ts`: diff --git a/packages/ui/src/sync/__tests__/issue-1637-2270.test.ts b/packages/ui/src/sync/__tests__/issue-1637-2270.test.ts index 5773f57e..5cd7f3b1 100644 --- a/packages/ui/src/sync/__tests__/issue-1637-2270.test.ts +++ b/packages/ui/src/sync/__tests__/issue-1637-2270.test.ts @@ -42,6 +42,7 @@ mock.module("../session-ui-store", () => ({ })) mock.module("../sync-refs", () => ({ + getSyncSessionDirectory: () => null, registerSessionDirectory: (sessionID: string, directory: string) => { registerSessionDirectoryCalls.push({ sessionID, directory }) }, diff --git a/packages/ui/src/sync/__tests__/issue-2039.test.ts b/packages/ui/src/sync/__tests__/issue-2039.test.ts index 8197823c..ef9f5439 100644 --- a/packages/ui/src/sync/__tests__/issue-2039.test.ts +++ b/packages/ui/src/sync/__tests__/issue-2039.test.ts @@ -4,11 +4,16 @@ import { togglePermissionAutoAccept } from "../../components/chat/permissionAuto const storage = new Map() const createSessionCalls: Array<{ title?: string; directory: string | null; parentID: string | null; metadata?: unknown }> = [] const permissionAutoAcceptCalls: Array<[string, boolean]> = [] +let createdSessionDirectory: string | undefined const getMockCalls = (fn: unknown): unknown[][] => ((fn as { mock?: { calls: unknown[][] } }).mock?.calls ?? []) mock.module("zustand", () => ({ - create: () => (initializer: (set: (patch: unknown | ((state: unknown) => unknown)) => void, get: () => unknown) => Record) => { + create: () => (initializer: ( + set: (patch: unknown | ((state: unknown) => unknown)) => void, + get: () => unknown, + api?: unknown, + ) => Record) => { let state: Record const get = () => state const set = (patch: unknown | ((current: Record) => unknown)) => { @@ -16,7 +21,12 @@ mock.module("zustand", () => ({ state = next && typeof next === "object" ? { ...state, ...(next as Record) } : state } - state = initializer(set, get) + state = initializer(set, get, { + setState: set, + getState: get, + getInitialState: get, + subscribe: () => () => undefined, + } as never) const store = ((selector?: (current: Record) => unknown) => ( typeof selector === "function" ? selector(state) : state @@ -53,6 +63,11 @@ const deferredStorage: Storage = { mock.module("@/stores/utils/safeStorage", () => ({ getDeferredSafeStorage: () => deferredStorage, + createDeferredSafeJSONStorage: () => ({ + getItem: async () => null, + setItem: async () => undefined, + removeItem: async () => undefined, + }), })) mock.module("@/lib/opencode/client", () => ({ @@ -224,15 +239,18 @@ mock.module("../sync-refs", () => ({ getSyncMessages: () => [], getSyncParts: () => [], getAllSyncSessions: () => [], + getSyncSessionDirectory: () => null, })) mock.module("../session-actions", () => ({ createSession: mock(async (title: string | undefined, directory: string | null, parentID: string | null, metadata?: unknown) => { createSessionCalls.push({ title, directory, parentID, metadata }) - return { id: "ses_issue_2039", directory } + return { id: "ses_issue_2039", directory: createdSessionDirectory ?? directory } }), deleteSession: mock(async () => true), + deleteSessions: mock(async () => ({ deletedIds: [], failedIds: [] })), archiveSession: mock(async () => true), + archiveSessions: mock(async () => ({ archivedIds: [], failedIds: [] })), updateSessionTitle: mock(async () => undefined), shareSession: mock(async () => undefined), unshareSession: mock(async () => undefined), @@ -242,6 +260,9 @@ mock.module("../session-actions", () => ({ unrevertSession: mock(async () => undefined), forkFromMessage: mock(async () => undefined), fetchMessagesForSession: mock(async () => undefined), + getSessionLastAssistantModel: () => null, + patchSessionMetadata: mock(async () => undefined), + abortCurrentOperation: mock(async () => undefined), })) const { materializeOpenDraftSession, useSessionUIStore } = await import("../session-ui-store") @@ -298,6 +319,7 @@ describe("issue 2039 draft auto-accept", () => { storage.clear() createSessionCalls.length = 0 permissionAutoAcceptCalls.length = 0 + createdSessionDirectory = undefined useSessionUIStore.setState({ currentSessionId: null, @@ -348,4 +370,45 @@ describe("issue 2039 draft auto-accept", () => { expect(createSessionCalls).toHaveLength(0) expect(permissionAutoAcceptCalls).toHaveLength(0) }) + + test("uses the server-authoritative directory after worktree session creation", async () => { + createdSessionDirectory = "/canonical/worktree" + useSessionUIStore.getState().openNewSessionDraft({ + directoryOverride: "/requested/worktree", + }) + + const result = await materializeOpenDraftSession({ + providerID: "provider", + modelID: "model", + }) + + expect(createSessionCalls[0]?.directory).toBe("/requested/worktree") + expect(result?.directory).toBe("/canonical/worktree") + expect(useSessionUIStore.getState().currentSessionDirectory).toBe("/canonical/worktree") + }) + + test("routes the session by the canonical directory, not the requested worktree path", async () => { + createdSessionDirectory = "/canonical/worktree" + useSessionUIStore.getState().openNewSessionDraft({ + directoryOverride: "/requested/worktree", + }) + + const created = await materializeOpenDraftSession({ + providerID: "provider", + modelID: "model", + }) + const sessionId = created?.sessionId ?? "" + + // The worktree attachment still holds the path this client asked for. The + // directory every send, queue key, and confirmation lookup is routed by + // must be the canonical one the server returned. + useSessionUIStore.getState().setWorktreeMetadata(sessionId, { + path: "/requested/worktree", + projectDirectory: "/repo", + branch: "feature", + label: "feature", + }) + + expect(useSessionUIStore.getState().getDirectoryForSession(sessionId)).toBe("/canonical/worktree") + }) }) diff --git a/packages/ui/src/sync/send-failure-log.ts b/packages/ui/src/sync/send-failure-log.ts new file mode 100644 index 00000000..5bbac589 --- /dev/null +++ b/packages/ui/src/sync/send-failure-log.ts @@ -0,0 +1,49 @@ +/** + * Recent prompt-send failures, kept in memory for diagnostics. + * + * A rejected send rolls the optimistic message back and, for transport-level + * failures, the composer stays silent by design. That makes a misrouted or + * refused prompt indistinguishable from "nothing happened" — the user has + * nothing to report beyond "it disappeared". + * + * This buffer gives the failure somewhere to live until someone asks for it, + * via the About dialog's diagnostics report or `__opencodeDebug`. It is + * in-memory only: never persisted, never sent anywhere, and dropped on reload. + */ + +const MAX_RECORDED_SEND_FAILURES = 20 +const MAX_REASON_LENGTH = 200 + +export type SendFailureRecord = { + at: number + sessionId: string + messageId: string + /** Directory the prompt was routed to — the value under suspicion. */ + directory: string | null + /** HTTP status, or null for a transport failure with no response. */ + status: number | null + /** Whether the send may still have been accepted server-side. */ + ambiguous: boolean + /** Whether a confirmation refetch ran and failed to find the message. */ + confirmationChecked: boolean + reason: string +} + +const records: SendFailureRecord[] = [] + +export function recordSendFailure(record: Omit & { reason: string }): void { + records.push({ + ...record, + reason: record.reason.slice(0, MAX_REASON_LENGTH), + at: Date.now(), + }) + if (records.length > MAX_RECORDED_SEND_FAILURES) { + records.splice(0, records.length - MAX_RECORDED_SEND_FAILURES) + } +} + +/** Newest first. */ +export function getRecentSendFailures(): SendFailureRecord[] { + return [...records].reverse() +} + diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index 85da9ffc..c9c4325a 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -245,6 +245,7 @@ mock.module("./session-deletion-cleanup", () => ({ })) mock.module("./sync-refs", () => ({ + getSyncSessionDirectory: () => null, registerSessionDirectory: (sessionID: string, directory: string) => { registeredSessionDirectories.push({ sessionID, directory }) }, diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index e310f3a0..5a6eab26 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -13,6 +13,7 @@ import { opencodeClient } from "@/lib/opencode/client" import { mergeSessionDirectoryMetadata, resolveGlobalSessionDirectory, useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore" import { useConfigStore } from "@/stores/useConfigStore" import { registerSessionDirectory } from "./sync-refs" +import { recordSendFailure } from "./send-failure-log" import { isSyntheticPart } from "@/lib/messages/synthetic" import { materializeSessionSnapshots } from "./materialization" import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize" @@ -1176,7 +1177,9 @@ export async function optimisticSend(input: { try { await input.send(messageID) } catch (error) { - const acceptedRecords = isAmbiguousSendFailure(error) + const status = getErrorStatus(error) + const ambiguousFailure = isAmbiguousSendFailure(error) + const acceptedRecords = ambiguousFailure ? await fetchRecentSendConfirmationRecords(input.sessionId, messageID, targetDirectory) : null @@ -1190,6 +1193,24 @@ export async function optimisticSend(input: { return } + // The rollback below makes the user's message disappear with no other + // trace, and the composer intentionally stays silent for transport-level + // failures. Record the failure so the About dialog's diagnostics report can + // answer "it disappeared and nothing happened" with an actual cause. + // `reason` is truncated by the recorder: a rejected send echoes the + // provider/OpenCode response body, which this log has no reason to keep. + const failureRecord = { + sessionId: input.sessionId, + messageId: messageID, + directory: targetDirectory ?? null, + status, + ambiguous: ambiguousFailure, + confirmationChecked: ambiguousFailure, + reason: error instanceof Error ? error.message : String(error), + } + recordSendFailure(failureRecord) + console.warn("[session-actions] prompt send rejected; rolling back optimistic message", failureRecord) + // Rollback via optimistic infrastructure _optimisticRemove({ sessionID: input.sessionId, diff --git a/packages/ui/src/sync/session-directory-resolution.test.ts b/packages/ui/src/sync/session-directory-resolution.test.ts new file mode 100644 index 00000000..c901ca30 --- /dev/null +++ b/packages/ui/src/sync/session-directory-resolution.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from 'bun:test'; + +import { + describeSessionDirectorySources, + resolveSessionDirectoryFromSources, +} from './session-directory-resolution'; + +const WORKTREE = '/repo/.worktrees/feature'; +const MAIN = '/repo'; + +describe('resolveSessionDirectoryFromSources', () => { + test('authoritative directory beats a selection-time fallback', () => { + const resolution = resolveSessionDirectoryFromSources({ + authoritative: WORKTREE, + selected: MAIN, + }); + + expect(resolution.directory).toBe(WORKTREE); + expect(resolution.source).toBe('authoritative'); + expect(resolution.conflict).toEqual({ source: 'selected', directory: MAIN }); + }); + + test('authoritative directory beats a directory persisted across restarts', () => { + const resolution = resolveSessionDirectoryFromSources({ + authoritative: WORKTREE, + remembered: MAIN, + }); + + expect(resolution.directory).toBe(WORKTREE); + expect(resolution.conflict).toEqual({ source: 'remembered', directory: MAIN }); + }); + + test('the indexed directory outranks a locally requested worktree path', () => { + // attachment/worktreeMetadata hold the path this client asked for, before + // the server canonicalized it. Letting them win would route prompts to a + // directory that no child store owns. + const resolution = resolveSessionDirectoryFromSources({ + attachment: '/requested/worktree', + worktreeMetadata: '/requested/worktree', + authoritative: WORKTREE, + }); + + expect(resolution.directory).toBe(WORKTREE); + expect(resolution.source).toBe('authoritative'); + expect(resolution.conflict).toEqual({ source: 'attachment', directory: '/requested/worktree' }); + }); + + test('a worktree attachment is used while the session is not indexed yet', () => { + // A guessed selection is not passed as `selected` at all, so the worktree + // assignment is the best available value during the bootstrap race. + const resolution = resolveSessionDirectoryFromSources({ + authoritative: null, + selected: null, + attachment: WORKTREE, + remembered: MAIN, + }); + + expect(resolution.directory).toBe(WORKTREE); + expect(resolution.source).toBe('attachment'); + expect(resolution.conflict).toEqual({ source: 'remembered', directory: MAIN }); + }); + + test('a server-confirmed selection outranks the requested worktree path', () => { + const resolution = resolveSessionDirectoryFromSources({ + authoritative: null, + selected: '/canonical/worktree', + attachment: '/requested/worktree', + worktreeMetadata: '/requested/worktree', + }); + + expect(resolution.directory).toBe('/canonical/worktree'); + expect(resolution.source).toBe('selected'); + }); + + test('falls back to the selection hint while the session is not indexed yet', () => { + const resolution = resolveSessionDirectoryFromSources({ + authoritative: null, + selected: WORKTREE, + }); + + expect(resolution.directory).toBe(WORKTREE); + expect(resolution.source).toBe('selected'); + expect(resolution.conflict).toBeNull(); + }); + + test('agreeing sources report no conflict', () => { + const resolution = resolveSessionDirectoryFromSources({ + authoritative: WORKTREE, + selected: WORKTREE, + remembered: WORKTREE, + }); + + expect(resolution.conflict).toBeNull(); + }); + + test('reports the first disagreeing source, not the last', () => { + const resolution = resolveSessionDirectoryFromSources({ + authoritative: WORKTREE, + selected: MAIN, + remembered: '/somewhere/else', + }); + + expect(resolution.conflict).toEqual({ source: 'selected', directory: MAIN }); + }); + + test('treats missing and blank values as unknown, never as a directory', () => { + const resolution = resolveSessionDirectoryFromSources({ + attachment: null, + worktreeMetadata: ' ', + authoritative: undefined, + selected: '', + }); + + expect(resolution.directory).toBeNull(); + expect(resolution.source).toBe('none'); + expect(resolution.conflict).toBeNull(); + }); +}); + +describe('describeSessionDirectorySources', () => { + test('lists populated sources in precedence order', () => { + expect(describeSessionDirectorySources({ + remembered: MAIN, + authoritative: WORKTREE, + selected: '', + })).toEqual([ + { source: 'authoritative', directory: WORKTREE }, + { source: 'remembered', directory: MAIN }, + ]); + }); +}); diff --git a/packages/ui/src/sync/session-directory-resolution.ts b/packages/ui/src/sync/session-directory-resolution.ts new file mode 100644 index 00000000..d8069176 --- /dev/null +++ b/packages/ui/src/sync/session-directory-resolution.ts @@ -0,0 +1,137 @@ +/** + * Session → directory resolution precedence. + * + * A session's directory decides which OpenCode project every send, message + * fetch, queue key, and confirmation lookup is routed to. Getting it wrong is + * not a cosmetic problem: the prompt is posted against a directory that does + * not own the session, the send is rejected, and the optimistic message is + * rolled back with no visible error. + * + * The precedence below is deliberate and ordered by authority, not by + * convenience: + * + * The ordering discriminator is **whether the server confirmed the path**, not + * whether the value is local or synced: + * + * 1. `authoritative` — the child store that actually holds the session, then + * the session's own record. Server-backed truth for an indexed session. + * 2. `selected` — the directory captured when the session was selected, but + * only when it came from a server response (the directory `createSession` + * returned, which may be a canonicalized form of what was requested). A + * selection that fell back to the active directory is a guess and is not + * passed here at all. + * 3. `attachment` / `worktreeMetadata` — the worktree this client assigned to + * the session. Both hold the *requested* path, before the server had a + * chance to canonicalize it, so they are a hint for a session sync has not + * indexed yet, never a correction of a confirmed one. + * 4. `remembered` — the per-runtime directory persisted across restarts. Last + * resort: it survives reloads, so a value written from a startup fallback + * would otherwise outlive the race that produced it. + * + * Routing a prompt by an unconfirmed path posts it against a directory that + * does not own the session, and the send is rejected. Moves need no exception: + * a session move updates the owning child store before any client-side value. + */ + +export type SessionDirectorySource = + | 'authoritative' + | 'selected' + | 'attachment' + | 'worktree-metadata' + | 'remembered' + | 'none' + +export type SessionDirectorySources = { + /** Directory of the child store that holds the session, or its own record. */ + authoritative?: string | null + /** Server-confirmed directory captured at selection. Never a guessed one. */ + selected?: string | null + /** Worktree attachment recorded for this session; the requested path. */ + attachment?: string | null + /** Worktree metadata captured when the session was created in a worktree. */ + worktreeMetadata?: string | null + /** Directory persisted for this runtime; may outlive the race that wrote it. */ + remembered?: string | null +} + +export type SessionDirectoryResolution = { + directory: string | null + source: SessionDirectorySource + /** + * Set when a lower-priority source disagrees with the winning one. This is + * the signature of the stale-directory bug: a persisted or selection-time + * fallback pointing at the parent repository while the session lives in a + * worktree. + */ + conflict: { source: SessionDirectorySource; directory: string } | null +} + +const RESOLUTION_ORDER: ReadonlyArray> = [ + 'authoritative', + 'selected', + 'attachment', + 'worktree-metadata', + 'remembered', +] + +const readSource = ( + sources: SessionDirectorySources, + source: Exclude, +): string | null => { + const value = source === 'attachment' + ? sources.attachment + : source === 'worktree-metadata' + ? sources.worktreeMetadata + : source === 'authoritative' + ? sources.authoritative + : source === 'selected' + ? sources.selected + : sources.remembered + if (typeof value !== 'string') return null + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + +/** + * Resolve a session directory from every known source, reporting which source + * won and whether a weaker source disagreed. + * + * Callers normalize paths before passing them in; this module only orders + * authority and never rewrites a path. + */ +export const resolveSessionDirectoryFromSources = ( + sources: SessionDirectorySources, +): SessionDirectoryResolution => { + let winner: { source: SessionDirectorySource; directory: string } | null = null + let conflict: { source: SessionDirectorySource; directory: string } | null = null + + for (const source of RESOLUTION_ORDER) { + const directory = readSource(sources, source) + if (!directory) continue + if (!winner) { + winner = { source, directory } + continue + } + if (!conflict && directory !== winner.directory) { + conflict = { source, directory } + } + } + + if (!winner) { + return { directory: null, source: 'none', conflict: null } + } + + return { directory: winner.directory, source: winner.source, conflict } +} + +/** Every source that carries a value, in precedence order. For diagnostics. */ +export const describeSessionDirectorySources = ( + sources: SessionDirectorySources, +): Array<{ source: SessionDirectorySource; directory: string }> => { + const described: Array<{ source: SessionDirectorySource; directory: string }> = [] + for (const source of RESOLUTION_ORDER) { + const directory = readSource(sources, source) + if (directory) described.push({ source, directory }) + } + return described +} diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 33618f71..4f65e98b 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -40,7 +40,13 @@ import { getSyncMessages, getSyncParts, getDirectoryState, + getSyncSessionDirectory, } from "./sync-refs" +import { + resolveSessionDirectoryFromSources, + type SessionDirectoryResolution, + type SessionDirectorySources, +} from "./session-directory-resolution" import { markSessionViewed } from "./notification-store" import { setActiveSession } from "./sync-context" import { @@ -73,7 +79,7 @@ import { useSessionWorktreeStore } from "./session-worktree-store" import { getAttachedSessionDirectory } from "./session-worktree-contract" import { setSessionOpener } from "./session-navigation" import { getRuntimeKey } from "@/lib/runtime-switch" -import { clearLastActiveSession, persistLastActiveSession } from "./last-session-cache" +import { clearLastActiveSession, persistLastActiveSession, readLastActiveSession } from "./last-session-cache" import { persistWorktreeTopology, readPersistedWorktreeTopology } from "./worktree-topology-cache" import { rememberRuntimeLiveStatus } from "./runtime-live-memory" @@ -394,23 +400,108 @@ const getAttachmentForSession = (sessionId: string | null | undefined): SessionW return useSessionWorktreeStore.getState().getAttachment(sessionId) } +/** + * Authoritative directory for a session: the child store that holds it, and + * only then the session record's own fields. `null` means "not indexed yet", + * never "no directory" — callers must fall back rather than treat it as empty. + */ +const getAuthoritativeSessionDirectory = (sessionId: string): string | null => { + const owningDirectory = getSyncSessionDirectory(sessionId) + if (owningDirectory) return normalizePath(owningDirectory) + const target = getAllSyncSessions().find((s) => s.id === sessionId) + return target ? resolveDirectoryKey(target) : null +} + +/** + * Directory remembered for a session in this runtime, plus the one persisted + * across restarts. Exported for diagnostics: a stale persisted directory is the + * hardest source to observe and the one that survives reloads, so a report that + * cannot show it cannot rule it out. + */ +export const getRememberedSessionDirectory = (sessionId: string): { + runtime: string | null + persisted: string | null +} => { + const key = runtimeMemoryKey() + const runtimeMemory = runtimeSessionMemory.get(key) + const persisted = readLastActiveSession(key) + return { + runtime: runtimeMemory?.sessionId === sessionId ? normalizePath(runtimeMemory.directory) : null, + persisted: persisted?.sessionId === sessionId ? normalizePath(persisted.directory) : null, + } +} + +/** + * Session whose `currentSessionDirectory` is only the active directory, used + * because the session's own directory was not known at selection time. Such a + * value must never outrank a worktree assignment or reach persistence — it is + * a guess, not a selection. + */ +let guessedSelectionSessionId: string | null = null + +const collectSessionDirectorySources = ( + sessionId: string, + getWtMeta: (id: string) => WorktreeMetadata | undefined, + selected: string | null, +): SessionDirectorySources => ({ + authoritative: getAuthoritativeSessionDirectory(sessionId), + selected: sessionId === guessedSelectionSessionId ? null : normalizePath(selected), + attachment: getAttachedSessionDirectory(getAttachmentForSession(sessionId)), + worktreeMetadata: normalizePath(getWtMeta(sessionId)?.path ?? null), + remembered: getRememberedSessionDirectory(sessionId).runtime, +}) + +/** + * Conflicts already warned about, so a stale directory logs once instead of on + * every keystroke. Keyed by runtime *and* the exact pair of directories: the + * same session ID means a different thing in another runtime, and a conflict + * that reappears after being resolved is news worth logging again. Bounded so + * a long-lived session cannot grow it without limit. + */ +const reportedDirectoryConflicts = new Set() +const MAX_REPORTED_DIRECTORY_CONFLICTS = 200 + +const reportSessionDirectoryConflict = ( + sessionId: string, + resolution: SessionDirectoryResolution, +): void => { + if (!resolution.conflict) return + const conflictKey = JSON.stringify([ + runtimeMemoryKey(), + sessionId, + resolution.directory, + resolution.conflict.source, + resolution.conflict.directory, + ]) + if (reportedDirectoryConflicts.has(conflictKey)) return + if (reportedDirectoryConflicts.size >= MAX_REPORTED_DIRECTORY_CONFLICTS) { + reportedDirectoryConflicts.clear() + } + reportedDirectoryConflicts.add(conflictKey) + console.warn( + "[session-directory] session directory sources disagree; using the higher-authority one. " + + "Run __opencodeDebug.diagnoseSessionDirectory() for the full picture.", + { + sessionId, + using: resolution.source, + directory: resolution.directory, + conflictingSource: resolution.conflict.source, + conflictingDirectory: resolution.conflict.directory, + }, + ) +} + const resolveSessionDirectory = ( sessionId: string | null | undefined, getWtMeta: (id: string) => WorktreeMetadata | undefined, + selected: string | null = null, ): string | null => { if (!sessionId) return null - const attachmentDirectory = getAttachedSessionDirectory(getAttachmentForSession(sessionId)) - if (attachmentDirectory) return attachmentDirectory - const metaPath = getWtMeta(sessionId)?.path - if (typeof metaPath === "string" && metaPath.trim().length > 0) return normalizePath(metaPath) - const runtimeMemory = runtimeSessionMemory.get(runtimeMemoryKey()) - if (runtimeMemory?.sessionId === sessionId && runtimeMemory.directory) { - return normalizePath(runtimeMemory.directory) - } - const sessions = getAllSyncSessions() - const target = sessions.find((s) => s.id === sessionId) - if (!target) return null - return resolveDirectoryKey(target) + const resolution = resolveSessionDirectoryFromSources( + collectSessionDirectorySources(sessionId, getWtMeta, selected), + ) + reportSessionDirectoryConflict(sessionId, resolution) + return resolution.directory } const activateConfigForDirectory = async (directory: string | null | undefined): Promise => { @@ -504,13 +595,18 @@ export async function materializeOpenDraftSession(selection: { const created = await store.createSession(draft.title, draftDirectoryOverride, draft.parentID ?? null) if (!created?.id) throw new Error("Failed to create session") + // The server response is authoritative. It may canonicalize a requested + // worktree path (for example through a symlink or platform path casing). + // Sending with the pre-canonical draft path can target a different + // directory scope than the session that was just created. + const createdDirectory = normalizePath(created.directory ?? draftDirectoryOverride ?? null) + persistDraftTarget({ projectId: draftProjectId, - directory: normalizePath(draftDirectoryOverride ?? created.directory ?? null), + directory: createdDirectory, }) const draftSyntheticParts = draft.syntheticParts - const createdDirectory = normalizePath(draftDirectoryOverride ?? created.directory ?? null) const configState = useConfigStore.getState() void activateConfigForDirectory(createdDirectory).catch((error) => { console.warn("Failed to activate directory after creating session:", error) @@ -604,7 +700,13 @@ export const useSessionUIStore = create()((set, get) => ({ (sid) => get().worktreeMetadata.get(sid), ) const fallbackDir = opencodeClient.getDirectory() ?? directoryState.currentDirectory ?? null - const resolvedDir = (directoryHint ? normalizePath(directoryHint) : null) ?? sessionDir ?? fallbackDir + const knownDir = (directoryHint ? normalizePath(directoryHint) : null) ?? sessionDir + const resolvedDir = knownDir ?? fallbackDir + // `fallbackDir` is the active directory, not this session's directory. It + // keeps routing usable while the owning directory store bootstraps, but it + // must never be remembered: a persisted guess outlives the race that + // produced it and survives reloads and restarts. + const isGuessedDir = knownDir === null const projectsState = useProjectsStore.getState() const sessionProject = resolvedDir ? resolveProjectForSessionDirectory( @@ -617,12 +719,14 @@ export const useSessionUIStore = create()((set, get) => ({ // Set the directory together with the session id so chat hooks read the // same child store that send/SSE events will update during startup races. set({ currentSessionId: id, currentSessionDirectory: id ? resolvedDir ?? null : null }) - writeRuntimeSessionMemory(key, { sessionId: id, directory: resolvedDir ?? null }) + guessedSelectionSessionId = isGuessedDir && id ? id : null + const rememberedDir = isGuessedDir ? null : resolvedDir ?? null + writeRuntimeSessionMemory(key, { sessionId: id, directory: rememberedDir }) // Keep the last NON-null session per runtime across app restarts (cold // mobile launches reopen it after the instance reconnects). Going back to // a draft intentionally does not erase it. if (id) { - persistLastActiveSession(key, { sessionId: id, directory: resolvedDir ?? null }) + persistLastActiveSession(key, { sessionId: id, directory: rememberedDir }) } // Kick off the message fetch on the same tick, before React commits the @@ -1560,16 +1664,19 @@ export const useSessionUIStore = create()((set, get) => ({ }, getDirectoryForSession: (sessionId) => { - if (sessionId === get().currentSessionId && get().currentSessionDirectory) { - return get().currentSessionDirectory - } - const resolved = resolveSessionDirectory(sessionId, (sid) => get().worktreeMetadata.get(sid)) + // The selection-time directory participates in resolution, it does not + // short-circuit it. For a worktree session selected before its directory + // store finished bootstrapping, that value is a startup fallback pointing + // at the parent repository; letting it win would route every send, queue + // key, and send-confirmation lookup to a directory that does not own the + // session. + const selected = sessionId === get().currentSessionId ? get().currentSessionDirectory : null + const resolved = resolveSessionDirectory( + sessionId, + (sid) => get().worktreeMetadata.get(sid), + selected, + ) if (resolved) return resolved - const attachmentDirectory = getAttachedSessionDirectory(getAttachmentForSession(sessionId)) - if (attachmentDirectory) return attachmentDirectory - const sessions = getAllSyncSessions() - const session = sessions.find((s) => s.id === sessionId) - if (session) return resolveDirectoryKey(session) const globalStore = useGlobalSessionsStore.getState() const globalSession = [...globalStore.activeSessions, ...globalStore.archivedSessions] .find((s) => s.id === sessionId) @@ -1634,6 +1741,11 @@ export const useSessionUIStore = create()((set, get) => ({ setSessionDirectory: (sessionId, directory) => { const normalized = normalizePath(directory) + // Callers set this from a confirmed destination (a completed move, a + // created worktree), so the selection is no longer a guess. + if (sessionId === guessedSelectionSessionId) { + guessedSelectionSessionId = null + } if (sessionId === get().currentSessionId) { set({ currentSessionDirectory: normalized }) writeRuntimeSessionMemory(runtimeMemoryKey(), { sessionId, directory: normalized }) diff --git a/packages/ui/src/sync/sync-refs.ts b/packages/ui/src/sync/sync-refs.ts index 9b15f0d3..0d96911c 100644 --- a/packages/ui/src/sync/sync-refs.ts +++ b/packages/ui/src/sync/sync-refs.ts @@ -17,6 +17,7 @@ const configListeners = new Set<(directory: string, config: Config) => void>() let cachedSessionManager: ChildStoreManager | null = null let cachedSessionSlices = new Map() let cachedSessionsById = new Map() +let cachedSessionDirectoryById = new Map() export function setSyncRefs( _sdk: OpencodeClient, @@ -29,6 +30,7 @@ export function setSyncRefs( cachedSessionManager = null cachedSessionSlices = new Map() cachedSessionsById = new Map() + cachedSessionDirectoryById = new Map() } _directory = directory if (registerSessionDirectory) { @@ -103,20 +105,38 @@ export function getAllSyncSessionMap(): ReadonlyMap() const nextSessionsById = new Map() + const nextDirectoriesById = new Map() for (const [directory, store] of stores.children) { const sessions = store.getState().session nextSlices.set(directory, sessions) for (const session of sessions) { if (!session?.id) continue nextSessionsById.set(session.id, session) + nextDirectoriesById.set(session.id, directory) } } cachedSessionManager = stores cachedSessionSlices = nextSlices cachedSessionsById = nextSessionsById + cachedSessionDirectoryById = nextDirectoriesById return cachedSessionsById } +/** + * Directory of the child store that actually holds this session. + * + * This is the authoritative session→directory mapping: a session is present in + * exactly the store for the directory it belongs to, regardless of whether the + * server populated `session.directory` on the record itself. Returns `null` + * when no initialized child store contains the session, which means "unknown", + * never "no directory". + */ +export function getSyncSessionDirectory(sessionId: string): string | null { + if (!sessionId) return null + getAllSyncSessionMap() + return cachedSessionDirectoryById.get(sessionId) ?? null +} + /** Read messages for a session from current directory's child store */ export function getSyncMessages(sessionId: string, directory?: string) { return getDirectoryState(directory)?.message[sessionId] ?? [] diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 54e4813f..e2d8aa34 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,5 +1,10 @@ ## [Unreleased] +- **Providers:** custom OpenAI-compatible providers can now be added and edited from Settings, including their endpoint, models, credentials, headers, and configuration scope (thanks to @makeittech). +- UI/Localization: added German interface translations (thanks to @SGD-DEV). +- Chat/Tools: Bash output now applies terminal control characters and strips ANSI formatting, preventing progress output and rewritten lines from appearing as raw escape sequences (thanks to @catan271). +- Chat: queued messages now retry after a temporary send failure or an interrupted turn instead of remaining stuck until another session update. +- Settings/Skills: repository-local `.agents/skills` now appear for the active workspace (thanks to @makeittech). - Chat: clicking an apply_patch tool result now opens each changed file at its correct path instead of always opening the first file (thanks to @nabsiddiqui). - Chat: assistant messages no longer render active HTML. - Sidebar: a worktree shared by more than one project no longer appears twice. diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index 6b358890..9218e072 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -61,6 +61,7 @@ The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can r - Includes session activity snapshot bridge handler used by webview parity routes (`/api/session-activity`). - Includes Zen utility model parity handler used by shared notification settings (`/api/zen/models`). - Owns managed OpenCode upgrade status and mutation handlers, including capability reporting, upgrade serialization, and process restart after a successful upgrade. + - Provider handlers cover source lookup, disconnect (`DELETE /api/provider/:id/auth`), and custom provider upsert (`PUT /api/provider`; create/update OpenAI-compatible config with explicit `scope` for user/project/custom layers; requires `env` or stored auth; secrets via OpenCode auth API). - `opencode-upgrade-runtime.ts` - Owns managed-versus-external capability decisions, latest-version checks, serialized OpenCode self-upgrades, and restart-after-upgrade behavior. diff --git a/packages/vscode/src/bridge-config-runtime.ts b/packages/vscode/src/bridge-config-runtime.ts index b87e6879..463353b3 100644 --- a/packages/vscode/src/bridge-config-runtime.ts +++ b/packages/vscode/src/bridge-config-runtime.ts @@ -25,6 +25,8 @@ import { createSkill, updateSkill, deleteSkill, + renameSkill, + isManagedSkillPath, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, @@ -581,7 +583,21 @@ export async function handleConfigBridgeMessage( if (!name && normalizedMethod === 'GET') { const skills = await resolveDiscoveredSkills(deps, ctx, workingDirectory); - return { id, type, success: true, data: { skills } }; + return { + id, + type, + success: true, + data: { + skills: skills.map((skill) => ({ + ...skill, + renamable: Boolean( + skill.path + && skill.path !== '' + && isManagedSkillPath(skill.path, workingDirectory) + ), + })), + }, + }; } const skillName = typeof name === 'string' ? name.trim() : ''; @@ -616,6 +632,24 @@ export async function handleConfigBridgeMessage( } if (normalizedMethod === 'PATCH') { + if (typeof body?.renameTo === 'string') { + const newName = body.renameTo.trim(); + renameSkill(skillName, newName, workingDirectory); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + name: newName, + requiresReload: true, + message: `Skill renamed to ${newName} successfully. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + updateSkill(skillName, (body || {}) as Record, workingDirectory); return { id, diff --git a/packages/vscode/src/bridge-system-runtime.ts b/packages/vscode/src/bridge-system-runtime.ts index 41bb4bc7..8bb27dfa 100644 --- a/packages/vscode/src/bridge-system-runtime.ts +++ b/packages/vscode/src/bridge-system-runtime.ts @@ -3,7 +3,7 @@ import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; import { randomUUID } from 'crypto'; -import { removeProviderConfig, getProviderSources } from './opencodeConfig'; +import { removeProviderConfig, getProviderSources, upsertProviderConfig } from './opencodeConfig'; import { getProviderAuth, removeProviderAuth } from './opencodeAuth'; import { fetchQuotaForProvider, listConfiguredQuotaProviders } from './quotaProviders'; import { fetchOpenCodeGoUsage } from './opencodeGoQuota'; @@ -484,6 +484,64 @@ export async function handleSystemBridgeMessage( } } + case 'api:provider:upsert': { + const { + providerID, + providerId: providerIdAlias, + config, + scope, + directory, + } = (payload || {}) as { + providerID?: string; + providerId?: string; + config?: unknown; + scope?: string; + directory?: string; + }; + const providerId = (typeof providerID === 'string' && providerID.trim()) + || (typeof providerIdAlias === 'string' && providerIdAlias.trim()) + || ''; + if (!providerId) { + return { id, type, success: false, error: 'Provider ID is required' }; + } + if (!config || typeof config !== 'object' || Array.isArray(config)) { + return { id, type, success: false, error: 'Provider config is required' }; + } + const normalizedScope = typeof scope === 'string' ? scope : 'user'; + if (normalizedScope !== 'user' && normalizedScope !== 'project' && normalizedScope !== 'custom') { + return { id, type, success: false, error: 'Invalid scope' }; + } + try { + const workingDirectory = typeof directory === 'string' && directory.trim().length > 0 + ? directory.trim() + : ctx?.manager?.getWorkingDirectory(); + const result = upsertProviderConfig( + providerId, + config, + workingDirectory, + normalizedScope, + { hasStoredAuth: Boolean(getProviderAuth(providerId)) }, + ); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + providerId: result.providerId, + path: result.path, + config: result.config, + requiresReload: true, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + case 'api:quota:providers': { try { const providers = listConfiguredQuotaProviders(); diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index 9ac5cf0e..6a9d6504 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -10,30 +10,7 @@ import { randomBytes } from 'crypto'; import { normalizeWindowsDriveLetter } from './pathUtils'; import { resolveWorkingDirectoryChange } from './workingDirectoryChange'; import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './opencodeProcessRegistry'; - -/** Keep in sync with packages/web/server/lib/opencode/provider-env-aliases.js */ -const GOOGLE_API_KEY_ALIASES = [ - 'GOOGLE_GENERATIVE_AI_API_KEY', - 'GOOGLE_API_KEY', - 'GEMINI_API_KEY', -] as const; - -function applyProviderEnvAliases(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const next: NodeJS.ProcessEnv = { ...env }; - const googleValue = GOOGLE_API_KEY_ALIASES - .map((key) => next[key]) - .find((value) => typeof value === 'string' && value.trim().length > 0); - - if (googleValue) { - for (const key of GOOGLE_API_KEY_ALIASES) { - if (typeof next[key] !== 'string' || next[key]!.trim().length === 0) { - next[key] = googleValue; - } - } - } - - return next; -} +import { applyProviderEnvAliases } from './provider-env-aliases'; const t = vscode.l10n.t; diff --git a/packages/vscode/src/opencodeConfig.providers.test.ts b/packages/vscode/src/opencodeConfig.providers.test.ts new file mode 100644 index 00000000..d4e64e87 --- /dev/null +++ b/packages/vscode/src/opencodeConfig.providers.test.ts @@ -0,0 +1,265 @@ +import { afterEach, beforeEach, describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + getProviderSources, + removeProviderConfig, + upsertProviderConfig, + validateCustomProviderConfig, +} from './opencodeConfig'; + +let projectDir: string; + +const writeJson = (filePath: string, value: unknown) => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8'); +}; + +const readJson = (filePath: string) => JSON.parse(fs.readFileSync(filePath, 'utf8')); + +describe('custom provider config persistence (VS Code parity)', () => { + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-provider-')); + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + }); + + test('validateCustomProviderConfig rejects invalid endpoint and credentials shape', () => { + assert.equal(validateCustomProviderConfig('Bad Id', { + name: 'X', + options: { baseURL: 'https://api.example.com' }, + models: { m: { name: 'M' } }, + }).ok, false); + + const ftp = validateCustomProviderConfig('ok', { + name: 'X', + options: { baseURL: 'ftp://api.example.com' }, + models: { m: { name: 'M' } }, + }); + assert.equal(ftp.ok, false); + assert.match(ftp.error ?? '', /http:\/\//); + + assert.equal(validateCustomProviderConfig('ok', { + name: 'X', + options: { baseURL: 'https://api.example.com' }, + models: {}, + }).ok, false); + }); + + test('validateCustomProviderConfig rejects missing credentials', () => { + assert.equal(validateCustomProviderConfig('ok', { + name: 'X', + options: { baseURL: 'https://api.example.com' }, + models: { m: { name: 'M' } }, + }).ok, false); + + assert.equal(validateCustomProviderConfig('ok', { + name: 'X', + options: { baseURL: 'https://api.example.com' }, + models: { m: { name: 'M' } }, + }, { hasStoredAuth: true }).ok, true); + + assert.equal(validateCustomProviderConfig('ok', { + name: 'X', + env: ['MY_KEY'], + options: { baseURL: 'https://api.example.com' }, + models: { m: { name: 'M' } }, + }).ok, true); + }); + + test('upsertProviderConfig writes and round-trips project config', () => { + const result = upsertProviderConfig('campus-llm', { + name: 'Campus LLM', + npm: '@ai-sdk/openai-compatible', + options: { + baseURL: 'https://llm.example.edu/v1', + headers: { 'X-Campus': '1' }, + }, + models: { + 'fast-model': { name: 'Fast' }, + }, + env: ['CAMPUS_KEY'], + }, projectDir, 'project'); + + assert.equal(result.providerId, 'campus-llm'); + assert.equal(fs.existsSync(result.path), true); + assert.equal(result.path.startsWith(projectDir), true); + + const written = readJson(result.path); + assert.deepEqual(written.provider['campus-llm'], { + npm: '@ai-sdk/openai-compatible', + name: 'Campus LLM', + env: ['CAMPUS_KEY'], + options: { + baseURL: 'https://llm.example.edu/v1', + headers: { 'X-Campus': '1' }, + }, + models: { + 'fast-model': { name: 'Fast' }, + }, + }); + + const sources = getProviderSources('campus-llm', projectDir); + assert.equal(sources.project.exists, true); + assert.equal(sources.project.path, result.path); + }); + + test('upsertProviderConfig updates existing entry and clears disabled_providers', () => { + const configPath = path.join(projectDir, 'opencode.json'); + writeJson(configPath, { + provider: { + 'campus-llm': { + npm: '@ai-sdk/openai-compatible', + name: 'Old', + options: { baseURL: 'https://old.example.edu/v1' }, + models: { a: { name: 'A' } }, + }, + }, + disabled_providers: ['campus-llm', 'other'], + }); + + upsertProviderConfig('campus-llm', { + name: 'Campus LLM', + options: { baseURL: 'https://llm.example.edu/v1' }, + models: { b: { name: 'B' } }, + env: ['CAMPUS_KEY'], + }, projectDir, 'project'); + + const written = readJson(configPath); + assert.equal(written.provider['campus-llm'].name, 'Campus LLM'); + assert.deepEqual(written.provider['campus-llm'].models, { b: { name: 'B' } }); + assert.deepEqual(written.disabled_providers, ['other']); + }); + + test('upsert then remove restores absence', () => { + upsertProviderConfig('temp-provider', { + name: 'Temp', + options: { baseURL: 'https://api.example.com/v1' }, + models: { m: { name: 'M' } }, + env: ['TEMP_KEY'], + }, projectDir, 'project'); + + assert.equal(getProviderSources('temp-provider', projectDir).project.exists, true); + assert.equal(removeProviderConfig('temp-provider', projectDir, 'project'), true); + assert.equal(getProviderSources('temp-provider', projectDir).project.exists, false); + }); + + test('failed validation does not write config', () => { + const configPath = path.join(projectDir, 'opencode.json'); + assert.throws( + () => upsertProviderConfig('ok', { + name: 'X', + options: { baseURL: 'not-a-url' }, + models: { m: { name: 'M' } }, + env: ['X'], + }, projectDir, 'project'), + /Base URL/, + ); + assert.equal(fs.existsSync(configPath), false); + }); + + test('upsert with hasStoredAuth allows config without env', () => { + const result = upsertProviderConfig('keyed-provider', { + name: 'Keyed', + options: { baseURL: 'https://api.example.com/v1' }, + models: { m: { name: 'M' } }, + }, projectDir, 'project', { hasStoredAuth: true }); + + assert.equal(result.providerId, 'keyed-provider'); + assert.equal(result.config.env, undefined); + }); + + test('project-scope edit updates project layer without creating a user entry', () => { + const providerId = `proj-scope-${Date.now()}`; + const configPath = path.join(projectDir, 'opencode.json'); + + upsertProviderConfig(providerId, { + name: 'Project Scoped', + options: { baseURL: 'https://project.example.com/v1' }, + models: { m: { name: 'M' } }, + }, projectDir, 'project', { hasStoredAuth: true }); + + upsertProviderConfig(providerId, { + name: 'Project Scoped Updated', + options: { baseURL: 'https://project.example.com/v2', headers: { 'X-Project': '1' } }, + models: { m: { name: 'M2' } }, + }, projectDir, 'project', { hasStoredAuth: true }); + + const written = readJson(configPath); + assert.deepEqual(written.provider[providerId], { + npm: '@ai-sdk/openai-compatible', + name: 'Project Scoped Updated', + options: { + baseURL: 'https://project.example.com/v2', + headers: { 'X-Project': '1' }, + }, + models: { m: { name: 'M2' } }, + }); + + const sources = getProviderSources(providerId, projectDir); + assert.equal(sources.project.exists, true); + assert.equal(sources.user.exists, false); + assert.equal(sources.custom.exists, false); + + for (const userPath of [ + path.join(os.homedir(), '.config', 'opencode', 'opencode.json'), + path.join(os.homedir(), '.config', 'opencode', 'config.json'), + ]) { + if (!fs.existsSync(userPath)) continue; + const userConfig = readJson(userPath); + assert.equal(userConfig.provider?.[providerId], undefined); + assert.equal(userConfig.providers?.[providerId], undefined); + } + }); + + test('custom-scope edit updates custom layer without creating a user entry', () => { + const providerId = `custom-scope-${Date.now()}`; + const customPath = path.join(projectDir, 'custom-opencode.json'); + const previousEnv = process.env.OPENCODE_CONFIG; + process.env.OPENCODE_CONFIG = customPath; + + try { + upsertProviderConfig(providerId, { + name: 'Custom Scoped', + options: { baseURL: 'https://custom.example.com/v1' }, + models: { m: { name: 'M' } }, + }, projectDir, 'custom', { hasStoredAuth: true }); + + upsertProviderConfig(providerId, { + name: 'Custom Scoped Updated', + options: { baseURL: 'https://custom.example.com/v2' }, + models: { n: { name: 'N' } }, + }, projectDir, 'custom', { hasStoredAuth: true }); + + const written = readJson(customPath); + assert.equal(written.provider[providerId].name, 'Custom Scoped Updated'); + assert.equal(written.provider[providerId].options.baseURL, 'https://custom.example.com/v2'); + + const sources = getProviderSources(providerId, projectDir); + assert.equal(sources.custom.exists, true); + assert.equal(sources.user.exists, false); + assert.equal(sources.project.exists, false); + + for (const userPath of [ + path.join(os.homedir(), '.config', 'opencode', 'opencode.json'), + path.join(os.homedir(), '.config', 'opencode', 'config.json'), + ]) { + if (!fs.existsSync(userPath)) continue; + const userConfig = readJson(userPath); + assert.equal(userConfig.provider?.[providerId], undefined); + assert.equal(userConfig.providers?.[providerId], undefined); + } + } finally { + if (previousEnv === undefined) { + delete process.env.OPENCODE_CONFIG; + } else { + process.env.OPENCODE_CONFIG = previousEnv; + } + } + }); +}); diff --git a/packages/vscode/src/opencodeConfig.ts b/packages/vscode/src/opencodeConfig.ts index 79a7455d..a14ef031 100644 --- a/packages/vscode/src/opencodeConfig.ts +++ b/packages/vscode/src/opencodeConfig.ts @@ -10,9 +10,6 @@ const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands'); const GLOBAL_SNIPPET_DIR = path.join(OPENCODE_CONFIG_DIR, 'snippet'); const GLOBAL_SNIPPET_DIR_ALT = path.join(OPENCODE_CONFIG_DIR, 'snippets'); const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'config.json'); -const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG - ? path.resolve(process.env.OPENCODE_CONFIG) - : null; const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i; const SNIPPET_EXTENSION = '.md'; const SNIPPET_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,79}$/i; @@ -541,7 +538,10 @@ const getConfigPaths = (workingDirectory?: string) => ({ path.join(OPENCODE_CONFIG_DIR, 'opencode.jsonc'), ], projectPath: getProjectConfigPath(workingDirectory), - customPath: CUSTOM_CONFIG_FILE + // Resolve at call time so OPENCODE_CONFIG changes (and tests) take effect. + customPath: process.env.OPENCODE_CONFIG + ? path.resolve(process.env.OPENCODE_CONFIG) + : null, }); const getPrimaryUserConfigPath = (userPaths: string[]): string => { @@ -2168,6 +2168,163 @@ export const removeProviderConfig = (providerId: string, workingDirectory?: stri return true; }; +const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/; +const BASE_URL_PATTERN = /^https?:\/\//; +const OPENAI_COMPATIBLE_NPM = '@ai-sdk/openai-compatible'; + +export const validateCustomProviderConfig = ( + providerId: string, + config: unknown, + options: { hasStoredAuth?: boolean } = {}, +) => { + if (!providerId || typeof providerId !== 'string' || !PROVIDER_ID_PATTERN.test(providerId)) { + return { ok: false as const, error: 'Provider ID must match /^[a-z0-9][a-z0-9-_]*$/' }; + } + + if (!isPlainObject(config)) { + return { ok: false as const, error: 'Provider config must be an object' }; + } + + const name = typeof config.name === 'string' ? config.name.trim() : ''; + if (!name) { + return { ok: false as const, error: 'Provider name is required' }; + } + + const npm = typeof config.npm === 'string' ? config.npm.trim() : OPENAI_COMPATIBLE_NPM; + if (npm !== OPENAI_COMPATIBLE_NPM) { + return { ok: false as const, error: `Custom providers must use npm package ${OPENAI_COMPATIBLE_NPM}` }; + } + + const optionsBlock = isPlainObject(config.options) ? config.options : null; + if (!optionsBlock) { + return { ok: false as const, error: 'Provider options are required' }; + } + + const baseURL = typeof optionsBlock.baseURL === 'string' ? optionsBlock.baseURL.trim() : ''; + if (!baseURL) { + return { ok: false as const, error: 'Base URL is required' }; + } + if (!BASE_URL_PATTERN.test(baseURL)) { + return { ok: false as const, error: 'Base URL must start with http:// or https://' }; + } + + const models = isPlainObject(config.models) ? config.models : null; + if (!models || Object.keys(models).length === 0) { + return { ok: false as const, error: 'At least one model is required' }; + } + + const normalizedModels: Record = {}; + for (const [modelId, modelValue] of Object.entries(models)) { + const trimmedId = typeof modelId === 'string' ? modelId.trim() : ''; + if (!trimmedId) { + return { ok: false as const, error: 'Model id is required' }; + } + if (!isPlainObject(modelValue)) { + return { ok: false as const, error: `Model "${trimmedId}" must be an object` }; + } + const modelName = typeof modelValue.name === 'string' ? modelValue.name.trim() : ''; + if (!modelName) { + return { ok: false as const, error: `Model "${trimmedId}" requires a name` }; + } + normalizedModels[trimmedId] = { name: modelName }; + } + + const normalized: Record = { + npm: OPENAI_COMPATIBLE_NPM, + name, + options: { + baseURL, + }, + models: normalizedModels, + }; + + let env: string[] = []; + if (Array.isArray(config.env)) { + env = config.env + .filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) + .map((entry) => entry.trim()); + if (env.length > 0) { + normalized.env = env; + } + } + + if (env.length === 0 && !options.hasStoredAuth) { + return { ok: false as const, error: 'API key or {env:VAR} credentials are required' }; + } + + if (isPlainObject(optionsBlock.headers)) { + const headers: Record = {}; + for (const [headerKey, headerValue] of Object.entries(optionsBlock.headers)) { + if (typeof headerKey !== 'string' || !headerKey.trim()) { + continue; + } + if (typeof headerValue !== 'string' || !headerValue.trim()) { + return { ok: false as const, error: `Header "${headerKey}" requires a non-empty value` }; + } + headers[headerKey.trim()] = headerValue.trim(); + } + if (Object.keys(headers).length > 0) { + (normalized.options as Record).headers = headers; + } + } + + return { ok: true as const, value: { providerId, config: normalized } }; +}; + +export const upsertProviderConfig = ( + providerId: string, + config: unknown, + workingDirectory?: string, + scope: 'user' | 'project' | 'custom' = 'user', + options: { hasStoredAuth?: boolean } = {}, +) => { + const validated = validateCustomProviderConfig(providerId, config, options); + if (!validated.ok) { + const error = new Error(validated.error) as Error & { statusCode?: number }; + error.statusCode = 400; + throw error; + } + + const layers = readConfigLayers(workingDirectory); + let targetPath: string | null | undefined = layers.paths.userPath; + + if (scope === 'project') { + if (!workingDirectory) { + throw new Error('Working directory is required for project scope'); + } + targetPath = layers.paths.projectPath ?? targetPath; + } else if (scope === 'custom') { + if (!layers.paths.customPath) { + throw new Error('Custom config path (OPENCODE_CONFIG) is not set'); + } + targetPath = layers.paths.customPath; + } else if (scope !== 'user') { + throw new Error('Invalid scope'); + } + + const targetConfig = getConfigForPath(layers, targetPath) as Record; + const providerConfig = isPlainObject(targetConfig.provider) + ? { ...(targetConfig.provider as Record) } + : {}; + providerConfig[validated.value.providerId] = validated.value.config; + targetConfig.provider = providerConfig; + + if (Array.isArray(targetConfig.disabled_providers)) { + targetConfig.disabled_providers = targetConfig.disabled_providers.filter( + (entry) => entry !== validated.value.providerId, + ); + } + + const writePath = targetPath || CONFIG_FILE; + writeConfig(targetConfig, writePath); + + return { + providerId: validated.value.providerId, + path: writePath, + config: validated.value.config, + }; +}; + export const deleteCommand = (commandName: string, workingDirectory?: string) => { let deleted = false; @@ -2761,7 +2918,7 @@ export const updateSkill = (skillName: string, updates: Record, let mdModified = false; for (const [field, value] of Object.entries(updates || {})) { - if (field === 'scope') continue; + if (field === 'scope' || field === 'source' || field === 'targetPath' || field === 'renameTo') continue; if (field === 'instructions') { const normalizedValue = typeof value === 'string' ? value : value == null ? '' : String(value); @@ -2833,3 +2990,123 @@ export const deleteSkill = (skillName: string, workingDirectory?: string): void throw new Error(`Skill "${skillName}" not found`); } }; + +const isPathInside = (candidatePath: string, parentPath: string): boolean => { + const resolvedCandidate = path.resolve(candidatePath); + const resolvedParent = path.resolve(parentPath); + return resolvedCandidate === resolvedParent + || resolvedCandidate.startsWith(`${resolvedParent}${path.sep}`); +}; + +const getManagedSkillRoots = (workingDirectory?: string): string[] => { + const roots: string[] = []; + const pushRoot = (dir?: string | null) => { + if (!dir) return; + const resolved = path.resolve(dir); + if (!roots.includes(resolved)) { + roots.push(resolved); + } + }; + + pushRoot(SKILL_DIR); + pushRoot(path.join(OPENCODE_CONFIG_DIR, 'skill')); + pushRoot(path.join(os.homedir(), '.opencode', 'skills')); + pushRoot(path.join(os.homedir(), '.opencode', 'skill')); + pushRoot(path.join(os.homedir(), '.claude', 'skills')); + pushRoot(path.join(os.homedir(), '.agents', 'skills')); + + const customConfigDir = process.env.OPENCODE_CONFIG_DIR + ? path.resolve(process.env.OPENCODE_CONFIG_DIR) + : null; + pushRoot(customConfigDir ? path.join(customConfigDir, 'skills') : null); + pushRoot(customConfigDir ? path.join(customConfigDir, 'skill') : null); + + if (workingDirectory) { + const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory); + for (const ancestor of getAncestors(workingDirectory, worktreeRoot)) { + pushRoot(path.join(ancestor, '.opencode', 'skills')); + pushRoot(path.join(ancestor, '.opencode', 'skill')); + pushRoot(path.join(ancestor, '.claude', 'skills')); + pushRoot(path.join(ancestor, '.agents', 'skills')); + } + } + + return roots; +}; + +const isManagedSkillPath = (skillMdPath: string, workingDirectory?: string): boolean => { + if (!skillMdPath || skillMdPath === BUILT_IN_SKILL_LOCATION) { + return false; + } + const skillDir = path.dirname(path.resolve(skillMdPath)); + return getManagedSkillRoots(workingDirectory).some((root) => isPathInside(skillDir, root)); +}; + +export { isManagedSkillPath }; + +export const renameSkill = (oldName: string, newName: string, workingDirectory?: string): void => { + ensureSkillDirs(); + validateSkillName(newName); + + if (oldName === newName) { + return; + } + + const existing = getSkillScope(oldName, workingDirectory); + if (!existing.path) { + throw new Error(`Skill "${oldName}" not found`); + } + if (existing.path === BUILT_IN_SKILL_LOCATION || !fs.existsSync(existing.path)) { + throw new Error(`Skill "${oldName}" cannot be renamed`); + } + if (path.basename(existing.path) !== 'SKILL.md') { + throw new Error(`Skill "${oldName}" target must be a SKILL.md file`); + } + if (!isManagedSkillPath(existing.path, workingDirectory)) { + throw new Error(`Skill "${oldName}" is outside managed skill directories and cannot be renamed`); + } + + const mdDataBeforeMove = parseMdFile(existing.path); + const frontmatterName = typeof mdDataBeforeMove.frontmatter?.name === 'string' + ? mdDataBeforeMove.frontmatter.name + : oldName; + if (frontmatterName !== oldName) { + throw new Error(`Skill "${oldName}" does not match ${existing.path}`); + } + + const conflict = getSkillScope(newName, workingDirectory); + if (conflict.path) { + throw new Error(`Skill ${newName} already exists at ${conflict.path}`); + } + + const oldDir = path.dirname(existing.path); + const newDir = path.join(path.dirname(oldDir), newName); + const directoriesDiffer = path.resolve(oldDir) !== path.resolve(newDir); + + if (directoriesDiffer && fs.existsSync(newDir)) { + throw new Error(`Skill directory already exists at ${newDir}`); + } + + if (directoriesDiffer) { + fs.renameSync(oldDir, newDir); + } + + const newPath = path.join(newDir, 'SKILL.md'); + try { + const mdData = parseMdFile(newPath); + mdData.frontmatter = { + ...mdData.frontmatter, + name: newName, + }; + writeMdFile(newPath, mdData.frontmatter, mdData.body); + } catch (error) { + if (directoriesDiffer && fs.existsSync(newDir) && !fs.existsSync(oldDir)) { + try { + fs.renameSync(newDir, oldDir); + } catch { + // Best-effort rollback; surface the original write failure. + } + } + throw error; + } +}; diff --git a/packages/vscode/src/provider-env-aliases.parity.test.ts b/packages/vscode/src/provider-env-aliases.parity.test.ts new file mode 100644 index 00000000..c275672f --- /dev/null +++ b/packages/vscode/src/provider-env-aliases.parity.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { applyProviderEnvAliases as fromVscode } from './provider-env-aliases'; +import { applyProviderEnvAliases as fromWeb } from '../../web/server/lib/opencode/provider-env-aliases.js'; + +describe('provider env alias parity (vscode ↔ web)', () => { + test('mirrors GEMINI_API_KEY onto Google Generative AI env names', () => { + const input = { + GEMINI_API_KEY: 'AIza-demo', + PATH: '/usr/bin', + }; + assert.deepEqual(fromVscode(input), fromWeb(input)); + assert.deepEqual(fromVscode(input), { + GEMINI_API_KEY: 'AIza-demo', + GOOGLE_API_KEY: 'AIza-demo', + GOOGLE_GENERATIVE_AI_API_KEY: 'AIza-demo', + PATH: '/usr/bin', + }); + }); + + test('does not overwrite an already-set preferred Google key', () => { + const input = { + GEMINI_API_KEY: 'from-gemini', + GOOGLE_GENERATIVE_AI_API_KEY: 'from-google', + }; + assert.deepEqual(fromVscode(input), fromWeb(input)); + }); + + test('returns empty object for invalid input', () => { + assert.deepEqual(fromVscode(null as unknown as NodeJS.ProcessEnv), fromWeb(null)); + assert.deepEqual(fromVscode(undefined as unknown as NodeJS.ProcessEnv), fromWeb(undefined)); + }); +}); diff --git a/packages/vscode/src/provider-env-aliases.ts b/packages/vscode/src/provider-env-aliases.ts new file mode 100644 index 00000000..4c86b492 --- /dev/null +++ b/packages/vscode/src/provider-env-aliases.ts @@ -0,0 +1,5 @@ +/** + * Shared with packages/web/server/lib/opencode/provider-env-aliases.js via esbuild + * bundling. Keep this module as a thin re-export so web and VS Code cannot diverge. + */ +export { applyProviderEnvAliases } from '../../web/server/lib/opencode/provider-env-aliases.js'; diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index ad081713..601859d9 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -1118,6 +1118,30 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R } } + // Handle custom provider upsert: PUT /api/provider + if (pathname === '/api/provider' && method === 'PUT') { + try { + const body = await extractJsonBody(input, init, method); + const queryDirectory = url.searchParams.get('directory') || undefined; + const data = await sendBridgeMessage('api:provider:upsert', { + ...(body && typeof body === 'object' ? body : {}), + directory: queryDirectory + ?? (body && typeof body === 'object' && typeof body.directory === 'string' ? body.directory : undefined), + }); + if (data && typeof data === 'object' && 'success' in data && (data as { success?: boolean }).success === false) { + const message = (data as { error?: string }).error || 'Failed to save provider config'; + return new Response(JSON.stringify({ error: message }), { status: 400, headers: { 'Content-Type': 'application/json' } }); + } + return new Response(JSON.stringify((data as { data?: unknown })?.data ?? data), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); + } + } + return null; }; diff --git a/packages/web/server/lib/inherited-env.js b/packages/web/server/lib/inherited-env.js new file mode 100644 index 00000000..a75de0ff --- /dev/null +++ b/packages/web/server/lib/inherited-env.js @@ -0,0 +1,74 @@ +/** + * Sanitize environment objects inherited by user-facing child processes. + * + * Linux AppImage runtimes export `ARGV0` as the AppImage path before launching + * the packaged app. zsh treats an exported `ARGV0` as the argv[0] for every + * external command it spawns, which corrupts Python venv detection and any + * other program that reads argv[0]/$0 while leaving `/proc/self/exe` correct. + * + * See openchamber/openchamber#2588 and pingdotgg/t3code#2509. + */ + +import { createRequire } from 'node:module'; +import { existsSync } from 'node:fs'; + +const LINUX_ENV_BINARIES = ['/usr/bin/env', '/bin/env']; + +/** + * Remove AppImage `ARGV0` from a mutable env object (or `process.env`). + * @param {NodeJS.ProcessEnv | Record | null | undefined} env + * @returns {typeof env} + */ +export function stripAppImageArgv0Leak(env) { + if (!env || typeof env !== 'object') return env; + if (Object.prototype.hasOwnProperty.call(env, 'ARGV0')) { + delete env.ARGV0; + } + return env; +} + +/** + * Clear AppImage `ARGV0` from this process. + * + * Bun keeps a native environ that `bun-pty` inherits even after + * `delete process.env.ARGV0`. On Linux under Bun we also call libc `unsetenv`. + */ +export function clearAppImageArgv0FromProcessEnv() { + delete process.env.ARGV0; + if (process.platform !== 'linux' || typeof Bun === 'undefined') return; + try { + const require = createRequire(import.meta.url); + const { dlopen } = require('bun:ffi'); + const libc = dlopen('libc.so.6', { + unsetenv: { args: ['cstring'], returns: 'i32' }, + }); + libc.symbols.unsetenv(Buffer.from('ARGV0\0')); + } catch { + // Node/Electron and environments without bun:ffi rely on explicit child envs. + } +} + +/** + * Resolve a Linux PTY launch that drops native `ARGV0` before the shell starts. + * + * `bun-pty` merges the OS environ into the child, so deleting `ARGV0` from the + * JS env object alone is not enough. Wrapping with `env -u ARGV0` unsets it + * before execing the real shell. No-op on non-Linux platforms. + * + * @param {string} executable + * @param {string[]} args + * @returns {{ executable: string, args: string[] }} + */ +export function resolveLinuxPtyLaunch(executable, args = []) { + if (process.platform !== 'linux') { + return { executable, args }; + } + const envBinary = LINUX_ENV_BINARIES.find((candidate) => existsSync(candidate)); + if (!envBinary) { + return { executable, args }; + } + return { + executable: envBinary, + args: ['-u', 'ARGV0', executable, ...args], + }; +} diff --git a/packages/web/server/lib/inherited-env.test.js b/packages/web/server/lib/inherited-env.test.js new file mode 100644 index 00000000..8348d449 --- /dev/null +++ b/packages/web/server/lib/inherited-env.test.js @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { + clearAppImageArgv0FromProcessEnv, + resolveLinuxPtyLaunch, + stripAppImageArgv0Leak, +} from './inherited-env.js'; + +describe('stripAppImageArgv0Leak', () => { + it('removes ARGV0 from a child env object', () => { + const env = { + PATH: '/usr/bin', + ARGV0: '/path/to/OpenChamber-1.17.2-linux-x86_64.AppImage', + SHELL: '/bin/zsh', + }; + + expect(stripAppImageArgv0Leak(env)).toBe(env); + expect(env).toEqual({ + PATH: '/usr/bin', + SHELL: '/bin/zsh', + }); + }); + + it('is a no-op when ARGV0 is absent', () => { + const env = { PATH: '/usr/bin', SHELL: '/bin/bash' }; + stripAppImageArgv0Leak(env); + expect(env).toEqual({ PATH: '/usr/bin', SHELL: '/bin/bash' }); + }); + + it('tolerates nullish env values', () => { + expect(stripAppImageArgv0Leak(null)).toBeNull(); + expect(stripAppImageArgv0Leak(undefined)).toBeUndefined(); + }); +}); + +describe('clearAppImageArgv0FromProcessEnv', () => { + it('removes ARGV0 from process.env', () => { + const previous = process.env.ARGV0; + process.env.ARGV0 = '/path/to/OpenChamber.AppImage'; + try { + clearAppImageArgv0FromProcessEnv(); + expect(process.env.ARGV0).toBeUndefined(); + } finally { + if (previous === undefined) delete process.env.ARGV0; + else process.env.ARGV0 = previous; + } + }); +}); + +describe('resolveLinuxPtyLaunch', () => { + it('wraps the shell with env -u ARGV0 on Linux', () => { + if (process.platform !== 'linux') return; + expect(resolveLinuxPtyLaunch('/bin/zsh', ['-l'])).toEqual({ + executable: expect.stringMatching(/\/env$/), + args: ['-u', 'ARGV0', '/bin/zsh', '-l'], + }); + }); + + it('leaves non-Linux launches unchanged', () => { + if (process.platform === 'linux') return; + expect(resolveLinuxPtyLaunch('/bin/zsh', ['-l'])).toEqual({ + executable: '/bin/zsh', + args: ['-l'], + }); + }); +}); diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index f7bbe377..92421fef 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -11,7 +11,7 @@ This module provides OpenCode server integration utilities for the web server ru - `packages/web/server/lib/opencode/cli-entry-runtime.js`: CLI entrypoint runtime that detects direct execution, parses CLI options, and starts server bootstrap. - `packages/web/server/lib/opencode/routes.js`: OpenCode/provider settings and auth-related route registration. - `packages/web/server/lib/opencode/lifecycle.js`: OpenCode process lifecycle runtime (startup, restart, readiness, health monitoring). After readiness it warms the most recently used directories (`getWarmupDirectories` dep, sequential and best-effort) because OpenCode initializes each directory lazily on first request and that cost would otherwise be paid by the user's first interactive session open. -- `packages/web/server/lib/opencode/provider-env-aliases.js`: mirrors known provider credential env aliases into the managed OpenCode process environment (for example `GEMINI_API_KEY` → `GOOGLE_GENERATIVE_AI_API_KEY`) so OpenCode connection detection and the upstream AI SDK agree on the same key names. +- `packages/web/server/lib/opencode/provider-env-aliases.js`: mirrors known provider credential env aliases into the managed OpenCode process environment (for example `GEMINI_API_KEY` → `GOOGLE_GENERATIVE_AI_API_KEY`) so OpenCode connection detection and the upstream AI SDK agree on the same key names. Canonical implementation shared by web lifecycle and the VS Code managed spawn path (`packages/vscode/src/provider-env-aliases.ts` re-exports this module). - `packages/web/server/lib/opencode/env-runtime.js`: OpenCode CLI/binary resolution and shell environment runtime. - `packages/web/server/lib/opencode/env-config.js`: OpenCode-related environment variable parsing and validation (host/port/hostname). - `packages/web/server/lib/opencode/hmr-state-runtime.js`: HMR-persistent runtime state initialization, auth-state bootstrap, and HMR sync helpers. @@ -59,8 +59,14 @@ This module provides OpenCode server integration utilities for the web server ru - `AUTH_FILE`: Auth file path constant. - `OPENCODE_DATA_DIR`: OpenCode data directory path constant. +## Public exports (providers.js) +- `getProviderSources(providerId, workingDirectory)`: Resolves which OpenCode config layers define a provider. +- `upsertProviderConfig(providerId, config, workingDirectory, scope?, options?)`: Validates and writes a custom OpenAI-compatible provider block (`npm`, `name`, `options.baseURL`, `models`, optional `env`/`headers`) into the user/project/custom config layer. Does not store API keys. Requires `config.env` or `options.hasStoredAuth` (auth already written via OpenCode `auth.set`). Edit flows must pass the provider's effective existing layer (`custom` > `project` > `user`) so updates do not create a global user override. +- `validateCustomProviderConfig(providerId, config, options?)`: Structural validation for custom provider payloads (id format, http(s) base URL, models, credentials via `env` or `hasStoredAuth`). +- `removeProviderConfig(providerId, workingDirectory, scope?)`: Removes a provider block from the selected config layer. + ## Public exports (shared.js) -- `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`, `CUSTOM_CONFIG_FILE`: Path constants. +- `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`: Path constants. `OPENCODE_CONFIG` is resolved at call time for the custom config layer path. - `AGENT_SCOPE`, `COMMAND_SCOPE`, `SKILL_SCOPE`: Scope constants with USER and PROJECT values. - `ensureDirs()`: Creates required OpenCode directories. - `parseMdFile(filePath)`, `writeMdFile(filePath, frontmatter, body)`: Markdown file operations with YAML frontmatter. @@ -84,6 +90,7 @@ This module provides OpenCode server integration utilities for the web server ru - `GET /api/opencode/upgrade-status` (returns version availability plus the authoritative `upgrade.supported`, `upgrade.manager`, and `upgrade.reason` capability) - `POST /api/opencode/directory` - `GET /api/provider/:providerId/source` + - `PUT /api/provider` (create/update custom OpenAI-compatible provider config in OpenCode user/project/custom layers via `scope`; secrets stay in auth via the OpenCode auth API) - `DELETE /api/provider/:providerId/auth` - Owns lazy auth library loading for provider auth checks/removal. - Keeps route behavior independent from composition root; `index.js` now supplies dependencies only. @@ -122,7 +129,9 @@ The runtime maintains active-session count incrementally from idempotent activit Managed OpenCode launch also merges the environment returned by the agent-tool runtime. PATH and `OPENCODE_SERVER_PASSWORD` remain lifecycle-owned and cannot be replaced by injected values. External OpenCode processes receive no -OpenChamber tool injection. +OpenChamber tool injection. Managed launch env strips AppImage `ARGV0` before +spawn so zsh-backed OpenCode tools do not rewrite child argv[0] to the AppImage +path (#2588). Before spawn, `applyProviderEnvAliases` fills unset Google credential aliases from any present sibling (`GOOGLE_GENERATIVE_AI_API_KEY`, `GOOGLE_API_KEY`, @@ -364,6 +373,8 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`. ## Public exports (skill-routes.js) - `registerSkillRoutes(app, dependencies)`: registers skills-related routes: - Skills config CRUD and metadata under `/api/config/skills*` + - Skill rename via `PATCH /api/config/skills/:name` with `{ renameTo }` (directory rename preserves `SKILL.md` body and supporting files; restricted to managed skill roots under `.opencode/skills|skill`, `.claude/skills`, and `.agents/skills`) + - Skill list responses include authoritative `renamable` derived from the same managed-root policy used by rename - Skills catalog listing/source pagination, scan, and install routes - Supporting skill file read/write/delete routes - Directory resolution prefers an explicit request directory, then soft-falls diff --git a/packages/web/server/lib/opencode/core-routes.js b/packages/web/server/lib/opencode/core-routes.js index 3b735169..f69a1460 100644 --- a/packages/web/server/lib/opencode/core-routes.js +++ b/packages/web/server/lib/opencode/core-routes.js @@ -1078,6 +1078,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => { req.path.startsWith('/api/push') || req.path.startsWith('/api/notifications') || req.path.startsWith('/api/permission-auto-accept') || + req.path.startsWith('/api/provider') || req.path.startsWith('/api/session-folders') || req.path.startsWith('/api/small-model') || req.path.startsWith('/api/walkthrough') || diff --git a/packages/web/server/lib/opencode/core-routes.test.js b/packages/web/server/lib/opencode/core-routes.test.js index 45d38b8f..200a1c13 100644 --- a/packages/web/server/lib/opencode/core-routes.test.js +++ b/packages/web/server/lib/opencode/core-routes.test.js @@ -127,6 +127,37 @@ describe('core-routes', () => { expect(response.body).toEqual({ body: { content: 'Snippet body' } }); }); + it('should parse JSON bodies for custom provider upsert routes', async () => { + const app = express(); + registerCommonRequestMiddleware(app, { express }); + app.put('/api/provider', (req, res) => { + res.json({ body: req.body }); + }); + + const response = await request(app) + .put('/api/provider') + .send({ + providerID: 'campus-llm', + config: { + name: 'Campus LLM', + options: { baseURL: 'https://llm.example.edu/v1' }, + models: { fast: { name: 'Fast' } }, + }, + }) + .expect(200); + + expect(response.body).toEqual({ + body: { + providerID: 'campus-llm', + config: { + name: 'Campus LLM', + options: { baseURL: 'https://llm.example.edu/v1' }, + models: { fast: { name: 'Fast' } }, + }, + }, + }); + }); + it('should require API auth before probing loopback preview URLs', async () => { const app = express(); const originalFetch = globalThis.fetch; diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index 0bd65b50..23950d63 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { clearAppImageArgv0FromProcessEnv } from '../inherited-env.js'; import { mergePathValues } from './path-utils.js'; export const createOpenCodeEnvRuntime = (deps) => { @@ -227,12 +228,16 @@ export const createOpenCodeEnvRuntime = (deps) => { }; const applyLoginShellEnvSnapshot = () => { + // Always clear AppImage ARGV0, even when no login-shell snapshot is available. + // Otherwise a leaked process.env.ARGV0 survives into later child spawns (#2588). + clearAppImageArgv0FromProcessEnv(); + const snapshot = getLoginShellEnvSnapshot(); if (!snapshot) { return; } - const skipKeys = new Set(['PWD', 'OLDPWD', 'SHLVL', '_']); + const skipKeys = new Set(['PWD', 'OLDPWD', 'SHLVL', '_', 'ARGV0']); for (const [key, value] of Object.entries(snapshot)) { if (skipKeys.has(key)) { continue; diff --git a/packages/web/server/lib/opencode/env-runtime.test.js b/packages/web/server/lib/opencode/env-runtime.test.js index 52ce6908..7f51f40f 100644 --- a/packages/web/server/lib/opencode/env-runtime.test.js +++ b/packages/web/server/lib/opencode/env-runtime.test.js @@ -131,6 +131,43 @@ describe('OpenCode env runtime', () => { expect(process.env.PATH).toBe(defaultDir); }); + it('clears AppImage ARGV0 when applying a login-shell env snapshot', () => { + const previousArgv0 = process.env.ARGV0; + process.env.ARGV0 = '/path/to/OpenChamber.AppImage'; + delete process.env.OPENCHAMBER_ARGV0_TEST_MARKER; + const { runtime, state } = createRuntime({}); + state.cachedLoginShellEnvSnapshot = { + PATH: '/usr/bin', + ARGV0: '/leaked/from/shell.AppImage', + OPENCHAMBER_ARGV0_TEST_MARKER: '1', + }; + + try { + runtime.applyLoginShellEnvSnapshot(); + expect(process.env.ARGV0).toBeUndefined(); + expect(process.env.OPENCHAMBER_ARGV0_TEST_MARKER).toBe('1'); + } finally { + delete process.env.OPENCHAMBER_ARGV0_TEST_MARKER; + if (previousArgv0 === undefined) delete process.env.ARGV0; + else process.env.ARGV0 = previousArgv0; + } + }); + + it('clears AppImage ARGV0 even when no login-shell snapshot is available', () => { + const previousArgv0 = process.env.ARGV0; + process.env.ARGV0 = '/path/to/OpenChamber.AppImage'; + const { runtime, state } = createRuntime({}); + state.cachedLoginShellEnvSnapshot = null; + + try { + runtime.applyLoginShellEnvSnapshot(); + expect(process.env.ARGV0).toBeUndefined(); + } finally { + if (previousArgv0 === undefined) delete process.env.ARGV0; + else process.env.ARGV0 = previousArgv0; + } + }); + it('throws a specific error for a missing configured OpenCode binary in strict mode', async () => { const { runtime } = createRuntime({ opencodeBinary: '/missing/opencode' }); diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js index d127515d..bf05007b 100644 --- a/packages/web/server/lib/opencode/feature-routes-runtime.js +++ b/packages/web/server/lib/opencode/feature-routes-runtime.js @@ -19,7 +19,7 @@ import { registerPluginRoutes } from './plugin-routes.js'; import { getNpmInfo, clearCache as clearNpmCache } from './npm-registry.js'; import { parseNpmSpec, parsePathSpec, isExactSemver } from './plugin-spec.js'; import { registerOpenCodeRoutes } from './routes.js'; -import { getProviderSources, removeProviderConfig } from './providers.js'; +import { getProviderSources, removeProviderConfig, upsertProviderConfig } from './providers.js'; import { getAgentSources, getAgentConfig, createAgent, updateAgent, deleteAgent } from './agents.js'; import { getCommandSources, createCommand, updateCommand, deleteCommand } from './commands.js'; import { listMcpConfigs, getMcpConfig, createMcpConfig, updateMcpConfig, deleteMcpConfig } from './mcp.js'; @@ -38,7 +38,7 @@ import { decodePluginId, } from './plugins.js'; import { SKILL_DIR, SKILL_SCOPE, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile } from './shared.js'; -import { getSkillSources, discoverSkills, mergeDiscoveredSkills, createSkill, updateSkill, deleteSkill } from './skills.js'; +import { getSkillSources, discoverSkills, mergeDiscoveredSkills, createSkill, updateSkill, deleteSkill, renameSkill, isManagedSkillPath } from './skills.js'; import { getCuratedSkillsSources } from '../skills-catalog/curated-sources.js'; import { getCacheKey, getCachedScan, setCachedScan } from '../skills-catalog/cache.js'; import { isClawdHubSource, parseSkillRepoSource } from '../skills-catalog/source.js'; @@ -145,6 +145,7 @@ export const createFeatureRoutesRuntime = (dependencies) => { resolveProjectDirectory, getProviderSources, removeProviderConfig, + upsertProviderConfig, refreshOpenCodeAfterConfigChange, buildOpenCodeUrl, getOpenCodeAuthHeaders, @@ -256,6 +257,8 @@ export const createFeatureRoutesRuntime = (dependencies) => { createSkill, updateSkill, deleteSkill, + renameSkill, + isManagedSkillPath, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, diff --git a/packages/web/server/lib/opencode/lifecycle.js b/packages/web/server/lib/opencode/lifecycle.js index af1976dc..ddd68351 100644 --- a/packages/web/server/lib/opencode/lifecycle.js +++ b/packages/web/server/lib/opencode/lifecycle.js @@ -1,5 +1,6 @@ import { spawn, spawnSync } from 'node:child_process'; import net from 'node:net'; +import { stripAppImageArgv0Leak } from '../inherited-env.js'; import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './managed-process-registry.js'; import { applyProviderEnvAliases } from './provider-env-aliases.js'; import { recordStartupPerformance } from './startup-performance.js'; @@ -70,7 +71,9 @@ export const createOpenCodeLifecycleRuntime = (deps) => { } }; - const hasChildProcessExited = (child) => !child || child.exitCode !== null || child.signalCode !== null; + const hasChildProcessExited = (child) => !child + || (child.exitCode !== null && child.exitCode !== undefined) + || (child.signalCode !== null && child.signalCode !== undefined); const isManagedOpenCodeProcessAlive = () => { const child = state.openCodeProcess; @@ -366,6 +369,12 @@ export const createOpenCodeLifecycleRuntime = (deps) => { return { url, pid: child.pid || null, + get exitCode() { + return child.exitCode; + }, + get signalCode() { + return child.signalCode; + }, async close() { await closeManagedOpenCodeChild(child); }, @@ -519,13 +528,13 @@ export const createOpenCodeLifecycleRuntime = (deps) => { timeout: 30000, cwd: state.openCodeWorkingDirectory, shellEnvKeysCount: Object.keys(shellEnv).length, - env: applyProviderEnvAliases({ + env: stripAppImageArgv0Leak(applyProviderEnvAliases({ ...shellEnv, ...process.env, ...managedOpenCodeEnv, PATH: envPath, OPENCODE_SERVER_PASSWORD: openCodePassword, - }), + })), }); if (!serverInstance || !serverInstance.url) { diff --git a/packages/web/server/lib/opencode/lifecycle.test.js b/packages/web/server/lib/opencode/lifecycle.test.js index d32a95bf..f5a51078 100644 --- a/packages/web/server/lib/opencode/lifecycle.test.js +++ b/packages/web/server/lib/opencode/lifecycle.test.js @@ -233,6 +233,30 @@ describe('OpenCode lifecycle', () => { warn.mockRestore(); }); + it('does not mistake a live managed process wrapper for an exited child', async () => { + const close = vi.fn(async () => {}); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + globalThis.fetch = vi.fn(async () => ({ + ok: false, + json: async () => null, + })); + const runtime = createRuntime({}, { + openCodePort: 45678, + openCodeProcess: { + pid: process.pid, + close, + }, + isOpenCodeReady: true, + }); + + await runtime.triggerHealthCheck(); + + expect(close).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('(1/20)')); + warn.mockRestore(); + }); + it('restarts an exited managed process without waiting for the failure interval', async () => { const close = vi.fn(async () => {}); const replacement = createMockChild(); @@ -281,8 +305,45 @@ describe('OpenCode lifecycle', () => { expect(options.env.PATH).toBe('/home/user/.bun/bin:/usr/local/bin:/usr/bin'); expect(options.env.SHELL_ONLY).toBe('yes'); expect(options.env.OPENCODE_SERVER_PASSWORD).toBe('password'); + expect(server.exitCode).toBeNull(); + expect(server.signalCode).toBeNull(); await server.close(); + expect(server.signalCode).toBe('SIGTERM'); + }); + + it('strips AppImage ARGV0 from managed OpenCode launch env', async () => { + delete process.env.OPENCODE_BINARY; + const previousArgv0 = process.env.ARGV0; + process.env.ARGV0 = '/path/to/OpenChamber/OpenChamber-1.17.2-linux-x86_64.AppImage'; + const child = createMockChild(); + spawnMock.mockImplementationOnce(() => { + queueMicrotask(() => { + child.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n'); + }); + return child; + }); + + try { + const runtime = createRuntime({ + getManagedOpenCodeShellEnvSnapshot: vi.fn(() => ({ + PATH: '/home/user/.bun/bin:/usr/local/bin:/usr/bin', + ARGV0: '/leaked/from/shell/snapshot.AppImage', + SHELL_ONLY: 'yes', + })), + }); + const server = await runtime.startOpenCode(); + const [, , options] = spawnMock.mock.calls[0]; + + expect(options.env).not.toHaveProperty('ARGV0'); + expect(options.env.SHELL_ONLY).toBe('yes'); + expect(options.env.PATH).toBe('/home/user/.bun/bin:/usr/local/bin:/usr/bin'); + + await server.close(); + } finally { + if (previousArgv0 === undefined) delete process.env.ARGV0; + else process.env.ARGV0 = previousArgv0; + } }); it('adds managed OpenChamber tool environment without allowing it to replace launch invariants', async () => { diff --git a/packages/web/server/lib/opencode/provider-env-aliases.d.ts b/packages/web/server/lib/opencode/provider-env-aliases.d.ts new file mode 100644 index 00000000..6d960ed8 --- /dev/null +++ b/packages/web/server/lib/opencode/provider-env-aliases.d.ts @@ -0,0 +1,3 @@ +export function applyProviderEnvAliases( + env: Record | null | undefined, +): Record; diff --git a/packages/web/server/lib/opencode/providers.js b/packages/web/server/lib/opencode/providers.js index 419cfe59..050c942c 100644 --- a/packages/web/server/lib/opencode/providers.js +++ b/packages/web/server/lib/opencode/providers.js @@ -6,6 +6,10 @@ import { writeConfig, } from './shared.js'; +const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/; +const BASE_URL_PATTERN = /^https?:\/\//; +const OPENAI_COMPATIBLE_NPM = '@ai-sdk/openai-compatible'; + function getProviderSources(providerId, workingDirectory) { const layers = readConfigLayers(workingDirectory); const { userConfig, projectConfig, customConfig, paths } = layers; @@ -37,6 +41,162 @@ function getProviderSources(providerId, workingDirectory) { }; } +/** + * Validate a custom OpenAI-compatible provider config payload before persistence. + * Returns { ok: true, value } or { ok: false, error }. + * + * Credentials: either config.env contains a variable name, or hasStoredAuth is true + * (auth.json already has a key — typically after auth.set, or when editing). + */ +function validateCustomProviderConfig(providerId, config, options = {}) { + if (!providerId || typeof providerId !== 'string' || !PROVIDER_ID_PATTERN.test(providerId)) { + return { ok: false, error: 'Provider ID must match /^[a-z0-9][a-z0-9-_]*$/' }; + } + + if (!isPlainObject(config)) { + return { ok: false, error: 'Provider config must be an object' }; + } + + const name = typeof config.name === 'string' ? config.name.trim() : ''; + if (!name) { + return { ok: false, error: 'Provider name is required' }; + } + + const npm = typeof config.npm === 'string' ? config.npm.trim() : OPENAI_COMPATIBLE_NPM; + if (npm !== OPENAI_COMPATIBLE_NPM) { + return { ok: false, error: `Custom providers must use npm package ${OPENAI_COMPATIBLE_NPM}` }; + } + + const optionsBlock = isPlainObject(config.options) ? config.options : null; + if (!optionsBlock) { + return { ok: false, error: 'Provider options are required' }; + } + + const baseURL = typeof optionsBlock.baseURL === 'string' ? optionsBlock.baseURL.trim() : ''; + if (!baseURL) { + return { ok: false, error: 'Base URL is required' }; + } + if (!BASE_URL_PATTERN.test(baseURL)) { + return { ok: false, error: 'Base URL must start with http:// or https://' }; + } + + const models = isPlainObject(config.models) ? config.models : null; + if (!models || Object.keys(models).length === 0) { + return { ok: false, error: 'At least one model is required' }; + } + + const normalizedModels = {}; + for (const [modelId, modelValue] of Object.entries(models)) { + const trimmedId = typeof modelId === 'string' ? modelId.trim() : ''; + if (!trimmedId) { + return { ok: false, error: 'Model id is required' }; + } + if (!isPlainObject(modelValue)) { + return { ok: false, error: `Model "${trimmedId}" must be an object` }; + } + const modelName = typeof modelValue.name === 'string' ? modelValue.name.trim() : ''; + if (!modelName) { + return { ok: false, error: `Model "${trimmedId}" requires a name` }; + } + normalizedModels[trimmedId] = { name: modelName }; + } + + const normalized = { + npm: OPENAI_COMPATIBLE_NPM, + name, + options: { + baseURL, + }, + models: normalizedModels, + }; + + let env = []; + if (Array.isArray(config.env)) { + env = config.env + .filter((entry) => typeof entry === 'string' && entry.trim().length > 0) + .map((entry) => entry.trim()); + if (env.length > 0) { + normalized.env = env; + } + } + + const hasStoredAuth = Boolean(options.hasStoredAuth); + if (env.length === 0 && !hasStoredAuth) { + return { + ok: false, + error: 'API key or {env:VAR} credentials are required', + }; + } + + if (isPlainObject(optionsBlock.headers)) { + const headers = {}; + for (const [headerKey, headerValue] of Object.entries(optionsBlock.headers)) { + if (typeof headerKey !== 'string' || !headerKey.trim()) { + continue; + } + if (typeof headerValue !== 'string' || !headerValue.trim()) { + return { ok: false, error: `Header "${headerKey}" requires a non-empty value` }; + } + headers[headerKey.trim()] = headerValue.trim(); + } + if (Object.keys(headers).length > 0) { + normalized.options.headers = headers; + } + } + + return { ok: true, value: { providerId, config: normalized } }; +} + +/** + * Persist (create or update) a custom provider block in OpenCode user/project/custom config. + * Does not write secrets — API keys remain in auth.json via the OpenCode auth API. + */ +function upsertProviderConfig(providerId, config, workingDirectory, scope = 'user', options = {}) { + const validated = validateCustomProviderConfig(providerId, config, options); + if (!validated.ok) { + const error = new Error(validated.error); + error.statusCode = 400; + throw error; + } + + const layers = readConfigLayers(workingDirectory); + let targetPath = layers.paths.userPath; + + if (scope === 'project') { + if (!workingDirectory) { + throw new Error('Working directory is required for project scope'); + } + targetPath = layers.paths.projectPath || targetPath; + } else if (scope === 'custom') { + if (!layers.paths.customPath) { + throw new Error('Custom config path (OPENCODE_CONFIG) is not set'); + } + targetPath = layers.paths.customPath; + } else if (scope !== 'user') { + throw new Error('Invalid scope'); + } + + const targetConfig = getConfigForPath(layers, targetPath); + const providerConfig = isPlainObject(targetConfig.provider) ? { ...targetConfig.provider } : {}; + providerConfig[validated.value.providerId] = validated.value.config; + targetConfig.provider = providerConfig; + + if (Array.isArray(targetConfig.disabled_providers)) { + targetConfig.disabled_providers = targetConfig.disabled_providers.filter( + (entry) => entry !== validated.value.providerId, + ); + } + + const writePath = targetPath || CONFIG_FILE; + writeConfig(targetConfig, writePath); + + return { + providerId: validated.value.providerId, + path: writePath, + config: validated.value.config, + }; +} + function removeProviderConfig(providerId, workingDirectory, scope = 'user') { if (!providerId || typeof providerId !== 'string') { throw new Error('Provider ID is required'); @@ -93,4 +253,6 @@ function removeProviderConfig(providerId, workingDirectory, scope = 'user') { export { getProviderSources, removeProviderConfig, + upsertProviderConfig, + validateCustomProviderConfig, }; diff --git a/packages/web/server/lib/opencode/providers.test.js b/packages/web/server/lib/opencode/providers.test.js new file mode 100644 index 00000000..b023bf87 --- /dev/null +++ b/packages/web/server/lib/opencode/providers.test.js @@ -0,0 +1,261 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + upsertProviderConfig, + validateCustomProviderConfig, + getProviderSources, + removeProviderConfig, +} from './providers.js'; + +let projectDir; + +function writeJson(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8'); +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +describe('custom provider config persistence', () => { + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-provider-')); + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + }); + + test('validateCustomProviderConfig rejects invalid endpoint and credentials shape', () => { + expect(validateCustomProviderConfig('Bad Id', { + name: 'X', + options: { baseURL: 'https://api.example.com' }, + models: { m: { name: 'M' } }, + }).ok).toBe(false); + + expect(validateCustomProviderConfig('ok', { + name: 'X', + options: { baseURL: 'ftp://api.example.com' }, + models: { m: { name: 'M' } }, + }).error).toContain('http://'); + + expect(validateCustomProviderConfig('ok', { + name: 'X', + options: { baseURL: 'https://api.example.com' }, + models: {}, + }).ok).toBe(false); + }); + + test('validateCustomProviderConfig rejects missing credentials', () => { + expect(validateCustomProviderConfig('ok', { + name: 'X', + options: { baseURL: 'https://api.example.com' }, + models: { m: { name: 'M' } }, + }).ok).toBe(false); + + expect(validateCustomProviderConfig('ok', { + name: 'X', + options: { baseURL: 'https://api.example.com' }, + models: { m: { name: 'M' } }, + }, { hasStoredAuth: true }).ok).toBe(true); + + expect(validateCustomProviderConfig('ok', { + name: 'X', + env: ['MY_KEY'], + options: { baseURL: 'https://api.example.com' }, + models: { m: { name: 'M' } }, + }).ok).toBe(true); + }); + + test('upsertProviderConfig writes and round-trips project config', () => { + const result = upsertProviderConfig('campus-llm', { + name: 'Campus LLM', + npm: '@ai-sdk/openai-compatible', + options: { + baseURL: 'https://llm.example.edu/v1', + headers: { 'X-Campus': '1' }, + }, + models: { + 'fast-model': { name: 'Fast' }, + }, + env: ['CAMPUS_KEY'], + }, projectDir, 'project'); + + expect(result.providerId).toBe('campus-llm'); + expect(fs.existsSync(result.path)).toBe(true); + expect(result.path.startsWith(projectDir)).toBe(true); + + const written = readJson(result.path); + expect(written.provider['campus-llm']).toEqual({ + npm: '@ai-sdk/openai-compatible', + name: 'Campus LLM', + env: ['CAMPUS_KEY'], + options: { + baseURL: 'https://llm.example.edu/v1', + headers: { 'X-Campus': '1' }, + }, + models: { + 'fast-model': { name: 'Fast' }, + }, + }); + + const sources = getProviderSources('campus-llm', projectDir); + expect(sources.sources.project.exists).toBe(true); + expect(sources.sources.project.path).toBe(result.path); + }); + + test('upsertProviderConfig updates existing entry and clears disabled_providers', () => { + const configPath = path.join(projectDir, 'opencode.json'); + writeJson(configPath, { + provider: { + 'campus-llm': { + npm: '@ai-sdk/openai-compatible', + name: 'Old', + options: { baseURL: 'https://old.example.edu/v1' }, + models: { a: { name: 'A' } }, + }, + }, + disabled_providers: ['campus-llm', 'other'], + }); + + upsertProviderConfig('campus-llm', { + name: 'Campus LLM', + options: { baseURL: 'https://llm.example.edu/v1' }, + models: { b: { name: 'B' } }, + env: ['CAMPUS_KEY'], + }, projectDir, 'project'); + + const written = readJson(configPath); + expect(written.provider['campus-llm'].name).toBe('Campus LLM'); + expect(written.provider['campus-llm'].models).toEqual({ b: { name: 'B' } }); + expect(written.disabled_providers).toEqual(['other']); + }); + + test('upsert then remove restores absence', () => { + upsertProviderConfig('temp-provider', { + name: 'Temp', + options: { baseURL: 'https://api.example.com/v1' }, + models: { m: { name: 'M' } }, + env: ['TEMP_KEY'], + }, projectDir, 'project'); + + expect(getProviderSources('temp-provider', projectDir).sources.project.exists).toBe(true); + expect(removeProviderConfig('temp-provider', projectDir, 'project')).toBe(true); + expect(getProviderSources('temp-provider', projectDir).sources.project.exists).toBe(false); + }); + + test('failed validation does not write config', () => { + const configPath = path.join(projectDir, 'opencode.json'); + expect(() => upsertProviderConfig('ok', { + name: 'X', + options: { baseURL: 'not-a-url' }, + models: { m: { name: 'M' } }, + env: ['X'], + }, projectDir, 'project')).toThrow(/Base URL/); + expect(fs.existsSync(configPath)).toBe(false); + }); + + test('upsert with hasStoredAuth allows config without env', () => { + const result = upsertProviderConfig('keyed-provider', { + name: 'Keyed', + options: { baseURL: 'https://api.example.com/v1' }, + models: { m: { name: 'M' } }, + }, projectDir, 'project', { hasStoredAuth: true }); + + expect(result.providerId).toBe('keyed-provider'); + expect(result.config.env).toEqual(undefined); + }); + + test('project-scope edit updates project layer without creating a user entry', () => { + const providerId = `proj-scope-${Date.now()}`; + const configPath = path.join(projectDir, 'opencode.json'); + + upsertProviderConfig(providerId, { + name: 'Project Scoped', + options: { baseURL: 'https://project.example.com/v1' }, + models: { m: { name: 'M' } }, + }, projectDir, 'project', { hasStoredAuth: true }); + + upsertProviderConfig(providerId, { + name: 'Project Scoped Updated', + options: { baseURL: 'https://project.example.com/v2', headers: { 'X-Project': '1' } }, + models: { m: { name: 'M2' } }, + }, projectDir, 'project', { hasStoredAuth: true }); + + const written = readJson(configPath); + expect(written.provider[providerId]).toEqual({ + npm: '@ai-sdk/openai-compatible', + name: 'Project Scoped Updated', + options: { + baseURL: 'https://project.example.com/v2', + headers: { 'X-Project': '1' }, + }, + models: { m: { name: 'M2' } }, + }); + + const sources = getProviderSources(providerId, projectDir); + expect(sources.sources.project.exists).toBe(true); + expect(sources.sources.user.exists).toBe(false); + expect(sources.sources.custom.exists).toBe(false); + + for (const userPath of [ + path.join(os.homedir(), '.config', 'opencode', 'opencode.json'), + path.join(os.homedir(), '.config', 'opencode', 'config.json'), + ]) { + if (!fs.existsSync(userPath)) continue; + const userConfig = readJson(userPath); + expect(userConfig.provider?.[providerId]).toBeUndefined(); + expect(userConfig.providers?.[providerId]).toBeUndefined(); + } + }); + + test('custom-scope edit updates custom layer without creating a user entry', () => { + const providerId = `custom-scope-${Date.now()}`; + const customPath = path.join(projectDir, 'custom-opencode.json'); + const previousEnv = process.env.OPENCODE_CONFIG; + process.env.OPENCODE_CONFIG = customPath; + + try { + upsertProviderConfig(providerId, { + name: 'Custom Scoped', + options: { baseURL: 'https://custom.example.com/v1' }, + models: { m: { name: 'M' } }, + }, projectDir, 'custom', { hasStoredAuth: true }); + + upsertProviderConfig(providerId, { + name: 'Custom Scoped Updated', + options: { baseURL: 'https://custom.example.com/v2' }, + models: { n: { name: 'N' } }, + }, projectDir, 'custom', { hasStoredAuth: true }); + + const written = readJson(customPath); + expect(written.provider[providerId].name).toBe('Custom Scoped Updated'); + expect(written.provider[providerId].options.baseURL).toBe('https://custom.example.com/v2'); + + const sources = getProviderSources(providerId, projectDir); + expect(sources.sources.custom.exists).toBe(true); + expect(sources.sources.user.exists).toBe(false); + expect(sources.sources.project.exists).toBe(false); + + for (const userPath of [ + path.join(os.homedir(), '.config', 'opencode', 'opencode.json'), + path.join(os.homedir(), '.config', 'opencode', 'config.json'), + ]) { + if (!fs.existsSync(userPath)) continue; + const userConfig = readJson(userPath); + expect(userConfig.provider?.[providerId]).toBeUndefined(); + expect(userConfig.providers?.[providerId]).toBeUndefined(); + } + } finally { + if (previousEnv === undefined) { + delete process.env.OPENCODE_CONFIG; + } else { + process.env.OPENCODE_CONFIG = previousEnv; + } + } + }); +}); diff --git a/packages/web/server/lib/opencode/routes.js b/packages/web/server/lib/opencode/routes.js index a24d1513..8f1ed8be 100644 --- a/packages/web/server/lib/opencode/routes.js +++ b/packages/web/server/lib/opencode/routes.js @@ -20,6 +20,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => { resolveProjectDirectory, getProviderSources, removeProviderConfig, + upsertProviderConfig, refreshOpenCodeAfterConfigChange, buildOpenCodeUrl, getOpenCodeAuthHeaders, @@ -445,6 +446,63 @@ export const registerOpenCodeRoutes = (app, dependencies) => { } }); + app.put('/api/provider', async (req, res) => { + try { + const providerID = typeof req.body?.providerID === 'string' + ? req.body.providerID.trim() + : (typeof req.body?.providerId === 'string' ? req.body.providerId.trim() : ''); + const config = req.body?.config; + const scope = typeof req.body?.scope === 'string' ? req.body.scope : 'user'; + + if (!providerID) { + return res.status(400).json({ error: 'Provider ID is required' }); + } + if (!config || typeof config !== 'object' || Array.isArray(config)) { + return res.status(400).json({ error: 'Provider config is required' }); + } + if (scope !== 'user' && scope !== 'project' && scope !== 'custom') { + return res.status(400).json({ error: 'Invalid scope' }); + } + + const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; + const queryDirectory = Array.isArray(req.query?.directory) + ? req.query.directory[0] + : req.query?.directory; + const requestedDirectory = headerDirectory || queryDirectory || null; + + let directory = null; + if (scope === 'project' || requestedDirectory) { + const resolved = await resolveProjectDirectory(req); + if (!resolved.directory) { + return res.status(400).json({ error: resolved.error || 'Working directory is required' }); + } + directory = resolved.directory; + } else { + const resolved = await resolveProjectDirectory(req); + if (resolved.directory) { + directory = resolved.directory; + } + } + + const { getProviderAuth } = await getAuthLibrary(); + const hasStoredAuth = Boolean(getProviderAuth(providerID)); + const upsertResult = upsertProviderConfig(providerID, config, directory, scope, { hasStoredAuth }); + + return res.json({ + ...buildDeferredRestartResponse( + `Provider ${providerID} saved. Restart OpenCode to apply.`, + ), + providerId: upsertResult.providerId, + path: upsertResult.path, + config: upsertResult.config, + }); + } catch (error) { + const status = typeof error?.statusCode === 'number' ? error.statusCode : 500; + console.error('Failed to upsert provider config:', error); + return res.status(status).json({ error: error.message || 'Failed to save provider config' }); + } + }); + app.delete('/api/provider/:providerId/auth', async (req, res) => { try { const { providerId } = req.params; diff --git a/packages/web/server/lib/opencode/shared.js b/packages/web/server/lib/opencode/shared.js index 8f499977..6df1faea 100644 --- a/packages/web/server/lib/opencode/shared.js +++ b/packages/web/server/lib/opencode/shared.js @@ -11,9 +11,6 @@ const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents'); const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands'); const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skills'); const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'config.json'); -const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG - ? path.resolve(process.env.OPENCODE_CONFIG) - : null; const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i; // ============== SCOPE TYPE CONSTANTS ============== @@ -121,7 +118,10 @@ function getConfigPaths(workingDirectory) { path.join(OPENCODE_CONFIG_DIR, 'opencode.jsonc'), ], projectPath: getProjectConfigPath(workingDirectory), - customPath: CUSTOM_CONFIG_FILE + // Resolve at call time so OPENCODE_CONFIG changes (and tests) take effect. + customPath: process.env.OPENCODE_CONFIG + ? path.resolve(process.env.OPENCODE_CONFIG) + : null, }; } diff --git a/packages/web/server/lib/opencode/skill-routes.js b/packages/web/server/lib/opencode/skill-routes.js index b979ecf2..ffc884df 100644 --- a/packages/web/server/lib/opencode/skill-routes.js +++ b/packages/web/server/lib/opencode/skill-routes.js @@ -21,6 +21,8 @@ export const registerSkillRoutes = (app, dependencies) => { createSkill, updateSkill, deleteSkill, + renameSkill, + isManagedSkillPath, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, @@ -236,9 +238,15 @@ export const registerSkillRoutes = (app, dependencies) => { const enrichedSkills = skills.map((skill) => { const sources = getSkillSources(skill.name, directory, skill); + const skillPath = typeof skill.path === 'string' ? skill.path : null; return { ...skill, - sources + sources, + renamable: Boolean( + skillPath + && skillPath !== '' + && isManagedSkillPath(skillPath, directory) + ), }; }); @@ -628,6 +636,22 @@ export const registerSkillRoutes = (app, dependencies) => { return res.status(400).json({ error }); } + if (typeof updates?.renameTo === 'string') { + const newName = updates.renameTo.trim(); + console.log(`[Server] Renaming skill: ${skillName} -> ${newName}`); + console.log('[Server] Working directory:', directory); + renameSkill(skillName, newName, directory); + await refreshOpenCodeAfterConfigChange('skill rename'); + + return res.json({ + success: true, + name: newName, + requiresReload: true, + message: `Skill renamed to ${newName} successfully. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } + console.log(`[Server] Updating skill: ${skillName}`); console.log('[Server] Working directory:', directory); diff --git a/packages/web/server/lib/opencode/skill-routes.test.js b/packages/web/server/lib/opencode/skill-routes.test.js index c83f7f84..3ba8526e 100644 --- a/packages/web/server/lib/opencode/skill-routes.test.js +++ b/packages/web/server/lib/opencode/skill-routes.test.js @@ -9,7 +9,9 @@ import { deleteSkill, discoverSkills, getSkillSources, + isManagedSkillPath, mergeDiscoveredSkills, + renameSkill, updateSkill, } from './skills.js'; import { @@ -58,6 +60,8 @@ const startSkillsApp = ({ projectRoot }) => { createSkill, updateSkill, deleteSkill, + renameSkill, + isManagedSkillPath, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, @@ -154,4 +158,62 @@ describe('skill-routes directory soft fallback', () => { const payload = await listResponse.json(); expect(payload.skills.map((skill) => skill.name)).toContain('manual-repo-skill'); }); + + it('marks managed-root skills renamable and cache skills not renamable', async () => { + projectRoot = createTempProject(); + const managedDir = path.join(projectRoot, '.opencode', 'skills', 'managed-list-skill'); + fs.mkdirSync(managedDir, { recursive: true }); + fs.writeFileSync( + path.join(managedDir, 'SKILL.md'), + [ + '---', + 'name: managed-list-skill', + 'description: Managed list skill', + '---', + '', + 'Managed body', + '', + ].join('\n'), + 'utf8', + ); + + const cacheStamp = `oc-skill-routes-${Date.now()}`; + const cacheDir = path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp, 'cache-list-skill'); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync( + path.join(cacheDir, 'SKILL.md'), + [ + '---', + 'name: cache-list-skill', + 'description: Cache list skill', + '---', + '', + 'Cache body', + '', + ].join('\n'), + 'utf8', + ); + + try { + appHandle = startSkillsApp({ projectRoot }); + const listResponse = await fetch( + `${appHandle.baseUrl}/api/config/skills?directory=${encodeURIComponent(projectRoot)}`, + ); + expect(listResponse.status).toBe(200); + const payload = await listResponse.json(); + + const managed = payload.skills.find((entry) => entry.name === 'managed-list-skill'); + const cached = payload.skills.find((entry) => entry.name === 'cache-list-skill'); + + expect(managed).toBeTruthy(); + expect(managed.renamable).toBe(true); + expect(cached).toBeTruthy(); + expect(cached.renamable).toBe(false); + } finally { + fs.rmSync(path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp), { + recursive: true, + force: true, + }); + } + }); }); diff --git a/packages/web/server/lib/opencode/skills.js b/packages/web/server/lib/opencode/skills.js index 91027b52..9ee24d6b 100644 --- a/packages/web/server/lib/opencode/skills.js +++ b/packages/web/server/lib/opencode/skills.js @@ -412,12 +412,22 @@ function getSkillSources(skillName, workingDirectory, discoveredSkill = null) { return sources; } -function createSkill(skillName, config, workingDirectory, scope) { - ensureDirs(); +function isValidSkillName(skillName) { + return typeof skillName === 'string' + && skillName.length > 0 + && skillName.length <= 64 + && /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName); +} - if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName) || skillName.length > 64) { +function assertValidSkillName(skillName) { + if (!isValidSkillName(skillName)) { throw new Error(`Invalid skill name "${skillName}". Must be 1-64 lowercase alphanumeric characters with hyphens, cannot start or end with hyphen.`); } +} + +function createSkill(skillName, config, workingDirectory, scope) { + ensureDirs(); + assertValidSkillName(skillName); const existing = getSkillScope(skillName, workingDirectory); if (existing.path) { @@ -505,7 +515,7 @@ function updateSkill(skillName, updates, workingDirectory, targetPath = null) { let mdModified = false; for (const [field, value] of Object.entries(updates)) { - if (field === 'scope' || field === 'source' || field === 'targetPath') { + if (field === 'scope' || field === 'source' || field === 'targetPath' || field === 'renameTo') { continue; } @@ -592,6 +602,130 @@ function deleteSkill(skillName, workingDirectory) { } } +function isPathInside(candidatePath, parentPath) { + if (!candidatePath || !parentPath) return false; + const resolvedCandidate = path.resolve(candidatePath); + const resolvedParent = path.resolve(parentPath); + return resolvedCandidate === resolvedParent + || resolvedCandidate.startsWith(`${resolvedParent}${path.sep}`); +} + +function getManagedSkillRoots(workingDirectory) { + const roots = []; + const pushRoot = (dir) => { + if (!dir) return; + const resolved = path.resolve(dir); + if (!roots.includes(resolved)) { + roots.push(resolved); + } + }; + + pushRoot(SKILL_DIR); + pushRoot(path.join(OPENCODE_CONFIG_DIR, 'skill')); + pushRoot(path.join(os.homedir(), '.opencode', 'skills')); + pushRoot(path.join(os.homedir(), '.opencode', 'skill')); + pushRoot(path.join(os.homedir(), '.claude', 'skills')); + pushRoot(path.join(os.homedir(), '.agents', 'skills')); + + const customConfigDir = process.env.OPENCODE_CONFIG_DIR + ? path.resolve(process.env.OPENCODE_CONFIG_DIR) + : null; + if (customConfigDir) { + pushRoot(path.join(customConfigDir, 'skills')); + pushRoot(path.join(customConfigDir, 'skill')); + } + + if (workingDirectory) { + const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory); + for (const ancestor of getAncestors(workingDirectory, worktreeRoot)) { + pushRoot(path.join(ancestor, '.opencode', 'skills')); + pushRoot(path.join(ancestor, '.opencode', 'skill')); + pushRoot(path.join(ancestor, '.claude', 'skills')); + pushRoot(path.join(ancestor, '.agents', 'skills')); + } + } + + return roots; +} + +function isManagedSkillPath(skillMdPath, workingDirectory) { + if (!skillMdPath || skillMdPath === BUILT_IN_SKILL_LOCATION) { + return false; + } + const skillDir = path.dirname(path.resolve(skillMdPath)); + return getManagedSkillRoots(workingDirectory).some((root) => isPathInside(skillDir, root)); +} + +function renameSkill(oldName, newName, workingDirectory) { + ensureDirs(); + assertValidSkillName(newName); + + if (oldName === newName) { + return; + } + + const existing = getSkillScope(oldName, workingDirectory); + if (!existing.path) { + throw new Error(`Skill "${oldName}" not found`); + } + if (existing.path === BUILT_IN_SKILL_LOCATION || !fs.existsSync(existing.path)) { + throw new Error(`Skill "${oldName}" cannot be renamed`); + } + if (path.basename(existing.path) !== 'SKILL.md') { + throw new Error(`Skill "${oldName}" target must be a SKILL.md file`); + } + if (!isManagedSkillPath(existing.path, workingDirectory)) { + throw new Error(`Skill "${oldName}" is outside managed skill directories and cannot be renamed`); + } + + const mdDataBeforeMove = parseMdFile(existing.path); + const frontmatterName = typeof mdDataBeforeMove.frontmatter?.name === 'string' + ? mdDataBeforeMove.frontmatter.name + : oldName; + if (frontmatterName !== oldName) { + throw new Error(`Skill "${oldName}" does not match ${existing.path}`); + } + + const conflict = getSkillScope(newName, workingDirectory); + if (conflict.path) { + throw new Error(`Skill ${newName} already exists at ${conflict.path}`); + } + + const oldDir = path.dirname(existing.path); + const newDir = path.join(path.dirname(oldDir), newName); + const directoriesDiffer = path.resolve(oldDir) !== path.resolve(newDir); + + if (directoriesDiffer && fs.existsSync(newDir)) { + throw new Error(`Skill directory already exists at ${newDir}`); + } + + // Rename the skill directory in place so supporting files and SKILL.md body are preserved. + if (directoriesDiffer) { + fs.renameSync(oldDir, newDir); + } + + const newPath = path.join(newDir, 'SKILL.md'); + try { + const mdData = parseMdFile(newPath); + mdData.frontmatter = { + ...mdData.frontmatter, + name: newName, + }; + writeMdFile(newPath, mdData.frontmatter, mdData.body); + } catch (error) { + if (directoriesDiffer && fs.existsSync(newDir) && !fs.existsSync(oldDir)) { + try { + fs.renameSync(newDir, oldDir); + } catch (rollbackError) { + console.error(`Failed to rollback skill rename from ${newDir} to ${oldDir}:`, rollbackError); + } + } + throw error; + } + + console.log(`Renamed skill: ${oldName} -> ${newName} (path: ${newPath})`); +} + export { getSkillSources, discoverSkills, @@ -599,4 +733,6 @@ export { createSkill, updateSkill, deleteSkill, + renameSkill, + isManagedSkillPath, }; diff --git a/packages/web/server/lib/opencode/skills.test.js b/packages/web/server/lib/opencode/skills.test.js index ffa3a6f2..975f3f79 100644 --- a/packages/web/server/lib/opencode/skills.test.js +++ b/packages/web/server/lib/opencode/skills.test.js @@ -1,8 +1,9 @@ import { describe, expect, it } from 'vitest'; +import fs from 'fs'; import fsPromises from 'fs/promises'; import os from 'os'; import path from 'path'; -import { discoverSkills, getSkillSources, mergeDiscoveredSkills } from './skills.js'; +import { discoverSkills, getSkillSources, mergeDiscoveredSkills, renameSkill } from './skills.js'; describe('skills', () => { it('merges locally discovered skills missing from OpenCode live discovery', () => { @@ -147,4 +148,198 @@ describe('skills', () => { await fsPromises.rm(tempRoot, { recursive: true, force: true }); } }); + + it('renames a skill directory while preserving SKILL.md body and supporting files', async () => { + const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-skills-rename-')); + const projectRoot = path.join(tempRoot, 'project'); + const skillDir = path.join(projectRoot, '.opencode', 'skills', 'original-skill'); + const skillPath = path.join(skillDir, 'SKILL.md'); + const supportPath = path.join(skillDir, 'notes.md'); + const body = [ + '# Original Skill', + '', + 'Preserve this non-trivial body across rename.', + '', + '## Details', + '', + '- step one', + '- step two', + ].join('\n'); + + try { + await fsPromises.mkdir(skillDir, { recursive: true }); + await fsPromises.writeFile( + skillPath, + [ + '---', + 'name: original-skill', + 'description: Original skill description', + 'license: MIT', + '---', + '', + body, + '', + ].join('\n'), + 'utf8', + ); + await fsPromises.writeFile(supportPath, 'supporting file contents\n', 'utf8'); + + renameSkill('original-skill', 'renamed-skill', projectRoot); + + const renamedDir = path.join(projectRoot, '.opencode', 'skills', 'renamed-skill'); + const renamedPath = path.join(renamedDir, 'SKILL.md'); + const renamedSupportPath = path.join(renamedDir, 'notes.md'); + + expect(fs.existsSync(skillDir)).toBe(false); + expect(fs.existsSync(renamedPath)).toBe(true); + expect(fs.existsSync(renamedSupportPath)).toBe(true); + + const sources = getSkillSources('renamed-skill', projectRoot, { + name: 'renamed-skill', + path: renamedPath, + scope: 'project', + source: 'opencode', + description: 'fallback', + }); + + expect(sources.md.exists).toBe(true); + expect(sources.md.name).toBe('renamed-skill'); + expect(sources.md.description).toBe('Original skill description'); + expect(sources.md.instructions).toBe(body); + expect(await fsPromises.readFile(renamedSupportPath, 'utf8')).toBe('supporting file contents\n'); + + const raw = await fsPromises.readFile(renamedPath, 'utf8'); + expect(raw).toContain('license: MIT'); + expect(raw).not.toContain('Renamed skill'); + } finally { + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + } + }); + + it('rolls back the directory rename when frontmatter write fails', async () => { + const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-skills-rename-rollback-')); + const projectRoot = path.join(tempRoot, 'project'); + const skillDir = path.join(projectRoot, '.opencode', 'skills', 'rollback-skill'); + const skillPath = path.join(skillDir, 'SKILL.md'); + const body = '# Rollback body\n\nMust remain in the original directory.'; + + try { + await fsPromises.mkdir(skillDir, { recursive: true }); + await fsPromises.writeFile( + skillPath, + [ + '---', + 'name: rollback-skill', + 'description: Rollback skill', + '---', + '', + body, + '', + ].join('\n'), + 'utf8', + ); + await fsPromises.chmod(skillPath, 0o444); + + expect(() => renameSkill('rollback-skill', 'rollback-skill-renamed', projectRoot)).toThrow(); + + expect(fs.existsSync(skillDir)).toBe(true); + expect(fs.existsSync(path.join(projectRoot, '.opencode', 'skills', 'rollback-skill-renamed'))).toBe(false); + expect(await fsPromises.readFile(skillPath, 'utf8')).toContain(body); + } finally { + try { + await fsPromises.chmod(skillPath, 0o644); + } catch { + // Best-effort cleanup when the file was rolled back under a different mode. + } + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + } + }); + + it('rejects invalid names, missing skills, conflicts, unmanaged paths, and frontmatter mismatches', async () => { + const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-skills-rename-reject-')); + const projectRoot = path.join(tempRoot, 'project'); + const managedDir = path.join(projectRoot, '.opencode', 'skills', 'managed-skill'); + const conflictDir = path.join(projectRoot, '.opencode', 'skills', 'taken-name'); + const mismatchDir = path.join(projectRoot, '.opencode', 'skills', 'folder-name'); + const cacheStamp = `oc-rename-${Date.now()}`; + const cacheDir = path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp, 'cache-skill'); + + try { + await fsPromises.mkdir(managedDir, { recursive: true }); + await fsPromises.writeFile( + path.join(managedDir, 'SKILL.md'), + [ + '---', + 'name: managed-skill', + 'description: Managed', + '---', + '', + 'Managed body', + '', + ].join('\n'), + 'utf8', + ); + + await fsPromises.mkdir(conflictDir, { recursive: true }); + await fsPromises.writeFile( + path.join(conflictDir, 'SKILL.md'), + [ + '---', + 'name: taken-name', + 'description: Taken', + '---', + '', + 'Taken body', + '', + ].join('\n'), + 'utf8', + ); + + await fsPromises.mkdir(mismatchDir, { recursive: true }); + await fsPromises.writeFile( + path.join(mismatchDir, 'SKILL.md'), + [ + '---', + 'name: frontmatter-name', + 'description: Mismatch', + '---', + '', + 'Mismatch body', + '', + ].join('\n'), + 'utf8', + ); + + await fsPromises.mkdir(cacheDir, { recursive: true }); + await fsPromises.writeFile( + path.join(cacheDir, 'SKILL.md'), + [ + '---', + 'name: cache-skill', + 'description: Cache skill', + '---', + '', + 'Cache body', + '', + ].join('\n'), + 'utf8', + ); + + expect(() => renameSkill('managed-skill', 'Invalid_Name', projectRoot)).toThrow(/Invalid skill name/); + expect(() => renameSkill('missing-skill', 'new-skill', projectRoot)).toThrow(/not found/); + expect(() => renameSkill('managed-skill', 'taken-name', projectRoot)).toThrow(/already exists/); + expect(() => renameSkill('folder-name', 'renamed-mismatch', projectRoot)).toThrow(/does not match/); + expect(() => renameSkill('cache-skill', 'cache-skill-renamed', projectRoot)).toThrow(/managed skill directories/); + + expect(fs.existsSync(managedDir)).toBe(true); + expect(fs.existsSync(cacheDir)).toBe(true); + expect(fs.existsSync(path.join(projectRoot, '.opencode', 'skills', 'renamed-mismatch'))).toBe(false); + } finally { + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + await fsPromises.rm(path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp), { + recursive: true, + force: true, + }); + } + }); }); diff --git a/packages/web/server/lib/terminal/DOCUMENTATION.md b/packages/web/server/lib/terminal/DOCUMENTATION.md index ffc27411..33fd9e54 100644 --- a/packages/web/server/lib/terminal/DOCUMENTATION.md +++ b/packages/web/server/lib/terminal/DOCUMENTATION.md @@ -10,11 +10,12 @@ - `attach` registers a connection for one terminal. One socket may attach to many terminals. - Every attach and reconnect begins with an authoritative `snapshot` containing bounded history and the current sequence. +- A current socket that closes or errors before its initial `open` invalidates its URL-scoped auth token before retrying, so retries mint a fresh token instead of backing off against a rejected upgrade. Hidden or offline clients wait 60 seconds and wake promptly on visibility/online recovery. - `output`, `exit`, and `restarted` carry monotonically increasing per-terminal sequences. Output carries raw live bytes plus replay-safe bytes with terminal query exchanges removed. - Attach registers before capturing the snapshot, buffers concurrent events, drops events represented by the snapshot sequence, then enters live delivery. - `write` always includes the terminal ID; sockets never have mutable single-terminal binding state. - `detach` removes only that attachment. -- Creation carries the active UI appearance. The PTY sets `COLORFGBG` and answers OSC 10, OSC 11, and Mode 2031 queries immediately, including queries emitted before a WebSocket attachment exists. Subscribed TUIs receive a Mode 2031 notification when the appearance changes. +- Creation carries the active UI appearance. The PTY sets `COLORFGBG` and answers OSC 10, OSC 11, Mode 2031, and primary-device-attribute queries immediately, including queries emitted before a WebSocket attachment exists. The DA1 fallback prevents Fish from waiting ten seconds for a renderer that cannot observe or answer its startup query. Subscribed TUIs receive a Mode 2031 notification when the appearance changes. HTTP remains the authenticated command plane for create, resize, appearance updates, restart, close, and force-kill. There is no SSE output or HTTP input compatibility path. @@ -23,7 +24,9 @@ HTTP remains the authenticated command plane for create, resize, appearance upda - IDs are client-provided or generated with `randomUUID()`. - Concurrent creates for one ID are single-flight only when working directory and shell preference match. Existing IDs cannot be reused for another working directory. - Dimensions are bounded to 1-1000 columns and 1-500 rows; input is capped at 64 KiB. +- A client may create before its renderer has mounted. It derives an initial size from the container and font metrics (falling back to 80x24 when unavailable), then sends a resize once Ghostty reports its final dimensions. This allows shell startup and renderer initialization to overlap. - PTY children explicitly clear `NODE_CHANNEL_FD`; daemon IPC descriptors are host-private and invalid after PTY descriptor cleanup. +- PTY children also strip AppImage `ARGV0` (and other host-private shell vars such as `ELECTRON_RUN_AS_NODE`, `BASH_ENV`, `ENV`, `BASH_XTRACEFD`). An exported `ARGV0` makes zsh rewrite argv[0] for every external command, which breaks Python venv detection and other argv[0]/$0 consumers while leaving `/proc/self/exe` correct. On Linux, PTY spawn is wrapped with `env -u ARGV0` because `bun-pty` merges the native OS environ and would otherwise reintroduce `ARGV0` after a JS-only delete. - `GET /api/terminal/shells` reports shell IDs available on the active server using the same augmented PATH provided to spawned PTYs, plus whether each executable has a supported login-mode argument. `auto` preserves environment/platform fallback order; an explicit unavailable shell fails creation instead of silently running a different shell. Login mode is opt-in and uses only built-in arguments for known shells. Preference changes affect new sessions and explicit restarts, not running PTYs. - PTY data and exit callbacks enter one FIFO queue. Stale callbacks from replaced processes are ignored. - Scrollback is retained on the server and capped at 512 KiB with UTF-8-safe trimming. Device-status, device-attribute, cursor-position reply, and color-query exchanges are removed from replay history with incomplete control sequences carried across PTY chunks; live output remains byte-for-byte unchanged. diff --git a/packages/web/server/lib/terminal/runtime.js b/packages/web/server/lib/terminal/runtime.js index f717cb7a..5e90ac93 100644 --- a/packages/web/server/lib/terminal/runtime.js +++ b/packages/web/server/lib/terminal/runtime.js @@ -10,6 +10,7 @@ import { import { sanitizeTerminalHistoryChunk } from './history.js'; import { consumeTerminalThemeQueries, terminalThemeModeReport } from './theme-response.js'; import { createTerminalShellResolver, getTerminalShellLoginArgs, normalizeTerminalShell } from './shells.js'; +import { stripAppImageArgv0Leak, resolveLinuxPtyLaunch } from '../inherited-env.js'; const MAX_SESSIONS = 20; const MAX_HISTORY_BYTES = 512 * 1024; @@ -66,8 +67,12 @@ export function createTerminalRuntime({ // required because bun-pty also inherits Bun's native process environment. env.NODE_CHANNEL_FD = ''; delete env.BASH_XTRACEFD; delete env.BASH_ENV; delete env.ENV; delete env.ELECTRON_RUN_AS_NODE; + // AppImage exports ARGV0; zsh would otherwise rewrite argv[0] for every command (#2588). + // bun-pty also merges the native OS environ, so wrap with `env -u ARGV0` on Linux. + stripAppImageArgv0Leak(env); + const launch = resolveLinuxPtyLaunch(executable, args); const options = { name: 'xterm-256color', cwd, cols, rows, env, ...(process.platform === 'win32' ? { useConpty: true } : {}) }; - return { process: provider.spawn(executable, args, options), backend: provider.backend, shell: resolvedShell.id, loginShell }; + return { process: provider.spawn(launch.executable, launch.args, options), backend: provider.backend, shell: resolvedShell.id, loginShell }; } catch (error) { lastError = error; } } throw lastError ?? new Error('No executable shell found'); @@ -145,7 +150,7 @@ export function createTerminalRuntime({ background: session.terminalBackground, foreground: session.terminalForeground, modeEnabled: session.themeModeEnabled, - }); + }, { respondToPrimaryDeviceAttributes: true }); session.pendingThemeControlSequence = theme.pending; session.themeModeEnabled = theme.modeEnabled; for (const response of theme.responses) session.process?.write(response); @@ -331,7 +336,7 @@ export function createTerminalRuntime({ session.process = spawned.process; session.backend = spawned.backend; session.shell = spawned.shell; session.loginShell = spawned.loginShell; session.cwd = cwd; session.cols = cols; session.rows = rows; session.history = ''; session.pendingHistoryControlSequence = ''; session.pendingThemeControlSequence = ''; session.themeModeEnabled = false; session.status = 'running'; session.exitCode = null; session.signal = null; session.eventQueue.length = 0; session.themeMode = themeMode === 'light' ? 'light' : 'dark'; session.terminalBackground = terminalBackground; session.terminalForeground = terminalForeground; - wire(session, spawned.process); void terminateProcess(oldProcess); publish(session, { t: 'restarted', history: '' }); + wire(session, spawned.process); void terminateProcess(oldProcess); publish(session, { t: 'restarted', history: '' }); }); pendingSessionRestarts.set(session.id, restart); try { diff --git a/packages/web/server/lib/terminal/runtime.test.js b/packages/web/server/lib/terminal/runtime.test.js index 4bc21420..8232be95 100644 --- a/packages/web/server/lib/terminal/runtime.test.js +++ b/packages/web/server/lib/terminal/runtime.test.js @@ -154,8 +154,14 @@ describe('terminal runtime', () => { expect(harness.processes[0].options.cwd).toBe('/repo'); expect(harness.processes[0].options.env.COLORFGBG).toBe('0;15'); expect(harness.processes[0].options.env.NODE_CHANNEL_FD).toBe(''); - harness.processes[0].emitData('\u001b[?2031h\u001b]10;?\u0007\u001b]11;?\u0007'); - expect(harness.processes[0].writes).toEqual(['\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\', '\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\']); + expect(harness.processes[0].options.env).not.toHaveProperty('ARGV0'); + expect(harness.processes[0].options.env).not.toHaveProperty('ELECTRON_RUN_AS_NODE'); + if (process.platform === 'linux') { + expect(harness.processes[0].shell).toMatch(/\/env$/); + expect(harness.processes[0].args.slice(0, 3)).toEqual(['-u', 'ARGV0', expect.any(String)]); + } + harness.processes[0].emitData('\u001b[?2031h\u001b]10;?\u0007\u001b]11;?\u0007\u001b[0c'); + expect(harness.processes[0].writes).toEqual(['\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\', '\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\', '\u001b[?1;2c']); const appearance = createResponse(); harness.routes.post.get('/api/terminal/:sessionId/appearance')({ params: { sessionId: 'term-1' }, body: { themeMode: 'dark' } }, appearance); @@ -173,6 +179,27 @@ describe('terminal runtime', () => { } finally { await harness.runtime.shutdown(); } }); + it('strips AppImage ARGV0 from PTY child environments', async () => { + const previousArgv0 = process.env.ARGV0; + process.env.ARGV0 = '/path/to/OpenChamber/OpenChamber-1.17.2-linux-x86_64.AppImage'; + const harness = createHarness(); + try { + const response = createResponse(); + await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-argv0', cwd: '/repo', cols: 80, rows: 24 } }, response); + expect(response.statusCode).toBe(200); + expect(harness.processes[0].options.env).not.toHaveProperty('ARGV0'); + if (process.platform === 'linux') { + expect(harness.processes[0].shell).toMatch(/\/env$/); + expect(harness.processes[0].args[0]).toBe('-u'); + expect(harness.processes[0].args[1]).toBe('ARGV0'); + } + } finally { + if (previousArgv0 === undefined) delete process.env.ARGV0; + else process.env.ARGV0 = previousArgv0; + await harness.runtime.shutdown(); + } + }); + it('lists available shells and uses the selected shell for create and restart', async () => { const executables = new Set(['/bin/zsh', '/bin/bash', '/bin/sh']); const harness = createHarness({ @@ -198,14 +225,24 @@ describe('terminal runtime', () => { const created = createResponse(); await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-shell', cwd: '/repo', shell: 'zsh', loginShell: true } }, created); expect(created.statusCode).toBe(200); - expect(harness.processes[0].shell).toBe('/bin/zsh'); - expect(harness.processes[0].args).toEqual(['-l']); + if (process.platform === 'linux') { + expect(harness.processes[0].shell).toMatch(/\/env$/); + expect(harness.processes[0].args).toEqual(['-u', 'ARGV0', '/bin/zsh', '-l']); + } else { + expect(harness.processes[0].shell).toBe('/bin/zsh'); + expect(harness.processes[0].args).toEqual(['-l']); + } const restarted = createResponse(); await harness.routes.post.get('/api/terminal/:sessionId/restart')({ params: { sessionId: 'term-shell' }, body: { shell: 'bash', loginShell: true } }, restarted); expect(restarted.statusCode).toBe(200); - expect(harness.processes[1].shell).toBe('/bin/bash'); - expect(harness.processes[1].args).toEqual(['-l']); + if (process.platform === 'linux') { + expect(harness.processes[1].shell).toMatch(/\/env$/); + expect(harness.processes[1].args).toEqual(['-u', 'ARGV0', '/bin/bash', '-l']); + } else { + expect(harness.processes[1].shell).toBe('/bin/bash'); + expect(harness.processes[1].args).toEqual(['-l']); + } } finally { await harness.runtime.shutdown(); } }); diff --git a/packages/web/server/lib/terminal/theme-response.js b/packages/web/server/lib/terminal/theme-response.js index 3738675a..26662610 100644 --- a/packages/web/server/lib/terminal/theme-response.js +++ b/packages/web/server/lib/terminal/theme-response.js @@ -2,11 +2,21 @@ const MODE_SET = '\u001b[?2031h'; const MODE_RESET = '\u001b[?2031l'; const CAPABILITY_QUERY = '\u001b[?2031$p'; const MODE_QUERIES = ['\u001b[?996n', '\u001b[?997n']; +// Fish asks this before an unattached browser terminal can reply. +const PRIMARY_DEVICE_ATTRIBUTE_QUERIES = ['\u001b[c', '\u001b[0c']; +const PRIMARY_DEVICE_ATTRIBUTE_RESPONSE = '\u001b[?1;2c'; const OSC_QUERIES = [10, 11].flatMap((code) => [ { sequence: `\u001b]${code};?\u0007`, code }, { sequence: `\u001b]${code};?\u001b\\`, code }, ]); -const CONTROL_SEQUENCES = [MODE_SET, MODE_RESET, CAPABILITY_QUERY, ...MODE_QUERIES, ...OSC_QUERIES.map(({ sequence }) => sequence)]; +const CONTROL_SEQUENCES = [ + MODE_SET, + MODE_RESET, + CAPABILITY_QUERY, + ...MODE_QUERIES, + ...PRIMARY_DEVICE_ATTRIBUTE_QUERIES, + ...OSC_QUERIES.map(({ sequence }) => sequence), +]; const parseColor = (value) => { if (typeof value !== 'string') return null; @@ -28,7 +38,12 @@ const colorReport = (code, color) => { export const terminalThemeModeReport = (themeMode) => `\u001b[?997;${themeMode === 'light' ? 2 : 1}n`; -export const consumeTerminalThemeQueries = (pending, data, appearance) => { +export const consumeTerminalThemeQueries = ( + pending, + data, + appearance, + { respondToPrimaryDeviceAttributes = false } = {}, +) => { if (!pending && !data.includes('\u001b')) return { pending: '', responses: [], modeEnabled: appearance.modeEnabled === true }; const input = `${pending}${data}`; const responses = []; @@ -56,6 +71,15 @@ export const consumeTerminalThemeQueries = (pending, data, appearance) => { index += modeQuery.length - 1; continue; } + const primaryDeviceAttributeQuery = PRIMARY_DEVICE_ATTRIBUTE_QUERIES.find((query) => input.startsWith(query, index)); + if (primaryDeviceAttributeQuery && respondToPrimaryDeviceAttributes) { + // A shell can ask before any browser terminal is attached. Answer with a + // conservative VT100 DA1 response so Fish does not block startup for its + // ten-second query timeout while waiting for a renderer that cannot see it. + responses.push(PRIMARY_DEVICE_ATTRIBUTE_RESPONSE); + index += primaryDeviceAttributeQuery.length - 1; + continue; + } const oscQuery = OSC_QUERIES.find(({ sequence }) => input.startsWith(sequence, index)); if (oscQuery) { const response = colorReport(oscQuery.code, oscQuery.code === 10 ? appearance.foreground : appearance.background); diff --git a/packages/web/server/lib/terminal/theme-response.test.js b/packages/web/server/lib/terminal/theme-response.test.js index f67fe418..e45fdd9c 100644 --- a/packages/web/server/lib/terminal/theme-response.test.js +++ b/packages/web/server/lib/terminal/theme-response.test.js @@ -44,4 +44,27 @@ describe('terminal theme responses', () => { '\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\', ]); }); + + test('answers a primary device attribute query when the fallback is enabled', () => { + const attached = consumeTerminalThemeQueries('', '\u001b[0c', lightAppearance); + const unattached = consumeTerminalThemeQueries('', '\u001b[0c', lightAppearance, { + respondToPrimaryDeviceAttributes: true, + }); + + expect(attached.responses).toEqual([]); + expect(unattached.responses).toEqual(['\u001b[?1;2c']); + }); + + test('answers a primary device attribute query split across PTY chunks', () => { + const first = consumeTerminalThemeQueries('', '\u001b[0', lightAppearance, { + respondToPrimaryDeviceAttributes: true, + }); + const second = consumeTerminalThemeQueries(first.pending, 'c', { + ...lightAppearance, + modeEnabled: first.modeEnabled, + }, { respondToPrimaryDeviceAttributes: true }); + + expect(first.pending).toBe('\u001b[0'); + expect(second.responses).toEqual(['\u001b[?1;2c']); + }); }); diff --git a/packages/web/server/lib/walkthrough/DOCUMENTATION.md b/packages/web/server/lib/walkthrough/DOCUMENTATION.md index 892d403d..43d59d16 100644 --- a/packages/web/server/lib/walkthrough/DOCUMENTATION.md +++ b/packages/web/server/lib/walkthrough/DOCUMENTATION.md @@ -136,7 +136,12 @@ silently. The prompt says so explicitly. `languages.js` owns the accepted tags; they match the UI's `Locale` union, and anything else — unknown, malformed, absent — resolves to English rather than -failing the request. The default language adds no instruction at all, since the +failing the request. The two lists cannot be one, because the server cannot +import from `packages/ui`, so `languages.test.js` reads `i18n/runtime.ts` and +compares them. That test exists because a locale added to the interface alone +fails silently in the worst way: the picker offers the language, the tag +resolves to English, and the reader pays for a walkthrough written in the wrong +one while the picker still names theirs. The default language adds no instruction at all, since the system prompt is already English. The language is part of the cache key. Without that, asking for a translation diff --git a/packages/web/server/lib/walkthrough/languages.js b/packages/web/server/lib/walkthrough/languages.js index 787dfd36..fba6b723 100644 --- a/packages/web/server/lib/walkthrough/languages.js +++ b/packages/web/server/lib/walkthrough/languages.js @@ -17,6 +17,7 @@ export const DEFAULT_LANGUAGE = 'en'; // which every model handles more reliably than a switch mid-sentence. const LANGUAGE_NAMES = { en: 'English', + de: 'German', fr: 'French', 'zh-CN': 'Simplified Chinese', 'zh-TW': 'Traditional Chinese', @@ -57,3 +58,9 @@ export function normalizeLanguage(value) { export function languageName(language) { return LANGUAGE_NAMES[language] ?? LANGUAGE_NAMES[DEFAULT_LANGUAGE]; } + +// The tags this list must agree with live in `packages/ui/src/lib/i18n`, which +// the server cannot import. `languages.test.js` compares the two by reading +// that file, because a locale added on one side only fails silently: the picker +// offers the language and the walkthrough comes back in English. +export const __testing = { LANGUAGE_NAMES }; diff --git a/packages/web/server/lib/walkthrough/languages.test.js b/packages/web/server/lib/walkthrough/languages.test.js new file mode 100644 index 00000000..b3c392eb --- /dev/null +++ b/packages/web/server/lib/walkthrough/languages.test.js @@ -0,0 +1,55 @@ +import fs from 'fs'; +import { fileURLToPath } from 'url'; +import { describe, expect, it } from 'vitest'; +import { normalizeLanguage, __testing } from './languages.js'; + +// The languages a walkthrough may be written in have to agree with the locales +// the interface ships, because the picker offers exactly those and the server +// decides what the prompt asks for. The two lists cannot be one list — the +// server cannot import from `packages/ui` — so they are compared here instead. +// +// This exists because German was added to the interface and not here. Nothing +// broke loudly: the picker offered Deutsch, `normalizeLanguage` quietly resolved +// it to English, and a German user paid for a walkthrough written in English +// while the picker still said Deutsch. A drift this quiet needs a test, not +// vigilance. +const RUNTIME_TS = fileURLToPath(new URL('../../../../ui/src/lib/i18n/runtime.ts', import.meta.url)); + +const interfaceLocales = () => { + const source = fs.readFileSync(RUNTIME_TS, 'utf8'); + const match = source.match(/export const LOCALES = \[([^\]]*)\]/); + if (!match) throw new Error(`Could not find LOCALES in ${RUNTIME_TS}`); + return match[1] + .split(',') + .map((entry) => entry.trim().replace(/^['"]|['"]$/g, '')) + .filter(Boolean); +}; + +describe('supported languages', () => { + it('covers every locale the interface offers', () => { + const missing = interfaceLocales().filter((locale) => !Object.hasOwn(__testing.LANGUAGE_NAMES, locale)); + + expect(missing, `add these to LANGUAGE_NAMES in languages.js: ${missing.join(', ')}`).toEqual([]); + }); + + it('offers nothing the interface cannot label', () => { + const locales = new Set(interfaceLocales()); + const extra = Object.keys(__testing.LANGUAGE_NAMES).filter((tag) => !locales.has(tag)); + + // A language here that the interface does not know is not harmful, but it + // is unreachable: the picker is built from the interface list. + expect(extra, `unreachable from the picker: ${extra.join(', ')}`).toEqual([]); + }); + + it('resolves every interface locale to itself rather than to the default', () => { + for (const locale of interfaceLocales()) { + expect(normalizeLanguage(locale)).toBe(locale); + } + }); + + it('names every supported language in English, for the prompt', () => { + for (const [tag, name] of Object.entries(__testing.LANGUAGE_NAMES)) { + expect(name, tag).toMatch(/^[A-Z][A-Za-z ]+$/); + } + }); +});