Merge branch 'dev' into feat/interactive-scrollbar - Resolved conflicts in messages.go

This commit is contained in:
honeycomb-tech 2025-07-02 22:28:27 -07:00
commit 6ef2dba263
58 changed files with 2228 additions and 675 deletions

View file

@ -5,3 +5,4 @@
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) | | 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-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-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) |

View file

@ -82,7 +82,7 @@
"sharp": "0.32.5", "sharp": "0.32.5",
"shiki": "3.4.2", "shiki": "3.4.2",
"solid-js": "1.9.7", "solid-js": "1.9.7",
"toolbeam-docs-theme": "0.3.0", "toolbeam-docs-theme": "0.4.1",
}, },
"devDependencies": { "devDependencies": {
"@types/node": "catalog:", "@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=="], "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=="], "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],

View file

@ -35,8 +35,7 @@ export class SyncServer extends DurableObject<Env> {
ws.close(code, "Durable Object is closing WebSocket") ws.close(code, "Durable Object is closing WebSocket")
} }
async publish(secret: string, key: string, content: any) { async publish(key: string, content: any) {
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
const sessionID = await this.getSessionID() const sessionID = await this.getSessionID()
if ( if (
!key.startsWith(`session/info/${sessionID}`) && !key.startsWith(`session/info/${sessionID}`) &&
@ -76,6 +75,10 @@ export class SyncServer extends DurableObject<Env> {
.map(([key, content]) => ({ key, content })) .map(([key, content]) => ({ key, content }))
} }
public async assertSecret(secret: string) {
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
}
private async getSecret() { private async getSecret() {
return this.ctx.storage.get<string>("secret") return this.ctx.storage.get<string>("secret")
} }
@ -84,15 +87,19 @@ export class SyncServer extends DurableObject<Env> {
return this.ctx.storage.get<string>("sessionID") return this.ctx.storage.get<string>("sessionID")
} }
async clear(secret: string) { async clear() {
await this.assertSecret(secret) 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() await this.ctx.storage.deleteAll()
} }
private async assertSecret(secret: string) {
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
}
static shortName(id: string) { static shortName(id: string) {
return id.substring(id.length - 8) return id.substring(id.length - 8)
} }
@ -134,7 +141,17 @@ export default {
const secret = body.secret const secret = body.secret
const id = env.SYNC_SERVER.idFromName(SyncServer.shortName(sessionID)) const id = env.SYNC_SERVER.idFromName(SyncServer.shortName(sessionID))
const stub = env.SYNC_SERVER.get(id) 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({}), { return new Response(JSON.stringify({}), {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}) })
@ -150,7 +167,8 @@ export default {
const name = SyncServer.shortName(body.sessionID) const name = SyncServer.shortName(body.sessionID)
const id = env.SYNC_SERVER.idFromName(name) const id = env.SYNC_SERVER.idFromName(name)
const stub = env.SYNC_SERVER.get(id) 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({}), { return new Response(JSON.stringify({}), {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}) })

View file

@ -49,7 +49,7 @@ else
done done
if [ -z "$resolved" ]; then 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 exit 1
fi fi
fi fi

View file

@ -48,9 +48,9 @@ set "current_dir=%parent_dir%"
goto :search_loop goto :search_loop
:not_found :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 exit /b 1
:execute :execute
rem Execute the binary with all arguments rem Execute the binary with all arguments
"%resolved%" %* "%resolved%" %*

View file

@ -297,6 +297,13 @@
}, },
"description": "MCP (Model Context Protocol) server configurations" "description": "MCP (Model Context Protocol) server configurations"
}, },
"instructions": {
"type": "array",
"items": {
"type": "string"
},
"description": "Additional instruction files or patterns to include"
},
"experimental": { "experimental": {
"type": "object", "type": "object",
"properties": { "properties": {

View file

@ -40,7 +40,7 @@ for (const [os, arch] of targets) {
console.log(`building ${os}-${arch}`) console.log(`building ${os}-${arch}`)
const name = `${pkg.name}-${os}-${arch}` const name = `${pkg.name}-${os}-${arch}`
await $`mkdir -p dist/${name}/bin` 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", "../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` 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 ( return (
!lower.includes("ignore:") && !lower.includes("ignore:") &&
!lower.includes("ci:") && !lower.includes("ci:") &&
!lower.includes("wip:") &&
!lower.includes("docs:") && !lower.includes("docs:") &&
!lower.includes("doc:") !lower.includes("doc:")
) )

View file

@ -1,13 +1,6 @@
import { File } from "../../../file" import { File } from "../../../file"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd" 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({ const FileReadCommand = cmd({
command: "read <path>", command: "read <path>",
@ -19,8 +12,26 @@ const FileReadCommand = cmd({
}), }),
async handler(args) { async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => { 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) 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() {},
})

View file

@ -100,7 +100,7 @@ export const TuiCommand = cmd({
UI.empty() UI.empty()
UI.println(UI.logo(" ")) UI.println(UI.logo(" "))
const result = await Bun.spawn({ const result = await Bun.spawn({
cmd: [process.execPath, "auth", "login"], cmd: [...getOpencodeCommand(), "auth", "login"],
cwd: process.cwd(), cwd: process.cwd(),
stdout: "inherit", stdout: "inherit",
stderr: "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]
}

View file

@ -176,6 +176,10 @@ export namespace Config {
.record(z.string(), Mcp) .record(z.string(), Mcp)
.optional() .optional()
.describe("MCP (Model Context Protocol) server configurations"), .describe("MCP (Model Context Protocol) server configurations"),
instructions: z
.array(z.string())
.optional()
.describe("Additional instruction files or patterns to include"),
experimental: z experimental: z
.object({ .object({
hook: z hook: z

View file

@ -3,8 +3,14 @@ import { Bus } from "../bus"
import { $ } from "bun" import { $ } from "bun"
import { createPatch } from "diff" import { createPatch } from "diff"
import path from "path" 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 { export namespace File {
const log = Log.create({ service: "file" })
export const Event = { export const Event = {
Edited: Bus.event( Edited: Bus.event(
"file.edited", "file.edited",
@ -14,25 +20,109 @@ export namespace File {
), ),
} }
export async function read(file: string) { export async function status() {
const content = await Bun.file(file).text() const app = App.info()
const gitDiff = await $`git diff HEAD -- ${file}` if (!app.git) return []
.cwd(path.dirname(file))
const diffOutput = await $`git diff --numstat HEAD`
.cwd(app.path.cwd)
.quiet() .quiet()
.nothrow() .nothrow()
.text() .text()
if (gitDiff.trim()) {
const relativePath = path.relative(process.cwd(), file) const changedFiles = []
const originalContent = await $`git show HEAD:./${relativePath}`
.cwd(process.cwd()) if (diffOutput.trim()) {
.quiet() const lines = diffOutput.trim().split("\n")
.nothrow() for (const line of lines) {
.text() const [added, removed, filepath] = line.split("\t")
if (originalContent.trim()) { changedFiles.push({
const patch = createPatch(file, originalContent, content) file: filepath,
return patch 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 }
} }
} }

View file

@ -32,7 +32,7 @@ export namespace Ripgrep {
}), }),
}) })
const Match = z.object({ export const Match = z.object({
type: z.literal("match"), type: z.literal("match"),
data: z.object({ data: z.object({
path: z.object({ path: z.object({
@ -86,9 +86,14 @@ export namespace Ripgrep {
export type End = z.infer<typeof End> export type End = z.infer<typeof End>
export type Summary = z.infer<typeof Summary> export type Summary = z.infer<typeof Summary>
const PLATFORM = { const PLATFORM = {
darwin: { platform: "apple-darwin", extension: "tar.gz" }, "arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
linux: { platform: "unknown-linux-musl", extension: "tar.gz" }, "arm64-linux": {
win32: { platform: "pc-windows-msvc", extension: "zip" }, 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 } as const
export const ExtractionFailedError = NamedError.create( export const ExtractionFailedError = NamedError.create(
@ -124,15 +129,13 @@ export namespace Ripgrep {
const file = Bun.file(filepath) const file = Bun.file(filepath)
if (!(await file.exists())) { if (!(await file.exists())) {
const archMap = { x64: "x86_64", arm64: "aarch64" } as const const platformKey =
const arch = archMap[process.arch as keyof typeof archMap] ?? process.arch `${process.arch}-${process.platform}` as keyof typeof PLATFORM
const config = PLATFORM[platformKey]
const config = PLATFORM[process.platform as keyof typeof PLATFORM] if (!config) throw new UnsupportedPlatformError({ platform: platformKey })
if (!config)
throw new UnsupportedPlatformError({ platform: process.platform })
const version = "14.1.1" 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 url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`
const response = await fetch(url) const response = await fetch(url)
@ -145,8 +148,8 @@ export namespace Ripgrep {
if (config.extension === "tar.gz") { if (config.extension === "tar.gz") {
const args = ["tar", "-xzf", archivePath, "--strip-components=1"] const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
if (process.platform === "darwin") args.push("--include=*/rg") if (platformKey.endsWith("-darwin")) args.push("--include=*/rg")
if (process.platform === "linux") args.push("--wildcards", "*/rg") if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg")
const proc = Bun.spawn(args, { const proc = Bun.spawn(args, {
cwd: Global.Path.bin, cwd: Global.Path.bin,
@ -177,7 +180,7 @@ export namespace Ripgrep {
}) })
} }
await fs.unlink(archivePath) await fs.unlink(archivePath)
if (process.platform !== "win32") await fs.chmod(filepath, 0o755) if (!platformKey.endsWith("-win32")) await fs.chmod(filepath, 0o755)
} }
return { return {

View file

@ -22,28 +22,30 @@ export namespace FileWatcher {
"file.watcher", "file.watcher",
() => { () => {
const app = App.use() const app = App.use()
const watcher = fs.watch( try {
app.info.path.cwd, const watcher = fs.watch(
{ recursive: true }, app.info.path.cwd,
(event, file) => { { recursive: true },
log.info("change", { file, event }) (event, file) => {
if (!file) return log.info("change", { file, event })
// for some reason async local storage is lost here if (!file) return
// https://github.com/oven-sh/bun/issues/20754 // for some reason async local storage is lost here
App.provideExisting(app, async () => { // https://github.com/oven-sh/bun/issues/20754
Bus.publish(Event.Updated, { App.provideExisting(app, async () => {
file, Bus.publish(Event.Updated, {
event, file,
event,
})
}) })
}) },
}, )
) return { watcher }
return { } catch {
watcher, return {}
} }
}, },
async (state) => { async (state) => {
state.watcher.close() state.watcher?.close()
}, },
)() )()
} }

View file

@ -31,7 +31,7 @@ export namespace Format {
const result = [] const result = []
for (const item of Object.values(Formatter)) { for (const item of Object.values(Formatter)) {
if (!item.extensions.includes(ext)) continue if (!item.extensions.includes(ext)) continue
if (!isEnabled(item)) continue if (!(await isEnabled(item))) continue
result.push(item) result.push(item)
} }
return result return result

View file

@ -4,10 +4,34 @@ import { LSPClient } from "./client"
import path from "path" import path from "path"
import { LSPServer } from "./server" import { LSPServer } from "./server"
import { Ripgrep } from "../file/ripgrep" import { Ripgrep } from "../file/ripgrep"
import { z } from "zod"
export namespace LSP { export namespace LSP {
const log = Log.create({ service: "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<typeof Symbol>
const state = App.state( const state = App.state(
"lsp", "lsp",
async (app) => { async (app) => {
@ -96,7 +120,7 @@ export namespace LSP {
client.connection.sendRequest("workspace/symbol", { client.connection.sendRequest("workspace/symbol", {
query, query,
}), }),
) ).then((result) => result.flat() as LSP.Symbol[])
} }
async function run<T>( async function run<T>(

View file

@ -57,6 +57,7 @@ export namespace LSPServer {
PATH: process.env["PATH"] + ":" + Global.Path.bin, PATH: process.env["PATH"] + ":" + Global.Path.bin,
}) })
if (!bin) { if (!bin) {
if (!Bun.which("go")) return
log.info("installing gopls") log.info("installing gopls")
const proc = Bun.spawn({ const proc = Bun.spawn({
cmd: ["go", "install", "golang.org/x/tools/gopls@latest"], cmd: ["go", "install", "golang.org/x/tools/gopls@latest"],

View file

@ -99,11 +99,25 @@ export namespace Provider {
}) })
info.access = tokens.access 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 = { const headers = {
...init.headers, ...init.headers,
...copilot.HEADERS, ...copilot.HEADERS,
Authorization: `Bearer ${info.access}`, Authorization: `Bearer ${info.access}`,
"Openai-Intent": "conversation-edits", "Openai-Intent": "conversation-edits",
"X-Initiator": isAgentCall ? "agent" : "user",
} }
delete headers["x-api-key"] delete headers["x-api-key"]
return fetch(input, { 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 () => { const state = App.state("provider", async () => {

View file

@ -14,6 +14,8 @@ import { NamedError } from "../util/error"
import { ModelsDev } from "../provider/models" import { ModelsDev } from "../provider/models"
import { Ripgrep } from "../file/ripgrep" import { Ripgrep } from "../file/ripgrep"
import { Config } from "../config/config" import { Config } from "../config/config"
import { File } from "../file"
import { LSP } from "../lsp"
const ERRORS = { const ERRORS = {
400: { 400: {
@ -73,7 +75,7 @@ export namespace Server {
documentation: { documentation: {
info: { info: {
title: "opencode", title: "opencode",
version: "0.0.2", version: "0.0.3",
description: "opencode api", description: "opencode api",
}, },
openapi: "3.0.0", openapi: "3.0.0",
@ -492,12 +494,44 @@ export namespace Server {
}, },
) )
.get( .get(
"/file", "/find",
describeRoute({ describeRoute({
description: "Search for files", description: "Find text in files",
responses: { responses: {
200: { 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: { content: {
"application/json": { "application/json": {
schema: resolver(z.string().array()), schema: resolver(z.string().array()),
@ -523,6 +557,98 @@ export namespace Server {
return c.json(result) 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 return result
} }

View file

@ -34,6 +34,7 @@ import type { ModelsDev } from "../provider/models"
import { Installation } from "../installation" import { Installation } from "../installation"
import { Config } from "../config/config" import { Config } from "../config/config"
import { ProviderTransform } from "../provider/transform" import { ProviderTransform } from "../provider/transform"
import { Snapshot } from "../snapshot"
export namespace Session { export namespace Session {
const log = Log.create({ service: "session" }) const log = Log.create({ service: "session" })
@ -53,6 +54,13 @@ export namespace Session {
created: z.number(), created: z.number(),
updated: z.number(), updated: z.number(),
}), }),
revert: z
.object({
messageID: z.string(),
part: z.number(),
snapshot: z.string().optional(),
})
.optional(),
}) })
.openapi({ .openapi({
ref: "Session", ref: "Session",
@ -285,6 +293,37 @@ export namespace Session {
l.info("chatting") l.info("chatting")
const model = await Provider.getModel(input.providerID, input.modelID) const model = await Provider.getModel(input.providerID, input.modelID)
let msgs = await messages(input.sessionID) 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) const previous = msgs.at(-1)
// auto summarize if too long // auto summarize if too long
@ -319,7 +358,6 @@ export namespace Session {
if (lastSummary) msgs = msgs.filter((msg) => msg.id >= lastSummary.id) if (lastSummary) msgs = msgs.filter((msg) => msg.id >= lastSummary.id)
const app = App.info() const app = App.info()
const session = await get(input.sessionID)
if (msgs.length === 0 && !session.parentID) { if (msgs.length === 0 && !session.parentID) {
generateText({ generateText({
maxTokens: input.providerID === "google" ? 1024 : 20, maxTokens: input.providerID === "google" ? 1024 : 20,
@ -349,6 +387,7 @@ export namespace Session {
}) })
.catch(() => {}) .catch(() => {})
} }
const snapshot = await Snapshot.create(input.sessionID)
const msg: Message.Info = { const msg: Message.Info = {
role: "user", role: "user",
id: Identifier.ascending("message"), id: Identifier.ascending("message"),
@ -359,6 +398,7 @@ export namespace Session {
}, },
sessionID: input.sessionID, sessionID: input.sessionID,
tool: {}, tool: {},
snapshot,
}, },
} }
await updateMessage(msg) await updateMessage(msg)
@ -373,6 +413,7 @@ export namespace Session {
role: "assistant", role: "assistant",
parts: [], parts: [],
metadata: { metadata: {
snapshot,
assistant: { assistant: {
system, system,
path: { path: {
@ -424,6 +465,7 @@ export namespace Session {
}) })
next.metadata!.tool![opts.toolCallId] = { next.metadata!.tool![opts.toolCallId] = {
...result.metadata, ...result.metadata,
snapshot: await Snapshot.create(input.sessionID),
time: { time: {
start, start,
end: Date.now(), end: Date.now(),
@ -436,6 +478,7 @@ export namespace Session {
error: true, error: true,
message: e.toString(), message: e.toString(),
title: e.toString(), title: e.toString(),
snapshot: await Snapshot.create(input.sessionID),
time: { time: {
start, start,
end: Date.now(), end: Date.now(),
@ -457,6 +500,7 @@ export namespace Session {
const result = await execute(args, opts) const result = await execute(args, opts)
next.metadata!.tool![opts.toolCallId] = { next.metadata!.tool![opts.toolCallId] = {
...result.metadata, ...result.metadata,
snapshot: await Snapshot.create(input.sessionID),
time: { time: {
start, start,
end: Date.now(), end: Date.now(),
@ -471,6 +515,7 @@ export namespace Session {
next.metadata!.tool![opts.toolCallId] = { next.metadata!.tool![opts.toolCallId] = {
error: true, error: true,
message: e.toString(), message: e.toString(),
snapshot: await Snapshot.create(input.sessionID),
title: "mcp", title: "mcp",
time: { time: {
start, start,
@ -735,6 +780,51 @@ export namespace Session {
return next 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: { export async function summarize(input: {
sessionID: string sessionID: string
providerID: string providerID: string

View file

@ -159,6 +159,7 @@ export namespace Message {
z z
.object({ .object({
title: z.string(), title: z.string(),
snapshot: z.string().optional(),
time: z.object({ time: z.object({
start: z.number(), start: z.number(),
end: z.number(), end: z.number(),
@ -188,11 +189,7 @@ export namespace Message {
}), }),
}) })
.optional(), .optional(),
user: z snapshot: z.string().optional(),
.object({
snapshot: z.string().optional(),
})
.optional(),
}) })
.openapi({ ref: "MessageMetadata" }), .openapi({ ref: "MessageMetadata" }),
}) })
@ -208,6 +205,13 @@ export namespace Message {
info: Info, info: Info,
}), }),
), ),
Removed: Bus.event(
"message.removed",
z.object({
sessionID: z.string(),
messageID: z.string(),
}),
),
PartUpdated: Bus.event( PartUpdated: Bus.event(
"message.part.updated", "message.part.updated",
z.object({ z.object({

View file

@ -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. - 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 - 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. - 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. 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 <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result. - Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.

View file

@ -2,6 +2,7 @@ import { App } from "../app/app"
import { Ripgrep } from "../file/ripgrep" import { Ripgrep } from "../file/ripgrep"
import { Global } from "../global" import { Global } from "../global"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import { Config } from "../config/config"
import path from "path" import path from "path"
import os from "os" import os from "os"
@ -55,8 +56,10 @@ export namespace SystemPrompt {
"CLAUDE.md", "CLAUDE.md",
"CONTEXT.md", // deprecated "CONTEXT.md", // deprecated
] ]
export async function custom() { export async function custom() {
const { cwd, root } = App.info().path const { cwd, root } = App.info().path
const config = await Config.get()
const found = [] const found = []
for (const item of CUSTOM_FILES) { for (const item of CUSTOM_FILES) {
const matches = await Filesystem.findUp(item, cwd, root) const matches = await Filesystem.findUp(item, cwd, root)
@ -72,6 +75,18 @@ export namespace SystemPrompt {
.text() .text()
.catch(() => ""), .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)) return Promise.all(found).then((result) => result.filter(Boolean))
} }

View file

@ -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 - 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 - Any lines longer than 2000 characters will be truncated
- Results are returned using cat -n format, with line numbers starting at 1 - 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 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 - 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. - If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.

View file

@ -15,4 +15,28 @@ export namespace Filesystem {
} }
return result 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
}
} }

View file

@ -4,6 +4,7 @@ export function lazy<T>(fn: () => T) {
return (): T => { return (): T => {
if (loaded) return value as T if (loaded) return value as T
loaded = true
value = fn() value = fn()
return value as T return value as T
} }

View file

@ -15,7 +15,7 @@ require (
github.com/muesli/reflow v0.3.0 github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.16.0 github.com/muesli/termenv v0.16.0
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 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 github.com/tidwall/gjson v1.14.4
rsc.io/qr v0.2.0 rsc.io/qr v0.2.0
) )

View file

@ -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/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 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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.8 h1:Tp7nbckbMCwAA/ieVZeeZCp79xXtrPMaWLRk5mhNwrw=
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/go.mod h1:uagorfAHZsVy6vf0xY6TlQraM4uCILdZ5tKKhl1oToM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 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 v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=

View file

@ -20,9 +20,6 @@ import (
"github.com/sst/opencode/internal/util" "github.com/sst/opencode/internal/util"
) )
var RootPath string
var CwdPath string
type App struct { type App struct {
Info opencode.App Info opencode.App
Version string Version string
@ -38,6 +35,7 @@ type App struct {
} }
type SessionSelectedMsg = *opencode.Session type SessionSelectedMsg = *opencode.Session
type SessionLoadedMsg struct{}
type ModelSelectedMsg struct { type ModelSelectedMsg struct {
Provider opencode.Provider Provider opencode.Provider
Model opencode.Model Model opencode.Model
@ -54,6 +52,9 @@ type CompletionDialogTriggeredMsg struct {
type OptimisticMessageAddedMsg struct { type OptimisticMessageAddedMsg struct {
Message opencode.Message Message opencode.Message
} }
type FileRenderedMsg struct {
FilePath string
}
func New( func New(
ctx context.Context, ctx context.Context,
@ -61,8 +62,8 @@ func New(
appInfo opencode.App, appInfo opencode.App,
httpClient *opencode.Client, httpClient *opencode.Client,
) (*App, error) { ) (*App, error) {
RootPath = appInfo.Path.Root util.RootPath = appInfo.Path.Root
CwdPath = appInfo.Path.Cwd util.CwdPath = appInfo.Path.Cwd
configInfo, err := httpClient.Config.Get(ctx) configInfo, err := httpClient.Config.Get(ctx)
if err != nil { if err != nil {
@ -125,6 +126,19 @@ func New(
return app, nil 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 { func (a *App) InitializeProvider() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
providersResponse, err := a.Client.Config.Providers(context.Background()) providersResponse, err := a.Client.Config.Providers(context.Background())

View file

@ -80,13 +80,15 @@ const (
ToolDetailsCommand CommandName = "tool_details" ToolDetailsCommand CommandName = "tool_details"
ModelListCommand CommandName = "model_list" ModelListCommand CommandName = "model_list"
ThemeListCommand CommandName = "theme_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" ProjectInitCommand CommandName = "project_init"
InputClearCommand CommandName = "input_clear" InputClearCommand CommandName = "input_clear"
InputPasteCommand CommandName = "input_paste" InputPasteCommand CommandName = "input_paste"
InputSubmitCommand CommandName = "input_submit" InputSubmitCommand CommandName = "input_submit"
InputNewlineCommand CommandName = "input_newline" InputNewlineCommand CommandName = "input_newline"
HistoryPreviousCommand CommandName = "history_previous"
HistoryNextCommand CommandName = "history_next"
MessagesPageUpCommand CommandName = "messages_page_up" MessagesPageUpCommand CommandName = "messages_page_up"
MessagesPageDownCommand CommandName = "messages_page_down" MessagesPageDownCommand CommandName = "messages_page_down"
MessagesHalfPageUpCommand CommandName = "messages_half_page_up" MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
@ -95,6 +97,9 @@ const (
MessagesNextCommand CommandName = "messages_next" MessagesNextCommand CommandName = "messages_next"
MessagesFirstCommand CommandName = "messages_first" MessagesFirstCommand CommandName = "messages_first"
MessagesLastCommand CommandName = "messages_last" MessagesLastCommand CommandName = "messages_last"
MessagesLayoutToggleCommand CommandName = "messages_layout_toggle"
MessagesCopyCommand CommandName = "messages_copy"
MessagesRevertCommand CommandName = "messages_revert"
AppExitCommand CommandName = "app_exit" AppExitCommand CommandName = "app_exit"
) )
@ -184,6 +189,27 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Keybindings: parseBindings("<leader>t"), Keybindings: parseBindings("<leader>t"),
Trigger: "themes", Trigger: "themes",
}, },
{
Name: FileListCommand,
Description: "list files",
Keybindings: parseBindings("<leader>f"),
Trigger: "files",
},
{
Name: FileCloseCommand,
Description: "close file",
Keybindings: parseBindings("esc"),
},
{
Name: FileSearchCommand,
Description: "search file",
Keybindings: parseBindings("<leader>/"),
},
{
Name: FileDiffToggleCommand,
Description: "split/unified diff",
Keybindings: parseBindings("<leader>v"),
},
{ {
Name: ProjectInitCommand, Name: ProjectInitCommand,
Description: "create/update AGENTS.md", Description: "create/update AGENTS.md",
@ -210,16 +236,6 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Description: "insert newline", Description: "insert newline",
Keybindings: parseBindings("shift+enter", "ctrl+j"), Keybindings: parseBindings("shift+enter", "ctrl+j"),
}, },
// {
// Name: HistoryPreviousCommand,
// Description: "previous prompt",
// Keybindings: parseBindings("up"),
// },
// {
// Name: HistoryNextCommand,
// Description: "next prompt",
// Keybindings: parseBindings("down"),
// },
{ {
Name: MessagesPageUpCommand, Name: MessagesPageUpCommand,
Description: "page up", Description: "page up",
@ -243,12 +259,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
{ {
Name: MessagesPreviousCommand, Name: MessagesPreviousCommand,
Description: "previous message", Description: "previous message",
Keybindings: parseBindings("ctrl+alt+k"), Keybindings: parseBindings("ctrl+up"),
}, },
{ {
Name: MessagesNextCommand, Name: MessagesNextCommand,
Description: "next message", Description: "next message",
Keybindings: parseBindings("ctrl+alt+j"), Keybindings: parseBindings("ctrl+down"),
}, },
{ {
Name: MessagesFirstCommand, Name: MessagesFirstCommand,
@ -260,6 +276,21 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Description: "last message", Description: "last message",
Keybindings: parseBindings("ctrl+alt+g"), Keybindings: parseBindings("ctrl+alt+g"),
}, },
{
Name: MessagesLayoutToggleCommand,
Description: "toggle layout",
Keybindings: parseBindings("<leader>p"),
},
{
Name: MessagesCopyCommand,
Description: "copy message",
Keybindings: parseBindings("<leader>y"),
},
{
Name: MessagesRevertCommand,
Description: "revert message",
Keybindings: parseBindings("<leader>u"),
},
{ {
Name: AppExitCommand, Name: AppExitCommand,
Description: "exit the app", Description: "exit the app",

View file

@ -25,13 +25,6 @@ func (c *CommandCompletionProvider) GetId() string {
return "commands" return "commands"
} }
func (c *CommandCompletionProvider) GetEntry() dialog.CompletionItemI {
return dialog.NewCompletionItem(dialog.CompletionItem{
Title: "Commands",
Value: "commands",
})
}
func (c *CommandCompletionProvider) GetEmptyMessage() string { func (c *CommandCompletionProvider) GetEmptyMessage() string {
return "no matching commands" return "no matching commands"
} }

View file

@ -2,64 +2,108 @@ package completions
import ( import (
"context" "context"
"log/slog"
"sort"
"strconv"
"strings"
"github.com/sst/opencode-sdk-go" "github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog" "github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
) )
type filesAndFoldersContextGroup struct { type filesAndFoldersContextGroup struct {
app *app.App app *app.App
prefix string prefix string
gitFiles []dialog.CompletionItemI
} }
func (cg *filesAndFoldersContextGroup) GetId() string { func (cg *filesAndFoldersContextGroup) GetId() string {
return cg.prefix return cg.prefix
} }
func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
return dialog.NewCompletionItem(dialog.CompletionItem{
Title: "Files & Folders",
Value: "files",
})
}
func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string { func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
return "no matching files" return "no matching files"
} }
func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) { func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI {
files, err := cg.app.Client.File.Search( t := theme.CurrentTheme()
context.Background(), items := make([]dialog.CompletionItemI, 0)
opencode.FileSearchParams{Query: opencode.F(query)}, base := styles.NewStyle().Background(t.BackgroundElement())
) green := base.Foreground(t.Success()).Render
if err != nil { red := base.Foreground(t.Error()).Render
return []string{}, err
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) { func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
matches, err := cg.getFiles(query) items := make([]dialog.CompletionItemI, 0)
if err != nil {
return nil, err query = strings.TrimSpace(query)
if query == "" {
items = append(items, cg.gitFiles...)
} }
items := make([]dialog.CompletionItemI, 0, len(matches)) files, err := cg.app.Client.Find.Files(
for _, file := range matches { context.Background(),
item := dialog.NewCompletionItem(dialog.CompletionItem{ opencode.FindFilesParams{Query: opencode.F(query)},
Title: file, )
Value: file, if err != nil {
}) slog.Error("Failed to get completion items", "error", err)
items = append(items, item) }
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 return items, nil
} }
func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider { func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
return &filesAndFoldersContextGroup{ cg := &filesAndFoldersContextGroup{
app: app, app: app,
prefix: "file", prefix: "file",
} }
cg.gitFiles = cg.getGitFiles()
return cg
} }

View file

@ -13,7 +13,6 @@ import (
"github.com/sst/opencode/internal/components/dialog" "github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/textarea" "github.com/sst/opencode/internal/components/textarea"
"github.com/sst/opencode/internal/image" "github.com/sst/opencode/internal/image"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util" "github.com/sst/opencode/internal/util"
@ -21,10 +20,8 @@ import (
type EditorComponent interface { type EditorComponent interface {
tea.Model tea.Model
// tea.ViewModel View(width int) string
SetSize(width, height int) tea.Cmd Content(width int) string
View(width int, align lipgloss.Position) string
Content(width int, align lipgloss.Position) string
Lines() int Lines() int
Value() string Value() string
Focused() bool Focused() bool
@ -34,19 +31,13 @@ type EditorComponent interface {
Clear() (tea.Model, tea.Cmd) Clear() (tea.Model, tea.Cmd)
Paste() (tea.Model, tea.Cmd) Paste() (tea.Model, tea.Cmd)
Newline() (tea.Model, tea.Cmd) Newline() (tea.Model, tea.Cmd)
Previous() (tea.Model, tea.Cmd)
Next() (tea.Model, tea.Cmd)
SetInterruptKeyInDebounce(inDebounce bool) SetInterruptKeyInDebounce(inDebounce bool)
} }
type editorComponent struct { type editorComponent struct {
app *app.App app *app.App
width, height int
textarea textarea.Model textarea textarea.Model
attachments []app.Attachment attachments []app.Attachment
history []string
historyIndex int
currentMessage string
spinner spinner.Model spinner spinner.Model
interruptKeyInDebounce bool interruptKeyInDebounce bool
} }
@ -106,7 +97,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...) 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() t := theme.CurrentTheme()
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
muted := styles.NewStyle().Foreground(t.TextMuted()).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) Bold(true)
prompt := promptStyle.Render(">") prompt := promptStyle.Render(">")
m.textarea.SetWidth(width - 6)
textarea := lipgloss.JoinHorizontal( textarea := lipgloss.JoinHorizontal(
lipgloss.Top, lipgloss.Top,
prompt, 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) 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("") spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
info := hint + spacer + model info := hint + spacer + model
@ -157,19 +149,18 @@ func (m *editorComponent) Content(width int, align lipgloss.Position) string {
return content return content
} }
func (m *editorComponent) View(width int, align lipgloss.Position) string { func (m *editorComponent) View(width int) string {
if m.Lines() > 1 { if m.Lines() > 1 {
t := theme.CurrentTheme()
return lipgloss.Place( return lipgloss.Place(
width, width,
m.height, 5,
align, lipgloss.Center,
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 { func (m *editorComponent) Focused() bool {
@ -184,16 +175,6 @@ func (m *editorComponent) Blur() {
m.textarea.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 { func (m *editorComponent) Lines() int {
return m.textarea.LineCount() return m.textarea.LineCount()
} }
@ -219,16 +200,6 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
attachments := m.attachments 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 m.attachments = nil
cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments})) 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 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) { func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
m.interruptKeyInDebounce = inDebounce m.interruptKeyInDebounce = inDebounce
} }
@ -336,7 +265,6 @@ func createTextArea(existing *textarea.Model) textarea.Model {
ta.Prompt = " " ta.Prompt = " "
ta.ShowLineNumbers = false ta.ShowLineNumbers = false
ta.CharLimit = -1 ta.CharLimit = -1
ta.SetWidth(layout.Current.Container.Width - 6)
if existing != nil { if existing != nil {
ta.SetValue(existing.Value()) ta.SetValue(existing.Value())
@ -368,9 +296,6 @@ func NewEditorComponent(app *app.App) EditorComponent {
return &editorComponent{ return &editorComponent{
app: app, app: app,
textarea: ta, textarea: ta,
history: []string{},
historyIndex: 0,
currentMessage: "",
spinner: s, spinner: s,
interruptKeyInDebounce: false, interruptKeyInDebounce: false,
} }

View file

@ -3,65 +3,46 @@ package chat
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"path/filepath"
"slices" "slices"
"strings" "strings"
"time" "time"
"unicode"
"github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat" "github.com/charmbracelet/lipgloss/v2/compat"
"github.com/charmbracelet/x/ansi"
"github.com/sst/opencode-sdk-go" "github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/diff" "github.com/sst/opencode/internal/components/diff"
"github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
"golang.org/x/text/cases" "golang.org/x/text/cases"
"golang.org/x/text/language" "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 { type blockRenderer struct {
border bool textColor compat.AdaptiveColor
borderColor *compat.AdaptiveColor border bool
paddingTop int borderColor *compat.AdaptiveColor
paddingBottom int borderColorRight bool
paddingLeft int paddingTop int
paddingRight int paddingBottom int
marginTop int paddingLeft int
marginBottom int paddingRight int
marginTop int
marginBottom int
} }
type renderingOption func(*blockRenderer) type renderingOption func(*blockRenderer)
func WithTextColor(color compat.AdaptiveColor) renderingOption {
return func(c *blockRenderer) {
c.textColor = color
}
}
func WithNoBorder() renderingOption { func WithNoBorder() renderingOption {
return func(c *blockRenderer) { return func(c *blockRenderer) {
c.border = false 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 { func WithMarginTop(padding int) renderingOption {
return func(c *blockRenderer) { return func(c *blockRenderer) {
c.marginTop = padding c.marginTop = padding
@ -120,13 +108,15 @@ func WithPaddingBottom(padding int) renderingOption {
} }
func renderContentBlock( func renderContentBlock(
app *app.App,
content string, content string,
highlight bool,
width int, width int,
align lipgloss.Position,
options ...renderingOption, options ...renderingOption,
) string { ) string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
renderer := &blockRenderer{ renderer := &blockRenderer{
textColor: t.TextMuted(),
border: true, border: true,
paddingTop: 1, paddingTop: 1,
paddingBottom: 1, paddingBottom: 1,
@ -143,7 +133,7 @@ func renderContentBlock(
} }
style := styles.NewStyle(). style := styles.NewStyle().
Foreground(t.TextMuted()). Foreground(renderer.textColor).
Background(t.BackgroundPanel()). Background(t.BackgroundPanel()).
Width(width). Width(width).
PaddingTop(renderer.paddingTop). PaddingTop(renderer.paddingTop).
@ -161,21 +151,32 @@ func renderContentBlock(
BorderLeftBackground(t.Background()). BorderLeftBackground(t.Background()).
BorderRightForeground(t.BackgroundPanel()). BorderRightForeground(t.BackgroundPanel()).
BorderRightBackground(t.Background()) 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 = 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 { if renderer.marginTop > 0 {
for range renderer.marginTop { for range renderer.marginTop {
content = "\n" + content content = "\n" + content
@ -186,16 +187,44 @@ func renderContentBlock(
content = content + "\n" 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 return content
} }
func renderText( func renderText(
app *app.App,
message opencode.Message, message opencode.Message,
text string, text string,
author string, author string,
showToolDetails bool, showToolDetails bool,
highlight bool,
width int, width int,
align lipgloss.Position,
toolCalls ...opencode.ToolInvocationPart, toolCalls ...opencode.ToolInvocationPart,
) string { ) string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
@ -206,17 +235,20 @@ func renderText(
timestamp = timestamp[12:] timestamp = timestamp[12:]
} }
info := fmt.Sprintf("%s (%s)", author, timestamp) info := fmt.Sprintf("%s (%s)", author, timestamp)
info = styles.NewStyle().Foreground(t.TextMuted()).Render(info)
messageStyle := styles.NewStyle(). backgroundColor := t.BackgroundPanel()
Background(t.BackgroundPanel()). if highlight {
Foreground(t.Text()) backgroundColor = t.BackgroundElement()
}
messageStyle := styles.NewStyle().Background(backgroundColor)
if message.Role == opencode.MessageRoleUser { if message.Role == opencode.MessageRoleUser {
messageStyle = messageStyle.Width(width - 6) messageStyle = messageStyle.Width(width - 6)
} }
content := messageStyle.Render(text) content := messageStyle.Render(text)
if message.Role == opencode.MessageRoleAssistant { if message.Role == opencode.MessageRoleAssistant {
content = toMarkdown(text, width, t.BackgroundPanel()) content = util.ToMarkdown(text, width, backgroundColor)
} }
if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 { if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 {
@ -242,16 +274,19 @@ func renderText(
switch message.Role { switch message.Role {
case opencode.MessageRoleUser: case opencode.MessageRoleUser:
return renderContentBlock( return renderContentBlock(
app,
content, content,
highlight,
width, width,
align, WithTextColor(t.Text()),
WithBorderColor(t.Secondary()), WithBorderColorRight(t.Secondary()),
) )
case opencode.MessageRoleAssistant: case opencode.MessageRoleAssistant:
return renderContentBlock( return renderContentBlock(
app,
content, content,
highlight,
width, width,
align,
WithBorderColor(t.Accent()), WithBorderColor(t.Accent()),
) )
} }
@ -259,10 +294,11 @@ func renderText(
} }
func renderToolDetails( func renderToolDetails(
app *app.App,
toolCall opencode.ToolInvocationPart, toolCall opencode.ToolInvocationPart,
messageMetadata opencode.MessageMetadata, messageMetadata opencode.MessageMetadata,
highlight bool,
width int, width int,
align lipgloss.Position,
) string { ) string {
ignoredTools := []string{"todoread"} ignoredTools := []string{"todoread"}
if slices.Contains(ignoredTools, toolCall.ToolInvocation.ToolName) { if slices.Contains(ignoredTools, toolCall.ToolInvocation.ToolName) {
@ -282,7 +318,7 @@ func renderToolDetails(
if toolCall.ToolInvocation.State == "partial-call" { if toolCall.ToolInvocation.State == "partial-call" {
title := renderToolTitle(toolCall, messageMetadata, width) title := renderToolTitle(toolCall, messageMetadata, width)
return renderContentBlock(title, width, align) return renderContentBlock(app, title, highlight, width)
} }
toolArgsMap := make(map[string]any) toolArgsMap := make(map[string]any)
@ -301,6 +337,10 @@ func renderToolDetails(
body := "" body := ""
finished := result != nil && *result != "" finished := result != nil && *result != ""
t := theme.CurrentTheme() t := theme.CurrentTheme()
backgroundColor := t.BackgroundPanel()
if highlight {
backgroundColor = t.BackgroundElement()
}
switch toolCall.ToolInvocation.ToolName { switch toolCall.ToolInvocation.ToolName {
case "read": case "read":
@ -308,7 +348,7 @@ func renderToolDetails(
if preview != nil && toolArgsMap["filePath"] != nil { if preview != nil && toolArgsMap["filePath"] != nil {
filename := toolArgsMap["filePath"].(string) filename := toolArgsMap["filePath"].(string)
body = preview.(string) body = preview.(string)
body = renderFile(filename, body, width, WithTruncate(6)) body = util.RenderFile(filename, body, width, util.WithTruncate(6))
} }
case "edit": case "edit":
if filename, ok := toolArgsMap["filePath"].(string); ok { if filename, ok := toolArgsMap["filePath"].(string); ok {
@ -321,38 +361,28 @@ func renderToolDetails(
patch, patch,
diff.WithWidth(width-2), 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 = strings.TrimSpace(formattedDiff)
body = renderContentBlock( style := styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Padding(1, 2).Width(width - 4)
body, if highlight {
width, style = style.Foreground(t.Text()).Bold(true)
align, }
WithNoBorder(),
WithPadding(0),
)
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" { 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 := renderToolTitle(toolCall, messageMetadata, width)
title = renderContentBlock(title, width, align) title = style.Render(title)
content := title + "\n" + body content := title + "\n" + body
content = renderContentBlock(app, content, highlight, width, WithPadding(0))
return content return content
} }
} }
case "write": case "write":
if filename, ok := toolArgsMap["filePath"].(string); ok { if filename, ok := toolArgsMap["filePath"].(string); ok {
if content, ok := toolArgsMap["content"].(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 != "" { if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
body += "\n\n" + diagnostics body += "\n\n" + diagnostics
} }
@ -363,14 +393,14 @@ func renderToolDetails(
if stdout != nil { if stdout != nil {
command := toolArgsMap["command"].(string) command := toolArgsMap["command"].(string)
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout) body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
body = toMarkdown(body, width, t.BackgroundPanel()) body = util.ToMarkdown(body, width, backgroundColor)
} }
case "webfetch": case "webfetch":
if format, ok := toolArgsMap["format"].(string); ok && result != nil { if format, ok := toolArgsMap["format"].(string); ok && result != nil {
body = *result body = *result
body = truncateHeight(body, 10) body = util.TruncateHeight(body, 10)
if format == "html" || format == "markdown" { if format == "html" || format == "markdown" {
body = toMarkdown(body, width, t.BackgroundPanel()) body = util.ToMarkdown(body, width, backgroundColor)
} }
} }
case "todowrite": case "todowrite":
@ -389,7 +419,7 @@ func renderToolDetails(
body += fmt.Sprintf("- [ ] %s\n", content) body += fmt.Sprintf("- [ ] %s\n", content)
} }
} }
body = toMarkdown(body, width, t.BackgroundPanel()) body = util.ToMarkdown(body, width, backgroundColor)
} }
case "task": case "task":
summary := metadata.JSON.ExtraFields["summary"] summary := metadata.JSON.ExtraFields["summary"]
@ -424,7 +454,7 @@ func renderToolDetails(
result = &empty result = &empty
} }
body = *result body = *result
body = truncateHeight(body, 10) body = util.TruncateHeight(body, 10)
} }
error := "" error := ""
@ -437,18 +467,18 @@ func renderToolDetails(
if error != "" { if error != "" {
body = styles.NewStyle(). body = styles.NewStyle().
Foreground(t.Error()). Foreground(t.Error()).
Background(t.BackgroundPanel()). Background(backgroundColor).
Render(error) Render(error)
} }
if body == "" && error == "" && result != nil { if body == "" && error == "" && result != nil {
body = *result body = *result
body = truncateHeight(body, 10) body = util.TruncateHeight(body, 10)
} }
title := renderToolTitle(toolCall, messageMetadata, width) title := renderToolTitle(toolCall, messageMetadata, width)
content := title + "\n\n" + body content := title + "\n\n" + body
return renderContentBlock(content, width, align) return renderContentBlock(app, content, highlight, width)
} }
func renderToolName(name string) string { func renderToolName(name string) string {
@ -505,7 +535,7 @@ func renderToolTitle(
title = fmt.Sprintf("%s %s", title, toolArgs) title = fmt.Sprintf("%s %s", title, toolArgs)
case "edit", "write": case "edit", "write":
if filename, ok := toolArgsMap["filePath"].(string); ok { 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": case "bash", "task":
if description, ok := toolArgsMap["description"].(string); ok { if description, ok := toolArgsMap["description"].(string); ok {
@ -551,50 +581,6 @@ func renderToolAction(name string) string {
return "Working..." 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 { func renderArgs(args *map[string]any, titleKey string) string {
if args == nil || len(*args) == 0 { if args == nil || len(*args) == 0 {
return "" return ""
@ -614,7 +600,7 @@ func renderArgs(args *map[string]any, titleKey string) string {
continue continue
} }
if key == "filePath" || key == "path" { if key == "filePath" || key == "path" {
value = relative(value.(string)) value = util.Relative(value.(string))
} }
if key == titleKey { if key == titleKey {
title = fmt.Sprintf("%s", value) 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, ", ")) 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 // Diagnostic represents an LSP diagnostic
type Diagnostic struct { type Diagnostic struct {
Range struct { Range struct {

View file

@ -17,39 +17,50 @@ import (
type MessagesComponent interface { type MessagesComponent interface {
tea.Model tea.Model
tea.ViewModel View(width, height int) string
// View(width int) string SetWidth(width int) tea.Cmd
SetSize(width, height int) tea.Cmd
PageUp() (tea.Model, tea.Cmd) PageUp() (tea.Model, tea.Cmd)
PageDown() (tea.Model, tea.Cmd) PageDown() (tea.Model, tea.Cmd)
HalfPageUp() (tea.Model, tea.Cmd) HalfPageUp() (tea.Model, tea.Cmd)
HalfPageDown() (tea.Model, tea.Cmd) HalfPageDown() (tea.Model, tea.Cmd)
First() (tea.Model, tea.Cmd) First() (tea.Model, tea.Cmd)
Last() (tea.Model, tea.Cmd) Last() (tea.Model, tea.Cmd)
// Previous() (tea.Model, tea.Cmd) Previous() (tea.Model, tea.Cmd)
// Next() (tea.Model, tea.Cmd) Next() (tea.Model, tea.Cmd)
ToolDetailsVisible() bool ToolDetailsVisible() bool
Selected() string
} }
type messagesComponent struct { type messagesComponent struct {
width, height int width int
app *app.App app *app.App
viewport viewport.Model viewport viewport.Model
attachments viewport.Model
cache *MessageCache cache *MessageCache
rendering bool rendering bool
showToolDetails bool showToolDetails bool
tail bool tail bool
scrollbarDragging bool scrollbarDragging bool
scrollbarDragStart int scrollbarDragStart int
partCount int
lineCount int
selectedPart int
selectedText string
} }
type renderFinishedMsg struct{} type renderFinishedMsg struct{}
type selectedMessagePartChangedMsg struct {
part int
}
type ToggleToolDetailsMsg struct{} type ToggleToolDetailsMsg struct{}
func (m *messagesComponent) Init() tea.Cmd { func (m *messagesComponent) Init() tea.Cmd {
return tea.Batch(m.viewport.Init()) 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) { func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []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: case app.SendMsg:
m.viewport.GotoBottom() m.viewport.GotoBottom()
m.tail = true m.tail = true
m.selectedPart = -1
return m, nil return m, nil
case app.OptimisticMessageAddedMsg: case app.OptimisticMessageAddedMsg:
m.renderView() m.renderView(m.width)
if m.tail { if m.tail {
m.viewport.GotoBottom() m.viewport.GotoBottom()
} }
return m, nil return m, nil
case dialog.ThemeSelectedMsg: case dialog.ThemeSelectedMsg:
m.cache.Clear() m.cache.Clear()
m.rendering = true
return m, m.Reload() return m, m.Reload()
case ToggleToolDetailsMsg: case ToggleToolDetailsMsg:
m.showToolDetails = !m.showToolDetails m.showToolDetails = !m.showToolDetails
m.rendering = true
return m, m.Reload() return m, m.Reload()
case app.SessionSelectedMsg: case app.SessionLoadedMsg:
m.cache.Clear() m.cache.Clear()
m.tail = true m.tail = true
m.rendering = true
return m, m.Reload() return m, m.Reload()
case app.SessionClearedMsg: case app.SessionClearedMsg:
m.cache.Clear() m.cache.Clear()
cmd := m.Reload() m.rendering = true
return m, cmd return m, m.Reload()
case renderFinishedMsg: case renderFinishedMsg:
m.rendering = false m.rendering = false
if m.tail { if m.tail {
m.viewport.GotoBottom() m.viewport.GotoBottom()
} }
case opencode.EventListResponseEventSessionUpdated, opencode.EventListResponseEventMessageUpdated: case selectedMessagePartChangedMsg:
m.renderView() return m, m.Reload()
if m.tail { case opencode.EventListResponseEventSessionUpdated:
m.viewport.GotoBottom() 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...) return m, tea.Batch(cmds...)
} }
func (m *messagesComponent) renderView() { func (m *messagesComponent) renderView(width int) {
if m.width == 0 {
return
}
measure := util.Measure("messages.renderView") measure := util.Measure("messages.renderView")
defer measure("messageCount", len(m.app.Messages)) defer measure("messageCount", len(m.app.Messages))
t := theme.CurrentTheme() t := theme.CurrentTheme()
blocks := make([]string, 0)
m.partCount = 0
m.lineCount = 0
align := lipgloss.Center for _, message := range m.app.Messages {
width := layout.Current.Container.Width
sb := strings.Builder{}
util.WriteStringsPar(&sb, m.app.Messages, func(message opencode.Message) string {
var content string var content string
var cached bool var cached bool
blocks := make([]string, 0)
switch message.Role { switch message.Role {
case opencode.MessageRoleUser: case opencode.MessageRoleUser:
for _, part := range message.Parts { for _, part := range message.Parts {
switch part := part.AsUnion().(type) { switch part := part.AsUnion().(type) {
case opencode.TextPart: 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) content, cached = m.cache.Get(key)
if !cached { if !cached {
content = renderText( content = renderText(
m.app,
message, message,
part.Text, part.Text,
m.app.Info.User, m.app.Info.User,
m.showToolDetails, m.showToolDetails,
m.partCount == m.selectedPart,
width, width,
align,
) )
m.cache.Set(key, content) m.cache.Set(key, content)
} }
if content != "" { if content != "" {
if m.selectedPart == m.partCount {
m.viewport.SetYOffset(m.lineCount - 4)
m.selectedText = part.Text
}
blocks = append(blocks, content) blocks = append(blocks, content)
m.partCount++
m.lineCount += lipgloss.Height(content) + 1
} }
} }
} }
@ -181,33 +208,41 @@ func (m *messagesComponent) renderView() {
} }
if finished { 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) content, cached = m.cache.Get(key)
if !cached { if !cached {
content = renderText( content = renderText(
m.app,
message, message,
p.Text, p.Text,
message.Metadata.Assistant.ModelID, message.Metadata.Assistant.ModelID,
m.showToolDetails, m.showToolDetails,
m.partCount == m.selectedPart,
width, width,
align,
toolCallParts..., toolCallParts...,
) )
m.cache.Set(key, content) m.cache.Set(key, content)
} }
} else { } else {
content = renderText( content = renderText(
m.app,
message, message,
p.Text, p.Text,
message.Metadata.Assistant.ModelID, message.Metadata.Assistant.ModelID,
m.showToolDetails, m.showToolDetails,
m.partCount == m.selectedPart,
width, width,
align,
toolCallParts..., toolCallParts...,
) )
} }
if content != "" { if content != "" {
if m.selectedPart == m.partCount {
m.viewport.SetYOffset(m.lineCount - 4)
m.selectedText = p.Text
}
blocks = append(blocks, content) blocks = append(blocks, content)
m.partCount++
m.lineCount += lipgloss.Height(content) + 1
} }
case opencode.ToolInvocationPart: case opencode.ToolInvocationPart:
if !m.showToolDetails { if !m.showToolDetails {
@ -218,29 +253,38 @@ func (m *messagesComponent) renderView() {
key := m.cache.GenerateKey(message.ID, key := m.cache.GenerateKey(message.ID,
part.ToolInvocation.ToolCallID, part.ToolInvocation.ToolCallID,
m.showToolDetails, m.showToolDetails,
layout.Current.Viewport.Width, width,
m.partCount == m.selectedPart,
) )
content, cached = m.cache.Get(key) content, cached = m.cache.Get(key)
if !cached { if !cached {
content = renderToolDetails( content = renderToolDetails(
m.app,
part, part,
message.Metadata, message.Metadata,
m.partCount == m.selectedPart,
width, width,
align,
) )
m.cache.Set(key, content) m.cache.Set(key, content)
} }
} else { } else {
// if the tool call isn't finished, don't cache // if the tool call isn't finished, don't cache
content = renderToolDetails( content = renderToolDetails(
m.app,
part, part,
message.Metadata, message.Metadata,
m.partCount == m.selectedPart,
width, width,
align,
) )
} }
if content != "" { if content != "" {
if m.selectedPart == m.partCount {
m.viewport.SetYOffset(m.lineCount - 4)
m.selectedText = ""
}
blocks = append(blocks, content) blocks = append(blocks, content)
m.partCount++
m.lineCount += lipgloss.Height(content) + 1
} }
} }
} }
@ -259,34 +303,33 @@ func (m *messagesComponent) renderView() {
if error != "" { if error != "" {
error = renderContentBlock( error = renderContentBlock(
m.app,
error, error,
false,
width, width,
align,
WithBorderColor(t.Error()), WithBorderColor(t.Error()),
) )
blocks = append(blocks, error) blocks = append(blocks, error)
m.lineCount += lipgloss.Height(error) + 1
} }
}
return strings.Join(blocks, "\n\n") m.viewport.SetContent("\n" + strings.Join(blocks, "\n\n"))
}) if m.selectedPart == m.partCount-1 {
m.viewport.GotoBottom()
content := sb.String() }
m.viewport.SetHeight(m.height - lipgloss.Height(m.header()) + 1)
m.viewport.SetContent("\n" + content)
} }
func (m *messagesComponent) header() string { func (m *messagesComponent) header(width int) string {
if m.app.Session.ID == "" { if m.app.Session.ID == "" {
return "" return ""
} }
t := theme.CurrentTheme() t := theme.CurrentTheme()
width := layout.Current.Container.Width
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
headerLines := []string{} 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 != "" { if m.app.Session.Share.URL != "" {
headerLines = append(headerLines, muted(m.app.Session.Share.URL)) headerLines = append(headerLines, muted(m.app.Session.Share.URL))
} else { } else {
@ -350,6 +393,7 @@ func (m *messagesComponent) renderScrollbar() string {
return strings.Join(scrollbar, "\n") return strings.Join(scrollbar, "\n")
} }
func (m *messagesComponent) handleScrollbarClick(x, y int) bool { func (m *messagesComponent) handleScrollbarClick(x, y int) bool {
// Check if click is in scrollbar area (rightmost column) // Check if click is in scrollbar area (rightmost column)
if x != m.width-1 { 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 // 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 scrollbarY := y - headerHeight
// Check if click is within scrollbar bounds // Check if click is within scrollbar bounds
@ -408,7 +452,7 @@ func (m *messagesComponent) handleScrollbarDrag(y int) {
} }
// Calculate header offset - consistent with click handler // Calculate header offset - consistent with click handler
headerHeight := lipgloss.Height(m.header()) headerHeight := lipgloss.Height(m.header(m.width))
scrollbarY := y - headerHeight - m.scrollbarDragStart scrollbarY := y - headerHeight - m.scrollbarDragStart
// Calculate scrollbar dimensions // Calculate scrollbar dimensions
scrollbarHeight := visibleLines 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() t := theme.CurrentTheme()
if m.rendering { if m.rendering {
return lipgloss.Place( return lipgloss.Place(
m.width, width,
m.height+1, height,
lipgloss.Center, lipgloss.Center,
lipgloss.Center, lipgloss.Center,
styles.NewStyle().Background(t.Background()).Render("Loading session..."), 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() content := m.viewport.View()
// Apply scrollbar overlay using OpenCode's overlay system // Apply scrollbar overlay using OpenCode's overlay system
content = m.applyScrollbarOverlay(content) content = m.applyScrollbarOverlay(content)
return lipgloss.JoinVertical( return styles.NewStyle().
lipgloss.Left, Background(t.Background()).
lipgloss.PlaceHorizontal( Render(header + "\n" + content)
m.width,
lipgloss.Center,
m.header(),
styles.WhitespaceStyle(t.Background()),
),
content,
)
} }
func (m *messagesComponent) SetSize(width, height int) tea.Cmd { func (m *messagesComponent) SetWidth(width int) tea.Cmd {
if m.width == width && m.height == height { if m.width == width {
return nil return nil
} }
// Clear cache on resize since width affects rendering // 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.cache.Clear()
} }
m.width = width m.width = width
m.height = height
m.viewport.SetWidth(width) m.viewport.SetWidth(width)
m.viewport.SetHeight(height - lipgloss.Height(m.header())) m.renderView(width)
m.attachments.SetWidth(width + 40)
m.attachments.SetHeight(3)
m.renderView()
return nil return nil
} }
func (m *messagesComponent) GetSize() (int, int) {
return m.width, m.height
}
func (m *messagesComponent) Reload() tea.Cmd { func (m *messagesComponent) Reload() tea.Cmd {
m.rendering = true
return func() tea.Msg { return func() tea.Msg {
m.renderView() m.renderView(m.width)
return renderFinishedMsg{} return renderFinishedMsg{}
} }
} }
@ -528,16 +560,45 @@ func (m *messagesComponent) HalfPageDown() (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
func (m *messagesComponent) First() (tea.Model, tea.Cmd) { func (m *messagesComponent) Previous() (tea.Model, tea.Cmd) {
m.viewport.GotoTop()
m.tail = false 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) { func (m *messagesComponent) Last() (tea.Model, tea.Cmd) {
m.viewport.GotoBottom() m.selectedPart = m.partCount - 1
m.tail = true m.tail = true
return m, nil return m, util.CmdHandler(selectedMessagePartChangedMsg{
part: m.selectedPart,
})
} }
func (m *messagesComponent) ToolDetailsVisible() bool { func (m *messagesComponent) ToolDetailsVisible() bool {
@ -546,16 +607,15 @@ func (m *messagesComponent) ToolDetailsVisible() bool {
func NewMessagesComponent(app *app.App) MessagesComponent { func NewMessagesComponent(app *app.App) MessagesComponent {
vp := viewport.New() vp := viewport.New()
attachments := viewport.New() // Keep viewport key bindings enabled for mouse scrolling
// Don't disable the viewport's key bindings - this allows mouse scrolling to work
// vp.KeyMap = viewport.KeyMap{} // vp.KeyMap = viewport.KeyMap{}
return &messagesComponent{ return &messagesComponent{
app: app, app: app,
viewport: vp, viewport: vp,
attachments: attachments,
showToolDetails: true, showToolDetails: true,
cache: NewMessageCache(), cache: NewMessageCache(),
tail: true, tail: true,
selectedPart: -1,
} }
} }

View file

@ -34,10 +34,6 @@ func (c *commandsComponent) SetSize(width, height int) tea.Cmd {
return nil return nil
} }
func (c *commandsComponent) GetSize() (int, int) {
return c.width, c.height
}
func (c *commandsComponent) SetBackgroundColor(color compat.AdaptiveColor) { func (c *commandsComponent) SetBackgroundColor(color compat.AdaptiveColor) {
c.background = &color c.background = &color
} }

View file

@ -41,7 +41,6 @@ func (ci *CompletionItem) Render(selected bool, width int) string {
title := itemStyle.Render( title := itemStyle.Render(
ci.DisplayValue(), ci.DisplayValue(),
) )
return title return title
} }
@ -59,7 +58,6 @@ func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
type CompletionProvider interface { type CompletionProvider interface {
GetId() string GetId() string
GetEntry() CompletionItemI
GetChildEntries(query string) ([]CompletionItemI, error) GetChildEntries(query string) ([]CompletionItemI, error)
GetEmptyMessage() string GetEmptyMessage() string
} }
@ -175,9 +173,6 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, c.pseudoSearchTextArea.Focus()) cmds = append(cmds, c.pseudoSearchTextArea.Focus())
return c, tea.Batch(cmds...) return c, tea.Batch(cmds...)
} }
case tea.WindowSizeMsg:
c.width = msg.Width
c.height = msg.Height
} }
return c, tea.Batch(cmds...) return c, tea.Batch(cmds...)

View file

@ -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),
),
}
}

View file

@ -73,44 +73,6 @@ type linePair struct {
right *DiffLine 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 // UnifiedConfig configures the rendering of unified diffs
type UnifiedConfig struct { type UnifiedConfig struct {
Width int Width int
@ -122,13 +84,22 @@ type UnifiedOption func(*UnifiedConfig)
// NewUnifiedConfig creates a UnifiedConfig with default values // NewUnifiedConfig creates a UnifiedConfig with default values
func NewUnifiedConfig(opts ...UnifiedOption) UnifiedConfig { func NewUnifiedConfig(opts ...UnifiedOption) UnifiedConfig {
config := UnifiedConfig{ config := UnifiedConfig{
Width: 80, // Default width for unified view Width: 80,
} }
for _, opt := range opts { for _, opt := range opts {
opt(&config) 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 return config
} }
@ -907,7 +878,7 @@ func RenderUnifiedHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
} }
// RenderSideBySideHunk formats a hunk for side-by-side display // 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 // Apply options to create the configuration
config := NewSideBySideConfig(opts...) config := NewSideBySideConfig(opts...)
@ -922,10 +893,10 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
pairs := pairLines(hunkCopy.Lines) pairs := pairLines(hunkCopy.Lines)
// Calculate column width // Calculate column width
colWidth := config.TotalWidth / 2 colWidth := config.Width / 2
leftWidth := colWidth leftWidth := colWidth
rightWidth := config.TotalWidth - colWidth rightWidth := config.Width - colWidth
var sb strings.Builder var sb strings.Builder
util.WriteStringsPar(&sb, pairs, func(p linePair) string { 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 // 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) diffResult, err := ParseUnifiedDiff(diffText)
if err != nil { if err != nil {
return "", err return "", err

View file

@ -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()
}

View file

@ -135,11 +135,11 @@ func (m *Modal) Render(contentView string, background string) string {
col := (bgWidth - modalWidth) / 2 col := (bgWidth - modalWidth) / 2
return layout.PlaceOverlay( return layout.PlaceOverlay(
col, col-1, // TODO: whyyyyy
row, row,
modalView, modalView,
background, background,
layout.WithOverlayBorder(), layout.WithOverlayBorder(),
layout.WithOverlayBorderColor(t.Primary()), layout.WithOverlayBorderColor(t.BorderActive()),
) )
} }

View file

@ -21,6 +21,8 @@ type State struct {
Provider string `toml:"provider"` Provider string `toml:"provider"`
Model string `toml:"model"` Model string `toml:"model"`
RecentlyUsedModels []ModelUsage `toml:"recently_used_models"` RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
MessagesRight bool `toml:"messages_right"`
SplitDiff bool `toml:"split_diff"`
} }
func NewState() *State { func NewState() *State {

View file

@ -4,7 +4,9 @@ import (
"strings" "strings"
"github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
) )
type Direction int type Direction int
@ -34,11 +36,13 @@ const (
) )
type FlexOptions struct { type FlexOptions struct {
Direction Direction Background *compat.AdaptiveColor
Justify Justify Direction Direction
Align Align Justify Justify
Width int Align Align
Height int Width int
Height int
Gap int
} }
type FlexItem struct { type FlexItem struct {
@ -53,6 +57,12 @@ func Render(opts FlexOptions, items ...FlexItem) string {
return "" return ""
} }
t := theme.CurrentTheme()
if opts.Background == nil {
background := t.Background()
opts.Background = &background
}
// Calculate dimensions for each item // Calculate dimensions for each item
mainAxisSize := opts.Width mainAxisSize := opts.Width
crossAxisSize := opts.Height 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 // Calculate available space for grow items
availableSpace := max(mainAxisSize-totalFixedSize, 0) availableSpace := max(mainAxisSize-totalFixedSize-totalGapSize, 0)
// Calculate size for each grow item // Calculate size for each grow item
growItemSize := 0 growItemSize := 0
@ -108,6 +124,7 @@ func Render(opts FlexOptions, items ...FlexItem) string {
// For row direction, constrain width and handle height alignment // For row direction, constrain width and handle height alignment
if itemSize > 0 { if itemSize > 0 {
view = styles.NewStyle(). view = styles.NewStyle().
Background(*opts.Background).
Width(itemSize). Width(itemSize).
Height(crossAxisSize). Height(crossAxisSize).
Render(view) Render(view)
@ -116,31 +133,65 @@ func Render(opts FlexOptions, items ...FlexItem) string {
// Apply cross-axis alignment // Apply cross-axis alignment
switch opts.Align { switch opts.Align {
case AlignCenter: case AlignCenter:
view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Center, view) view = lipgloss.PlaceVertical(
crossAxisSize,
lipgloss.Center,
view,
styles.WhitespaceStyle(*opts.Background),
)
case AlignEnd: case AlignEnd:
view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Bottom, view) view = lipgloss.PlaceVertical(
crossAxisSize,
lipgloss.Bottom,
view,
styles.WhitespaceStyle(*opts.Background),
)
case AlignStart: case AlignStart:
view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Top, view) view = lipgloss.PlaceVertical(
crossAxisSize,
lipgloss.Top,
view,
styles.WhitespaceStyle(*opts.Background),
)
case AlignStretch: case AlignStretch:
// Already stretched by Height setting above // Already stretched by Height setting above
} }
} else { } else {
// For column direction, constrain height and handle width alignment // For column direction, constrain height and handle width alignment
if itemSize > 0 { if itemSize > 0 {
view = styles.NewStyle(). style := styles.NewStyle().
Height(itemSize). Background(*opts.Background).
Width(crossAxisSize). Height(itemSize)
Render(view) // Only set width for stretch alignment
if opts.Align == AlignStretch {
style = style.Width(crossAxisSize)
}
view = style.Render(view)
} }
// Apply cross-axis alignment // Apply cross-axis alignment
switch opts.Align { switch opts.Align {
case AlignCenter: case AlignCenter:
view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Center, view) view = lipgloss.PlaceHorizontal(
crossAxisSize,
lipgloss.Center,
view,
styles.WhitespaceStyle(*opts.Background),
)
case AlignEnd: case AlignEnd:
view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Right, view) view = lipgloss.PlaceHorizontal(
crossAxisSize,
lipgloss.Right,
view,
styles.WhitespaceStyle(*opts.Background),
)
case AlignStart: case AlignStart:
view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Left, view) view = lipgloss.PlaceHorizontal(
crossAxisSize,
lipgloss.Left,
view,
styles.WhitespaceStyle(*opts.Background),
)
case AlignStretch: case AlignStretch:
// Already stretched by Width setting above // 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 totalActualSize := 0
for _, size := range actualSizes { for _, size := range actualSizes {
totalActualSize += size totalActualSize += size
} }
if len(items) > 1 && opts.Gap > 0 {
totalActualSize += opts.Gap * (len(items) - 1)
}
// Apply justification // Apply justification
remainingSpace := max(mainAxisSize-totalActualSize, 0) remainingSpace := max(mainAxisSize-totalActualSize, 0)
@ -191,12 +245,17 @@ func Render(opts FlexOptions, items ...FlexItem) string {
// Build the final layout // Build the final layout
var parts []string var parts []string
spaceStyle := styles.NewStyle().Background(*opts.Background)
// Add space before if needed // Add space before if needed
if spaceBefore > 0 { if spaceBefore > 0 {
if opts.Direction == Row { if opts.Direction == Row {
parts = append(parts, strings.Repeat(" ", spaceBefore)) space := strings.Repeat(" ", spaceBefore)
parts = append(parts, spaceStyle.Render(space))
} else { } 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) parts = append(parts, view)
// Add space between items (not after the last one) // Add space between items (not after the last one)
if i < len(sizedViews)-1 && spaceBetween > 0 { if i < len(sizedViews)-1 {
if opts.Direction == Row { // Add gap first, then any additional spacing from justification
parts = append(parts, strings.Repeat(" ", spaceBetween)) totalSpacing := opts.Gap + spaceBetween
} else { if totalSpacing > 0 {
parts = append(parts, strings.Repeat("\n", spaceBetween)) 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 // Add space after if needed
if spaceAfter > 0 { if spaceAfter > 0 {
if opts.Direction == Row { if opts.Direction == Row {
parts = append(parts, strings.Repeat(" ", spaceAfter)) space := strings.Repeat(" ", spaceAfter)
parts = append(parts, spaceStyle.Render(space))
} else { } else {
parts = append(parts, strings.Repeat("\n", spaceAfter)) // For vertical layout, add empty lines as separate parts
for range spaceAfter {
parts = append(parts, "")
}
} }
} }

View file

@ -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
}

View file

@ -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)
}
})
}
}

View file

@ -19,6 +19,7 @@ import (
"github.com/sst/opencode/internal/components/chat" "github.com/sst/opencode/internal/components/chat"
cmdcomp "github.com/sst/opencode/internal/components/commands" cmdcomp "github.com/sst/opencode/internal/components/commands"
"github.com/sst/opencode/internal/components/dialog" "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/modal"
"github.com/sst/opencode/internal/components/status" "github.com/sst/opencode/internal/components/status"
"github.com/sst/opencode/internal/components/toast" "github.com/sst/opencode/internal/components/toast"
@ -40,6 +41,7 @@ const (
) )
const interruptDebounceTimeout = 1 * time.Second const interruptDebounceTimeout = 1 * time.Second
const fileViewerFullWidthCutoff = 200
type appModel struct { type appModel struct {
width, height int width, height int
@ -56,6 +58,12 @@ type appModel struct {
toastManager *toast.ToastManager toastManager *toast.ToastManager
interruptKeyState InterruptKeyState interruptKeyState InterruptKeyState
lastScroll time.Time lastScroll time.Time
messagesRight bool
fileViewer fileviewer.Model
lastMouse tea.Mouse
fileViewerStart int
fileViewerEnd int
fileViewerHit bool
} }
func (a appModel) Init() tea.Cmd { 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.status.Init())
cmds = append(cmds, a.completions.Init()) cmds = append(cmds, a.completions.Init())
cmds = append(cmds, a.toastManager.Init()) cmds = append(cmds, a.toastManager.Init())
cmds = append(cmds, a.fileViewer.Init())
// Check if we should show the init dialog // Check if we should show the init dialog
cmds = append(cmds, func() tea.Msg { cmds = append(cmds, func() tea.Msg {
@ -98,13 +107,33 @@ var BUGGED_SCROLL_KEYS = map[string]bool{
";": true, ";": 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) { func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd var cmds []tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyPressMsg: case tea.KeyPressMsg:
keyString := msg.String() 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 return a, nil
} }
@ -112,10 +141,20 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.modal != nil { if a.modal != nil {
switch keyString { switch keyString {
// Escape always closes current modal // Escape always closes current modal
case "esc", "ctrl+c": case "esc":
cmd := a.modal.Close() cmd := a.modal.Close()
a.modal = nil a.modal = nil
return a, cmd 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 // 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 { if a.modal != nil {
return a, nil return a, nil
} }
updated, cmd := a.messages.Update(msg)
a.messages = updated.(chat.MessagesComponent) var cmd tea.Cmd
cmds = append(cmds, 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...) 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: case tea.BackgroundColorMsg:
styles.Terminal = &styles.TerminalInfo{ styles.Terminal = &styles.TerminalInfo{
Background: msg.Color, Background: msg.Color,
@ -269,6 +326,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
} }
case modal.CloseModalMsg: case modal.CloseModalMsg:
a.editor.Focus()
var cmd tea.Cmd var cmd tea.Cmd
if a.modal != nil { if a.modal != nil {
cmd = a.modal.Close() 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) slog.Error("Server error", "name", err.Name, "message", err.Data.Message)
return a, toast.NewErrorToast(err.Data.Message, toast.WithTitle(string(err.Name))) 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: case tea.WindowSizeMsg:
msg.Height -= 2 // Make space for the status bar msg.Height -= 2 // Make space for the status bar
a.width, a.height = msg.Width, msg.Height 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{ layout.Current = &layout.LayoutInfo{
Viewport: layout.Dimensions{ Viewport: layout.Dimensions{
Width: a.width, Width: a.width,
Height: a.height, Height: a.height,
}, },
Container: layout.Dimensions{ Container: layout.Dimensions{
Width: min(a.width, 80), Width: container,
}, },
} }
// Update child component sizes mainWidth := layout.Current.Container.Width
messagesHeight := a.height - 6 // Leave room for editor and status bar a.messages.SetWidth(mainWidth - 4)
a.messages.SetSize(a.width, messagesHeight)
a.editor.SetSize(min(a.width, 80), 5) 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: case app.SessionSelectedMsg:
messages, err := a.app.ListMessages(context.Background(), msg.ID) messages, err := a.app.ListMessages(context.Background(), msg.ID)
if err != nil { if err != nil {
@ -376,6 +459,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
a.app.Session = msg a.app.Session = msg
a.app.Messages = messages a.app.Messages = messages
return a, util.CmdHandler(app.SessionLoadedMsg{})
case app.ModelSelectedMsg: case app.ModelSelectedMsg:
a.app.Provider = &msg.Provider a.app.Provider = &msg.Provider
a.app.Model = &msg.Model 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 // Reset interrupt key state after timeout
a.interruptKeyState = InterruptKeyIdle a.interruptKeyState = InterruptKeyIdle
a.editor.SetInterruptKeyInDebounce(false) a.editor.SetInterruptKeyInDebounce(false)
case dialog.FindSelectedMsg:
return a.openFile(msg.FilePath)
} }
// update status bar
s, cmd := a.status.Update(msg) s, cmd := a.status.Update(msg)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
a.status = s.(status.StatusComponent) a.status = s.(status.StatusComponent)
// update editor
u, cmd := a.editor.Update(msg) u, cmd := a.editor.Update(msg)
a.editor = u.(chat.EditorComponent) a.editor = u.(chat.EditorComponent)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
// update messages
u, cmd = a.messages.Update(msg) u, cmd = a.messages.Update(msg)
a.messages = u.(chat.MessagesComponent) a.messages = u.(chat.MessagesComponent)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
// update modal
if a.modal != nil { if a.modal != nil {
u, cmd := a.modal.Update(msg) u, cmd := a.modal.Update(msg)
a.modal = u.(layout.Modal) a.modal = u.(layout.Modal)
@ -428,86 +510,95 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
} }
fv, cmd := a.fileViewer.Update(msg)
a.fileViewer = fv
cmds = append(cmds, cmd)
return a, tea.Batch(cmds...) return a, tea.Batch(cmds...)
} }
func (a appModel) View() string { 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 { if a.modal != nil {
mainLayout = a.modal.Render(mainLayout) mainLayout = a.modal.Render(mainLayout)
} }
mainLayout = a.toastManager.RenderOverlay(mainLayout) mainLayout = a.toastManager.RenderOverlay(mainLayout)
if theme.CurrentThemeUsesAnsiColors() { if theme.CurrentThemeUsesAnsiColors() {
mainLayout = util.ConvertRGBToAnsi16Colors(mainLayout) mainLayout = util.ConvertRGBToAnsi16Colors(mainLayout)
} }
return mainLayout + "\n" + a.status.View() return mainLayout + "\n" + a.status.View()
} }
func (a appModel) chat(width int, align lipgloss.Position) string { func (a appModel) openFile(filepath string) (tea.Model, tea.Cmd) {
editorView := a.editor.View(width, align) var cmd tea.Cmd
lines := a.editor.Lines() response, err := a.app.Client.File.Read(
messagesView := a.messages.View() context.Background(),
if a.app.Session.ID == "" { opencode.FileReadParams{
messagesView = a.home() Path: opencode.F(filepath),
}
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,
}, },
) )
if err != nil {
if lines > 1 { slog.Error("Failed to read file", "error", err)
editorWidth := min(a.width, 80) return a, toast.NewErrorToast("Failed to read file")
editorX := (a.width - editorWidth) / 2
editorY := a.height - editorHeight
mainLayout = layout.PlaceOverlay(
editorX,
editorY,
a.editor.Content(width, align),
mainLayout,
)
} }
a.fileViewer, cmd = a.fileViewer.SetFile(
if a.showCompletionDialog { filepath,
editorWidth := min(a.width, 80) response.Content,
editorX := (a.width - editorWidth) / 2 response.Type == "patch",
a.completions.SetWidth(editorWidth) )
overlay := a.completions.View() return a, cmd
overlayHeight := lipgloss.Height(overlay)
editorY := a.height - editorHeight + 1
mainLayout = layout.PlaceOverlay(
editorX,
editorY-overlayHeight,
overlay,
mainLayout,
)
}
return mainLayout
} }
func (a appModel) home() string { func (a appModel) home(width int) string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
baseStyle := styles.NewStyle().Background(t.Background()) baseStyle := styles.NewStyle().Background(t.Background())
base := baseStyle.Render base := baseStyle.Render
@ -539,7 +630,7 @@ func (a appModel) home() string {
logoAndVersion := strings.Join([]string{logo, version}, "\n") logoAndVersion := strings.Join([]string{logo, version}, "\n")
logoAndVersion = lipgloss.PlaceHorizontal( logoAndVersion = lipgloss.PlaceHorizontal(
a.width, width,
lipgloss.Center, lipgloss.Center,
logoAndVersion, logoAndVersion,
styles.WhitespaceStyle(t.Background()), styles.WhitespaceStyle(t.Background()),
@ -550,13 +641,15 @@ func (a appModel) home() string {
cmdcomp.WithLimit(6), cmdcomp.WithLimit(6),
) )
cmds := lipgloss.PlaceHorizontal( cmds := lipgloss.PlaceHorizontal(
a.width, width,
lipgloss.Center, lipgloss.Center,
commandsView.View(), commandsView.View(),
styles.WhitespaceStyle(t.Background()), styles.WhitespaceStyle(t.Background()),
) )
lines := []string{} lines := []string{}
lines = append(lines, "")
lines = append(lines, "")
lines = append(lines, logoAndVersion) lines = append(lines, logoAndVersion)
lines = append(lines, "") lines = append(lines, "")
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, base("config ")+muted(config))
// lines = append(lines, "") // lines = append(lines, "")
lines = append(lines, cmds) lines = append(lines, cmds)
lines = append(lines, "")
lines = append(lines, "")
return lipgloss.Place( mainHeight := lipgloss.Height(strings.Join(lines, "\n"))
a.width,
a.height-5, 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,
lipgloss.Center, lipgloss.Center,
baseStyle.Render(strings.Join(lines, "\n")), baseStyle.Render(strings.Join(lines, "\n")),
styles.WhitespaceStyle(t.Background()), 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) { func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
cmds := []tea.Cmd{ cmds := []tea.Cmd{
util.CmdHandler(commands.CommandExecutedMsg(command)), util.CmdHandler(commands.CommandExecutedMsg(command)),
} }
@ -679,6 +854,22 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
case commands.ThemeListCommand: case commands.ThemeListCommand:
themeDialog := dialog.NewThemeDialog() themeDialog := dialog.NewThemeDialog()
a.modal = themeDialog 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: case commands.ProjectInitCommand:
cmds = append(cmds, a.app.InitializeProject(context.Background())) cmds = append(cmds, a.app.InitializeProject(context.Background()))
case commands.InputClearCommand: case commands.InputClearCommand:
@ -700,20 +891,6 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
updated, cmd := a.editor.Newline() updated, cmd := a.editor.Newline()
a.editor = updated.(chat.EditorComponent) a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd) 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: case commands.MessagesFirstCommand:
updated, cmd := a.messages.First() updated, cmd := a.messages.First()
a.messages = updated.(chat.MessagesComponent) 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) a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
case commands.MessagesPageUpCommand: case commands.MessagesPageUpCommand:
updated, cmd := a.messages.PageUp() if a.fileViewer.HasFile() {
a.messages = updated.(chat.MessagesComponent) a.fileViewer, cmd = a.fileViewer.PageUp()
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
} else {
updated, cmd := a.messages.PageUp()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
}
case commands.MessagesPageDownCommand: case commands.MessagesPageDownCommand:
updated, cmd := a.messages.PageDown() if a.fileViewer.HasFile() {
a.messages = updated.(chat.MessagesComponent) a.fileViewer, cmd = a.fileViewer.PageDown()
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
} else {
updated, cmd := a.messages.PageDown()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
}
case commands.MessagesHalfPageUpCommand: case commands.MessagesHalfPageUpCommand:
updated, cmd := a.messages.HalfPageUp() if a.fileViewer.HasFile() {
a.messages = updated.(chat.MessagesComponent) a.fileViewer, cmd = a.fileViewer.HalfPageUp()
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
} else {
updated, cmd := a.messages.HalfPageUp()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
}
case commands.MessagesHalfPageDownCommand: 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) a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd) 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: case commands.AppExitCommand:
return a, tea.Quit return a, tea.Quit
} }
@ -779,6 +997,8 @@ func NewModel(app *app.App) tea.Model {
showCompletionDialog: false, showCompletionDialog: false,
toastManager: toast.NewToastManager(), toastManager: toast.NewToastManager(),
interruptKeyState: InterruptKeyIdle, interruptKeyState: InterruptKeyIdle,
fileViewer: fileviewer.New(app),
messagesRight: app.State.MessagesRight,
} }
return model return model

View file

@ -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")
}

View file

@ -28,7 +28,7 @@
"sharp": "0.32.5", "sharp": "0.32.5",
"shiki": "3.4.2", "shiki": "3.4.2",
"solid-js": "1.9.7", "solid-js": "1.9.7",
"toolbeam-docs-theme": "0.3.0" "toolbeam-docs-theme": "0.4.1"
}, },
"devDependencies": { "devDependencies": {
"opencode": "workspace:*", "opencode": "workspace:*",

View file

@ -7,13 +7,14 @@ import config from '../../config.mjs'
const slug = Astro.url.pathname.replace(/^\//, "").replace(/\/$/, ""); const slug = Astro.url.pathname.replace(/^\//, "").replace(/\/$/, "");
const { const {
entry: { entry: {
data: { title }, data: { title , description },
}, },
} = Astro.locals.starlightRoute; } = Astro.locals.starlightRoute;
const isDocs = slug.startsWith("docs") const isDocs = slug.startsWith("docs")
let encodedTitle = ''; let encodedTitle = '';
let ogImage = `${config.url}/social-share.png`; let ogImage = `${config.url}/social-share.png`;
let truncatedDesc = '';
if (isDocs) { if (isDocs) {
// Truncate to fit S3's max key size // 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}`;
} }
--- ---

View file

@ -1346,7 +1346,8 @@ export default function Share(props: {
</ErrorPart> </ErrorPart>
</div> </div>
</Match> </Match>
<Match when={preview()}> {/* Always try to show CodeBlock if preview is available (even if empty string) */}
<Match when={typeof preview() === 'string'}>
<div data-part-tool-result> <div data-part-tool-result>
<ResultsButton <ResultsButton
showCopy="Show preview" showCopy="Show preview"
@ -1366,7 +1367,8 @@ export default function Share(props: {
</Show> </Show>
</div> </div>
</Match> </Match>
<Match when={toolData()?.result}> {/* Fallback to TextPart if preview is not a string (e.g. undefined) AND result exists */}
<Match when={typeof preview() !== 'string' && toolData()?.result}>
<div data-part-tool-result> <div data-part-tool-result>
<ResultsButton <ResultsButton
results={showResults()} results={showResults()}
@ -1616,7 +1618,7 @@ export default function Share(props: {
> >
{(_part) => { {(_part) => {
const todos = createMemo(() => const todos = createMemo(() =>
sortTodosByStatus(toolData()?.args.todos), sortTodosByStatus(toolData()?.args?.todos ?? []),
) )
const starting = () => const starting = () =>
todos().every((t) => t.status === "pending") todos().every((t) => t.status === "pending")

View file

@ -1,5 +1,6 @@
--- ---
title: CLI title: CLI
description: The opencode CLI options and commands.
--- ---
Running the opencode CLI starts it for the current directory. 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. The opencode CLI also has the following commands.
---
### run ### run
Run opencode in non-interactive mode by passing a prompt directly. 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 | | `--continue` | `-c` | Continue the last session |
| `--session` | `-s` | Session ID to continue | | `--session` | `-s` | Session ID to continue |
| `--share` | | Share the session | | `--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] opencode auth [command]
``` ```
---
#### login #### login
Logs you into a provider and saves them in the credentials file in `~/.local/share/opencode/auth.json`. 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. 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 #### list
Lists all the authenticated providers as stored in the credentials file. Lists all the authenticated providers as stored in the credentials file.
@ -77,6 +84,8 @@ Or the short version.
opencode auth ls opencode auth ls
``` ```
---
#### logout #### logout
Logs you out of a provider by clearing it from the credentials file. Logs you out of a provider by clearing it from the credentials file.

View file

@ -1,5 +1,6 @@
--- ---
title: Config title: Config
description: Using the opencode JSON config.
--- ---
You can configure opencode using a JSON config file that can be placed in: You can configure opencode using a JSON config file that can be placed in:

View file

@ -1,5 +1,6 @@
--- ---
title: Intro title: Intro
description: Get started with opencode.
--- ---
import { Tabs, TabItem } from '@astrojs/starlight/components'; import { Tabs, TabItem } from '@astrojs/starlight/components';
@ -62,12 +63,12 @@ brew install sst/tap/opencode
paru -S opencode-bin paru -S opencode-bin
``` ```
---
##### Windows ##### 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). 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 ## 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. 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.

View file

@ -1,5 +1,6 @@
--- ---
title: Keybinds title: Keybinds
description: Customize your keybinds.
--- ---
opencode has a list of keybinds that you can customize through the opencode config. opencode has a list of keybinds that you can customize through the opencode config.

View file

@ -1,5 +1,6 @@
--- ---
title: MCP servers 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: You can add external tools to opencode using the _Model Context Protocol_, or MCP. opencode supports both:

View file

@ -1,5 +1,6 @@
--- ---
title: Models 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. 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.

View file

@ -1,5 +1,6 @@
--- ---
title: Rules 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. 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.

View file

@ -1,5 +1,6 @@
--- ---
title: Themes 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. 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. opencode supports a flexible JSON-based theme system that allows users to create and customize themes easily.
---
### Hierarchy ### Hierarchy
Themes are loaded from multiple directories in the following order where later directories override earlier ones: 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. If multiple directories contain a theme with the same name, the theme from the directory with higher priority will be used.
---
### Creating a theme ### Creating a theme
To create a custom theme, create a JSON file in one of the theme directories. 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 vim .opencode/themes/my-theme.json
``` ```
---
### JSON format ### JSON format
Themes use a flexible JSON format with support for: 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"}` - **Dark/light variants**: `{"dark": "#000", "light": "#fff"}`
- **No color**: `"none"` - Uses the terminal's default color or transparent - **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 ### Example
Here's an example of a custom theme: 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

View file

@ -51,9 +51,16 @@ resources:
get: get /app get: get /app
init: post /app/init init: post /app/init
find:
methods:
text: get /find
files: get /find/file
symbols: get /find/symbol
file: file:
methods: methods:
search: get /file read: get /file
status: get /file/status
config: config:
models: models: