diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index 65fbf0f3d601..8cf87c5d8e85 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -4,6 +4,7 @@ runs: using: "composite" steps: - name: Mount Bun Cache + if: ${{ runner.os == 'Linux' }} uses: useblacksmith/stickydisk@v1 with: key: ${{ github.repository }}-bun-cache-${{ runner.os }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f63802ac5aa1..431581f5966e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -171,13 +171,23 @@ jobs: GH_TOKEN: ${{ github.token }} GITHUB_RUN_ID: ${{ github.run_id }} + - name: Resolve tauri portable SHA + if: contains(matrix.settings.host, 'ubuntu') + run: echo "TAURI_PORTABLE_SHA=$(git ls-remote https://github.com/tauri-apps/tauri.git refs/heads/feat/truly-portable-appimage | cut -f1)" >> "$GITHUB_ENV" + # Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released - name: Install tauri-cli from portable appimage branch + uses: taiki-e/cache-cargo-install-action@v3 if: contains(matrix.settings.host, 'ubuntu') - run: | - cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force - echo "Installed tauri-cli version:" - cargo tauri --version + with: + tool: tauri-cli + git: https://github.com/tauri-apps/tauri + # branch: feat/truly-portable-appimage + rev: ${{ env.TAURI_PORTABLE_SHA }} + + - name: Show tauri-cli version + if: contains(matrix.settings.host, 'ubuntu') + run: cargo tauri --version - name: Build and upload artifacts uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a diff --git a/.opencode/agent/triage.md b/.opencode/agent/triage.md index 5d1147a88594..ccf3f0c33e27 100644 --- a/.opencode/agent/triage.md +++ b/.opencode/agent/triage.md @@ -1,7 +1,7 @@ --- mode: primary hidden: true -model: opencode/claude-haiku-4-5 +model: opencode/minimax-m2.5 color: "#44BA81" tools: "*": false @@ -12,6 +12,8 @@ You are a triage agent responsible for triaging github issues. Use your github-triage tool to triage issues. +This file is the source of truth for ownership/routing rules. + ## Labels ### windows @@ -43,12 +45,30 @@ Desktop app issues: **Only** add if the issue explicitly mentions nix. +If the issue does not mention nix, do not add nix. + +If the issue mentions nix, assign to `rekram1-node`. + #### zen **Only** add if the issue mentions "zen" or "opencode zen" or "opencode black". If the issue doesn't have "zen" or "opencode black" in it then don't add zen label +#### core + +Use for core server issues in `packages/opencode/`, excluding `packages/opencode/src/cli/cmd/tui/`. + +Examples: + +- LSP server behavior +- Harness behavior (agent + tools) +- Feature requests for server behavior +- Agent context construction +- API endpoints +- Provider integration issues +- New, broken, or poor-quality models + #### docs Add if the issue requests better documentation or docs updates. @@ -66,13 +86,47 @@ TUI issues potentially caused by our underlying TUI library: When assigning to people here are the following rules: -adamdotdev: -ONLY assign adam if the issue will have the "desktop" label. +Desktop / Web: +Use for desktop-labeled issues only. + +- adamdotdevin +- iamdavidhill +- Brendonovich +- nexxeln + +Zen: +ONLY assign if the issue will have the "zen" label. + +- fwang +- MrMushrooooom + +TUI (`packages/opencode/src/cli/cmd/tui/...`): + +- thdxr for TUI UX/UI product decisions and interaction flow +- kommander for OpenTUI engine issues: rendering artifacts, keybind handling, terminal compatibility, SSH behavior, and low-level perf bottlenecks +- rekram1-node for TUI bugs that are not clearly OpenTUI engine issues + +Core (`packages/opencode/...`, excluding TUI subtree): + +- thdxr for sqlite/snapshot/memory bugs and larger architectural core features +- jlongster for opencode server + API feature work (tool currently remaps jlongster -> thdxr until assignable) +- rekram1-node for harness issues, provider issues, and other bug-squashing + +For core bugs that do not clearly map, either thdxr or rekram1-node is acceptable. + +Docs: + +- R44VC0RP + +Windows: + +- Hona (assign any issue that mentions Windows or is likely Windows-specific) -fwang: -ONLY assign fwang if the issue will have the "zen" label. +Determinism rules: -jayair: -ONLY assign jayair if the issue will have the "docs" label. +- If title + body does not contain "zen", do not add the "zen" label +- If "nix" label is added but title + body does not mention nix/nixos, the tool will drop "nix" +- If title + body mentions nix/nixos, assign to `rekram1-node` +- If "desktop" label is added, the tool will override assignee and randomly pick one Desktop / Web owner -In all other cases use best judgment. Avoid assigning to kommander needlessly, when in doubt assign to rekram1-node. +In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random. diff --git a/.opencode/tool/github-triage.ts b/.opencode/tool/github-triage.ts index 1e216f1c8daa..3a70c4e002bc 100644 --- a/.opencode/tool/github-triage.ts +++ b/.opencode/tool/github-triage.ts @@ -1,8 +1,22 @@ /// -// import { Octokit } from "@octokit/rest" import { tool } from "@opencode-ai/plugin" import DESCRIPTION from "./github-triage.txt" +const TEAM = { + desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"], + zen: ["fwang", "MrMushrooooom"], + tui: ["thdxr", "kommander", "rekram1-node"], + core: ["thdxr", "rekram1-node", "jlongster"], + docs: ["R44VC0RP"], + windows: ["Hona"], +} as const + +const ASSIGNEES = [...new Set(Object.values(TEAM).flat())] + +function pick(items: readonly T[]) { + return items[Math.floor(Math.random() * items.length)]! +} + function getIssueNumber(): number { const issue = parseInt(process.env.ISSUE_NUMBER ?? "", 10) if (!issue) throw new Error("ISSUE_NUMBER env var not set") @@ -29,60 +43,79 @@ export default tool({ description: DESCRIPTION, args: { assignee: tool.schema - .enum(["thdxr", "adamdotdevin", "rekram1-node", "fwang", "jayair", "kommander"]) + .enum(ASSIGNEES as [string, ...string[]]) .describe("The username of the assignee") .default("rekram1-node"), labels: tool.schema - .array(tool.schema.enum(["nix", "opentui", "perf", "desktop", "zen", "docs", "windows"])) + .array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"])) .describe("The labels(s) to add to the issue") .default([]), }, async execute(args) { const issue = getIssueNumber() - // const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }) const owner = "anomalyco" const repo = "opencode" const results: string[] = [] + let labels = [...new Set(args.labels.map((x) => (x === "desktop" ? "web" : x)))] + const web = labels.includes("web") + const text = `${process.env.ISSUE_TITLE ?? ""}\n${process.env.ISSUE_BODY ?? ""}`.toLowerCase() + const zen = /\bzen\b/.test(text) || text.includes("opencode black") + const nix = /\bnix(os)?\b/.test(text) + + if (labels.includes("nix") && !nix) { + labels = labels.filter((x) => x !== "nix") + results.push("Dropped label: nix (issue does not mention nix)") + } + + const assignee = nix + ? "rekram1-node" + : web + ? pick(TEAM.desktop) + : args.assignee === "jlongster" + ? "thdxr" + : args.assignee + + if (args.assignee === "jlongster" && assignee === "thdxr") { + results.push("Remapped assignee: jlongster -> thdxr (jlongster not assignable yet)") + } + + if (labels.includes("zen") && !zen) { + throw new Error("Only add the zen label when issue title/body contains 'zen'") + } + + if (web && !nix && !(TEAM.desktop as readonly string[]).includes(assignee)) { + throw new Error("Web issues must be assigned to adamdotdevin, iamdavidhill, Brendonovich, or nexxeln") + } + + if ((TEAM.zen as readonly string[]).includes(assignee) && !labels.includes("zen")) { + throw new Error("Only zen issues should be assigned to fwang or MrMushrooooom") + } - if (args.assignee === "adamdotdevin" && !args.labels.includes("desktop")) { - throw new Error("Only desktop issues should be assigned to adamdotdevin") + if (assignee === "Hona" && !labels.includes("windows")) { + throw new Error("Only windows issues should be assigned to Hona") } - if (args.assignee === "fwang" && !args.labels.includes("zen")) { - throw new Error("Only zen issues should be assigned to fwang") + if (assignee === "R44VC0RP" && !labels.includes("docs")) { + throw new Error("Only docs issues should be assigned to R44VC0RP") } - if (args.assignee === "kommander" && !args.labels.includes("opentui")) { + if (assignee === "kommander" && !labels.includes("opentui")) { throw new Error("Only opentui issues should be assigned to kommander") } - // await octokit.rest.issues.addAssignees({ - // owner, - // repo, - // issue_number: issue, - // assignees: [args.assignee], - // }) await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, { method: "POST", - body: JSON.stringify({ assignees: [args.assignee] }), + body: JSON.stringify({ assignees: [assignee] }), }) - results.push(`Assigned @${args.assignee} to issue #${issue}`) - - const labels: string[] = args.labels.map((label) => (label === "desktop" ? "web" : label)) + results.push(`Assigned @${assignee} to issue #${issue}`) if (labels.length > 0) { - // await octokit.rest.issues.addLabels({ - // owner, - // repo, - // issue_number: issue, - // labels, - // }) await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, { method: "POST", body: JSON.stringify({ labels }), }) - results.push(`Added labels: ${args.labels.join(", ")}`) + results.push(`Added labels: ${labels.join(", ")}`) } return results.join("\n") diff --git a/.opencode/tool/github-triage.txt b/.opencode/tool/github-triage.txt index ae47cf4cb0a8..4369ed23512f 100644 --- a/.opencode/tool/github-triage.txt +++ b/.opencode/tool/github-triage.txt @@ -1,88 +1,6 @@ Use this tool to assign and/or label a GitHub issue. -You can assign the following users: -- thdxr -- adamdotdevin -- fwang -- jayair -- kommander -- rekram1-node +Choose labels and assignee using the current triage policy and ownership rules. +Pick the most fitting labels for the issue and assign one owner. - -You can use the following labels: -- nix -- opentui -- perf -- web -- zen -- docs - -Always try to assign an issue, if in doubt, assign rekram1-node to it. - -## Breakdown of responsibilities: - -### thdxr - -Dax is responsible for managing core parts of the application, for large feature requests, api changes, or things that require significant changes to the codebase assign him. - -This relates to OpenCode server primarily but has overlap with just about anything - -### adamdotdevin - -Adam is responsible for managing the Desktop/Web app. If there is an issue relating to the desktop app or `opencode web` command. Assign him. - - -### fwang - -Frank is responsible for managing Zen, if you see complaints about OpenCode Zen, maybe it's the dashboard, the model quality, billing issues, etc. Assign him to the issue. - -### jayair - -Jay is responsible for documentation. If there is an issue relating to documentation assign him. - -### kommander - -Sebastian is responsible for managing an OpenTUI (a library for building terminal user interfaces). OpenCode's TUI is built with OpenTUI. If there are issues about: -- random characters on screen -- keybinds not working on different terminals -- general terminal stuff -Then assign the issue to Him. - -### rekram1-node - -ALL BUGS SHOULD BE assigned to rekram1-node unless they have the `opentui` label. - -Assign Aiden to an issue as a catch all, if you can't assign anyone else. Most of the time this will be bugs/polish things. -If no one else makes sense to assign, assign rekram1-node to it. - -Always assign to aiden if the issue mentions "acp", "zed", or model performance issues - -## Breakdown of Labels: - -### nix - -Any issue that mentions nix, or nixos should have a nix label - -### opentui - -Anything relating to the TUI itself should have an opentui label - -### perf - -Anything related to slow performance, high ram, high cpu usage, or any other performance related issue should have a perf label - -### desktop - -Anything related to `opencode web` command or the desktop app should have a desktop label. Never add this label for anything terminal/tui related - -### zen - -Anything related to OpenCode Zen, billing, or model quality from Zen should have a zen label - -### docs - -Anything related to the documentation should have a docs label - -### windows - -Use for any issue that involves the windows OS +If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random. diff --git a/infra/console.ts b/infra/console.ts index 4e5a14b04573..9089055821c4 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -145,6 +145,16 @@ const ZEN_MODELS = [ new sst.Secret("ZEN_MODELS18"), new sst.Secret("ZEN_MODELS19"), new sst.Secret("ZEN_MODELS20"), + new sst.Secret("ZEN_MODELS21"), + new sst.Secret("ZEN_MODELS22"), + new sst.Secret("ZEN_MODELS23"), + new sst.Secret("ZEN_MODELS24"), + new sst.Secret("ZEN_MODELS25"), + new sst.Secret("ZEN_MODELS26"), + new sst.Secret("ZEN_MODELS27"), + new sst.Secret("ZEN_MODELS28"), + new sst.Secret("ZEN_MODELS29"), + new sst.Secret("ZEN_MODELS30"), ] const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY") const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY") diff --git a/packages/app/e2e/commands/input-focus.spec.ts b/packages/app/e2e/commands/input-focus.spec.ts new file mode 100644 index 000000000000..4ba1aa3e6908 --- /dev/null +++ b/packages/app/e2e/commands/input-focus.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" + +test("ctrl+l focuses the prompt", async ({ page, gotoSession }) => { + await gotoSession() + + const prompt = page.locator(promptSelector) + await expect(prompt).toBeVisible() + + await page.locator("main").click({ position: { x: 5, y: 5 } }) + await expect(prompt).not.toBeFocused() + + await page.keyboard.press("Control+L") + await expect(prompt).toBeFocused() +}) diff --git a/packages/app/e2e/commands/panels.spec.ts b/packages/app/e2e/commands/panels.spec.ts new file mode 100644 index 000000000000..58c1f0a9af3d --- /dev/null +++ b/packages/app/e2e/commands/panels.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from "../fixtures" +import { modKey } from "../utils" + +const expanded = async (el: { getAttribute: (name: string) => Promise }) => { + const value = await el.getAttribute("aria-expanded") + if (value !== "true" && value !== "false") throw new Error(`Expected aria-expanded to be true|false, got: ${value}`) + return value === "true" +} + +test("review panel can be toggled via keybind", async ({ page, gotoSession }) => { + await gotoSession() + + const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first() + await expect(treeToggle).toBeVisible() + if (await expanded(treeToggle)) await treeToggle.click() + await expect(treeToggle).toHaveAttribute("aria-expanded", "false") + + const reviewToggle = page.getByRole("button", { name: "Toggle review" }).first() + await expect(reviewToggle).toBeVisible() + if (await expanded(reviewToggle)) await reviewToggle.click() + await expect(reviewToggle).toHaveAttribute("aria-expanded", "false") + await expect(page.locator("#review-panel")).toHaveCount(0) + + await page.keyboard.press(`${modKey}+Shift+R`) + await expect(reviewToggle).toHaveAttribute("aria-expanded", "true") + await expect(page.locator("#review-panel")).toBeVisible() + + await page.keyboard.press(`${modKey}+Shift+R`) + await expect(reviewToggle).toHaveAttribute("aria-expanded", "false") + await expect(page.locator("#review-panel")).toHaveCount(0) +}) diff --git a/packages/app/e2e/commands/tab-close.spec.ts b/packages/app/e2e/commands/tab-close.spec.ts new file mode 100644 index 000000000000..981ee561e2bb --- /dev/null +++ b/packages/app/e2e/commands/tab-close.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" +import { modKey } from "../utils" + +test("mod+w closes the active file tab", async ({ page, gotoSession }) => { + await gotoSession() + + await page.locator(promptSelector).click() + await page.keyboard.type("/open") + await expect(page.locator('[data-slash-id="file.open"]').first()).toBeVisible() + await page.keyboard.press("Enter") + + const dialog = page + .getByRole("dialog") + .filter({ has: page.getByPlaceholder(/search files/i) }) + .first() + await expect(dialog).toBeVisible() + + await dialog.getByRole("textbox").first().fill("package.json") + const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first() + await expect(item).toBeVisible({ timeout: 30_000 }) + await item.click() + await expect(dialog).toHaveCount(0) + + const tab = page.getByRole("tab", { name: "package.json" }).first() + await expect(tab).toBeVisible() + await tab.click() + await expect(tab).toHaveAttribute("aria-selected", "true") + + await page.keyboard.press(`${modKey}+W`) + await expect(page.getByRole("tab", { name: "package.json" })).toHaveCount(0) +}) diff --git a/packages/app/e2e/models/model-picker.spec.ts b/packages/app/e2e/models/model-picker.spec.ts index 01e72464cc56..220a0baa1a89 100644 --- a/packages/app/e2e/models/model-picker.spec.ts +++ b/packages/app/e2e/models/model-picker.spec.ts @@ -28,7 +28,6 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession } const key = await target.getAttribute("data-key") if (!key) throw new Error("Failed to resolve model key from list item") - const name = (await target.locator("span").first().innerText()).trim() const model = key.split(":").slice(1).join(":") await input.fill(model) @@ -37,6 +36,13 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession } await expect(dialog).toHaveCount(0) - const form = page.locator(promptSelector).locator("xpath=ancestor::form[1]") - await expect(form.locator('[data-component="button"]').filter({ hasText: name }).first()).toBeVisible() + await page.locator(promptSelector).click() + await page.keyboard.type("/model") + await expect(command).toBeVisible() + await command.hover() + await page.keyboard.press("Enter") + + const dialogAgain = page.getByRole("dialog") + await expect(dialogAgain).toBeVisible() + await expect(dialogAgain.locator(`[data-slot="list-item"][data-key="${key}"][data-selected="true"]`)).toBeVisible() }) diff --git a/packages/app/e2e/prompt/prompt-drop-file-uri.spec.ts b/packages/app/e2e/prompt/prompt-drop-file-uri.spec.ts new file mode 100644 index 000000000000..add2d8d8bc52 --- /dev/null +++ b/packages/app/e2e/prompt/prompt-drop-file-uri.spec.ts @@ -0,0 +1,22 @@ +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" + +test("dropping text/plain file: uri inserts a file pill", async ({ page, gotoSession }) => { + await gotoSession() + + const prompt = page.locator(promptSelector) + await prompt.click() + + const path = process.platform === "win32" ? "C:\\opencode-e2e-drop.txt" : "/tmp/opencode-e2e-drop.txt" + const dt = await page.evaluateHandle((text) => { + const dt = new DataTransfer() + dt.setData("text/plain", text) + return dt + }, `file:${path}`) + + await page.dispatchEvent("body", "drop", { dataTransfer: dt }) + + const pill = page.locator(`${promptSelector} [data-type="file"]`).first() + await expect(pill).toBeVisible() + await expect(pill).toHaveAttribute("data-path", path) +}) diff --git a/packages/app/e2e/prompt/prompt-drop-file.spec.ts b/packages/app/e2e/prompt/prompt-drop-file.spec.ts new file mode 100644 index 000000000000..0a138de9977b --- /dev/null +++ b/packages/app/e2e/prompt/prompt-drop-file.spec.ts @@ -0,0 +1,30 @@ +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" + +test("dropping an image file adds an attachment", async ({ page, gotoSession }) => { + await gotoSession() + + const prompt = page.locator(promptSelector) + await prompt.click() + + const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3+4uQAAAAASUVORK5CYII=" + const dt = await page.evaluateHandle((b64) => { + const dt = new DataTransfer() + const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)) + const file = new File([bytes], "drop.png", { type: "image/png" }) + dt.items.add(file) + return dt + }, png) + + await page.dispatchEvent("body", "drop", { dataTransfer: dt }) + + const img = page.locator('img[alt="drop.png"]').first() + await expect(img).toBeVisible() + + const remove = page.getByRole("button", { name: "Remove attachment" }).first() + await expect(remove).toBeVisible() + + await img.hover() + await remove.click() + await expect(page.locator('img[alt="drop.png"]')).toHaveCount(0) +}) diff --git a/packages/app/e2e/prompt/prompt-multiline.spec.ts b/packages/app/e2e/prompt/prompt-multiline.spec.ts new file mode 100644 index 000000000000..216aa3fdaec2 --- /dev/null +++ b/packages/app/e2e/prompt/prompt-multiline.spec.ts @@ -0,0 +1,18 @@ +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" + +test("shift+enter inserts a newline without submitting", async ({ page, gotoSession }) => { + await gotoSession() + + await expect(page).toHaveURL(/\/session\/?$/) + + const prompt = page.locator(promptSelector) + await prompt.click() + await page.keyboard.type("line one") + await page.keyboard.press("Shift+Enter") + await page.keyboard.type("line two") + + await expect(page).toHaveURL(/\/session\/?$/) + await expect(prompt).toContainText("line one") + await expect(prompt).toContainText("line two") +}) diff --git a/packages/app/e2e/prompt/prompt-slash-terminal.spec.ts b/packages/app/e2e/prompt/prompt-slash-terminal.spec.ts new file mode 100644 index 000000000000..eefce19dc0bb --- /dev/null +++ b/packages/app/e2e/prompt/prompt-slash-terminal.spec.ts @@ -0,0 +1,23 @@ +import { test, expect } from "../fixtures" +import { promptSelector, terminalSelector } from "../selectors" + +test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => { + await gotoSession() + + const prompt = page.locator(promptSelector) + const terminal = page.locator(terminalSelector) + + await expect(terminal).not.toBeVisible() + + await prompt.click() + await page.keyboard.type("/terminal") + await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible() + await page.keyboard.press("Enter") + await expect(terminal).toBeVisible() + + await prompt.click() + await page.keyboard.type("/terminal") + await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible() + await page.keyboard.press("Enter") + await expect(terminal).not.toBeVisible() +}) diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index a196db231a67..9f7afb8cd27d 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -121,7 +121,7 @@ export function ModelSelectorPopover(props: { }} modal={false} placement="top-start" - gutter={8} + gutter={4} > {props.children} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 984888c35d87..91c7625903de 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -32,7 +32,6 @@ import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid import { useProviders } from "@/hooks/use-providers" import { useCommand } from "@/context/command" import { Persist, persisted } from "@/utils/persist" -import { SessionContextUsage } from "@/components/session-context-usage" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" @@ -94,7 +93,6 @@ export const PromptInput: Component = (props) => { const local = useLocal() const files = useFile() const prompt = usePrompt() - const commentCount = createMemo(() => prompt.context.items().filter((item) => !!item.comment?.trim()).length) const layout = useLayout() const comments = useComments() const params = useParams() @@ -105,7 +103,7 @@ export const PromptInput: Component = (props) => { const language = useLanguage() const platform = usePlatform() let editorRef!: HTMLDivElement - let fileInputRef!: HTMLInputElement + let fileInputRef: HTMLInputElement | undefined let scrollRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement @@ -223,14 +221,25 @@ export const PromptInput: Component = (props) => { mode: "normal", applyingHistory: false, }) - const placeholder = createMemo(() => - promptPlaceholder({ - mode: store.mode, - commentCount: commentCount(), - example: language.t(EXAMPLES[store.placeholder]), - t: (key, params) => language.t(key as Parameters[0], params as never), - }), - ) + + const commentCount = createMemo(() => { + if (store.mode === "shell") return 0 + return prompt.context.items().filter((item) => !!item.comment?.trim()).length + }) + + 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 = params.id + if (!sessionID) return false + const messages = sync.data.message[sessionID] + if (!messages) return false + return messages.some((m) => m.role === "user") + }) const MAX_HISTORY = 100 const [history, setHistory] = persisted( @@ -250,6 +259,18 @@ export const PromptInput: Component = (props) => { }), ) + const suggest = createMemo(() => !hasUserPrompt()) + + const placeholder = createMemo(() => + promptPlaceholder({ + mode: store.mode, + commentCount: commentCount(), + example: suggest() ? language.t(EXAMPLES[store.placeholder]) : "", + suggest: suggest(), + t: (key, params) => language.t(key as Parameters[0], params as never), + }), + ) + const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { const length = position === "start" ? 0 : promptLength(p) setStore("applyingHistory", true) @@ -280,6 +301,26 @@ export const PromptInput: Component = (props) => { } const isFocused = createFocusSignal(() => editorRef) + const escBlur = () => platform.platform === "desktop" && platform.os === "macos" + + const pick = () => fileInputRef?.click() + + const setMode = (mode: "normal" | "shell") => { + setStore("mode", mode) + setStore("popover", null) + requestAnimationFrame(() => editorRef?.focus()) + } + + 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, + }, + ]) const closePopover = () => setStore("popover", null) @@ -325,6 +366,7 @@ export const PromptInput: Component = (props) => { createEffect(() => { params.id if (params.id) return + if (!suggest()) return const interval = setInterval(() => { setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length) }, 6500) @@ -815,6 +857,13 @@ export const PromptInput: Component = (props) => { }) 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) { @@ -842,13 +891,39 @@ export const PromptInput: Component = (props) => { return } } - if (store.mode === "shell") { - const { collapsed, cursorPosition, textLength } = getCaretState() - if (event.key === "Escape") { + + 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()) { + 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() @@ -927,17 +1002,12 @@ export const PromptInput: Component = (props) => { if (event.key === "Enter" && !event.shiftKey) { handleSubmit(event) } - if (event.key === "Escape") { - if (store.popover) { - closePopover() - } else if (working()) { - abort() - } - } } + const variants = createMemo(() => ["default", ...local.model.variant.list()]) + return ( -
+
(slashPopoverRef = el)} @@ -957,8 +1027,8 @@ export const PromptInput: Component = (props) => { onSubmit={handleSubmit} classList={{ "group/prompt-input": true, - "bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true, - "rounded-[14px] overflow-clip focus-within:shadow-xs-border": true, + "bg-surface-raised-stronger-non-alpha shadow-xs-border relative z-10": true, + "rounded-[12px] overflow-clip focus-within:shadow-xs-border": true, "border-icon-info-active border-dashed": store.draggingType !== null, [props.class ?? ""]: !!props.class, }} @@ -968,7 +1038,7 @@ export const PromptInput: Component = (props) => { label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")} /> { const active = comments.active() return !!item.commentID && item.commentID === active?.id && item.path === active?.file @@ -988,7 +1058,22 @@ export const PromptInput: Component = (props) => { onRemove={removeImageAttachment} removeLabel={language.t("prompt.attachment.remove")} /> -
(scrollRef = el)}> +
(scrollRef = el)} + onMouseDown={(e) => { + const target = e.target + if (!(target instanceof HTMLElement)) return + if ( + target.closest( + '[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-permissions"]', + ) + ) { + return + } + editorRef?.focus() + }} + >
{ @@ -1009,41 +1094,158 @@ export const PromptInput: Component = (props) => { onKeyDown={handleKeyDown} classList={{ "select-text": true, - "w-full p-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, + "w-full pl-3 pr-2 pt-2 pb-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, "[&_[data-type=file]]:text-syntax-property": true, "[&_[data-type=agent]]:text-syntax-type": true, "font-mono!": store.mode === "shell", }} /> -
+
{placeholder()}
+ +
+ { + const file = e.currentTarget.files?.[0] + if (file) addImageAttachment(file) + e.currentTarget.value = "" + }} + /> + +
+ + + + + + +
+ {language.t("prompt.action.stop")} + {language.t("common.key.esc")} +
+
+ +
+ {language.t("prompt.action.send")} + +
+
+ + } + > + +
+
+
+ + +
+
+ + + +
+
+
-
-
- - -
- - {language.t("prompt.mode.shell")} - {language.t("prompt.mode.shell.exit")} + + +
+
+
+ +
+ {language.t("prompt.mode.shell")} +
- - + + + (x === "default" ? language.t("common.default") : x)} + onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)} + class="capitalize max-w-[160px]" + valueClass="truncate text-13-regular" + triggerStyle={{ height: "28px" }} + variant="ghost" + /> + + +
+ +
+
+
+ - - - - -
-
- { - const file = e.currentTarget.files?.[0] - if (file) addImageAttachment(file) - e.currentTarget.value = "" - }} - /> -
- - - -
+ + - - + +
+ +
- - -
- {language.t("prompt.action.stop")} - {language.t("common.key.esc")} -
-
- -
- {language.t("prompt.action.send")} - -
-
- - } - > - -
- +
) } diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx index b575c3961110..b138fe3ef690 100644 --- a/packages/app/src/components/prompt-input/context-items.tsx +++ b/packages/app/src/components/prompt-input/context-items.tsx @@ -41,10 +41,9 @@ export const PromptContextItems: Component = (props) => { >
props.openComment(item)} diff --git a/packages/app/src/components/prompt-input/placeholder.test.ts b/packages/app/src/components/prompt-input/placeholder.test.ts index b633df829561..5f6aa59e9a41 100644 --- a/packages/app/src/components/prompt-input/placeholder.test.ts +++ b/packages/app/src/components/prompt-input/placeholder.test.ts @@ -9,27 +9,40 @@ describe("promptPlaceholder", () => { mode: "shell", commentCount: 0, example: "example", + suggest: true, t, }) expect(value).toBe("prompt.placeholder.shell") }) test("returns summarize placeholders for comment context", () => { - expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", t })).toBe( + expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", suggest: true, t })).toBe( "prompt.placeholder.summarizeComment", ) - expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", t })).toBe( + expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", suggest: true, t })).toBe( "prompt.placeholder.summarizeComments", ) }) - test("returns default placeholder with example", () => { + test("returns default placeholder with example when suggestions enabled", () => { const value = promptPlaceholder({ mode: "normal", commentCount: 0, example: "translated-example", + suggest: true, t, }) expect(value).toBe("prompt.placeholder.normal:translated-example") }) + + test("returns simple placeholder when suggestions disabled", () => { + const value = promptPlaceholder({ + mode: "normal", + commentCount: 0, + example: "translated-example", + suggest: false, + t, + }) + expect(value).toBe("prompt.placeholder.simple") + }) }) diff --git a/packages/app/src/components/prompt-input/placeholder.ts b/packages/app/src/components/prompt-input/placeholder.ts index 07f6a43b510f..395fee51b1c1 100644 --- a/packages/app/src/components/prompt-input/placeholder.ts +++ b/packages/app/src/components/prompt-input/placeholder.ts @@ -2,6 +2,7 @@ type PromptPlaceholderInput = { mode: "normal" | "shell" commentCount: number example: string + suggest: boolean t: (key: string, params?: Record) => string } @@ -9,5 +10,6 @@ export function promptPlaceholder(input: PromptPlaceholderInput) { if (input.mode === "shell") return input.t("prompt.placeholder.shell") if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments") if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment") + if (!input.suggest) return input.t("prompt.placeholder.simple") return input.t("prompt.placeholder.normal", { example: input.example }) } diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index 259883d61e84..65eb01c797b8 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -40,9 +40,9 @@ export const PromptPopover: Component = (props) => { ref={(el) => { if (props.popover === "slash") props.setSlashPopoverRef(el) }} - class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10 - overflow-auto no-scrollbar flex flex-col p-2 rounded-md - border border-border-base bg-surface-raised-stronger-non-alpha shadow-md" + 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 rounded-[12px] + bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]" onMouseDown={(e) => e.preventDefault()} > diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 9a1fba5d5c49..6b6f4a4e05e6 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -80,6 +80,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { queued.abort.abort() queued.cleanup() pending.delete(sessionID) + globalSync.todo.set(sessionID, undefined) return Promise.resolve() } return sdk.client.session @@ -87,6 +88,9 @@ export function createPromptSubmit(input: PromptSubmitInput) { sessionID, }) .catch(() => {}) + .finally(() => { + globalSync.todo.set(sessionID, undefined) + }) } const restoreCommentItems = (items: CommentItem[]) => { diff --git a/packages/app/src/components/question-dock.tsx b/packages/app/src/components/question-dock.tsx index 5054253b87b5..1a0bbbe974e1 100644 --- a/packages/app/src/components/question-dock.tsx +++ b/packages/app/src/components/question-dock.tsx @@ -1,4 +1,4 @@ -import { For, Show, createMemo, type Component } from "solid-js" +import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" @@ -12,25 +12,98 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => const language = useLanguage() const questions = createMemo(() => props.request.questions) - const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) + const total = createMemo(() => questions().length) const [store, setStore] = createStore({ tab: 0, answers: [] as QuestionAnswer[], custom: [] as string[], + customOn: [] as boolean[], editing: false, sending: false, }) + let root: HTMLDivElement | undefined + const question = createMemo(() => questions()[store.tab]) - const confirm = createMemo(() => !single() && store.tab === questions().length) const options = createMemo(() => question()?.options ?? []) const input = createMemo(() => store.custom[store.tab] ?? "") + const on = createMemo(() => store.customOn[store.tab] === true) const multi = createMemo(() => question()?.multiple === true) - const customPicked = createMemo(() => { - const value = input() - if (!value) return false - return store.answers[store.tab]?.includes(value) ?? false + + const summary = createMemo(() => { + const n = Math.min(store.tab + 1, total()) + return `${n} of ${total()} questions` + }) + + const last = createMemo(() => store.tab >= total() - 1) + + const customUpdate = (value: string, selected: boolean = on()) => { + const prev = input().trim() + const next = value.trim() + + setStore("custom", store.tab, value) + if (!selected) return + + if (multi()) { + setStore("answers", store.tab, (current = []) => { + const removed = prev ? current.filter((item) => item.trim() !== prev) : current + if (!next) return removed + if (removed.some((item) => item.trim() === next)) return removed + return [...removed, next] + }) + return + } + + setStore("answers", store.tab, next ? [next] : []) + } + + const measure = () => { + if (!root) return + + const scroller = document.querySelector(".session-scroller") + const head = scroller instanceof HTMLElement ? scroller.firstElementChild : undefined + const top = + head instanceof HTMLElement && head.classList.contains("sticky") ? head.getBoundingClientRect().bottom : 0 + if (!top) { + root.style.removeProperty("--question-prompt-max-height") + return + } + + const dock = root.closest('[data-component="session-prompt-dock"]') + if (!(dock instanceof HTMLElement)) return + + const dockBottom = dock.getBoundingClientRect().bottom + const below = Math.max(0, dockBottom - root.getBoundingClientRect().bottom) + const gap = 8 + const max = Math.max(240, Math.floor(dockBottom - top - gap - below)) + root.style.setProperty("--question-prompt-max-height", `${max}px`) + } + + onMount(() => { + let raf: number | undefined + const update = () => { + if (raf !== undefined) cancelAnimationFrame(raf) + raf = requestAnimationFrame(() => { + raf = undefined + measure() + }) + } + + update() + window.addEventListener("resize", update) + + const dock = root?.closest('[data-component="session-prompt-dock"]') + const scroller = document.querySelector(".session-scroller") + const observer = new ResizeObserver(update) + if (dock instanceof HTMLElement) observer.observe(dock) + if (scroller instanceof HTMLElement) observer.observe(scroller) + + onCleanup(() => { + window.removeEventListener("resize", update) + observer.disconnect() + if (raf !== undefined) cancelAnimationFrame(raf) + }) }) const fail = (err: unknown) => { @@ -64,23 +137,13 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => } } - const submit = () => { - void reply(questions().map((_, i) => store.answers[i] ?? [])) - } + const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? [])) const pick = (answer: string, custom: boolean = false) => { setStore("answers", store.tab, [answer]) - - if (custom) { - setStore("custom", store.tab, answer) - } - - if (single()) { - void reply([[answer]]) - return - } - - setStore("tab", store.tab + 1) + if (custom) setStore("custom", store.tab, answer) + if (!custom) setStore("customOn", store.tab, false) + setStore("editing", false) } const toggle = (answer: string) => { @@ -90,16 +153,41 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => }) } - const selectTab = (index: number) => { - setStore("tab", index) + const customToggle = () => { + if (store.sending) return + + if (!multi()) { + setStore("customOn", store.tab, true) + setStore("editing", true) + customUpdate(input(), true) + return + } + + const next = !on() + setStore("customOn", store.tab, next) + if (next) { + setStore("editing", true) + customUpdate(input(), true) + return + } + + const value = input().trim() + if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value)) setStore("editing", false) } + const customOpen = () => { + if (store.sending) return + if (!on()) setStore("customOn", store.tab, true) + setStore("editing", true) + customUpdate(input(), true) + } + const selectOption = (optIndex: number) => { if (store.sending) return if (optIndex === options().length) { - setStore("editing", true) + customOpen() return } @@ -112,67 +200,67 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => pick(opt.label) } - const handleCustomSubmit = (e: Event) => { - e.preventDefault() + const commitCustom = () => { + setStore("editing", false) + customUpdate(input()) + } + + const next = () => { if (store.sending) return + if (store.editing) commitCustom() - const value = input().trim() - if (!value) { - setStore("editing", false) + if (store.tab >= total() - 1) { + submit() return } - if (multi()) { - setStore("answers", store.tab, (current = []) => { - if (current.includes(value)) return current - return [...current, value] - }) - setStore("editing", false) - return - } + setStore("tab", store.tab + 1) + setStore("editing", false) + } - pick(value, true) + const back = () => { + if (store.sending) return + if (store.tab <= 0) return + setStore("tab", store.tab - 1) + setStore("editing", false) + } + + const jump = (tab: number) => { + if (store.sending) return + setStore("tab", tab) setStore("editing", false) } return ( -
- -
- - {(q, index) => { - const active = () => index() === store.tab - const answered = () => (store.answers[index()]?.length ?? 0) > 0 - return ( +
(root = el)}> +
+
+
{summary()}
+
+ + {(_, i) => ( - ) - }} - - + onClick={() => jump(i())} + aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`} + /> + )} + +
- -
-
- {question()?.question} - {multi() ? " " + language.t("ui.question.multiHint") : ""} -
+
{question()?.question}
+ {language.t("ui.question.singleHint")}
}> +
{language.t("ui.question.multiHint")}
+
{(opt, i) => { @@ -181,106 +269,156 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => ) }} - - -
- setTimeout(() => el.focus(), 0)} - type="text" - data-slot="custom-input" - placeholder={language.t("ui.question.custom.placeholder")} - value={input()} + + { - setStore("custom", store.tab, e.currentTarget.value) + onClick={customOpen} + > + + + {language.t("ui.messagePart.option.typeOwnAnswer")} + + {input() || language.t("ui.question.custom.placeholder")} + + + + } + > + { + if (store.sending) { + e.preventDefault() + return + } + if (e.target instanceof HTMLTextAreaElement) return + const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]') + if (input instanceof HTMLTextAreaElement) input.focus() + }} + onSubmit={(e) => { + e.preventDefault() + commitCustom() + }} + > + + + {language.t("ui.messagePart.option.typeOwnAnswer")} +