diff --git a/STATS.md b/STATS.md index 242ca4c1..0fe0dac5 100644 --- a/STATS.md +++ b/STATS.md @@ -5,3 +5,4 @@ | 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) | | 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) | | 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) | +| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) | diff --git a/bun.lock b/bun.lock index 5457a140..a14065e0 100644 --- a/bun.lock +++ b/bun.lock @@ -82,7 +82,7 @@ "sharp": "0.32.5", "shiki": "3.4.2", "solid-js": "1.9.7", - "toolbeam-docs-theme": "0.3.0", + "toolbeam-docs-theme": "0.4.1", }, "devDependencies": { "@types/node": "catalog:", @@ -1546,7 +1546,7 @@ "token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="], - "toolbeam-docs-theme": ["toolbeam-docs-theme@0.3.0", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-qlBkKRp8HVYV7p7jaG9lT2lvQY7c8b9czZ0tnsJUrN2TBTtEyFJymCdkhhpZNC9U4oGZ7lLk0glRJHrndWvVsg=="], + "toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.1", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-lTI4dHZaVNQky29m7sb36Oy4tWPwxsCuFxFjF8hgGW0vpV+S6qPvI9SwsJFvdE/OHO5DoI7VMbryV1pxZHkkHQ=="], "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], diff --git a/packages/function/src/api.ts b/packages/function/src/api.ts index 1d0e2cd0..0d5e44df 100644 --- a/packages/function/src/api.ts +++ b/packages/function/src/api.ts @@ -35,8 +35,7 @@ export class SyncServer extends DurableObject { ws.close(code, "Durable Object is closing WebSocket") } - async publish(secret: string, key: string, content: any) { - if (secret !== (await this.getSecret())) throw new Error("Invalid secret") + async publish(key: string, content: any) { const sessionID = await this.getSessionID() if ( !key.startsWith(`session/info/${sessionID}`) && @@ -76,6 +75,10 @@ export class SyncServer extends DurableObject { .map(([key, content]) => ({ key, content })) } + public async assertSecret(secret: string) { + if (secret !== (await this.getSecret())) throw new Error("Invalid secret") + } + private async getSecret() { return this.ctx.storage.get("secret") } @@ -84,15 +87,19 @@ export class SyncServer extends DurableObject { return this.ctx.storage.get("sessionID") } - async clear(secret: string) { - await this.assertSecret(secret) + async clear() { + const sessionID = await this.getSessionID() + const list = await this.env.Bucket.list({ + prefix: `session/message/${sessionID}/`, + limit: 1000, + }) + for (const item of list.objects) { + await this.env.Bucket.delete(item.key) + } + await this.env.Bucket.delete(`session/info/${sessionID}`) await this.ctx.storage.deleteAll() } - private async assertSecret(secret: string) { - if (secret !== (await this.getSecret())) throw new Error("Invalid secret") - } - static shortName(id: string) { return id.substring(id.length - 8) } @@ -134,7 +141,17 @@ export default { const secret = body.secret const id = env.SYNC_SERVER.idFromName(SyncServer.shortName(sessionID)) const stub = env.SYNC_SERVER.get(id) - await stub.clear(secret) + await stub.assertSecret(secret) + await stub.clear() + return new Response(JSON.stringify({}), { + headers: { "Content-Type": "application/json" }, + }) + } + + if (request.method === "POST" && method === "share_delete_admin") { + const id = env.SYNC_SERVER.idFromName("oVF8Rsiv") + const stub = env.SYNC_SERVER.get(id) + await stub.clear() return new Response(JSON.stringify({}), { headers: { "Content-Type": "application/json" }, }) @@ -150,7 +167,8 @@ export default { const name = SyncServer.shortName(body.sessionID) const id = env.SYNC_SERVER.idFromName(name) const stub = env.SYNC_SERVER.get(id) - await stub.publish(body.secret, body.key, body.content) + await stub.assertSecret(body.secret) + await stub.publish(body.key, body.content) return new Response(JSON.stringify({}), { headers: { "Content-Type": "application/json" }, }) diff --git a/packages/opencode/bin/opencode b/packages/opencode/bin/opencode index 63c524f6..8f75eb18 100755 --- a/packages/opencode/bin/opencode +++ b/packages/opencode/bin/opencode @@ -49,7 +49,7 @@ else done if [ -z "$resolved" ]; then - printf "It seems that your package manager failed to install the right version of the OpenCode CLI for your platform. You can try manually installing the \"%s\" package\n" "$name" >&2 + printf "It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the \"%s\" package\n" "$name" >&2 exit 1 fi fi diff --git a/packages/opencode/bin/opencode.cmd b/packages/opencode/bin/opencode.cmd index 8bac765c..5908a815 100644 --- a/packages/opencode/bin/opencode.cmd +++ b/packages/opencode/bin/opencode.cmd @@ -48,9 +48,9 @@ set "current_dir=%parent_dir%" goto :search_loop :not_found -echo It seems that your package manager failed to install the right version of the OpenCode CLI for your platform. You can try manually installing the "%name%" package >&2 +echo It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the "%name%" package >&2 exit /b 1 :execute rem Execute the binary with all arguments -"%resolved%" %* \ No newline at end of file +"%resolved%" %* diff --git a/packages/opencode/config.schema.json b/packages/opencode/config.schema.json index 6ee406c0..35dfd6f1 100644 --- a/packages/opencode/config.schema.json +++ b/packages/opencode/config.schema.json @@ -297,6 +297,13 @@ }, "description": "MCP (Model Context Protocol) server configurations" }, + "instructions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional instruction files or patterns to include" + }, "experimental": { "type": "object", "properties": { diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index 7b66e06a..3f4c2005 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -40,7 +40,7 @@ for (const [os, arch] of targets) { console.log(`building ${os}-${arch}`) const name = `${pkg.name}-${os}-${arch}` await $`mkdir -p dist/${name}/bin` - await $`GOOS=${os} GOARCH=${GOARCH[arch]} go build -ldflags="-s -w -X main.Version=${version}" -o ../opencode/dist/${name}/bin/tui ../tui/cmd/opencode/main.go`.cwd( + await $`CGO_ENABLED=0 GOOS=${os} GOARCH=${GOARCH[arch]} go build -ldflags="-s -w -X main.Version=${version}" -o ../opencode/dist/${name}/bin/tui ../tui/cmd/opencode/main.go`.cwd( "../tui", ) await $`bun build --define OPENCODE_VERSION="'${version}'" --compile --minify --target=bun-${os}-${arch} --outfile=dist/${name}/bin/opencode ./src/index.ts ./dist/${name}/bin/tui` @@ -110,6 +110,7 @@ if (!snapshot) { return ( !lower.includes("ignore:") && !lower.includes("ci:") && + !lower.includes("wip:") && !lower.includes("docs:") && !lower.includes("doc:") ) diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts index 1d23ed85..021c49db 100644 --- a/packages/opencode/src/cli/cmd/debug/file.ts +++ b/packages/opencode/src/cli/cmd/debug/file.ts @@ -1,13 +1,6 @@ import { File } from "../../../file" import { bootstrap } from "../../bootstrap" import { cmd } from "../cmd" -import path from "path" - -export const FileCommand = cmd({ - command: "file", - builder: (yargs) => yargs.command(FileReadCommand).demandCommand(), - async handler() {}, -}) const FileReadCommand = cmd({ command: "read ", @@ -19,8 +12,26 @@ const FileReadCommand = cmd({ }), async handler(args) { await bootstrap({ cwd: process.cwd() }, async () => { - const content = await File.read(path.resolve(args.path)) + const content = await File.read(args.path) console.log(content) }) }, }) + +const FileStatusCommand = cmd({ + command: "status", + builder: (yargs) => yargs, + async handler() { + await bootstrap({ cwd: process.cwd() }, async () => { + const status = await File.status() + console.log(JSON.stringify(status, null, 2)) + }) + }, +}) + +export const FileCommand = cmd({ + command: "file", + builder: (yargs) => + yargs.command(FileReadCommand).command(FileStatusCommand).demandCommand(), + async handler() {}, +}) diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 87f6f982..04e90978 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -100,7 +100,7 @@ export const TuiCommand = cmd({ UI.empty() UI.println(UI.logo(" ")) const result = await Bun.spawn({ - cmd: [process.execPath, "auth", "login"], + cmd: [...getOpencodeCommand(), "auth", "login"], cwd: process.cwd(), stdout: "inherit", stderr: "inherit", @@ -112,3 +112,25 @@ export const TuiCommand = cmd({ } }, }) + +/** + * Get the correct command to run opencode CLI + * In development: ["bun", "run", "packages/opencode/src/index.ts"] + * In production: ["/path/to/opencode"] + */ +function getOpencodeCommand(): string[] { + // Check if OPENCODE_BIN_PATH is set (used by shell wrapper scripts) + if (process.env["OPENCODE_BIN_PATH"]) { + return [process.env["OPENCODE_BIN_PATH"]] + } + + const execPath = process.execPath.toLowerCase() + + if (Installation.isDev()) { + // In development, use bun to run the TypeScript entry point + return [execPath, "run", process.argv[1]] + } + + // In production, use the current executable path + return [process.execPath] +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index cf2f3479..eb67778e 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -176,6 +176,10 @@ export namespace Config { .record(z.string(), Mcp) .optional() .describe("MCP (Model Context Protocol) server configurations"), + instructions: z + .array(z.string()) + .optional() + .describe("Additional instruction files or patterns to include"), experimental: z .object({ hook: z diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index ead4a290..d9e61fa5 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -3,8 +3,14 @@ import { Bus } from "../bus" import { $ } from "bun" import { createPatch } from "diff" import path from "path" +import * as git from "isomorphic-git" +import { App } from "../app/app" +import fs from "fs" +import { Log } from "../util/log" export namespace File { + const log = Log.create({ service: "file" }) + export const Event = { Edited: Bus.event( "file.edited", @@ -14,25 +20,109 @@ export namespace File { ), } - export async function read(file: string) { - const content = await Bun.file(file).text() - const gitDiff = await $`git diff HEAD -- ${file}` - .cwd(path.dirname(file)) + export async function status() { + const app = App.info() + if (!app.git) return [] + + const diffOutput = await $`git diff --numstat HEAD` + .cwd(app.path.cwd) .quiet() .nothrow() .text() - if (gitDiff.trim()) { - const relativePath = path.relative(process.cwd(), file) - const originalContent = await $`git show HEAD:./${relativePath}` - .cwd(process.cwd()) - .quiet() - .nothrow() - .text() - if (originalContent.trim()) { - const patch = createPatch(file, originalContent, content) - return patch + + const changedFiles = [] + + if (diffOutput.trim()) { + const lines = diffOutput.trim().split("\n") + for (const line of lines) { + const [added, removed, filepath] = line.split("\t") + changedFiles.push({ + file: filepath, + added: added === "-" ? 0 : parseInt(added, 10), + removed: removed === "-" ? 0 : parseInt(removed, 10), + status: "modified", + }) } } - return content.trim() + + const untrackedOutput = await $`git ls-files --others --exclude-standard` + .cwd(app.path.cwd) + .quiet() + .nothrow() + .text() + + if (untrackedOutput.trim()) { + const untrackedFiles = untrackedOutput.trim().split("\n") + for (const filepath of untrackedFiles) { + try { + const content = await Bun.file( + path.join(app.path.root, filepath), + ).text() + const lines = content.split("\n").length + changedFiles.push({ + file: filepath, + added: lines, + removed: 0, + status: "added", + }) + } catch { + continue + } + } + } + + // Get deleted files + const deletedOutput = await $`git diff --name-only --diff-filter=D HEAD` + .cwd(app.path.cwd) + .quiet() + .nothrow() + .text() + + if (deletedOutput.trim()) { + const deletedFiles = deletedOutput.trim().split("\n") + for (const filepath of deletedFiles) { + changedFiles.push({ + file: filepath, + added: 0, + removed: 0, // Could get original line count but would require another git command + status: "deleted", + }) + } + } + + return changedFiles.map((x) => ({ + ...x, + file: path.relative(app.path.cwd, path.join(app.path.root, x.file)), + })) + } + + export async function read(file: string) { + using _ = log.time("read", { file }) + const app = App.info() + const full = path.join(app.path.cwd, file) + const content = await Bun.file(full) + .text() + .catch(() => "") + .then((x) => x.trim()) + if (app.git) { + const rel = path.relative(app.path.root, full) + const diff = await git.status({ + fs, + dir: app.path.root, + filepath: rel, + }) + if (diff !== "unmodified") { + const original = await $`git show HEAD:${rel}` + .cwd(app.path.root) + .quiet() + .nothrow() + .text() + const patch = createPatch(file, original, content, "old", "new", { + context: Infinity, + }) + return { type: "patch", content: patch } + } + } + return { type: "raw", content } } } diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 5ebc2b43..a975d34b 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -32,7 +32,7 @@ export namespace Ripgrep { }), }) - const Match = z.object({ + export const Match = z.object({ type: z.literal("match"), data: z.object({ path: z.object({ @@ -86,9 +86,14 @@ export namespace Ripgrep { export type End = z.infer export type Summary = z.infer const PLATFORM = { - darwin: { platform: "apple-darwin", extension: "tar.gz" }, - linux: { platform: "unknown-linux-musl", extension: "tar.gz" }, - win32: { platform: "pc-windows-msvc", extension: "zip" }, + "arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" }, + "arm64-linux": { + platform: "aarch64-unknown-linux-gnu", + extension: "tar.gz", + }, + "x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" }, + "x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" }, + "x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" }, } as const export const ExtractionFailedError = NamedError.create( @@ -124,15 +129,13 @@ export namespace Ripgrep { const file = Bun.file(filepath) if (!(await file.exists())) { - const archMap = { x64: "x86_64", arm64: "aarch64" } as const - const arch = archMap[process.arch as keyof typeof archMap] ?? process.arch - - const config = PLATFORM[process.platform as keyof typeof PLATFORM] - if (!config) - throw new UnsupportedPlatformError({ platform: process.platform }) + const platformKey = + `${process.arch}-${process.platform}` as keyof typeof PLATFORM + const config = PLATFORM[platformKey] + if (!config) throw new UnsupportedPlatformError({ platform: platformKey }) const version = "14.1.1" - const filename = `ripgrep-${version}-${arch}-${config.platform}.${config.extension}` + const filename = `ripgrep-${version}-${config.platform}.${config.extension}` const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}` const response = await fetch(url) @@ -145,8 +148,8 @@ export namespace Ripgrep { if (config.extension === "tar.gz") { const args = ["tar", "-xzf", archivePath, "--strip-components=1"] - if (process.platform === "darwin") args.push("--include=*/rg") - if (process.platform === "linux") args.push("--wildcards", "*/rg") + if (platformKey.endsWith("-darwin")) args.push("--include=*/rg") + if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg") const proc = Bun.spawn(args, { cwd: Global.Path.bin, @@ -177,7 +180,7 @@ export namespace Ripgrep { }) } await fs.unlink(archivePath) - if (process.platform !== "win32") await fs.chmod(filepath, 0o755) + if (!platformKey.endsWith("-win32")) await fs.chmod(filepath, 0o755) } return { diff --git a/packages/opencode/src/file/watch.ts b/packages/opencode/src/file/watch.ts index 2a702984..eec450ed 100644 --- a/packages/opencode/src/file/watch.ts +++ b/packages/opencode/src/file/watch.ts @@ -22,28 +22,30 @@ export namespace FileWatcher { "file.watcher", () => { const app = App.use() - const watcher = fs.watch( - app.info.path.cwd, - { recursive: true }, - (event, file) => { - log.info("change", { file, event }) - if (!file) return - // for some reason async local storage is lost here - // https://github.com/oven-sh/bun/issues/20754 - App.provideExisting(app, async () => { - Bus.publish(Event.Updated, { - file, - event, + try { + const watcher = fs.watch( + app.info.path.cwd, + { recursive: true }, + (event, file) => { + log.info("change", { file, event }) + if (!file) return + // for some reason async local storage is lost here + // https://github.com/oven-sh/bun/issues/20754 + App.provideExisting(app, async () => { + Bus.publish(Event.Updated, { + file, + event, + }) }) - }) - }, - ) - return { - watcher, + }, + ) + return { watcher } + } catch { + return {} } }, async (state) => { - state.watcher.close() + state.watcher?.close() }, )() } diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 78cc6b92..754b75d4 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -31,7 +31,7 @@ export namespace Format { const result = [] for (const item of Object.values(Formatter)) { if (!item.extensions.includes(ext)) continue - if (!isEnabled(item)) continue + if (!(await isEnabled(item))) continue result.push(item) } return result diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index e4280bf2..2c73feb8 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -4,10 +4,34 @@ import { LSPClient } from "./client" import path from "path" import { LSPServer } from "./server" import { Ripgrep } from "../file/ripgrep" +import { z } from "zod" export namespace LSP { const log = Log.create({ service: "lsp" }) + export const Symbol = z + .object({ + name: z.string(), + kind: z.number(), + location: z.object({ + uri: z.string(), + range: z.object({ + start: z.object({ + line: z.number(), + character: z.number(), + }), + end: z.object({ + line: z.number(), + character: z.number(), + }), + }), + }), + }) + .openapi({ + ref: "LSP.Symbol", + }) + export type Symbol = z.infer + const state = App.state( "lsp", async (app) => { @@ -96,7 +120,7 @@ export namespace LSP { client.connection.sendRequest("workspace/symbol", { query, }), - ) + ).then((result) => result.flat() as LSP.Symbol[]) } async function run( diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 8dde48ee..ce7972f5 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -57,6 +57,7 @@ export namespace LSPServer { PATH: process.env["PATH"] + ":" + Global.Path.bin, }) if (!bin) { + if (!Bun.which("go")) return log.info("installing gopls") const proc = Bun.spawn({ cmd: ["go", "install", "golang.org/x/tools/gopls@latest"], diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 3374e3b2..f05d15ce 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -99,11 +99,25 @@ export namespace Provider { }) info.access = tokens.access } + let isAgentCall = false + try { + const body = + typeof init.body === "string" + ? JSON.parse(init.body) + : init.body + if (body?.messages) { + isAgentCall = body.messages.some( + (msg: any) => + msg.role && ["tool", "assistant"].includes(msg.role), + ) + } + } catch {} const headers = { ...init.headers, ...copilot.HEADERS, Authorization: `Bearer ${info.access}`, "Openai-Intent": "conversation-edits", + "X-Initiator": isAgentCall ? "agent" : "user", } delete headers["x-api-key"] return fetch(input, { @@ -191,6 +205,17 @@ export namespace Provider { }, } }, + openrouter: async () => { + return { + autoload: false, + options: { + headers: { + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }, + }, + } + }, } const state = App.state("provider", async () => { diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index d636e5c6..df645cd8 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -14,6 +14,8 @@ import { NamedError } from "../util/error" import { ModelsDev } from "../provider/models" import { Ripgrep } from "../file/ripgrep" import { Config } from "../config/config" +import { File } from "../file" +import { LSP } from "../lsp" const ERRORS = { 400: { @@ -73,7 +75,7 @@ export namespace Server { documentation: { info: { title: "opencode", - version: "0.0.2", + version: "0.0.3", description: "opencode api", }, openapi: "3.0.0", @@ -492,12 +494,44 @@ export namespace Server { }, ) .get( - "/file", + "/find", describeRoute({ - description: "Search for files", + description: "Find text in files", responses: { 200: { - description: "Search for files", + description: "Matches", + content: { + "application/json": { + schema: resolver(Ripgrep.Match.shape.data.array()), + }, + }, + }, + }, + }), + zValidator( + "query", + z.object({ + pattern: z.string(), + }), + ), + async (c) => { + const app = App.info() + const pattern = c.req.valid("query").pattern + const result = await Ripgrep.search({ + cwd: app.path.cwd, + pattern, + limit: 10, + }) + return c.json(result) + }, + ) + .get( + "/find/file", + describeRoute({ + description: "Find files", + responses: { + 200: { + description: "File paths", content: { "application/json": { schema: resolver(z.string().array()), @@ -523,6 +557,98 @@ export namespace Server { return c.json(result) }, ) + .get( + "/find/symbol", + describeRoute({ + description: "Find workspace symbols", + responses: { + 200: { + description: "Symbols", + content: { + "application/json": { + schema: resolver(z.unknown().array()), + }, + }, + }, + }, + }), + zValidator( + "query", + z.object({ + query: z.string(), + }), + ), + async (c) => { + const query = c.req.valid("query").query + const result = await LSP.workspaceSymbol(query) + return c.json(result) + }, + ) + .get( + "/file", + describeRoute({ + description: "Read a file", + responses: { + 200: { + description: "File content", + content: { + "application/json": { + schema: resolver( + z.object({ + type: z.enum(["raw", "patch"]), + content: z.string(), + }), + ), + }, + }, + }, + }, + }), + zValidator( + "query", + z.object({ + path: z.string(), + }), + ), + async (c) => { + const path = c.req.valid("query").path + const content = await File.read(path) + log.info("read file", { + path, + content: content.content, + }) + return c.json(content) + }, + ) + .get( + "/file/status", + describeRoute({ + description: "Get file status", + responses: { + 200: { + description: "File status", + content: { + "application/json": { + schema: resolver( + z + .object({ + file: z.string(), + added: z.number().int(), + removed: z.number().int(), + status: z.enum(["added", "deleted", "modified"]), + }) + .array(), + ), + }, + }, + }, + }, + }), + async (c) => { + const content = await File.status() + return c.json(content) + }, + ) return result } diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index e5dbffac..71e894f8 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -34,6 +34,7 @@ import type { ModelsDev } from "../provider/models" import { Installation } from "../installation" import { Config } from "../config/config" import { ProviderTransform } from "../provider/transform" +import { Snapshot } from "../snapshot" export namespace Session { const log = Log.create({ service: "session" }) @@ -53,6 +54,13 @@ export namespace Session { created: z.number(), updated: z.number(), }), + revert: z + .object({ + messageID: z.string(), + part: z.number(), + snapshot: z.string().optional(), + }) + .optional(), }) .openapi({ ref: "Session", @@ -285,6 +293,37 @@ export namespace Session { l.info("chatting") const model = await Provider.getModel(input.providerID, input.modelID) let msgs = await messages(input.sessionID) + const session = await get(input.sessionID) + + if (session.revert) { + const trimmed = [] + for (const msg of msgs) { + if ( + msg.id > session.revert.messageID || + (msg.id === session.revert.messageID && session.revert.part === 0) + ) { + await Storage.remove( + "session/message/" + input.sessionID + "/" + msg.id, + ) + await Bus.publish(Message.Event.Removed, { + sessionID: input.sessionID, + messageID: msg.id, + }) + continue + } + + if (msg.id === session.revert.messageID) { + if (session.revert.part === 0) break + msg.parts = msg.parts.slice(0, session.revert.part) + } + trimmed.push(msg) + } + msgs = trimmed + await update(input.sessionID, (draft) => { + draft.revert = undefined + }) + } + const previous = msgs.at(-1) // auto summarize if too long @@ -319,7 +358,6 @@ export namespace Session { if (lastSummary) msgs = msgs.filter((msg) => msg.id >= lastSummary.id) const app = App.info() - const session = await get(input.sessionID) if (msgs.length === 0 && !session.parentID) { generateText({ maxTokens: input.providerID === "google" ? 1024 : 20, @@ -349,6 +387,7 @@ export namespace Session { }) .catch(() => {}) } + const snapshot = await Snapshot.create(input.sessionID) const msg: Message.Info = { role: "user", id: Identifier.ascending("message"), @@ -359,6 +398,7 @@ export namespace Session { }, sessionID: input.sessionID, tool: {}, + snapshot, }, } await updateMessage(msg) @@ -373,6 +413,7 @@ export namespace Session { role: "assistant", parts: [], metadata: { + snapshot, assistant: { system, path: { @@ -424,6 +465,7 @@ export namespace Session { }) next.metadata!.tool![opts.toolCallId] = { ...result.metadata, + snapshot: await Snapshot.create(input.sessionID), time: { start, end: Date.now(), @@ -436,6 +478,7 @@ export namespace Session { error: true, message: e.toString(), title: e.toString(), + snapshot: await Snapshot.create(input.sessionID), time: { start, end: Date.now(), @@ -457,6 +500,7 @@ export namespace Session { const result = await execute(args, opts) next.metadata!.tool![opts.toolCallId] = { ...result.metadata, + snapshot: await Snapshot.create(input.sessionID), time: { start, end: Date.now(), @@ -471,6 +515,7 @@ export namespace Session { next.metadata!.tool![opts.toolCallId] = { error: true, message: e.toString(), + snapshot: await Snapshot.create(input.sessionID), title: "mcp", time: { start, @@ -735,6 +780,51 @@ export namespace Session { return next } + export async function revert(input: { + sessionID: string + messageID: string + part: number + }) { + const message = await getMessage(input.sessionID, input.messageID) + if (!message) return + const part = message.parts[input.part] + if (!part) return + const session = await get(input.sessionID) + const snapshot = + session.revert?.snapshot ?? (await Snapshot.create(input.sessionID)) + const old = (() => { + if (message.role === "assistant") { + const lastTool = message.parts.findLast( + (part, index) => + part.type === "tool-invocation" && index < input.part, + ) + if (lastTool && lastTool.type === "tool-invocation") + return message.metadata.tool[lastTool.toolInvocation.toolCallId] + .snapshot + } + return message.metadata.snapshot + })() + if (old) await Snapshot.restore(input.sessionID, old) + await update(input.sessionID, (draft) => { + draft.revert = { + messageID: input.messageID, + part: input.part, + snapshot, + } + }) + } + + export async function unrevert(sessionID: string) { + const session = await get(sessionID) + if (!session) return + if (!session.revert) return + if (session.revert.snapshot) + await Snapshot.restore(sessionID, session.revert.snapshot) + update(sessionID, (draft) => { + draft.revert = undefined + }) + } + export async function summarize(input: { sessionID: string providerID: string diff --git a/packages/opencode/src/session/message.ts b/packages/opencode/src/session/message.ts index b2171fa4..2d319e87 100644 --- a/packages/opencode/src/session/message.ts +++ b/packages/opencode/src/session/message.ts @@ -159,6 +159,7 @@ export namespace Message { z .object({ title: z.string(), + snapshot: z.string().optional(), time: z.object({ start: z.number(), end: z.number(), @@ -188,11 +189,7 @@ export namespace Message { }), }) .optional(), - user: z - .object({ - snapshot: z.string().optional(), - }) - .optional(), + snapshot: z.string().optional(), }) .openapi({ ref: "MessageMetadata" }), }) @@ -208,6 +205,13 @@ export namespace Message { info: Info, }), ), + Removed: Bus.event( + "message.removed", + z.object({ + sessionID: z.string(), + messageID: z.string(), + }), + ), PartUpdated: Bus.event( "message.part.updated", z.object({ diff --git a/packages/opencode/src/session/prompt/anthropic.txt b/packages/opencode/src/session/prompt/anthropic.txt index f70bf05b..45b001e4 100644 --- a/packages/opencode/src/session/prompt/anthropic.txt +++ b/packages/opencode/src/session/prompt/anthropic.txt @@ -134,7 +134,7 @@ The user will primarily request you perform software engineering tasks. This inc - Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially. - Implement the solution using all tools available to you - Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach. -- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to CLAUDE.md so that you will know to run it next time. +- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time. NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive. - Tool results and user messages may include tags. tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result. diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 1c77824b..722964ea 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -2,6 +2,7 @@ import { App } from "../app/app" import { Ripgrep } from "../file/ripgrep" import { Global } from "../global" import { Filesystem } from "../util/filesystem" +import { Config } from "../config/config" import path from "path" import os from "os" @@ -55,8 +56,10 @@ export namespace SystemPrompt { "CLAUDE.md", "CONTEXT.md", // deprecated ] + export async function custom() { const { cwd, root } = App.info().path + const config = await Config.get() const found = [] for (const item of CUSTOM_FILES) { const matches = await Filesystem.findUp(item, cwd, root) @@ -72,6 +75,18 @@ export namespace SystemPrompt { .text() .catch(() => ""), ) + + if (config.instructions) { + for (const instruction of config.instructions) { + try { + const matches = await Filesystem.globUp(instruction, cwd, root) + found.push(...matches.map((x) => Bun.file(x).text())) + } catch { + continue // Skip invalid glob patterns + } + } + } + return Promise.all(found).then((result) => result.filter(Boolean)) } diff --git a/packages/opencode/src/tool/read.txt b/packages/opencode/src/tool/read.txt index b00740c1..d1bf8c5d 100644 --- a/packages/opencode/src/tool/read.txt +++ b/packages/opencode/src/tool/read.txt @@ -7,7 +7,7 @@ Usage: - You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters - Any lines longer than 2000 characters will be truncated - Results are returned using cat -n format, with line numbers starting at 1 -- This tool allows OpenCode to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as OpenCode is a multimodal LLM. +- This tool allows opencode to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as opencode is a multimodal LLM. - You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful. - You will regularly be asked to read screenshots. If the user provides a path to a screenshot ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths like /var/folders/123/abc/T/TemporaryItems/NSIRD_screencaptureui_ZfB1tD/Screenshot.png - If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents. diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index bddc4025..c4fd163c 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -15,4 +15,28 @@ export namespace Filesystem { } return result } + + export async function globUp(pattern: string, start: string, stop?: string) { + let current = start + const result = [] + while (true) { + try { + const glob = new Bun.Glob(pattern) + for await (const match of glob.scan({ + cwd: current, + onlyFiles: true, + dot: true, + })) { + result.push(join(current, match)) + } + } catch { + // Skip invalid glob patterns + } + if (stop === current) break + const parent = dirname(current) + if (parent === current) break + current = parent + } + return result + } } diff --git a/packages/opencode/src/util/lazy.ts b/packages/opencode/src/util/lazy.ts index 3533bcc8..935ebe0f 100644 --- a/packages/opencode/src/util/lazy.ts +++ b/packages/opencode/src/util/lazy.ts @@ -4,6 +4,7 @@ export function lazy(fn: () => T) { return (): T => { if (loaded) return value as T + loaded = true value = fn() return value as T } diff --git a/packages/tui/go.mod b/packages/tui/go.mod index 0ea1f9da..6cd1bae6 100644 --- a/packages/tui/go.mod +++ b/packages/tui/go.mod @@ -15,7 +15,7 @@ require ( github.com/muesli/reflow v0.3.0 github.com/muesli/termenv v0.16.0 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 - github.com/sst/opencode-sdk-go v0.1.0-alpha.7 + github.com/sst/opencode-sdk-go v0.1.0-alpha.8 github.com/tidwall/gjson v1.14.4 rsc.io/qr v0.2.0 ) diff --git a/packages/tui/go.sum b/packages/tui/go.sum index 159f2b20..ac6981f2 100644 --- a/packages/tui/go.sum +++ b/packages/tui/go.sum @@ -181,8 +181,8 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/sst/opencode-sdk-go v0.1.0-alpha.7 h1:trfzTMn9o/h2fxE4z+BtJPZvCTdVHjwgXnAH/rTAx0I= -github.com/sst/opencode-sdk-go v0.1.0-alpha.7/go.mod h1:uagorfAHZsVy6vf0xY6TlQraM4uCILdZ5tKKhl1oToM= +github.com/sst/opencode-sdk-go v0.1.0-alpha.8 h1:Tp7nbckbMCwAA/ieVZeeZCp79xXtrPMaWLRk5mhNwrw= +github.com/sst/opencode-sdk-go v0.1.0-alpha.8/go.mod h1:uagorfAHZsVy6vf0xY6TlQraM4uCILdZ5tKKhl1oToM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 2369b196..9b341c19 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -20,9 +20,6 @@ import ( "github.com/sst/opencode/internal/util" ) -var RootPath string -var CwdPath string - type App struct { Info opencode.App Version string @@ -38,6 +35,7 @@ type App struct { } type SessionSelectedMsg = *opencode.Session +type SessionLoadedMsg struct{} type ModelSelectedMsg struct { Provider opencode.Provider Model opencode.Model @@ -54,6 +52,9 @@ type CompletionDialogTriggeredMsg struct { type OptimisticMessageAddedMsg struct { Message opencode.Message } +type FileRenderedMsg struct { + FilePath string +} func New( ctx context.Context, @@ -61,8 +62,8 @@ func New( appInfo opencode.App, httpClient *opencode.Client, ) (*App, error) { - RootPath = appInfo.Path.Root - CwdPath = appInfo.Path.Cwd + util.RootPath = appInfo.Path.Root + util.CwdPath = appInfo.Path.Cwd configInfo, err := httpClient.Config.Get(ctx) if err != nil { @@ -125,6 +126,19 @@ func New( return app, nil } +func (a *App) Key(commandName commands.CommandName) string { + t := theme.CurrentTheme() + base := styles.NewStyle().Background(t.Background()).Foreground(t.Text()).Bold(true).Render + muted := styles.NewStyle().Background(t.Background()).Foreground(t.TextMuted()).Faint(true).Render + command := a.Commands[commandName] + kb := command.Keybindings[0] + key := kb.Key + if kb.RequiresLeader { + key = a.Config.Keybinds.Leader + " " + kb.Key + } + return base(key) + muted(" "+command.Description) +} + func (a *App) InitializeProvider() tea.Cmd { return func() tea.Msg { providersResponse, err := a.Client.Config.Providers(context.Background()) diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go index 4ef45883..13fb3d5e 100644 --- a/packages/tui/internal/commands/command.go +++ b/packages/tui/internal/commands/command.go @@ -80,13 +80,15 @@ const ( ToolDetailsCommand CommandName = "tool_details" ModelListCommand CommandName = "model_list" ThemeListCommand CommandName = "theme_list" + FileListCommand CommandName = "file_list" + FileCloseCommand CommandName = "file_close" + FileSearchCommand CommandName = "file_search" + FileDiffToggleCommand CommandName = "file_diff_toggle" ProjectInitCommand CommandName = "project_init" InputClearCommand CommandName = "input_clear" InputPasteCommand CommandName = "input_paste" InputSubmitCommand CommandName = "input_submit" InputNewlineCommand CommandName = "input_newline" - HistoryPreviousCommand CommandName = "history_previous" - HistoryNextCommand CommandName = "history_next" MessagesPageUpCommand CommandName = "messages_page_up" MessagesPageDownCommand CommandName = "messages_page_down" MessagesHalfPageUpCommand CommandName = "messages_half_page_up" @@ -95,6 +97,9 @@ const ( MessagesNextCommand CommandName = "messages_next" MessagesFirstCommand CommandName = "messages_first" MessagesLastCommand CommandName = "messages_last" + MessagesLayoutToggleCommand CommandName = "messages_layout_toggle" + MessagesCopyCommand CommandName = "messages_copy" + MessagesRevertCommand CommandName = "messages_revert" AppExitCommand CommandName = "app_exit" ) @@ -184,6 +189,27 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry { Keybindings: parseBindings("t"), Trigger: "themes", }, + { + Name: FileListCommand, + Description: "list files", + Keybindings: parseBindings("f"), + Trigger: "files", + }, + { + Name: FileCloseCommand, + Description: "close file", + Keybindings: parseBindings("esc"), + }, + { + Name: FileSearchCommand, + Description: "search file", + Keybindings: parseBindings("/"), + }, + { + Name: FileDiffToggleCommand, + Description: "split/unified diff", + Keybindings: parseBindings("v"), + }, { Name: ProjectInitCommand, Description: "create/update AGENTS.md", @@ -210,16 +236,6 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry { Description: "insert newline", Keybindings: parseBindings("shift+enter", "ctrl+j"), }, - // { - // Name: HistoryPreviousCommand, - // Description: "previous prompt", - // Keybindings: parseBindings("up"), - // }, - // { - // Name: HistoryNextCommand, - // Description: "next prompt", - // Keybindings: parseBindings("down"), - // }, { Name: MessagesPageUpCommand, Description: "page up", @@ -243,12 +259,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry { { Name: MessagesPreviousCommand, Description: "previous message", - Keybindings: parseBindings("ctrl+alt+k"), + Keybindings: parseBindings("ctrl+up"), }, { Name: MessagesNextCommand, Description: "next message", - Keybindings: parseBindings("ctrl+alt+j"), + Keybindings: parseBindings("ctrl+down"), }, { Name: MessagesFirstCommand, @@ -260,6 +276,21 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry { Description: "last message", Keybindings: parseBindings("ctrl+alt+g"), }, + { + Name: MessagesLayoutToggleCommand, + Description: "toggle layout", + Keybindings: parseBindings("p"), + }, + { + Name: MessagesCopyCommand, + Description: "copy message", + Keybindings: parseBindings("y"), + }, + { + Name: MessagesRevertCommand, + Description: "revert message", + Keybindings: parseBindings("u"), + }, { Name: AppExitCommand, Description: "exit the app", diff --git a/packages/tui/internal/completions/commands.go b/packages/tui/internal/completions/commands.go index 21a26cbc..c73923e8 100644 --- a/packages/tui/internal/completions/commands.go +++ b/packages/tui/internal/completions/commands.go @@ -25,13 +25,6 @@ func (c *CommandCompletionProvider) GetId() string { return "commands" } -func (c *CommandCompletionProvider) GetEntry() dialog.CompletionItemI { - return dialog.NewCompletionItem(dialog.CompletionItem{ - Title: "Commands", - Value: "commands", - }) -} - func (c *CommandCompletionProvider) GetEmptyMessage() string { return "no matching commands" } diff --git a/packages/tui/internal/completions/files-folders.go b/packages/tui/internal/completions/files-folders.go index 6fb4316f..cb7a7453 100644 --- a/packages/tui/internal/completions/files-folders.go +++ b/packages/tui/internal/completions/files-folders.go @@ -2,64 +2,108 @@ package completions import ( "context" + "log/slog" + "sort" + "strconv" + "strings" "github.com/sst/opencode-sdk-go" "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/components/dialog" + "github.com/sst/opencode/internal/styles" + "github.com/sst/opencode/internal/theme" ) type filesAndFoldersContextGroup struct { - app *app.App - prefix string + app *app.App + prefix string + gitFiles []dialog.CompletionItemI } func (cg *filesAndFoldersContextGroup) GetId() string { return cg.prefix } -func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI { - return dialog.NewCompletionItem(dialog.CompletionItem{ - Title: "Files & Folders", - Value: "files", - }) -} - func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string { return "no matching files" } -func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) { - files, err := cg.app.Client.File.Search( - context.Background(), - opencode.FileSearchParams{Query: opencode.F(query)}, - ) - if err != nil { - return []string{}, err +func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI { + t := theme.CurrentTheme() + items := make([]dialog.CompletionItemI, 0) + base := styles.NewStyle().Background(t.BackgroundElement()) + green := base.Foreground(t.Success()).Render + red := base.Foreground(t.Error()).Render + + status, _ := cg.app.Client.File.Status(context.Background()) + if status != nil { + files := *status + sort.Slice(files, func(i, j int) bool { + return files[i].Added+files[i].Removed > files[j].Added+files[j].Removed + }) + + for _, file := range files { + title := file.File + if file.Added > 0 { + title += green(" +" + strconv.Itoa(int(file.Added))) + } + if file.Removed > 0 { + title += red(" -" + strconv.Itoa(int(file.Removed))) + } + item := dialog.NewCompletionItem(dialog.CompletionItem{ + Title: title, + Value: file.File, + }) + items = append(items, item) + } } - return *files, nil + + return items } func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) { - matches, err := cg.getFiles(query) - if err != nil { - return nil, err + items := make([]dialog.CompletionItemI, 0) + + query = strings.TrimSpace(query) + if query == "" { + items = append(items, cg.gitFiles...) } - items := make([]dialog.CompletionItemI, 0, len(matches)) - for _, file := range matches { - item := dialog.NewCompletionItem(dialog.CompletionItem{ - Title: file, - Value: file, - }) - items = append(items, item) + files, err := cg.app.Client.Find.Files( + context.Background(), + opencode.FindFilesParams{Query: opencode.F(query)}, + ) + if err != nil { + slog.Error("Failed to get completion items", "error", err) + } + + for _, file := range *files { + exists := false + for _, existing := range cg.gitFiles { + if existing.GetValue() == file { + if query != "" { + items = append(items, existing) + } + exists = true + } + } + if !exists { + item := dialog.NewCompletionItem(dialog.CompletionItem{ + Title: file, + Value: file, + }) + items = append(items, item) + } } return items, nil } func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider { - return &filesAndFoldersContextGroup{ + cg := &filesAndFoldersContextGroup{ app: app, prefix: "file", } + cg.gitFiles = cg.getGitFiles() + return cg } diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index b4abd0f8..669ef47d 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -13,7 +13,6 @@ import ( "github.com/sst/opencode/internal/components/dialog" "github.com/sst/opencode/internal/components/textarea" "github.com/sst/opencode/internal/image" - "github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/util" @@ -21,10 +20,8 @@ import ( type EditorComponent interface { tea.Model - // tea.ViewModel - SetSize(width, height int) tea.Cmd - View(width int, align lipgloss.Position) string - Content(width int, align lipgloss.Position) string + View(width int) string + Content(width int) string Lines() int Value() string Focused() bool @@ -34,19 +31,13 @@ type EditorComponent interface { Clear() (tea.Model, tea.Cmd) Paste() (tea.Model, tea.Cmd) Newline() (tea.Model, tea.Cmd) - Previous() (tea.Model, tea.Cmd) - Next() (tea.Model, tea.Cmd) SetInterruptKeyInDebounce(inDebounce bool) } type editorComponent struct { app *app.App - width, height int textarea textarea.Model attachments []app.Attachment - history []string - historyIndex int - currentMessage string spinner spinner.Model interruptKeyInDebounce bool } @@ -106,7 +97,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m *editorComponent) Content(width int, align lipgloss.Position) string { +func (m *editorComponent) Content(width int) string { t := theme.CurrentTheme() base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render @@ -115,6 +106,7 @@ func (m *editorComponent) Content(width int, align lipgloss.Position) string { Bold(true) prompt := promptStyle.Render(">") + m.textarea.SetWidth(width - 6) textarea := lipgloss.JoinHorizontal( lipgloss.Top, prompt, @@ -147,7 +139,7 @@ func (m *editorComponent) Content(width int, align lipgloss.Position) string { model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name) } - space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint) + space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint) spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("") info := hint + spacer + model @@ -157,19 +149,18 @@ func (m *editorComponent) Content(width int, align lipgloss.Position) string { return content } -func (m *editorComponent) View(width int, align lipgloss.Position) string { +func (m *editorComponent) View(width int) string { if m.Lines() > 1 { - t := theme.CurrentTheme() return lipgloss.Place( width, - m.height, - align, + 5, + lipgloss.Center, lipgloss.Center, "", - styles.WhitespaceStyle(t.Background()), + styles.WhitespaceStyle(theme.CurrentTheme().Background()), ) } - return m.Content(width, align) + return m.Content(width) } func (m *editorComponent) Focused() bool { @@ -184,16 +175,6 @@ func (m *editorComponent) Blur() { m.textarea.Blur() } -func (m *editorComponent) GetSize() (width, height int) { - return m.width, m.height -} - -func (m *editorComponent) SetSize(width, height int) tea.Cmd { - m.width = width - m.height = height - return nil -} - func (m *editorComponent) Lines() int { return m.textarea.LineCount() } @@ -219,16 +200,6 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) attachments := m.attachments - - // Save to history if not empty and not a duplicate of the last entry - if value != "" { - if len(m.history) == 0 || m.history[len(m.history)-1] != value { - m.history = append(m.history, value) - } - m.historyIndex = len(m.history) - m.currentMessage = "" - } - m.attachments = nil cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments})) @@ -261,48 +232,6 @@ func (m *editorComponent) Newline() (tea.Model, tea.Cmd) { return m, nil } -func (m *editorComponent) Previous() (tea.Model, tea.Cmd) { - currentLine := m.textarea.Line() - - // Only navigate history if we're at the first line - if currentLine == 0 && len(m.history) > 0 { - // Save current message if we're just starting to navigate - if m.historyIndex == len(m.history) { - m.currentMessage = m.textarea.Value() - } - - // Go to previous message in history - if m.historyIndex > 0 { - m.historyIndex-- - m.textarea.SetValue(m.history[m.historyIndex]) - } - return m, nil - } - return m, nil -} - -func (m *editorComponent) Next() (tea.Model, tea.Cmd) { - currentLine := m.textarea.Line() - value := m.textarea.Value() - lines := strings.Split(value, "\n") - totalLines := len(lines) - - // Only navigate history if we're at the last line - if currentLine == totalLines-1 { - if m.historyIndex < len(m.history)-1 { - // Go to next message in history - m.historyIndex++ - m.textarea.SetValue(m.history[m.historyIndex]) - } else if m.historyIndex == len(m.history)-1 { - // Return to the current message being composed - m.historyIndex = len(m.history) - m.textarea.SetValue(m.currentMessage) - } - return m, nil - } - return m, nil -} - func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) { m.interruptKeyInDebounce = inDebounce } @@ -336,7 +265,6 @@ func createTextArea(existing *textarea.Model) textarea.Model { ta.Prompt = " " ta.ShowLineNumbers = false ta.CharLimit = -1 - ta.SetWidth(layout.Current.Container.Width - 6) if existing != nil { ta.SetValue(existing.Value()) @@ -368,9 +296,6 @@ func NewEditorComponent(app *app.App) EditorComponent { return &editorComponent{ app: app, textarea: ta, - history: []string{}, - historyIndex: 0, - currentMessage: "", spinner: s, interruptKeyInDebounce: false, } diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go index 8e4cbc1a..4ef73856 100644 --- a/packages/tui/internal/components/chat/message.go +++ b/packages/tui/internal/components/chat/message.go @@ -3,65 +3,46 @@ package chat import ( "encoding/json" "fmt" - "path/filepath" "slices" "strings" "time" - "unicode" "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/lipgloss/v2/compat" - "github.com/charmbracelet/x/ansi" "github.com/sst/opencode-sdk-go" "github.com/sst/opencode/internal/app" + "github.com/sst/opencode/internal/commands" "github.com/sst/opencode/internal/components/diff" "github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/theme" + "github.com/sst/opencode/internal/util" "github.com/tidwall/gjson" "golang.org/x/text/cases" "golang.org/x/text/language" ) -func toMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string { - r := styles.GetMarkdownRenderer(width-7, backgroundColor) - content = strings.ReplaceAll(content, app.RootPath+"/", "") - rendered, _ := r.Render(content) - lines := strings.Split(rendered, "\n") - - if len(lines) > 0 { - firstLine := lines[0] - cleaned := ansi.Strip(firstLine) - nospace := strings.ReplaceAll(cleaned, " ", "") - if nospace == "" { - lines = lines[1:] - } - if len(lines) > 0 { - lastLine := lines[len(lines)-1] - cleaned = ansi.Strip(lastLine) - nospace = strings.ReplaceAll(cleaned, " ", "") - if nospace == "" { - lines = lines[:len(lines)-1] - } - } - } - content = strings.Join(lines, "\n") - return strings.TrimSuffix(content, "\n") -} - type blockRenderer struct { - border bool - borderColor *compat.AdaptiveColor - paddingTop int - paddingBottom int - paddingLeft int - paddingRight int - marginTop int - marginBottom int + textColor compat.AdaptiveColor + border bool + borderColor *compat.AdaptiveColor + borderColorRight bool + paddingTop int + paddingBottom int + paddingLeft int + paddingRight int + marginTop int + marginBottom int } type renderingOption func(*blockRenderer) +func WithTextColor(color compat.AdaptiveColor) renderingOption { + return func(c *blockRenderer) { + c.textColor = color + } +} + func WithNoBorder() renderingOption { return func(c *blockRenderer) { c.border = false @@ -74,6 +55,13 @@ func WithBorderColor(color compat.AdaptiveColor) renderingOption { } } +func WithBorderColorRight(color compat.AdaptiveColor) renderingOption { + return func(c *blockRenderer) { + c.borderColorRight = true + c.borderColor = &color + } +} + func WithMarginTop(padding int) renderingOption { return func(c *blockRenderer) { c.marginTop = padding @@ -120,13 +108,15 @@ func WithPaddingBottom(padding int) renderingOption { } func renderContentBlock( + app *app.App, content string, + highlight bool, width int, - align lipgloss.Position, options ...renderingOption, ) string { t := theme.CurrentTheme() renderer := &blockRenderer{ + textColor: t.TextMuted(), border: true, paddingTop: 1, paddingBottom: 1, @@ -143,7 +133,7 @@ func renderContentBlock( } style := styles.NewStyle(). - Foreground(t.TextMuted()). + Foreground(renderer.textColor). Background(t.BackgroundPanel()). Width(width). PaddingTop(renderer.paddingTop). @@ -161,21 +151,32 @@ func renderContentBlock( BorderLeftBackground(t.Background()). BorderRightForeground(t.BackgroundPanel()). BorderRightBackground(t.Background()) + + if renderer.borderColorRight { + style = style. + BorderLeftBackground(t.Background()). + BorderLeftForeground(t.BackgroundPanel()). + BorderRightForeground(borderColor). + BorderRightBackground(t.Background()) + } + + if highlight { + style = style. + BorderLeftBackground(t.Primary()). + BorderLeftForeground(t.Primary()). + BorderRightForeground(t.Primary()). + BorderRightBackground(t.Primary()) + } + } + + if highlight { + style = style. + Foreground(t.Text()). + Bold(true). + Background(t.BackgroundElement()) } content = style.Render(content) - content = lipgloss.PlaceHorizontal( - width, - lipgloss.Left, - content, - styles.WhitespaceStyle(t.Background()), - ) - content = lipgloss.PlaceHorizontal( - layout.Current.Viewport.Width, - align, - content, - styles.WhitespaceStyle(t.Background()), - ) if renderer.marginTop > 0 { for range renderer.marginTop { content = "\n" + content @@ -186,16 +187,44 @@ func renderContentBlock( content = content + "\n" } } + + if highlight { + copy := app.Key(commands.MessagesCopyCommand) + // revert := app.Key(commands.MessagesRevertCommand) + + background := t.Background() + header := layout.Render( + layout.FlexOptions{ + Background: &background, + Direction: layout.Row, + Justify: layout.JustifyCenter, + Align: layout.AlignStretch, + Width: width - 2, + Gap: 5, + }, + layout.FlexItem{ + View: copy, + }, + // layout.FlexItem{ + // View: revert, + // }, + ) + header = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(header) + + content = "\n\n\n" + header + "\n\n" + content + "\n\n" + } + return content } func renderText( + app *app.App, message opencode.Message, text string, author string, showToolDetails bool, + highlight bool, width int, - align lipgloss.Position, toolCalls ...opencode.ToolInvocationPart, ) string { t := theme.CurrentTheme() @@ -206,17 +235,20 @@ func renderText( timestamp = timestamp[12:] } info := fmt.Sprintf("%s (%s)", author, timestamp) + info = styles.NewStyle().Foreground(t.TextMuted()).Render(info) - messageStyle := styles.NewStyle(). - Background(t.BackgroundPanel()). - Foreground(t.Text()) + backgroundColor := t.BackgroundPanel() + if highlight { + backgroundColor = t.BackgroundElement() + } + messageStyle := styles.NewStyle().Background(backgroundColor) if message.Role == opencode.MessageRoleUser { messageStyle = messageStyle.Width(width - 6) } content := messageStyle.Render(text) if message.Role == opencode.MessageRoleAssistant { - content = toMarkdown(text, width, t.BackgroundPanel()) + content = util.ToMarkdown(text, width, backgroundColor) } if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 { @@ -242,16 +274,19 @@ func renderText( switch message.Role { case opencode.MessageRoleUser: return renderContentBlock( + app, content, + highlight, width, - align, - WithBorderColor(t.Secondary()), + WithTextColor(t.Text()), + WithBorderColorRight(t.Secondary()), ) case opencode.MessageRoleAssistant: return renderContentBlock( + app, content, + highlight, width, - align, WithBorderColor(t.Accent()), ) } @@ -259,10 +294,11 @@ func renderText( } func renderToolDetails( + app *app.App, toolCall opencode.ToolInvocationPart, messageMetadata opencode.MessageMetadata, + highlight bool, width int, - align lipgloss.Position, ) string { ignoredTools := []string{"todoread"} if slices.Contains(ignoredTools, toolCall.ToolInvocation.ToolName) { @@ -282,7 +318,7 @@ func renderToolDetails( if toolCall.ToolInvocation.State == "partial-call" { title := renderToolTitle(toolCall, messageMetadata, width) - return renderContentBlock(title, width, align) + return renderContentBlock(app, title, highlight, width) } toolArgsMap := make(map[string]any) @@ -301,6 +337,10 @@ func renderToolDetails( body := "" finished := result != nil && *result != "" t := theme.CurrentTheme() + backgroundColor := t.BackgroundPanel() + if highlight { + backgroundColor = t.BackgroundElement() + } switch toolCall.ToolInvocation.ToolName { case "read": @@ -308,7 +348,7 @@ func renderToolDetails( if preview != nil && toolArgsMap["filePath"] != nil { filename := toolArgsMap["filePath"].(string) body = preview.(string) - body = renderFile(filename, body, width, WithTruncate(6)) + body = util.RenderFile(filename, body, width, util.WithTruncate(6)) } case "edit": if filename, ok := toolArgsMap["filePath"].(string); ok { @@ -321,38 +361,28 @@ func renderToolDetails( patch, diff.WithWidth(width-2), ) - formattedDiff = strings.TrimSpace(formattedDiff) - formattedDiff = styles.NewStyle(). - BorderStyle(lipgloss.ThickBorder()). - BorderBackground(t.Background()). - BorderForeground(t.BackgroundPanel()). - BorderLeft(true). - BorderRight(true). - Render(formattedDiff) - body = strings.TrimSpace(formattedDiff) - body = renderContentBlock( - body, - width, - align, - WithNoBorder(), - WithPadding(0), - ) + style := styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Padding(1, 2).Width(width - 4) + if highlight { + style = style.Foreground(t.Text()).Bold(true) + } if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" { - body += "\n" + renderContentBlock(diagnostics, width, align) + diagnostics = style.Render(diagnostics) + body += "\n" + diagnostics } title := renderToolTitle(toolCall, messageMetadata, width) - title = renderContentBlock(title, width, align) + title = style.Render(title) content := title + "\n" + body + content = renderContentBlock(app, content, highlight, width, WithPadding(0)) return content } } case "write": if filename, ok := toolArgsMap["filePath"].(string); ok { if content, ok := toolArgsMap["content"].(string); ok { - body = renderFile(filename, content, width) + body = util.RenderFile(filename, content, width) if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" { body += "\n\n" + diagnostics } @@ -363,14 +393,14 @@ func renderToolDetails( if stdout != nil { command := toolArgsMap["command"].(string) body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout) - body = toMarkdown(body, width, t.BackgroundPanel()) + body = util.ToMarkdown(body, width, backgroundColor) } case "webfetch": if format, ok := toolArgsMap["format"].(string); ok && result != nil { body = *result - body = truncateHeight(body, 10) + body = util.TruncateHeight(body, 10) if format == "html" || format == "markdown" { - body = toMarkdown(body, width, t.BackgroundPanel()) + body = util.ToMarkdown(body, width, backgroundColor) } } case "todowrite": @@ -389,7 +419,7 @@ func renderToolDetails( body += fmt.Sprintf("- [ ] %s\n", content) } } - body = toMarkdown(body, width, t.BackgroundPanel()) + body = util.ToMarkdown(body, width, backgroundColor) } case "task": summary := metadata.JSON.ExtraFields["summary"] @@ -424,7 +454,7 @@ func renderToolDetails( result = &empty } body = *result - body = truncateHeight(body, 10) + body = util.TruncateHeight(body, 10) } error := "" @@ -437,18 +467,18 @@ func renderToolDetails( if error != "" { body = styles.NewStyle(). Foreground(t.Error()). - Background(t.BackgroundPanel()). + Background(backgroundColor). Render(error) } if body == "" && error == "" && result != nil { body = *result - body = truncateHeight(body, 10) + body = util.TruncateHeight(body, 10) } title := renderToolTitle(toolCall, messageMetadata, width) content := title + "\n\n" + body - return renderContentBlock(content, width, align) + return renderContentBlock(app, content, highlight, width) } func renderToolName(name string) string { @@ -505,7 +535,7 @@ func renderToolTitle( title = fmt.Sprintf("%s %s", title, toolArgs) case "edit", "write": if filename, ok := toolArgsMap["filePath"].(string); ok { - title = fmt.Sprintf("%s %s", title, relative(filename)) + title = fmt.Sprintf("%s %s", title, util.Relative(filename)) } case "bash", "task": if description, ok := toolArgsMap["description"].(string); ok { @@ -551,50 +581,6 @@ func renderToolAction(name string) string { return "Working..." } -type fileRenderer struct { - filename string - content string - height int -} - -type fileRenderingOption func(*fileRenderer) - -func WithTruncate(height int) fileRenderingOption { - return func(c *fileRenderer) { - c.height = height - } -} - -func renderFile( - filename string, - content string, - width int, - options ...fileRenderingOption) string { - t := theme.CurrentTheme() - renderer := &fileRenderer{ - filename: filename, - content: content, - } - for _, option := range options { - option(renderer) - } - - lines := []string{} - for line := range strings.SplitSeq(content, "\n") { - line = strings.TrimRightFunc(line, unicode.IsSpace) - line = strings.ReplaceAll(line, "\t", " ") - lines = append(lines, line) - } - content = strings.Join(lines, "\n") - - if renderer.height > 0 { - content = truncateHeight(content, renderer.height) - } - content = fmt.Sprintf("```%s\n%s\n```", extension(renderer.filename), content) - content = toMarkdown(content, width, t.BackgroundPanel()) - return content -} - func renderArgs(args *map[string]any, titleKey string) string { if args == nil || len(*args) == 0 { return "" @@ -614,7 +600,7 @@ func renderArgs(args *map[string]any, titleKey string) string { continue } if key == "filePath" || key == "path" { - value = relative(value.(string)) + value = util.Relative(value.(string)) } if key == titleKey { title = fmt.Sprintf("%s", value) @@ -628,29 +614,6 @@ func renderArgs(args *map[string]any, titleKey string) string { return fmt.Sprintf("%s (%s)", title, strings.Join(parts, ", ")) } -func truncateHeight(content string, height int) string { - lines := strings.Split(content, "\n") - if len(lines) > height { - return strings.Join(lines[:height], "\n") - } - return content -} - -func relative(path string) string { - path = strings.TrimPrefix(path, app.CwdPath+"/") - return strings.TrimPrefix(path, app.RootPath+"/") -} - -func extension(path string) string { - ext := filepath.Ext(path) - if ext == "" { - ext = "" - } else { - ext = strings.ToLower(ext[1:]) - } - return ext -} - // Diagnostic represents an LSP diagnostic type Diagnostic struct { Range struct { diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index c13d8b46..a9001e77 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -17,39 +17,50 @@ import ( type MessagesComponent interface { tea.Model - tea.ViewModel - // View(width int) string - SetSize(width, height int) tea.Cmd + View(width, height int) string + SetWidth(width int) tea.Cmd PageUp() (tea.Model, tea.Cmd) PageDown() (tea.Model, tea.Cmd) HalfPageUp() (tea.Model, tea.Cmd) HalfPageDown() (tea.Model, tea.Cmd) First() (tea.Model, tea.Cmd) Last() (tea.Model, tea.Cmd) - // Previous() (tea.Model, tea.Cmd) - // Next() (tea.Model, tea.Cmd) + Previous() (tea.Model, tea.Cmd) + Next() (tea.Model, tea.Cmd) ToolDetailsVisible() bool + Selected() string } type messagesComponent struct { - width, height int + width int app *app.App viewport viewport.Model - attachments viewport.Model cache *MessageCache rendering bool showToolDetails bool tail bool scrollbarDragging bool scrollbarDragStart int + partCount int + lineCount int + selectedPart int + selectedText string } type renderFinishedMsg struct{} +type selectedMessagePartChangedMsg struct { + part int +} + type ToggleToolDetailsMsg struct{} func (m *messagesComponent) Init() tea.Cmd { return tea.Batch(m.viewport.Init()) } +func (m *messagesComponent) Selected() string { + return m.selectedText +} + func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd @@ -69,40 +80,55 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - switch msg.(type) { + switch msg := msg.(type) { case app.SendMsg: m.viewport.GotoBottom() m.tail = true + m.selectedPart = -1 return m, nil case app.OptimisticMessageAddedMsg: - m.renderView() + m.renderView(m.width) if m.tail { m.viewport.GotoBottom() } return m, nil case dialog.ThemeSelectedMsg: m.cache.Clear() + m.rendering = true return m, m.Reload() case ToggleToolDetailsMsg: m.showToolDetails = !m.showToolDetails + m.rendering = true return m, m.Reload() - case app.SessionSelectedMsg: + case app.SessionLoadedMsg: m.cache.Clear() m.tail = true + m.rendering = true return m, m.Reload() case app.SessionClearedMsg: m.cache.Clear() - cmd := m.Reload() - return m, cmd + m.rendering = true + return m, m.Reload() case renderFinishedMsg: m.rendering = false if m.tail { m.viewport.GotoBottom() } - case opencode.EventListResponseEventSessionUpdated, opencode.EventListResponseEventMessageUpdated: - m.renderView() - if m.tail { - m.viewport.GotoBottom() + case selectedMessagePartChangedMsg: + return m, m.Reload() + case opencode.EventListResponseEventSessionUpdated: + if msg.Properties.Info.ID == m.app.Session.ID { + m.renderView(m.width) + if m.tail { + m.viewport.GotoBottom() + } + } + case opencode.EventListResponseEventMessageUpdated: + if msg.Properties.Info.Metadata.SessionID == m.app.Session.ID { + m.renderView(m.width) + if m.tail { + m.viewport.GotoBottom() + } } } @@ -114,45 +140,46 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m *messagesComponent) renderView() { - if m.width == 0 { - return - } - +func (m *messagesComponent) renderView(width int) { measure := util.Measure("messages.renderView") defer measure("messageCount", len(m.app.Messages)) t := theme.CurrentTheme() + blocks := make([]string, 0) + m.partCount = 0 + m.lineCount = 0 - align := lipgloss.Center - width := layout.Current.Container.Width - - sb := strings.Builder{} - util.WriteStringsPar(&sb, m.app.Messages, func(message opencode.Message) string { + for _, message := range m.app.Messages { var content string var cached bool - blocks := make([]string, 0) switch message.Role { case opencode.MessageRoleUser: for _, part := range message.Parts { switch part := part.AsUnion().(type) { case opencode.TextPart: - key := m.cache.GenerateKey(message.ID, part.Text, layout.Current.Viewport.Width) + key := m.cache.GenerateKey(message.ID, part.Text, width, m.selectedPart == m.partCount) content, cached = m.cache.Get(key) if !cached { content = renderText( + m.app, message, part.Text, m.app.Info.User, m.showToolDetails, + m.partCount == m.selectedPart, width, - align, ) m.cache.Set(key, content) } if content != "" { + if m.selectedPart == m.partCount { + m.viewport.SetYOffset(m.lineCount - 4) + m.selectedText = part.Text + } blocks = append(blocks, content) + m.partCount++ + m.lineCount += lipgloss.Height(content) + 1 } } } @@ -181,33 +208,41 @@ func (m *messagesComponent) renderView() { } if finished { - key := m.cache.GenerateKey(message.ID, p.Text, layout.Current.Viewport.Width, m.showToolDetails) + key := m.cache.GenerateKey(message.ID, p.Text, width, m.showToolDetails, m.selectedPart == m.partCount) content, cached = m.cache.Get(key) if !cached { content = renderText( + m.app, message, p.Text, message.Metadata.Assistant.ModelID, m.showToolDetails, + m.partCount == m.selectedPart, width, - align, toolCallParts..., ) m.cache.Set(key, content) } } else { content = renderText( + m.app, message, p.Text, message.Metadata.Assistant.ModelID, m.showToolDetails, + m.partCount == m.selectedPart, width, - align, toolCallParts..., ) } if content != "" { + if m.selectedPart == m.partCount { + m.viewport.SetYOffset(m.lineCount - 4) + m.selectedText = p.Text + } blocks = append(blocks, content) + m.partCount++ + m.lineCount += lipgloss.Height(content) + 1 } case opencode.ToolInvocationPart: if !m.showToolDetails { @@ -218,29 +253,38 @@ func (m *messagesComponent) renderView() { key := m.cache.GenerateKey(message.ID, part.ToolInvocation.ToolCallID, m.showToolDetails, - layout.Current.Viewport.Width, + width, + m.partCount == m.selectedPart, ) content, cached = m.cache.Get(key) if !cached { content = renderToolDetails( + m.app, part, message.Metadata, + m.partCount == m.selectedPart, width, - align, ) m.cache.Set(key, content) } } else { // if the tool call isn't finished, don't cache content = renderToolDetails( + m.app, part, message.Metadata, + m.partCount == m.selectedPart, width, - align, ) } if content != "" { + if m.selectedPart == m.partCount { + m.viewport.SetYOffset(m.lineCount - 4) + m.selectedText = "" + } blocks = append(blocks, content) + m.partCount++ + m.lineCount += lipgloss.Height(content) + 1 } } } @@ -259,34 +303,33 @@ func (m *messagesComponent) renderView() { if error != "" { error = renderContentBlock( + m.app, error, + false, width, - align, WithBorderColor(t.Error()), ) blocks = append(blocks, error) + m.lineCount += lipgloss.Height(error) + 1 } + } - return strings.Join(blocks, "\n\n") - }) - - content := sb.String() - - m.viewport.SetHeight(m.height - lipgloss.Height(m.header()) + 1) - m.viewport.SetContent("\n" + content) + m.viewport.SetContent("\n" + strings.Join(blocks, "\n\n")) + if m.selectedPart == m.partCount-1 { + m.viewport.GotoBottom() + } } -func (m *messagesComponent) header() string { +func (m *messagesComponent) header(width int) string { if m.app.Session.ID == "" { return "" } t := theme.CurrentTheme() - width := layout.Current.Container.Width base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render headerLines := []string{} - headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width-6, t.Background())) + headerLines = append(headerLines, util.ToMarkdown("# "+m.app.Session.Title, width-6, t.Background())) if m.app.Session.Share.URL != "" { headerLines = append(headerLines, muted(m.app.Session.Share.URL)) } else { @@ -350,6 +393,7 @@ func (m *messagesComponent) renderScrollbar() string { return strings.Join(scrollbar, "\n") } + func (m *messagesComponent) handleScrollbarClick(x, y int) bool { // Check if click is in scrollbar area (rightmost column) if x != m.width-1 { @@ -364,7 +408,7 @@ func (m *messagesComponent) handleScrollbarClick(x, y int) bool { } // Calculate header offset - account for the header in the layout - headerHeight := lipgloss.Height(m.header()) + headerHeight := lipgloss.Height(m.header(m.width)) scrollbarY := y - headerHeight // Check if click is within scrollbar bounds @@ -408,7 +452,7 @@ func (m *messagesComponent) handleScrollbarDrag(y int) { } // Calculate header offset - consistent with click handler - headerHeight := lipgloss.Height(m.header()) + headerHeight := lipgloss.Height(m.header(m.width)) scrollbarY := y - headerHeight - m.scrollbarDragStart // Calculate scrollbar dimensions scrollbarHeight := visibleLines @@ -447,12 +491,12 @@ func (m *messagesComponent) applyScrollbarOverlay(viewportContent string) string ) } -func (m *messagesComponent) View() string { +func (m *messagesComponent) View(width, height int) string { t := theme.CurrentTheme() if m.rendering { return lipgloss.Place( - m.width, - m.height+1, + width, + height, lipgloss.Center, lipgloss.Center, styles.NewStyle().Background(t.Background()).Render("Loading session..."), @@ -460,26 +504,23 @@ func (m *messagesComponent) View() string { ) } - // Get the viewport content - this should remain untouched + header := m.header(width) + m.viewport.SetWidth(width) + m.viewport.SetHeight(height - lipgloss.Height(header)) + + // Get the viewport content content := m.viewport.View() // Apply scrollbar overlay using OpenCode's overlay system content = m.applyScrollbarOverlay(content) - return lipgloss.JoinVertical( - lipgloss.Left, - lipgloss.PlaceHorizontal( - m.width, - lipgloss.Center, - m.header(), - styles.WhitespaceStyle(t.Background()), - ), - content, - ) + return styles.NewStyle(). + Background(t.Background()). + Render(header + "\n" + content) } -func (m *messagesComponent) SetSize(width, height int) tea.Cmd { - if m.width == width && m.height == height { +func (m *messagesComponent) SetWidth(width int) tea.Cmd { + if m.width == width { return nil } // Clear cache on resize since width affects rendering @@ -487,23 +528,14 @@ func (m *messagesComponent) SetSize(width, height int) tea.Cmd { m.cache.Clear() } m.width = width - m.height = height m.viewport.SetWidth(width) - m.viewport.SetHeight(height - lipgloss.Height(m.header())) - m.attachments.SetWidth(width + 40) - m.attachments.SetHeight(3) - m.renderView() + m.renderView(width) return nil } -func (m *messagesComponent) GetSize() (int, int) { - return m.width, m.height -} - func (m *messagesComponent) Reload() tea.Cmd { - m.rendering = true return func() tea.Msg { - m.renderView() + m.renderView(m.width) return renderFinishedMsg{} } } @@ -528,16 +560,45 @@ func (m *messagesComponent) HalfPageDown() (tea.Model, tea.Cmd) { return m, nil } -func (m *messagesComponent) First() (tea.Model, tea.Cmd) { - m.viewport.GotoTop() +func (m *messagesComponent) Previous() (tea.Model, tea.Cmd) { m.tail = false - return m, nil + if m.selectedPart < 0 { + m.selectedPart = m.partCount + } + m.selectedPart-- + if m.selectedPart < 0 { + m.selectedPart = 0 + } + return m, util.CmdHandler(selectedMessagePartChangedMsg{ + part: m.selectedPart, + }) +} + +func (m *messagesComponent) Next() (tea.Model, tea.Cmd) { + m.tail = false + m.selectedPart++ + if m.selectedPart >= m.partCount { + m.selectedPart = m.partCount + } + return m, util.CmdHandler(selectedMessagePartChangedMsg{ + part: m.selectedPart, + }) +} + +func (m *messagesComponent) First() (tea.Model, tea.Cmd) { + m.selectedPart = 0 + m.tail = false + return m, util.CmdHandler(selectedMessagePartChangedMsg{ + part: m.selectedPart, + }) } func (m *messagesComponent) Last() (tea.Model, tea.Cmd) { - m.viewport.GotoBottom() + m.selectedPart = m.partCount - 1 m.tail = true - return m, nil + return m, util.CmdHandler(selectedMessagePartChangedMsg{ + part: m.selectedPart, + }) } func (m *messagesComponent) ToolDetailsVisible() bool { @@ -546,16 +607,15 @@ func (m *messagesComponent) ToolDetailsVisible() bool { func NewMessagesComponent(app *app.App) MessagesComponent { vp := viewport.New() - attachments := viewport.New() - // Don't disable the viewport's key bindings - this allows mouse scrolling to work + // Keep viewport key bindings enabled for mouse scrolling // vp.KeyMap = viewport.KeyMap{} return &messagesComponent{ app: app, viewport: vp, - attachments: attachments, showToolDetails: true, cache: NewMessageCache(), tail: true, + selectedPart: -1, } } diff --git a/packages/tui/internal/components/commands/commands.go b/packages/tui/internal/components/commands/commands.go index dbd00149..f3080b38 100644 --- a/packages/tui/internal/components/commands/commands.go +++ b/packages/tui/internal/components/commands/commands.go @@ -34,10 +34,6 @@ func (c *commandsComponent) SetSize(width, height int) tea.Cmd { return nil } -func (c *commandsComponent) GetSize() (int, int) { - return c.width, c.height -} - func (c *commandsComponent) SetBackgroundColor(color compat.AdaptiveColor) { c.background = &color } diff --git a/packages/tui/internal/components/dialog/complete.go b/packages/tui/internal/components/dialog/complete.go index 68e65614..f204d910 100644 --- a/packages/tui/internal/components/dialog/complete.go +++ b/packages/tui/internal/components/dialog/complete.go @@ -41,7 +41,6 @@ func (ci *CompletionItem) Render(selected bool, width int) string { title := itemStyle.Render( ci.DisplayValue(), ) - return title } @@ -59,7 +58,6 @@ func NewCompletionItem(completionItem CompletionItem) CompletionItemI { type CompletionProvider interface { GetId() string - GetEntry() CompletionItemI GetChildEntries(query string) ([]CompletionItemI, error) GetEmptyMessage() string } @@ -175,9 +173,6 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, c.pseudoSearchTextArea.Focus()) return c, tea.Batch(cmds...) } - case tea.WindowSizeMsg: - c.width = msg.Width - c.height = msg.Height } return c, tea.Batch(cmds...) diff --git a/packages/tui/internal/components/dialog/find.go b/packages/tui/internal/components/dialog/find.go new file mode 100644 index 00000000..3ca0d105 --- /dev/null +++ b/packages/tui/internal/components/dialog/find.go @@ -0,0 +1,235 @@ +package dialog + +import ( + "log/slog" + + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/bubbles/v2/textinput" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/sst/opencode/internal/components/list" + "github.com/sst/opencode/internal/components/modal" + "github.com/sst/opencode/internal/layout" + "github.com/sst/opencode/internal/styles" + "github.com/sst/opencode/internal/theme" + "github.com/sst/opencode/internal/util" +) + +type FindSelectedMsg struct { + FilePath string +} + +type FindDialogCloseMsg struct{} + +type FindDialog interface { + layout.Modal + tea.Model + tea.ViewModel + SetWidth(width int) + SetHeight(height int) + IsEmpty() bool + SetProvider(provider CompletionProvider) +} + +type findDialogComponent struct { + query string + completionProvider CompletionProvider + width, height int + modal *modal.Modal + textInput textinput.Model + list list.List[CompletionItemI] +} + +type findDialogKeyMap struct { + Select key.Binding + Cancel key.Binding +} + +var findDialogKeys = findDialogKeyMap{ + Select: key.NewBinding( + key.WithKeys("enter"), + ), + Cancel: key.NewBinding( + key.WithKeys("esc"), + ), +} + +func (f *findDialogComponent) Init() tea.Cmd { + return textinput.Blink +} + +func (f *findDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + switch msg := msg.(type) { + case []CompletionItemI: + f.list.SetItems(msg) + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c": + if f.textInput.Value() == "" { + return f, nil + } + f.textInput.SetValue("") + return f.update(msg) + } + + switch { + case key.Matches(msg, findDialogKeys.Select): + item, i := f.list.GetSelectedItem() + if i == -1 { + return f, nil + } + return f, f.selectFile(item) + case key.Matches(msg, findDialogKeys.Cancel): + return f, f.Close() + default: + f.textInput, cmd = f.textInput.Update(msg) + cmds = append(cmds, cmd) + + f, cmd = f.update(msg) + cmds = append(cmds, cmd) + } + } + + return f, tea.Batch(cmds...) +} + +func (f *findDialogComponent) update(msg tea.Msg) (*findDialogComponent, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + query := f.textInput.Value() + if query != f.query { + f.query = query + cmd = func() tea.Msg { + items, err := f.completionProvider.GetChildEntries(query) + if err != nil { + slog.Error("Failed to get completion items", "error", err) + } + return items + } + cmds = append(cmds, cmd) + } + + u, cmd := f.list.Update(msg) + f.list = u.(list.List[CompletionItemI]) + cmds = append(cmds, cmd) + + return f, tea.Batch(cmds...) +} + +func (f *findDialogComponent) View() string { + t := theme.CurrentTheme() + f.textInput.SetWidth(f.width - 8) + f.list.SetMaxWidth(f.width - 4) + inputView := f.textInput.View() + inputView = styles.NewStyle(). + Background(t.BackgroundPanel()). + Height(1). + Width(f.width-4). + Padding(0, 0). + Render(inputView) + + listView := f.list.View() + return styles.NewStyle().Height(12).Render(inputView + "\n" + listView) +} + +func (f *findDialogComponent) SetWidth(width int) { + f.width = width + if width > 4 { + f.textInput.SetWidth(width - 4) + f.list.SetMaxWidth(width - 4) + } +} + +func (f *findDialogComponent) SetHeight(height int) { + f.height = height +} + +func (f *findDialogComponent) IsEmpty() bool { + return f.list.IsEmpty() +} + +func (f *findDialogComponent) SetProvider(provider CompletionProvider) { + f.completionProvider = provider + f.list.SetEmptyMessage(" " + provider.GetEmptyMessage()) + f.list.SetItems([]CompletionItemI{}) +} + +func (f *findDialogComponent) selectFile(item CompletionItemI) tea.Cmd { + return tea.Sequence( + f.Close(), + util.CmdHandler(FindSelectedMsg{ + FilePath: item.GetValue(), + }), + ) +} + +func (f *findDialogComponent) Render(background string) string { + return f.modal.Render(f.View(), background) +} + +func (f *findDialogComponent) Close() tea.Cmd { + f.textInput.Reset() + f.textInput.Blur() + return util.CmdHandler(modal.CloseModalMsg{}) +} + +func createTextInput(existing *textinput.Model) textinput.Model { + t := theme.CurrentTheme() + bgColor := t.BackgroundPanel() + textColor := t.Text() + textMutedColor := t.TextMuted() + + ti := textinput.New() + + ti.Styles.Blurred.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss() + ti.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() + ti.Styles.Focused.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss() + ti.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() + ti.Styles.Cursor.Color = t.Primary() + ti.VirtualCursor = true + + ti.Prompt = " " + ti.CharLimit = -1 + ti.Focus() + + if existing != nil { + ti.SetValue(existing.Value()) + ti.SetWidth(existing.Width()) + } + + return ti +} + +func NewFindDialog(completionProvider CompletionProvider) FindDialog { + ti := createTextInput(nil) + + li := list.NewListComponent( + []CompletionItemI{}, + 10, // max visible items + completionProvider.GetEmptyMessage(), + false, + ) + + // Load initial items + go func() { + items, err := completionProvider.GetChildEntries("") + if err != nil { + slog.Error("Failed to get completion items", "error", err) + } + li.SetItems(items) + }() + + return &findDialogComponent{ + query: "", + completionProvider: completionProvider, + textInput: ti, + list: li, + modal: modal.New( + modal.WithTitle("Find Files"), + modal.WithMaxWidth(80), + ), + } +} diff --git a/packages/tui/internal/components/diff/diff.go b/packages/tui/internal/components/diff/diff.go index 3d0e41fc..02c2c31e 100644 --- a/packages/tui/internal/components/diff/diff.go +++ b/packages/tui/internal/components/diff/diff.go @@ -73,44 +73,6 @@ type linePair struct { right *DiffLine } -// ------------------------------------------------------------------------- -// Side-by-Side Configuration -// ------------------------------------------------------------------------- - -// SideBySideConfig configures the rendering of side-by-side diffs -type SideBySideConfig struct { - TotalWidth int -} - -// SideBySideOption modifies a SideBySideConfig -type SideBySideOption func(*SideBySideConfig) - -// NewSideBySideConfig creates a SideBySideConfig with default values -func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig { - config := SideBySideConfig{ - TotalWidth: 160, // Default width for side-by-side view - } - - for _, opt := range opts { - opt(&config) - } - - return config -} - -// WithTotalWidth sets the total width for side-by-side view -func WithTotalWidth(width int) SideBySideOption { - return func(s *SideBySideConfig) { - if width > 0 { - s.TotalWidth = width - } - } -} - -// ------------------------------------------------------------------------- -// Unified Configuration -// ------------------------------------------------------------------------- - // UnifiedConfig configures the rendering of unified diffs type UnifiedConfig struct { Width int @@ -122,13 +84,22 @@ type UnifiedOption func(*UnifiedConfig) // NewUnifiedConfig creates a UnifiedConfig with default values func NewUnifiedConfig(opts ...UnifiedOption) UnifiedConfig { config := UnifiedConfig{ - Width: 80, // Default width for unified view + Width: 80, } - for _, opt := range opts { opt(&config) } + return config +} +// NewSideBySideConfig creates a SideBySideConfig with default values +func NewSideBySideConfig(opts ...UnifiedOption) UnifiedConfig { + config := UnifiedConfig{ + Width: 160, + } + for _, opt := range opts { + opt(&config) + } return config } @@ -907,7 +878,7 @@ func RenderUnifiedHunk(fileName string, h Hunk, opts ...UnifiedOption) string { } // RenderSideBySideHunk formats a hunk for side-by-side display -func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string { +func RenderSideBySideHunk(fileName string, h Hunk, opts ...UnifiedOption) string { // Apply options to create the configuration config := NewSideBySideConfig(opts...) @@ -922,10 +893,10 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str pairs := pairLines(hunkCopy.Lines) // Calculate column width - colWidth := config.TotalWidth / 2 + colWidth := config.Width / 2 leftWidth := colWidth - rightWidth := config.TotalWidth - colWidth + rightWidth := config.Width - colWidth var sb strings.Builder util.WriteStringsPar(&sb, pairs, func(p linePair) string { @@ -963,7 +934,7 @@ func FormatUnifiedDiff(filename string, diffText string, opts ...UnifiedOption) } // FormatDiff creates a side-by-side formatted view of a diff -func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (string, error) { +func FormatDiff(filename string, diffText string, opts ...UnifiedOption) (string, error) { diffResult, err := ParseUnifiedDiff(diffText) if err != nil { return "", err diff --git a/packages/tui/internal/components/fileviewer/fileviewer.go b/packages/tui/internal/components/fileviewer/fileviewer.go new file mode 100644 index 00000000..6627bc3f --- /dev/null +++ b/packages/tui/internal/components/fileviewer/fileviewer.go @@ -0,0 +1,281 @@ +package fileviewer + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/v2/viewport" + tea "github.com/charmbracelet/bubbletea/v2" + + "github.com/sst/opencode/internal/app" + "github.com/sst/opencode/internal/commands" + "github.com/sst/opencode/internal/components/dialog" + "github.com/sst/opencode/internal/components/diff" + "github.com/sst/opencode/internal/layout" + "github.com/sst/opencode/internal/styles" + "github.com/sst/opencode/internal/theme" + "github.com/sst/opencode/internal/util" +) + +type DiffStyle int + +const ( + DiffStyleSplit DiffStyle = iota + DiffStyleUnified +) + +type Model struct { + app *app.App + width, height int + viewport viewport.Model + filename *string + content *string + isDiff *bool + diffStyle DiffStyle +} + +type fileRenderedMsg struct { + content string +} + +func New(app *app.App) Model { + vp := viewport.New() + m := Model{ + app: app, + viewport: vp, + diffStyle: DiffStyleUnified, + } + if app.State.SplitDiff { + m.diffStyle = DiffStyleSplit + } + return m +} + +func (m Model) Init() tea.Cmd { + return m.viewport.Init() +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case fileRenderedMsg: + m.viewport.SetContent(msg.content) + return m, util.CmdHandler(app.FileRenderedMsg{ + FilePath: *m.filename, + }) + case dialog.ThemeSelectedMsg: + return m, m.render() + case tea.KeyMsg: + switch msg.String() { + // TODO + } + } + + vp, cmd := m.viewport.Update(msg) + m.viewport = vp + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m Model) View() string { + if !m.HasFile() { + return "" + } + + header := *m.filename + header = styles.NewStyle(). + Padding(1, 2). + Width(m.width). + Background(theme.CurrentTheme().BackgroundElement()). + Foreground(theme.CurrentTheme().Text()). + Render(header) + + t := theme.CurrentTheme() + + close := m.app.Key(commands.FileCloseCommand) + diffToggle := m.app.Key(commands.FileDiffToggleCommand) + if m.isDiff == nil || *m.isDiff == false { + diffToggle = "" + } + layoutToggle := m.app.Key(commands.MessagesLayoutToggleCommand) + + background := t.Background() + footer := layout.Render( + layout.FlexOptions{ + Background: &background, + Direction: layout.Row, + Justify: layout.JustifyCenter, + Align: layout.AlignStretch, + Width: m.width - 2, + Gap: 5, + }, + layout.FlexItem{ + View: close, + }, + layout.FlexItem{ + View: layoutToggle, + }, + layout.FlexItem{ + View: diffToggle, + }, + ) + footer = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(footer) + + return header + "\n" + m.viewport.View() + "\n" + footer +} + +func (m *Model) Clear() (Model, tea.Cmd) { + m.filename = nil + m.content = nil + m.isDiff = nil + return *m, m.render() +} + +func (m *Model) ToggleDiff() (Model, tea.Cmd) { + switch m.diffStyle { + case DiffStyleSplit: + m.diffStyle = DiffStyleUnified + default: + m.diffStyle = DiffStyleSplit + } + return *m, m.render() +} + +func (m *Model) DiffStyle() DiffStyle { + return m.diffStyle +} + +func (m Model) HasFile() bool { + return m.filename != nil && m.content != nil +} + +func (m Model) Filename() string { + if m.filename == nil { + return "" + } + return *m.filename +} + +func (m *Model) SetSize(width, height int) (Model, tea.Cmd) { + if m.width != width || m.height != height { + m.width = width + m.height = height + m.viewport.SetWidth(width) + m.viewport.SetHeight(height - 4) + return *m, m.render() + } + return *m, nil +} + +func (m *Model) SetFile(filename string, content string, isDiff bool) (Model, tea.Cmd) { + m.filename = &filename + m.content = &content + m.isDiff = &isDiff + return *m, m.render() +} + +func (m *Model) render() tea.Cmd { + if m.filename == nil || m.content == nil { + m.viewport.SetContent("") + return nil + } + + return func() tea.Msg { + t := theme.CurrentTheme() + var rendered string + + if m.isDiff != nil && *m.isDiff { + diffResult := "" + var err error + if m.diffStyle == DiffStyleSplit { + diffResult, err = diff.FormatDiff( + *m.filename, + *m.content, + diff.WithWidth(m.width), + ) + } else if m.diffStyle == DiffStyleUnified { + diffResult, err = diff.FormatUnifiedDiff( + *m.filename, + *m.content, + diff.WithWidth(m.width), + ) + } + if err != nil { + rendered = styles.NewStyle(). + Foreground(t.Error()). + Render(fmt.Sprintf("Error rendering diff: %v", err)) + } else { + rendered = strings.TrimRight(diffResult, "\n") + } + } else { + rendered = util.RenderFile( + *m.filename, + *m.content, + m.width, + ) + } + + rendered = styles.NewStyle(). + Width(m.width). + Background(t.BackgroundPanel()). + Render(rendered) + + return fileRenderedMsg{ + content: rendered, + } + } +} + +func (m *Model) ScrollTo(line int) { + m.viewport.SetYOffset(line) +} + +func (m *Model) ScrollToBottom() { + m.viewport.GotoBottom() +} + +func (m *Model) ScrollToTop() { + m.viewport.GotoTop() +} + +func (m *Model) PageUp() (Model, tea.Cmd) { + m.viewport.ViewUp() + return *m, nil +} + +func (m *Model) PageDown() (Model, tea.Cmd) { + m.viewport.ViewDown() + return *m, nil +} + +func (m *Model) HalfPageUp() (Model, tea.Cmd) { + m.viewport.HalfViewUp() + return *m, nil +} + +func (m *Model) HalfPageDown() (Model, tea.Cmd) { + m.viewport.HalfViewDown() + return *m, nil +} + +func (m Model) AtTop() bool { + return m.viewport.AtTop() +} + +func (m Model) AtBottom() bool { + return m.viewport.AtBottom() +} + +func (m Model) ScrollPercent() float64 { + return m.viewport.ScrollPercent() +} + +func (m Model) TotalLineCount() int { + return m.viewport.TotalLineCount() +} + +func (m Model) VisibleLineCount() int { + return m.viewport.VisibleLineCount() +} diff --git a/packages/tui/internal/components/modal/modal.go b/packages/tui/internal/components/modal/modal.go index 6bce6424..aa81a83e 100644 --- a/packages/tui/internal/components/modal/modal.go +++ b/packages/tui/internal/components/modal/modal.go @@ -135,11 +135,11 @@ func (m *Modal) Render(contentView string, background string) string { col := (bgWidth - modalWidth) / 2 return layout.PlaceOverlay( - col, + col-1, // TODO: whyyyyy row, modalView, background, layout.WithOverlayBorder(), - layout.WithOverlayBorderColor(t.Primary()), + layout.WithOverlayBorderColor(t.BorderActive()), ) } diff --git a/packages/tui/internal/config/config.go b/packages/tui/internal/config/config.go index 502f5531..3dd6fcf5 100644 --- a/packages/tui/internal/config/config.go +++ b/packages/tui/internal/config/config.go @@ -21,6 +21,8 @@ type State struct { Provider string `toml:"provider"` Model string `toml:"model"` RecentlyUsedModels []ModelUsage `toml:"recently_used_models"` + MessagesRight bool `toml:"messages_right"` + SplitDiff bool `toml:"split_diff"` } func NewState() *State { diff --git a/packages/tui/internal/layout/flex.go b/packages/tui/internal/layout/flex.go index c7d9ee1b..5b10a952 100644 --- a/packages/tui/internal/layout/flex.go +++ b/packages/tui/internal/layout/flex.go @@ -4,7 +4,9 @@ import ( "strings" "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/lipgloss/v2/compat" "github.com/sst/opencode/internal/styles" + "github.com/sst/opencode/internal/theme" ) type Direction int @@ -34,11 +36,13 @@ const ( ) type FlexOptions struct { - Direction Direction - Justify Justify - Align Align - Width int - Height int + Background *compat.AdaptiveColor + Direction Direction + Justify Justify + Align Align + Width int + Height int + Gap int } type FlexItem struct { @@ -53,6 +57,12 @@ func Render(opts FlexOptions, items ...FlexItem) string { return "" } + t := theme.CurrentTheme() + if opts.Background == nil { + background := t.Background() + opts.Background = &background + } + // Calculate dimensions for each item mainAxisSize := opts.Width crossAxisSize := opts.Height @@ -72,8 +82,14 @@ func Render(opts FlexOptions, items ...FlexItem) string { } } + // Account for gaps between items + totalGapSize := 0 + if len(items) > 1 && opts.Gap > 0 { + totalGapSize = opts.Gap * (len(items) - 1) + } + // Calculate available space for grow items - availableSpace := max(mainAxisSize-totalFixedSize, 0) + availableSpace := max(mainAxisSize-totalFixedSize-totalGapSize, 0) // Calculate size for each grow item growItemSize := 0 @@ -108,6 +124,7 @@ func Render(opts FlexOptions, items ...FlexItem) string { // For row direction, constrain width and handle height alignment if itemSize > 0 { view = styles.NewStyle(). + Background(*opts.Background). Width(itemSize). Height(crossAxisSize). Render(view) @@ -116,31 +133,65 @@ func Render(opts FlexOptions, items ...FlexItem) string { // Apply cross-axis alignment switch opts.Align { case AlignCenter: - view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Center, view) + view = lipgloss.PlaceVertical( + crossAxisSize, + lipgloss.Center, + view, + styles.WhitespaceStyle(*opts.Background), + ) case AlignEnd: - view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Bottom, view) + view = lipgloss.PlaceVertical( + crossAxisSize, + lipgloss.Bottom, + view, + styles.WhitespaceStyle(*opts.Background), + ) case AlignStart: - view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Top, view) + view = lipgloss.PlaceVertical( + crossAxisSize, + lipgloss.Top, + view, + styles.WhitespaceStyle(*opts.Background), + ) case AlignStretch: // Already stretched by Height setting above } } else { // For column direction, constrain height and handle width alignment if itemSize > 0 { - view = styles.NewStyle(). - Height(itemSize). - Width(crossAxisSize). - Render(view) + style := styles.NewStyle(). + Background(*opts.Background). + Height(itemSize) + // Only set width for stretch alignment + if opts.Align == AlignStretch { + style = style.Width(crossAxisSize) + } + view = style.Render(view) } // Apply cross-axis alignment switch opts.Align { case AlignCenter: - view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Center, view) + view = lipgloss.PlaceHorizontal( + crossAxisSize, + lipgloss.Center, + view, + styles.WhitespaceStyle(*opts.Background), + ) case AlignEnd: - view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Right, view) + view = lipgloss.PlaceHorizontal( + crossAxisSize, + lipgloss.Right, + view, + styles.WhitespaceStyle(*opts.Background), + ) case AlignStart: - view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Left, view) + view = lipgloss.PlaceHorizontal( + crossAxisSize, + lipgloss.Left, + view, + styles.WhitespaceStyle(*opts.Background), + ) case AlignStretch: // Already stretched by Width setting above } @@ -154,11 +205,14 @@ func Render(opts FlexOptions, items ...FlexItem) string { } } - // Calculate total actual size + // Calculate total actual size including gaps totalActualSize := 0 for _, size := range actualSizes { totalActualSize += size } + if len(items) > 1 && opts.Gap > 0 { + totalActualSize += opts.Gap * (len(items) - 1) + } // Apply justification remainingSpace := max(mainAxisSize-totalActualSize, 0) @@ -191,12 +245,17 @@ func Render(opts FlexOptions, items ...FlexItem) string { // Build the final layout var parts []string + spaceStyle := styles.NewStyle().Background(*opts.Background) // Add space before if needed if spaceBefore > 0 { if opts.Direction == Row { - parts = append(parts, strings.Repeat(" ", spaceBefore)) + space := strings.Repeat(" ", spaceBefore) + parts = append(parts, spaceStyle.Render(space)) } else { - parts = append(parts, strings.Repeat("\n", spaceBefore)) + // For vertical layout, add empty lines as separate parts + for range spaceBefore { + parts = append(parts, "") + } } } @@ -205,11 +264,19 @@ func Render(opts FlexOptions, items ...FlexItem) string { parts = append(parts, view) // Add space between items (not after the last one) - if i < len(sizedViews)-1 && spaceBetween > 0 { - if opts.Direction == Row { - parts = append(parts, strings.Repeat(" ", spaceBetween)) - } else { - parts = append(parts, strings.Repeat("\n", spaceBetween)) + if i < len(sizedViews)-1 { + // Add gap first, then any additional spacing from justification + totalSpacing := opts.Gap + spaceBetween + if totalSpacing > 0 { + if opts.Direction == Row { + space := strings.Repeat(" ", totalSpacing) + parts = append(parts, spaceStyle.Render(space)) + } else { + // For vertical layout, add empty lines as separate parts + for range totalSpacing { + parts = append(parts, "") + } + } } } } @@ -217,9 +284,13 @@ func Render(opts FlexOptions, items ...FlexItem) string { // Add space after if needed if spaceAfter > 0 { if opts.Direction == Row { - parts = append(parts, strings.Repeat(" ", spaceAfter)) + space := strings.Repeat(" ", spaceAfter) + parts = append(parts, spaceStyle.Render(space)) } else { - parts = append(parts, strings.Repeat("\n", spaceAfter)) + // For vertical layout, add empty lines as separate parts + for range spaceAfter { + parts = append(parts, "") + } } } diff --git a/packages/tui/internal/layout/flex_example_test.go b/packages/tui/internal/layout/flex_example_test.go new file mode 100644 index 00000000..a03346eb --- /dev/null +++ b/packages/tui/internal/layout/flex_example_test.go @@ -0,0 +1,41 @@ +package layout_test + +import ( + "fmt" + "github.com/sst/opencode/internal/layout" +) + +func ExampleRender_withGap() { + // Create a horizontal layout with 3px gap between items + result := layout.Render( + layout.FlexOptions{ + Direction: layout.Row, + Width: 30, + Height: 1, + Gap: 3, + }, + layout.FlexItem{View: "Item1"}, + layout.FlexItem{View: "Item2"}, + layout.FlexItem{View: "Item3"}, + ) + fmt.Println(result) + // Output: Item1 Item2 Item3 +} + +func ExampleRender_withGapAndJustify() { + // Create a horizontal layout with gap and space-between justification + result := layout.Render( + layout.FlexOptions{ + Direction: layout.Row, + Width: 30, + Height: 1, + Gap: 2, + Justify: layout.JustifySpaceBetween, + }, + layout.FlexItem{View: "A"}, + layout.FlexItem{View: "B"}, + layout.FlexItem{View: "C"}, + ) + fmt.Println(result) + // Output: A B C +} diff --git a/packages/tui/internal/layout/flex_test.go b/packages/tui/internal/layout/flex_test.go new file mode 100644 index 00000000..cad38dc8 --- /dev/null +++ b/packages/tui/internal/layout/flex_test.go @@ -0,0 +1,90 @@ +package layout + +import ( + "strings" + "testing" +) + +func TestFlexGap(t *testing.T) { + tests := []struct { + name string + opts FlexOptions + items []FlexItem + expected string + }{ + { + name: "Row with gap", + opts: FlexOptions{ + Direction: Row, + Width: 20, + Height: 1, + Gap: 2, + }, + items: []FlexItem{ + {View: "A"}, + {View: "B"}, + {View: "C"}, + }, + expected: "A B C", + }, + { + name: "Column with gap", + opts: FlexOptions{ + Direction: Column, + Width: 1, + Height: 5, + Gap: 1, + Align: AlignStart, + }, + items: []FlexItem{ + {View: "A", FixedSize: 1}, + {View: "B", FixedSize: 1}, + {View: "C", FixedSize: 1}, + }, + expected: "A\n \nB\n \nC", + }, + { + name: "Row with gap and justify space between", + opts: FlexOptions{ + Direction: Row, + Width: 15, + Height: 1, + Gap: 1, + Justify: JustifySpaceBetween, + }, + items: []FlexItem{ + {View: "A"}, + {View: "B"}, + {View: "C"}, + }, + expected: "A B C", + }, + { + name: "No gap specified", + opts: FlexOptions{ + Direction: Row, + Width: 10, + Height: 1, + }, + items: []FlexItem{ + {View: "A"}, + {View: "B"}, + {View: "C"}, + }, + expected: "ABC", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Render(tt.opts, tt.items...) + // Trim any trailing spaces for comparison + result = strings.TrimRight(result, " ") + expected := strings.TrimRight(tt.expected, " ") + + if result != expected { + t.Errorf("Render() = %q, want %q", result, expected) + } + }) + } +} diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 3d69604c..00e484fb 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -19,6 +19,7 @@ import ( "github.com/sst/opencode/internal/components/chat" cmdcomp "github.com/sst/opencode/internal/components/commands" "github.com/sst/opencode/internal/components/dialog" + "github.com/sst/opencode/internal/components/fileviewer" "github.com/sst/opencode/internal/components/modal" "github.com/sst/opencode/internal/components/status" "github.com/sst/opencode/internal/components/toast" @@ -40,6 +41,7 @@ const ( ) const interruptDebounceTimeout = 1 * time.Second +const fileViewerFullWidthCutoff = 200 type appModel struct { width, height int @@ -56,6 +58,12 @@ type appModel struct { toastManager *toast.ToastManager interruptKeyState InterruptKeyState lastScroll time.Time + messagesRight bool + fileViewer fileviewer.Model + lastMouse tea.Mouse + fileViewerStart int + fileViewerEnd int + fileViewerHit bool } func (a appModel) Init() tea.Cmd { @@ -71,6 +79,7 @@ func (a appModel) Init() tea.Cmd { cmds = append(cmds, a.status.Init()) cmds = append(cmds, a.completions.Init()) cmds = append(cmds, a.toastManager.Init()) + cmds = append(cmds, a.fileViewer.Init()) // Check if we should show the init dialog cmds = append(cmds, func() tea.Msg { @@ -98,13 +107,33 @@ var BUGGED_SCROLL_KEYS = map[string]bool{ ";": true, } +func isScrollRelatedInput(keyString string) bool { + if len(keyString) == 0 { + return false + } + + for _, char := range keyString { + charStr := string(char) + if !BUGGED_SCROLL_KEYS[charStr] { + return false + } + } + + if len(keyString) > 3 && (keyString[len(keyString)-1] == 'M' || keyString[len(keyString)-1] == 'm') { + return true + } + + return len(keyString) > 1 +} + func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyPressMsg: keyString := msg.String() - if time.Since(a.lastScroll) < time.Millisecond*100 && BUGGED_SCROLL_KEYS[keyString] { + if time.Since(a.lastScroll) < time.Millisecond*100 && (BUGGED_SCROLL_KEYS[keyString] || isScrollRelatedInput(keyString)) { return a, nil } @@ -112,10 +141,20 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.modal != nil { switch keyString { // Escape always closes current modal - case "esc", "ctrl+c": + case "esc": cmd := a.modal.Close() a.modal = nil return a, cmd + case "ctrl+c": + // give the modal a chance to handle the ctrl+c + updatedModal, cmd := a.modal.Update(msg) + a.modal = updatedModal.(layout.Modal) + if cmd != nil { + return a, cmd + } + cmd = a.modal.Close() + a.modal = nil + return a, cmd } // Pass all other key presses to the modal @@ -249,10 +288,28 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.modal != nil { return a, nil } - updated, cmd := a.messages.Update(msg) - a.messages = updated.(chat.MessagesComponent) - cmds = append(cmds, cmd) + + var cmd tea.Cmd + if a.fileViewerHit { + a.fileViewer, cmd = a.fileViewer.Update(msg) + cmds = append(cmds, cmd) + } else { + updated, cmd := a.messages.Update(msg) + a.messages = updated.(chat.MessagesComponent) + cmds = append(cmds, cmd) + } + return a, tea.Batch(cmds...) + case tea.MouseMotionMsg: + a.lastMouse = msg.Mouse() + a.fileViewerHit = a.fileViewer.HasFile() && + a.lastMouse.X > a.fileViewerStart && + a.lastMouse.X < a.fileViewerEnd + case tea.MouseClickMsg: + a.lastMouse = msg.Mouse() + a.fileViewerHit = a.fileViewer.HasFile() && + a.lastMouse.X > a.fileViewerStart && + a.lastMouse.X < a.fileViewerEnd case tea.BackgroundColorMsg: styles.Terminal = &styles.TerminalInfo{ Background: msg.Color, @@ -269,6 +326,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } case modal.CloseModalMsg: + a.editor.Focus() var cmd tea.Cmd if a.modal != nil { cmd = a.modal.Close() @@ -352,22 +410,47 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { slog.Error("Server error", "name", err.Name, "message", err.Data.Message) return a, toast.NewErrorToast(err.Data.Message, toast.WithTitle(string(err.Name))) } + case opencode.EventListResponseEventFileWatcherUpdated: + if a.fileViewer.HasFile() { + if a.fileViewer.Filename() == msg.Properties.File { + return a.openFile(msg.Properties.File) + } + } case tea.WindowSizeMsg: msg.Height -= 2 // Make space for the status bar a.width, a.height = msg.Width, msg.Height + container := min(a.width, 84) + if a.fileViewer.HasFile() { + if a.width < fileViewerFullWidthCutoff { + container = a.width + } else { + container = min(min(a.width, max(a.width/2, 50)), 84) + } + } layout.Current = &layout.LayoutInfo{ Viewport: layout.Dimensions{ Width: a.width, Height: a.height, }, Container: layout.Dimensions{ - Width: min(a.width, 80), + Width: container, }, } - // Update child component sizes - messagesHeight := a.height - 6 // Leave room for editor and status bar - a.messages.SetSize(a.width, messagesHeight) - a.editor.SetSize(min(a.width, 80), 5) + mainWidth := layout.Current.Container.Width + a.messages.SetWidth(mainWidth - 4) + + sideWidth := a.width - mainWidth + if a.width < fileViewerFullWidthCutoff { + sideWidth = a.width + } + a.fileViewerStart = mainWidth + a.fileViewerEnd = a.fileViewerStart + sideWidth + if a.messagesRight { + a.fileViewerStart = 0 + a.fileViewerEnd = sideWidth + } + a.fileViewer, cmd = a.fileViewer.SetSize(sideWidth, layout.Current.Viewport.Height) + cmds = append(cmds, cmd) case app.SessionSelectedMsg: messages, err := a.app.ListMessages(context.Background(), msg.ID) if err != nil { @@ -376,6 +459,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } a.app.Session = msg a.app.Messages = messages + return a, util.CmdHandler(app.SessionLoadedMsg{}) case app.ModelSelectedMsg: a.app.Provider = &msg.Provider a.app.Model = &msg.Model @@ -398,24 +482,22 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Reset interrupt key state after timeout a.interruptKeyState = InterruptKeyIdle a.editor.SetInterruptKeyInDebounce(false) + case dialog.FindSelectedMsg: + return a.openFile(msg.FilePath) } - // update status bar s, cmd := a.status.Update(msg) cmds = append(cmds, cmd) a.status = s.(status.StatusComponent) - // update editor u, cmd := a.editor.Update(msg) a.editor = u.(chat.EditorComponent) cmds = append(cmds, cmd) - // update messages u, cmd = a.messages.Update(msg) a.messages = u.(chat.MessagesComponent) cmds = append(cmds, cmd) - // update modal if a.modal != nil { u, cmd := a.modal.Update(msg) a.modal = u.(layout.Modal) @@ -428,86 +510,95 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) } + fv, cmd := a.fileViewer.Update(msg) + a.fileViewer = fv + cmds = append(cmds, cmd) + return a, tea.Batch(cmds...) } func (a appModel) View() string { - mainLayout := a.chat(layout.Current.Container.Width, lipgloss.Center) + t := theme.CurrentTheme() + + var mainLayout string + mainWidth := layout.Current.Container.Width - 4 + if a.app.Session.ID == "" { + mainLayout = a.home(mainWidth) + } else { + mainLayout = a.chat(mainWidth) + } + mainLayout = styles.NewStyle(). + Background(t.Background()). + Padding(0, 2). + Render(mainLayout) + + mainHeight := lipgloss.Height(mainLayout) + + if a.fileViewer.HasFile() { + file := a.fileViewer.View() + baseStyle := styles.NewStyle().Background(t.BackgroundPanel()) + sidePanel := baseStyle.Height(mainHeight).Render(file) + if a.width >= fileViewerFullWidthCutoff { + if a.messagesRight { + mainLayout = lipgloss.JoinHorizontal( + lipgloss.Top, + sidePanel, + mainLayout, + ) + } else { + mainLayout = lipgloss.JoinHorizontal( + lipgloss.Top, + mainLayout, + sidePanel, + ) + } + } else { + mainLayout = sidePanel + } + } else { + mainLayout = lipgloss.PlaceHorizontal( + a.width, + lipgloss.Center, + mainLayout, + styles.WhitespaceStyle(t.Background()), + ) + } + + mainStyle := styles.NewStyle().Background(t.Background()) + mainLayout = mainStyle.Render(mainLayout) + if a.modal != nil { mainLayout = a.modal.Render(mainLayout) } mainLayout = a.toastManager.RenderOverlay(mainLayout) + if theme.CurrentThemeUsesAnsiColors() { mainLayout = util.ConvertRGBToAnsi16Colors(mainLayout) } return mainLayout + "\n" + a.status.View() } -func (a appModel) chat(width int, align lipgloss.Position) string { - editorView := a.editor.View(width, align) - lines := a.editor.Lines() - messagesView := a.messages.View() - if a.app.Session.ID == "" { - messagesView = a.home() - } - editorHeight := max(lines, 5) - - t := theme.CurrentTheme() - centeredEditorView := lipgloss.PlaceHorizontal( - a.width, - align, - editorView, - styles.WhitespaceStyle(t.Background()), - ) - - mainLayout := layout.Render( - layout.FlexOptions{ - Direction: layout.Column, - Width: a.width, - Height: a.height, - }, - layout.FlexItem{ - View: messagesView, - Grow: true, - }, - layout.FlexItem{ - View: centeredEditorView, - FixedSize: 5, +func (a appModel) openFile(filepath string) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + response, err := a.app.Client.File.Read( + context.Background(), + opencode.FileReadParams{ + Path: opencode.F(filepath), }, ) - - if lines > 1 { - editorWidth := min(a.width, 80) - editorX := (a.width - editorWidth) / 2 - editorY := a.height - editorHeight - mainLayout = layout.PlaceOverlay( - editorX, - editorY, - a.editor.Content(width, align), - mainLayout, - ) + if err != nil { + slog.Error("Failed to read file", "error", err) + return a, toast.NewErrorToast("Failed to read file") } - - if a.showCompletionDialog { - editorWidth := min(a.width, 80) - editorX := (a.width - editorWidth) / 2 - a.completions.SetWidth(editorWidth) - overlay := a.completions.View() - overlayHeight := lipgloss.Height(overlay) - editorY := a.height - editorHeight + 1 - - mainLayout = layout.PlaceOverlay( - editorX, - editorY-overlayHeight, - overlay, - mainLayout, - ) - } - - return mainLayout + a.fileViewer, cmd = a.fileViewer.SetFile( + filepath, + response.Content, + response.Type == "patch", + ) + return a, cmd } -func (a appModel) home() string { +func (a appModel) home(width int) string { t := theme.CurrentTheme() baseStyle := styles.NewStyle().Background(t.Background()) base := baseStyle.Render @@ -539,7 +630,7 @@ func (a appModel) home() string { logoAndVersion := strings.Join([]string{logo, version}, "\n") logoAndVersion = lipgloss.PlaceHorizontal( - a.width, + width, lipgloss.Center, logoAndVersion, styles.WhitespaceStyle(t.Background()), @@ -550,13 +641,15 @@ func (a appModel) home() string { cmdcomp.WithLimit(6), ) cmds := lipgloss.PlaceHorizontal( - a.width, + width, lipgloss.Center, commandsView.View(), styles.WhitespaceStyle(t.Background()), ) lines := []string{} + lines = append(lines, "") + lines = append(lines, "") lines = append(lines, logoAndVersion) lines = append(lines, "") lines = append(lines, "") @@ -564,18 +657,100 @@ func (a appModel) home() string { // lines = append(lines, base("config ")+muted(config)) // lines = append(lines, "") lines = append(lines, cmds) + lines = append(lines, "") + lines = append(lines, "") - return lipgloss.Place( - a.width, - a.height-5, + mainHeight := lipgloss.Height(strings.Join(lines, "\n")) + + editorWidth := min(width, 80) + editorView := a.editor.View(editorWidth) + editorView = lipgloss.PlaceHorizontal( + width, + lipgloss.Center, + editorView, + styles.WhitespaceStyle(t.Background()), + ) + lines = append(lines, editorView) + + editorLines := a.editor.Lines() + + mainLayout := lipgloss.Place( + width, + a.height, lipgloss.Center, lipgloss.Center, baseStyle.Render(strings.Join(lines, "\n")), styles.WhitespaceStyle(t.Background()), ) + + editorX := (width - editorWidth) / 2 + editorY := (a.height / 2) + (mainHeight / 2) - 2 + + if editorLines > 1 { + mainLayout = layout.PlaceOverlay( + editorX, + editorY, + a.editor.Content(editorWidth), + mainLayout, + ) + } + + if a.showCompletionDialog { + a.completions.SetWidth(editorWidth) + overlay := a.completions.View() + overlayHeight := lipgloss.Height(overlay) + + mainLayout = layout.PlaceOverlay( + editorX, + editorY-overlayHeight+1, + overlay, + mainLayout, + ) + } + + return mainLayout +} + +func (a appModel) chat(width int) string { + editorView := a.editor.View(width) + lines := a.editor.Lines() + messagesView := a.messages.View(width, a.height-5) + + editorWidth := lipgloss.Width(editorView) + editorHeight := max(lines, 5) + + mainLayout := messagesView + "\n" + editorView + editorX := (a.width - editorWidth) / 2 + + if lines > 1 { + editorY := a.height - editorHeight + mainLayout = layout.PlaceOverlay( + editorX, + editorY, + a.editor.Content(width), + mainLayout, + ) + } + + if a.showCompletionDialog { + a.completions.SetWidth(editorWidth) + overlay := a.completions.View() + overlayHeight := lipgloss.Height(overlay) + editorY := a.height - editorHeight + 1 + + mainLayout = layout.PlaceOverlay( + editorX, + editorY-overlayHeight, + overlay, + mainLayout, + ) + } + + return mainLayout } func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) { + var cmd tea.Cmd cmds := []tea.Cmd{ util.CmdHandler(commands.CommandExecutedMsg(command)), } @@ -679,6 +854,22 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) case commands.ThemeListCommand: themeDialog := dialog.NewThemeDialog() a.modal = themeDialog + case commands.FileListCommand: + a.editor.Blur() + provider := completions.NewFileAndFolderContextGroup(a.app) + findDialog := dialog.NewFindDialog(provider) + findDialog.SetWidth(layout.Current.Container.Width - 8) + a.modal = findDialog + case commands.FileCloseCommand: + a.fileViewer, cmd = a.fileViewer.Clear() + cmds = append(cmds, cmd) + case commands.FileDiffToggleCommand: + a.fileViewer, cmd = a.fileViewer.ToggleDiff() + a.app.State.SplitDiff = a.fileViewer.DiffStyle() == fileviewer.DiffStyleSplit + a.app.SaveState() + cmds = append(cmds, cmd) + case commands.FileSearchCommand: + return a, nil case commands.ProjectInitCommand: cmds = append(cmds, a.app.InitializeProject(context.Background())) case commands.InputClearCommand: @@ -700,20 +891,6 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) updated, cmd := a.editor.Newline() a.editor = updated.(chat.EditorComponent) cmds = append(cmds, cmd) - case commands.HistoryPreviousCommand: - if a.showCompletionDialog { - return a, nil - } - updated, cmd := a.editor.Previous() - a.editor = updated.(chat.EditorComponent) - cmds = append(cmds, cmd) - case commands.HistoryNextCommand: - if a.showCompletionDialog { - return a, nil - } - updated, cmd := a.editor.Next() - a.editor = updated.(chat.EditorComponent) - cmds = append(cmds, cmd) case commands.MessagesFirstCommand: updated, cmd := a.messages.First() a.messages = updated.(chat.MessagesComponent) @@ -723,21 +900,62 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) a.messages = updated.(chat.MessagesComponent) cmds = append(cmds, cmd) case commands.MessagesPageUpCommand: - updated, cmd := a.messages.PageUp() - a.messages = updated.(chat.MessagesComponent) - cmds = append(cmds, cmd) + if a.fileViewer.HasFile() { + a.fileViewer, cmd = a.fileViewer.PageUp() + cmds = append(cmds, cmd) + } else { + updated, cmd := a.messages.PageUp() + a.messages = updated.(chat.MessagesComponent) + cmds = append(cmds, cmd) + } case commands.MessagesPageDownCommand: - updated, cmd := a.messages.PageDown() - a.messages = updated.(chat.MessagesComponent) - cmds = append(cmds, cmd) + if a.fileViewer.HasFile() { + a.fileViewer, cmd = a.fileViewer.PageDown() + cmds = append(cmds, cmd) + } else { + updated, cmd := a.messages.PageDown() + a.messages = updated.(chat.MessagesComponent) + cmds = append(cmds, cmd) + } case commands.MessagesHalfPageUpCommand: - updated, cmd := a.messages.HalfPageUp() - a.messages = updated.(chat.MessagesComponent) - cmds = append(cmds, cmd) + if a.fileViewer.HasFile() { + a.fileViewer, cmd = a.fileViewer.HalfPageUp() + cmds = append(cmds, cmd) + } else { + updated, cmd := a.messages.HalfPageUp() + a.messages = updated.(chat.MessagesComponent) + cmds = append(cmds, cmd) + } case commands.MessagesHalfPageDownCommand: - updated, cmd := a.messages.HalfPageDown() + if a.fileViewer.HasFile() { + a.fileViewer, cmd = a.fileViewer.HalfPageDown() + cmds = append(cmds, cmd) + } else { + updated, cmd := a.messages.HalfPageDown() + a.messages = updated.(chat.MessagesComponent) + cmds = append(cmds, cmd) + } + case commands.MessagesPreviousCommand: + updated, cmd := a.messages.Previous() a.messages = updated.(chat.MessagesComponent) cmds = append(cmds, cmd) + case commands.MessagesNextCommand: + updated, cmd := a.messages.Next() + a.messages = updated.(chat.MessagesComponent) + cmds = append(cmds, cmd) + case commands.MessagesLayoutToggleCommand: + a.messagesRight = !a.messagesRight + a.app.State.MessagesRight = a.messagesRight + a.app.SaveState() + case commands.MessagesCopyCommand: + selected := a.messages.Selected() + if selected != "" { + cmd = tea.SetClipboard(selected) + cmds = append(cmds, cmd) + cmd = toast.NewSuccessToast("Message copied to clipboard") + cmds = append(cmds, cmd) + } + case commands.MessagesRevertCommand: case commands.AppExitCommand: return a, tea.Quit } @@ -779,6 +997,8 @@ func NewModel(app *app.App) tea.Model { showCompletionDialog: false, toastManager: toast.NewToastManager(), interruptKeyState: InterruptKeyIdle, + fileViewer: fileviewer.New(app), + messagesRight: app.State.MessagesRight, } return model diff --git a/packages/tui/internal/util/file.go b/packages/tui/internal/util/file.go new file mode 100644 index 00000000..2c0987dc --- /dev/null +++ b/packages/tui/internal/util/file.go @@ -0,0 +1,109 @@ +package util + +import ( + "fmt" + "path/filepath" + "strings" + "unicode" + + "github.com/charmbracelet/lipgloss/v2/compat" + "github.com/charmbracelet/x/ansi" + "github.com/sst/opencode/internal/styles" + "github.com/sst/opencode/internal/theme" +) + +var RootPath string +var CwdPath string + +type fileRenderer struct { + filename string + content string + height int +} + +type fileRenderingOption func(*fileRenderer) + +func WithTruncate(height int) fileRenderingOption { + return func(c *fileRenderer) { + c.height = height + } +} + +func RenderFile( + filename string, + content string, + width int, + options ...fileRenderingOption) string { + t := theme.CurrentTheme() + renderer := &fileRenderer{ + filename: filename, + content: content, + } + for _, option := range options { + option(renderer) + } + + lines := []string{} + for line := range strings.SplitSeq(content, "\n") { + line = strings.TrimRightFunc(line, unicode.IsSpace) + line = strings.ReplaceAll(line, "\t", " ") + lines = append(lines, line) + } + content = strings.Join(lines, "\n") + + if renderer.height > 0 { + content = TruncateHeight(content, renderer.height) + } + content = fmt.Sprintf("```%s\n%s\n```", Extension(renderer.filename), content) + content = ToMarkdown(content, width, t.BackgroundPanel()) + return content +} + +func TruncateHeight(content string, height int) string { + lines := strings.Split(content, "\n") + if len(lines) > height { + return strings.Join(lines[:height], "\n") + } + return content +} + +func Relative(path string) string { + path = strings.TrimPrefix(path, CwdPath+"/") + return strings.TrimPrefix(path, RootPath+"/") +} + +func Extension(path string) string { + ext := filepath.Ext(path) + if ext == "" { + ext = "" + } else { + ext = strings.ToLower(ext[1:]) + } + return ext +} + +func ToMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string { + r := styles.GetMarkdownRenderer(width-7, backgroundColor) + content = strings.ReplaceAll(content, RootPath+"/", "") + rendered, _ := r.Render(content) + lines := strings.Split(rendered, "\n") + + if len(lines) > 0 { + firstLine := lines[0] + cleaned := ansi.Strip(firstLine) + nospace := strings.ReplaceAll(cleaned, " ", "") + if nospace == "" { + lines = lines[1:] + } + if len(lines) > 0 { + lastLine := lines[len(lines)-1] + cleaned = ansi.Strip(lastLine) + nospace = strings.ReplaceAll(cleaned, " ", "") + if nospace == "" { + lines = lines[:len(lines)-1] + } + } + } + content = strings.Join(lines, "\n") + return strings.TrimSuffix(content, "\n") +} diff --git a/packages/web/package.json b/packages/web/package.json index 2d69de27..383b979f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -28,7 +28,7 @@ "sharp": "0.32.5", "shiki": "3.4.2", "solid-js": "1.9.7", - "toolbeam-docs-theme": "0.3.0" + "toolbeam-docs-theme": "0.4.1" }, "devDependencies": { "opencode": "workspace:*", diff --git a/packages/web/src/components/Head.astro b/packages/web/src/components/Head.astro index 36f11c95..3d9bc0f5 100644 --- a/packages/web/src/components/Head.astro +++ b/packages/web/src/components/Head.astro @@ -7,13 +7,14 @@ import config from '../../config.mjs' const slug = Astro.url.pathname.replace(/^\//, "").replace(/\/$/, ""); const { entry: { - data: { title }, + data: { title , description }, }, } = Astro.locals.starlightRoute; const isDocs = slug.startsWith("docs") let encodedTitle = ''; let ogImage = `${config.url}/social-share.png`; +let truncatedDesc = ''; if (isDocs) { // Truncate to fit S3's max key size @@ -26,7 +27,12 @@ if (isDocs) { ) ) ); - ogImage = `${config.socialCard}/opencode-docs/${encodedTitle}.png`; + + if (description) { + truncatedDesc = encodeURIComponent(description.substring(0, 400)) + } + + ogImage = `${config.socialCard}/opencode-docs/${encodedTitle}.png?desc=${truncatedDesc}`; } --- diff --git a/packages/web/src/components/Share.tsx b/packages/web/src/components/Share.tsx index 008f863c..fd828629 100644 --- a/packages/web/src/components/Share.tsx +++ b/packages/web/src/components/Share.tsx @@ -1346,7 +1346,8 @@ export default function Share(props: { - + {/* Always try to show CodeBlock if preview is available (even if empty string) */} +
- + {/* Fallback to TextPart if preview is not a string (e.g. undefined) AND result exists */} +
{(_part) => { const todos = createMemo(() => - sortTodosByStatus(toolData()?.args.todos), + sortTodosByStatus(toolData()?.args?.todos ?? []), ) const starting = () => todos().every((t) => t.status === "pending") diff --git a/packages/web/src/content/docs/docs/cli.mdx b/packages/web/src/content/docs/docs/cli.mdx index fce59263..49d343be 100644 --- a/packages/web/src/content/docs/docs/cli.mdx +++ b/packages/web/src/content/docs/docs/cli.mdx @@ -1,5 +1,6 @@ --- title: CLI +description: The opencode CLI options and commands. --- Running the opencode CLI starts it for the current directory. @@ -20,6 +21,8 @@ opencode /path/to/project The opencode CLI also has the following commands. +--- + ### run Run opencode in non-interactive mode by passing a prompt directly. @@ -41,7 +44,7 @@ opencode run Explain the use of context in Go | `--continue` | `-c` | Continue the last session | | `--session` | `-s` | Session ID to continue | | `--share` | | Share the session | -| `--model` | `-m` | Mode to use in the form of provider/model | +| `--model` | `-m` | Model to use in the form of provider/model | --- @@ -53,6 +56,8 @@ Command to manage credentials and login for providers. opencode auth [command] ``` +--- + #### login Logs you into a provider and saves them in the credentials file in `~/.local/share/opencode/auth.json`. @@ -63,6 +68,8 @@ opencode auth login When opencode starts up it will loads the providers from the credentials file. And if there are any keys defined in your environments or a `.env` file in your project. +--- + #### list Lists all the authenticated providers as stored in the credentials file. @@ -77,6 +84,8 @@ Or the short version. opencode auth ls ``` +--- + #### logout Logs you out of a provider by clearing it from the credentials file. diff --git a/packages/web/src/content/docs/docs/config.mdx b/packages/web/src/content/docs/docs/config.mdx index e9a493af..d88749c6 100644 --- a/packages/web/src/content/docs/docs/config.mdx +++ b/packages/web/src/content/docs/docs/config.mdx @@ -1,5 +1,6 @@ --- title: Config +description: Using the opencode JSON config. --- You can configure opencode using a JSON config file that can be placed in: diff --git a/packages/web/src/content/docs/docs/index.mdx b/packages/web/src/content/docs/docs/index.mdx index 4926450c..b39ce452 100644 --- a/packages/web/src/content/docs/docs/index.mdx +++ b/packages/web/src/content/docs/docs/index.mdx @@ -1,5 +1,6 @@ --- title: Intro +description: Get started with opencode. --- import { Tabs, TabItem } from '@astrojs/starlight/components'; @@ -62,12 +63,12 @@ brew install sst/tap/opencode paru -S opencode-bin ``` ---- - ##### Windows Right now the automatic installation methods do not work properly on Windows. However you can grab the binary from the [Releases](https://github.com/sst/opencode/releases). +--- + ## Providers We recommend signing up for Claude Pro or Max, running `opencode auth login` and selecting Anthropic. It's the most cost-effective way to use opencode. diff --git a/packages/web/src/content/docs/docs/keybinds.mdx b/packages/web/src/content/docs/docs/keybinds.mdx index ba18fd25..8c5aa2c4 100644 --- a/packages/web/src/content/docs/docs/keybinds.mdx +++ b/packages/web/src/content/docs/docs/keybinds.mdx @@ -1,5 +1,6 @@ --- title: Keybinds +description: Customize your keybinds. --- opencode has a list of keybinds that you can customize through the opencode config. diff --git a/packages/web/src/content/docs/docs/mcp-servers.mdx b/packages/web/src/content/docs/docs/mcp-servers.mdx index d34a1ac4..72c33e8a 100644 --- a/packages/web/src/content/docs/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/docs/mcp-servers.mdx @@ -1,5 +1,6 @@ --- title: MCP servers +description: Add local and remote MCP tools. --- You can add external tools to opencode using the _Model Context Protocol_, or MCP. opencode supports both: diff --git a/packages/web/src/content/docs/docs/models.mdx b/packages/web/src/content/docs/docs/models.mdx index 28a22dd4..f60a2544 100644 --- a/packages/web/src/content/docs/docs/models.mdx +++ b/packages/web/src/content/docs/docs/models.mdx @@ -1,5 +1,6 @@ --- title: Models +description: Configuring an LLM provider and model. --- opencode uses the [AI SDK](https://ai-sdk.dev/) and [Models.dev](https://models.dev) to support for **75+ LLM providers** and it supports running local models. diff --git a/packages/web/src/content/docs/docs/rules.mdx b/packages/web/src/content/docs/docs/rules.mdx index c02ce50f..aed08535 100644 --- a/packages/web/src/content/docs/docs/rules.mdx +++ b/packages/web/src/content/docs/docs/rules.mdx @@ -1,5 +1,6 @@ --- title: Rules +description: Set custom instructions for opencode. --- You can provide custom instructions to opencode by creating an `AGENTS.md` file. This is similar to `CLAUDE.md` or Cursor's rules. It contains instructions that will be included in the LLM's context to customize its behavior for your specific project. diff --git a/packages/web/src/content/docs/docs/themes.mdx b/packages/web/src/content/docs/docs/themes.mdx index 42f4edce..da612284 100644 --- a/packages/web/src/content/docs/docs/themes.mdx +++ b/packages/web/src/content/docs/docs/themes.mdx @@ -1,5 +1,6 @@ --- title: Themes +description: Select a built-in theme or define your own. --- With opencode you can select from one of several built-in themes, use a theme that adapts to your terminal theme, or define your own custom theme. @@ -62,6 +63,8 @@ You can select a theme by bringing up the theme select with the `/theme` command opencode supports a flexible JSON-based theme system that allows users to create and customize themes easily. +--- + ### Hierarchy Themes are loaded from multiple directories in the following order where later directories override earlier ones: @@ -73,6 +76,8 @@ Themes are loaded from multiple directories in the following order where later d If multiple directories contain a theme with the same name, the theme from the directory with higher priority will be used. +--- + ### Creating a theme To create a custom theme, create a JSON file in one of the theme directories. @@ -91,6 +96,8 @@ mkdir -p .opencode/themes vim .opencode/themes/my-theme.json ``` +--- + ### JSON format Themes use a flexible JSON format with support for: @@ -101,6 +108,23 @@ Themes use a flexible JSON format with support for: - **Dark/light variants**: `{"dark": "#000", "light": "#fff"}` - **No color**: `"none"` - Uses the terminal's default color or transparent +--- + +### Color definitions + +The `defs` section is optional and it allows you to define reusable colors that can be referenced in the theme. + +--- + +### Terminal defaults + +The special value `"none"` can be used for any color to inherit the terminal's default color. This is particularly useful for creating themes that blend seamlessly with your terminal's color scheme: + +- `"text": "none"` - Uses terminal's default foreground color +- `"background": "none"` - Uses terminal's default background color + +--- + ### Example Here's an example of a custom theme: @@ -330,14 +354,3 @@ Here's an example of a custom theme: } } ``` - -### Color definitions - -The `defs` section is optional and it allows you to define reusable colors that can be referenced in the theme. - -### Terminal defaults - -The special value `\"none\"` can be used for any color to inherit the terminal's default color. This is particularly useful for creating themes that blend seamlessly with your terminal's color scheme: - -- `"text": "none"` - Uses terminal's default foreground color -- `"background": "none"` - Uses terminal's default background color diff --git a/stainless.yml b/stainless.yml index 23e0be23..f8d654fb 100644 --- a/stainless.yml +++ b/stainless.yml @@ -51,9 +51,16 @@ resources: get: get /app init: post /app/init + find: + methods: + text: get /find + files: get /find/file + symbols: get /find/symbol + file: methods: - search: get /file + read: get /file + status: get /file/status config: models: