From 7617f594412acd3545bce6f6dd07f86d36b25560 Mon Sep 17 00:00:00 2001 From: ewired <37567272+ewired@users.noreply.github.com> Date: Sat, 27 Dec 2025 19:53:17 -0500 Subject: [PATCH 01/60] Allow line numbers and ranges in autocomplete (#4238) --- .../cmd/tui/component/prompt/autocomplete.tsx | 68 ++++++++++++++++--- 1 file changed, 57 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index cef083ad734..a5823289505 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -12,6 +12,38 @@ import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" +function removeLineRange(input: string) { + const hashIndex = input.lastIndexOf("#") + return hashIndex !== -1 ? input.substring(0, hashIndex) : input +} + +function extractLineRange(input: string) { + const hashIndex = input.lastIndexOf("#") + if (hashIndex === -1) { + return { baseQuery: input } + } + + const baseName = input.substring(0, hashIndex) + const linePart = input.substring(hashIndex + 1) + const lineMatch = linePart.match(/^(\d+)(?:-(\d*))?$/) + + if (!lineMatch) { + return { baseQuery: baseName } + } + + const startLine = Number(lineMatch[1]) + const endLine = lineMatch[2] && startLine < Number(lineMatch[2]) ? Number(lineMatch[2]) : undefined + + return { + lineRange: { + baseName, + startLine, + endLine, + }, + baseQuery: baseName, + } +} + export type AutocompleteRef = { onInput: (value: string) => void onKeyDown: (e: KeyEvent) => void @@ -142,9 +174,11 @@ export function Autocomplete(props: { async (query) => { if (!store.visible || store.visible === "/") return [] + const { lineRange, baseQuery } = extractLineRange(query ?? "") + // Get files from SDK const result = await sdk.client.find.files({ - query: query ?? "", + query: baseQuery, }) const options: AutocompleteOption[] = [] @@ -153,15 +187,27 @@ export function Autocomplete(props: { if (!result.error && result.data) { const width = props.anchor().width - 4 options.push( - ...result.data.map( - (item): AutocompleteOption => ({ - display: Locale.truncateMiddle(item, width), + ...result.data.map((item): AutocompleteOption => { + let url = `file://${process.cwd()}/${item}` + let filename = item + if (lineRange && !item.endsWith("/")) { + filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}` + const urlObj = new URL(url) + urlObj.searchParams.set("start", String(lineRange.startLine)) + if (lineRange.endLine !== undefined) { + urlObj.searchParams.set("end", String(lineRange.endLine)) + } + url = urlObj.toString() + } + + return { + display: Locale.truncateMiddle(filename, width), onSelect: () => { - insertPart(item, { + insertPart(filename, { type: "file", mime: "text/plain", - filename: item, - url: `file://${process.cwd()}/${item}`, + filename, + url, source: { type: "file", text: { @@ -173,8 +219,8 @@ export function Autocomplete(props: { }, }) }, - }), - ), + } + }), ) } @@ -383,8 +429,8 @@ export function Autocomplete(props: { return prev } - const result = fuzzysort.go(currentFilter, mixed, { - keys: [(obj) => obj.display.trimEnd(), "description", (obj) => obj.aliases?.join(" ") ?? ""], + const result = fuzzysort.go(removeLineRange(currentFilter), mixed, { + keys: [(obj) => removeLineRange(obj.display.trimEnd()), "description", (obj) => obj.aliases?.join(" ") ?? ""], limit: 10, scoreFn: (objResults) => { const displayResult = objResults[0] From 613813ac12fa17cfae9b3b26d7b0d2105e273a63 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 28 Dec 2025 00:53:48 +0000 Subject: [PATCH 02/60] chore: generate --- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 4d82f2a5fd8..63174acec23 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -24,4 +24,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index ac6f8480269..7933647718b 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -29,4 +29,4 @@ "publishConfig": { "directory": "dist" } -} \ No newline at end of file +} From 9d485dd307ebced056729ae56903064c66cff85c Mon Sep 17 00:00:00 2001 From: Ivan Pantic Date: Sun, 28 Dec 2025 01:54:27 +0100 Subject: [PATCH 03/60] docs: add opencode-notificator to ecosystem plugins list (#6269) Co-authored-by: Ivan Pantic Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/web/src/content/docs/ecosystem.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index 9c772b99305..de0d5fd3769 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -32,6 +32,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw | [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs | | [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply) | 10x faster code editing with Morph Fast Apply API and lazy edit markers | | [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible | +| [opencode-notificator](https://github.com/panta/opencode-notificator) | Desktop notifications and sound alerts for OpenCode sessions | | [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer) | AI-powered automatic Zellij session naming based on OpenCode context | --- From de28fafb471cca4a79be2b9e0b8767ec852ea5ab Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 27 Dec 2025 19:07:25 -0600 Subject: [PATCH 04/60] fix: search all recent models instead of only top 5 in TUI /models command --- .../src/cli/cmd/tui/component/dialog-model.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index fc0559cd686..bc90dbb5c6e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -37,11 +37,9 @@ export function DialogModel(props: { providerID?: string }) { const recents = local.model.recent() const recentList = showExtra() - ? recents - .filter( - (item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID), - ) - .slice(0, 5) + ? recents.filter( + (item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID), + ) : [] const favoriteOptions = favorites.flatMap((item) => { @@ -182,7 +180,10 @@ export function DialogModel(props: { providerID?: string }) { // Apply fuzzy filtering to each section separately, maintaining section order if (q) { const filteredFavorites = fuzzysort.go(q, favoriteOptions, { keys: ["title"] }).map((x) => x.obj) - const filteredRecents = fuzzysort.go(q, recentOptions, { keys: ["title"] }).map((x) => x.obj) + const filteredRecents = fuzzysort + .go(q, recentOptions, { keys: ["title"] }) + .map((x) => x.obj) + .slice(0, 5) const filteredProviders = fuzzysort.go(q, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj) const filteredPopular = fuzzysort.go(q, popularProviders, { keys: ["title"] }).map((x) => x.obj) return [...filteredFavorites, ...filteredRecents, ...filteredProviders, ...filteredPopular] From 7a94d7a2c5a12c9fbda987b8c63dddd5df3f1393 Mon Sep 17 00:00:00 2001 From: processtrader <232431073+processtrader@users.noreply.github.com> Date: Sun, 28 Dec 2025 02:10:23 +0100 Subject: [PATCH 05/60] fix: stats command to correctly handle `--days 0` for current day statistics (#6259) Co-authored-by: opencode-agent[bot] Co-authored-by: rekram1-node --- packages/opencode/src/cli/cmd/stats.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index f41b23ee971..94f1b549f40 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -82,12 +82,21 @@ async function getAllSessions(): Promise { return sessions } -async function aggregateSessionStats(days?: number, projectFilter?: string): Promise { +export async function aggregateSessionStats(days?: number, projectFilter?: string): Promise { const sessions = await getAllSessions() - const DAYS_IN_SECOND = 24 * 60 * 60 * 1000 - const cutoffTime = days ? Date.now() - days * DAYS_IN_SECOND : 0 + const MS_IN_DAY = 24 * 60 * 60 * 1000 + + const cutoffTime = (() => { + if (days === undefined) return 0 + if (days === 0) { + const now = new Date() + now.setHours(0, 0, 0, 0) + return now.getTime() + } + return Date.now() - days * MS_IN_DAY + })() - let filteredSessions = days ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions + let filteredSessions = cutoffTime > 0 ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions if (projectFilter !== undefined) { if (projectFilter === "") { @@ -198,7 +207,7 @@ async function aggregateSessionStats(days?: number, projectFilter?: string): Pro } } - const actualDays = Math.max(1, Math.ceil((latestTime - earliestTime) / DAYS_IN_SECOND)) + const actualDays = Math.max(1, Math.ceil((latestTime - earliestTime) / MS_IN_DAY)) stats.dateRange = { earliest: earliestTime, latest: latestTime, From 8a2f4ddf70813aa37c69810d5c5a96a1388dd6fa Mon Sep 17 00:00:00 2001 From: Connor Adams Date: Sun, 28 Dec 2025 01:10:51 +0000 Subject: [PATCH 06/60] chore: update `INVALID_DIRS` to include plural 'skills' directory (#6255) --- packages/opencode/src/config/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index c94a34be0e6..807cd46fd26 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -155,7 +155,7 @@ export namespace Config { } }) - const INVALID_DIRS = new Bun.Glob(`{${["agents", "commands", "plugins", "tools"].join(",")}}/`) + const INVALID_DIRS = new Bun.Glob(`{${["agents", "commands", "plugins", "tools", "skills"].join(",")}}/`) async function assertValid(dir: string) { const invalid = await Array.fromAsync( INVALID_DIRS.scan({ From 2fe7a7f2d32e8f61d1e88e2bd2966b66ee9a9cd3 Mon Sep 17 00:00:00 2001 From: Nindaleth Date: Sun, 28 Dec 2025 02:11:30 +0100 Subject: [PATCH 07/60] docs: document attach command (#6254) Co-authored-by: Black_Fox --- packages/web/src/content/docs/cli.mdx | 29 ++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index 4a826e5b3ff..35ef993b8ec 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -57,6 +57,33 @@ opencode agent [command] --- +### attach + +Attach a terminal to an already running OpenCode backend server started via `serve` or `web` commands. + +```bash +opencode attach [url] +``` + +This allows using the TUI with a remote OpenCode backend. For example: + +```bash +# Start the backend server for web/mobile access +opencode web --port 4096 --hostname 0.0.0.0 + +# In another terminal, attach the TUI to the running backend +opencode attach http://10.20.30.40:4096 +``` + +#### Flags + +| Flag | Short | Description | +| ------------ | ----- | --------------------------------- | +| `--dir` | | Working directory to start TUI in | +| `--session` | `-s` | Session ID to continue | + +--- + #### create Create a new agent with custom configuration. @@ -325,7 +352,7 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" ### serve -Start a headless opencode server for API access. Check out the [server docs](/docs/server) for the full HTTP interface. +Start a headless OpenCode server for API access. Check out the [server docs](/docs/server) for the full HTTP interface. ```bash opencode serve From 2c0d9a46cbd394b5f50e12e6a4a03928307607c2 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 28 Dec 2025 01:12:02 +0000 Subject: [PATCH 08/60] chore: generate --- packages/web/src/content/docs/cli.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index 35ef993b8ec..1553dc80ee9 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -77,10 +77,10 @@ opencode attach http://10.20.30.40:4096 #### Flags -| Flag | Short | Description | -| ------------ | ----- | --------------------------------- | -| `--dir` | | Working directory to start TUI in | -| `--session` | `-s` | Session ID to continue | +| Flag | Short | Description | +| ----------- | ----- | --------------------------------- | +| `--dir` | | Working directory to start TUI in | +| `--session` | `-s` | Session ID to continue | --- From e35d97f9d7005a4227eb56cc008cffb230161eda Mon Sep 17 00:00:00 2001 From: scarf Date: Sun, 28 Dec 2025 10:14:56 +0900 Subject: [PATCH 09/60] feat: add bash shell completions (#6239) --- packages/opencode/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 638ee7347db..03ccf76042f 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -77,6 +77,7 @@ const cli = yargs(hideBin(process.argv)) }) }) .usage("\n" + UI.logo()) + .completion("completion", "generate shell completion script") .command(AcpCommand) .command(McpCommand) .command(TuiThreadCommand) From 7ea0d37ee3b01be8788a95db5b6f08690d01465c Mon Sep 17 00:00:00 2001 From: rektide Date: Sun, 28 Dec 2025 01:32:33 +0000 Subject: [PATCH 10/60] Thinking & tool call visibility settings for `/copy` and `/export` (#6243) Co-authored-by: Aiden Cline --- .../src/cli/cmd/tui/routes/session/index.tsx | 51 ++++-- .../cli/cmd/tui/ui/dialog-export-options.tsx | 148 ++++++++++++++++++ 2 files changed, 187 insertions(+), 12 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 177c43a463a..d5298518700 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -52,7 +52,6 @@ import { DialogMessage } from "./dialog-message" import type { PromptInfo } from "../../component/prompt/history" import { iife } from "@/util/iife" import { DialogConfirm } from "@tui/ui/dialog-confirm" -import { DialogPrompt } from "@tui/ui/dialog-prompt" import { DialogTimeline } from "./dialog-timeline" import { DialogForkFromTimeline } from "./dialog-fork-from-timeline" import { DialogSessionRename } from "../../component/dialog-session-rename" @@ -67,7 +66,7 @@ import stripAnsi from "strip-ansi" import { Footer } from "./footer.tsx" import { usePromptRef } from "../../context/prompt" import { Filesystem } from "@/util/filesystem" -import { DialogSubagent } from "./dialog-subagent.tsx" +import { DialogExportOptions } from "../../ui/dialog-export-options" addDefaultParsers(parsers.parsers) @@ -784,8 +783,22 @@ export function Session() { for (const part of parts) { if (part.type === "text" && !part.synthetic) { transcript += `${part.text}\n\n` + } else if (part.type === "reasoning") { + if (showThinking()) { + transcript += `_Thinking:_\n\n${part.text}\n\n` + } } else if (part.type === "tool") { - transcript += `\`\`\`\nTool: ${part.tool}\n\`\`\`\n\n` + transcript += `\`\`\`\nTool: ${part.tool}\n` + if (showDetails() && part.state.input) { + transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`` + } + if (showDetails() && part.state.status === "completed" && part.state.output) { + transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`` + } + if (showDetails() && part.state.status === "error" && part.state.error) { + transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`` + } + transcript += `\n\`\`\`\n\n` } } @@ -812,6 +825,14 @@ export function Session() { const sessionData = session() const sessionMessages = messages() + const defaultFilename = `session-${sessionData.id.slice(0, 8)}.md` + + const options = await DialogExportOptions.show(dialog, defaultFilename, showThinking(), showDetails()) + + if (options === null) return + + const { filename: customFilename, thinking: includeThinking, toolDetails: includeToolDetails } = options + let transcript = `# ${sessionData.title}\n\n` transcript += `**Session ID:** ${sessionData.id}\n` transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n` @@ -826,22 +847,28 @@ export function Session() { for (const part of parts) { if (part.type === "text" && !part.synthetic) { transcript += `${part.text}\n\n` + } else if (part.type === "reasoning") { + if (includeThinking) { + transcript += `_Thinking:_\n\n${part.text}\n\n` + } } else if (part.type === "tool") { - transcript += `\`\`\`\nTool: ${part.tool}\n\`\`\`\n\n` + transcript += `\`\`\`\nTool: ${part.tool}\n` + if (includeToolDetails && part.state.input) { + transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`` + } + if (includeToolDetails && part.state.status === "completed" && part.state.output) { + transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`` + } + if (includeToolDetails && part.state.status === "error" && part.state.error) { + transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`` + } + transcript += `\n\`\`\`\n\n` } } transcript += `---\n\n` } - // Prompt for optional filename - const customFilename = await DialogPrompt.show(dialog, "Export filename", { - value: `session-${sessionData.id.slice(0, 8)}.md`, - }) - - // Cancel if user pressed escape - if (customFilename === null) return - // Save to file in current working directory const exportDir = process.cwd() const filename = customFilename.trim() diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx new file mode 100644 index 00000000000..874a236ee4c --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx @@ -0,0 +1,148 @@ +import { TextareaRenderable, TextAttributes } from "@opentui/core" +import { useTheme } from "../context/theme" +import { useDialog, type DialogContext } from "./dialog" +import { createStore } from "solid-js/store" +import { onMount, Show, type JSX } from "solid-js" +import { useKeyboard } from "@opentui/solid" + +export type DialogExportOptionsProps = { + defaultFilename: string + defaultThinking: boolean + defaultToolDetails: boolean + onConfirm?: (options: { filename: string; thinking: boolean; toolDetails: boolean }) => void + onCancel?: () => void +} + +export function DialogExportOptions(props: DialogExportOptionsProps) { + const dialog = useDialog() + const { theme } = useTheme() + let textarea: TextareaRenderable + const [store, setStore] = createStore({ + thinking: props.defaultThinking, + toolDetails: props.defaultToolDetails, + active: "filename" as "filename" | "thinking" | "toolDetails", + }) + + useKeyboard((evt) => { + if (evt.name === "return") { + props.onConfirm?.({ + filename: textarea.plainText, + thinking: store.thinking, + toolDetails: store.toolDetails, + }) + } + if (evt.name === "tab") { + const order: Array<"filename" | "thinking" | "toolDetails"> = ["filename", "thinking", "toolDetails"] + const currentIndex = order.indexOf(store.active) + const nextIndex = (currentIndex + 1) % order.length + setStore("active", order[nextIndex]) + evt.preventDefault() + } + if (evt.name === "space") { + if (store.active === "thinking") setStore("thinking", !store.thinking) + if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails) + evt.preventDefault() + } + }) + + onMount(() => { + dialog.setSize("medium") + setTimeout(() => { + textarea.focus() + }, 1) + textarea.gotoLineEnd() + }) + + return ( + + + + Export Options + + esc + + + + Filename: + +