diff --git a/DIVERGENCES.md b/DIVERGENCES.md
index 131f7f4b41..4d9aa7f973 100644
--- a/DIVERGENCES.md
+++ b/DIVERGENCES.md
@@ -199,7 +199,7 @@ from the squash message first, or use a plain merge.
| `packages/ui/src/context/dialog.tsx` | Per-dialog `persistent` flag (precedent: the TUI's persistent dialog, see the `packages/tui/src/ui/dialog.tsx` row): Escape, backdrop click, and Kobalte `onOpenChange` are no-ops for a dialog marked persistent; programmatic `close()` still works; `setPersistent` targets the top dialog so pushed sub-dialogs stay dismissable; `useDialog()` gains `persistent`/`setPersistent`. | The desktop configure gate needs a dialog the user cannot dismiss while no provider is configured. | Keep the flag and the three guards; take upstream's surrounding dialog changes. |
| `packages/app/src/app.tsx` | `` (fork-only `components/gateway/`) mounted as the first child inside `ServerScopedProviders`' `ModelsProvider`. | The desktop configure gate; the placement is load-bearing — completing the flow seeds the live `useModels()` recents store (`buildGatewayConfig` deliberately writes no `config.model` pin). | Keep the mount inside `ModelsProvider`; take upstream's surrounding shell changes. |
| `packages/app/src/context/platform.tsx` | Added optional `gatewaySso?: GatewaySsoPlatform` capability (wire types in fork-only `src/gateway/types.ts`, exported as `@opencode-ai/app/gateway/types` — see the `packages/app/package.json` row). | Desktop-only SSO login for the configure gate; absence of the capability is the web deployment's kill-switch. | Keep the field + import; take upstream's surrounding platform changes. |
-| `packages/app/src/components/prompt-input-v2.tsx`, `packages/app/src/components/prompt-input.tsx` | `onUnpaidClick` opens `DialogConnectProvider` (lazy import) instead of the free-models "unpaid" dialogs; their now-unused imports dropped. | With the hosted providers hidden, "unpaid" means nothing is connected, and the free-models dialogs would render empty lists (this path is reachable on the web deployment; on desktop the gate covers it). | Keep the handler swap; take upstream's surrounding prompt-input changes. |
+| `packages/app/src/components/prompt-input-v2.tsx` | `onUnpaidClick` opens `DialogConnectProvider` (lazy import) instead of the free-models "unpaid" dialogs; their now-unused imports dropped. (The legacy `prompt-input.tsx` carried the same swap until its deletion — see the legacy prompt-input removal row.) | With the hosted providers hidden, "unpaid" means nothing is connected, and the free-models dialogs would render empty lists (this path is reachable on the web deployment; on desktop the gate covers it). | Keep the handler swap; take upstream's surrounding prompt-input changes. |
| `packages/desktop/src/renderer/index.tsx` | Calls `installConsoleFormatting()` (fork-only `./console-format`) before anything else logs. | `spyRendererConsole` receives Chromium's flattened console-message string, so any renderer `console.*` with an object argument reached the terminal and the exported debug logs as `[object Object]`; the wrapper serializes structured arguments (Error-aware, circular-safe, truncated) at the source. | Keep the import and call; take upstream's surrounding entry changes. |
| Desktop gateway SSO wiring: `packages/desktop/src/main/index.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx` | Main instantiates the fork-only `src/main/gateway-sso/` controller (wrapping `@codev/gateway` `login`/`silentSso` — node-only loopback OAuth — with `shell.openExternal` and a `net.fetch` wrapper) and registers its `gateway-sso-*` IPC channels next to the sandbox ones, passing an `onConfigured` callback that restarts the running sandboxes (`SandboxController.restartRunning`) because a guest is handed its codev config once at boot and cannot see credentials that appear afterwards; preload exposes the `gatewaySso` namespace (subscribe fan-out, sandbox pattern) with types re-exported from `@opencode-ai/app/gateway/types`; the renderer platform maps `gatewaySso: window.api.gatewaySso`. | SSO login for the desktop configure gate must run in the main process (`node:http` loopback server, `~/.codev/auth.json` writes); `net.fetch` so enterprise proxies/PAC apply (egress precedent). | Keep the wiring; take upstream's surrounding changes. Keep channel names `gateway-sso-*` stable. |
| `packages/app/package.json` | Added `"./gateway/types": "./src/gateway/types.ts"` to `exports`. | The desktop preload aliases the app-owned gateway SSO wire types (sandbox/types pattern). | Keep the export; take upstream's other manifest changes. |
@@ -266,7 +266,7 @@ from the squash message first, or use a plain merge.
| Env-var rename, repo-wide (~230 files): both flag registries (`packages/core/src/flag/flag.ts`, `packages/opencode/src/effect/runtime-flags.ts`), every `process.env`/`Bun.env` read+set site across `packages/*`, `sdks/vscode`, `infra/enterprise.ts`, `nix/opencode.nix`, `turbo.json`, `.github/workflows/test.yml`, test preloads/fixtures/snapshots, docs (en + es), `SECURITY.md`, specs | `OPENCODE_*` env vars → `CODEV_*` (hard cutover, no legacy fallback), including the bundler define identifiers (`CODEV_VERSION`, `CODEV_CHANNEL`, `CODEV_MODELS_DEV`, `CODEV_LIBC`, `CODEV_CLI_NAME`, `CODEV_WORKER_PATH`), `VITE_OPENCODE_*` → `VITE_CODEV_*`, `import.meta.env.OPENCODE_CHANNEL` → `CODEV_CHANNEL`, the VSCode extension's `OPENCODE_CALLER`/`_EXTENSION_OPENCODE_PORT`, and the bare `OPENCODE=1` marker → `CODEV=1` | The fork's env surface uses the fork's name (reverses the earlier "internal names stay upstream" decision for env vars). NOT renamed: `__OPENCODE_PHOTON_WASM_PATH` and `__OPENCODE__` globals (not env vars; the photon name is baked into `patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch`), `patches/install-korean-ime-fix.sh` script-local shell vars, upstream secret names in workflow-deletion notes, lowercase identifiers (`@opencode-ai/*`, URLs, provider id) | Keep our names; rename any new upstream `OPENCODE_*` env var (or define/`VITE_` name) to `CODEV_*` when syncing. |
| Locale dicts in `packages/{ui,app}/src/i18n/`, `packages/desktop/src/renderer/i18n/`, `packages/console/app/src/i18n/`, `packages/stats/app/src/i18n/`, `packages/web/src/content/i18n/`; every translated docs dir in `packages/web/src/content/docs/` | Deleted every locale except `en`/`es` (incl. `packages/app/src/i18n/parity.test.ts`) | Fork ships English + Spanish only. | Keep deleted (`git rm` the upstream side of modify/delete conflicts); also delete any locale files upstream adds. |
| `packages/app/src/context/language.tsx`, `packages/app/src/entry.tsx`, `packages/desktop/src/renderer/i18n/index.ts`, `packages/console/app/src/i18n/index.ts`, `packages/console/app/src/lib/language.ts`, `packages/console/app/src/component/footer.tsx`, `packages/stats/app/src/i18n.ts`, `packages/enterprise/src/app.tsx`, `packages/app/e2e/regression/session-timeline-locale-projection.spec.ts` | Locale registries trimmed to `en` + `es`: `Locale` types, LOCALES/LABEL/TAG/DOCS maps, dict loaders, accept-language matchers; enterprise/entry `zh` special cases now use `es`; console footer always links Discord (Feishu was zh-only); e2e locale-projection spec tests `es` ("Explorado") instead of de/ar | Fork ships English + Spanish only. | Keep our trimmed lists; take upstream's structural changes and re-trim any new locale entries. |
-| `packages/tui/src/component/prompt/autocomplete.tsx`, `packages/app/src/components/prompt-input.tsx`, `packages/opencode/src/cli/cmd/run/footer.prompt.tsx`, `packages/core/src/filesystem/search.ts`, `packages/tui/test/fixture/tui-sdk.ts`, `packages/opencode/test/cli/run/footer.view.test.tsx`, `packages/core/test/filesystem/search.test.ts` | `@` mention autocomplete lists file/directory results before references/agents/MCP resources; TUI and run-CLI menus cap the leading files so up to 3 non-file matches stay in the first visible page; web app group rank reordered to recent → file → reference → agent → resource (extracted into fork-only `prompt-input/at-options.ts`). TUI and run-CLI file resources track menu visibility so opening on an empty query fetches (previously nothing refetched until the query changed); core's ripgrep-fallback `find` returns its first entries for an empty query to mirror fff. Tests: tui-sdk fixture's `FetchHandler` gains an optional `method` arg, `renderFooter` accepts `findFiles`/`agents` overrides + a mention-menu ordering test, core search tests cover the fallback's empty query (fork-only test files: `packages/tui/test/prompt-autocomplete.test.tsx`, `packages/app/test-browser/prompt-at-options.test.ts`, `packages/app/src/components/prompt-input/at-options{,.test}.ts`) | Files are the dominant `@` use case; they should rank first and appear on a bare `@`. | Keep our ordering/reserve logic, resource source tracking, empty-query branch, fixture/test additions; take upstream's surrounding changes. |
+| `packages/tui/src/component/prompt/autocomplete.tsx`, `packages/opencode/src/cli/cmd/run/footer.prompt.tsx`, `packages/core/src/filesystem/search.ts`, `packages/tui/test/fixture/tui-sdk.ts`, `packages/opencode/test/cli/run/footer.view.test.tsx`, `packages/core/test/filesystem/search.test.ts` | `@` mention autocomplete lists file/directory results before references/agents/MCP resources; TUI and run-CLI menus cap the leading files so up to 3 non-file matches stay in the first visible page. TUI and run-CLI file resources track menu visibility so opening on an empty query fetches (previously nothing refetched until the query changed); core's ripgrep-fallback `find` returns its first entries for an empty query to mirror fff. Tests: tui-sdk fixture's `FetchHandler` gains an optional `method` arg, `renderFooter` accepts `findFiles`/`agents` overrides + a mention-menu ordering test, core search tests cover the fallback's empty query (fork-only test file: `packages/tui/test/prompt-autocomplete.test.tsx`). The legacy web prompt originally carried the same reorder (fork-only `prompt-input/at-options.ts`); it was deleted with the legacy prompt input (see the removal row below), and the V2 composer's files-first ordering is tracked in its own `session-ui` row (PR #289). | Files are the dominant `@` use case; they should rank first and appear on a bare `@`. | Keep our ordering/reserve logic, resource source tracking, empty-query branch, fixture/test additions; take upstream's surrounding changes. |
| `packages/slack/.gitignore`, `sdks/vscode/.gitignore` | Added the missing trailing newline (content unchanged) | Upstream's copies end without a newline; POSIX text files should end with one, and tools that concatenate or append to these files misbehave without it. | Whitespace-only: keep the trailing newline; take upstream's entries. |
| `packages/tui/src/config/keybind.ts`, `packages/tui/src/component/prompt/index.tsx`, `packages/tui/src/app.tsx` | Permission-mode indicator + shift+tab cycle. keybind.ts: added `permission_mode_cycle: keybind("shift+tab", ...)` + its `permission.mode` CommandMap entry; `agent_cycle_reverse` default `"shift+tab"` → `"none"` (tab still cycles agents forward; previous-agent stays rebindable). prompt/index.tsx: the footer badge next to the agent name always renders in prompt mode — gray `manual` (`theme.textMuted`) in normal mode, red `auto` (`theme.error`) in auto mode (upstream only showed gray `auto`); the footer's shortcut hints gain a `shift+tab modes` text (via a `modeShortcut` `useCommandShortcut("permission.mode")` memo) left of `tab agents`, in the same `Show` gate (see the hints-and-usage row above for when that gate renders). app.tsx: `"permission.mode"` added to `appBindingCommands` so the binding is gathered. | The permission mode is always visible (manual/auto) with auto in red as a danger signal, and shift+tab toggles it without opening the palette. | Keep the `permission_mode_cycle` definition + mapping, the `agent_cycle_reverse` `"none"` default, the badge `Show` block, the `modeShortcut` memo + hint text, and the `appBindingCommands` entry; take upstream's surrounding changes. If upstream reworks the auto badge or rebinds shift+tab, re-apply on top and re-audit the shift+tab conflict. |
| `packages/core/src/filesystem/search.ts`, `packages/core/src/ripgrep/binary.ts`, `packages/core/src/filesystem.ts`, `packages/core/src/tool/read-filesystem.ts`, `packages/core/test/filesystem/search.test.ts`, `packages/core/test/location-filesystem.test.ts`, `packages/core/test/ripgrep-binary.test.ts`, `packages/core/test/preload.ts` | Windows file-search fallback robustness. search.ts: the background ripgrep index walk no longer dies silently (`Effect.orDie` in a forked fiber) — each attempt resets the accumulated file/directory state, failures log a warning ("file index scan failed, file search may be empty"), and the walk retries forever on a 1s exponential backoff capped at 30s spacing, so installing rg mid-session self-heals; directory entries are suffixed and detected with `"/"` instead of `path.sep` (both ripgrep and fff layers), and fallback `find` queries normalize `\` to `/` and collapse doubled slashes (directory tab-expansion appends `/` to an already-`/`-suffixed entry). binary.ts: `Effect.cached` on ripgrep binary resolution (which memoizes failure exits for the process lifetime) replaced with a success-only cache behind a semaphore, so a failed rg download (offline, blocked proxy) retries on the next use instead of bricking search until restart; the download tail tries GitHub first (each attempt bounded to 30s) and falls back to the CoDev landing-page mirror (the base64-encoded `MIRROR` constant plus `/rg---[.exe]`, a raw executable — no archive/extract — staged in a scoped temp dir then copied into place), via `Effect.catchCause` so a proxy block page (200 + HTML) that dies in `extract` as a defect still reaches the mirror. filesystem.ts `list` + read-filesystem.ts `list`: directory suffix `path.sep` → `"/"`; `fs.list` also `/`-normalizes `path.relative` output. Tests: search.test.ts pins `"/"`-suffixed directories, adds query-normalization coverage, and a stub-Ripgrep scan-retry test asserting the partial first walk is discarded; location-filesystem.test.ts pins `"src/"`; ripgrep-binary.test.ts adds mocked-HTTP coverage of the fallback order, success caching, and failure non-caching; preload.ts redirects `XDG_CACHE_HOME` to a fresh temp dir so no test can read or clobber the developer's real `~/.cache/codev`. | On Windows fff is disabled by default (upstream `e4300e9b74`), so the `@` mention popup and file search depend entirely on the ripgrep fallback; a missing `rg.exe` plus one failed download left the index permanently empty with no user-visible signal (the CI-workflows row's choco preinstall papered over the same swallowed failure). `\`-suffixed directory entries also broke `endsWith("/")` directory-mime detection in the run CLI and poisoned drill-down queries with backslashes that can never match the `/`-normalized index. | Keep our retry/reset scan, success-only binary cache, `"/"` suffixes, and query normalization; take upstream's surrounding changes. If upstream reworks the fallback or re-enables fff on win32, preserve the invariants: entry paths are `/`-normalized with `/`-suffixed directories on every platform, index-scan failures are logged and retried, a failed binary download is never cached, and the GitHub download keeps its CoDev-mirror fallback — the mirror filename scheme must match what codev-landing-page hosts under `public/docs/code/downloads` (and codevhub's `RG_VERSION` pin in its `src/lib/ripgrep.ts`); bump all three together when changing `VERSION`. |
@@ -281,6 +281,7 @@ from the squash message first, or use a plain merge.
| Sandbox/WSL delete cleanup + confirmation: `packages/app/src/context/server.tsx`, `packages/app/src/components/dialog-select-server.tsx`, `packages/app/src/i18n/{en,es}.ts` | `server.tsx` gains `forget(key)` beside `remove(key)`: it deletes the persisted per-server `projects`/`lastProject`/`recentlyClosed` map entries for one key. `dialog-select-server.tsx` `handleRemove` (already diverged, see the renderer-slice row) additionally: for `sandbox:` and `wsl:` keys, awaits a confirmation through the fork-only `packages/app/src/sandbox/dialog-delete.tsx` / `packages/app/src/wsl/dialog-delete.tsx` (lazy-imported, pushed with `dialog.push` so it stacks on whatever dialog opened the menu) and returns without touching anything when declined; and calls `server.forget(key)` right after `server.remove(key)` for both key kinds. The controller gains a `useDialog()` binding for this. i18n gains the `sandbox.delete.*` and `wsl.delete.*` families. The two dialogs state different consequences on purpose: deleting a sandbox erases its disk (sessions, terminals, guest files — permanently; mounted host folders survive), while removing a WSL server destroys nothing — the app never unregisters distributions — it only forgets the app-side project state. | `remove()` only edits the stored HTTP server list; the project maps are keyed by server key and outlive the server, which is right for an HTTP server that still exists remotely but wrong for a deleted sandbox or removed distro — a recreated sandbox with the same name inherited the old projects (`server.projects["sandbox:"]`) and, via the leftover `recentlyClosed`, silently refused to seed folders closed in the previous sandbox's life; a re-added distro likewise inherited its previous project list. And deleting a sandbox erases guest data permanently, so it must not be a one-click menu action; the confirmations live in `handleRemove` because the settings rows and the generic server list all funnel through it. | Keep the confirm-and-forget block at the top of `handleRemove` and the `forget` calls between `server.remove(key)` and the awaited platform removals; take upstream's surrounding changes. `forget` is fork-only API — if upstream reworks the persisted `server` record shape, re-point the three deletes at the new maps. Do not gate `forget` behind the platform removal succeeding: the tab/workspace cleanup ahead of it already treats the server as gone. HTTP removals deliberately keep their maps — the server still exists remotely and re-adding it should restore its projects. If the app ever gains real distro unregistration, the WSL dialog's "nothing is deleted" wording must change with it. |
| Vanished-stream retry + visibility + persisted retry parts: `packages/opencode/src/provider/error.ts`, `packages/opencode/src/session/{processor,retry,prompt,message-v2}.ts`, `packages/session-ui/src/components/message-part.tsx`, `packages/tui/src/routes/session/index.tsx`, `packages/ui/src/i18n/{en,es}.ts`, tests `packages/opencode/test/session/{retry,message-v2,processor-effect}.test.ts`, `packages/opencode/test/cli/run/run-process.test.ts` | error.ts: new `ProviderError.StreamVanishedError`. processor.ts: `ProcessorContext.produced` (set on `text-delta`/`tool-input-start`/`tool-call`, reset per attempt); after the stream drains, a turn with no output, no error, and finish `undefined`/`"unknown"` fails with `StreamVanishedError` so the existing `SessionRetry.policy` retries it (visible "retrying… attempt #N" status). retry.ts: `retryable()` gains an optional `attempt` param (policy passes `meta.attempt`); `STREAM_VANISHED_CODE` matches `metadata.code` and stops after `STREAM_VANISHED_MAX_RETRIES` (3) so a deterministic provider quirk cannot re-bill turns forever. message-v2.ts: maps `StreamVanishedError` → `APIError{isRetryable:true, metadata.code}`; new `transportInterruptCode()` walks the cause chain for `ECONNRESET`/`EPIPE`/`ETIMEDOUT`/`UND_ERR_SOCKET` so undici's mid-stream `TypeError("terminated")` (cause `SocketError "other side closed"`) becomes a retryable `APIError` instead of an unretryable `Unknown` (AbortedError when `ctx.aborted`). prompt.ts: the run-loop exit records a visible `APIError` ("the reply may be incomplete") on a message whose finish is `"unknown"` with no error, instead of going idle silently — mirrors upstream's own content-filter precedent. Persisted retry parts: the schema's dormant `RetryPart` (already in `@opencode-ai/schema/session-v1` and the generated SDKs, previously never written or rendered) is now written per retry attempt — `SessionRetry.policy`'s `set` callback gains the parsed `error`, and processor.ts persists `{type:"retry", attempt, error, time}` via `session.updatePart` before publishing the live retry status — and rendered by a new `PART_MAPPING["retry"]` component in session-ui's message-part.tsx (error-card style, message + "retried - attempt #N" line; new i18n key `ui.messagePart.retry.retried` en/es) and by a `PART_MAPPING["retry"]` entry in the TUI's routes/session/index.tsx (a muted one-liner aligned with the transcript — error-colored `Retried` label, `(attempt #N) · `, mirroring the footer's live `Retrying (attempt N) · message` wording; fork-only view test `packages/tui/test/retry-part.view.test.tsx`). Upstream's two `codev run` subprocess tests that pin the old contract ("unknown stream finish preserves partial output and exits 0" — renamed here to "…retries and preserves partial output" — and "--format json records partial output for an unknown stream finish") are updated to the retrying behaviour: each queues an explicit reply after `llm.fail(...)` so the retry consumes it instead of the test server's auto-"ok", stdout carries both turns, and the json test asserts the extra `step_start,text,step_finish` trio with the vanished step still finishing `"unknown"` and the retried one `"stop"`. What they lock in is unchanged: an SSE error item is not fatal — exit stays 0, pre-failure output survives, and the provider message never reaches stderr. | A provider (or corporate proxy) that drops the SSE mid-turn ended the run with no retry, no error card, no banner — the agent "just stopped" and only a user-typed "continue" resumed it. Empty turns are safely retried (nothing to duplicate); turns with output are not rerun but now say why they ended. | Keep our `produced` tracking, the post-drain fail, the classifier cap, the transport-code walk, the loop-exit error, and the two retry-part renderers (session-ui and TUI `PART_MAPPING["retry"]` entries); take upstream's surrounding changes. If upstream ships its own abnormal-finish handling, prefer theirs and drop ours where they overlap. Good upstream-PR candidate. The two `run-process.test.ts` expectations are ours only because the retry is: if upstream's version of that file conflicts, re-apply the queued-reply shape on top of their test body; if their behaviour is adopted instead, revert these two tests to upstream wholesale. |
| `packages/session-ui/src/v2/components/prompt-input/interaction.ts` | The `@` mention popup's `contextList` grouping delegates to the fork-only `at-options.ts` beside it (`atSuggestionCategory` + `compareAtCategories`, unit-tested in the fork-only `at-options.test.ts`): suggestion groups rank recent → file → reference → agent → resource instead of upstream's reference → agent → resource → recent → file, so file results lead in the V2 composer — the prompt the desktop and web apps render now that the legacy layout is retired. The grouped menu scrolls, so no row-reserve logic is needed (unlike the TUI/run-footer variants), and default selection stays index 0, so Enter accepts the top file match. | Files are the dominant `@` use case; the earlier files-first reorder (see the `@` autocomplete row above) covered the TUI, the legacy web prompt, and the `codev run` footer but missed the V2 composer. | Keep the two-line delegation to `at-options.ts`; take upstream's surrounding changes. If upstream restructures the suggestion list, re-apply the ranking wherever context suggestions are grouped. |
+| Legacy prompt input removed: deleted `packages/app/src/components/prompt-input.tsx` and its now-orphaned submodules `packages/app/src/components/prompt-input/{attachments,files,paste,transient-state}.ts`, `{context-items,drag-overlay,image-attachments,slash-popover}.tsx`, `image-attachments.css`, `attachments.test.ts`, `packages/app/test-browser/{prompt-attachments,prompt-transient-state,prompt-at-options}.test.ts` (plus the fork-only `at-options{,.test}.ts`); edits in `packages/app/src/pages/session.tsx`, `packages/app/src/pages/session/composer/{session-composer-region-controller.ts,todo-panel-motion.stories.tsx}` | The fork hardcodes `newLayoutDesigns` to `true` (see the settings.tsx row), so the legacy `PromptInput` — the `fallback` of session.tsx's `` — could never render. session.tsx now creates the V2 controller unconditionally and always mounts `PromptInputV2Composer` (no `Show`); `session-composer-region-controller.ts` imports `PromptInputState` from `prompt-input/contracts` (where it is defined) instead of the deleted barrel; the todo-panel-motion story fills its composer-region `promptInput` slot with a static styled placeholder instead of the legacy component (its unused `controls` mock removed). Kept: `prompt-input/{contracts,history,history-store,placeholder,submit,editor-dom,build-request-parts,submission-state}.ts` and their tests — the V2 composer path still uses them. | Dead code: retired with the legacy layout, kept compiling for months, and PR #56-style fixes had to be applied to it for nothing. | Keep deleted — `git rm` the upstream side of modify/delete conflicts on these files, and delete any new module upstream adds under `prompt-input/` that only the legacy `PromptInput` imports. Take upstream changes to the kept shared modules. If upstream restructures session.tsx's composer block, re-apply "always render the V2 composer" and keep the legacy fallback out; if upstream rewires the story's input slot, keep our placeholder. If the fork ever wants the legacy layout back, restore from upstream, not from history. |
## Known rebrand gaps
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
deleted file mode 100644
index 15229bc472..0000000000
--- a/packages/app/src/components/prompt-input.tsx
+++ /dev/null
@@ -1,1780 +0,0 @@
-import { useFilteredList } from "@opencode-ai/ui/hooks"
-import { useSpring } from "@opencode-ai/ui/motion-spring"
-import {
- createEffect,
- on,
- Component,
- Show,
- onCleanup,
- createMemo,
- createSignal,
- createResource,
- Switch,
- Match,
- type JSX,
-} from "solid-js"
-import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
-import {
- ContentPart,
- DEFAULT_PROMPT,
- isCommentItem,
- isPromptEqual,
- Prompt,
- usePrompt,
- ImageAttachmentPart,
- AgentPart,
- FileAttachmentPart,
-} from "@/context/prompt"
-import { useLayout } from "@/context/layout"
-import { useSDK } from "@/context/sdk"
-import { useSync } from "@/context/sync"
-import { useComments } from "@/context/comments"
-import { Button } from "@opencode-ai/ui/button"
-import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
-import { Icon } from "@opencode-ai/ui/icon"
-import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
-import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
-import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
-import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
-import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
-import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
-import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
-import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
-import { IconButton } from "@opencode-ai/ui/icon-button"
-import { Select } from "@opencode-ai/ui/select"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
-import { useCommand } from "@/context/command"
-import { usePermission } from "@/context/permission"
-import { useLanguage } from "@/context/language"
-import { usePlatform } from "@/context/platform"
-import { createSessionTabs } from "@/pages/session/helpers"
-import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
-import { createPromptAttachments } from "./prompt-input/attachments"
-import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
-import {
- canNavigateHistoryAtCursor,
- navigatePromptHistory,
- type PromptHistoryComment,
- type PromptHistoryEntry,
- promptLength,
-} from "./prompt-input/history"
-import {
- createPersistedPromptInputHistory,
- createPromptInputHistory,
- type PromptInputHistory,
-} from "./prompt-input/history-store"
-import {
- type PromptInputControls,
- type PromptInputProps,
- type PromptInputState,
- type PromptInputSubmission,
-} from "./prompt-input/contracts"
-import { createPromptSubmit } from "./prompt-input/submit"
-import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
-import { atOptionCategory, compareAtCategories } from "./prompt-input/at-options"
-import { PromptContextItems } from "./prompt-input/context-items"
-import { PromptImageAttachments } from "./prompt-input/image-attachments"
-import { PromptDragOverlay } from "./prompt-input/drag-overlay"
-import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/placeholder"
-import { createPromptInputTransientState } from "./prompt-input/transient-state"
-import { showToast } from "@/utils/toast"
-import { ImagePreview } from "@opencode-ai/ui/image-preview"
-import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
-
-export { createPromptInputHistory }
-export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission }
-
-const EXAMPLES = [
- "prompt.example.1",
- "prompt.example.2",
- "prompt.example.3",
- "prompt.example.4",
- "prompt.example.5",
- "prompt.example.6",
- "prompt.example.7",
- "prompt.example.8",
- "prompt.example.9",
- "prompt.example.10",
- "prompt.example.11",
- "prompt.example.12",
- "prompt.example.13",
- "prompt.example.14",
- "prompt.example.15",
- "prompt.example.16",
- "prompt.example.17",
- "prompt.example.18",
- "prompt.example.19",
- "prompt.example.20",
- "prompt.example.21",
- "prompt.example.22",
- "prompt.example.23",
- "prompt.example.24",
- "prompt.example.25",
-] as const
-
-export const PromptInput: Component = (props) => {
- const sdk = useSDK()
-
- const sync = useSync()
- const files = useFile()
- const prompt = props.state ?? usePrompt()
- const layout = useLayout()
- const comments = useComments()
- const dialog = useDialog()
- const command = useCommand()
- const permission = usePermission()
- const language = useLanguage()
- const platform = usePlatform()
- const tabs = () => props.controls.session.tabs
- let editorRef!: HTMLDivElement
- let fileInputRef: HTMLInputElement | undefined
- let scrollRef!: HTMLDivElement
- let slashPopoverRef!: HTMLDivElement
- let restoreEndOnFocus = true
- let savedCursor: number | null = null
-
- const mirror = { input: false }
- const inset = 56
- const space = `${inset}px`
-
- const scrollCursorIntoView = () => {
- const container = scrollRef
- const selection = window.getSelection()
- if (!container || !selection || selection.rangeCount === 0) return
-
- const range = selection.getRangeAt(0)
- if (!editorRef.contains(range.startContainer)) return
-
- const cursor = getCursorPosition(editorRef)
- const length = promptLength(prompt.current().filter((part) => part.type !== "image"))
- if (cursor >= length) {
- container.scrollTop = container.scrollHeight
- return
- }
-
- const rect = range.getClientRects().item(0) ?? range.getBoundingClientRect()
- if (!rect.height) return
-
- const containerRect = container.getBoundingClientRect()
- const top = rect.top - containerRect.top + container.scrollTop
- const bottom = rect.bottom - containerRect.top + container.scrollTop
- const padding = 12
-
- if (top < container.scrollTop + padding) {
- container.scrollTop = Math.max(0, top - padding)
- return
- }
-
- if (bottom > container.scrollTop + container.clientHeight - inset) {
- container.scrollTop = bottom - container.clientHeight + inset
- }
- }
-
- const queueScroll = (count = 2) => {
- requestAnimationFrame(() => {
- scrollCursorIntoView()
- if (count > 1) queueScroll(count - 1)
- })
- }
-
- const activeFileTab = createSessionTabs({
- tabs,
- pathFromTab: files.pathFromTab,
- normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
- }).activeFileTab
-
- const commentInReview = (path: string) => {
- const sessionID = props.controls.session.id
- if (!sessionID) return false
-
- const diffs = sync().data.session_diff[sessionID]
- if (!diffs) return false
- return diffs.some((diff) => diff.file === path)
- }
-
- const openComment = (item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }) => {
- if (!item.commentID) return
-
- const focus = { file: item.path, id: item.commentID }
- comments.setActive(focus)
-
- const queueCommentFocus = (attempts = 6) => {
- const schedule = (left: number) => {
- requestAnimationFrame(() => {
- comments.setFocus({ ...focus })
- if (left <= 0) return
- requestAnimationFrame(() => {
- const current = comments.focus()
- if (!current) return
- if (current.file !== focus.file || current.id !== focus.id) return
- schedule(left - 1)
- })
- })
- }
-
- schedule(attempts)
- }
-
- const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
- if (wantsReview) {
- if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
- layout.fileTree.setTab("changes")
- tabs().setActive("review")
- queueCommentFocus()
- return
- }
-
- if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
- layout.fileTree.setTab("all")
- const tab = files.tab(item.path)
- void tabs().open(tab)
- tabs().setActive(tab)
- void Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus())
- }
-
- const recent = createMemo(() => {
- const all = tabs().all()
- const active = activeFileTab()
- const order = active ? [active, ...all.filter((x) => x !== active)] : all
- const seen = new Set()
- const paths: string[] = []
-
- for (const tab of order) {
- const path = files.pathFromTab(tab)
- if (!path) continue
- if (seen.has(path)) continue
- seen.add(path)
- paths.push(path)
- }
-
- return paths
- })
- const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
- const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
- const imageAttachments = createMemo(() =>
- prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
- )
-
- const [store, setStore] = createPromptInputTransientState(
- () => prompt.capture(),
- Math.floor(Math.random() * EXAMPLES.length),
- )
- const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
- const motion = (value: number) => ({
- opacity: value,
- transform: `scale(${0.98 + value * 0.02})`,
- filter: `blur(${(1 - value) * 2}px)`,
- "pointer-events": value > 0.5 ? ("auto" as const) : ("none" as const),
- })
- const buttons = createMemo(() => motion(buttonsSpring()))
- const shell = createMemo(() => motion(1 - buttonsSpring()))
- const control = createMemo(() => ({ height: "28px", ...buttons() }))
-
- const commentCount = createMemo(() => {
- if (store.mode === "shell") return 0
- return prompt.context.items().filter((item) => !!item.comment?.trim()).length
- })
- const blank = createMemo(() => {
- const text = prompt
- .current()
- .map((part) => ("content" in part ? part.content : ""))
- .join("")
- return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0
- })
- const stopping = createMemo(() => working() && blank())
- const tip = () => {
- if (stopping()) {
- return (
-