From bb79e0e4af0bf853597bf4c17c810fa682a8528e Mon Sep 17 00:00:00 2001 From: brettheap Date: Mon, 29 Dec 2025 23:14:26 -0400 Subject: [PATCH 1/7] Add underline styling to Link component for better visual clarity --- packages/opencode/src/cli/cmd/tui/ui/link.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/cli/cmd/tui/ui/link.tsx b/packages/opencode/src/cli/cmd/tui/ui/link.tsx index 3b328e478d6..1f798c54cca 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/link.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/link.tsx @@ -18,6 +18,7 @@ export function Link(props: LinkProps) { return ( { open(props.href).catch(() => {}) }} From 42bdae1fcc371f1f26f5f6aafd837083ee220f35 Mon Sep 17 00:00:00 2001 From: brettheap Date: Mon, 29 Dec 2025 23:14:45 -0400 Subject: [PATCH 2/7] fix(tui): improve model selection dialog --- .../cli/cmd/tui/component/dialog-model.tsx | 28 +++++++++++++++---- .../cli/cmd/tui/component/dialog-provider.tsx | 17 +++++++++-- packages/opencode/src/cli/cmd/tui/ui/link.tsx | 14 ++++++---- 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index bc90dbb5c6e..bd4a9ad59c1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -21,6 +21,7 @@ export function DialogModel(props: { providerID?: string }) { const dialog = useDialog() const [ref, setRef] = createSignal>() const [query, setQuery] = createSignal("") + const [showLatestOnly, setShowLatestOnly] = createSignal(true) const connected = useConnected() const providers = createDialogProviderOptions() @@ -116,14 +117,27 @@ export function DialogModel(props: { providerID?: string }) { entries(), filter(([_, info]) => info.status !== "deprecated"), filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)), + // Filter out dated model versions when "show latest only" is enabled + filter(([model, _]) => !showLatestOnly() || !model.match(/-\d{8}$/)), map(([model, info]) => { const value = { providerID: provider.id, modelID: model, } + // Extract date suffix from model ID (e.g., "20251101" from "claude-opus-4-5-20251101") + const dateMatch = model.match(/-(\d{8})$/) + let title = info.name ?? model + // If model has a date suffix and title doesn't already include it, append it + if (dateMatch && !title.includes(dateMatch[1])) { + title = `${title} (${dateMatch[1]})` + } + // If model doesn't have a date suffix and title doesn't say "latest", mark it as latest + else if (!dateMatch && !title.toLowerCase().includes("latest")) { + title = `${title} (latest)` + } return { value, - title: info.name ?? model, + title, description: favorites.some( (item) => item.providerID === value.providerID && item.modelID === value.modelID, ) @@ -150,10 +164,6 @@ export function DialogModel(props: { providerID?: string }) { (item) => item.providerID === value.providerID && item.modelID === value.modelID, ) if (inFavorites) return false - const inRecents = recents.some( - (item) => item.providerID === value.providerID && item.modelID === value.modelID, - ) - if (inRecents) return false return true }), sortBy( @@ -219,6 +229,14 @@ export function DialogModel(props: { providerID?: string }) { local.model.toggleFavorite(option.value as { providerID: string; modelID: string }) }, }, + { + keybind: Keybind.parse("ctrl+l")[0], + title: showLatestOnly() ? "Show all versions" : "Show latest only", + disabled: !connected(), + onTrigger: () => { + setShowLatestOnly(!showLatestOnly()) + }, + }, ]} ref={setRef} onFilter={setQuery} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index d976485319f..a789f27f1f2 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -10,6 +10,7 @@ import { useTheme } from "../context/theme" import { TextAttributes } from "@opentui/core" import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2" import { DialogModel } from "./dialog-model" +import open from "open" const PROVIDER_PRIORITY: Record = { opencode: 0, @@ -63,11 +64,13 @@ export function createDialogProviderOptions() { } if (index == null) return const method = methods[index] + if (method.type === "oauth") { const result = await sdk.client.provider.oauth.authorize({ providerID: provider.id, method: index, }) + if (result.data?.method === "code") { dialog.replace(() => ( @@ -107,6 +110,9 @@ function AutoMethod(props: AutoMethodProps) { const sync = useSync() onMount(async () => { + // Automatically open the auth URL in the browser + open(props.authorization.url).catch(() => {}) + const result = await sdk.client.provider.oauth.callback({ providerID: props.providerID, method: props.index, @@ -117,7 +123,7 @@ function AutoMethod(props: AutoMethodProps) { } await sdk.client.instance.dispose() await sync.bootstrap() - dialog.replace(() => ) + dialog.replace(() => ) }) return ( @@ -150,6 +156,11 @@ function CodeMethod(props: CodeMethodProps) { const dialog = useDialog() const [error, setError] = createSignal(false) + // Automatically open the auth URL in the browser + onMount(() => { + open(props.authorization.url).catch(() => {}) + }) + return ( ) + dialog.replace(() => ) return } setError(true) @@ -218,7 +229,7 @@ function ApiMethod(props: ApiMethodProps) { }) await sdk.client.instance.dispose() await sync.bootstrap() - dialog.replace(() => ) + dialog.replace(() => ) }} /> ) diff --git a/packages/opencode/src/cli/cmd/tui/ui/link.tsx b/packages/opencode/src/cli/cmd/tui/ui/link.tsx index 1f798c54cca..90dcfb19cbf 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/link.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/link.tsx @@ -1,5 +1,5 @@ import type { JSX } from "solid-js" -import type { RGBA } from "@opentui/core" +import { TextAttributes, type RGBA } from "@opentui/core" import open from "open" export interface LinkProps { @@ -11,19 +11,21 @@ export interface LinkProps { /** * Link component that renders clickable hyperlinks. * Clicking anywhere on the link text opens the URL in the default browser. + * Uses a full-width box wrapper to ensure the entire area is clickable even when text wraps. */ export function Link(props: LinkProps) { const displayText = props.children ?? props.href return ( - { open(props.href).catch(() => {}) }} > - {displayText} - + + {displayText} + + ) } From 4de83a23f7439eb7a03250f39fb3befb5fd39e99 Mon Sep 17 00:00:00 2001 From: brettheap Date: Mon, 29 Dec 2025 23:14:54 -0400 Subject: [PATCH 3/7] Extract and apply consistent model title formatting across all sections --- .../cli/cmd/tui/component/dialog-model.tsx | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index bd4a9ad59c1..4d4df523acc 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -15,6 +15,27 @@ export function useConnected() { ) } +/** + * Formats a model title with date suffix and latest indicator + * @param modelID The model ID (e.g., "claude-opus-4-5-20251101") + * @param modelName The model's display name (e.g., "Claude Opus 4.5") + * @returns Formatted title with date suffix or "(latest)" indicator + */ +function formatModelTitle(modelID: string, modelName: string): string { + // Extract date suffix from model ID (e.g., "20251101" from "claude-opus-4-5-20251101") + const dateMatch = modelID.match(/-(\d{8})$/) + let title = modelName ?? modelID + // If model has a date suffix and title doesn't already include it, append it + if (dateMatch && !title.includes(dateMatch[1])) { + title = `${title} (${dateMatch[1]})` + } + // If model doesn't have a date suffix and title doesn't say "latest", mark it as latest + else if (!dateMatch && !title.toLowerCase().includes("latest")) { + title = `${title} (latest)` + } + return title +} + export function DialogModel(props: { providerID?: string }) { const local = useLocal() const sync = useSync() @@ -55,7 +76,7 @@ export function DialogModel(props: { providerID?: string }) { providerID: provider.id, modelID: model.id, }, - title: model.name ?? item.modelID, + title: formatModelTitle(model.id, model.name ?? item.modelID), description: provider.name, category: "Favorites", disabled: provider.id === "opencode" && model.id.includes("-nano"), @@ -86,7 +107,7 @@ export function DialogModel(props: { providerID?: string }) { providerID: provider.id, modelID: model.id, }, - title: model.name ?? item.modelID, + title: formatModelTitle(model.id, model.name ?? item.modelID), description: provider.name, category: "Recent", disabled: provider.id === "opencode" && model.id.includes("-nano"), @@ -124,17 +145,7 @@ export function DialogModel(props: { providerID?: string }) { providerID: provider.id, modelID: model, } - // Extract date suffix from model ID (e.g., "20251101" from "claude-opus-4-5-20251101") - const dateMatch = model.match(/-(\d{8})$/) - let title = info.name ?? model - // If model has a date suffix and title doesn't already include it, append it - if (dateMatch && !title.includes(dateMatch[1])) { - title = `${title} (${dateMatch[1]})` - } - // If model doesn't have a date suffix and title doesn't say "latest", mark it as latest - else if (!dateMatch && !title.toLowerCase().includes("latest")) { - title = `${title} (latest)` - } + const title = formatModelTitle(model, info.name ?? model) return { value, title, From 0ed0ed2e475b951d7c33af16f752f8d827dcf773 Mon Sep 17 00:00:00 2001 From: brettheap Date: Mon, 29 Dec 2025 23:15:53 -0400 Subject: [PATCH 4/7] refactor: improve date validation and remove unsafe non-null assertions --- .../cli/cmd/tui/component/dialog-model.tsx | 85 +++++++++++++------ 1 file changed, 60 insertions(+), 25 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index 4d4df523acc..72ca0951b67 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -8,6 +8,47 @@ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" import { Keybind } from "@/util/keybind" import * as fuzzysort from "fuzzysort" +/** + * Validates that an 8-digit string represents a plausible date in YYYYMMDD format. + * Checks that: + * - Year is between 2000-2099 + * - Month is between 01-12 + * - Day is between 01-31 and is valid for the given month/year + */ +function isPlausibleDate(dateStr: string): boolean { + if (dateStr.length !== 8 || !/^\d{8}$/.test(dateStr)) { + return false + } + + const year = parseInt(dateStr.substring(0, 4), 10) + const month = parseInt(dateStr.substring(4, 6), 10) + const day = parseInt(dateStr.substring(6, 8), 10) + + // Validate year range (2000-2099) + if (year < 2000 || year > 2099) { + return false + } + + // Validate month range (01-12) + if (month < 1 || month > 12) { + return false + } + + // Validate day range (01-31) + if (day < 1 || day > 31) { + return false + } + + // Use Date constructor to validate the actual date existence + // (e.g., February 30th would be invalid) + const date = new Date(year, month - 1, day) + if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) { + return false + } + + return true +} + export function useConnected() { const sync = useSync() return createMemo(() => @@ -15,27 +56,6 @@ export function useConnected() { ) } -/** - * Formats a model title with date suffix and latest indicator - * @param modelID The model ID (e.g., "claude-opus-4-5-20251101") - * @param modelName The model's display name (e.g., "Claude Opus 4.5") - * @returns Formatted title with date suffix or "(latest)" indicator - */ -function formatModelTitle(modelID: string, modelName: string): string { - // Extract date suffix from model ID (e.g., "20251101" from "claude-opus-4-5-20251101") - const dateMatch = modelID.match(/-(\d{8})$/) - let title = modelName ?? modelID - // If model has a date suffix and title doesn't already include it, append it - if (dateMatch && !title.includes(dateMatch[1])) { - title = `${title} (${dateMatch[1]})` - } - // If model doesn't have a date suffix and title doesn't say "latest", mark it as latest - else if (!dateMatch && !title.toLowerCase().includes("latest")) { - title = `${title} (latest)` - } - return title -} - export function DialogModel(props: { providerID?: string }) { const local = useLocal() const sync = useSync() @@ -76,7 +96,7 @@ export function DialogModel(props: { providerID?: string }) { providerID: provider.id, modelID: model.id, }, - title: formatModelTitle(model.id, model.name ?? item.modelID), + title: model.name ?? item.modelID, description: provider.name, category: "Favorites", disabled: provider.id === "opencode" && model.id.includes("-nano"), @@ -107,7 +127,7 @@ export function DialogModel(props: { providerID?: string }) { providerID: provider.id, modelID: model.id, }, - title: formatModelTitle(model.id, model.name ?? item.modelID), + title: model.name ?? item.modelID, description: provider.name, category: "Recent", disabled: provider.id === "opencode" && model.id.includes("-nano"), @@ -139,13 +159,28 @@ export function DialogModel(props: { providerID?: string }) { filter(([_, info]) => info.status !== "deprecated"), filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)), // Filter out dated model versions when "show latest only" is enabled - filter(([model, _]) => !showLatestOnly() || !model.match(/-\d{8}$/)), + filter(([model, _]) => { + if (!showLatestOnly()) return true + const dateMatch = model.match(/-(\d{8})$/) + return !dateMatch || !isPlausibleDate(dateMatch[1]) + }), map(([model, info]) => { const value = { providerID: provider.id, modelID: model, } - const title = formatModelTitle(model, info.name ?? model) + // Extract date suffix from model ID (e.g., "20251101" from "claude-opus-4-5-20251101") + const dateMatch = model.match(/-(\d{8})$/) + const validDateSuffix = dateMatch && isPlausibleDate(dateMatch[1]) ? dateMatch[1] : null + let title = info.name ?? model + // If model has a date suffix and title doesn't already include it, append it + if (validDateSuffix && !title.includes(validDateSuffix)) { + title = `${title} (${validDateSuffix})` + } + // If model doesn't have a date suffix and title doesn't say "latest", mark it as latest + else if (!validDateSuffix && !title.toLowerCase().includes("latest")) { + title = `${title} (latest)` + } return { value, title, From d587f9c028b264a5891f970e9d6ad846c50f2539 Mon Sep 17 00:00:00 2001 From: brettheap Date: Mon, 29 Dec 2025 23:16:27 -0400 Subject: [PATCH 5/7] fix(tui): enable threaded rendering to prevent UI freezing --- packages/opencode/src/cli/cmd/tui/app.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 5214b0c1a9a..b26edcec85f 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -149,6 +149,7 @@ export function tui(input: { url: string; args: Args; onExit?: () => Promise Date: Mon, 29 Dec 2025 23:16:40 -0400 Subject: [PATCH 6/7] docs: add openspec proposal for TUI threaded rendering fix --- .../fix-tui-threaded-rendering/proposal.md | 34 +++++++++++++++++++ .../fix-tui-threaded-rendering/tasks.md | 13 +++++++ 2 files changed, 47 insertions(+) create mode 100644 openspec/changes/fix-tui-threaded-rendering/proposal.md create mode 100644 openspec/changes/fix-tui-threaded-rendering/tasks.md diff --git a/openspec/changes/fix-tui-threaded-rendering/proposal.md b/openspec/changes/fix-tui-threaded-rendering/proposal.md new file mode 100644 index 00000000000..50ffb265d0a --- /dev/null +++ b/openspec/changes/fix-tui-threaded-rendering/proposal.md @@ -0,0 +1,34 @@ +# Change: Enable Threaded Rendering to Fix TUI Freezing + +## Why + +The TUI freezes for 6+ seconds during normal typing, making the application unusable during these periods. Users experience complete input lag where keystrokes are not registered until the freeze ends. + +**Root Cause**: The @opentui native library makes FFI (Foreign Function Interface) calls to render the terminal UI. In Bun, FFI calls are synchronous and block the JavaScript event loop. The native rendering operations were taking ~6.5 seconds and occurring every ~5 seconds, causing periodic complete freezes. + +**Diagnosis Method**: Created a heartbeat-based diagnostic that detected event loop blocks by measuring gaps between expected 100ms intervals. The diagnostic confirmed consistent ~6.5 second blocks. + +## What Changes + +- Enable `useThread: true` in the @opentui render configuration +- This moves native FFI rendering calls to a separate thread, preventing them from blocking the JS event loop + +**Code Change** (1 line): +```typescript +// packages/opencode/src/cli/cmd/tui/app.tsx +render(component, { + targetFps: 60, + gatherStats: false, + exitOnCtrlC: false, + useThread: true, // Enable threaded rendering to avoid blocking the JS event loop + useKittyKeyboard: {}, + // ... +}) +``` + +## Impact + +- **Affected specs**: None (bug fix restoring expected behavior - TUI should not freeze) +- **Affected code**: `packages/opencode/src/cli/cmd/tui/app.tsx:152` +- **Risk**: Low - the `useThread` option is a supported @opentui feature +- **Testing**: Verified with perf diagnostics showing zero `[BLOCKED]` messages after fix diff --git a/openspec/changes/fix-tui-threaded-rendering/tasks.md b/openspec/changes/fix-tui-threaded-rendering/tasks.md new file mode 100644 index 00000000000..01f1c97bc28 --- /dev/null +++ b/openspec/changes/fix-tui-threaded-rendering/tasks.md @@ -0,0 +1,13 @@ +# Tasks: Enable Threaded Rendering + +## 1. Implementation +- [x] 1.1 Add `useThread: true` to render config in `app.tsx` + +## 2. Verification +- [x] 2.1 Run typecheck to ensure no type errors +- [x] 2.2 Test TUI with normal typing - confirm no freezing +- [x] 2.3 Verify with perf diagnostics (OPENCODE_PERF_DEBUG=1) - confirm zero blocked events + +## 3. Cleanup +- [x] 3.1 Remove diagnostic instrumentation code +- [x] 3.2 Commit and push changes From 8308e36dc0cb633aa58f2d23b067970789e10e63 Mon Sep 17 00:00:00 2001 From: brettheap Date: Wed, 31 Dec 2025 05:04:30 -0400 Subject: [PATCH 7/7] docs: add openspec proposals and project documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add baseURL validation proposal (fix-provider-baseurl-validation) - Add auth URL clickability proposal (fix-tui-auth-url-clickability) - Add CLAUDE.md project context - Add openspec command definitions πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .opencode/command/openspec-apply.md | 24 + .opencode/command/openspec-archive.md | 27 ++ .opencode/command/openspec-proposal.md | 29 ++ CLAUDE.md | 22 + openspec/AGENTS.md | 456 ++++++++++++++++++ .../proposal.md | 56 +++ .../specs/provider/spec.md | 29 ++ .../fix-provider-baseurl-validation/tasks.md | 18 + .../fix-tui-auth-url-clickability/proposal.md | 171 +++++++ .../specs/tui-auth/spec.md | 56 +++ .../fix-tui-auth-url-clickability/tasks.md | 13 + openspec/project.md | 31 ++ 12 files changed, 932 insertions(+) create mode 100644 .opencode/command/openspec-apply.md create mode 100644 .opencode/command/openspec-archive.md create mode 100644 .opencode/command/openspec-proposal.md create mode 100644 CLAUDE.md create mode 100644 openspec/AGENTS.md create mode 100644 openspec/changes/fix-provider-baseurl-validation/proposal.md create mode 100644 openspec/changes/fix-provider-baseurl-validation/specs/provider/spec.md create mode 100644 openspec/changes/fix-provider-baseurl-validation/tasks.md create mode 100644 openspec/changes/fix-tui-auth-url-clickability/proposal.md create mode 100644 openspec/changes/fix-tui-auth-url-clickability/specs/tui-auth/spec.md create mode 100644 openspec/changes/fix-tui-auth-url-clickability/tasks.md create mode 100644 openspec/project.md diff --git a/.opencode/command/openspec-apply.md b/.opencode/command/openspec-apply.md new file mode 100644 index 00000000000..954b9a761d7 --- /dev/null +++ b/.opencode/command/openspec-apply.md @@ -0,0 +1,24 @@ +--- +description: Implement an approved OpenSpec change and keep tasks in sync. +--- +The user has requested to implement the following change proposal. Find the change proposal and follow the instructions below. If you're not sure or if ambiguous, ask for clarification from the user. + + $ARGUMENTS + + +**Guardrails** +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directoryβ€”run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. + +**Steps** +Track these steps as TODOs and complete them one by one. +1. Read `changes//proposal.md`, `design.md` (if present), and `tasks.md` to confirm scope and acceptance criteria. +2. Work through tasks sequentially, keeping edits minimal and focused on the requested change. +3. Confirm completion before updating statusesβ€”make sure every item in `tasks.md` is finished. +4. Update the checklist after all work is done so each task is marked `- [x]` and reflects reality. +5. Reference `openspec list` or `openspec show ` when additional context is required. + +**Reference** +- Use `openspec show --json --deltas-only` if you need additional context from the proposal while implementing. + diff --git a/.opencode/command/openspec-archive.md b/.opencode/command/openspec-archive.md new file mode 100644 index 00000000000..d172e448321 --- /dev/null +++ b/.opencode/command/openspec-archive.md @@ -0,0 +1,27 @@ +--- +description: Archive a deployed OpenSpec change and update specs. +--- + + $ARGUMENTS + + +**Guardrails** +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directoryβ€”run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. + +**Steps** +1. Determine the change ID to archive: + - If this prompt already includes a specific change ID (for example inside a `` block populated by slash-command arguments), use that value after trimming whitespace. + - If the conversation references a change loosely (for example by title or summary), run `openspec list` to surface likely IDs, share the relevant candidates, and confirm which one the user intends. + - Otherwise, review the conversation, run `openspec list`, and ask the user which change to archive; wait for a confirmed change ID before proceeding. + - If you still cannot identify a single change ID, stop and tell the user you cannot archive anything yet. +2. Validate the change ID by running `openspec list` (or `openspec show `) and stop if the change is missing, already archived, or otherwise not ready to archive. +3. Run `openspec archive --yes` so the CLI moves the change and applies spec updates without prompts (use `--skip-specs` only for tooling-only work). +4. Review the command output to confirm the target specs were updated and the change landed in `changes/archive/`. +5. Validate with `openspec validate --strict` and inspect with `openspec show ` if anything looks off. + +**Reference** +- Use `openspec list` to confirm change IDs before archiving. +- Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off. + diff --git a/.opencode/command/openspec-proposal.md b/.opencode/command/openspec-proposal.md new file mode 100644 index 00000000000..0de9bc1303f --- /dev/null +++ b/.opencode/command/openspec-proposal.md @@ -0,0 +1,29 @@ +--- +description: Scaffold a new OpenSpec change and validate strictly. +--- +The user has requested the following change proposal. Use the openspec instructions to create their change proposal. + + $ARGUMENTS + + +**Guardrails** +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directoryβ€”run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. +- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files. +- Do not write any code during the proposal stage. Only create design documents (proposal.md, tasks.md, design.md, and spec deltas). Implementation happens in the apply stage after approval. + +**Steps** +1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (e.g., via `rg`/`ls`) to ground the proposal in current behaviour; note any gaps that require clarification. +2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, and `design.md` (when needed) under `openspec/changes//`. +3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing. +4. Capture architectural reasoning in `design.md` when the solution spans multiple systems, introduces new patterns, or demands trade-off discussion before committing to specs. +5. Draft spec deltas in `changes//specs//spec.md` (one folder per capability) using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement and cross-reference related capabilities when relevant. +6. Draft `tasks.md` as an ordered list of small, verifiable work items that deliver user-visible progress, include validation (tests, tooling), and highlight dependencies or parallelizable work. +7. Validate with `openspec validate --strict` and resolve every issue before sharing the proposal. + +**Reference** +- Use `openspec show --json --deltas-only` or `openspec show --type spec` to inspect details when validation fails. +- Search existing requirements with `rg -n "Requirement:|Scenario:" openspec/specs` before writing new ones. +- Explore the codebase with `rg `, `ls`, or direct file reads so proposals align with current implementation realities. + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..a02d06e802a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,22 @@ + +# OpenSpec Instructions + +These instructions are for AI assistants working in this project. + +Always open `@/openspec/AGENTS.md` when the request: +- Mentions planning or proposals (words like proposal, spec, change, plan) +- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work +- Sounds ambiguous and you need the authoritative spec before coding + +Use `@/openspec/AGENTS.md` to learn: +- How to create and apply change proposals +- Spec format and conventions +- Project structure and guidelines + +Keep this managed block so 'openspec update' can refresh the instructions. + + + +# WSL Environment Rules + +- When accessing Windows files from WSL, use `/mnt/c/` paths (e.g., `/mnt/c/Users/brett/...` instead of `C:\Users\brett\...`) \ No newline at end of file diff --git a/openspec/AGENTS.md b/openspec/AGENTS.md new file mode 100644 index 00000000000..96ab0bb3901 --- /dev/null +++ b/openspec/AGENTS.md @@ -0,0 +1,456 @@ +# OpenSpec Instructions + +Instructions for AI coding assistants using OpenSpec for spec-driven development. + +## TL;DR Quick Checklist + +- Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search) +- Decide scope: new capability vs modify existing capability +- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`) +- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability +- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement +- Validate: `openspec validate [change-id] --strict` and fix issues +- Request approval: Do not start implementation until proposal is approved + +## Three-Stage Workflow + +### Stage 1: Creating Changes +Create proposal when you need to: +- Add features or functionality +- Make breaking changes (API, schema) +- Change architecture or patterns +- Optimize performance (changes behavior) +- Update security patterns + +Triggers (examples): +- "Help me create a change proposal" +- "Help me plan a change" +- "Help me create a proposal" +- "I want to create a spec proposal" +- "I want to create a spec" + +Loose matching guidance: +- Contains one of: `proposal`, `change`, `spec` +- With one of: `create`, `plan`, `make`, `start`, `help` + +Skip proposal for: +- Bug fixes (restore intended behavior) +- Typos, formatting, comments +- Dependency updates (non-breaking) +- Configuration changes +- Tests for existing behavior + +**Workflow** +1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context. +2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes//`. +3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement. +4. Run `openspec validate --strict` and resolve any issues before sharing the proposal. + +### Stage 2: Implementing Changes +Track these steps as TODOs and complete them one by one. +1. **Read proposal.md** - Understand what's being built +2. **Read design.md** (if exists) - Review technical decisions +3. **Read tasks.md** - Get implementation checklist +4. **Implement tasks sequentially** - Complete in order +5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses +6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality +7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved + +### Stage 3: Archiving Changes +After deployment, create separate PR to: +- Move `changes/[name]/` β†’ `changes/archive/YYYY-MM-DD-[name]/` +- Update `specs/` if capabilities changed +- Use `openspec archive --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly) +- Run `openspec validate --strict` to confirm the archived change passes checks + +## Before Any Task + +**Context Checklist:** +- [ ] Read relevant specs in `specs/[capability]/spec.md` +- [ ] Check pending changes in `changes/` for conflicts +- [ ] Read `openspec/project.md` for conventions +- [ ] Run `openspec list` to see active changes +- [ ] Run `openspec list --specs` to see existing capabilities + +**Before Creating Specs:** +- Always check if capability already exists +- Prefer modifying existing specs over creating duplicates +- Use `openspec show [spec]` to review current state +- If request is ambiguous, ask 1–2 clarifying questions before scaffolding + +### Search Guidance +- Enumerate specs: `openspec spec list --long` (or `--json` for scripts) +- Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available) +- Show details: + - Spec: `openspec show --type spec` (use `--json` for filters) + - Change: `openspec show --json --deltas-only` +- Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs` + +## Quick Start + +### CLI Commands + +```bash +# Essential commands +openspec list # List active changes +openspec list --specs # List specifications +openspec show [item] # Display change or spec +openspec validate [item] # Validate changes or specs +openspec archive [--yes|-y] # Archive after deployment (add --yes for non-interactive runs) + +# Project management +openspec init [path] # Initialize OpenSpec +openspec update [path] # Update instruction files + +# Interactive mode +openspec show # Prompts for selection +openspec validate # Bulk validation mode + +# Debugging +openspec show [change] --json --deltas-only +openspec validate [change] --strict +``` + +### Command Flags + +- `--json` - Machine-readable output +- `--type change|spec` - Disambiguate items +- `--strict` - Comprehensive validation +- `--no-interactive` - Disable prompts +- `--skip-specs` - Archive without spec updates +- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive) + +## Directory Structure + +``` +openspec/ +β”œβ”€β”€ project.md # Project conventions +β”œβ”€β”€ specs/ # Current truth - what IS built +β”‚ └── [capability]/ # Single focused capability +β”‚ β”œβ”€β”€ spec.md # Requirements and scenarios +β”‚ └── design.md # Technical patterns +β”œβ”€β”€ changes/ # Proposals - what SHOULD change +β”‚ β”œβ”€β”€ [change-name]/ +β”‚ β”‚ β”œβ”€β”€ proposal.md # Why, what, impact +β”‚ β”‚ β”œβ”€β”€ tasks.md # Implementation checklist +β”‚ β”‚ β”œβ”€β”€ design.md # Technical decisions (optional; see criteria) +β”‚ β”‚ └── specs/ # Delta changes +β”‚ β”‚ └── [capability]/ +β”‚ β”‚ └── spec.md # ADDED/MODIFIED/REMOVED +β”‚ └── archive/ # Completed changes +``` + +## Creating Change Proposals + +### Decision Tree + +``` +New request? +β”œβ”€ Bug fix restoring spec behavior? β†’ Fix directly +β”œβ”€ Typo/format/comment? β†’ Fix directly +β”œβ”€ New feature/capability? β†’ Create proposal +β”œβ”€ Breaking change? β†’ Create proposal +β”œβ”€ Architecture change? β†’ Create proposal +└─ Unclear? β†’ Create proposal (safer) +``` + +### Proposal Structure + +1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique) + +2. **Write proposal.md:** +```markdown +# Change: [Brief description of change] + +## Why +[1-2 sentences on problem/opportunity] + +## What Changes +- [Bullet list of changes] +- [Mark breaking changes with **BREAKING**] + +## Impact +- Affected specs: [list capabilities] +- Affected code: [key files/systems] +``` + +3. **Create spec deltas:** `specs/[capability]/spec.md` +```markdown +## ADDED Requirements +### Requirement: New Feature +The system SHALL provide... + +#### Scenario: Success case +- **WHEN** user performs action +- **THEN** expected result + +## MODIFIED Requirements +### Requirement: Existing Feature +[Complete modified requirement] + +## REMOVED Requirements +### Requirement: Old Feature +**Reason**: [Why removing] +**Migration**: [How to handle] +``` +If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs//spec.md`β€”one per capability. + +4. **Create tasks.md:** +```markdown +## 1. Implementation +- [ ] 1.1 Create database schema +- [ ] 1.2 Implement API endpoint +- [ ] 1.3 Add frontend component +- [ ] 1.4 Write tests +``` + +5. **Create design.md when needed:** +Create `design.md` if any of the following apply; otherwise omit it: +- Cross-cutting change (multiple services/modules) or a new architectural pattern +- New external dependency or significant data model changes +- Security, performance, or migration complexity +- Ambiguity that benefits from technical decisions before coding + +Minimal `design.md` skeleton: +```markdown +## Context +[Background, constraints, stakeholders] + +## Goals / Non-Goals +- Goals: [...] +- Non-Goals: [...] + +## Decisions +- Decision: [What and why] +- Alternatives considered: [Options + rationale] + +## Risks / Trade-offs +- [Risk] β†’ Mitigation + +## Migration Plan +[Steps, rollback] + +## Open Questions +- [...] +``` + +## Spec File Format + +### Critical: Scenario Formatting + +**CORRECT** (use #### headers): +```markdown +#### Scenario: User login success +- **WHEN** valid credentials provided +- **THEN** return JWT token +``` + +**WRONG** (don't use bullets or bold): +```markdown +- **Scenario: User login** ❌ +**Scenario**: User login ❌ +### Scenario: User login ❌ +``` + +Every requirement MUST have at least one scenario. + +### Requirement Wording +- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative) + +### Delta Operations + +- `## ADDED Requirements` - New capabilities +- `## MODIFIED Requirements` - Changed behavior +- `## REMOVED Requirements` - Deprecated features +- `## RENAMED Requirements` - Name changes + +Headers matched with `trim(header)` - whitespace ignored. + +#### When to use ADDED vs MODIFIED +- ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement. +- MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details. +- RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name. + +Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you aren’t explicitly changing the existing requirement, add a new requirement under ADDED instead. + +Authoring a MODIFIED requirement correctly: +1) Locate the existing requirement in `openspec/specs//spec.md`. +2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios). +3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior. +4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`. + +Example for RENAMED: +```markdown +## RENAMED Requirements +- FROM: `### Requirement: Login` +- TO: `### Requirement: User Authentication` +``` + +## Troubleshooting + +### Common Errors + +**"Change must have at least one delta"** +- Check `changes/[name]/specs/` exists with .md files +- Verify files have operation prefixes (## ADDED Requirements) + +**"Requirement must have at least one scenario"** +- Check scenarios use `#### Scenario:` format (4 hashtags) +- Don't use bullet points or bold for scenario headers + +**Silent scenario parsing failures** +- Exact format required: `#### Scenario: Name` +- Debug with: `openspec show [change] --json --deltas-only` + +### Validation Tips + +```bash +# Always use strict mode for comprehensive checks +openspec validate [change] --strict + +# Debug delta parsing +openspec show [change] --json | jq '.deltas' + +# Check specific requirement +openspec show [spec] --json -r 1 +``` + +## Happy Path Script + +```bash +# 1) Explore current state +openspec spec list --long +openspec list +# Optional full-text search: +# rg -n "Requirement:|Scenario:" openspec/specs +# rg -n "^#|Requirement:" openspec/changes + +# 2) Choose change id and scaffold +CHANGE=add-two-factor-auth +mkdir -p openspec/changes/$CHANGE/{specs/auth} +printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md +printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md + +# 3) Add deltas (example) +cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF' +## ADDED Requirements +### Requirement: Two-Factor Authentication +Users MUST provide a second factor during login. + +#### Scenario: OTP required +- **WHEN** valid credentials are provided +- **THEN** an OTP challenge is required +EOF + +# 4) Validate +openspec validate $CHANGE --strict +``` + +## Multi-Capability Example + +``` +openspec/changes/add-2fa-notify/ +β”œβ”€β”€ proposal.md +β”œβ”€β”€ tasks.md +└── specs/ + β”œβ”€β”€ auth/ + β”‚ └── spec.md # ADDED: Two-Factor Authentication + └── notifications/ + └── spec.md # ADDED: OTP email notification +``` + +auth/spec.md +```markdown +## ADDED Requirements +### Requirement: Two-Factor Authentication +... +``` + +notifications/spec.md +```markdown +## ADDED Requirements +### Requirement: OTP Email Notification +... +``` + +## Best Practices + +### Simplicity First +- Default to <100 lines of new code +- Single-file implementations until proven insufficient +- Avoid frameworks without clear justification +- Choose boring, proven patterns + +### Complexity Triggers +Only add complexity with: +- Performance data showing current solution too slow +- Concrete scale requirements (>1000 users, >100MB data) +- Multiple proven use cases requiring abstraction + +### Clear References +- Use `file.ts:42` format for code locations +- Reference specs as `specs/auth/spec.md` +- Link related changes and PRs + +### Capability Naming +- Use verb-noun: `user-auth`, `payment-capture` +- Single purpose per capability +- 10-minute understandability rule +- Split if description needs "AND" + +### Change ID Naming +- Use kebab-case, short and descriptive: `add-two-factor-auth` +- Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-` +- Ensure uniqueness; if taken, append `-2`, `-3`, etc. + +## Tool Selection Guide + +| Task | Tool | Why | +|------|------|-----| +| Find files by pattern | Glob | Fast pattern matching | +| Search code content | Grep | Optimized regex search | +| Read specific files | Read | Direct file access | +| Explore unknown scope | Task | Multi-step investigation | + +## Error Recovery + +### Change Conflicts +1. Run `openspec list` to see active changes +2. Check for overlapping specs +3. Coordinate with change owners +4. Consider combining proposals + +### Validation Failures +1. Run with `--strict` flag +2. Check JSON output for details +3. Verify spec file format +4. Ensure scenarios properly formatted + +### Missing Context +1. Read project.md first +2. Check related specs +3. Review recent archives +4. Ask for clarification + +## Quick Reference + +### Stage Indicators +- `changes/` - Proposed, not yet built +- `specs/` - Built and deployed +- `archive/` - Completed changes + +### File Purposes +- `proposal.md` - Why and what +- `tasks.md` - Implementation steps +- `design.md` - Technical decisions +- `spec.md` - Requirements and behavior + +### CLI Essentials +```bash +openspec list # What's in progress? +openspec show [item] # View details +openspec validate --strict # Is it correct? +openspec archive [--yes|-y] # Mark complete (add --yes for automation) +``` + +Remember: Specs are truth. Changes are proposals. Keep them in sync. diff --git a/openspec/changes/fix-provider-baseurl-validation/proposal.md b/openspec/changes/fix-provider-baseurl-validation/proposal.md new file mode 100644 index 00000000000..d387cf09ffe --- /dev/null +++ b/openspec/changes/fix-provider-baseurl-validation/proposal.md @@ -0,0 +1,56 @@ +# Change: Validate provider baseURL format before passing to SDK + +## Why + +When users misconfigure their `opencode.json` with an invalid `api` field (e.g., `"api": "anthropic"` instead of a URL), or when invalid values slip through the options merge chain, the SDK receives a malformed `baseURL`. The `@ai-sdk/*` SDKs use nullish coalescing (`??`) for defaults, which doesn't catch empty strings or non-URL valuesβ€”causing cryptic `ERR_INVALID_URL` errors from `fetch()`. + +## What Changes + +- Add URL format validation before passing `baseURL` to provider SDKs +- Only set `baseURL` if it's a valid URL (starts with `http://` or `https://`) +- Delete invalid `baseURL` values so SDKs fall back to their hardcoded defaults + +## Impact + +- Affected code: `packages/opencode/src/provider/provider.ts` +- Risk: Low - only filters out invalid URLs, cannot break valid configurations +- User benefit: Clear failure mode instead of cryptic fetch errors + +## Root Cause Analysis + +### The Problem + +The `@ai-sdk/anthropic` SDK (and others) construct their baseURL like this: + +```javascript +const baseURL = withoutTrailingSlash(loadOptionalSetting({ + settingValue: options.baseURL, + environmentVariableName: "ANTHROPIC_BASE_URL" +})) ?? "https://api.anthropic.com/v1"; +``` + +The `??` operator only catches `null`/`undefined`. If `baseURL` is: +- Empty string `""` +- Invalid value like `"anthropic"` + +...it bypasses the default and causes `fetch()` to fail with `ERR_INVALID_URL`. + +### How Invalid Values Reach the SDK + +1. **User config misconfiguration**: `"api": "anthropic"` instead of a URL +2. **models.dev API**: Returns `"api": null` for some providers +3. **Options merge chain**: Various sources merged without validation + +### The Fix + +```typescript +const isValidUrl = (url: string | undefined | null): url is string => { + return typeof url === "string" && (url.startsWith("https://") || url.startsWith("https://")) +} +if (!isValidUrl(options["baseURL"])) { + delete options["baseURL"] + if (isValidUrl(model.api.url)) options["baseURL"] = model.api.url +} +``` + +This ensures only valid URLs reach the SDK; otherwise, the SDK uses its default. diff --git a/openspec/changes/fix-provider-baseurl-validation/specs/provider/spec.md b/openspec/changes/fix-provider-baseurl-validation/specs/provider/spec.md new file mode 100644 index 00000000000..9cb63436d8f --- /dev/null +++ b/openspec/changes/fix-provider-baseurl-validation/specs/provider/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: Provider baseURL Validation + +The system SHALL validate that `baseURL` values passed to provider SDKs are valid URLs (starting with `http://` or `https://`). Invalid or empty values SHALL be removed so the SDK can use its default endpoint. + +#### Scenario: User misconfigures api field with provider name + +- **WHEN** user sets `"api": "anthropic"` in config instead of a URL +- **THEN** the invalid value is filtered out +- **AND** the SDK uses its default baseURL (`https://api.anthropic.com/v1`) + +#### Scenario: baseURL is empty string + +- **WHEN** baseURL is set to empty string `""` +- **THEN** the empty value is deleted from options +- **AND** the SDK uses its default baseURL + +#### Scenario: baseURL is null from models.dev + +- **WHEN** models.dev API returns `"api": null` for a provider +- **THEN** the null value does not override SDK defaults +- **AND** the SDK uses its hardcoded default baseURL + +#### Scenario: Valid baseURL is preserved + +- **WHEN** baseURL is a valid URL like `"https://custom-proxy.example.com/v1"` +- **THEN** the URL is passed to the SDK unchanged +- **AND** the SDK uses the custom baseURL diff --git a/openspec/changes/fix-provider-baseurl-validation/tasks.md b/openspec/changes/fix-provider-baseurl-validation/tasks.md new file mode 100644 index 00000000000..68f16c2059c --- /dev/null +++ b/openspec/changes/fix-provider-baseurl-validation/tasks.md @@ -0,0 +1,18 @@ +# Tasks + +## 1. Implementation + +- [x] 1.1 Add `isValidUrl` helper function to validate URL format +- [x] 1.2 Replace falsy check with URL format validation +- [x] 1.3 Delete invalid baseURL before conditionally setting from model.api.url + +## 2. Testing + +- [x] 2.1 Verify fix compiles (typecheck passes) +- [x] 2.2 Manual test with Anthropic provider +- [x] 2.3 Confirm SDK uses default URL when invalid baseURL is filtered + +## 3. Documentation + +- [x] 3.1 Create openspec proposal documenting the issue +- [ ] 3.2 Submit PR with clear description diff --git a/openspec/changes/fix-tui-auth-url-clickability/proposal.md b/openspec/changes/fix-tui-auth-url-clickability/proposal.md new file mode 100644 index 00000000000..1632423df69 --- /dev/null +++ b/openspec/changes/fix-tui-auth-url-clickability/proposal.md @@ -0,0 +1,171 @@ +# Fix TUI Auth URL Clickability + +## Problem Statement + +When using `/connect` in the OpenCode TUI and selecting Anthropic OAuth authentication, the authorization URL is displayed wrapped across multiple lines due to the narrow dialog width. When users click on the URL, only the first line is recognized as a clickable link by the terminal emulator, resulting in an incomplete/invalid URL being opened in the browser. + +### Current Behavior + +``` +https://claude.ai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed- +5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fsummer-heart-0930.chufeiyun1688.workers.dev%3A443%2Fhttps%2Fconsole.anthropic.com%2Foauth%2Fcod +e%2Fcallback&scope=org%3Acreate_api_key+user%3Aprofile+user%3Ainference&code_challenge=... +``` + +Clicking opens only: `https://claude.ai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-` (truncated at first line break) + +### Expected Behavior + +The entire URL should be clickable as a single hyperlink, OR alternative methods should be provided to access the full URL (copy to clipboard, auto-open browser). + +## Root Cause + +In `packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx`, the URL is rendered as plain text: + +```tsx +// In CodeMethod component (~line 136-139) +{props.authorization.instructions} +{props.authorization.url} + +// In AutoMethod component (~line 114-116) +{props.authorization.url} +{props.authorization.instructions} +``` + +The `` component renders plain colored text without terminal hyperlink escape sequences (OSC 8). + +## Proposed Solution + +### Option A: OSC 8 Hyperlinks (Recommended) + +Use OSC 8 terminal escape sequences to make the entire URL a single clickable hyperlink regardless of visual line wrapping. + +**OSC 8 Format:** +``` +\x1b]8;;URL\x1b\\DISPLAY_TEXT\x1b]8;;\x1b\\ +``` + +**Implementation:** + +1. Create a `Link` component or utility in the TUI framework: + +```tsx +// packages/opencode/src/cli/cmd/tui/ui/link.tsx +import { TextAttributes } from "@opentui/core" + +interface LinkProps { + href: string + children?: string + fg?: string +} + +export function Link(props: LinkProps) { + // OSC 8 hyperlink: \x1b]8;;URL\x07 TEXT \x1b]8;;\x07 + const linkStart = `\x1b]8;;${props.href}\x07` + const linkEnd = `\x1b]8;;\x07` + const displayText = props.children || props.href + + return ( + + {linkStart}{displayText}{linkEnd} + + ) +} +``` + +2. Update `dialog-provider.tsx` to use the Link component: + +```tsx +// In CodeMethod + + {props.authorization.url} + + +// In AutoMethod + + {props.authorization.url} + +``` + +### Option B: Copy to Clipboard Button + +Add a keyboard shortcut or button to copy the URL to clipboard. + +**Implementation:** + +1. Add clipboard functionality to the dialog +2. Show instruction like "Press 'c' to copy URL to clipboard" +3. Use `navigator.clipboard` or spawn `xclip`/`pbcopy` command + +### Option C: Auto-Open Browser + +Automatically open the URL in the default browser instead of displaying it. + +**Implementation:** + +```typescript +import { spawn } from "child_process" + +function openInBrowser(url: string) { + const platform = process.platform + const cmd = platform === "darwin" ? "open" + : platform === "win32" ? "start" + : "xdg-open" + spawn(cmd, [url], { detached: true, stdio: "ignore" }) +} +``` + +### Option D: Combination Approach (Best UX) + +Combine options: +1. Use OSC 8 for clickable URL +2. Auto-open browser when authorization starts +3. Show "Press 'c' to copy URL" as fallback + +## Files to Modify + +| File | Changes | +|------|---------| +| `packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx` | Update URL rendering in `CodeMethod` and `AutoMethod` components | +| `packages/opencode/src/cli/cmd/tui/ui/link.tsx` | NEW: Create Link component with OSC 8 support | +| `packages/opencode/src/cli/cmd/tui/ui/index.ts` | Export new Link component | + +## Implementation Steps + +1. [ ] Fork/clone the OpenCode repository: `git clone https://github.com/sst/opencode.git` +2. [ ] Create new `Link` component with OSC 8 hyperlink support +3. [ ] Update `CodeMethod` component to use `Link` for URL display +4. [ ] Update `AutoMethod` component to use `Link` for URL display +5. [ ] Test in various terminal emulators (Wave, iTerm2, Windows Terminal, etc.) +6. [ ] Consider adding clipboard copy fallback for terminals without OSC 8 support +7. [ ] Submit PR to sst/opencode + +## Testing + +1. Run `opencode` TUI +2. Use `/connect` or `Ctrl+P` β†’ "Connect provider" +3. Select "Anthropic" β†’ "Claude Pro/Max" +4. Verify the URL is fully clickable +5. Verify clicking opens the complete URL in browser + +### Terminal Compatibility + +OSC 8 hyperlinks are supported by: +- iTerm2 +- Windows Terminal +- GNOME Terminal (3.26+) +- Konsole +- Alacritty +- Kitty +- WezTerm +- Wave Terminal + +## References + +- [OSC 8 Hyperlinks Spec](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda) +- [OpenCode Repository](https://github.com/sst/opencode) +- [OpenCode TUI dialog-provider.tsx](https://github.com/sst/opencode/blob/main/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx) + +## Priority + +**High** - This bug prevents users from completing OAuth authentication flow in the TUI, forcing them to manually copy/reconstruct the URL or use the CLI instead. diff --git a/openspec/changes/fix-tui-auth-url-clickability/specs/tui-auth/spec.md b/openspec/changes/fix-tui-auth-url-clickability/specs/tui-auth/spec.md new file mode 100644 index 00000000000..6e153073cbb --- /dev/null +++ b/openspec/changes/fix-tui-auth-url-clickability/specs/tui-auth/spec.md @@ -0,0 +1,56 @@ +# TUI Authentication URL Display + +## ADDED Requirements + +### Requirement: Clickable Auth URLs + +The TUI MUST render OAuth authorization URLs as clickable elements so that clicking anywhere on the URL text opens it in the default browser, regardless of visual line wrapping. + +#### Scenario: User clicks wrapped OAuth URL + +**Given** the user is in the TUI auth dialog for Anthropic Claude Pro/Max +**And** the OAuth authorization URL is longer than the dialog width +**And** the URL wraps across multiple lines visually +**When** the user clicks on any part of the displayed URL +**Then** the entire URL MUST be opened in the default browser +**And** the URL MUST be complete and valid (not truncated at line breaks) + +### Requirement: Link Component for TUI + +A new `Link` component MUST be created in the TUI UI library that: +- Accepts `href` (URL) and optional display text via `children` prop +- Supports foreground color styling via `fg` prop +- Opens the URL in the default browser when clicked (using `onMouseUp` handler) +- Works in all terminal emulators (not dependent on OSC 8 support) + +#### Scenario: Link component renders clickable hyperlink + +**Given** a `Link` component with `href="https://example.com"` and `children="Click here"` +**When** the component is rendered in the terminal +**And** the user clicks on the text +**Then** the URL "https://example.com" MUST be opened in the default browser +**And** the text "Click here" MUST be displayed with the specified foreground color + +## MODIFIED Requirements + +### Requirement: Update CodeMethod URL Display + +The `CodeMethod` component in `dialog-provider.tsx` MUST use the new `Link` component instead of plain `` for displaying the authorization URL. + +#### Scenario: CodeMethod displays clickable URL + +**Given** the user selects "Claude Pro/Max" OAuth authentication +**When** the CodeMethod dialog is displayed +**Then** the authorization URL should be rendered using the `Link` component +**And** the URL should be fully clickable regardless of line wrapping + +### Requirement: Update AutoMethod URL Display + +The `AutoMethod` component in `dialog-provider.tsx` MUST use the new `Link` component instead of plain `` for displaying the authorization URL. + +#### Scenario: AutoMethod displays clickable URL + +**Given** the user selects an OAuth method with auto-callback +**When** the AutoMethod dialog is displayed +**Then** the authorization URL should be rendered using the `Link` component +**And** the URL should be fully clickable regardless of line wrapping diff --git a/openspec/changes/fix-tui-auth-url-clickability/tasks.md b/openspec/changes/fix-tui-auth-url-clickability/tasks.md new file mode 100644 index 00000000000..a2e1f5ee67b --- /dev/null +++ b/openspec/changes/fix-tui-auth-url-clickability/tasks.md @@ -0,0 +1,13 @@ +# Implementation Tasks + +## Tasks + +- [x] Create `Link` component with OSC 8 hyperlink support in `packages/opencode/src/cli/cmd/tui/ui/link.tsx` +- [x] Export `Link` component from UI index (if applicable) - N/A, direct import used +- [x] Update `CodeMethod` component in `dialog-provider.tsx` to use `Link` for URL display +- [x] Update `AutoMethod` component in `dialog-provider.tsx` to use `Link` for URL display +- [x] Test in Wave Terminal - verify full URL is clickable +- [ ] Test in other terminal emulators (iTerm2, Windows Terminal, etc.) +- [x] Consider adding clipboard copy fallback for terminals without OSC 8 support - N/A, used click handler approach instead +- [x] Build and verify no TypeScript errors +- [ ] Submit PR to sst/opencode diff --git a/openspec/project.md b/openspec/project.md new file mode 100644 index 00000000000..3da5119d0ac --- /dev/null +++ b/openspec/project.md @@ -0,0 +1,31 @@ +# Project Context + +## Purpose +[Describe your project's purpose and goals] + +## Tech Stack +- [List your primary technologies] +- [e.g., TypeScript, React, Node.js] + +## Project Conventions + +### Code Style +[Describe your code style preferences, formatting rules, and naming conventions] + +### Architecture Patterns +[Document your architectural decisions and patterns] + +### Testing Strategy +[Explain your testing approach and requirements] + +### Git Workflow +[Describe your branching strategy and commit conventions] + +## Domain Context +[Add domain-specific knowledge that AI assistants need to understand] + +## Important Constraints +[List any technical, business, or regulatory constraints] + +## External Dependencies +[Document key external services, APIs, or systems]