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 ( -
- {language.t("prompt.action.stop")} - {language.t("common.key.esc")} -
- ) - } - - return ( -
- {language.t("prompt.action.send")} - -
- ) - } - - const contextItems = createMemo(() => { - const items = prompt.context.items() - if (store.mode !== "shell") return items - return items.filter((item) => !item.comment?.trim()) - }) - - const hasUserPrompt = createMemo(() => { - const sessionID = props.controls.session.id - if (!sessionID) return false - const messages = sync().data.message[sessionID] - if (!messages) return false - return messages.some((m) => m.role === "user") - }) - - const history = props.history ?? createPersistedPromptInputHistory() - - const suggest = createMemo(() => !hasUserPrompt()) - - const placeholder = createMemo(() => - promptPlaceholder({ - mode: store.mode, - commentCount: commentCount(), - example: suggest() ? (store.mode === "shell" ? "git status" : language.t(EXAMPLES[store.placeholder])) : "", - suggest: suggest(), - t: (key, params) => language.t(key as Parameters[0], params as never), - }), - ) - - const historyComments = () => { - const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const)) - return prompt.context.items().flatMap((item) => { - if (item.type !== "file") return [] - const comment = item.comment?.trim() - if (!comment) return [] - - const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined - const nextSelection = - selection ?? - (item.selection - ? ({ - start: item.selection.startLine, - end: item.selection.endLine, - } satisfies SelectedLineRange) - : undefined) - if (!nextSelection) return [] - - return [ - { - id: item.commentID ?? item.key, - path: item.path, - selection: { ...nextSelection }, - comment, - time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(), - origin: item.commentOrigin, - preview: item.preview, - } satisfies PromptHistoryComment, - ] - }) - } - - const applyHistoryComments = (items: PromptHistoryComment[]) => { - comments.replace( - items.map((item) => ({ - id: item.id, - file: item.path, - selection: { ...item.selection }, - comment: item.comment, - time: item.time, - })), - ) - prompt.context.replaceComments( - items.map((item) => ({ - type: "file" as const, - path: item.path, - selection: selectionFromLines(item.selection), - comment: item.comment, - commentID: item.id, - commentOrigin: item.origin, - preview: item.preview, - })), - ) - } - - const applyHistoryPrompt = (entry: PromptHistoryEntry, position: "start" | "end") => { - const p = entry.prompt - const length = position === "start" ? 0 : promptLength(p) - setStore("applyingHistory", true) - applyHistoryComments(entry.comments) - prompt.set(p, length) - requestAnimationFrame(() => { - editorRef.focus() - setCursorPosition(editorRef, length) - setStore("applyingHistory", false) - queueScroll() - }) - } - - const getCaretState = () => { - const selection = window.getSelection() - const textLength = promptLength(prompt.current()) - if (!selection || selection.rangeCount === 0) { - return { collapsed: false, cursorPosition: 0, textLength } - } - const anchorNode = selection.anchorNode - if (!anchorNode || !editorRef.contains(anchorNode)) { - return { collapsed: false, cursorPosition: 0, textLength } - } - return { - collapsed: selection.isCollapsed, - cursorPosition: getCursorPosition(editorRef), - textLength, - } - } - - const escBlur = () => platform.platform === "desktop" && platform.os === "macos" - - const pick = () => { - pickAttachmentFiles({ - picker: platform.openAttachmentPickerDialog, - directory: () => sdk().directory, - fallback: () => fileInputRef?.click(), - onFile: addAttachment, - onError: (error) => - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: error instanceof Error ? error.message : String(error), - }), - }) - } - - const setMode = (mode: "normal" | "shell") => { - setStore("mode", mode) - setStore({ popover: null, slashMenu: false, slashMenuQuery: "" }) - requestAnimationFrame(() => editorRef?.focus()) - } - - const shellModeKey = "mod+shift+x" - const normalModeKey = "mod+shift+e" - - command.register("prompt-input", () => [ - { - id: "file.attach", - title: language.t("prompt.action.attachFile"), - category: language.t("command.category.file"), - keybind: "mod+u", - disabled: store.mode !== "normal", - onSelect: pick, - }, - { - id: "prompt.mode.shell", - title: language.t("command.prompt.mode.shell"), - category: language.t("command.category.session"), - keybind: shellModeKey, - disabled: store.mode === "shell", - onSelect: () => setMode("shell"), - }, - { - id: "prompt.mode.normal", - title: language.t("command.prompt.mode.normal"), - category: language.t("command.category.session"), - keybind: normalModeKey, - disabled: store.mode === "normal", - onSelect: () => setMode("normal"), - }, - ]) - - const closePopover = () => setStore({ popover: null, slashMenu: false, slashMenuQuery: "" }) - - const resetHistoryNavigation = (force = false) => { - if (!force && (store.historyIndex < 0 || store.applyingHistory)) return - setStore("historyIndex", -1) - setStore("savedPrompt", null) - } - - const clearEditor = () => { - editorRef.innerHTML = "" - } - - const setEditorText = (text: string) => { - clearEditor() - editorRef.textContent = text - } - - const focusEditorEnd = () => { - requestAnimationFrame(() => { - editorRef.focus() - const range = document.createRange() - const selection = window.getSelection() - range.selectNodeContents(editorRef) - range.collapse(false) - selection?.removeAllRanges() - selection?.addRange(range) - }) - } - - const currentCursor = () => { - const selection = window.getSelection() - if (!selection || selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) return null - return getCursorPosition(editorRef) - } - - const restoreFocus = () => { - requestAnimationFrame(() => { - const cursor = savedCursor ?? prompt.cursor() ?? promptLength(prompt.current()) - editorRef.focus() - setCursorPosition(editorRef, cursor) - queueScroll() - }) - } - - const handleFocus = () => { - if (!restoreEndOnFocus) return - restoreEndOnFocus = false - requestAnimationFrame(() => { - if (document.activeElement !== editorRef) return - setCursorPosition(editorRef, prompt.cursor() ?? promptLength(prompt.current())) - queueScroll() - }) - } - - const renderEditorWithCursor = (parts: Prompt) => { - const cursor = currentCursor() - renderEditor(parts) - if (cursor !== null) setCursorPosition(editorRef, cursor) - } - - createEffect(() => { - props.controls.session.id - if (props.controls.session.id) return - if (!suggest()) return - const interval = setInterval(() => { - setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length) - }, 6500) - onCleanup(() => clearInterval(interval)) - }) - - const [composing, setComposing] = createSignal(false) - const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 - - const handleBlur = () => { - const cursor = currentCursor() - savedCursor = cursor - if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor) - closePopover() - setComposing(false) - } - - const handleCompositionStart = () => { - setComposing(true) - } - - const handleCompositionEnd = () => { - setComposing(false) - requestAnimationFrame(() => { - if (composing()) return - reconcile(prompt.current().filter((part) => part.type !== "image")) - }) - } - - const referenceDescription = (reference: ReferenceInfo) => - reference.source.type === "git" ? reference.source.repository : reference.source.path - - const referenceList = createMemo(() => - sync() - .data.reference.filter((reference) => !reference.hidden) - .map( - (reference): AtOption => ({ - type: "reference", - name: reference.name, - path: reference.path, - display: reference.name, - description: reference.description ?? referenceDescription(reference), - }), - ), - ) - - const agentList = createMemo(() => - props.controls.agents.available - .filter((agent) => !agent.hidden && agent.mode !== "primary") - .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), - ) - - const mcpResourceList = createMemo(() => - Object.values(sync().data.mcp_resource).map( - (resource): AtOption => ({ - type: "resource", - name: resource.name, - uri: resource.uri, - client: resource.client, - display: resource.name, - description: resource.description, - mime: resource.mimeType, - }), - ), - ) - - const handleAtSelect = (option: AtOption | undefined) => { - if (!option) return - if (option.type === "agent") { - addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 }) - return - } - if (option.type === "reference") { - addPart({ - type: "file", - path: option.path, - content: "@" + option.name, - start: 0, - end: 0, - mime: "application/x-directory", - filename: option.name, - }) - return - } - if (option.type === "resource") { - addPart({ - type: "file", - path: option.uri, - content: "@" + option.name, - start: 0, - end: 0, - mime: option.mime ?? "text/plain", - filename: option.name, - url: option.uri, - source: { - type: "resource", - text: { value: "@" + option.name, start: 0, end: 0 }, - clientName: option.client, - uri: option.uri, - }, - }) - return - } - addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) - } - - const atKey = (x: AtOption | undefined) => { - if (!x) return "" - if (x.type === "agent") return `agent:${x.name}` - if (x.type === "reference") return `reference:${x.name}` - if (x.type === "resource") return `resource:${x.client}:${x.uri}` - return `file:${x.path}` - } - - const { - flat: atFlat, - active: atActive, - setActive: setAtActive, - onInput: atOnInput, - onKeyDown: atOnKeyDown, - } = useFilteredList({ - items: async (query) => { - const references = referenceList() - const agents = agentList() - const mcpResources = mcpResourceList() - const open = recent() - const seen = new Set(open) - const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) - if (!query.trim()) return [...references, ...agents, ...mcpResources, ...pinned] - const paths = await files.searchFilesAndDirectories(query) - const fileOptions: AtOption[] = paths - .filter((path) => !seen.has(path)) - .map((path) => ({ type: "file", path, display: path })) - return [...references, ...agents, ...mcpResources, ...pinned, ...fileOptions] - }, - key: atKey, - filterKeys: ["display"], - skipFilter: (item) => item.type === "file" && !item.recent, - groupBy: atOptionCategory, - sortGroupsBy: compareAtCategories, - onSelect: handleAtSelect, - }) - - const slashCommands = createMemo(() => { - const builtin = command.options - .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash) - .map((opt) => ({ - id: opt.id, - trigger: opt.slash!, - title: opt.title, - description: opt.description, - keybind: opt.keybind, - type: "builtin" as const, - })) - - const custom = sync().data.command.map((cmd) => ({ - id: `custom.${cmd.name}`, - trigger: cmd.name, - title: cmd.name, - description: cmd.description, - type: "custom" as const, - source: cmd.source, - })) - - return [...custom, ...builtin] - }) - - const handleSlashSelect = (cmd: SlashCommand | undefined) => { - if (!cmd) return - const menu = store.slashMenu - closePopover() - const images = imageAttachments() - - if (cmd.type === "custom") { - const text = `/${cmd.trigger} ` - if (menu) { - editorRef.focus() - setCursorPosition(editorRef, 0) - addPart({ type: "text", content: text, start: 0, end: text.length }) - focusEditorEnd() - return - } - setEditorText(text) - prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length) - focusEditorEnd() - return - } - - if (menu) { - command.trigger(cmd.id, "slash") - return - } - - clearEditor() - prompt.set([...DEFAULT_PROMPT, ...images], 0) - command.trigger(cmd.id, "slash") - } - - const { - flat: slashFlat, - active: slashActive, - setActive: setSlashActive, - onInput: slashOnInput, - onKeyDown: slashOnKeyDown, - } = useFilteredList({ - items: slashCommands, - key: (x) => x?.id, - filterKeys: ["trigger", "title"], - onSelect: handleSlashSelect, - }) - - const createPill = (part: FileAttachmentPart | AgentPart) => { - const pill = document.createElement("span") - pill.textContent = part.content - pill.setAttribute("data-type", part.type) - if (part.type === "file") { - pill.setAttribute("data-path", part.path) - if (part.mime) pill.setAttribute("data-mime", part.mime) - if (part.filename) pill.setAttribute("data-filename", part.filename) - if (part.url) pill.setAttribute("data-url", part.url) - if (part.source?.type === "resource") { - pill.setAttribute("data-source-type", part.source.type) - pill.setAttribute("data-source-client-name", part.source.clientName) - pill.setAttribute("data-source-uri", part.source.uri) - } - } - if (part.type === "agent") pill.setAttribute("data-name", part.name) - pill.setAttribute("contenteditable", "false") - pill.style.userSelect = "text" - pill.style.cursor = "default" - return pill - } - - const isNormalizedEditor = () => - Array.from(editorRef.childNodes).every((node) => { - if (node.nodeType === Node.TEXT_NODE) { - const text = node.textContent ?? "" - if (!text.includes("\u200B")) return true - if (text !== "\u200B") return false - - const prev = node.previousSibling - const next = node.nextSibling - const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR" - return !!prevIsBr && !next - } - if (node.nodeType !== Node.ELEMENT_NODE) return false - const el = node as HTMLElement - if (el.dataset.type === "file") return true - if (el.dataset.type === "agent") return true - return el.tagName === "BR" - }) - - const renderEditor = (parts: Prompt) => { - clearEditor() - for (const part of parts) { - if (part.type === "text") { - editorRef.appendChild(createTextFragment(part.content)) - continue - } - if (part.type === "file" || part.type === "agent") { - editorRef.appendChild(createPill(part)) - } - } - - const last = editorRef.lastChild - if (last?.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR") { - editorRef.appendChild(document.createTextNode("\u200B")) - } - } - - const scrollSlashActiveIntoView = () => { - const activeId = slashActive() - if (!activeId || !slashPopoverRef) return - - requestAnimationFrame(() => { - const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`) - element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) - }) - } - const selectPopoverActive = () => { - if (store.popover === "at") { - const items = atFlat() - if (items.length === 0) return - const active = atActive() - const item = items.find((entry) => atKey(entry) === active) ?? items[0] - handleAtSelect(item) - return - } - - if (store.popover === "slash") { - const items = slashFlat() - if (items.length === 0) return - const active = slashActive() - const item = items.find((entry) => entry.id === active) ?? items[0] - handleSlashSelect(item) - } - } - - const reconcile = (input: Prompt) => { - if (mirror.input) { - mirror.input = false - if (isNormalizedEditor()) return - - renderEditorWithCursor(input) - return - } - - const dom = parseFromDOM() - if (isNormalizedEditor() && isPromptEqual(input, dom)) return - - renderEditorWithCursor(input) - } - - createEffect( - on( - () => prompt.current(), - (parts) => { - if (composing()) return - reconcile(parts.filter((part) => part.type !== "image")) - }, - ), - ) - - const parseFromDOM = (): Prompt => { - const parts: Prompt = [] - let position = 0 - let buffer = "" - - const flushText = () => { - let content = buffer - if (content.includes("\r")) content = content.replace(/\r\n?/g, "\n") - if (content.includes("\u200B")) content = content.replace(/\u200B/g, "") - buffer = "" - if (!content) return - parts.push({ type: "text", content, start: position, end: position + content.length }) - position += content.length - } - - const pushFile = (file: HTMLElement) => { - const content = file.textContent ?? "" - const source = - file.dataset.sourceType === "resource" && file.dataset.sourceClientName && file.dataset.sourceUri - ? { - type: "resource" as const, - text: { - value: content, - start: position, - end: position + content.length, - }, - clientName: file.dataset.sourceClientName, - uri: file.dataset.sourceUri, - } - : undefined - parts.push({ - type: "file", - path: file.dataset.path!, - content, - start: position, - end: position + content.length, - ...(file.dataset.mime ? { mime: file.dataset.mime } : {}), - ...(file.dataset.filename ? { filename: file.dataset.filename } : {}), - ...(file.dataset.url ? { url: file.dataset.url } : {}), - ...(source ? { source } : {}), - }) - position += content.length - } - - const pushAgent = (agent: HTMLElement) => { - const content = agent.textContent ?? "" - parts.push({ - type: "agent", - name: agent.dataset.name!, - content, - start: position, - end: position + content.length, - }) - position += content.length - } - - const visit = (node: Node) => { - if (node.nodeType === Node.TEXT_NODE) { - buffer += node.textContent ?? "" - return - } - if (node.nodeType !== Node.ELEMENT_NODE) return - - const el = node as HTMLElement - if (el.dataset.type === "file") { - flushText() - pushFile(el) - return - } - if (el.dataset.type === "agent") { - flushText() - pushAgent(el) - return - } - if (el.tagName === "BR") { - buffer += "\n" - return - } - - for (const child of Array.from(el.childNodes)) { - visit(child) - } - } - - const children = Array.from(editorRef.childNodes) - children.forEach((child, index) => { - const isBlock = child.nodeType === Node.ELEMENT_NODE && ["DIV", "P"].includes((child as HTMLElement).tagName) - visit(child) - if (isBlock && index < children.length - 1) { - buffer += "\n" - } - }) - - flushText() - - if (parts.length === 0) parts.push(...DEFAULT_PROMPT) - return parts - } - - const handleInput = () => { - const rawParts = parseFromDOM() - const images = imageAttachments() - const cursorPosition = getCursorPosition(editorRef) - const rawText = - rawParts.length === 1 && rawParts[0]?.type === "text" - ? rawParts[0].content - : rawParts.map((p) => ("content" in p ? p.content : "")).join("") - const hasNonText = rawParts.some((part) => part.type !== "text") - const textContent = (editorRef.textContent ?? "").replace(/\u200B/g, "") - const shouldReset = - textContent.length === 0 && rawText.replace(/\n/g, "").length === 0 && !hasNonText && images.length === 0 - - if (shouldReset) { - closePopover() - resetHistoryNavigation() - if (prompt.dirty()) { - mirror.input = true - prompt.set(DEFAULT_PROMPT, 0) - } - queueScroll() - return - } - - const shellMode = store.mode === "shell" - - if (!shellMode) { - const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) - const slashMatch = rawText.match(/^\/(\S*)$/) - - if (atMatch) { - atOnInput(atMatch[1]) - setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" }) - } else if (slashMatch) { - slashOnInput(slashMatch[1]) - setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" }) - } else { - closePopover() - } - } else { - closePopover() - } - - resetHistoryNavigation() - - mirror.input = true - prompt.set([...rawParts, ...images], cursorPosition) - queueScroll() - } - - const addPart = (part: ContentPart) => { - if (part.type === "image") return false - - const selection = window.getSelection() - if (!selection) return false - - if (selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) { - editorRef.focus() - const cursor = prompt.cursor() ?? promptLength(prompt.current()) - setCursorPosition(editorRef, cursor) - } - - if (selection.rangeCount === 0) return false - const range = selection.getRangeAt(0) - if (!editorRef.contains(range.startContainer)) return false - - if (part.type === "file" || part.type === "agent") { - const cursorPosition = getCursorPosition(editorRef) - const rawText = prompt - .current() - .map((p) => ("content" in p ? p.content : "")) - .join("") - const textBeforeCursor = rawText.substring(0, cursorPosition) - const atMatch = textBeforeCursor.match(/@(\S*)$/) - const pill = createPill(part) - const gap = document.createTextNode(" ") - - if (atMatch) { - const start = atMatch.index ?? cursorPosition - atMatch[0].length - setRangeEdge(editorRef, range, "start", start) - setRangeEdge(editorRef, range, "end", cursorPosition) - } - - range.deleteContents() - range.insertNode(gap) - range.insertNode(pill) - range.setStartAfter(gap) - range.collapse(true) - selection.removeAllRanges() - selection.addRange(range) - } - - if (part.type === "text") { - const fragment = createTextFragment(part.content) - const last = fragment.lastChild - range.deleteContents() - range.insertNode(fragment) - if (last) { - if (last.nodeType === Node.TEXT_NODE) { - const text = last.textContent ?? "" - if (text === "\u200B") { - range.setStart(last, 0) - } - if (text !== "\u200B") { - range.setStart(last, text.length) - } - } - if (last.nodeType !== Node.TEXT_NODE) { - const isBreak = last.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR" - const next = last.nextSibling - const emptyText = next?.nodeType === Node.TEXT_NODE && (next.textContent ?? "") === "" - if (isBreak && (!next || emptyText)) { - const placeholder = next && emptyText ? next : document.createTextNode("\u200B") - if (!next) last.parentNode?.insertBefore(placeholder, null) - placeholder.textContent = "\u200B" - range.setStart(placeholder, 0) - } else { - range.setStartAfter(last) - } - } - } - range.collapse(true) - selection.removeAllRanges() - selection.addRange(range) - } - - handleInput() - closePopover() - return true - } - - const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { - history.add(prompt, mode, mode === "shell" ? [] : historyComments()) - } - - createEffect( - on( - () => props.edit?.id, - (id) => { - const edit = props.edit - if (!id || !edit) return - - for (const item of prompt.context.items()) { - prompt.context.remove(item.key) - } - - for (const item of edit.context) { - prompt.context.add({ - type: item.type, - path: item.path, - selection: item.selection, - comment: item.comment, - commentID: item.commentID, - commentOrigin: item.commentOrigin, - preview: item.preview, - }) - } - - setStore("mode", "normal") - closePopover() - setStore("historyIndex", -1) - setStore("savedPrompt", null) - prompt.set(edit.prompt, promptLength(edit.prompt)) - requestAnimationFrame(() => { - editorRef.focus() - setCursorPosition(editorRef, promptLength(edit.prompt)) - queueScroll() - }) - props.onEditLoaded?.() - }, - { defer: true }, - ), - ) - - const navigateHistory = (direction: "up" | "down") => { - const result = navigatePromptHistory({ - direction, - entries: history.entries(store.mode), - historyIndex: store.historyIndex, - currentPrompt: prompt.current(), - currentComments: historyComments(), - savedPrompt: store.savedPrompt, - }) - if (!result.handled) return false - setStore("historyIndex", result.historyIndex) - setStore("savedPrompt", result.savedPrompt) - applyHistoryPrompt(result.entry, result.cursor) - return true - } - - const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({ - prompt, - editor: () => editorRef, - isDialogActive: () => !!dialog.active, - setDraggingType: (type) => setStore("draggingType", type), - focusEditor: () => { - editorRef.focus() - setCursorPosition(editorRef, promptLength(prompt.current())) - }, - addPart, - readClipboardImage: platform.readClipboardImage, - getPathForFile: platform.getPathForFile, - }) - - const fileAttachmentInput = () => ( - (fileInputRef = el)} - type="file" - multiple - accept={ACCEPTED_FILE_TYPES.join(",")} - class="hidden" - onChange={(e) => { - const list = e.currentTarget.files - if (list) void addAttachments(Array.from(list)) - e.currentTarget.value = "" - }} - /> - ) - - const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()]) - // Check provider variants directly: `variants` also includes the UI-only default option. - const showVariantControl = createMemo(() => props.controls.model.selection.variant.list().length > 0) - const accepting = createMemo(() => { - const id = props.controls.session.id - if (!id) return permission.isAutoAcceptingDirectory(sdk().directory) - return permission.isAutoAccepting(id, sdk().directory) - }) - - const { abort, handleSubmit } = - props.submission ?? - createPromptSubmit({ - prompt, - info, - imageAttachments, - commentCount, - autoAccept: () => accepting(), - mode: () => store.mode, - working, - editor: () => editorRef, - queueScroll, - promptLength, - addToHistory, - resetHistoryNavigation: () => { - resetHistoryNavigation(true) - }, - setMode: (mode) => setStore("mode", mode), - setPopover: (popover) => { - if (!popover) return closePopover() - setStore({ popover, slashMenu: false, slashMenuQuery: "" }) - }, - newSessionWorktree: () => props.newSessionWorktree, - onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, - shouldQueue: props.shouldQueue, - onQueue: props.onQueue, - onAbort: props.onAbort, - onSubmit: props.onSubmit, - model: props.controls.model.selection, - }) - - const handleKeyDown = (event: KeyboardEvent) => { - if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") { - event.preventDefault() - if (store.mode !== "normal") return - pick() - return - } - - if (event.key === "Backspace") { - const selection = window.getSelection() - if (selection && selection.isCollapsed) { - const node = selection.anchorNode - const offset = selection.anchorOffset - if (node && node.nodeType === Node.TEXT_NODE) { - const text = node.textContent ?? "" - if (/^\u200B+$/.test(text) && offset > 0) { - const range = document.createRange() - range.setStart(node, 0) - range.collapse(true) - selection.removeAllRanges() - selection.addRange(range) - } - } - } - } - - if (event.key === "!" && store.mode === "normal") { - const cursorPosition = getCursorPosition(editorRef) - if (cursorPosition === 0) { - setStore("mode", "shell") - closePopover() - event.preventDefault() - return - } - } - - if (event.key === "Escape") { - if (store.popover) { - closePopover() - event.preventDefault() - event.stopPropagation() - return - } - - if (store.mode === "shell") { - setStore("mode", "normal") - event.preventDefault() - event.stopPropagation() - return - } - - if (working()) { - void abort() - event.preventDefault() - event.stopPropagation() - return - } - - if (escBlur()) { - editorRef.blur() - event.preventDefault() - event.stopPropagation() - return - } - } - - if (store.mode === "shell") { - const { collapsed, cursorPosition, textLength } = getCaretState() - if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) { - setStore("mode", "normal") - event.preventDefault() - return - } - } - - // Handle Shift+Enter BEFORE IME check - Shift+Enter is never used for IME input - // and should always insert a newline regardless of composition state - if (event.key === "Enter" && event.shiftKey) { - addPart({ type: "text", content: "\n", start: 0, end: 0 }) - event.preventDefault() - return - } - - if (event.key === "Enter" && isImeComposing(event)) { - return - } - - const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey - - if (store.popover) { - if (event.key === "Tab") { - selectPopoverActive() - event.preventDefault() - return - } - const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter" - const ctrlNav = ctrl && (event.key === "n" || event.key === "p") - if (nav || ctrlNav) { - if (store.popover === "at") { - atOnKeyDown(event) - event.preventDefault() - return - } - if (store.popover === "slash") { - slashOnKeyDown(event) - if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) { - scrollSlashActiveIntoView() - } - } - event.preventDefault() - return - } - } - - if (ctrl && event.code === "KeyG") { - if (store.popover) { - closePopover() - event.preventDefault() - return - } - if (working()) { - void abort() - event.preventDefault() - } - return - } - - if (event.key === "ArrowUp" || event.key === "ArrowDown") { - if (event.altKey || event.ctrlKey || event.metaKey) return - const { collapsed } = getCaretState() - if (!collapsed) return - - const cursorPosition = getCursorPosition(editorRef) - const textContent = prompt - .current() - .map((part) => ("content" in part ? part.content : "")) - .join("") - const direction = event.key === "ArrowUp" ? "up" : "down" - if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition, store.historyIndex >= 0)) return - if (navigateHistory(direction)) { - event.preventDefault() - } - return - } - - // Note: Shift+Enter is handled earlier, before IME check - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault() - if (event.repeat) return - if ( - working() && - prompt - .current() - .map((part) => ("content" in part ? part.content : "")) - .join("") - .trim().length === 0 && - imageAttachments().length === 0 && - commentCount() === 0 - ) { - return - } - void handleSubmit(event) - } - } - - const handleSlashMenuKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - closePopover() - requestAnimationFrame(() => editorRef.focus()) - event.preventDefault() - return - } - - if (event.key === "Tab") { - selectPopoverActive() - event.preventDefault() - return - } - - const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey - const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter" - const ctrlNav = ctrl && (event.key === "n" || event.key === "p") - if (!nav && !ctrlNav) return - slashOnKeyDown(event) - if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) scrollSlashActiveIntoView() - event.preventDefault() - } - - const agentsLoading = () => props.controls.agents.loading - const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading()) - const providersLoading = () => props.controls.model.loading - const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading()) - - const [promptReady] = createResource( - () => prompt.ready.promise, - (p) => p, - ) - - const bindEditorRef = (el: HTMLDivElement) => { - editorRef = el - restoreEndOnFocus = true - props.ref?.(el) - } - return ( -
- {(promptReady(), null)} - (slashPopoverRef = el)} - atFlat={atFlat()} - atActive={atActive() ?? undefined} - atKey={atKey} - setAtActive={setAtActive} - onAtSelect={handleAtSelect} - slashFlat={slashFlat()} - slashActive={slashActive() ?? undefined} - setSlashActive={setSlashActive} - onSlashSelect={handleSlashSelect} - slashMenu={store.slashMenu} - slashMenuQuery={store.slashMenuQuery} - onSlashMenuInput={(value) => { - setStore("slashMenuQuery", value) - slashOnInput(value) - }} - onSlashMenuKeyDown={handleSlashMenuKeyDown} - commandKeybind={command.keybind} - commandKeybindParts={command.keybindParts} - newLayoutDesigns={false} - t={(key) => language.t(key as Parameters[0])} - /> - - - { - const active = comments.active() - return !!item.commentID && item.commentID === active?.id && item.path === active?.file - }} - openComment={openComment} - remove={(item) => { - if (item.commentID) comments.remove(item.path, item.commentID) - prompt.context.remove(item.key) - }} - newLayoutDesigns={false} - t={(key) => language.t(key as Parameters[0])} - /> - - dialog.show(() => ) - } - onRemove={removeAttachment} - removeLabel={language.t("prompt.attachment.remove")} - newLayoutDesigns={false} - /> -
{ - const target = e.target - if (!(target instanceof HTMLElement)) return - if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) { - return - } - editorRef?.focus() - }} - > -
(scrollRef = el)} - style={{ "scroll-padding-bottom": space }} - > -
-
- {placeholder()} -
-
- - - - - -
-
-
- - {language.t("prompt.mode.shell")} -
- -
-
- -
- - (x === "default" ? language.t("common.default") : x)} - onSelect={(value) => { - props.controls.model.selection.variant.set(value === "default" ? undefined : value) - restoreFocus() - }} - class="capitalize max-w-[160px] text-text-base" - valueClass="truncate text-13-regular text-text-base" - triggerStyle={control()} - triggerProps={{ "data-action": "prompt-model-variant" }} - variant="ghost" - /> - -
-
- - -
-
-
- - -
- ) -} diff --git a/packages/app/src/components/prompt-input/at-options.test.ts b/packages/app/src/components/prompt-input/at-options.test.ts deleted file mode 100644 index 7f8dcfbe6e..0000000000 --- a/packages/app/src/components/prompt-input/at-options.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { AtOption } from "./slash-popover" -import { atOptionCategory, compareAtCategories } from "./at-options" - -const options: AtOption[] = [ - { type: "reference", name: "effect", path: "/refs/effect", display: "effect", description: "" }, - { type: "agent", name: "general", display: "general" }, - { type: "resource", name: "docs", uri: "mcp://docs", client: "mcp", display: "docs" }, - { type: "file", path: "src/open.ts", display: "src/open.ts", recent: true }, - { type: "file", path: "src/found.ts", display: "src/found.ts" }, -] - -describe("atOptionCategory", () => { - test("categorizes every option shape", () => { - expect(options.map(atOptionCategory)).toEqual(["reference", "agent", "resource", "recent", "file"]) - }) -}) - -describe("compareAtCategories", () => { - test("ranks file groups before references, agents, and resources", () => { - const sorted = ["reference", "agent", "resource", "recent", "file"] - .map((category) => ({ category })) - .sort(compareAtCategories) - .map((group) => group.category) - expect(sorted).toEqual(["recent", "file", "reference", "agent", "resource"]) - }) -}) diff --git a/packages/app/src/components/prompt-input/at-options.ts b/packages/app/src/components/prompt-input/at-options.ts deleted file mode 100644 index c0771335b4..0000000000 --- a/packages/app/src/components/prompt-input/at-options.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { AtOption } from "./slash-popover" - -export function atOptionCategory(item: AtOption) { - if (item.type === "reference") return "reference" - if (item.type === "agent") return "agent" - if (item.type === "resource") return "resource" - if (item.recent) return "recent" - return "file" -} - -// Files are the dominant @ use case, so file groups (recent open tabs, then -// search results) rank ahead of references, agents, and MCP resources. -const rank = (category: string) => { - if (category === "recent") return 0 - if (category === "file") return 1 - if (category === "reference") return 2 - if (category === "agent") return 3 - return 4 -} - -export function compareAtCategories(a: { category: string }, b: { category: string }) { - return rank(a.category) - rank(b.category) -} diff --git a/packages/app/src/components/prompt-input/attachments.test.ts b/packages/app/src/components/prompt-input/attachments.test.ts deleted file mode 100644 index 104921697d..0000000000 --- a/packages/app/src/components/prompt-input/attachments.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { attachmentMime, pickAttachmentFiles } from "./files" -import { pasteMode } from "./paste" - -describe("attachmentMime", () => { - test("keeps PDFs when the browser reports the mime", async () => { - const file = new File(["%PDF-1.7"], "guide.pdf", { type: "application/pdf" }) - expect(await attachmentMime(file)).toBe("application/pdf") - }) - - test("normalizes structured text types to text/plain", async () => { - const file = new File(['{"ok":true}\n'], "data.json", { type: "application/json" }) - expect(await attachmentMime(file)).toBe("text/plain") - }) - - test("accepts text files even with a misleading browser mime", async () => { - const file = new File(["export const x = 1\n"], "main.ts", { type: "video/mp2t" }) - expect(await attachmentMime(file)).toBe("text/plain") - }) - - test("rejects binary files", async () => { - const file = new File([Uint8Array.of(0, 255, 1, 2)], "blob.bin", { type: "application/octet-stream" }) - expect(await attachmentMime(file)).toBeUndefined() - }) -}) - -describe("pickAttachmentFiles", () => { - test("reads the current project directory for every native picker invocation", async () => { - const paths: string[] = [] - const files: File[] = [] - const file = new File(["hello"], "hello.txt", { type: "text/plain" }) - let directory = "C:\\Projects\\LoremIpsum" - const picker = async (options?: { defaultPath?: string }, onFile?: (file: File) => Promise) => { - paths.push(options?.defaultPath ?? "") - await onFile?.(file) - } - - pickAttachmentFiles({ - picker, - directory: () => directory, - fallback: () => undefined, - onFile: async (selected) => files.push(selected), - onError: () => undefined, - }) - await Promise.resolve() - directory = "C:\\Projects\\DolorSit" - pickAttachmentFiles({ - picker, - directory: () => directory, - fallback: () => undefined, - onFile: async (selected) => files.push(selected), - onError: () => undefined, - }) - await Promise.resolve() - expect(files).toEqual([file, file]) - expect(paths).toEqual(["C:\\Projects\\LoremIpsum", "C:\\Projects\\DolorSit"]) - }) - - test("uses the browser file input when no native picker exists", async () => { - let fallback = 0 - pickAttachmentFiles({ - directory: () => "/projects/consectetur-adipiscing", - fallback: () => { - fallback += 1 - }, - onFile: async () => undefined, - onError: () => undefined, - }) - expect(fallback).toBe(1) - }) - - test("reports native picker failures without rejecting", async () => { - const error = new Error("picker unavailable") - const errors: unknown[] = [] - const handled = Promise.withResolvers() - pickAttachmentFiles({ - picker: async () => Promise.reject(error), - directory: () => "C:\\Projects\\LoremIpsum", - fallback: () => undefined, - onFile: async () => undefined, - onError: (cause) => { - errors.push(cause) - handled.resolve() - }, - }) - await handled.promise - expect(errors).toEqual([error]) - }) -}) - -describe("pasteMode", () => { - test("uses native paste for short single-line text", () => { - expect(pasteMode("hello world")).toBe("native") - }) - - test("uses manual paste for multiline text", () => { - expect( - pasteMode(`{ - "ok": true -}`), - ).toBe("manual") - expect(pasteMode("a\r\nb")).toBe("manual") - }) - - test("uses manual paste for large text", () => { - expect(pasteMode("x".repeat(8000))).toBe("manual") - }) -}) diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts deleted file mode 100644 index 9568b17d19..0000000000 --- a/packages/app/src/components/prompt-input/attachments.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { onMount } from "solid-js" -import { makeEventListener } from "@solid-primitives/event-listener" -import { showToast } from "@/utils/toast" -import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt" -import { useLanguage } from "@/context/language" -import { uuid } from "@/utils/uuid" -import { getCursorPosition } from "./editor-dom" -import { attachmentMime } from "./files" -import { normalizePaste, pasteMode } from "./paste" - -function dataUrl(file: File, mime: string) { - return new Promise((resolve) => { - const reader = new FileReader() - reader.addEventListener("error", () => resolve("")) - reader.addEventListener("load", () => { - const value = typeof reader.result === "string" ? reader.result : "" - const idx = value.indexOf(",") - if (idx === -1) { - resolve(value) - return - } - resolve(`data:${mime};base64,${value.slice(idx + 1)}`) - }) - reader.readAsDataURL(file) - }) -} - -type PromptTarget = Pick["capture"]>, "current" | "cursor" | "set"> -type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined } - -type PromptAttachmentsCoreInput = { - capture: () => PromptTarget - editor: () => HTMLDivElement | undefined - focusEditor?: () => void - addPart?: (part: ContentPart) => boolean - warn?: () => void - readClipboardImage?: () => Promise - getPathForFile?: (file: File) => string -} - -export type PromptAttachmentsInput = { - prompt: ReturnType - editor: () => HTMLDivElement | undefined - isDialogActive: () => boolean - setDraggingType: (type: "image" | "@mention" | null) => void - focusEditor: () => void - addPart: (part: ContentPart) => boolean - readClipboardImage?: () => Promise - getPathForFile?: (file: File) => string -} - -export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) { - const capture = (): AttachmentTarget | undefined => { - const prompt = input.capture() - const editor = input.editor() - if (!editor) return - return { prompt, cursor: prompt.cursor() ?? getCursorPosition(editor) } - } - - const add = async (file: File, toast = true, target = capture()) => { - if (!target) return false - const mime = await attachmentMime(file) - if (!mime) { - if (toast) input.warn?.() - return false - } - - const url = await dataUrl(file, mime) - if (!url) return false - - const attachment: ImageAttachmentPart = { - type: "image", - id: uuid(), - filename: file.name, - sourcePath: input.getPathForFile?.(file) || undefined, - mime, - dataUrl: url, - } - target.prompt.set([...target.prompt.current(), attachment], target.cursor) - return true - } - - const addAttachment = (file: File) => add(file) - - const addAttachments = async (files: File[], toast = true, target = capture()) => { - let found = false - - for (const file of files) { - const ok = await add(file, false, target) - if (ok) found = true - } - - if (!found && files.length > 0 && toast) input.warn?.() - return found - } - - const addClipboardAttachment = async (pending: Promise, target = capture()) => { - const file = await pending - if (!file) return false - return add(file, true, target) - } - - const removeAttachment = (id: string) => { - const target = input.capture() - const current = target.current() - const next = current.filter((part) => part.type !== "image" || part.id !== id) - target.set(next, target.cursor()) - } - - const handlePaste = async (event: ClipboardEvent) => { - const clipboardData = event.clipboardData - if (!clipboardData) return - const target = capture() - if (!target) return - - event.preventDefault() - event.stopPropagation() - - const files = Array.from(clipboardData.items).flatMap((item) => { - if (item.kind !== "file") return [] - const file = item.getAsFile() - return file ? [file] : [] - }) - - if (files.length > 0) { - await addAttachments(files, true, target) - return - } - - const plainText = clipboardData.getData("text/plain") ?? "" - - // Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images - if (input.readClipboardImage && !plainText) { - if (await addClipboardAttachment(input.readClipboardImage(), target)) return - } - - if (!plainText) return - - const text = normalizePaste(plainText) - - const put = () => { - if (input.addPart?.({ type: "text", content: text, start: 0, end: 0 })) return true - input.focusEditor?.() - return input.addPart?.({ type: "text", content: text, start: 0, end: 0 }) ?? false - } - - if (pasteMode(text) === "manual") { - put() - return - } - - const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, text) - if (inserted) return - - put() - } - - return { - addAttachment, - addAttachments, - addClipboardAttachment, - removeAttachment, - handlePaste, - } -} - -export function createPromptAttachments(input: PromptAttachmentsInput) { - const language = useLanguage() - const attachments = createPromptAttachmentsCore({ - ...input, - capture: input.prompt.capture, - warn: () => { - showToast({ - title: language.t("prompt.toast.pasteUnsupported.title"), - description: language.t("prompt.toast.pasteUnsupported.description"), - }) - }, - }) - - const handleGlobalDragOver = (event: DragEvent) => { - if (input.isDialogActive()) return - - event.preventDefault() - const hasFiles = event.dataTransfer?.types.includes("Files") - const hasText = event.dataTransfer?.types.includes("text/plain") - if (hasFiles) { - input.setDraggingType("image") - } else if (hasText) { - input.setDraggingType("@mention") - } - } - - const handleGlobalDragLeave = (event: DragEvent) => { - if (input.isDialogActive()) return - if (!event.relatedTarget) { - input.setDraggingType(null) - } - } - - const handleGlobalDrop = async (event: DragEvent) => { - if (input.isDialogActive()) return - - event.preventDefault() - input.setDraggingType(null) - - const plainText = event.dataTransfer?.getData("text/plain") - const filePrefix = "file:" - if (plainText?.startsWith(filePrefix)) { - const filePath = plainText.slice(filePrefix.length) - input.focusEditor() - input.addPart({ type: "file", path: filePath, content: "@" + filePath, start: 0, end: 0 }) - return - } - - const dropped = event.dataTransfer?.files - if (!dropped) return - - await attachments.addAttachments(Array.from(dropped)) - } - - onMount(() => { - makeEventListener(document, "dragover", handleGlobalDragOver) - makeEventListener(document, "dragleave", handleGlobalDragLeave) - makeEventListener(document, "drop", handleGlobalDrop) - }) - - return attachments -} diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx deleted file mode 100644 index 1f03f199ab..0000000000 --- a/packages/app/src/components/prompt-input/context-items.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { Component, For, Show } from "solid-js" -import { Dynamic } from "solid-js/web" -import { FileIcon } from "@opencode-ai/ui/file-icon" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { Tooltip } from "@opencode-ai/ui/tooltip" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path" -import type { ContextItem } from "@/context/prompt" - -type PromptContextItem = ContextItem & { key: string } - -type ContextItemsProps = { - items: PromptContextItem[] - active: (item: PromptContextItem) => boolean - openComment: (item: PromptContextItem) => void - remove: (item: PromptContextItem) => void - newLayoutDesigns: boolean - t: (key: string) => string -} - -export const PromptContextItems: Component = (props) => { - return ( - 0}> -
- - {(item) => { - const directory = getDirectory(item.path) - const filename = getFilename(item.path) - const label = getFilenameTruncated(item.path, 14) - const selected = props.active(item) - - return ( - - - {directory} - - {filename} - - } - placement="top" - openDelay={800} - > -
props.openComment(item)} - > -
- -
- {label} - - {(sel) => ( - - {sel().startLine === sel().endLine - ? `:${sel().startLine}` - : `:${sel().startLine}-${sel().endLine}`} - - )} - -
- { - e.stopPropagation() - props.remove(item) - }} - aria-label={props.t("prompt.context.removeFile")} - /> -
- - {(comment) =>
{comment()}
} -
-
-
- ) - }} -
-
-
- ) -} diff --git a/packages/app/src/components/prompt-input/drag-overlay.tsx b/packages/app/src/components/prompt-input/drag-overlay.tsx deleted file mode 100644 index 41962ce536..0000000000 --- a/packages/app/src/components/prompt-input/drag-overlay.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Component, Show } from "solid-js" -import { Icon } from "@opencode-ai/ui/icon" - -type PromptDragOverlayProps = { - type: "image" | "@mention" | null - label: string -} - -const kindToIcon = { - image: "photo", - "@mention": "link", -} as const - -export const PromptDragOverlay: Component = (props) => { - return ( - -
-
- - {props.label} -
-
-
- ) -} diff --git a/packages/app/src/components/prompt-input/files.ts b/packages/app/src/components/prompt-input/files.ts deleted file mode 100644 index 3c6ad1ff08..0000000000 --- a/packages/app/src/components/prompt-input/files.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-picker" - -export { ACCEPTED_FILE_TYPES } - -type AttachmentPicker = ( - options: { - defaultPath?: string - multiple?: boolean - accept?: string[] - }, - onFile: (file: File) => Promise, -) => Promise - -export function pickAttachmentFiles(input: { - picker?: AttachmentPicker - directory: () => string - fallback: () => void - onFile: (file: File) => Promise - onError: (error: unknown) => void -}) { - if (!input.picker) { - input.fallback() - return - } - void input - .picker( - { - defaultPath: input.directory(), - multiple: true, - accept: ACCEPTED_FILE_TYPES, - }, - input.onFile, - ) - .catch(input.onError) -} - -const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES) -const IMAGE_EXTS = new Map([ - ["gif", "image/gif"], - ["jpeg", "image/jpeg"], - ["jpg", "image/jpeg"], - ["png", "image/png"], - ["webp", "image/webp"], -]) -const TEXT_MIMES = new Set([ - "application/json", - "application/ld+json", - "application/toml", - "application/x-toml", - "application/x-yaml", - "application/xml", - "application/yaml", -]) - -const SAMPLE = 4096 - -function kind(type: string) { - return type.split(";", 1)[0]?.trim().toLowerCase() ?? "" -} - -function ext(name: string) { - const idx = name.lastIndexOf(".") - if (idx === -1) return "" - return name.slice(idx + 1).toLowerCase() -} - -function textMime(type: string) { - if (!type) return false - if (type.startsWith("text/")) return true - if (TEXT_MIMES.has(type)) return true - if (type.endsWith("+json")) return true - return type.endsWith("+xml") -} - -function textBytes(bytes: Uint8Array) { - if (bytes.length === 0) return true - let count = 0 - for (const byte of bytes) { - if (byte === 0) return false - if (byte < 9 || (byte > 13 && byte < 32)) count += 1 - } - return count / bytes.length <= 0.3 -} - -export async function attachmentMime(file: File) { - const type = kind(file.type) - if (IMAGE_MIMES.has(type)) return type - if (type === "application/pdf") return type - - const suffix = ext(file.name) - const fallback = IMAGE_EXTS.get(suffix) ?? (suffix === "pdf" ? "application/pdf" : undefined) - if ((!type || type === "application/octet-stream") && fallback) return fallback - - if (textMime(type)) return "text/plain" - const bytes = new Uint8Array(await file.slice(0, SAMPLE).arrayBuffer()) - if (!textBytes(bytes)) return - return "text/plain" -} diff --git a/packages/app/src/components/prompt-input/image-attachments.css b/packages/app/src/components/prompt-input/image-attachments.css deleted file mode 100644 index 2aaf494ebf..0000000000 --- a/packages/app/src/components/prompt-input/image-attachments.css +++ /dev/null @@ -1,43 +0,0 @@ -@keyframes prompt-attachments-fade-left { - from { - visibility: hidden; - } - to { - visibility: visible; - } -} - -@keyframes prompt-attachments-fade-right { - from { - visibility: visible; - } - to { - visibility: hidden; - } -} - -[data-slot="prompt-attachments"] { - timeline-scope: --prompt-attachments-scroll; - - [data-slot^="prompt-attachments-fade-"] { - visibility: hidden; - } -} - -@supports (animation-timeline: --prompt-attachments-scroll) and (timeline-scope: --prompt-attachments-scroll) { - [data-slot="prompt-attachments-scroll"] { - scroll-timeline: --prompt-attachments-scroll x; - } - - [data-slot="prompt-attachments-fade-left"] { - animation: prompt-attachments-fade-left linear both; - animation-timeline: --prompt-attachments-scroll; - animation-range: 0 0.1px; - } - - [data-slot="prompt-attachments-fade-right"] { - animation: prompt-attachments-fade-right linear both; - animation-timeline: --prompt-attachments-scroll; - animation-range: calc(100% - 1.1px) calc(100% - 1px); - } -} diff --git a/packages/app/src/components/prompt-input/image-attachments.tsx b/packages/app/src/components/prompt-input/image-attachments.tsx deleted file mode 100644 index 3a0cc3e4dd..0000000000 --- a/packages/app/src/components/prompt-input/image-attachments.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { Component, For, Show } from "solid-js" -import { Icon } from "@opencode-ai/ui/icon" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { Tooltip } from "@opencode-ai/ui/tooltip" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { AttachmentCardV2 } from "@opencode-ai/session-ui/v2/attachment-card-v2" -import { CommentCardV2 } from "@opencode-ai/session-ui/v2/comment-card-v2" -import { typeLabel } from "@opencode-ai/session-ui/message-file" -import type { ContextItem, ImageAttachmentPart } from "@/context/prompt" -import "./image-attachments.css" - -type PromptCommentItem = ContextItem & { key: string } - -type PromptImageAttachmentsProps = { - attachments: ImageAttachmentPart[] - onOpen: (attachment: ImageAttachmentPart) => void - onRemove: (id: string) => void - removeLabel: string - newLayoutDesigns: boolean - comments?: PromptCommentItem[] - commentActive?: (item: PromptCommentItem) => boolean - onOpenComment?: (item: PromptCommentItem) => void - onRemoveComment?: (item: PromptCommentItem) => void -} - -const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base" -const imageClass = - "size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors" -const imageClassV2 = "w-[58px] h-[46px] rounded-[6px] object-cover" -// inset box-shadows do not paint over content, so the hairline is a separate overlay -const imageHairlineClassV2 = - "absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" -const removeClass = - "absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover" -const removeClassV2 = - "absolute -top-1 -right-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" -const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md" - -export const PromptImageAttachments: Component = (props) => { - return ( - 0 || (props.newLayoutDesigns && (props.comments?.length ?? 0) > 0)}> -
-
- - - {(item) => ( -
- - props.onOpenComment?.(item)} - /> - - -
- )} -
-
- - {(attachment) => { - const image = attachment.mime.startsWith("image/") - const media = () => ( - - -
- } - > - - {typeLabel(attachment.filename, attachment.mime)} - - - } - > - {attachment.filename} props.onOpen(attachment)} - /> - - ) - const name = () => ( -
- {attachment.filename} -
- ) - const remove = () => ( - - ) - // v2 keeps the remove button outside the tooltip trigger so hovering it dismisses the tooltip - return ( - -
- {media()} - {name()} - {remove()} -
- - } - > -
- - {media()} - -
- - - {remove()} -
-
- ) - }} - -
- -
-
- -
- - ) -} diff --git a/packages/app/src/components/prompt-input/paste.ts b/packages/app/src/components/prompt-input/paste.ts deleted file mode 100644 index 6787d50309..0000000000 --- a/packages/app/src/components/prompt-input/paste.ts +++ /dev/null @@ -1,24 +0,0 @@ -const LARGE_PASTE_CHARS = 8000 -const LARGE_PASTE_BREAKS = 120 - -function largePaste(text: string) { - if (text.length >= LARGE_PASTE_CHARS) return true - let breaks = 0 - for (const char of text) { - if (char !== "\n") continue - breaks += 1 - if (breaks >= LARGE_PASTE_BREAKS) return true - } - return false -} - -export function normalizePaste(text: string) { - if (!text.includes("\r")) return text - return text.replace(/\r\n?/g, "\n") -} - -export function pasteMode(text: string) { - if (largePaste(text)) return "manual" - if (text.includes("\n") || text.includes("\r")) return "manual" - return "native" -} diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx deleted file mode 100644 index b9a2dc7afa..0000000000 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ /dev/null @@ -1,376 +0,0 @@ -import { Component, For, Match, Show, Switch } from "solid-js" -import { FileIcon } from "@opencode-ai/ui/file-icon" -import { Icon } from "@opencode-ai/ui/icon" -import { Tag } from "@opencode-ai/ui/v2/badge-v2" -import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" -import { getDirectory, getFilename } from "@opencode-ai/core/util/path" - -export type AtOption = - | { type: "agent"; name: string; display: string } - | { - type: "resource" - name: string - uri: string - client: string - display: string - description?: string - mime?: string - } - | { type: "reference"; name: string; path: string; display: string; description: string } - | { type: "file"; path: string; display: string; recent?: boolean } - -export interface SlashCommand { - id: string - trigger: string - title: string - description?: string - keybind?: string - type: "builtin" | "custom" - source?: "command" | "mcp" | "skill" -} - -type PromptPopoverProps = { - popover: "at" | "slash" | null - setSlashPopoverRef: (el: HTMLDivElement) => void - atFlat: AtOption[] - atActive?: string - atKey: (item: AtOption) => string - setAtActive: (id: string) => void - onAtSelect: (item: AtOption) => void - slashFlat: SlashCommand[] - slashActive?: string - setSlashActive: (id: string) => void - onSlashSelect: (item: SlashCommand) => void - slashMenu: boolean - slashMenuQuery: string - onSlashMenuInput: (value: string) => void - onSlashMenuKeyDown: (event: KeyboardEvent) => void - commandKeybind: (id: string) => string | undefined - commandKeybindParts: (id: string) => string[] - newLayoutDesigns: boolean - t: (key: string) => string -} - -export const PromptPopover: Component = (props) => { - return ( - -
{ - if (props.popover === "slash") props.setSlashPopoverRef(el) - }} - class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10 - overflow-auto no-scrollbar flex flex-col p-2" - classList={{ - "z-[70] rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": props.newLayoutDesigns, - "rounded-[12px] bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]": - !props.newLayoutDesigns, - }} - onMouseDown={(e) => e.preventDefault()} - > - - - 0} - fallback={ -
- {props.t("prompt.popover.emptyResults")} -
- } - > - - {(item) => { - const key = props.atKey(item) - - if (item.type === "agent") { - return ( - - ) - } - - if (item.type === "resource") { - return ( - - ) - } - - if (item.type === "reference") { - return ( - - ) - } - - const isDirectory = item.path.endsWith("/") - const directory = isDirectory ? item.path : getDirectory(item.path) - const filename = isDirectory ? "" : getFilename(item.path) - - return ( - - ) - }} - -
-
- - -
- requestAnimationFrame(() => el.focus())} - value={props.slashMenuQuery} - onInput={(event) => props.onSlashMenuInput(event.currentTarget.value)} - onKeyDown={props.onSlashMenuKeyDown} - onMouseDown={(event) => event.stopPropagation()} - aria-label={props.t("prompt.menu.commands")} - placeholder="/" - class="w-full bg-transparent outline-none text-[13px] leading-5 text-v2-text-text-base placeholder:text-v2-text-text-faint" - /> -
-
- 0} - fallback={ -
- {props.t("prompt.popover.emptyCommands")} -
- } - > - - {(cmd) => { - const keybind = () => props.commandKeybind(cmd.id) - const keybindParts = () => props.commandKeybindParts(cmd.id) - return ( - - ) - }} - -
-
-
-
-
- ) -} diff --git a/packages/app/src/components/prompt-input/transient-state.ts b/packages/app/src/components/prompt-input/transient-state.ts deleted file mode 100644 index 29f2f182b3..0000000000 --- a/packages/app/src/components/prompt-input/transient-state.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { createComputed, on, type Accessor } from "solid-js" -import { createStore, type SetStoreFunction } from "solid-js/store" -import type { PromptHistoryEntry } from "./history" - -export type PromptInputTransientState = { - popover: "at" | "slash" | null - slashMenu: boolean - slashMenuQuery: string - historyIndex: number - savedPrompt: PromptHistoryEntry | null - placeholder: number - draggingType: "image" | "@mention" | null - mode: "normal" | "shell" - applyingHistory: boolean -} - -function resetPromptInputTransientState(setStore: SetStoreFunction) { - setStore({ - popover: null, - slashMenu: false, - slashMenuQuery: "", - historyIndex: -1, - savedPrompt: null, - draggingType: null, - mode: "normal", - applyingHistory: false, - }) -} - -export function createPromptInputTransientState(identity: Accessor, placeholder: number) { - const [store, setStore] = createStore({ - popover: null, - slashMenu: false, - slashMenuQuery: "", - historyIndex: -1, - savedPrompt: null, - placeholder, - draggingType: null, - mode: "normal", - applyingHistory: false, - }) - - createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true })) - - return [store, setStore] as const -} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 3ce2841676..b545cfefa8 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -57,7 +57,6 @@ import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTabs } from "@/context/tabs" import { TerminalProvider, useTerminal } from "@/context/terminal" -import { PromptInput } from "@/components/prompt-input" import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2" import { useSettingsCommand } from "@/components/settings-dialog" import { setCursorPosition } from "@/components/prompt-input/editor-dom" @@ -2209,70 +2208,39 @@ export default function Page() { promptDock = el }, }) + const promptController = usePromptInputV2Controller({ + get controls() { + return inputController() + }, + ref: (el) => { + inputRef = el + }, + get newSessionWorktree() { + return newSessionWorktree() + }, + onNewSessionWorktreeReset: () => setStore("newSessionWorktree", "main"), + onSubmit: () => { + comments.clear() + resumeScroll() + }, + shouldQueue: queueEnabled, + onQueue: queueFollowup, + onAbort: () => { + const id = params.id + if (!id) return + setFollowup("paused", id, true) + }, + }) return ( { - inputRef = el - }} - newSessionWorktree={newSessionWorktree()} - onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} - onSubmit={() => { - comments.clear() - resumeScroll() - }} - edit={editingFollowup()} - onEditLoaded={clearFollowupEdit} - shouldQueue={queueEnabled} - onQueue={queueFollowup} - onAbort={() => { - const id = params.id - if (!id) return - setFollowup("paused", id, true) - }} - /> - } - > - {(_) => { - const controller = usePromptInputV2Controller({ - get controls() { - return inputController() - }, - ref: (el) => { - inputRef = el - }, - get newSessionWorktree() { - return newSessionWorktree() - }, - onNewSessionWorktreeReset: () => setStore("newSessionWorktree", "main"), - onSubmit: () => { - comments.clear() - resumeScroll() - }, - shouldQueue: queueEnabled, - onQueue: queueFollowup, - onAbort: () => { - const id = params.id - if (!id) return - setFollowup("paused", id, true) - }, - }) - return ( - - ) - }} - + } /> ) diff --git a/packages/app/src/pages/session/composer/session-composer-region-controller.ts b/packages/app/src/pages/session/composer/session-composer-region-controller.ts index 4073f1c191..a0794505ce 100644 --- a/packages/app/src/pages/session/composer/session-composer-region-controller.ts +++ b/packages/app/src/pages/session/composer/session-composer-region-controller.ts @@ -2,7 +2,7 @@ import { createResizeObserver } from "@solid-primitives/resize-observer" import { useSpring } from "@opencode-ai/ui/motion-spring" import { type Accessor, createEffect, createMemo, createResource, onCleanup } from "solid-js" import { createStore } from "solid-js/store" -import type { PromptInputState } from "@/components/prompt-input" +import type { PromptInputState } from "@/components/prompt-input/contracts" import { useSync } from "@/context/sync" import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" import type { SessionComposerController } from "./session-composer-state" diff --git a/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx b/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx index 41d1f984bd..e8f5553c5a 100644 --- a/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx +++ b/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx @@ -3,7 +3,6 @@ import { createEffect, createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import type { Todo } from "@opencode-ai/sdk/v2" import { useServerSync } from "@/context/global-sync" -import { PromptInput } from "@/components/prompt-input" import { usePrompt } from "@/context/prompt" import { SessionComposerRegion, @@ -26,9 +25,9 @@ This playground renders the real session composer region from app code. ### Includes - \`SessionTodoDock\` (real) -- \`PromptInput\` (real) +- a static prompt-input placeholder (the retired legacy \`PromptInput\` used to fill this slot) -No visual reimplementation layer is used for the dock/input stack.`, +The dock renders from real app code; only the input below it is a stand-in.`, }, }, }, @@ -56,23 +55,6 @@ const btn = (accent?: boolean) => "font-size": "13px", }) as const -const controls = { - agents: { available: [], options: ["build"], current: "build", loading: false, visible: true, select: () => {} }, - model: { - selection: { - current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }), - variant: { list: () => [], current: () => undefined, set: () => {} }, - }, - paid: true, - loading: false, - }, - session: { - id: "story-session", - tabs: { active: () => undefined, all: () => [], open: () => {}, setActive: () => {} }, - reviewPanel: { opened: () => false, open: () => {} }, - }, -} - const css = ` [data-component="todo-stage"] { display: grid; @@ -301,13 +283,20 @@ export const Playground = { setDockRef: () => {}, })} promptInput={ - {}, handleSubmit: (event) => event.preventDefault() }} - ref={() => {}} - newSessionWorktree="" - onNewSessionWorktreeReset={() => {}} - /> +
+ Ask anything... +
} />
diff --git a/packages/app/test-browser/prompt-at-options.test.ts b/packages/app/test-browser/prompt-at-options.test.ts deleted file mode 100644 index 77ca3bb494..0000000000 --- a/packages/app/test-browser/prompt-at-options.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { createRoot } from "solid-js" -import { useFilteredList } from "@opencode-ai/ui/hooks" -import type { AtOption } from "@/components/prompt-input/slash-popover" -import { atOptionCategory, compareAtCategories } from "@/components/prompt-input/at-options" - -const options: AtOption[] = [ - { type: "reference", name: "effect", path: "/refs/effect", display: "effect", description: "" }, - { type: "agent", name: "general", display: "general" }, - { type: "resource", name: "docs", uri: "mcp://docs", client: "mcp", display: "docs" }, - { type: "file", path: "src/open.ts", display: "src/open.ts", recent: true }, - { type: "file", path: "src/found.ts", display: "src/found.ts" }, -] - -describe("@ menu ordering through useFilteredList", () => { - test("flattens with files first on an empty query", async () => { - await createRoot(async (dispose) => { - const list = useFilteredList({ - items: options, - key: (item) => `${item.type}:${item.display}`, - filterKeys: ["display"], - skipFilter: (item) => item.type === "file" && !item.recent, - groupBy: atOptionCategory, - sortGroupsBy: compareAtCategories, - }) - // grouped is a resource; wait for it to settle. - for (let i = 0; i < 100 && list.flat().length === 0; i++) { - await new Promise((resolve) => setTimeout(resolve, 10)) - } - expect(list.flat().map((item) => item.display)).toEqual([ - "src/open.ts", - "src/found.ts", - "effect", - "general", - "docs", - ]) - dispose() - }) - }) -}) diff --git a/packages/app/test-browser/prompt-attachments.test.ts b/packages/app/test-browser/prompt-attachments.test.ts deleted file mode 100644 index ca7686598e..0000000000 --- a/packages/app/test-browser/prompt-attachments.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { createRoot } from "solid-js" -import { createPromptAttachmentsCore } from "@/components/prompt-input/attachments" -import { createPromptState } from "@/context/prompt" - -describe("prompt attachment session ownership", () => { - test("adds an asynchronously read image to the session where the read started", async () => { - await createRoot(async (dispose) => { - const sessions = { A: createPromptState(), B: createPromptState() } - let active: "A" | "B" = "A" - const attachments = createPromptAttachmentsCore({ - capture: () => sessions[active].capture(), - editor: () => document.createElement("div"), - }) - const pending = attachments.addAttachment(new File([new Uint8Array(1024 * 1024)], "a.png", { type: "image/png" })) - - active = "B" - await pending - - expect(images(sessions.A)).toHaveLength(1) - expect(images(sessions.B)).toHaveLength(0) - dispose() - }) - }) - - test("finishes the captured attachment after the active editor is removed", async () => { - await createRoot(async (dispose) => { - const prompt = createPromptState() - let editor: HTMLDivElement | undefined = document.createElement("div") - const attachments = createPromptAttachmentsCore({ - capture: prompt.capture, - editor: () => editor, - }) - const pending = attachments.addAttachment(new File([new Uint8Array(1024 * 1024)], "a.png", { type: "image/png" })) - - editor = undefined - await pending - - expect(images(prompt)).toHaveLength(1) - dispose() - }) - }) - - test("keeps every file in a batch on the session where the batch started", async () => { - await createRoot(async (dispose) => { - const sessions = { A: createPromptState(), B: createPromptState() } - let active: "A" | "B" = "A" - const attachments = createPromptAttachmentsCore({ - capture: () => sessions[active].capture(), - editor: () => document.createElement("div"), - }) - const pending = attachments.addAttachments([ - new File([new Uint8Array(1024 * 1024)], "first.png", { type: "image/png" }), - new File([new Uint8Array(1024 * 1024)], "second.png", { type: "image/png" }), - ]) - - active = "B" - await pending - - expect(images(sessions.A)).toHaveLength(2) - expect(images(sessions.B)).toHaveLength(0) - dispose() - }) - }) - - test("keeps a delayed native clipboard image on the session where paste started", async () => { - await createRoot(async (dispose) => { - const sessions = { A: createPromptState(), B: createPromptState() } - const read = Promise.withResolvers() - let active: "A" | "B" = "A" - const attachments = createPromptAttachmentsCore({ - capture: () => sessions[active].capture(), - editor: () => document.createElement("div"), - }) - const pending = attachments.addClipboardAttachment(read.promise) - - active = "B" - read.resolve(new File([new Uint8Array(1024 * 1024)], "clipboard.png", { type: "image/png" })) - await pending - - expect(images(sessions.A)).toHaveLength(1) - expect(images(sessions.B)).toHaveLength(0) - dispose() - }) - }) -}) - -function images(prompt: ReturnType) { - return prompt.current().filter((part) => part.type === "image") -} diff --git a/packages/app/test-browser/prompt-transient-state.test.ts b/packages/app/test-browser/prompt-transient-state.test.ts deleted file mode 100644 index 5777c9c392..0000000000 --- a/packages/app/test-browser/prompt-transient-state.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { expect, test } from "bun:test" -import { createRoot, createSignal } from "solid-js" -import { createPromptInputTransientState } from "@/components/prompt-input/transient-state" - -test("resets transient prompt input state when the prompt session changes", () => { - createRoot((dispose) => { - const [identity, setIdentity] = createSignal("A") - const [state, setState] = createPromptInputTransientState(identity, 3) - setState({ - popover: "slash", - slashMenu: true, - slashMenuQuery: "compact", - historyIndex: 2, - savedPrompt: { - prompt: [{ type: "text", content: "draft-A", start: 0, end: 7 }], - comments: [], - }, - draggingType: "image", - mode: "shell", - applyingHistory: true, - }) - - setIdentity("B") - - expect(state).toMatchObject({ - popover: null, - slashMenu: false, - slashMenuQuery: "", - historyIndex: -1, - savedPrompt: null, - placeholder: 3, - draggingType: null, - mode: "normal", - applyingHistory: false, - }) - dispose() - }) -})