This commit is contained in:
Dax Raad
2025-05-30 20:47:56 -04:00
parent 9a26b3058f
commit f3da73553c
178 changed files with 765 additions and 3382 deletions

View File

@@ -0,0 +1,10 @@
{
"name": "@opencode/function",
"version": "0.0.1",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
"devDependencies": {
"@cloudflare/workers-types": "^4.20250522.0"
}
}

View File

@@ -0,0 +1,167 @@
import { DurableObject } from "cloudflare:workers"
import { randomUUID } from "node:crypto"
type Env = {
SYNC_SERVER: DurableObjectNamespace<SyncServer>
Bucket: R2Bucket
}
export class SyncServer extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env)
}
async fetch() {
console.log("SyncServer subscribe")
const webSocketPair = new WebSocketPair()
const [client, server] = Object.values(webSocketPair)
this.ctx.acceptWebSocket(server)
const data = await this.ctx.storage.list()
for (const [key, content] of data.entries()) {
server.send(JSON.stringify({ key, content }))
}
return new Response(null, {
status: 101,
webSocket: client,
})
}
async webSocketMessage(ws, message) {}
async webSocketClose(ws, code, reason, wasClean) {
ws.close(code, "Durable Object is closing WebSocket")
}
async publish(secret: string, key: string, content: any) {
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
const sessionID = await this.getSessionID()
if (
!key.startsWith(`session/info/${sessionID}`) &&
!key.startsWith(`session/message/${sessionID}/`)
)
return new Response("Error: Invalid key", { status: 400 })
// store message
await this.env.Bucket.put(`share/${key}.json`, JSON.stringify(content), {
httpMetadata: {
contentType: "application/json",
},
})
await this.ctx.storage.put(key, content)
const clients = this.ctx.getWebSockets()
console.log("SyncServer publish", key, "to", clients.length, "subscribers")
for (const client of clients) {
client.send(JSON.stringify({ key, content }))
}
}
public async share(sessionID: string) {
let secret = await this.getSecret()
if (secret) return secret
secret = randomUUID()
await this.ctx.storage.put("secret", secret)
await this.ctx.storage.put("sessionID", sessionID)
return secret
}
private async getSecret() {
return this.ctx.storage.get<string>("secret")
}
private async getSessionID() {
return this.ctx.storage.get<string>("sessionID")
}
async clear(secret: string) {
await this.assertSecret(secret)
await this.ctx.storage.deleteAll()
}
private async assertSecret(secret: string) {
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
}
static shortName(id: string) {
return id.substring(id.length - 8)
}
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const url = new URL(request.url)
const splits = url.pathname.split("/")
const method = splits[1]
if (request.method === "GET" && method === "") {
return new Response("Hello, world!", {
headers: { "Content-Type": "text/plain" },
})
}
if (request.method === "POST" && method === "share_create") {
const body = await request.json<any>()
const sessionID = body.sessionID
const short = SyncServer.shortName(sessionID)
const id = env.SYNC_SERVER.idFromName(short)
const stub = env.SYNC_SERVER.get(id)
const secret = await stub.share(sessionID)
return new Response(
JSON.stringify({
secret,
url: "https://dev.opencode.ai/s?id=" + short,
}),
{
headers: { "Content-Type": "application/json" },
},
)
}
if (request.method === "POST" && method === "share_delete") {
const body = await request.json<any>()
const sessionID = body.sessionID
const secret = body.secret
const id = env.SYNC_SERVER.idFromName(SyncServer.shortName(sessionID))
const stub = env.SYNC_SERVER.get(id)
await stub.clear(secret)
return new Response(JSON.stringify({}), {
headers: { "Content-Type": "application/json" },
})
}
if (request.method === "POST" && method === "share_sync") {
const body = await request.json<{
sessionID: string
secret: string
key: string
content: any
}>()
const name = SyncServer.shortName(body.sessionID)
const id = env.SYNC_SERVER.idFromName(name)
const stub = env.SYNC_SERVER.get(id)
await stub.publish(body.secret, body.key, body.content)
return new Response(JSON.stringify({}), {
headers: { "Content-Type": "application/json" },
})
}
if (request.method === "GET" && method === "share_poll") {
const upgradeHeader = request.headers.get("Upgrade")
if (!upgradeHeader || upgradeHeader !== "websocket") {
return new Response("Error: Upgrade header is required", {
status: 426,
})
}
const id = url.searchParams.get("id")
console.log("share_poll", id)
if (!id)
return new Response("Error: Share ID is required", { status: 400 })
const stub = env.SYNC_SERVER.get(env.SYNC_SERVER.idFromName(id))
return stub.fetch(request)
}
},
}

25
packages/function/sst-env.d.ts vendored Normal file
View File

@@ -0,0 +1,25 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
import "sst"
declare module "sst" {
export interface Resource {
"Web": {
"type": "sst.cloudflare.StaticSite"
"url": string
}
}
}
// cloudflare
import * as cloudflare from "@cloudflare/workers-types";
declare module "sst" {
export interface Resource {
"Api": cloudflare.Service
"Bucket": cloudflare.R2Bucket
}
}
import "sst"
export {}

View File

@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/node22/tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["@cloudflare/workers-types", "node"]
}
}

3
packages/opencode/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
gen

View File

@@ -0,0 +1,15 @@
# js
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.2.12. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env node
import { createRequire } from "node:module"
const require = createRequire(import.meta.url)
import path from "path"
import { execFileSync } from "child_process"
let resolved = process.env.SST_BIN_PATH
if (!resolved) {
const name = `opencode-${process.platform}-${process.arch}`
const binary = process.platform === "win32" ? "opencode.exe" : "opencode"
try {
resolved = require.resolve(path.join(name, "bin", binary))
} catch (ex) {
console.error(
`It seems that your package manager failed to install the right version of the SST CLI for your platform. You can try manually installing the "${name}" package`,
)
process.exit(1)
}
}
process.on("SIGINT", () => {})
try {
execFileSync(resolved, process.argv.slice(2), {
stdio: "inherit",
})
} catch (ex) {
process.exit(1)
}

View File

@@ -0,0 +1,39 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "0.0.0",
"name": "opencode",
"type": "module",
"private": true,
"scripts": {},
"exports": {
"./*": [
"./src/*.ts",
"./src/*/index.ts"
]
},
"devDependencies": {
"@tsconfig/bun": "^1.0.7",
"@types/bun": "latest",
"@types/jsdom": "^21.1.7",
"@types/turndown": "^5.0.5"
},
"dependencies": {
"@flystorage/file-storage": "^1.1.0",
"@flystorage/local-fs": "^1.1.0",
"@hono/zod-validator": "^0.5.0",
"ai": "5.0.0-alpha.7",
"cac": "^6.7.14",
"decimal.js": "^10.5.0",
"env-paths": "^3.0.0",
"hono": "^4.7.10",
"hono-openapi": "^0.4.8",
"jsdom": "^26.1.0",
"remeda": "^2.22.3",
"ts-lsp-client": "^1.0.3",
"turndown": "^7.2.0",
"vscode-jsonrpc": "^8.2.1",
"vscode-languageclient": "8",
"zod": "^3.25.3",
"zod-openapi": "^4.2.4"
}
}

View File

@@ -0,0 +1,30 @@
// This is a dummy file for testing purposes
console.log('Hello, world!');
export function dummyFunction(): void {
console.log('This is a dummy function');
}
export function anotherDummyFunction(): string {
return 'This is another dummy function';
}
export function newDummyFunction(): number {
return 42;
}
export function extraDummyFunction(): boolean {
return true;
}
export function superDummyFunction(): void {
console.log('This is a super dummy function');
}
export function ultraDummyFunction(): object {
return { dummy: true };
}
export function megaDummyFunction(): Array<string> {
return ['dummy', 'mega', 'function'];
}

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bun
import { $ } from "bun"
import pkg from "../package.json"
const version = `0.0.0-${Date.now()}`
const ARCH = {
arm64: "arm64",
x64: "amd64",
}
const OS = {
linux: "linux",
darwin: "mac",
win32: "windows",
}
const targets = [
["linux", "arm64"],
["linux", "x64"],
["darwin", "x64"],
["darwin", "arm64"],
["windows", "x64"],
]
await $`rm -rf dist`
const optionalDependencies: Record<string, string> = {}
for (const [os, arch] of targets) {
console.log(`building ${os}-${arch}`)
const name = `${pkg.name}-${os}-${arch}`
await $`mkdir -p dist/${name}/bin`
await $`bun build --compile --minify --target=bun-${os}-${arch} --outfile=dist/${name}/bin/${pkg.name} ./src/index.ts`
await Bun.file(`dist/${name}/package.json`).write(
JSON.stringify(
{
name,
version,
os: [os],
cpu: [arch],
},
null,
2,
),
)
await $`cd dist/${name} && npm publish --access public --tag latest`
optionalDependencies[name] = version
}
await $`mkdir -p ./dist/${pkg.name}`
await $`cp -r ./bin ./dist/${pkg.name}/bin`
await Bun.file(`./dist/${pkg.name}/package.json`).write(
JSON.stringify(
{
name: pkg.name + "-ai",
bin: {
[pkg.name]: `./bin/${pkg.name}.mjs`,
},
version,
optionalDependencies,
},
null,
2,
),
)
await $`cd ./dist/${pkg.name} && npm publish --access public --tag latest`

View File

@@ -0,0 +1,78 @@
import fs from "fs/promises";
import { AppPath } from "./path";
import { Log } from "../util/log";
import { Context } from "../util/context";
export namespace App {
const log = Log.create({ service: "app" });
export type Info = Awaited<ReturnType<typeof create>>;
const ctx = Context.create<Info>("app");
async function create(input: { directory: string }) {
const dataDir = AppPath.data(input.directory);
await fs.mkdir(dataDir, { recursive: true });
await Log.file(input.directory);
log.info("created", { path: dataDir });
const services = new Map<
any,
{
state: any;
shutdown?: (input: any) => Promise<void>;
}
>();
const result = {
get services() {
return services;
},
get root() {
return input.directory;
},
};
return result;
}
export function state<State>(
key: any,
init: (app: Info) => State,
shutdown?: (state: Awaited<State>) => Promise<void>,
) {
return () => {
const app = ctx.use();
const services = app.services;
if (!services.has(key)) {
log.info("registering service", { name: key });
services.set(key, {
state: init(app),
shutdown: shutdown,
});
}
return services.get(key)?.state as State;
};
}
export async function use() {
return ctx.use();
}
export async function provide<T extends (app: Info) => any>(
input: { directory: string },
cb: T,
) {
const app = await create(input);
return ctx.provide(app, async () => {
const result = await cb(app);
for (const [key, entry] of app.services.entries()) {
log.info("shutdown", { name: key });
await entry.shutdown?.(await entry.state);
}
return result;
});
}
}

View File

@@ -0,0 +1,11 @@
import path from "path";
export namespace AppPath {
export function data(input: string) {
return path.join(input, ".opencode");
}
export function storage(input: string) {
return path.join(data(input), "storage");
}
}

View File

@@ -0,0 +1,28 @@
import path from "path";
import { Log } from "../util/log";
export namespace BunProc {
const log = Log.create({ service: "bun" });
export function run(
cmd: string[],
options?: Bun.SpawnOptions.OptionsObject<any, any, any>,
) {
const root =
process.argv0 !== "bun"
? path.resolve(process.cwd(), process.argv0)
: process.argv0;
log.info("running", {
cmd: [root, ...cmd],
options,
});
const result = Bun.spawnSync([root, ...cmd], {
...options,
argv0: "bun",
env: {
...process.env,
...options?.env,
},
});
return result;
}
}

View File

@@ -0,0 +1,101 @@
import { z, type ZodType } from "zod";
import { App } from "../app/app";
import { Log } from "../util/log";
export namespace Bus {
const log = Log.create({ service: "bus" });
type Subscription = (event: any) => void;
const state = App.state("bus", () => {
const subscriptions = new Map<any, Subscription[]>();
return {
subscriptions,
};
});
export type EventDefinition = ReturnType<typeof event>;
const registry = new Map<string, EventDefinition>();
export function event<Type extends string, Properties extends ZodType>(
type: Type,
properties: Properties,
) {
const result = {
type,
properties,
};
registry.set(type, result);
return result;
}
export function payloads() {
return z.discriminatedUnion(
"type",
registry
.entries()
.map(([type, def]) =>
z
.object({
type: z.literal(type),
properties: def.properties,
})
.openapi({
ref: "Event" + "." + def.type,
}),
)
.toArray() as any,
);
}
export function publish<Definition extends EventDefinition>(
def: Definition,
properties: z.output<Definition["properties"]>,
) {
const payload = {
type: def.type,
properties,
};
log.info("publishing", {
type: def.type,
});
for (const key of [def.type, "*"]) {
const match = state().subscriptions.get(key);
for (const sub of match ?? []) {
sub(payload);
}
}
}
export function subscribe<Definition extends EventDefinition>(
def: Definition,
callback: (event: {
type: Definition["type"];
properties: z.infer<Definition["properties"]>;
}) => void,
) {
return raw(def.type, callback);
}
export function subscribeAll(callback: (event: any) => void) {
return raw("*", callback);
}
function raw(type: string, callback: (event: any) => void) {
log.info("subscribing", { type });
const subscriptions = state().subscriptions;
let match = subscriptions.get(type) ?? [];
match.push(callback);
subscriptions.set(type, match);
return () => {
log.info("unsubscribing", { type });
const match = subscriptions.get(type);
if (!match) return;
const index = match.indexOf(callback);
if (index === -1) return;
match.splice(index, 1);
};
}
}

View File

@@ -0,0 +1,51 @@
import path from "path";
import { Log } from "../util/log";
import { z } from "zod";
import { App } from "../app/app";
import { Provider } from "../provider/provider";
export namespace Config {
const log = Log.create({ service: "config" });
export const state = App.state("config", async (app) => {
const result = await load(app.root);
return result;
});
export const Info = z
.object({
providers: Provider.Info.array().optional(),
})
.strict();
export type Info = z.output<typeof Info>;
export function get() {
return state();
}
async function load(directory: string) {
let result: Info = {};
for (const file of ["opencode.jsonc", "opencode.json"]) {
const resolved = path.join(directory, file);
log.info("searching", { path: resolved });
try {
result = await import(path.join(directory, file)).then((mod) =>
Info.parse(mod.default),
);
log.info("found", { path: resolved });
break;
} catch (e) {
if (e instanceof z.ZodError) {
for (const issue of e.issues) {
log.info(issue.message);
}
throw e;
}
continue;
}
}
log.info("loaded", result);
return result;
}
}

View File

@@ -0,0 +1,20 @@
import envpaths from "env-paths";
import fs from "fs/promises";
const paths = envpaths("opencode", {
suffix: "",
});
await Promise.all([
fs.mkdir(paths.config, { recursive: true }),
fs.mkdir(paths.cache, { recursive: true }),
]);
export namespace Global {
export function config() {
return paths.config;
}
export function cache() {
return paths.cache;
}
}

View File

@@ -0,0 +1,74 @@
import { z } from "zod";
import { randomBytes } from "crypto";
export namespace Identifier {
const prefixes = {
session: "ses",
message: "msg",
} as const;
export function schema(prefix: keyof typeof prefixes) {
return z.string().startsWith(prefixes[prefix]);
}
const LENGTH = 26;
// State for monotonic ID generation
let lastTimestamp = 0;
let counter = 0;
export function ascending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, false, given);
}
export function descending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, true, given);
}
function generateID(
prefix: keyof typeof prefixes,
descending: boolean,
given?: string,
): string {
if (!given) {
return generateNewID(prefix, descending);
}
if (!given.startsWith(prefixes[prefix])) {
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`);
}
return given;
}
function generateNewID(
prefix: keyof typeof prefixes,
descending: boolean,
): string {
const currentTimestamp = Date.now();
if (currentTimestamp !== lastTimestamp) {
lastTimestamp = currentTimestamp;
counter = 0;
}
counter++;
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter);
now = descending ? ~now : now;
const timeBytes = Buffer.alloc(6);
for (let i = 0; i < 6; i++) {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff));
}
const randLength = (LENGTH - 12) / 2;
const random = randomBytes(randLength);
return (
prefixes[prefix] +
"_" +
timeBytes.toString("hex") +
random.toString("hex")
);
}
}

View File

@@ -0,0 +1,85 @@
import "zod-openapi/extend"
import { App } from "./app/app"
import { Server } from "./server/server"
import fs from "fs/promises"
import path from "path"
import { Bus } from "./bus"
import { Session } from "./session/session"
import cac from "cac"
import { Share } from "./share/share"
import { Storage } from "./storage/storage"
import { LLM } from "./llm/llm"
import { Message } from "./session/message"
const cli = cac("opencode")
cli.command("", "Start the opencode in interactive mode").action(async () => {
await App.provide({ directory: process.cwd() }, async () => {
await Share.init()
Server.listen()
})
})
cli.command("generate", "Generate OpenAPI and event specs").action(async () => {
const specs = await Server.openapi()
const dir = "gen"
await fs.rmdir(dir, { recursive: true }).catch(() => {})
await fs.mkdir(dir, { recursive: true })
await Bun.write(
path.join(dir, "openapi.json"),
JSON.stringify(specs, null, 2),
)
})
cli
.command("run [...message]", "Run a chat message")
.option("--session <id>", "Session ID")
.action(async (message: string[], options) => {
await App.provide({ directory: process.cwd() }, async () => {
await Share.init()
const session = options.session
? await Session.get(options.session)
: await Session.create()
console.log("Session:", session.id)
Bus.subscribe(Message.Event.Updated, async (message) => {
console.log("Thinking...")
})
const unsub = Bus.subscribe(Session.Event.Updated, async (message) => {
if (message.properties.info.share?.url)
console.log("Share:", message.properties.info.share.url)
unsub()
})
const providers = await LLM.providers()
const providerID = Object.keys(providers)[0]
const modelID = providers[providerID].info.models[0].id
console.log("using", providerID, modelID)
const result = await Session.chat({
sessionID: session.id,
providerID,
modelID,
parts: [
{
type: "text",
text: message.join(" "),
},
],
})
for (const part of result.parts) {
if (part.type === "text") {
console.log("opencode:", part.text)
}
}
console.log({
cost: result.metadata.assistant?.cost,
tokens: result.metadata.assistant?.tokens,
})
})
})
cli.help()
cli.version("1.0.0")
cli.parse()

View File

@@ -0,0 +1,172 @@
import { App } from "../app/app"
import { Log } from "../util/log"
import { concat } from "remeda"
import path from "path"
import { Provider } from "../provider/provider"
import type { LanguageModel, Provider as ProviderInstance } from "ai"
import { NoSuchModelError } from "ai"
import { Config } from "../config/config"
import { BunProc } from "../bun"
import { Global } from "../global"
export namespace LLM {
const log = Log.create({ service: "llm" })
export class ModelNotFoundError extends Error {
constructor(public readonly model: string) {
super()
}
}
const NATIVE_PROVIDERS: Provider.Info[] = [
{
id: "anthropic",
name: "Anthropic",
models: [
{
id: "claude-sonnet-4-20250514",
name: "Claude Sonnet 4",
cost: {
input: 3.0 / 1_000_000,
output: 15.0 / 1_000_000,
inputCached: 3.75 / 1_000_000,
outputCached: 0.3 / 1_000_000,
},
contextWindow: 200_000,
maxOutputTokens: 50_000,
attachment: true,
},
],
},
{
id: "openai",
name: "OpenAI",
models: [
{
id: "codex-mini-latest",
name: "Codex Mini",
cost: {
input: 1.5 / 1_000_000,
inputCached: 0.375 / 1_000_000,
output: 6.0 / 1_000_000,
outputCached: 0.0 / 1_000_000,
},
contextWindow: 200_000,
maxOutputTokens: 100_000,
attachment: true,
reasoning: true,
},
],
},
{
id: "google",
name: "Google",
models: [
{
id: "gemini-2.5-pro-preview-03-25",
name: "Gemini 2.5 Pro",
cost: {
input: 1.25 / 1_000_000,
inputCached: 0 / 1_000_000,
output: 10 / 1_000_000,
outputCached: 0 / 1_000_000,
},
contextWindow: 1_000_000,
maxOutputTokens: 50_000,
attachment: true,
},
],
},
]
const AUTODETECT: Record<string, string[]> = {
anthropic: ["ANTHROPIC_API_KEY"],
openai: ["OPENAI_API_KEY"],
google: ["GOOGLE_GENERATIVE_AI_API_KEY", "GEMINI_API_KEY"],
}
const state = App.state("llm", async () => {
const config = await Config.get()
const providers: Record<
string,
{
info: Provider.Info
instance: ProviderInstance
}
> = {}
const models = new Map<
string,
{ info: Provider.Model; instance: LanguageModel }
>()
const list = concat(NATIVE_PROVIDERS, config.providers ?? [])
for (const provider of list) {
if (
!config.providers?.find((p) => p.id === provider.id) &&
!AUTODETECT[provider.id]?.some((env) => process.env[env])
)
continue
const dir = path.join(
Global.cache(),
`node_modules`,
`@ai-sdk`,
provider.id,
)
if (!(await Bun.file(path.join(dir, "package.json")).exists())) {
BunProc.run(["add", "--exact", `@ai-sdk/${provider.id}@alpha`], {
cwd: Global.cache(),
})
}
const mod = await import(
path.join(Global.cache(), `node_modules`, `@ai-sdk`, provider.id)
)
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
const loaded = fn(provider.options)
log.info("loaded", { provider: provider.id })
providers[provider.id] = {
info: provider,
instance: loaded,
}
}
return {
models,
providers,
}
})
export async function providers() {
return state().then((state) => state.providers)
}
export async function findModel(providerID: string, modelID: string) {
const key = `${providerID}/${modelID}`
const s = await state()
if (s.models.has(key)) return s.models.get(key)!
const provider = s.providers[providerID]
if (!provider) throw new ModelNotFoundError(modelID)
log.info("loading", {
providerID,
modelID,
})
const info = provider.info.models.find((m) => m.id === modelID)
if (!info) throw new ModelNotFoundError(modelID)
try {
const match = provider.instance.languageModel(modelID)
log.info("found", { providerID, modelID })
s.models.set(key, {
info,
instance: match,
})
return {
info,
instance: match,
}
} catch (e) {
if (e instanceof NoSuchModelError) throw new ModelNotFoundError(modelID)
throw e
}
}
}

View File

@@ -0,0 +1,208 @@
import { spawn } from "child_process";
import path from "path";
import {
createMessageConnection,
StreamMessageReader,
StreamMessageWriter,
} from "vscode-jsonrpc/node";
import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types";
import { App } from "../app/app";
import { Log } from "../util/log";
import { LANGUAGE_EXTENSIONS } from "./language";
import { Bus } from "../bus";
import z from "zod";
export namespace LSPClient {
const log = Log.create({ service: "lsp.client" });
export type Info = Awaited<ReturnType<typeof create>>;
export type Diagnostic = VSCodeDiagnostic;
export const Event = {
Diagnostics: Bus.event(
"lsp.client.diagnostics",
z.object({
serverID: z.string(),
path: z.string(),
}),
),
};
export async function create(input: { cmd: string[]; serverID: string }) {
log.info("starting client", input);
const app = await App.use();
const [command, ...args] = input.cmd;
const server = spawn(command, args, {
stdio: ["pipe", "pipe", "pipe"],
cwd: app.root,
});
const connection = createMessageConnection(
new StreamMessageReader(server.stdout),
new StreamMessageWriter(server.stdin),
);
const diagnostics = new Map<string, Diagnostic[]>();
connection.onNotification("textDocument/publishDiagnostics", (params) => {
const path = new URL(params.uri).pathname;
log.info("textDocument/publishDiagnostics", {
path,
});
const exists = diagnostics.has(path);
diagnostics.set(path, params.diagnostics);
// servers seem to send one blank publishDiagnostics event before the first real one
if (!exists && !params.diagnostics.length) return;
Bus.publish(Event.Diagnostics, { path, serverID: input.serverID });
});
connection.listen();
await connection.sendRequest("initialize", {
processId: server.pid,
initializationOptions: {
workspaceFolders: [
{
name: "workspace",
uri: "file://" + app.root,
},
],
tsserver: {
path: require.resolve("typescript/lib/tsserver.js"),
},
},
capabilities: {
workspace: {
configuration: true,
didChangeConfiguration: {
dynamicRegistration: true,
},
didChangeWatchedFiles: {
dynamicRegistration: true,
relativePatternSupport: true,
},
},
textDocument: {
synchronization: {
dynamicRegistration: true,
didSave: true,
},
completion: {
completionItem: {},
},
codeLens: {
dynamicRegistration: true,
},
documentSymbol: {},
codeAction: {
codeActionLiteralSupport: {
codeActionKind: {
valueSet: [],
},
},
},
publishDiagnostics: {
versionSupport: true,
},
semanticTokens: {
requests: {
range: {},
full: {},
},
tokenTypes: [],
tokenModifiers: [],
formats: [],
},
},
window: {},
},
});
await connection.sendNotification("initialized", {});
log.info("initialized");
const files = new Set<string>();
const result = {
get clientID() {
return input.serverID;
},
get connection() {
return connection;
},
notify: {
async open(input: { path: string }) {
const file = Bun.file(input.path);
const text = await file.text();
const opened = files.has(input.path);
if (!opened) {
log.info("textDocument/didOpen", input);
diagnostics.delete(input.path);
const extension = path.extname(input.path);
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext";
await connection.sendNotification("textDocument/didOpen", {
textDocument: {
uri: `file://` + input.path,
languageId,
version: Date.now(),
text,
},
});
files.add(input.path);
return;
}
log.info("textDocument/didChange", input);
diagnostics.delete(input.path);
await connection.sendNotification("textDocument/didChange", {
textDocument: {
uri: `file://` + input.path,
version: Date.now(),
},
contentChanges: [
{
text,
},
],
});
},
},
get diagnostics() {
return diagnostics;
},
async waitForDiagnostics(input: { path: string }) {
log.info("waiting for diagnostics", input);
let unsub: () => void;
let timeout: NodeJS.Timeout;
return await Promise.race([
new Promise<void>(async (resolve) => {
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
if (
event.properties.path === input.path &&
event.properties.serverID === result.clientID
) {
log.info("got diagnostics", input);
clearTimeout(timeout);
unsub?.();
resolve();
}
});
}),
new Promise<void>((resolve) => {
timeout = setTimeout(() => {
log.info("timed out refreshing diagnostics", input);
unsub?.();
resolve();
}, 5000);
}),
]);
},
async shutdown() {
log.info("shutting down");
connection.end();
connection.dispose();
},
};
return result;
}
}

View File

@@ -0,0 +1,131 @@
import { App } from "../app/app";
import { Log } from "../util/log";
import { LSPClient } from "./client";
import path from "path";
export namespace LSP {
const log = Log.create({ service: "lsp" });
const state = App.state(
"lsp",
async () => {
log.info("initializing");
const clients = new Map<string, LSPClient.Info>();
return {
clients,
};
},
async (state) => {
for (const client of state.clients.values()) {
await client.shutdown();
}
},
);
export async function file(input: string) {
const extension = path.parse(input).ext;
const s = await state();
const matches = AUTO.filter((x) => x.extensions.includes(extension));
for (const match of matches) {
const existing = s.clients.get(match.id);
if (existing) continue;
const client = await LSPClient.create({
cmd: match.command,
serverID: match.id,
});
s.clients.set(match.id, client);
}
await run(async (client) => {
const wait = client.waitForDiagnostics({ path: input });
await client.notify.open({ path: input });
return wait;
});
}
export async function diagnostics() {
const results: Record<string, LSPClient.Diagnostic[]> = {};
for (const result of await run(async (client) => client.diagnostics)) {
for (const [path, diagnostics] of result.entries()) {
const arr = results[path] || [];
arr.push(...diagnostics);
results[path] = arr;
}
}
return results;
}
export async function hover(input: {
file: string;
line: number;
character: number;
}) {
return run((client) => {
return client.connection.sendRequest("textDocument/hover", {
textDocument: {
uri: `file://${input.file}`,
},
position: {
line: input.line,
character: input.character,
},
});
});
}
async function run<T>(
input: (client: LSPClient.Info) => Promise<T>,
): Promise<T[]> {
const clients = await state().then((x) => [...x.clients.values()]);
const tasks = clients.map((x) => input(x));
return Promise.all(tasks);
}
const AUTO: {
id: string;
command: string[];
extensions: string[];
install?: () => Promise<void>;
}[] = [
{
id: "typescript",
command: ["bun", "x", "typescript-language-server", "--stdio"],
extensions: [
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".mts",
".cts",
".mtsx",
".ctsx",
],
},
/*
{
id: "golang",
command: ["gopls"],
extensions: [".go"],
},
*/
];
export namespace Diagnostic {
export function pretty(diagnostic: LSPClient.Diagnostic) {
const severityMap = {
1: "ERROR",
2: "WARN",
3: "INFO",
4: "HINT",
};
const severity = severityMap[diagnostic.severity || 1];
const line = diagnostic.range.start.line + 1;
const col = diagnostic.range.start.character + 1;
return `${severity} [${line}:${col}] ${diagnostic.message}`;
}
}
}

View File

@@ -0,0 +1,89 @@
export const LANGUAGE_EXTENSIONS: Record<string, string> = {
".abap": "abap",
".bat": "bat",
".bib": "bibtex",
".bibtex": "bibtex",
".clj": "clojure",
".coffee": "coffeescript",
".c": "c",
".cpp": "cpp",
".cxx": "cpp",
".cc": "cpp",
".c++": "cpp",
".cs": "csharp",
".css": "css",
".d": "d",
".pas": "pascal",
".pascal": "pascal",
".diff": "diff",
".patch": "diff",
".dart": "dart",
".dockerfile": "dockerfile",
".ex": "elixir",
".exs": "elixir",
".erl": "erlang",
".hrl": "erlang",
".fs": "fsharp",
".fsi": "fsharp",
".fsx": "fsharp",
".fsscript": "fsharp",
".gitcommit": "git-commit",
".gitrebase": "git-rebase",
".go": "go",
".groovy": "groovy",
".hbs": "handlebars",
".handlebars": "handlebars",
".hs": "haskell",
".html": "html",
".htm": "html",
".ini": "ini",
".java": "java",
".js": "javascript",
".jsx": "javascriptreact",
".json": "json",
".tex": "latex",
".latex": "latex",
".less": "less",
".lua": "lua",
".makefile": "makefile",
makefile: "makefile",
".md": "markdown",
".markdown": "markdown",
".m": "objective-c",
".mm": "objective-cpp",
".pl": "perl",
".pm": "perl6",
".php": "php",
".ps1": "powershell",
".psm1": "powershell",
".pug": "jade",
".jade": "jade",
".py": "python",
".r": "r",
".cshtml": "razor",
".razor": "razor",
".rb": "ruby",
".rs": "rust",
".scss": "scss",
".sass": "sass",
".scala": "scala",
".shader": "shaderlab",
".sh": "shellscript",
".bash": "shellscript",
".zsh": "shellscript",
".ksh": "shellscript",
".sql": "sql",
".swift": "swift",
".ts": "typescript",
".tsx": "typescriptreact",
".mts": "typescript",
".cts": "typescript",
".mtsx": "typescriptreact",
".ctsx": "typescriptreact",
".xml": "xml",
".xsl": "xsl",
".yaml": "yaml",
".yml": "yaml",
".mjs": "javascript",
".cjs": "javascript",
} as const;

View File

@@ -0,0 +1,35 @@
import z from "zod";
export namespace Provider {
export const Model = z
.object({
id: z.string(),
name: z.string().optional(),
cost: z.object({
input: z.number(),
inputCached: z.number(),
output: z.number(),
outputCached: z.number(),
}),
contextWindow: z.number(),
maxOutputTokens: z.number().optional(),
attachment: z.boolean(),
reasoning: z.boolean().optional(),
})
.openapi({
ref: "Provider.Model",
});
export type Model = z.output<typeof Model>;
export const Info = z
.object({
id: z.string(),
name: z.string(),
options: z.record(z.string(), z.any()).optional(),
models: Model.array(),
})
.openapi({
ref: "Provider.Info",
});
export type Info = z.output<typeof Info>;
}

View File

@@ -0,0 +1,309 @@
import { Log } from "../util/log";
import { Bus } from "../bus";
import { describeRoute, generateSpecs, openAPISpecs } from "hono-openapi";
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { Session } from "../session/session";
import { resolver, validator as zValidator } from "hono-openapi/zod";
import { z } from "zod";
import { LLM } from "../llm/llm";
import { Message } from "../session/message";
import { Provider } from "../provider/provider";
export namespace Server {
const log = Log.create({ service: "server" });
const PORT = 16713;
export type App = ReturnType<typeof app>;
function app() {
const app = new Hono();
const result = app
.get(
"/openapi",
openAPISpecs(app, {
documentation: {
info: {
title: "opencode",
version: "1.0.0",
description: "opencode api",
},
openapi: "3.0.0",
},
}),
)
.get(
"/event",
describeRoute({
description: "Get events",
responses: {
200: {
description: "Event stream",
content: {
"application/json": {
schema: resolver(
Bus.payloads().openapi({
ref: "Event",
}),
),
},
},
},
},
}),
async (c) => {
log.info("event connected");
return streamSSE(c, async (stream) => {
stream.writeSSE({
data: JSON.stringify({}),
});
const unsub = Bus.subscribeAll(async (event) => {
await stream.writeSSE({
data: JSON.stringify(event),
});
});
await new Promise<void>((resolve) => {
stream.onAbort(() => {
unsub();
resolve();
log.info("event disconnected");
});
});
});
},
)
.post(
"/session_create",
describeRoute({
description: "Create a new session",
responses: {
200: {
description: "Successfully created session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
async (c) => {
const session = await Session.create();
return c.json(session);
},
)
.post(
"/session_share",
describeRoute({
description: "Share the session",
responses: {
200: {
description: "Successfully shared session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json");
await Session.share(body.sessionID);
const session = await Session.get(body.sessionID);
return c.json(session);
},
)
.post(
"/session_messages",
describeRoute({
description: "Get messages for a session",
responses: {
200: {
description: "Successfully created session",
content: {
"application/json": {
schema: resolver(Message.Info.array()),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const messages = await Session.messages(
c.req.valid("json").sessionID,
);
return c.json(messages);
},
)
.post(
"/session_list",
describeRoute({
description: "List all sessions",
responses: {
200: {
description: "List of sessions",
content: {
"application/json": {
schema: resolver(Session.Info.array()),
},
},
},
},
}),
async (c) => {
const sessions = await Array.fromAsync(Session.list());
return c.json(sessions);
},
)
.post(
"/session_abort",
describeRoute({
description: "Abort a session",
responses: {
200: {
description: "Aborted session",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json");
return c.json(Session.abort(body.sessionID));
},
)
.post(
"/session_summarize",
describeRoute({
description: "Summarize the session",
responses: {
200: {
description: "Summarize the session",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
providerID: z.string(),
modelID: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json");
await Session.summarize(body);
return c.json(true);
},
)
.post(
"/session_chat",
describeRoute({
description: "Chat with a model",
responses: {
200: {
description: "Chat with a model",
content: {
"application/json": {
schema: resolver(Message.Info),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
providerID: z.string(),
modelID: z.string(),
parts: Message.Part.array(),
}),
),
async (c) => {
const body = c.req.valid("json");
const msg = await Session.chat(body);
return c.json(msg);
},
)
.post(
"/provider_list",
describeRoute({
description: "List all providers",
responses: {
200: {
description: "List of providers",
content: {
"application/json": {
schema: resolver(Provider.Info.array()),
},
},
},
},
}),
async (c) => {
const providers = await LLM.providers();
const result = [] as (Provider.Info & { key: string })[];
for (const [key, provider] of Object.entries(providers)) {
result.push({ ...provider.info, key });
}
return c.json(result);
},
);
return result;
}
export async function openapi() {
const a = app();
const result = await generateSpecs(a, {
documentation: {
info: {
title: "opencode",
version: "1.0.0",
description: "opencode api",
},
openapi: "3.0.0",
},
});
return result;
}
export function listen() {
const server = Bun.serve({
port: PORT,
hostname: "0.0.0.0",
idleTimeout: 0,
fetch: app().fetch,
});
return server;
}
}

View File

@@ -0,0 +1,171 @@
import z from "zod";
import { Bus } from "../bus";
export namespace Message {
export const ToolCall = z
.object({
state: z.literal("call"),
step: z.number().optional(),
toolCallId: z.string(),
toolName: z.string(),
args: z.custom<Required<unknown>>(),
})
.openapi({
ref: "Message.ToolInvocation.ToolCall",
});
export type ToolCall = z.infer<typeof ToolCall>;
export const ToolPartialCall = z
.object({
state: z.literal("partial-call"),
step: z.number().optional(),
toolCallId: z.string(),
toolName: z.string(),
args: z.custom<Required<unknown>>(),
})
.openapi({
ref: "Message.ToolInvocation.ToolPartialCall",
});
export type ToolPartialCall = z.infer<typeof ToolPartialCall>;
export const ToolResult = z
.object({
state: z.literal("result"),
step: z.number().optional(),
toolCallId: z.string(),
toolName: z.string(),
args: z.custom<Required<unknown>>(),
result: z.string(),
})
.openapi({
ref: "Message.ToolInvocation.ToolResult",
});
export type ToolResult = z.infer<typeof ToolResult>;
export const ToolInvocation = z
.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult])
.openapi({
ref: "Message.ToolInvocation",
});
export type ToolInvocation = z.infer<typeof ToolInvocation>;
export const TextPart = z
.object({
type: z.literal("text"),
text: z.string(),
})
.openapi({
ref: "Message.Part.Text",
});
export type TextPart = z.infer<typeof TextPart>;
export const ReasoningPart = z
.object({
type: z.literal("reasoning"),
text: z.string(),
providerMetadata: z.record(z.any()).optional(),
})
.openapi({
ref: "Message.Part.Reasoning",
});
export type ReasoningPart = z.infer<typeof ReasoningPart>;
export const ToolInvocationPart = z
.object({
type: z.literal("tool-invocation"),
toolInvocation: ToolInvocation,
})
.openapi({
ref: "Message.Part.ToolInvocation",
});
export type ToolInvocationPart = z.infer<typeof ToolInvocationPart>;
export const SourceUrlPart = z
.object({
type: z.literal("source-url"),
sourceId: z.string(),
url: z.string(),
title: z.string().optional(),
providerMetadata: z.record(z.any()).optional(),
})
.openapi({
ref: "Message.Part.SourceUrl",
});
export type SourceUrlPart = z.infer<typeof SourceUrlPart>;
export const FilePart = z
.object({
type: z.literal("file"),
mediaType: z.string(),
filename: z.string().optional(),
url: z.string(),
})
.openapi({
ref: "Message.Part.File",
});
export type FilePart = z.infer<typeof FilePart>;
export const StepStartPart = z
.object({
type: z.literal("step-start"),
})
.openapi({
ref: "Message.Part.StepStart",
});
export type StepStartPart = z.infer<typeof StepStartPart>;
export const Part = z
.discriminatedUnion("type", [
TextPart,
ReasoningPart,
ToolInvocationPart,
SourceUrlPart,
FilePart,
StepStartPart,
])
.openapi({
ref: "Message.Part",
});
export type Part = z.infer<typeof Part>;
export const Info = z
.object({
id: z.string(),
role: z.enum(["system", "user", "assistant"]),
parts: z.array(Part),
metadata: z.object({
time: z.object({
created: z.number(),
completed: z.number().optional(),
}),
sessionID: z.string(),
tool: z.record(z.string(), z.any()),
assistant: z
.object({
modelID: z.string(),
providerID: z.string(),
cost: z.number(),
summary: z.boolean().optional(),
tokens: z.object({
input: z.number(),
output: z.number(),
reasoning: z.number(),
}),
})
.optional(),
}),
})
.openapi({
ref: "Message.Info",
});
export type Info = z.infer<typeof Info>;
export const Event = {
Updated: Bus.event(
"message.updated",
z.object({
info: Info,
}),
),
};
}

View File

@@ -0,0 +1,95 @@
You are OpenCode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
# Memory
If the current working directory contains a file called OpenCode.md, it will be automatically added to your context. This file serves multiple purposes:
1. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
3. Maintaining useful information about the codebase structure and organization
When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to CONTEXT.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to CONTEXT.md so you can remember it for next time.
# Tone and style
You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". Here are some examples to demonstrate appropriate verbosity:
<example>
user: 2 + 2
assistant: 4
</example>
<example>
user: what is 2+2?
assistant: 4
</example>
<example>
user: is 11 a prime number?
assistant: yes
</example>
<example>
user: what command should I run to list files in the current directory?
assistant: ls
</example>
<example>
user: what command should I run to watch files in the current directory?
assistant: [use the ls tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
npm run dev
</example>
<example>
user: How many golf balls fit inside a jetta?
assistant: 150000
</example>
<example>
user: what files are in the directory src/?
assistant: [runs ls and sees foo.c, bar.c, baz.c]
user: which file contains the implementation of foo?
assistant: src/foo.c
</example>
<example>
user: write tests for new feature
assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit/patch file tool to write new tests]
</example>
# Proactiveness
You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
1. Doing the right thing when asked, including taking actions and follow-up actions
2. Not surprising the user with actions you take without asking
For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
# Following conventions
When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
# Code style
- Do not add comments to the code you write, unless the user asks you to, or the code is complex and requires additional context.
# Doing tasks
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
1. 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.
2. Implement the solution using all tools available to you
3. 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.
4. 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.) 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 opencode.md so that you will know to run it next time.
NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
# Tool usage policy
- When doing file search, prefer to use the Agent tool in order to reduce context usage.
- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in the same function_calls block.
- IMPORTANT: The user does not see the full output of the tool responses, so if you need the output of the tool for the response make sure to summarize it for the user.
You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.

View File

@@ -0,0 +1,10 @@
You are a helpful AI assistant tasked with summarizing conversations.
When asked to summarize, provide a detailed but concise summary of the conversation.
Focus on information that would be helpful for continuing the conversation, including:
- What was done
- What is currently being worked on
- Which files are being modified
- What needs to be done next
Your summary should be comprehensive enough to provide context but concise enough to be quickly understood.

View File

@@ -0,0 +1,7 @@
you will generate a short title based on the first message a user begins a conversation with
- ensure it is not more than 50 characters long
- the title should be a summary of the user's message
- it should be one line long
- do not use quotes or colons
- the entire text you return will be used as the title
- never return anything that is more than one sentence (one line) long

View File

@@ -0,0 +1,498 @@
import path from "path";
import { App } from "../app/app";
import { Identifier } from "../id/id";
import { LLM } from "../llm/llm";
import { Storage } from "../storage/storage";
import { Log } from "../util/log";
import {
convertToModelMessages,
generateText,
stepCountIs,
streamText,
type LanguageModelUsage,
} from "ai";
import { z } from "zod";
import * as tools from "../tool";
import { Decimal } from "decimal.js";
import PROMPT_ANTHROPIC from "./prompt/anthropic.txt";
import PROMPT_TITLE from "./prompt/title.txt";
import PROMPT_SUMMARIZE from "./prompt/summarize.txt";
import { Share } from "../share/share";
import { Message } from "./message";
import { Bus } from "../bus";
import type { Provider } from "../provider/provider";
export namespace Session {
const log = Log.create({ service: "session" });
export const Info = z
.object({
id: Identifier.schema("session"),
share: z
.object({
secret: z.string(),
url: z.string(),
})
.optional(),
title: z.string(),
time: z.object({
created: z.number(),
updated: z.number(),
}),
})
.openapi({
ref: "session.info",
});
export type Info = z.output<typeof Info>;
export const Event = {
Updated: Bus.event(
"session.updated",
z.object({
info: Info,
}),
),
};
const state = App.state("session", () => {
const sessions = new Map<string, Info>();
const messages = new Map<string, Message.Info[]>();
return {
sessions,
messages,
};
});
export async function create() {
const result: Info = {
id: Identifier.descending("session"),
title: "New Session - " + new Date().toISOString(),
time: {
created: Date.now(),
updated: Date.now(),
},
};
log.info("created", result);
state().sessions.set(result.id, result);
await Storage.writeJSON("session/info/" + result.id, result);
share(result.id).then((share) => {
update(result.id, (draft) => {
draft.share = share;
});
});
Bus.publish(Event.Updated, {
info: result,
});
return result;
}
export async function get(id: string) {
const result = state().sessions.get(id);
if (result) {
return result;
}
const read = await Storage.readJSON<Info>("session/info/" + id);
state().sessions.set(id, read);
return read as Info;
}
export async function share(id: string) {
const session = await get(id);
if (session.share) return session.share;
const share = await Share.create(id);
await update(id, (draft) => {
draft.share = share;
});
return share;
}
export async function update(id: string, editor: (session: Info) => void) {
const { sessions } = state();
const session = await get(id);
if (!session) return;
editor(session);
session.time.updated = Date.now();
sessions.set(id, session);
await Storage.writeJSON("session/info/" + id, session);
Bus.publish(Event.Updated, {
info: session,
});
return session;
}
export async function messages(sessionID: string) {
const result = [] as Message.Info[];
const list = Storage.list("session/message/" + sessionID);
for await (const p of list) {
const read = await Storage.readJSON<Message.Info>(p).catch(() => {});
if (!read) continue;
result.push(read);
}
result.sort((a, b) => (a.id > b.id ? 1 : -1));
return result;
}
export async function* list() {
for await (const item of Storage.list("session/info")) {
const sessionID = path.basename(item, ".json");
yield get(sessionID);
}
}
export function abort(sessionID: string) {
const controller = pending.get(sessionID);
if (!controller) return false;
controller.abort();
pending.delete(sessionID);
return true;
}
async function updateMessage(msg: Message.Info) {
await Storage.writeJSON(
"session/message/" + msg.metadata.sessionID + "/" + msg.id,
msg,
);
Bus.publish(Message.Event.Updated, {
info: msg,
});
}
export async function chat(input: {
sessionID: string;
providerID: string;
modelID: string;
parts: Message.Part[];
}) {
const l = log.clone().tag("session", input.sessionID);
l.info("chatting");
const model = await LLM.findModel(input.providerID, input.modelID);
let msgs = await messages(input.sessionID);
const previous = msgs.at(-1);
if (previous?.metadata.assistant) {
const tokens =
previous.metadata.assistant.tokens.input +
previous.metadata.assistant.tokens.output;
if (
tokens >
(model.info.contextWindow - (model.info.maxOutputTokens ?? 0)) * 0.9
) {
await summarize({
sessionID: input.sessionID,
providerID: input.providerID,
modelID: input.modelID,
});
return chat(input);
}
}
using abort = lock(input.sessionID);
const lastSummary = msgs.findLast(
(msg) => msg.metadata.assistant?.summary === true,
);
if (lastSummary)
msgs = msgs.filter(
(msg) => msg.role === "system" || msg.id >= lastSummary.id,
);
const app = await App.use();
if (msgs.length === 0) {
const system: Message.Info = {
id: Identifier.ascending("message"),
role: "system",
parts: [
{
type: "text",
text: PROMPT_ANTHROPIC,
},
],
metadata: {
sessionID: input.sessionID,
time: {
created: Date.now(),
},
tool: {},
},
};
const contextFile = Bun.file(path.join(app.root, "CONTEXT.md"));
if (await contextFile.exists()) {
const context = await contextFile.text();
system.parts.push({
type: "text",
text: context,
});
}
msgs.push(system);
generateText({
messages: convertToModelMessages([
{
role: "system",
parts: [
{
type: "text",
text: PROMPT_TITLE,
},
],
},
{
role: "user",
parts: input.parts,
},
]),
model: model.instance,
}).then((result) => {
return Session.update(input.sessionID, (draft) => {
draft.title = result.text;
});
});
await updateMessage(system);
}
const msg: Message.Info = {
role: "user",
id: Identifier.ascending("message"),
parts: input.parts,
metadata: {
time: {
created: Date.now(),
},
sessionID: input.sessionID,
tool: {},
},
};
msgs.push(msg);
await updateMessage(msg);
const next: Message.Info = {
id: Identifier.ascending("message"),
role: "assistant",
parts: [],
metadata: {
assistant: {
cost: 0,
tokens: {
input: 0,
output: 0,
reasoning: 0,
},
modelID: input.modelID,
providerID: input.providerID,
},
time: {
created: Date.now(),
},
sessionID: input.sessionID,
tool: {},
},
};
await updateMessage(next);
const result = streamText({
onStepFinish: async (step) => {
const assistant = next.metadata!.assistant!;
const usage = getUsage(step.usage, model.info);
assistant.cost = usage.cost;
assistant.tokens = usage.tokens;
await updateMessage(next);
},
abortSignal: abort.signal,
maxRetries: 6,
stopWhen: stepCountIs(1000),
messages: convertToModelMessages(msgs),
temperature: 0,
tools,
model: model.instance,
});
let text: Message.TextPart | undefined;
const reader = result.toUIMessageStream().getReader();
while (true) {
const result = await reader.read().catch((e) => {
if (e instanceof DOMException && e.name === "AbortError") {
return;
}
throw e;
});
if (!result) break;
const { done, value } = result;
if (done) break;
l.info("part", {
type: value.type,
});
switch (value.type) {
case "start":
break;
case "start-step":
text = undefined;
next.parts.push({
type: "step-start",
});
break;
case "text":
if (!text) {
text = value;
next.parts.push(value);
break;
}
text.text += value.text;
break;
case "tool-call":
next.parts.push({
type: "tool-invocation",
toolInvocation: {
state: "call",
...value,
// hack until zod v4
args: value.args as any,
},
});
break;
case "tool-result":
const match = next.parts.find(
(p) =>
p.type === "tool-invocation" &&
p.toolInvocation.toolCallId === value.toolCallId,
);
if (match && match.type === "tool-invocation") {
const { output, metadata } = value.result as any;
next.metadata!.tool[value.toolCallId] = metadata;
match.toolInvocation = {
...match.toolInvocation,
state: "result",
result: output,
};
}
break;
case "finish":
break;
case "finish-step":
break;
case "error":
log.error("error", value);
break;
default:
l.info("unhandled", {
type: value.type,
});
}
await updateMessage(next);
}
next.metadata!.time.completed = Date.now();
await updateMessage(next);
return next;
}
export async function summarize(input: {
sessionID: string;
providerID: string;
modelID: string;
}) {
using abort = lock(input.sessionID);
const msgs = await messages(input.sessionID);
const lastSummary = msgs.findLast(
(msg) => msg.metadata.assistant?.summary === true,
)?.id;
const filtered = msgs.filter(
(msg) => msg.role !== "system" && (!lastSummary || msg.id >= lastSummary),
);
const model = await LLM.findModel(input.providerID, input.modelID);
const next: Message.Info = {
id: Identifier.ascending("message"),
role: "assistant",
parts: [],
metadata: {
tool: {},
sessionID: input.sessionID,
assistant: {
summary: true,
cost: 0,
modelID: input.modelID,
providerID: input.providerID,
tokens: {
input: 0,
output: 0,
reasoning: 0,
},
},
time: {
created: Date.now(),
},
},
};
await updateMessage(next);
const result = await generateText({
abortSignal: abort.signal,
model: model.instance,
messages: convertToModelMessages([
{
role: "system",
parts: [
{
type: "text",
text: PROMPT_SUMMARIZE,
},
],
},
...filtered,
{
role: "user",
parts: [
{
type: "text",
text: "Provide a detailed but concise summary of our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.",
},
],
},
]),
});
next.parts.push({
type: "text",
text: result.text,
});
const assistant = next.metadata!.assistant!;
const usage = getUsage(result.usage, model.info);
assistant.cost = usage.cost;
assistant.tokens = usage.tokens;
await updateMessage(next);
}
const pending = new Map<string, AbortController>();
function lock(sessionID: string) {
log.info("locking", { sessionID });
if (pending.has(sessionID)) throw new BusyError(sessionID);
const controller = new AbortController();
pending.set(sessionID, controller);
return {
signal: controller.signal,
[Symbol.dispose]() {
log.info("unlocking", { sessionID });
pending.delete(sessionID);
},
};
}
function getUsage(usage: LanguageModelUsage, model: Provider.Model) {
const tokens = {
input: usage.inputTokens ?? 0,
output: usage.outputTokens ?? 0,
reasoning: usage.reasoningTokens ?? 0,
};
return {
cost: new Decimal(0)
.add(new Decimal(tokens.input).mul(model.cost.input))
.add(new Decimal(tokens.output).mul(model.cost.output))
.toNumber(),
tokens,
};
}
export class BusyError extends Error {
constructor(public readonly sessionID: string) {
super(`Session ${sessionID} is busy`);
}
}
}

View File

@@ -0,0 +1,67 @@
import { App } from "../app/app";
import { Bus } from "../bus";
import { Session } from "../session/session";
import { Storage } from "../storage/storage";
import { Log } from "../util/log";
export namespace Share {
const log = Log.create({ service: "share" });
let queue: Promise<void> = Promise.resolve();
const pending = new Map<string, any>();
const state = App.state("share", async () => {
Bus.subscribe(Storage.Event.Write, async (payload) => {
const [root, ...splits] = payload.properties.key.split("/");
if (root !== "session") return;
const [, sessionID] = splits;
const session = await Session.get(sessionID);
if (!session.share) return;
const { secret } = session.share;
const key = payload.properties.key;
pending.set(key, payload.properties.content);
queue = queue
.then(async () => {
const content = pending.get(key);
if (content === undefined) return;
pending.delete(key);
return fetch(`${URL}/share_sync`, {
method: "POST",
body: JSON.stringify({
sessionID: sessionID,
secret,
key: key,
content,
}),
});
})
.then((x) => {
if (x) {
log.info("synced", {
key: key,
status: x.status,
});
}
});
});
});
export async function init() {
await state();
}
export const URL =
process.env["OPENCODE_API"] ?? "https://api.dev.opencode.ai";
export async function create(sessionID: string) {
return fetch(`${URL}/share_create`, {
method: "POST",
body: JSON.stringify({ sessionID: sessionID }),
})
.then((x) => x.json())
.then((x) => x as { url: string; secret: string });
}
}

View File

@@ -0,0 +1,55 @@
import { FileStorage } from "@flystorage/file-storage";
import { LocalStorageAdapter } from "@flystorage/local-fs";
import fs from "fs/promises";
import { Log } from "../util/log";
import { App } from "../app/app";
import { AppPath } from "../app/path";
import { Bus } from "../bus";
import z from "zod";
export namespace Storage {
const log = Log.create({ service: "storage" });
export const Event = {
Write: Bus.event(
"storage.write",
z.object({ key: z.string(), content: z.any() }),
),
};
const state = App.state("storage", async () => {
const app = await App.use();
const storageDir = AppPath.storage(app.root);
await fs.mkdir(storageDir, { recursive: true });
const storage = new FileStorage(new LocalStorageAdapter(storageDir));
log.info("created", { path: storageDir });
return {
storage,
};
});
export async function readJSON<T>(key: string) {
const storage = await state().then((x) => x.storage);
const data = await storage.readToString(key + ".json");
return JSON.parse(data) as T;
}
export async function writeJSON<T>(key: string, content: T) {
const storage = await state().then((x) => x.storage);
const json = JSON.stringify(content);
await storage.write(key + ".json", json);
Bus.publish(Event.Write, { key, content });
}
export async function* list(prefix: string) {
try {
const storage = await state().then((x) => x.storage);
const list = storage.list(prefix);
for await (const item of list) {
yield item.path.slice(0, -5);
}
} catch {
return;
}
}
}

View File

@@ -0,0 +1,199 @@
import { z } from "zod";
import { Tool } from "./tool";
const MAX_OUTPUT_LENGTH = 30000;
const BANNED_COMMANDS = [
"alias",
"curl",
"curlie",
"wget",
"axel",
"aria2c",
"nc",
"telnet",
"lynx",
"w3m",
"links",
"httpie",
"xh",
"http-prompt",
"chrome",
"firefox",
"safari",
];
const DEFAULT_TIMEOUT = 1 * 60 * 1000;
const MAX_TIMEOUT = 10 * 60 * 1000;
const DESCRIPTION = `Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
Before executing the command, please follow these steps:
1. Directory Verification:
- If the command will create new directories or files, first use the LS tool to verify the parent directory exists and is the correct location
- For example, before running "mkdir foo/bar", first use LS to check that "foo" exists and is the intended parent directory
2. Security Check:
- For security and to limit the threat of a prompt injection attack, some commands are limited or banned. If you use a disallowed command, you will receive an error message explaining the restriction. Explain the error to the User.
- Verify that the command is not one of the banned commands: ${BANNED_COMMANDS.join(", ")}.
3. Command Execution:
- After ensuring proper quoting, execute the command.
- Capture the output of the command.
4. Output Processing:
- If the output exceeds ${MAX_OUTPUT_LENGTH} characters, output will be truncated before being returned to you.
- Prepare the output for display to the user.
5. Return Result:
- Provide the processed output of the command.
- If any errors occurred during execution, include those in the output.
Usage notes:
- The command argument is required.
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 30 minutes.
- VERY IMPORTANT: You MUST avoid using search commands like 'find' and 'grep'. Instead use Grep, Glob, or Agent tools to search. You MUST avoid read tools like 'cat', 'head', 'tail', and 'ls', and use FileRead and LS tools to read files.
- When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).
- IMPORTANT: All commands share the same shell session. Shell state (environment variables, virtual environments, current directory, etc.) persist between commands. For example, if you set an environment variable as part of a command, the environment variable will persist for subsequent commands.
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of 'cd'. You may use 'cd' if the User explicitly requests it.
<good-example>
pytest /foo/bar/tests
</good-example>
<bad-example>
cd /foo/bar && pytest tests
</bad-example>
# Committing changes with git
When the user asks you to create a new git commit, follow these steps carefully:
1. Start with a single message that contains exactly three tool_use blocks that do the following (it is VERY IMPORTANT that you send these tool_use blocks in a single message, otherwise it will feel slow to the user!):
- Run a git status command to see all untracked files.
- Run a git diff command to see both staged and unstaged changes that will be committed.
- Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
2. Use the git context at the start of this conversation to determine which files are relevant to your commit. Add relevant untracked files to the staging area. Do not commit files that were already modified at the start of this conversation, if they are not relevant to your commit.
3. Analyze all staged changes (both previously staged and newly added) and draft a commit message. Wrap your analysis process in <commit_analysis> tags:
<commit_analysis>
- List the files that have been changed or added
- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)
- Brainstorm the purpose or motivation behind these changes
- Do not use tools to explore code, beyond what is available in the git context
- Assess the impact of these changes on the overall project
- Check for any sensitive information that shouldn't be committed
- Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
- Ensure your language is clear, concise, and to the point
- Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.)
- Ensure the message is not generic (avoid words like "Update" or "Fix" without context)
- Review the draft message to ensure it accurately reflects the changes and their purpose
</commit_analysis>
4. Create the commit with a message ending with:
🤖 Generated with opencode
Co-Authored-By: opencode <noreply@opencode.ai>
- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
<example>
git commit -m "$(cat <<'EOF'
Commit message here.
🤖 Generated with opencode
Co-Authored-By: opencode <noreply@opencode.ai>
EOF
)"
</example>
5. If the commit fails due to pre-commit hook changes, retry the commit ONCE to include these automated changes. If it fails again, it usually means a pre-commit hook is preventing the commit. If the commit succeeds but you notice that files were modified by the pre-commit hook, you MUST amend your commit to include them.
6. Finally, run git status to make sure the commit succeeded.
Important notes:
- When possible, combine the "git add" and "git commit" commands into a single "git commit -am" command, to speed things up
- However, be careful not to stage files (e.g. with 'git add .') for commits that aren't part of the change, they may have untracked files they want to keep around, but not commit.
- NEVER update the git config
- DO NOT push to the remote repository
- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.
- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
- Ensure your commit message is meaningful and concise. It should explain the purpose of the changes, not just describe them.
- Return an empty response - the user will see the git output directly
# Creating pull requests
Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed.
IMPORTANT: When the user asks you to create a pull request, follow these steps carefully:
1. Understand the current state of the branch. Remember to send a single message that contains multiple tool_use blocks (it is VERY IMPORTANT that you do this in a single message, otherwise it will feel slow to the user!):
- Run a git status command to see all untracked files.
- Run a git diff command to see both staged and unstaged changes that will be committed.
- Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote
- Run a git log command and 'git diff main...HEAD' to understand the full commit history for the current branch (from the time it diverged from the 'main' branch.)
2. Create new branch if needed
3. Commit changes if needed
4. Push to remote with -u flag if needed
5. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (not just the latest commit, but all commits that will be included in the pull request!), and draft a pull request summary. Wrap your analysis process in <pr_analysis> tags:
<pr_analysis>
- List the commits since diverging from the main branch
- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)
- Brainstorm the purpose or motivation behind these changes
- Assess the impact of these changes on the overall project
- Do not use tools to explore code, beyond what is available in the git context
- Check for any sensitive information that shouldn't be committed
- Draft a concise (1-2 bullet points) pull request summary that focuses on the "why" rather than the "what"
- Ensure the summary accurately reflects all changes since diverging from the main branch
- Ensure your language is clear, concise, and to the point
- Ensure the summary accurately reflects the changes and their purpose (ie. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.)
- Ensure the summary is not generic (avoid words like "Update" or "Fix" without context)
- Review the draft summary to ensure it accurately reflects the changes and their purpose
</pr_analysis>
6. Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.
<example>
gh pr create --title "the pr title" --body "$(cat <<'EOF'
## Summary
<1-3 bullet points>
## Test plan
[Checklist of TODOs for testing the pull request...]
🤖 Generated with opencode
EOF
)"
</example>
Important:
- Return an empty response - the user will see the gh output directly
- Never update git config`;
export const bash = Tool.define({
name: "opencode.bash",
description: DESCRIPTION,
parameters: z.object({
command: z.string(),
timeout: z
.number()
.min(0)
.max(MAX_TIMEOUT)
.describe("Optional timeout in milliseconds")
.optional(),
}),
async execute(params) {
const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT);
if (BANNED_COMMANDS.some((item) => params.command.startsWith(item)))
throw new Error(`Command '${params.command}' is not allowed`);
const process = Bun.spawnSync({
cmd: ["bash", "-c", params.command],
maxBuffer: MAX_OUTPUT_LENGTH,
timeout: timeout,
});
return {
output: process.stdout.toString("utf-8"),
};
},
});

View File

@@ -0,0 +1,136 @@
import { z } from "zod";
import * as path from "path";
import { Tool } from "./tool";
import { FileTimes } from "./util/file-times";
import { LSP } from "../lsp";
const DESCRIPTION = `Edits files by replacing text, creating new files, or deleting content. For moving or renaming files, use the Bash tool with the 'mv' command instead. For larger file edits, use the FileWrite tool to overwrite files.
Before using this tool:
1. Use the FileRead tool to understand the file's contents and context
2. Verify the directory path is correct (only applicable when creating new files):
- Use the LS tool to verify the parent directory exists and is the correct location
To make a file edit, provide the following:
1. file_path: The relative path to the file to modify (must be relative, not absolute)
2. old_string: The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation)
3. new_string: The edited text to replace the old_string
Special cases:
- To create a new file: provide file_path and new_string, leave old_string empty
- To delete content: provide file_path and old_string, leave new_string empty
The tool will replace ONE occurrence of old_string with new_string in the specified file.
CRITICAL REQUIREMENTS FOR USING THIS TOOL:
1. UNIQUENESS: The old_string MUST uniquely identify the specific instance you want to change. This means:
- Include AT LEAST 3-5 lines of context BEFORE the change point
- Include AT LEAST 3-5 lines of context AFTER the change point
- Include all whitespace, indentation, and surrounding code exactly as it appears in the file
2. SINGLE INSTANCE: This tool can only change ONE instance at a time. If you need to change multiple instances:
- Make separate calls to this tool for each instance
- Each call must uniquely identify its specific instance using extensive context
3. VERIFICATION: Before using this tool:
- Check how many instances of the target text exist in the file
- If multiple instances exist, gather enough context to uniquely identify each one
- Plan separate tool calls for each instance
WARNING: If you do not follow these requirements:
- The tool will fail if old_string matches multiple locations
- The tool will fail if old_string doesn't match exactly (including whitespace)
- You may change the wrong instance if you don't include enough context
When making edits:
- Ensure the edit results in idiomatic, correct code
- Do not leave the code in a broken state
- Always use relative file paths
Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.`;
export const edit = Tool.define({
name: "opencode.edit",
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z.string().describe("The text to replace it with"),
}),
async execute(params) {
if (!params.filePath) {
throw new Error("filePath is required");
}
let filePath = params.filePath;
if (!path.isAbsolute(filePath)) {
filePath = path.join(process.cwd(), filePath);
}
await (async () => {
if (params.oldString === "") {
await Bun.write(filePath, params.newString);
return;
}
const read = FileTimes.get(filePath);
if (!read)
throw new Error(
`You must read the file ${filePath} before editing it. Use the View tool first`,
);
const file = Bun.file(filePath);
if (!(await file.exists())) throw new Error(`File ${filePath} not found`);
const stats = await file.stat();
if (stats.isDirectory())
throw new Error(`Path is a directory, not a file: ${filePath}`);
if (stats.mtime.getTime() > read.getTime())
throw new Error(
`File ${filePath} has been modified since it was last read.\nLast modification: ${read.toISOString()}\nLast read: ${stats.mtime.toISOString()}\n\nPlease read the file again before modifying it.`,
);
const content = await file.text();
const index = content.indexOf(params.oldString);
if (index === -1)
throw new Error(
`oldString not found in file. Make sure it matches exactly, including whitespace and line breaks`,
);
const lastIndex = content.lastIndexOf(params.oldString);
if (index !== lastIndex)
throw new Error(
`oldString appears multiple times in the file. Please provide more context to ensure a unique match`,
);
const newContent =
content.substring(0, index) +
params.newString +
content.substring(index + params.oldString.length);
await file.write(newContent);
})();
FileTimes.write(filePath);
FileTimes.read(filePath);
let output = "";
await LSP.file(filePath);
const diagnostics = await LSP.diagnostics();
for (const [file, issues] of Object.entries(diagnostics)) {
if (issues.length === 0) continue;
if (file === filePath) {
output += `\nThis file has errors, please fix\n<file_diagnostics>\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n</file_diagnostics>\n`;
continue;
}
output += `\n<project_diagnostics>\n${file}\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n</project_diagnostics>\n`;
}
return {
metadata: {
diagnostics,
},
output,
};
},
});

View File

@@ -0,0 +1,137 @@
import { z } from "zod";
import { Tool } from "./tool";
import { JSDOM } from "jsdom";
import TurndownService from "turndown";
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024; // 5MB
const DEFAULT_TIMEOUT = 30 * 1000; // 30 seconds
const MAX_TIMEOUT = 120 * 1000; // 2 minutes
const DESCRIPTION = `Fetches content from a URL and returns it in the specified format.
WHEN TO USE THIS TOOL:
- Use when you need to download content from a URL
- Helpful for retrieving documentation, API responses, or web content
- Useful for getting external information to assist with tasks
HOW TO USE:
- Provide the URL to fetch content from
- Specify the desired output format (text, markdown, or html)
- Optionally set a timeout for the request
FEATURES:
- Supports three output formats: text, markdown, and html
- Automatically handles HTTP redirects
- Sets reasonable timeouts to prevent hanging
- Validates input parameters before making requests
LIMITATIONS:
- Maximum response size is 5MB
- Only supports HTTP and HTTPS protocols
- Cannot handle authentication or cookies
- Some websites may block automated requests
TIPS:
- Use text format for plain text content or simple API responses
- Use markdown format for content that should be rendered with formatting
- Use html format when you need the raw HTML structure
- Set appropriate timeouts for potentially slow websites`;
export const Fetch = Tool.define({
name: "opencode.fetch",
description: DESCRIPTION,
parameters: z.object({
url: z.string().describe("The URL to fetch content from"),
format: z
.enum(["text", "markdown", "html"])
.describe(
"The format to return the content in (text, markdown, or html)",
),
timeout: z
.number()
.min(0)
.max(MAX_TIMEOUT / 1000)
.describe("Optional timeout in seconds (max 120)")
.optional(),
}),
async execute(params, opts) {
// Validate URL
if (
!params.url.startsWith("http://") &&
!params.url.startsWith("https://")
) {
throw new Error("URL must start with http:// or https://");
}
const timeout = Math.min(
(params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000,
MAX_TIMEOUT,
);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
if (opts?.abortSignal) {
opts.abortSignal.addEventListener("abort", () => controller.abort());
}
const response = await fetch(params.url, {
signal: controller.signal,
headers: {
"User-Agent": "opencode/1.0",
},
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`Request failed with status code: ${response.status}`);
}
// Check content length
const contentLength = response.headers.get("content-length");
if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) {
throw new Error("Response too large (exceeds 5MB limit)");
}
const arrayBuffer = await response.arrayBuffer();
if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) {
throw new Error("Response too large (exceeds 5MB limit)");
}
const content = new TextDecoder().decode(arrayBuffer);
const contentType = response.headers.get("content-type") || "";
switch (params.format) {
case "text":
if (contentType.includes("text/html")) {
const text = extractTextFromHTML(content);
return { output: text };
}
return { output: content };
case "markdown":
if (contentType.includes("text/html")) {
const markdown = convertHTMLToMarkdown(content);
return { output: markdown };
}
return { output: "```\n" + content + "\n```" };
case "html":
return { output: content };
default:
return { output: content };
}
},
});
function extractTextFromHTML(html: string): string {
const dom = new JSDOM(html);
const text = dom.window.document.body?.textContent || "";
return text.replace(/\s+/g, " ").trim();
}
function convertHTMLToMarkdown(html: string): string {
const turndownService = new TurndownService();
return turndownService.turndown(html);
}

View File

@@ -0,0 +1,96 @@
import { z } from "zod";
import { Tool } from "./tool";
import { App } from "../app/app";
const DESCRIPTION = `Fast file pattern matching tool that finds files by name and pattern, returning matching paths sorted by modification time (newest first).
WHEN TO USE THIS TOOL:
- Use when you need to find files by name patterns or extensions
- Great for finding specific file types across a directory structure
- Useful for discovering files that match certain naming conventions
HOW TO USE:
- Provide a glob pattern to match against file paths
- Optionally specify a starting directory (defaults to current working directory)
- Results are sorted with most recently modified files first
GLOB PATTERN SYNTAX:
- '*' matches any sequence of non-separator characters
- '**' matches any sequence of characters, including separators
- '?' matches any single non-separator character
- '[...]' matches any character in the brackets
- '[!...]' matches any character not in the brackets
COMMON PATTERN EXAMPLES:
- '*.js' - Find all JavaScript files in the current directory
- '**/*.js' - Find all JavaScript files in any subdirectory
- 'src/**/*.{ts,tsx}' - Find all TypeScript files in the src directory
- '*.{html,css,js}' - Find all HTML, CSS, and JS files
LIMITATIONS:
- Results are limited to 100 files (newest first)
- Does not search file contents (use Grep tool for that)
- Hidden files (starting with '.') are skipped
TIPS:
- For the most useful results, combine with the Grep tool: first find files with Glob, then search their contents with Grep
- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
- Always check if results are truncated and refine your search pattern if needed`;
export const glob = Tool.define({
name: "opencode.glob",
description: DESCRIPTION,
parameters: z.object({
pattern: z.string().describe("The glob pattern to match files against"),
path: z
.string()
.describe(
"The directory to search in. Defaults to the current working directory.",
)
.optional(),
}),
async execute(params) {
const app = await App.use();
const search = params.path || app.root;
const limit = 100;
const glob = new Bun.Glob(params.pattern);
const files = [];
let truncated = false;
for await (const file of glob.scan({ cwd: search })) {
if (files.length >= limit) {
truncated = true;
break;
}
const stats = await Bun.file(file)
.stat()
.then((x) => x.mtime.getTime())
.catch(() => 0);
files.push({
path: file,
mtime: stats,
});
}
files.sort((a, b) => b.mtime - a.mtime);
const output = [];
if (files.length === 0) output.push("No files found");
if (files.length > 0) {
output.push(...files.map((f) => f.path));
if (truncated) {
output.push("");
output.push(
"(Results are truncated. Consider using a more specific path or pattern.)",
);
}
}
return {
metadata: {
count: files.length,
truncated,
},
output: output.join("\n"),
};
},
});

View File

@@ -0,0 +1,345 @@
import { z } from "zod";
import { Tool } from "./tool";
import { App } from "../app/app";
import { spawn } from "child_process";
import { promises as fs } from "fs";
import path from "path";
const DESCRIPTION = `Fast content search tool that finds files containing specific text or patterns, returning matching file paths sorted by modification time (newest first).
WHEN TO USE THIS TOOL:
- Use when you need to find files containing specific text or patterns
- Great for searching code bases for function names, variable declarations, or error messages
- Useful for finding all files that use a particular API or pattern
HOW TO USE:
- Provide a regex pattern to search for within file contents
- Set literal_text=true if you want to search for the exact text with special characters (recommended for non-regex users)
- Optionally specify a starting directory (defaults to current working directory)
- Optionally provide an include pattern to filter which files to search
- Results are sorted with most recently modified files first
REGEX PATTERN SYNTAX (when literal_text=false):
- Supports standard regular expression syntax
- 'function' searches for the literal text "function"
- 'log\\..*Error' finds text starting with "log." and ending with "Error"
- 'import\\s+.*\\s+from' finds import statements in JavaScript/TypeScript
COMMON INCLUDE PATTERN EXAMPLES:
- '*.js' - Only search JavaScript files
- '*.{ts,tsx}' - Only search TypeScript files
- '*.go' - Only search Go files
LIMITATIONS:
- Results are limited to 100 files (newest first)
- Performance depends on the number of files being searched
- Very large binary files may be skipped
- Hidden files (starting with '.') are skipped
TIPS:
- For faster, more targeted searches, first use Glob to find relevant files, then use Grep
- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
- Always check if results are truncated and refine your search pattern if needed
- Use literal_text=true when searching for exact text containing special characters like dots, parentheses, etc.`;
interface GrepMatch {
path: string;
modTime: number;
lineNum: number;
lineText: string;
}
function escapeRegexPattern(pattern: string): string {
const specialChars = [
"\\",
".",
"+",
"*",
"?",
"(",
")",
"[",
"]",
"{",
"}",
"^",
"$",
"|",
];
let escaped = pattern;
for (const char of specialChars) {
escaped = escaped.replaceAll(char, "\\" + char);
}
return escaped;
}
function globToRegex(glob: string): string {
let regexPattern = glob.replaceAll(".", "\\.");
regexPattern = regexPattern.replaceAll("*", ".*");
regexPattern = regexPattern.replaceAll("?", ".");
// Handle {a,b,c} patterns
regexPattern = regexPattern.replace(/\{([^}]+)\}/g, (_, inner) => {
return "(" + inner.replace(/,/g, "|") + ")";
});
return regexPattern;
}
async function searchWithRipgrep(
pattern: string,
searchPath: string,
include?: string,
): Promise<GrepMatch[]> {
return new Promise((resolve, reject) => {
const args = ["-n", pattern];
if (include) {
args.push("--glob", include);
}
args.push(searchPath);
const rg = spawn("rg", args);
let output = "";
let errorOutput = "";
rg.stdout.on("data", (data) => {
output += data.toString();
});
rg.stderr.on("data", (data) => {
errorOutput += data.toString();
});
rg.on("close", async (code) => {
if (code === 1) {
// No matches found
resolve([]);
return;
}
if (code !== 0) {
reject(new Error(`ripgrep failed: ${errorOutput}`));
return;
}
const lines = output.trim().split("\n");
const matches: GrepMatch[] = [];
for (const line of lines) {
if (!line) continue;
// Parse ripgrep output format: file:line:content
const parts = line.split(":", 3);
if (parts.length < 3) continue;
const filePath = parts[0];
const lineNum = parseInt(parts[1], 10);
const lineText = parts[2];
try {
const stats = await fs.stat(filePath);
matches.push({
path: filePath,
modTime: stats.mtime.getTime(),
lineNum,
lineText,
});
} catch {
// Skip files we can't access
continue;
}
}
resolve(matches);
});
rg.on("error", (err) => {
reject(err);
});
});
}
async function searchFilesWithRegex(
pattern: string,
rootPath: string,
include?: string,
): Promise<GrepMatch[]> {
const matches: GrepMatch[] = [];
const regex = new RegExp(pattern);
let includePattern: RegExp | undefined;
if (include) {
const regexPattern = globToRegex(include);
includePattern = new RegExp(regexPattern);
}
async function walkDir(dir: string) {
if (matches.length >= 200) return;
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (matches.length >= 200) break;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Skip hidden directories
if (entry.name.startsWith(".")) continue;
await walkDir(fullPath);
} else if (entry.isFile()) {
// Skip hidden files
if (entry.name.startsWith(".")) continue;
if (includePattern && !includePattern.test(fullPath)) {
continue;
}
try {
const content = await fs.readFile(fullPath, "utf-8");
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
if (regex.test(lines[i])) {
const stats = await fs.stat(fullPath);
matches.push({
path: fullPath,
modTime: stats.mtime.getTime(),
lineNum: i + 1,
lineText: lines[i],
});
break; // Only first match per file
}
}
} catch {
// Skip files we can't read
continue;
}
}
}
} catch {
// Skip directories we can't read
return;
}
}
await walkDir(rootPath);
return matches;
}
async function searchFiles(
pattern: string,
rootPath: string,
include?: string,
limit: number = 100,
): Promise<{ matches: GrepMatch[]; truncated: boolean }> {
let matches: GrepMatch[];
try {
matches = await searchWithRipgrep(pattern, rootPath, include);
} catch {
matches = await searchFilesWithRegex(pattern, rootPath, include);
}
// Sort by modification time (newest first)
matches.sort((a, b) => b.modTime - a.modTime);
const truncated = matches.length > limit;
if (truncated) {
matches = matches.slice(0, limit);
}
return { matches, truncated };
}
export const grep = Tool.define({
name: "opencode.grep",
description: DESCRIPTION,
parameters: z.object({
pattern: z
.string()
.describe("The regex pattern to search for in file contents"),
path: z
.string()
.describe(
"The directory to search in. Defaults to the current working directory.",
)
.optional(),
include: z
.string()
.describe(
'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
)
.optional(),
literalText: z
.boolean()
.describe(
"If true, the pattern will be treated as literal text with special regex characters escaped. Default is false.",
)
.optional(),
}),
async execute(params) {
if (!params.pattern) {
throw new Error("pattern is required");
}
const app = await App.use();
const searchPath = params.path || app.root;
// If literalText is true, escape the pattern
const searchPattern = params.literalText
? escapeRegexPattern(params.pattern)
: params.pattern;
const { matches, truncated } = await searchFiles(
searchPattern,
searchPath,
params.include,
100,
);
if (matches.length === 0) {
return {
metadata: { matches: 0, truncated },
output: "No files found"
};
}
const lines = [`Found ${matches.length} matches`];
let currentFile = "";
for (const match of matches) {
if (currentFile !== match.path) {
if (currentFile !== "") {
lines.push("");
}
currentFile = match.path;
lines.push(`${match.path}:`);
}
if (match.lineNum > 0) {
lines.push(` Line ${match.lineNum}: ${match.lineText}`);
} else {
lines.push(` ${match.path}`);
}
}
if (truncated) {
lines.push("");
lines.push(
"(Results are truncated. Consider using a more specific path or pattern.)",
);
}
return {
metadata: {
matches: matches.length,
truncated,
},
output: lines.join("\n"),
};
},
});

View File

@@ -0,0 +1,9 @@
export * from "./bash";
export * from "./edit";
export * from "./fetch";
export * from "./glob";
export * from "./grep";
export * from "./view";
export * from "./ls";
export * from "./lsp-diagnostics";
export * from "./lsp-hover";

View File

@@ -0,0 +1,96 @@
import { z } from "zod";
import { Tool } from "./tool";
import { App } from "../app/app";
import * as path from "path";
const IGNORE_PATTERNS = [
"node_modules/",
"__pycache__/",
".git/",
"dist/",
"build/",
"target/",
"vendor/",
"bin/",
"obj/",
".idea/",
".vscode/",
];
export const ls = Tool.define({
name: "opencode.ls",
description: "List directory contents",
parameters: z.object({
path: z.string().optional(),
ignore: z.array(z.string()).optional(),
}),
async execute(params) {
const app = await App.use();
const searchPath = path.resolve(app.root, params.path || ".");
const glob = new Bun.Glob("**/*");
const files = [];
for await (const file of glob.scan({ cwd: searchPath })) {
if (file.startsWith(".") || IGNORE_PATTERNS.some((p) => file.includes(p)))
continue;
if (params.ignore?.some((pattern) => new Bun.Glob(pattern).match(file)))
continue;
files.push(file);
if (files.length >= 1000) break;
}
// Build directory structure
const dirs = new Set<string>();
const filesByDir = new Map<string, string[]>();
for (const file of files) {
const dir = path.dirname(file);
const parts = dir === "." ? [] : dir.split("/");
// Add all parent directories
for (let i = 0; i <= parts.length; i++) {
const dirPath = i === 0 ? "." : parts.slice(0, i).join("/");
dirs.add(dirPath);
}
// Add file to its directory
if (!filesByDir.has(dir)) filesByDir.set(dir, []);
filesByDir.get(dir)!.push(path.basename(file));
}
function renderDir(dirPath: string, depth: number): string {
const indent = " ".repeat(depth);
let output = "";
if (depth > 0) {
output += `${indent}${path.basename(dirPath)}/\n`;
}
const childIndent = " ".repeat(depth + 1);
const children = Array.from(dirs)
.filter((d) => path.dirname(d) === dirPath && d !== dirPath)
.sort();
// Render subdirectories first
for (const child of children) {
output += renderDir(child, depth + 1);
}
// Render files
const files = filesByDir.get(dirPath) || [];
for (const file of files.sort()) {
output += `${childIndent}${file}\n`;
}
return output;
}
const output = `${searchPath}/\n` + renderDir(".", 0);
return {
metadata: { count: files.length, truncated: files.length >= 1000 },
output,
};
},
});

View File

@@ -0,0 +1,53 @@
import { z } from "zod";
import { Tool } from "./tool";
import path from "path";
import { LSP } from "../lsp";
import { App } from "../app/app";
export const LspDiagnosticTool = Tool.define({
name: "opencode.lsp_diagnostic",
description: `Get diagnostics for a file and/or project.
WHEN TO USE THIS TOOL:
- Use when you need to check for errors or warnings in your code
- Helpful for debugging and ensuring code quality
- Good for getting a quick overview of issues in a file or project
HOW TO USE:
- Provide a path to a file to get diagnostics for that file
- Results are displayed in a structured format with severity levels
FEATURES:
- Displays errors, warnings, and hints
- Groups diagnostics by severity
- Provides detailed information about each diagnostic
LIMITATIONS:
- Results are limited to the diagnostics provided by the LSP clients
- May not cover all possible issues in the code
- Does not provide suggestions for fixing issues
TIPS:
- Use in conjunction with other tools for a comprehensive code review
- Combine with the LSP client for real-time diagnostics`,
parameters: z.object({
path: z.string().describe("The path to the file to get diagnostics."),
}),
execute: async (args) => {
const app = await App.use();
const normalized = path.isAbsolute(args.path)
? args.path
: path.join(app.root, args.path);
await LSP.file(normalized);
const diagnostics = await LSP.diagnostics();
const file = diagnostics[normalized];
return {
metadata: {
diagnostics,
},
output: file?.length
? file.map(LSP.Diagnostic.pretty).join("\n")
: "No errors found",
};
},
});

View File

@@ -0,0 +1,38 @@
import { z } from "zod";
import { Tool } from "./tool";
import path from "path";
import { LSP } from "../lsp";
import { App } from "../app/app";
export const LspHoverTool = Tool.define({
name: "opencode.lsp_hover",
description: `
Looks up hover information for a given position in a source file using the Language Server Protocol (LSP).
This includes type information, documentation, or symbol details at the specified line and character.
Useful for providing code insights, explanations, or context-aware assistance based on the user's current cursor location.
`,
parameters: z.object({
file: z.string().describe("The path to the file to get diagnostics."),
line: z.number().describe("The line number to get diagnostics."),
character: z.number().describe("The character number to get diagnostics."),
}),
execute: async (args) => {
console.log(args);
const app = await App.use();
const file = path.isAbsolute(args.file)
? args.file
: path.join(app.root, args.file);
await LSP.file(file);
const result = await LSP.hover({
...args,
file,
});
console.log(result);
return {
metadata: {
result,
},
output: JSON.stringify(result, null, 2),
};
},
});

View File

@@ -0,0 +1,420 @@
import { z } from "zod";
import * as path from "path";
import * as fs from "fs/promises";
import { Tool } from "./tool";
import { FileTimes } from "./util/file-times";
const DESCRIPTION = `Applies a patch to multiple files in one operation. This tool is useful for making coordinated changes across multiple files.
The patch text must follow this format:
*** Begin Patch
*** Update File: /path/to/file
@@ Context line (unique within the file)
Line to keep
-Line to remove
+Line to add
Line to keep
*** Add File: /path/to/new/file
+Content of the new file
+More content
*** Delete File: /path/to/file/to/delete
*** End Patch
Before using this tool:
1. Use the FileRead tool to understand the files' contents and context
2. Verify all file paths are correct (use the LS tool)
CRITICAL REQUIREMENTS FOR USING THIS TOOL:
1. UNIQUENESS: Context lines MUST uniquely identify the specific sections you want to change
2. PRECISION: All whitespace, indentation, and surrounding code must match exactly
3. VALIDATION: Ensure edits result in idiomatic, correct code
4. PATHS: Always use absolute file paths (starting with /)
The tool will apply all changes in a single atomic operation.`;
const PatchParams = z.object({
patchText: z
.string()
.describe("The full patch text that describes all changes to be made"),
});
interface PatchResponseMetadata {
changed: string[];
additions: number;
removals: number;
}
interface Change {
type: "add" | "update" | "delete";
old_content?: string;
new_content?: string;
}
interface Commit {
changes: Record<string, Change>;
}
interface PatchOperation {
type: "update" | "add" | "delete";
filePath: string;
hunks?: PatchHunk[];
content?: string;
}
interface PatchHunk {
contextLine: string;
changes: PatchChange[];
}
interface PatchChange {
type: "keep" | "remove" | "add";
content: string;
}
function identifyFilesNeeded(patchText: string): string[] {
const files: string[] = [];
const lines = patchText.split("\n");
for (const line of lines) {
if (
line.startsWith("*** Update File:") ||
line.startsWith("*** Delete File:")
) {
const filePath = line.split(":", 2)[1]?.trim();
if (filePath) files.push(filePath);
}
}
return files;
}
function identifyFilesAdded(patchText: string): string[] {
const files: string[] = [];
const lines = patchText.split("\n");
for (const line of lines) {
if (line.startsWith("*** Add File:")) {
const filePath = line.split(":", 2)[1]?.trim();
if (filePath) files.push(filePath);
}
}
return files;
}
function textToPatch(
patchText: string,
_currentFiles: Record<string, string>,
): [PatchOperation[], number] {
const operations: PatchOperation[] = [];
const lines = patchText.split("\n");
let i = 0;
let fuzz = 0;
while (i < lines.length) {
const line = lines[i];
if (line.startsWith("*** Update File:")) {
const filePath = line.split(":", 2)[1]?.trim();
if (!filePath) {
i++;
continue;
}
const hunks: PatchHunk[] = [];
i++;
while (i < lines.length && !lines[i].startsWith("***")) {
if (lines[i].startsWith("@@")) {
const contextLine = lines[i].substring(2).trim();
const changes: PatchChange[] = [];
i++;
while (
i < lines.length &&
!lines[i].startsWith("@@") &&
!lines[i].startsWith("***")
) {
const changeLine = lines[i];
if (changeLine.startsWith(" ")) {
changes.push({ type: "keep", content: changeLine.substring(1) });
} else if (changeLine.startsWith("-")) {
changes.push({
type: "remove",
content: changeLine.substring(1),
});
} else if (changeLine.startsWith("+")) {
changes.push({ type: "add", content: changeLine.substring(1) });
}
i++;
}
hunks.push({ contextLine, changes });
} else {
i++;
}
}
operations.push({ type: "update", filePath, hunks });
} else if (line.startsWith("*** Add File:")) {
const filePath = line.split(":", 2)[1]?.trim();
if (!filePath) {
i++;
continue;
}
let content = "";
i++;
while (i < lines.length && !lines[i].startsWith("***")) {
if (lines[i].startsWith("+")) {
content += lines[i].substring(1) + "\n";
}
i++;
}
operations.push({ type: "add", filePath, content: content.slice(0, -1) });
} else if (line.startsWith("*** Delete File:")) {
const filePath = line.split(":", 2)[1]?.trim();
if (filePath) {
operations.push({ type: "delete", filePath });
}
i++;
} else {
i++;
}
}
return [operations, fuzz];
}
function patchToCommit(
operations: PatchOperation[],
currentFiles: Record<string, string>,
): Commit {
const changes: Record<string, Change> = {};
for (const op of operations) {
if (op.type === "delete") {
changes[op.filePath] = {
type: "delete",
old_content: currentFiles[op.filePath] || "",
};
} else if (op.type === "add") {
changes[op.filePath] = {
type: "add",
new_content: op.content || "",
};
} else if (op.type === "update" && op.hunks) {
const originalContent = currentFiles[op.filePath] || "";
const lines = originalContent.split("\n");
for (const hunk of op.hunks) {
const contextIndex = lines.findIndex((line) =>
line.includes(hunk.contextLine),
);
if (contextIndex === -1) {
throw new Error(`Context line not found: ${hunk.contextLine}`);
}
let currentIndex = contextIndex;
for (const change of hunk.changes) {
if (change.type === "keep") {
currentIndex++;
} else if (change.type === "remove") {
lines.splice(currentIndex, 1);
} else if (change.type === "add") {
lines.splice(currentIndex, 0, change.content);
currentIndex++;
}
}
}
changes[op.filePath] = {
type: "update",
old_content: originalContent,
new_content: lines.join("\n"),
};
}
}
return { changes };
}
function generateDiff(
oldContent: string,
newContent: string,
filePath: string,
): [string, number, number] {
// Mock implementation - would need actual diff generation
const lines1 = oldContent.split("\n");
const lines2 = newContent.split("\n");
const additions = Math.max(0, lines2.length - lines1.length);
const removals = Math.max(0, lines1.length - lines2.length);
return [`--- ${filePath}\n+++ ${filePath}\n`, additions, removals];
}
async function applyCommit(
commit: Commit,
writeFile: (path: string, content: string) => Promise<void>,
deleteFile: (path: string) => Promise<void>,
): Promise<void> {
for (const [filePath, change] of Object.entries(commit.changes)) {
if (change.type === "delete") {
await deleteFile(filePath);
} else if (change.new_content !== undefined) {
await writeFile(filePath, change.new_content);
}
}
}
export const patch = Tool.define({
name: "opencode.patch",
description: DESCRIPTION,
parameters: PatchParams,
execute: async (params) => {
if (!params.patchText) {
throw new Error("patchText is required");
}
// Identify all files needed for the patch and verify they've been read
const filesToRead = identifyFilesNeeded(params.patchText);
for (const filePath of filesToRead) {
let absPath = filePath;
if (!path.isAbsolute(absPath)) {
absPath = path.resolve(process.cwd(), absPath);
}
if (!FileTimes.get(absPath)) {
throw new Error(
`you must read the file ${filePath} before patching it. Use the FileRead tool first`,
);
}
try {
const stats = await fs.stat(absPath);
if (stats.isDirectory()) {
throw new Error(`path is a directory, not a file: ${absPath}`);
}
const lastRead = FileTimes.get(absPath);
if (lastRead && stats.mtime > lastRead) {
throw new Error(
`file ${absPath} has been modified since it was last read (mod time: ${stats.mtime.toISOString()}, last read: ${lastRead.toISOString()})`,
);
}
} catch (error: any) {
if (error.code === "ENOENT") {
throw new Error(`file not found: ${absPath}`);
}
throw new Error(`failed to access file: ${error.message}`);
}
}
// Check for new files to ensure they don't already exist
const filesToAdd = identifyFilesAdded(params.patchText);
for (const filePath of filesToAdd) {
let absPath = filePath;
if (!path.isAbsolute(absPath)) {
absPath = path.resolve(process.cwd(), absPath);
}
try {
await fs.stat(absPath);
throw new Error(`file already exists and cannot be added: ${absPath}`);
} catch (error: any) {
if (error.code !== "ENOENT") {
throw new Error(`failed to check file: ${error.message}`);
}
}
}
// Load all required files
const currentFiles: Record<string, string> = {};
for (const filePath of filesToRead) {
let absPath = filePath;
if (!path.isAbsolute(absPath)) {
absPath = path.resolve(process.cwd(), absPath);
}
try {
const content = await fs.readFile(absPath, "utf-8");
currentFiles[filePath] = content;
} catch (error: any) {
throw new Error(`failed to read file ${absPath}: ${error.message}`);
}
}
// Process the patch
const [patch, fuzz] = textToPatch(params.patchText, currentFiles);
if (fuzz > 3) {
throw new Error(
`patch contains fuzzy matches (fuzz level: ${fuzz}). Please make your context lines more precise`,
);
}
// Convert patch to commit
const commit = patchToCommit(patch, currentFiles);
// Apply the changes to the filesystem
await applyCommit(
commit,
async (filePath: string, content: string) => {
let absPath = filePath;
if (!path.isAbsolute(absPath)) {
absPath = path.resolve(process.cwd(), absPath);
}
// Create parent directories if needed
const dir = path.dirname(absPath);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(absPath, content, "utf-8");
},
async (filePath: string) => {
let absPath = filePath;
if (!path.isAbsolute(absPath)) {
absPath = path.resolve(process.cwd(), absPath);
}
await fs.unlink(absPath);
},
);
// Calculate statistics
const changedFiles: string[] = [];
let totalAdditions = 0;
let totalRemovals = 0;
for (const [filePath, change] of Object.entries(commit.changes)) {
let absPath = filePath;
if (!path.isAbsolute(absPath)) {
absPath = path.resolve(process.cwd(), absPath);
}
changedFiles.push(absPath);
const oldContent = change.old_content || "";
const newContent = change.new_content || "";
// Calculate diff statistics
const [, additions, removals] = generateDiff(
oldContent,
newContent,
filePath,
);
totalAdditions += additions;
totalRemovals += removals;
// Record file operations
FileTimes.write(absPath);
FileTimes.read(absPath);
}
const result = `Patch applied successfully. ${changedFiles.length} files changed, ${totalAdditions} additions, ${totalRemovals} removals`;
const output = result;
return {
metadata: {
changed: changedFiles,
additions: totalAdditions,
removals: totalRemovals,
} satisfies PatchResponseMetadata,
output,
};
},
});

View File

@@ -0,0 +1,61 @@
import { tool, type Tool as AITool } from "ai";
import { Log } from "../util/log";
const log = Log.create({ service: "tool" });
export namespace Tool {
export interface Metadata<
Properties extends Record<string, any> = Record<string, any>,
> {
properties: Properties;
time: {
start: number;
end: number;
};
}
export function define<
Params,
Output extends { metadata?: any; output: any },
Name extends string,
>(
input: AITool<Params, Output> & {
name: Name;
},
) {
return tool({
...input,
execute: async (params, opts) => {
log.info("invoking", {
id: opts.toolCallId,
name: input.name,
...params,
});
try {
const start = Date.now();
const result = await input.execute!(params, opts);
const metadata: Metadata<Output["metadata"]> = {
...result.metadata,
time: {
start,
end: Date.now(),
},
};
return {
metadata,
output: result.output,
};
} catch (e: any) {
log.error("error", {
msg: e.toString(),
});
return {
metadata: {
error: true,
},
output: "An error occurred: " + e.toString(),
};
}
},
});
}
}

View File

@@ -0,0 +1,20 @@
import { App } from "../../app/app";
export namespace FileTimes {
export const state = App.state("tool.filetimes", () => ({
read: new Map<string, Date>(),
write: new Map<string, Date>(),
}));
export function read(filePath: string) {
state().read.set(filePath, new Date());
}
export function write(filePath: string) {
state().write.set(filePath, new Date());
}
export function get(filePath: string): Date | null {
return state().read.get(filePath) || null;
}
}

View File

@@ -0,0 +1,152 @@
import { z } from "zod";
import * as fs from "fs";
import * as path from "path";
import { Tool } from "./tool";
import { LSP } from "../lsp";
import { FileTimes } from "./util/file-times";
const MAX_READ_SIZE = 250 * 1024;
const DEFAULT_READ_LIMIT = 2000;
const MAX_LINE_LENGTH = 2000;
const DESCRIPTION = `File viewing tool that reads and displays the contents of files with line numbers, allowing you to examine code, logs, or text data.
WHEN TO USE THIS TOOL:
- Use when you need to read the contents of a specific file
- Helpful for examining source code, configuration files, or log files
- Perfect for looking at text-based file formats
HOW TO USE:
- Provide the path to the file you want to view
- Optionally specify an offset to start reading from a specific line
- Optionally specify a limit to control how many lines are read
FEATURES:
- Displays file contents with line numbers for easy reference
- Can read from any position in a file using the offset parameter
- Handles large files by limiting the number of lines read
- Automatically truncates very long lines for better display
- Suggests similar file names when the requested file isn't found
LIMITATIONS:
- Maximum file size is 250KB
- Default reading limit is 2000 lines
- Lines longer than 2000 characters are truncated
- Cannot display binary files or images
- Images can be identified but not displayed
TIPS:
- Use with Glob tool to first find files you want to view
- For code exploration, first use Grep to find relevant files, then View to examine them
- When viewing large files, use the offset parameter to read specific sections`;
export const view = Tool.define({
name: "opencode.view",
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The path to the file to read"),
offset: z
.number()
.describe("The line number to start reading from (0-based)")
.optional(),
limit: z
.number()
.describe("The number of lines to read (defaults to 2000)")
.optional(),
}),
async execute(params) {
let filePath = params.filePath;
if (!path.isAbsolute(filePath)) {
filePath = path.join(process.cwd(), filePath);
}
const file = Bun.file(filePath);
if (!(await file.exists())) {
const dir = path.dirname(filePath);
const base = path.basename(filePath);
const dirEntries = fs.readdirSync(dir);
const suggestions = dirEntries
.filter(
(entry) =>
entry.toLowerCase().includes(base.toLowerCase()) ||
base.toLowerCase().includes(entry.toLowerCase()),
)
.map((entry) => path.join(dir, entry))
.slice(0, 3);
if (suggestions.length > 0) {
throw new Error(
`File not found: ${filePath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`,
);
}
throw new Error(`File not found: ${filePath}`);
}
const stats = await file.stat();
if (stats.size > MAX_READ_SIZE)
throw new Error(
`File is too large (${stats.size} bytes). Maximum size is ${MAX_READ_SIZE} bytes`,
);
const limit = params.limit ?? DEFAULT_READ_LIMIT;
const offset = params.offset || 0;
const isImage = isImageFile(filePath);
if (isImage)
throw new Error(
`This is an image file of type: ${isImage}\nUse a different tool to process images`,
);
const lines = await file.text().then((text) => text.split("\n"));
const raw = lines.slice(offset, offset + limit).map((line) => {
return line.length > MAX_LINE_LENGTH
? line.substring(0, MAX_LINE_LENGTH) + "..."
: line;
});
const content = raw.map((line, index) => {
return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}`;
});
const preview = raw.slice(0, 20).join("\n");
let output = "<file>\n";
output += content.join("\n");
if (lines.length > offset + content.length) {
output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${
offset + content.length
})`;
}
output += "\n</file>";
// just warms the lsp client
LSP.file(filePath);
FileTimes.read(filePath);
return {
output,
metadata: {
preview,
},
};
},
});
function isImageFile(filePath: string): string | false {
const ext = path.extname(filePath).toLowerCase();
switch (ext) {
case ".jpg":
case ".jpeg":
return "JPEG";
case ".png":
return "PNG";
case ".gif":
return "GIF";
case ".bmp":
return "BMP";
case ".svg":
return "SVG";
case ".webp":
return "WebP";
default:
return false;
}
}

View File

@@ -0,0 +1,25 @@
import { AsyncLocalStorage } from "async_hooks";
export namespace Context {
export class NotFound extends Error {
constructor(public readonly name: string) {
super(`No context found for ${name}`);
}
}
export function create<T>(name: string) {
const storage = new AsyncLocalStorage<T>();
return {
use() {
const result = storage.getStore();
if (!result) {
throw new NotFound(name);
}
return result;
},
provide<R>(value: T, fn: () => R) {
return storage.run<R>(value, fn);
},
};
}
}

View File

View File

@@ -0,0 +1,64 @@
import path from "path";
import { AppPath } from "../app/path";
import fs from "fs/promises";
export namespace Log {
const write = {
out: (msg: string) => {
process.stdout.write(msg);
},
err: (msg: string) => {
process.stderr.write(msg);
},
};
export async function file(directory: string) {
const outPath = path.join(AppPath.data(directory), "opencode.out.log");
const errPath = path.join(AppPath.data(directory), "opencode.err.log");
await fs.truncate(outPath).catch(() => {});
await fs.truncate(errPath).catch(() => {});
const out = Bun.file(outPath);
const err = Bun.file(errPath);
const outWriter = out.writer();
const errWriter = err.writer();
write["out"] = (msg) => {
outWriter.write(msg);
outWriter.flush();
};
write["err"] = (msg) => {
errWriter.write(msg);
errWriter.flush();
};
}
export function create(tags?: Record<string, any>) {
tags = tags || {};
function build(message: any, extra?: Record<string, any>) {
const prefix = Object.entries({
...tags,
...extra,
})
.filter(([_, value]) => value !== undefined && value !== null)
.map(([key, value]) => `${key}=${value}`)
.join(" ");
return [new Date().toISOString(), prefix, message].filter(Boolean).join(" ") + "\n";
}
const result = {
info(message?: any, extra?: Record<string, any>) {
write.out(build(message, extra));
},
error(message?: any, extra?: Record<string, any>) {
write.err(build(message, extra));
},
tag(key: string, value: string) {
if (tags) tags[key] = value;
return result;
},
clone() {
return Log.create({ ...tags });
},
};
return result;
}
}

View File

@@ -0,0 +1,5 @@
export const foo: string = "42";
export function dummyFunction(): void {
console.log("This is a dummy function");
}

View File

@@ -0,0 +1,17 @@
// Bun Snapshot v1, https://goo.gl/fbAQLP
exports[`tool.ls basic 1`] = `
"- /home/thdxr/dev/projects/sst/opencode/js/example/
- home/
- thdxr/
- dev/
- projects/
- sst/
- opencode/
- js/
- example/
- ink.tsx
- broken.ts
- cli.ts
"
`;

View File

@@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test";
import { App } from "../../src/app/app";
import { glob } from "../../src/tool/glob";
import { ls } from "../../src/tool/ls";
describe("tool.glob", () => {
test("truncate", async () => {
await App.provide({ directory: process.cwd() }, async () => {
let result = await glob.execute(
{
pattern: "./node_modules/**/*",
},
{
toolCallId: "test",
messages: [],
},
);
expect(result.metadata.truncated).toBe(true);
});
});
test("basic", async () => {
await App.provide({ directory: process.cwd() }, async () => {
let result = await glob.execute(
{
pattern: "*.json",
},
{
toolCallId: "test",
messages: [],
},
);
expect(result.metadata).toMatchObject({
truncated: false,
count: 2,
});
});
});
});
describe("tool.ls", () => {
test("basic", async () => {
const result = await App.provide({ directory: process.cwd() }, async () => {
return await ls.execute(
{
path: "./example",
},
{
toolCallId: "test",
messages: [],
},
);
});
expect(result.output).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {}
}

View File

@@ -0,0 +1,77 @@
version: 2
project_name: opencode
before:
hooks:
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- darwin
goarch:
- amd64
- arm64
ldflags:
- -s -w -X github.com/sst/opencode/internal/version.Version={{.Version}}
main: ./main.go
archives:
- format: tar.gz
name_template: >-
opencode-
{{- if eq .Os "darwin" }}mac-
{{- else if eq .Os "windows" }}windows-
{{- else if eq .Os "linux" }}linux-{{end}}
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "#86" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
format_overrides:
- goos: windows
format: zip
checksum:
name_template: "checksums.txt"
snapshot:
name_template: "0.0.0-{{ .Timestamp }}"
aurs:
- name: opencode
homepage: "https://github.com/sst/opencode"
description: "terminal based agent that can build anything"
maintainers:
- "dax"
- "adam"
license: "MIT"
private_key: "{{ .Env.AUR_KEY }}"
git_url: "ssh://aur@aur.archlinux.org/opencode-bin.git"
provides:
- opencode
conflicts:
- opencode
package: |-
install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode"
brews:
- repository:
owner: sst
name: homebrew-tap
nfpms:
- maintainer: kujtimiihoxha
description: terminal based agent that can build anything
formats:
- deb
- rpm
file_name_template: >-
{{ .ProjectName }}-
{{- if eq .Os "darwin" }}mac
{{- else }}{{ .Os }}{{ end }}-{{ .Arch }}
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^doc:"
- "^test:"
- "^ci:"
- "^ignore:"
- "^example:"
- "^wip:"

8
packages/tui/app.log Normal file
View File

@@ -0,0 +1,8 @@
time=2025-05-30T19:37:27.576-04:00 level=DEBUG msg="Set theme from config" theme=opencode
time=2025-05-30T19:37:27.580-04:00 level=INFO msg="Reading directory: /home/thdxr"
time=2025-05-30T19:37:29.815-04:00 level=INFO msg="Cancelling all subscriptions"
time=2025-05-30T19:37:29.815-04:00 level=INFO msg="subscription cancelled" name=status
time=2025-05-30T19:37:29.815-04:00 level=INFO msg="All subscription goroutines completed successfully"
time=2025-05-30T19:37:29.815-04:00 level=INFO msg="TUI message channel closed"
time=2025-05-30T19:37:29.815-04:00 level=INFO msg="All goroutines cleaned up"
time=2025-05-30T19:37:29.815-04:00 level=INFO msg="TUI exited" result="{width:272 height:73 currentPage:chat previousPage: pages:map[chat:0xc0002c4280] loadedPages:map[chat:true] status:{app:0xc0002aa690 queue:[] width:272 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002aa690 showPermissions:false permissions:0xc000279408 showHelp:false help:0xc00052da10 showQuit:true quit:0xc0004761f9 showSessionDialog:false sessionDialog:0xc0000adcc0 showCommandDialog:false commandDialog:0xc000429500 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc000261860 showInitDialog:true initDialog:{width:272 height:73 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d6c88 showThemeDialog:false themeDialog:0xc0000adf00 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000adf40}"

258
packages/tui/cmd/root.go Normal file
View File

@@ -0,0 +1,258 @@
package cmd
import (
"context"
"fmt"
"os"
"sync"
"time"
"log/slog"
tea "github.com/charmbracelet/bubbletea"
zone "github.com/lrstanley/bubblezone"
"github.com/spf13/cobra"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/pubsub"
"github.com/sst/opencode/internal/tui"
"github.com/sst/opencode/internal/tui/app"
"github.com/sst/opencode/internal/version"
)
var rootCmd = &cobra.Command{
Use: "OpenCode",
Short: "A terminal AI assistant for software development",
Long: `OpenCode is a powerful terminal-based AI assistant that helps with software development tasks.
It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
to assist developers in writing, debugging, and understanding code directly from the terminal.`,
RunE: func(cmd *cobra.Command, args []string) error {
// If the help flag is set, show the help message
if cmd.Flag("help").Changed {
cmd.Help()
return nil
}
if cmd.Flag("version").Changed {
fmt.Println(version.Version)
return nil
}
// Setup logging
file, err := os.OpenFile("app.log", os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
if err != nil {
panic(err)
}
defer file.Close()
logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
slog.SetDefault(logger)
// Load the config
debug, _ := cmd.Flags().GetBool("debug")
cwd, _ := cmd.Flags().GetString("cwd")
if cwd != "" {
err := os.Chdir(cwd)
if err != nil {
return fmt.Errorf("failed to change directory: %v", err)
}
}
if cwd == "" {
c, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current working directory: %v", err)
}
cwd = c
}
_, err = config.Load(cwd, debug)
if err != nil {
return err
}
// Create main context for the application
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
app, err := app.New(ctx)
if err != nil {
slog.Error("Failed to create app", "error", err)
return err
}
// Set up the TUI
zone.NewGlobal()
program := tea.NewProgram(
tui.New(app),
tea.WithAltScreen(),
)
evts, err := app.Events.Event(ctx)
if err != nil {
slog.Error("Failed to subscribe to events", "error", err)
return err
}
go func() {
for item := range evts {
program.Send(item)
}
}()
// Setup the subscriptions, this will send services events to the TUI
ch, cancelSubs := setupSubscriptions(app, ctx)
// Create a context for the TUI message handler
tuiCtx, tuiCancel := context.WithCancel(ctx)
var tuiWg sync.WaitGroup
tuiWg.Add(1)
// Set up message handling for the TUI
go func() {
defer tuiWg.Done()
// defer logging.RecoverPanic("TUI-message-handler", func() {
// attemptTUIRecovery(program)
// })
for {
select {
case <-tuiCtx.Done():
slog.Info("TUI message handler shutting down")
return
case msg, ok := <-ch:
if !ok {
slog.Info("TUI message channel closed")
return
}
program.Send(msg)
}
}
}()
// Cleanup function for when the program exits
cleanup := func() {
// Cancel subscriptions first
cancelSubs()
// Then shutdown the app
app.Shutdown()
// Then cancel TUI message handler
tuiCancel()
// Wait for TUI message handler to finish
tuiWg.Wait()
slog.Info("All goroutines cleaned up")
}
// Run the TUI
result, err := program.Run()
cleanup()
if err != nil {
slog.Error("TUI error", "error", err)
return fmt.Errorf("TUI error: %v", err)
}
slog.Info("TUI exited", "result", result)
return nil
},
}
func setupSubscriber[T any](
ctx context.Context,
wg *sync.WaitGroup,
name string,
subscriber func(context.Context) <-chan pubsub.Event[T],
outputCh chan<- tea.Msg,
) {
wg.Add(1)
go func() {
defer wg.Done()
// defer logging.RecoverPanic(fmt.Sprintf("subscription-%s", name), nil)
subCh := subscriber(ctx)
if subCh == nil {
slog.Warn("subscription channel is nil", "name", name)
return
}
for {
select {
case event, ok := <-subCh:
if !ok {
slog.Info("subscription channel closed", "name", name)
return
}
var msg tea.Msg = event
select {
case outputCh <- msg:
case <-time.After(2 * time.Second):
slog.Warn("message dropped due to slow consumer", "name", name)
case <-ctx.Done():
slog.Info("subscription cancelled", "name", name)
return
}
case <-ctx.Done():
slog.Info("subscription cancelled", "name", name)
return
}
}
}()
}
func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg, func()) {
ch := make(chan tea.Msg, 100)
wg := sync.WaitGroup{}
ctx, cancel := context.WithCancel(parentCtx) // Inherit from parent context
setupSubscriber(ctx, &wg, "status", app.Status.Subscribe, ch)
cleanupFunc := func() {
slog.Info("Cancelling all subscriptions")
cancel() // Signal all goroutines to stop
waitCh := make(chan struct{})
go func() {
// defer logging.RecoverPanic("subscription-cleanup", nil)
wg.Wait()
close(waitCh)
}()
select {
case <-waitCh:
slog.Info("All subscription goroutines completed successfully")
close(ch) // Only close after all writers are confirmed done
case <-time.After(5 * time.Second):
slog.Warn("Timed out waiting for some subscription goroutines to complete")
close(ch)
}
}
return ch, cleanupFunc
}
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
rootCmd.Flags().BoolP("help", "h", false, "Help")
rootCmd.Flags().BoolP("version", "v", false, "Version")
rootCmd.Flags().BoolP("debug", "d", false, "Debug")
rootCmd.Flags().StringP("cwd", "c", "", "Current working directory")
rootCmd.Flags().StringP("prompt", "p", "", "Run a single prompt in non-interactive mode")
rootCmd.Flags().StringP("output-format", "f", "text", "Output format for non-interactive mode (text, json)")
rootCmd.Flags().BoolP("quiet", "q", false, "Hide spinner in non-interactive mode")
rootCmd.Flags().BoolP("verbose", "", false, "Display logs to stderr in non-interactive mode")
rootCmd.Flags().StringSlice("allowedTools", nil, "Restrict the agent to only use the specified tools in non-interactive mode (comma-separated list)")
rootCmd.Flags().StringSlice("excludedTools", nil, "Prevent the agent from using the specified tools in non-interactive mode (comma-separated list)")
// Make allowedTools and excludedTools mutually exclusive
rootCmd.MarkFlagsMutuallyExclusive("allowedTools", "excludedTools")
// Make quiet and verbose mutually exclusive
rootCmd.MarkFlagsMutuallyExclusive("quiet", "verbose")
}

105
packages/tui/go.mod Normal file
View File

@@ -0,0 +1,105 @@
module github.com/sst/opencode
go 1.24.0
require (
github.com/alecthomas/chroma/v2 v2.15.0
github.com/aymanbagabas/go-udiff v0.2.0
github.com/bmatcuk/doublestar/v4 v4.8.1
github.com/catppuccin/go v0.3.0
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.4
github.com/charmbracelet/glamour v0.9.1
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/ansi v0.8.0
github.com/lithammer/fuzzysearch v1.1.8
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.16.0
github.com/oapi-codegen/runtime v1.1.1
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.0
github.com/stretchr/testify v1.10.0
rsc.io/qr v0.2.0
)
require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
require (
dario.cat/mergo v1.0.2 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/atombender/go-jsonschema v0.20.0 // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/getkin/kin-openapi v0.127.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/goccy/go-yaml v1.17.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/invopop/yaml v0.3.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sanity-io/litter v1.5.8 // indirect
github.com/sosodev/duration v1.3.1 // indirect
github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/tools v0.31.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
require (
github.com/atotto/clipboard v0.1.4
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/disintegration/imaging v1.6.2
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/image v0.26.0
golang.org/x/net v0.39.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/term v0.31.0 // indirect
golang.org/x/text v0.24.0
gopkg.in/yaml.v3 v3.0.1 // indirect
)
tool (
github.com/atombender/go-jsonschema
github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
)

338
packages/tui/go.sum Normal file
View File

@@ -0,0 +1,338 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY=
github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM=
github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/getkin/kin-openapi v0.127.0 h1:Mghqi3Dhryf3F8vR370nN67pAERW+3a95vomb3MAREY=
github.com/getkin/kin-openapi v0.127.0/go.mod h1:OZrfXzUfGrNbsKj+xmFBx6E5c6yH3At/tAKSc2UszXM=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms=
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q=
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8=
github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg=
github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/speakeasy-api/openapi-overlay v0.9.0 h1:Wrz6NO02cNlLzx1fB093lBlYxSI54VRhy1aSutx0PQg=
github.com/speakeasy-api/openapi-overlay v0.9.0/go.mod h1:f5FloQrHA7MsxYg9djzMD5h6dxrHjVVByWKh7an8TRc=
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=

View File

@@ -0,0 +1,191 @@
package completions
import (
"bytes"
"fmt"
"os/exec"
"path/filepath"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/sst/opencode/internal/fileutil"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/tui/components/dialog"
)
type filesAndFoldersContextGroup struct {
prefix string
}
func (cg *filesAndFoldersContextGroup) GetId() string {
return cg.prefix
}
func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
return dialog.NewCompletionItem(dialog.CompletionItem{
Title: "Files & Folders",
Value: "files",
})
}
func processNullTerminatedOutput(outputBytes []byte) []string {
if len(outputBytes) > 0 && outputBytes[len(outputBytes)-1] == 0 {
outputBytes = outputBytes[:len(outputBytes)-1]
}
if len(outputBytes) == 0 {
return []string{}
}
split := bytes.Split(outputBytes, []byte{0})
matches := make([]string, 0, len(split))
for _, p := range split {
if len(p) == 0 {
continue
}
path := string(p)
path = filepath.Join(".", path)
if !fileutil.SkipHidden(path) {
matches = append(matches, path)
}
}
return matches
}
func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) {
cmdRg := fileutil.GetRgCmd("") // No glob pattern for this use case
cmdFzf := fileutil.GetFzfCmd(query)
var matches []string
// Case 1: Both rg and fzf available
if cmdRg != nil && cmdFzf != nil {
rgPipe, err := cmdRg.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("failed to get rg stdout pipe: %w", err)
}
defer rgPipe.Close()
cmdFzf.Stdin = rgPipe
var fzfOut bytes.Buffer
var fzfErr bytes.Buffer
cmdFzf.Stdout = &fzfOut
cmdFzf.Stderr = &fzfErr
if err := cmdFzf.Start(); err != nil {
return nil, fmt.Errorf("failed to start fzf: %w", err)
}
errRg := cmdRg.Run()
errFzf := cmdFzf.Wait()
if errRg != nil {
status.Warn(fmt.Sprintf("rg command failed during pipe: %v", errRg))
}
if errFzf != nil {
if exitErr, ok := errFzf.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return []string{}, nil // No matches from fzf
}
return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", errFzf, fzfErr.String())
}
matches = processNullTerminatedOutput(fzfOut.Bytes())
// Case 2: Only rg available
} else if cmdRg != nil {
status.Debug("Using Ripgrep with fuzzy match fallback for file completions")
var rgOut bytes.Buffer
var rgErr bytes.Buffer
cmdRg.Stdout = &rgOut
cmdRg.Stderr = &rgErr
if err := cmdRg.Run(); err != nil {
return nil, fmt.Errorf("rg command failed: %w\nStderr: %s", err, rgErr.String())
}
allFiles := processNullTerminatedOutput(rgOut.Bytes())
matches = fuzzy.Find(query, allFiles)
// Case 3: Only fzf available
} else if cmdFzf != nil {
status.Debug("Using FZF with doublestar fallback for file completions")
files, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0)
if err != nil {
return nil, fmt.Errorf("failed to list files for fzf: %w", err)
}
allFiles := make([]string, 0, len(files))
for _, file := range files {
if !fileutil.SkipHidden(file) {
allFiles = append(allFiles, file)
}
}
var fzfIn bytes.Buffer
for _, file := range allFiles {
fzfIn.WriteString(file)
fzfIn.WriteByte(0)
}
cmdFzf.Stdin = &fzfIn
var fzfOut bytes.Buffer
var fzfErr bytes.Buffer
cmdFzf.Stdout = &fzfOut
cmdFzf.Stderr = &fzfErr
if err := cmdFzf.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return []string{}, nil
}
return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", err, fzfErr.String())
}
matches = processNullTerminatedOutput(fzfOut.Bytes())
// Case 4: Fallback to doublestar with fuzzy match
} else {
status.Debug("Using doublestar with fuzzy match for file completions")
allFiles, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0)
if err != nil {
return nil, fmt.Errorf("failed to glob files: %w", err)
}
filteredFiles := make([]string, 0, len(allFiles))
for _, file := range allFiles {
if !fileutil.SkipHidden(file) {
filteredFiles = append(filteredFiles, file)
}
}
matches = fuzzy.Find(query, filteredFiles)
}
return matches, nil
}
func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
matches, err := cg.getFiles(query)
if err != nil {
return nil, err
}
items := make([]dialog.CompletionItemI, 0, len(matches))
for _, file := range matches {
item := dialog.NewCompletionItem(dialog.CompletionItem{
Title: file,
Value: file,
})
items = append(items, item)
}
return items, nil
}
func NewFileAndFolderContextGroup() dialog.CompletionProvider {
return &filesAndFoldersContextGroup{
prefix: "file",
}
}

View File

@@ -0,0 +1,266 @@
// Package config manages application configuration from various sources.
package config
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"os/user"
"path/filepath"
"strings"
"github.com/spf13/viper"
)
// Data defines storage configuration.
type Data struct {
Directory string `json:"directory,omitempty"`
}
// TUIConfig defines the configuration for the Terminal User Interface.
type TUIConfig struct {
Theme string `json:"theme,omitempty"`
CustomTheme map[string]any `json:"customTheme,omitempty"`
}
// ShellConfig defines the configuration for the shell used by the bash tool.
type ShellConfig struct {
Path string `json:"path,omitempty"`
Args []string `json:"args,omitempty"`
}
// Config is the main configuration structure for the application.
type Config struct {
Data Data `json:"data"`
WorkingDir string `json:"wd,omitempty"`
Debug bool `json:"debug,omitempty"`
DebugLSP bool `json:"debugLSP,omitempty"`
ContextPaths []string `json:"contextPaths,omitempty"`
TUI TUIConfig `json:"tui"`
Shell ShellConfig `json:"shell,omitempty"`
}
// Application constants
const (
defaultDataDirectory = ".opencode"
defaultLogLevel = "info"
appName = "opencode"
MaxTokensFallbackDefault = 4096
)
var defaultContextPaths = []string{
".github/copilot-instructions.md",
".cursorrules",
".cursor/rules/",
"CLAUDE.md",
"CLAUDE.local.md",
"CONTEXT.md",
"CONTEXT.local.md",
"opencode.md",
"opencode.local.md",
"OpenCode.md",
"OpenCode.local.md",
"OPENCODE.md",
"OPENCODE.local.md",
}
// Global configuration instance
var cfg *Config
// Load initializes the configuration from environment variables and config files.
// If debug is true, debug mode is enabled and log level is set to debug.
// It returns an error if configuration loading fails.
func Load(workingDir string, debug bool) (*Config, error) {
if cfg != nil {
return cfg, nil
}
cfg = &Config{
WorkingDir: workingDir,
}
configureViper()
setDefaults(debug)
// Read global config
if err := readConfig(viper.ReadInConfig()); err != nil {
return cfg, err
}
// Load and merge local config
mergeLocalConfig(workingDir)
// Apply configuration to the struct
if err := viper.Unmarshal(cfg); err != nil {
return cfg, fmt.Errorf("failed to unmarshal config: %w", err)
}
defaultLevel := slog.LevelInfo
if cfg.Debug {
defaultLevel = slog.LevelDebug
}
slog.SetLogLoggerLevel(defaultLevel)
// Validate configuration
if err := Validate(); err != nil {
return cfg, fmt.Errorf("config validation failed: %w", err)
}
return cfg, nil
}
// configureViper sets up viper's configuration paths and environment variables.
func configureViper() {
viper.SetConfigName(fmt.Sprintf(".%s", appName))
viper.SetConfigType("json")
viper.AddConfigPath("$HOME")
viper.AddConfigPath(fmt.Sprintf("$XDG_CONFIG_HOME/%s", appName))
viper.AddConfigPath(fmt.Sprintf("$HOME/.config/%s", appName))
viper.SetEnvPrefix(strings.ToUpper(appName))
viper.AutomaticEnv()
}
// setDefaults configures default values for configuration options.
func setDefaults(debug bool) {
viper.SetDefault("data.directory", defaultDataDirectory)
viper.SetDefault("contextPaths", defaultContextPaths)
viper.SetDefault("tui.theme", "opencode")
if debug {
viper.SetDefault("debug", true)
viper.Set("log.level", "debug")
} else {
viper.SetDefault("debug", false)
viper.SetDefault("log.level", defaultLogLevel)
}
}
// readConfig handles the result of reading a configuration file.
func readConfig(err error) error {
if err == nil {
return nil
}
// It's okay if the config file doesn't exist
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
return nil
}
return fmt.Errorf("failed to read config: %w", err)
}
// mergeLocalConfig loads and merges configuration from the local directory.
func mergeLocalConfig(workingDir string) {
local := viper.New()
local.SetConfigName(fmt.Sprintf(".%s", appName))
local.SetConfigType("json")
local.AddConfigPath(workingDir)
// Merge local config if it exists
if err := local.ReadInConfig(); err == nil {
viper.MergeConfigMap(local.AllSettings())
}
}
// Validate checks if the configuration is valid and applies defaults where needed.
func Validate() error {
if cfg == nil {
return fmt.Errorf("config not loaded")
}
return nil
}
// Get returns the current configuration.
// It's safe to call this function multiple times.
func Get() *Config {
return cfg
}
// WorkingDirectory returns the current working directory from the configuration.
func WorkingDirectory() string {
if cfg == nil {
panic("config not loaded")
}
return cfg.WorkingDir
}
// GetHostname returns the system hostname or "User" if it can't be determined
func GetHostname() (string, error) {
hostname, err := os.Hostname()
if err != nil {
return "User", err
}
return hostname, nil
}
// GetUsername returns the current user's username
func GetUsername() (string, error) {
currentUser, err := user.Current()
if err != nil {
return "User", err
}
return currentUser.Username, nil
}
func updateCfgFile(updateCfg func(config *Config)) error {
if cfg == nil {
return fmt.Errorf("config not loaded")
}
// Get the config file path
configFile := viper.ConfigFileUsed()
var configData []byte
if configFile == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
}
configFile = filepath.Join(homeDir, fmt.Sprintf(".%s.json", appName))
slog.Info("config file not found, creating new one", "path", configFile)
configData = []byte(`{}`)
} else {
// Read the existing config file
data, err := os.ReadFile(configFile)
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
}
configData = data
}
// Parse the JSON
var userCfg *Config
if err := json.Unmarshal(configData, &userCfg); err != nil {
return fmt.Errorf("failed to parse config file: %w", err)
}
updateCfg(userCfg)
// Write the updated config back to file
updatedData, err := json.MarshalIndent(userCfg, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
if err := os.WriteFile(configFile, updatedData, 0o644); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
return nil
}
// UpdateTheme updates the theme in the configuration and writes it to the config file.
func UpdateTheme(themeName string) error {
if cfg == nil {
return fmt.Errorf("config not loaded")
}
// Update the in-memory config
cfg.TUI.Theme = themeName
// Update the file config
return updateCfgFile(func(config *Config) {
config.TUI.Theme = themeName
})
}

View File

@@ -0,0 +1,60 @@
package config
import (
"fmt"
"os"
"path/filepath"
)
const (
// InitFlagFilename is the name of the file that indicates whether the project has been initialized
InitFlagFilename = "init"
)
// ProjectInitFlag represents the initialization status for a project directory
type ProjectInitFlag struct {
Initialized bool `json:"initialized"`
}
// ShouldShowInitDialog checks if the initialization dialog should be shown for the current directory
func ShouldShowInitDialog() (bool, error) {
if cfg == nil {
return false, fmt.Errorf("config not loaded")
}
// Create the flag file path
flagFilePath := filepath.Join(cfg.Data.Directory, InitFlagFilename)
// Check if the flag file exists
_, err := os.Stat(flagFilePath)
if err == nil {
// File exists, don't show the dialog
return false, nil
}
// If the error is not "file not found", return the error
if !os.IsNotExist(err) {
return false, fmt.Errorf("failed to check init flag file: %w", err)
}
// File doesn't exist, show the dialog
return true, nil
}
// MarkProjectInitialized marks the current project as initialized
func MarkProjectInitialized() error {
if cfg == nil {
return fmt.Errorf("config not loaded")
}
// Create the flag file path
flagFilePath := filepath.Join(cfg.Data.Directory, InitFlagFilename)
// Create an empty file to mark the project as initialized
file, err := os.Create(flagFilePath)
if err != nil {
return fmt.Errorf("failed to create init flag file: %w", err)
}
defer file.Close()
return nil
}

View File

@@ -0,0 +1,869 @@
package diff
import (
"bytes"
"fmt"
"io"
"regexp"
"strconv"
"strings"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
"github.com/aymanbagabas/go-udiff"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/sergi/go-diff/diffmatchpatch"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/tui/theme"
)
// -------------------------------------------------------------------------
// Core Types
// -------------------------------------------------------------------------
// LineType represents the kind of line in a diff.
type LineType int
const (
LineContext LineType = iota // Line exists in both files
LineAdded // Line added in the new file
LineRemoved // Line removed from the old file
)
// Segment represents a portion of a line for intra-line highlighting
type Segment struct {
Start int
End int
Type LineType
Text string
}
// DiffLine represents a single line in a diff
type DiffLine struct {
OldLineNo int // Line number in old file (0 for added lines)
NewLineNo int // Line number in new file (0 for removed lines)
Kind LineType // Type of line (added, removed, context)
Content string // Content of the line
Segments []Segment // Segments for intraline highlighting
}
// Hunk represents a section of changes in a diff
type Hunk struct {
Header string
Lines []DiffLine
}
// DiffResult contains the parsed result of a diff
type DiffResult struct {
OldFile string
NewFile string
Hunks []Hunk
}
// linePair represents a pair of lines for side-by-side display
type linePair struct {
left *DiffLine
right *DiffLine
}
// -------------------------------------------------------------------------
// Parse Configuration
// -------------------------------------------------------------------------
// ParseConfig configures the behavior of diff parsing
type ParseConfig struct {
ContextSize int // Number of context lines to include
}
// ParseOption modifies a ParseConfig
type ParseOption func(*ParseConfig)
// WithContextSize sets the number of context lines to include
func WithContextSize(size int) ParseOption {
return func(p *ParseConfig) {
if size >= 0 {
p.ContextSize = size
}
}
}
// -------------------------------------------------------------------------
// 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
}
}
}
// -------------------------------------------------------------------------
// Diff Parsing
// -------------------------------------------------------------------------
// ParseUnifiedDiff parses a unified diff format string into structured data
func ParseUnifiedDiff(diff string) (DiffResult, error) {
var result DiffResult
var currentHunk *Hunk
hunkHeaderRe := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`)
lines := strings.Split(diff, "\n")
var oldLine, newLine int
inFileHeader := true
for _, line := range lines {
// Parse file headers
if inFileHeader {
if strings.HasPrefix(line, "--- a/") {
result.OldFile = strings.TrimPrefix(line, "--- a/")
continue
}
if strings.HasPrefix(line, "+++ b/") {
result.NewFile = strings.TrimPrefix(line, "+++ b/")
inFileHeader = false
continue
}
}
// Parse hunk headers
if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil {
if currentHunk != nil {
result.Hunks = append(result.Hunks, *currentHunk)
}
currentHunk = &Hunk{
Header: line,
Lines: []DiffLine{},
}
oldStart, _ := strconv.Atoi(matches[1])
newStart, _ := strconv.Atoi(matches[3])
oldLine = oldStart
newLine = newStart
continue
}
// Ignore "No newline at end of file" markers
if strings.HasPrefix(line, "\\ No newline at end of file") {
continue
}
if currentHunk == nil {
continue
}
// Process the line based on its prefix
if len(line) > 0 {
switch line[0] {
case '+':
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
OldLineNo: 0,
NewLineNo: newLine,
Kind: LineAdded,
Content: line[1:],
})
newLine++
case '-':
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
OldLineNo: oldLine,
NewLineNo: 0,
Kind: LineRemoved,
Content: line[1:],
})
oldLine++
default:
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
OldLineNo: oldLine,
NewLineNo: newLine,
Kind: LineContext,
Content: line,
})
oldLine++
newLine++
}
} else {
// Handle empty lines
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
OldLineNo: oldLine,
NewLineNo: newLine,
Kind: LineContext,
Content: "",
})
oldLine++
newLine++
}
}
// Add the last hunk if there is one
if currentHunk != nil {
result.Hunks = append(result.Hunks, *currentHunk)
}
return result, nil
}
// HighlightIntralineChanges updates lines in a hunk to show character-level differences
func HighlightIntralineChanges(h *Hunk) {
var updated []DiffLine
dmp := diffmatchpatch.New()
for i := 0; i < len(h.Lines); i++ {
// Look for removed line followed by added line
if i+1 < len(h.Lines) &&
h.Lines[i].Kind == LineRemoved &&
h.Lines[i+1].Kind == LineAdded {
oldLine := h.Lines[i]
newLine := h.Lines[i+1]
// Find character-level differences
patches := dmp.DiffMain(oldLine.Content, newLine.Content, false)
patches = dmp.DiffCleanupSemantic(patches)
patches = dmp.DiffCleanupMerge(patches)
patches = dmp.DiffCleanupEfficiency(patches)
segments := make([]Segment, 0)
removeStart := 0
addStart := 0
for _, patch := range patches {
switch patch.Type {
case diffmatchpatch.DiffDelete:
segments = append(segments, Segment{
Start: removeStart,
End: removeStart + len(patch.Text),
Type: LineRemoved,
Text: patch.Text,
})
removeStart += len(patch.Text)
case diffmatchpatch.DiffInsert:
segments = append(segments, Segment{
Start: addStart,
End: addStart + len(patch.Text),
Type: LineAdded,
Text: patch.Text,
})
addStart += len(patch.Text)
default:
// Context text, no highlighting needed
removeStart += len(patch.Text)
addStart += len(patch.Text)
}
}
oldLine.Segments = segments
newLine.Segments = segments
updated = append(updated, oldLine, newLine)
i++ // Skip the next line as we've already processed it
} else {
updated = append(updated, h.Lines[i])
}
}
h.Lines = updated
}
// pairLines converts a flat list of diff lines to pairs for side-by-side display
func pairLines(lines []DiffLine) []linePair {
var pairs []linePair
i := 0
for i < len(lines) {
switch lines[i].Kind {
case LineRemoved:
// Check if the next line is an addition, if so pair them
if i+1 < len(lines) && lines[i+1].Kind == LineAdded {
pairs = append(pairs, linePair{left: &lines[i], right: &lines[i+1]})
i += 2
} else {
pairs = append(pairs, linePair{left: &lines[i], right: nil})
i++
}
case LineAdded:
pairs = append(pairs, linePair{left: nil, right: &lines[i]})
i++
case LineContext:
pairs = append(pairs, linePair{left: &lines[i], right: &lines[i]})
i++
}
}
return pairs
}
// -------------------------------------------------------------------------
// Syntax Highlighting
// -------------------------------------------------------------------------
// SyntaxHighlight applies syntax highlighting to text based on file extension
func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipgloss.TerminalColor) error {
t := theme.CurrentTheme()
// Determine the language lexer to use
l := lexers.Match(fileName)
if l == nil {
l = lexers.Analyse(source)
}
if l == nil {
l = lexers.Fallback
}
l = chroma.Coalesce(l)
// Get the formatter
f := formatters.Get(formatter)
if f == nil {
f = formatters.Fallback
}
// Dynamic theme based on current theme values
syntaxThemeXml := fmt.Sprintf(`
<style name="opencode-theme">
<!-- Base colors -->
<entry type="Background" style="bg:%s"/>
<entry type="Text" style="%s"/>
<entry type="Other" style="%s"/>
<entry type="Error" style="%s"/>
<!-- Keywords -->
<entry type="Keyword" style="%s"/>
<entry type="KeywordConstant" style="%s"/>
<entry type="KeywordDeclaration" style="%s"/>
<entry type="KeywordNamespace" style="%s"/>
<entry type="KeywordPseudo" style="%s"/>
<entry type="KeywordReserved" style="%s"/>
<entry type="KeywordType" style="%s"/>
<!-- Names -->
<entry type="Name" style="%s"/>
<entry type="NameAttribute" style="%s"/>
<entry type="NameBuiltin" style="%s"/>
<entry type="NameBuiltinPseudo" style="%s"/>
<entry type="NameClass" style="%s"/>
<entry type="NameConstant" style="%s"/>
<entry type="NameDecorator" style="%s"/>
<entry type="NameEntity" style="%s"/>
<entry type="NameException" style="%s"/>
<entry type="NameFunction" style="%s"/>
<entry type="NameLabel" style="%s"/>
<entry type="NameNamespace" style="%s"/>
<entry type="NameOther" style="%s"/>
<entry type="NameTag" style="%s"/>
<entry type="NameVariable" style="%s"/>
<entry type="NameVariableClass" style="%s"/>
<entry type="NameVariableGlobal" style="%s"/>
<entry type="NameVariableInstance" style="%s"/>
<!-- Literals -->
<entry type="Literal" style="%s"/>
<entry type="LiteralDate" style="%s"/>
<entry type="LiteralString" style="%s"/>
<entry type="LiteralStringBacktick" style="%s"/>
<entry type="LiteralStringChar" style="%s"/>
<entry type="LiteralStringDoc" style="%s"/>
<entry type="LiteralStringDouble" style="%s"/>
<entry type="LiteralStringEscape" style="%s"/>
<entry type="LiteralStringHeredoc" style="%s"/>
<entry type="LiteralStringInterpol" style="%s"/>
<entry type="LiteralStringOther" style="%s"/>
<entry type="LiteralStringRegex" style="%s"/>
<entry type="LiteralStringSingle" style="%s"/>
<entry type="LiteralStringSymbol" style="%s"/>
<!-- Numbers -->
<entry type="LiteralNumber" style="%s"/>
<entry type="LiteralNumberBin" style="%s"/>
<entry type="LiteralNumberFloat" style="%s"/>
<entry type="LiteralNumberHex" style="%s"/>
<entry type="LiteralNumberInteger" style="%s"/>
<entry type="LiteralNumberIntegerLong" style="%s"/>
<entry type="LiteralNumberOct" style="%s"/>
<!-- Operators -->
<entry type="Operator" style="%s"/>
<entry type="OperatorWord" style="%s"/>
<entry type="Punctuation" style="%s"/>
<!-- Comments -->
<entry type="Comment" style="%s"/>
<entry type="CommentHashbang" style="%s"/>
<entry type="CommentMultiline" style="%s"/>
<entry type="CommentSingle" style="%s"/>
<entry type="CommentSpecial" style="%s"/>
<entry type="CommentPreproc" style="%s"/>
<!-- Generic styles -->
<entry type="Generic" style="%s"/>
<entry type="GenericDeleted" style="%s"/>
<entry type="GenericEmph" style="italic %s"/>
<entry type="GenericError" style="%s"/>
<entry type="GenericHeading" style="bold %s"/>
<entry type="GenericInserted" style="%s"/>
<entry type="GenericOutput" style="%s"/>
<entry type="GenericPrompt" style="%s"/>
<entry type="GenericStrong" style="bold %s"/>
<entry type="GenericSubheading" style="bold %s"/>
<entry type="GenericTraceback" style="%s"/>
<entry type="GenericUnderline" style="underline"/>
<entry type="TextWhitespace" style="%s"/>
</style>
`,
getColor(t.Background()), // Background
getColor(t.Text()), // Text
getColor(t.Text()), // Other
getColor(t.Error()), // Error
getColor(t.SyntaxKeyword()), // Keyword
getColor(t.SyntaxKeyword()), // KeywordConstant
getColor(t.SyntaxKeyword()), // KeywordDeclaration
getColor(t.SyntaxKeyword()), // KeywordNamespace
getColor(t.SyntaxKeyword()), // KeywordPseudo
getColor(t.SyntaxKeyword()), // KeywordReserved
getColor(t.SyntaxType()), // KeywordType
getColor(t.Text()), // Name
getColor(t.SyntaxVariable()), // NameAttribute
getColor(t.SyntaxType()), // NameBuiltin
getColor(t.SyntaxVariable()), // NameBuiltinPseudo
getColor(t.SyntaxType()), // NameClass
getColor(t.SyntaxVariable()), // NameConstant
getColor(t.SyntaxFunction()), // NameDecorator
getColor(t.SyntaxVariable()), // NameEntity
getColor(t.SyntaxType()), // NameException
getColor(t.SyntaxFunction()), // NameFunction
getColor(t.Text()), // NameLabel
getColor(t.SyntaxType()), // NameNamespace
getColor(t.SyntaxVariable()), // NameOther
getColor(t.SyntaxKeyword()), // NameTag
getColor(t.SyntaxVariable()), // NameVariable
getColor(t.SyntaxVariable()), // NameVariableClass
getColor(t.SyntaxVariable()), // NameVariableGlobal
getColor(t.SyntaxVariable()), // NameVariableInstance
getColor(t.SyntaxString()), // Literal
getColor(t.SyntaxString()), // LiteralDate
getColor(t.SyntaxString()), // LiteralString
getColor(t.SyntaxString()), // LiteralStringBacktick
getColor(t.SyntaxString()), // LiteralStringChar
getColor(t.SyntaxString()), // LiteralStringDoc
getColor(t.SyntaxString()), // LiteralStringDouble
getColor(t.SyntaxString()), // LiteralStringEscape
getColor(t.SyntaxString()), // LiteralStringHeredoc
getColor(t.SyntaxString()), // LiteralStringInterpol
getColor(t.SyntaxString()), // LiteralStringOther
getColor(t.SyntaxString()), // LiteralStringRegex
getColor(t.SyntaxString()), // LiteralStringSingle
getColor(t.SyntaxString()), // LiteralStringSymbol
getColor(t.SyntaxNumber()), // LiteralNumber
getColor(t.SyntaxNumber()), // LiteralNumberBin
getColor(t.SyntaxNumber()), // LiteralNumberFloat
getColor(t.SyntaxNumber()), // LiteralNumberHex
getColor(t.SyntaxNumber()), // LiteralNumberInteger
getColor(t.SyntaxNumber()), // LiteralNumberIntegerLong
getColor(t.SyntaxNumber()), // LiteralNumberOct
getColor(t.SyntaxOperator()), // Operator
getColor(t.SyntaxKeyword()), // OperatorWord
getColor(t.SyntaxPunctuation()), // Punctuation
getColor(t.SyntaxComment()), // Comment
getColor(t.SyntaxComment()), // CommentHashbang
getColor(t.SyntaxComment()), // CommentMultiline
getColor(t.SyntaxComment()), // CommentSingle
getColor(t.SyntaxComment()), // CommentSpecial
getColor(t.SyntaxKeyword()), // CommentPreproc
getColor(t.Text()), // Generic
getColor(t.Error()), // GenericDeleted
getColor(t.Text()), // GenericEmph
getColor(t.Error()), // GenericError
getColor(t.Text()), // GenericHeading
getColor(t.Success()), // GenericInserted
getColor(t.TextMuted()), // GenericOutput
getColor(t.Text()), // GenericPrompt
getColor(t.Text()), // GenericStrong
getColor(t.Text()), // GenericSubheading
getColor(t.Error()), // GenericTraceback
getColor(t.Text()), // TextWhitespace
)
r := strings.NewReader(syntaxThemeXml)
style := chroma.MustNewXMLStyle(r)
// Modify the style to use the provided background
s, err := style.Builder().Transform(
func(t chroma.StyleEntry) chroma.StyleEntry {
r, g, b, _ := bg.RGBA()
t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
return t
},
).Build()
if err != nil {
s = styles.Fallback
}
// Tokenize and format
it, err := l.Tokenise(nil, source)
if err != nil {
return err
}
return f.Format(w, s, it)
}
// getColor returns the appropriate hex color string based on terminal background
func getColor(adaptiveColor lipgloss.AdaptiveColor) string {
if lipgloss.HasDarkBackground() {
return adaptiveColor.Dark
}
return adaptiveColor.Light
}
// highlightLine applies syntax highlighting to a single line
func highlightLine(fileName string, line string, bg lipgloss.TerminalColor) string {
var buf bytes.Buffer
err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg)
if err != nil {
return line
}
return buf.String()
}
// createStyles generates the lipgloss styles needed for rendering diffs
func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
removedLineStyle = lipgloss.NewStyle().Background(t.DiffRemovedBg())
addedLineStyle = lipgloss.NewStyle().Background(t.DiffAddedBg())
contextLineStyle = lipgloss.NewStyle().Background(t.DiffContextBg())
lineNumberStyle = lipgloss.NewStyle().Foreground(t.DiffLineNumber())
return
}
// -------------------------------------------------------------------------
// Rendering Functions
// -------------------------------------------------------------------------
// applyHighlighting applies intra-line highlighting to a piece of text
func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg lipgloss.AdaptiveColor) string {
// Find all ANSI sequences in the content
ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
// Build a mapping of visible character positions to their actual indices
visibleIdx := 0
ansiSequences := make(map[int]string)
lastAnsiSeq := "\x1b[0m" // Default reset sequence
for i := 0; i < len(content); {
isAnsi := false
for _, match := range ansiMatches {
if match[0] == i {
ansiSequences[visibleIdx] = content[match[0]:match[1]]
lastAnsiSeq = content[match[0]:match[1]]
i = match[1]
isAnsi = true
break
}
}
if isAnsi {
continue
}
// For non-ANSI positions, store the last ANSI sequence
if _, exists := ansiSequences[visibleIdx]; !exists {
ansiSequences[visibleIdx] = lastAnsiSeq
}
visibleIdx++
i++
}
// Apply highlighting
var sb strings.Builder
inSelection := false
currentPos := 0
// Get the appropriate color based on terminal background
bgColor := lipgloss.Color(getColor(highlightBg))
fgColor := lipgloss.Color(getColor(theme.CurrentTheme().Background()))
for i := 0; i < len(content); {
// Check if we're at an ANSI sequence
isAnsi := false
for _, match := range ansiMatches {
if match[0] == i {
sb.WriteString(content[match[0]:match[1]]) // Preserve ANSI sequence
i = match[1]
isAnsi = true
break
}
}
if isAnsi {
continue
}
// Check for segment boundaries
for _, seg := range segments {
if seg.Type == segmentType {
if currentPos == seg.Start {
inSelection = true
}
if currentPos == seg.End {
inSelection = false
}
}
}
// Get current character
char := string(content[i])
if inSelection {
// Get the current styling
currentStyle := ansiSequences[currentPos]
// Apply foreground and background highlight
sb.WriteString("\x1b[38;2;")
r, g, b, _ := fgColor.RGBA()
sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
sb.WriteString("\x1b[48;2;")
r, g, b, _ = bgColor.RGBA()
sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
sb.WriteString(char)
// Full reset of all attributes to ensure clean state
sb.WriteString("\x1b[0m")
// Reapply the original ANSI sequence
sb.WriteString(currentStyle)
} else {
// Not in selection, just copy the character
sb.WriteString(char)
}
currentPos++
i++
}
return sb.String()
}
// renderDiffColumnLine is a helper function that handles the common logic for rendering diff columns
func renderDiffColumnLine(
fileName string,
dl *DiffLine,
colWidth int,
isLeftColumn bool,
t theme.Theme,
) string {
if dl == nil {
contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
return contextLineStyle.Width(colWidth).Render("")
}
removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t)
// Determine line style based on line type and column
var marker string
var bgStyle lipgloss.Style
var lineNum string
var highlightType LineType
var highlightColor lipgloss.AdaptiveColor
if isLeftColumn {
// Left column logic
switch dl.Kind {
case LineRemoved:
marker = "-"
bgStyle = removedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
highlightType = LineRemoved
highlightColor = t.DiffHighlightRemoved()
case LineAdded:
marker = "?"
bgStyle = contextLineStyle
case LineContext:
marker = " "
bgStyle = contextLineStyle
}
// Format line number for left column
if dl.OldLineNo > 0 {
lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
}
} else {
// Right column logic
switch dl.Kind {
case LineAdded:
marker = "+"
bgStyle = addedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
highlightType = LineAdded
highlightColor = t.DiffHighlightAdded()
case LineRemoved:
marker = "?"
bgStyle = contextLineStyle
case LineContext:
marker = " "
bgStyle = contextLineStyle
}
// Format line number for right column
if dl.NewLineNo > 0 {
lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
}
}
// Style the marker based on line type
var styledMarker string
switch dl.Kind {
case LineRemoved:
styledMarker = removedLineStyle.Foreground(t.DiffRemoved()).Render(marker)
case LineAdded:
styledMarker = addedLineStyle.Foreground(t.DiffAdded()).Render(marker)
case LineContext:
styledMarker = contextLineStyle.Foreground(t.TextMuted()).Render(marker)
default:
styledMarker = marker
}
// Create the line prefix
prefix := lineNumberStyle.Render(lineNum + " " + styledMarker)
// Apply syntax highlighting
content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
// Apply intra-line highlighting if needed
if (dl.Kind == LineRemoved && isLeftColumn || dl.Kind == LineAdded && !isLeftColumn) && len(dl.Segments) > 0 {
content = applyHighlighting(content, dl.Segments, highlightType, highlightColor)
}
// Add a padding space for added/removed lines
if (dl.Kind == LineRemoved && isLeftColumn) || (dl.Kind == LineAdded && !isLeftColumn) {
content = bgStyle.Render(" ") + content
}
// Create the final line and truncate if needed
lineText := prefix + content
return bgStyle.MaxHeight(1).Width(colWidth).Render(
ansi.Truncate(
lineText,
colWidth,
lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
),
)
}
// renderLeftColumn formats the left side of a side-by-side diff
func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
return renderDiffColumnLine(fileName, dl, colWidth, true, theme.CurrentTheme())
}
// renderRightColumn formats the right side of a side-by-side diff
func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
return renderDiffColumnLine(fileName, dl, colWidth, false, theme.CurrentTheme())
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
// RenderSideBySideHunk formats a hunk for side-by-side display
func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
// Apply options to create the configuration
config := NewSideBySideConfig(opts...)
// Make a copy of the hunk so we don't modify the original
hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
copy(hunkCopy.Lines, h.Lines)
// Highlight changes within lines
HighlightIntralineChanges(&hunkCopy)
// Pair lines for side-by-side display
pairs := pairLines(hunkCopy.Lines)
// Calculate column width
colWidth := config.TotalWidth / 2
leftWidth := colWidth
rightWidth := config.TotalWidth - colWidth
var sb strings.Builder
for _, p := range pairs {
leftStr := renderLeftColumn(fileName, p.left, leftWidth)
rightStr := renderRightColumn(fileName, p.right, rightWidth)
sb.WriteString(leftStr + rightStr + "\n")
}
return sb.String()
}
// FormatDiff creates a side-by-side formatted view of a diff
func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
t := theme.CurrentTheme()
diffResult, err := ParseUnifiedDiff(diffText)
if err != nil {
return "", err
}
var sb strings.Builder
config := NewSideBySideConfig(opts...)
for _, h := range diffResult.Hunks {
sb.WriteString(
lipgloss.NewStyle().
Background(t.DiffHunkHeader()).
Foreground(t.Background()).
Width(config.TotalWidth).
Render(h.Header) + "\n",
)
sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
}
return sb.String(), nil
}
// GenerateDiff creates a unified diff from two file contents
func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, int) {
// remove the cwd prefix and ensure consistent path format
// this prevents issues with absolute paths in different environments
cwd := config.WorkingDirectory()
fileName = strings.TrimPrefix(fileName, cwd)
fileName = strings.TrimPrefix(fileName, "/")
edits := udiff.Strings(beforeContent, afterContent)
unified, _ := udiff.ToUnified("a/"+fileName, "b/"+fileName, beforeContent, edits, 8)
var (
additions = 0
removals = 0
)
lines := strings.SplitSeq(unified, "\n")
for line := range lines {
if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") {
additions++
} else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") {
removals++
}
}
return unified, additions, removals
}

View File

@@ -0,0 +1,103 @@
package diff
import (
"fmt"
"testing"
"github.com/charmbracelet/lipgloss"
"github.com/stretchr/testify/assert"
)
// TestApplyHighlighting tests the applyHighlighting function with various ANSI sequences
func TestApplyHighlighting(t *testing.T) {
t.Parallel()
// Mock theme colors for testing
mockHighlightBg := lipgloss.AdaptiveColor{
Dark: "#FF0000", // Red background for highlighting
Light: "#FF0000",
}
// Test cases
tests := []struct {
name string
content string
segments []Segment
segmentType LineType
expectContains string
}{
{
name: "Simple text with no ANSI",
content: "This is a test",
segments: []Segment{{Start: 0, End: 4, Type: LineAdded}},
segmentType: LineAdded,
// Should contain full reset sequence after highlighting
expectContains: "\x1b[0m",
},
{
name: "Text with existing ANSI foreground",
content: "This \x1b[32mis\x1b[0m a test", // "is" in green
segments: []Segment{{Start: 5, End: 7, Type: LineAdded}},
segmentType: LineAdded,
// Should contain full reset sequence after highlighting
expectContains: "\x1b[0m",
},
{
name: "Text with existing ANSI background",
content: "This \x1b[42mis\x1b[0m a test", // "is" with green background
segments: []Segment{{Start: 5, End: 7, Type: LineAdded}},
segmentType: LineAdded,
// Should contain full reset sequence after highlighting
expectContains: "\x1b[0m",
},
{
name: "Text with complex ANSI styling",
content: "This \x1b[1;32;45mis\x1b[0m a test", // "is" bold green on magenta
segments: []Segment{{Start: 5, End: 7, Type: LineAdded}},
segmentType: LineAdded,
// Should contain full reset sequence after highlighting
expectContains: "\x1b[0m",
},
}
for _, tc := range tests {
tc := tc // Capture range variable for parallel testing
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
result := applyHighlighting(tc.content, tc.segments, tc.segmentType, mockHighlightBg)
// Verify the result contains the expected sequence
assert.Contains(t, result, tc.expectContains,
"Result should contain full reset sequence")
// Print the result for manual inspection if needed
if t.Failed() {
fmt.Printf("Original: %q\nResult: %q\n", tc.content, result)
}
})
}
}
// TestApplyHighlightingWithMultipleSegments tests highlighting multiple segments
func TestApplyHighlightingWithMultipleSegments(t *testing.T) {
t.Parallel()
// Mock theme colors for testing
mockHighlightBg := lipgloss.AdaptiveColor{
Dark: "#FF0000", // Red background for highlighting
Light: "#FF0000",
}
content := "This is a test with multiple segments to highlight"
segments := []Segment{
{Start: 0, End: 4, Type: LineAdded}, // "This"
{Start: 8, End: 9, Type: LineAdded}, // "a"
{Start: 15, End: 23, Type: LineAdded}, // "multiple"
}
result := applyHighlighting(content, segments, LineAdded, mockHighlightBg)
// Verify the result contains the full reset sequence
assert.Contains(t, result, "\x1b[0m",
"Result should contain full reset sequence")
}

View File

@@ -0,0 +1,740 @@
package diff
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
type ActionType string
const (
ActionAdd ActionType = "add"
ActionDelete ActionType = "delete"
ActionUpdate ActionType = "update"
)
type FileChange struct {
Type ActionType
OldContent *string
NewContent *string
MovePath *string
}
type Commit struct {
Changes map[string]FileChange
}
type Chunk struct {
OrigIndex int // line index of the first line in the original file
DelLines []string // lines to delete
InsLines []string // lines to insert
}
type PatchAction struct {
Type ActionType
NewFile *string
Chunks []Chunk
MovePath *string
}
type Patch struct {
Actions map[string]PatchAction
}
type DiffError struct {
message string
}
func (e DiffError) Error() string {
return e.message
}
// Helper functions for error handling
func NewDiffError(message string) DiffError {
return DiffError{message: message}
}
func fileError(action, reason, path string) DiffError {
return NewDiffError(fmt.Sprintf("%s File Error: %s: %s", action, reason, path))
}
func contextError(index int, context string, isEOF bool) DiffError {
prefix := "Invalid Context"
if isEOF {
prefix = "Invalid EOF Context"
}
return NewDiffError(fmt.Sprintf("%s %d:\n%s", prefix, index, context))
}
type Parser struct {
currentFiles map[string]string
lines []string
index int
patch Patch
fuzz int
}
func NewParser(currentFiles map[string]string, lines []string) *Parser {
return &Parser{
currentFiles: currentFiles,
lines: lines,
index: 0,
patch: Patch{Actions: make(map[string]PatchAction, len(currentFiles))},
fuzz: 0,
}
}
func (p *Parser) isDone(prefixes []string) bool {
if p.index >= len(p.lines) {
return true
}
for _, prefix := range prefixes {
if strings.HasPrefix(p.lines[p.index], prefix) {
return true
}
}
return false
}
func (p *Parser) startsWith(prefix any) bool {
var prefixes []string
switch v := prefix.(type) {
case string:
prefixes = []string{v}
case []string:
prefixes = v
}
for _, pfx := range prefixes {
if strings.HasPrefix(p.lines[p.index], pfx) {
return true
}
}
return false
}
func (p *Parser) readStr(prefix string, returnEverything bool) string {
if p.index >= len(p.lines) {
return "" // Changed from panic to return empty string for safer operation
}
if strings.HasPrefix(p.lines[p.index], prefix) {
var text string
if returnEverything {
text = p.lines[p.index]
} else {
text = p.lines[p.index][len(prefix):]
}
p.index++
return text
}
return ""
}
func (p *Parser) Parse() error {
endPatchPrefixes := []string{"*** End Patch"}
for !p.isDone(endPatchPrefixes) {
path := p.readStr("*** Update File: ", false)
if path != "" {
if _, exists := p.patch.Actions[path]; exists {
return fileError("Update", "Duplicate Path", path)
}
moveTo := p.readStr("*** Move to: ", false)
if _, exists := p.currentFiles[path]; !exists {
return fileError("Update", "Missing File", path)
}
text := p.currentFiles[path]
action, err := p.parseUpdateFile(text)
if err != nil {
return err
}
if moveTo != "" {
action.MovePath = &moveTo
}
p.patch.Actions[path] = action
continue
}
path = p.readStr("*** Delete File: ", false)
if path != "" {
if _, exists := p.patch.Actions[path]; exists {
return fileError("Delete", "Duplicate Path", path)
}
if _, exists := p.currentFiles[path]; !exists {
return fileError("Delete", "Missing File", path)
}
p.patch.Actions[path] = PatchAction{Type: ActionDelete, Chunks: []Chunk{}}
continue
}
path = p.readStr("*** Add File: ", false)
if path != "" {
if _, exists := p.patch.Actions[path]; exists {
return fileError("Add", "Duplicate Path", path)
}
if _, exists := p.currentFiles[path]; exists {
return fileError("Add", "File already exists", path)
}
action, err := p.parseAddFile()
if err != nil {
return err
}
p.patch.Actions[path] = action
continue
}
return NewDiffError(fmt.Sprintf("Unknown Line: %s", p.lines[p.index]))
}
if !p.startsWith("*** End Patch") {
return NewDiffError("Missing End Patch")
}
p.index++
return nil
}
func (p *Parser) parseUpdateFile(text string) (PatchAction, error) {
action := PatchAction{Type: ActionUpdate, Chunks: []Chunk{}}
fileLines := strings.Split(text, "\n")
index := 0
endPrefixes := []string{
"*** End Patch",
"*** Update File:",
"*** Delete File:",
"*** Add File:",
"*** End of File",
}
for !p.isDone(endPrefixes) {
defStr := p.readStr("@@ ", false)
sectionStr := ""
if defStr == "" && p.index < len(p.lines) && p.lines[p.index] == "@@" {
sectionStr = p.lines[p.index]
p.index++
}
if defStr == "" && sectionStr == "" && index != 0 {
return action, NewDiffError(fmt.Sprintf("Invalid Line:\n%s", p.lines[p.index]))
}
if strings.TrimSpace(defStr) != "" {
found := false
for i := range fileLines[:index] {
if fileLines[i] == defStr {
found = true
break
}
}
if !found {
for i := index; i < len(fileLines); i++ {
if fileLines[i] == defStr {
index = i + 1
found = true
break
}
}
}
if !found {
for i := range fileLines[:index] {
if strings.TrimSpace(fileLines[i]) == strings.TrimSpace(defStr) {
found = true
break
}
}
}
if !found {
for i := index; i < len(fileLines); i++ {
if strings.TrimSpace(fileLines[i]) == strings.TrimSpace(defStr) {
index = i + 1
p.fuzz++
found = true
break
}
}
}
}
nextChunkContext, chunks, endPatchIndex, eof := peekNextSection(p.lines, p.index)
newIndex, fuzz := findContext(fileLines, nextChunkContext, index, eof)
if newIndex == -1 {
ctxText := strings.Join(nextChunkContext, "\n")
return action, contextError(index, ctxText, eof)
}
p.fuzz += fuzz
for _, ch := range chunks {
ch.OrigIndex += newIndex
action.Chunks = append(action.Chunks, ch)
}
index = newIndex + len(nextChunkContext)
p.index = endPatchIndex
}
return action, nil
}
func (p *Parser) parseAddFile() (PatchAction, error) {
lines := make([]string, 0, 16) // Preallocate space for better performance
endPrefixes := []string{
"*** End Patch",
"*** Update File:",
"*** Delete File:",
"*** Add File:",
}
for !p.isDone(endPrefixes) {
s := p.readStr("", true)
if !strings.HasPrefix(s, "+") {
return PatchAction{}, NewDiffError(fmt.Sprintf("Invalid Add File Line: %s", s))
}
lines = append(lines, s[1:])
}
newFile := strings.Join(lines, "\n")
return PatchAction{
Type: ActionAdd,
NewFile: &newFile,
Chunks: []Chunk{},
}, nil
}
// Refactored to use a matcher function for each comparison type
func findContextCore(lines []string, context []string, start int) (int, int) {
if len(context) == 0 {
return start, 0
}
// Try exact match
if idx, fuzz := tryFindMatch(lines, context, start, func(a, b string) bool {
return a == b
}); idx >= 0 {
return idx, fuzz
}
// Try trimming right whitespace
if idx, fuzz := tryFindMatch(lines, context, start, func(a, b string) bool {
return strings.TrimRight(a, " \t") == strings.TrimRight(b, " \t")
}); idx >= 0 {
return idx, fuzz
}
// Try trimming all whitespace
if idx, fuzz := tryFindMatch(lines, context, start, func(a, b string) bool {
return strings.TrimSpace(a) == strings.TrimSpace(b)
}); idx >= 0 {
return idx, fuzz
}
return -1, 0
}
// Helper function to DRY up the match logic
func tryFindMatch(lines []string, context []string, start int,
compareFunc func(string, string) bool,
) (int, int) {
for i := start; i < len(lines); i++ {
if i+len(context) <= len(lines) {
match := true
for j := range context {
if !compareFunc(lines[i+j], context[j]) {
match = false
break
}
}
if match {
// Return fuzz level: 0 for exact, 1 for trimRight, 100 for trimSpace
var fuzz int
if compareFunc("a ", "a") && !compareFunc("a", "b") {
fuzz = 1
} else if compareFunc("a ", "a") {
fuzz = 100
}
return i, fuzz
}
}
}
return -1, 0
}
func findContext(lines []string, context []string, start int, eof bool) (int, int) {
if eof {
newIndex, fuzz := findContextCore(lines, context, len(lines)-len(context))
if newIndex != -1 {
return newIndex, fuzz
}
newIndex, fuzz = findContextCore(lines, context, start)
return newIndex, fuzz + 10000
}
return findContextCore(lines, context, start)
}
func peekNextSection(lines []string, initialIndex int) ([]string, []Chunk, int, bool) {
index := initialIndex
old := make([]string, 0, 32) // Preallocate for better performance
delLines := make([]string, 0, 8)
insLines := make([]string, 0, 8)
chunks := make([]Chunk, 0, 4)
mode := "keep"
// End conditions for the section
endSectionConditions := func(s string) bool {
return strings.HasPrefix(s, "@@") ||
strings.HasPrefix(s, "*** End Patch") ||
strings.HasPrefix(s, "*** Update File:") ||
strings.HasPrefix(s, "*** Delete File:") ||
strings.HasPrefix(s, "*** Add File:") ||
strings.HasPrefix(s, "*** End of File") ||
s == "***" ||
strings.HasPrefix(s, "***")
}
for index < len(lines) {
s := lines[index]
if endSectionConditions(s) {
break
}
index++
lastMode := mode
line := s
if len(line) > 0 {
switch line[0] {
case '+':
mode = "add"
case '-':
mode = "delete"
case ' ':
mode = "keep"
default:
mode = "keep"
line = " " + line
}
} else {
mode = "keep"
line = " "
}
line = line[1:]
if mode == "keep" && lastMode != mode {
if len(insLines) > 0 || len(delLines) > 0 {
chunks = append(chunks, Chunk{
OrigIndex: len(old) - len(delLines),
DelLines: delLines,
InsLines: insLines,
})
}
delLines = make([]string, 0, 8)
insLines = make([]string, 0, 8)
}
switch mode {
case "delete":
delLines = append(delLines, line)
old = append(old, line)
case "add":
insLines = append(insLines, line)
default:
old = append(old, line)
}
}
if len(insLines) > 0 || len(delLines) > 0 {
chunks = append(chunks, Chunk{
OrigIndex: len(old) - len(delLines),
DelLines: delLines,
InsLines: insLines,
})
}
if index < len(lines) && lines[index] == "*** End of File" {
index++
return old, chunks, index, true
}
return old, chunks, index, false
}
func TextToPatch(text string, orig map[string]string) (Patch, int, error) {
text = strings.TrimSpace(text)
lines := strings.Split(text, "\n")
if len(lines) < 2 || !strings.HasPrefix(lines[0], "*** Begin Patch") || lines[len(lines)-1] != "*** End Patch" {
return Patch{}, 0, NewDiffError("Invalid patch text")
}
parser := NewParser(orig, lines)
parser.index = 1
if err := parser.Parse(); err != nil {
return Patch{}, 0, err
}
return parser.patch, parser.fuzz, nil
}
func IdentifyFilesNeeded(text string) []string {
text = strings.TrimSpace(text)
lines := strings.Split(text, "\n")
result := make(map[string]bool)
for _, line := range lines {
if strings.HasPrefix(line, "*** Update File: ") {
result[line[len("*** Update File: "):]] = true
}
if strings.HasPrefix(line, "*** Delete File: ") {
result[line[len("*** Delete File: "):]] = true
}
}
files := make([]string, 0, len(result))
for file := range result {
files = append(files, file)
}
return files
}
func IdentifyFilesAdded(text string) []string {
text = strings.TrimSpace(text)
lines := strings.Split(text, "\n")
result := make(map[string]bool)
for _, line := range lines {
if strings.HasPrefix(line, "*** Add File: ") {
result[line[len("*** Add File: "):]] = true
}
}
files := make([]string, 0, len(result))
for file := range result {
files = append(files, file)
}
return files
}
func getUpdatedFile(text string, action PatchAction, path string) (string, error) {
if action.Type != ActionUpdate {
return "", errors.New("expected UPDATE action")
}
origLines := strings.Split(text, "\n")
destLines := make([]string, 0, len(origLines)) // Preallocate with capacity
origIndex := 0
for _, chunk := range action.Chunks {
if chunk.OrigIndex > len(origLines) {
return "", NewDiffError(fmt.Sprintf("%s: chunk.orig_index %d > len(lines) %d", path, chunk.OrigIndex, len(origLines)))
}
if origIndex > chunk.OrigIndex {
return "", NewDiffError(fmt.Sprintf("%s: orig_index %d > chunk.orig_index %d", path, origIndex, chunk.OrigIndex))
}
destLines = append(destLines, origLines[origIndex:chunk.OrigIndex]...)
delta := chunk.OrigIndex - origIndex
origIndex += delta
if len(chunk.InsLines) > 0 {
destLines = append(destLines, chunk.InsLines...)
}
origIndex += len(chunk.DelLines)
}
destLines = append(destLines, origLines[origIndex:]...)
return strings.Join(destLines, "\n"), nil
}
func PatchToCommit(patch Patch, orig map[string]string) (Commit, error) {
commit := Commit{Changes: make(map[string]FileChange, len(patch.Actions))}
for pathKey, action := range patch.Actions {
switch action.Type {
case ActionDelete:
oldContent := orig[pathKey]
commit.Changes[pathKey] = FileChange{
Type: ActionDelete,
OldContent: &oldContent,
}
case ActionAdd:
commit.Changes[pathKey] = FileChange{
Type: ActionAdd,
NewContent: action.NewFile,
}
case ActionUpdate:
newContent, err := getUpdatedFile(orig[pathKey], action, pathKey)
if err != nil {
return Commit{}, err
}
oldContent := orig[pathKey]
fileChange := FileChange{
Type: ActionUpdate,
OldContent: &oldContent,
NewContent: &newContent,
}
if action.MovePath != nil {
fileChange.MovePath = action.MovePath
}
commit.Changes[pathKey] = fileChange
}
}
return commit, nil
}
func AssembleChanges(orig map[string]string, updatedFiles map[string]string) Commit {
commit := Commit{Changes: make(map[string]FileChange, len(updatedFiles))}
for p, newContent := range updatedFiles {
oldContent, exists := orig[p]
if exists && oldContent == newContent {
continue
}
if exists && newContent != "" {
commit.Changes[p] = FileChange{
Type: ActionUpdate,
OldContent: &oldContent,
NewContent: &newContent,
}
} else if newContent != "" {
commit.Changes[p] = FileChange{
Type: ActionAdd,
NewContent: &newContent,
}
} else if exists {
commit.Changes[p] = FileChange{
Type: ActionDelete,
OldContent: &oldContent,
}
} else {
return commit // Changed from panic to simply return current commit
}
}
return commit
}
func LoadFiles(paths []string, openFn func(string) (string, error)) (map[string]string, error) {
orig := make(map[string]string, len(paths))
for _, p := range paths {
content, err := openFn(p)
if err != nil {
return nil, fileError("Open", "File not found", p)
}
orig[p] = content
}
return orig, nil
}
func ApplyCommit(commit Commit, writeFn func(string, string) error, removeFn func(string) error) error {
for p, change := range commit.Changes {
switch change.Type {
case ActionDelete:
if err := removeFn(p); err != nil {
return err
}
case ActionAdd:
if change.NewContent == nil {
return NewDiffError(fmt.Sprintf("Add action for %s has nil new_content", p))
}
if err := writeFn(p, *change.NewContent); err != nil {
return err
}
case ActionUpdate:
if change.NewContent == nil {
return NewDiffError(fmt.Sprintf("Update action for %s has nil new_content", p))
}
if change.MovePath != nil {
if err := writeFn(*change.MovePath, *change.NewContent); err != nil {
return err
}
if err := removeFn(p); err != nil {
return err
}
} else {
if err := writeFn(p, *change.NewContent); err != nil {
return err
}
}
}
}
return nil
}
func ProcessPatch(text string, openFn func(string) (string, error), writeFn func(string, string) error, removeFn func(string) error) (string, error) {
if !strings.HasPrefix(text, "*** Begin Patch") {
return "", NewDiffError("Patch must start with *** Begin Patch")
}
paths := IdentifyFilesNeeded(text)
orig, err := LoadFiles(paths, openFn)
if err != nil {
return "", err
}
patch, fuzz, err := TextToPatch(text, orig)
if err != nil {
return "", err
}
if fuzz > 0 {
return "", NewDiffError(fmt.Sprintf("Patch contains fuzzy matches (fuzz level: %d)", fuzz))
}
commit, err := PatchToCommit(patch, orig)
if err != nil {
return "", err
}
if err := ApplyCommit(commit, writeFn, removeFn); err != nil {
return "", err
}
return "Patch applied successfully", nil
}
func OpenFile(p string) (string, error) {
data, err := os.ReadFile(p)
if err != nil {
return "", err
}
return string(data), nil
}
func WriteFile(p string, content string) error {
if filepath.IsAbs(p) {
return NewDiffError("We do not support absolute paths.")
}
dir := filepath.Dir(p)
if dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
return os.WriteFile(p, []byte(content), 0o644)
}
func RemoveFile(p string) error {
return os.Remove(p)
}
func ValidatePatch(patchText string, files map[string]string) (bool, string, error) {
if !strings.HasPrefix(patchText, "*** Begin Patch") {
return false, "Patch must start with *** Begin Patch", nil
}
neededFiles := IdentifyFilesNeeded(patchText)
for _, filePath := range neededFiles {
if _, exists := files[filePath]; !exists {
return false, fmt.Sprintf("File not found: %s", filePath), nil
}
}
patch, fuzz, err := TextToPatch(patchText, files)
if err != nil {
return false, err.Error(), nil
}
if fuzz > 0 {
return false, fmt.Sprintf("Patch contains fuzzy matches (fuzz level: %d)", fuzz), nil
}
_, err = PatchToCommit(patch, files)
if err != nil {
return false, err.Error(), nil
}
return true, "Patch is valid", nil
}

View File

@@ -0,0 +1,163 @@
package fileutil
import (
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/bmatcuk/doublestar/v4"
"github.com/sst/opencode/internal/status"
)
var (
rgPath string
fzfPath string
)
func Init() {
var err error
rgPath, err = exec.LookPath("rg")
if err != nil {
status.Warn("Ripgrep (rg) not found in $PATH. Some features might be limited or slower.")
rgPath = ""
}
fzfPath, err = exec.LookPath("fzf")
if err != nil {
status.Warn("FZF not found in $PATH. Some features might be limited or slower.")
fzfPath = ""
}
}
func GetRgCmd(globPattern string) *exec.Cmd {
if rgPath == "" {
return nil
}
rgArgs := []string{
"--files",
"-L",
"--null",
}
if globPattern != "" {
if !filepath.IsAbs(globPattern) && !strings.HasPrefix(globPattern, "/") {
globPattern = "/" + globPattern
}
rgArgs = append(rgArgs, "--glob", globPattern)
}
cmd := exec.Command(rgPath, rgArgs...)
cmd.Dir = "."
return cmd
}
func GetFzfCmd(query string) *exec.Cmd {
if fzfPath == "" {
return nil
}
fzfArgs := []string{
"--filter",
query,
"--read0",
"--print0",
}
cmd := exec.Command(fzfPath, fzfArgs...)
cmd.Dir = "."
return cmd
}
type FileInfo struct {
Path string
ModTime time.Time
}
func SkipHidden(path string) bool {
// Check for hidden files (starting with a dot)
base := filepath.Base(path)
if base != "." && strings.HasPrefix(base, ".") {
return true
}
commonIgnoredDirs := map[string]bool{
".opencode": true,
"node_modules": true,
"vendor": true,
"dist": true,
"build": true,
"target": true,
".git": true,
".idea": true,
".vscode": true,
"__pycache__": true,
"bin": true,
"obj": true,
"out": true,
"coverage": true,
"tmp": true,
"temp": true,
"logs": true,
"generated": true,
"bower_components": true,
"jspm_packages": true,
}
parts := strings.Split(path, string(os.PathSeparator))
for _, part := range parts {
if commonIgnoredDirs[part] {
return true
}
}
return false
}
func GlobWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) {
fsys := os.DirFS(searchPath)
relPattern := strings.TrimPrefix(pattern, "/")
var matches []FileInfo
err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
if d.IsDir() {
return nil
}
if SkipHidden(path) {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
absPath := path
if !strings.HasPrefix(absPath, searchPath) && searchPath != "." {
absPath = filepath.Join(searchPath, absPath)
} else if !strings.HasPrefix(absPath, "/") && searchPath == "." {
absPath = filepath.Join(searchPath, absPath) // Ensure relative paths are joined correctly
}
matches = append(matches, FileInfo{Path: absPath, ModTime: info.ModTime()})
if limit > 0 && len(matches) >= limit*2 {
return fs.SkipAll
}
return nil
})
if err != nil {
return nil, false, fmt.Errorf("glob walk error: %w", err)
}
sort.Slice(matches, func(i, j int) bool {
return matches[i].ModTime.After(matches[j].ModTime)
})
truncated := false
if limit > 0 && len(matches) > limit {
matches = matches[:limit]
truncated = true
}
results := make([]string, len(matches))
for i, m := range matches {
results[i] = m.Path
}
return results, truncated, nil
}

View File

@@ -0,0 +1,46 @@
package format
import (
"encoding/json"
"fmt"
)
// OutputFormat represents the format for non-interactive mode output
type OutputFormat string
const (
// TextFormat is plain text output (default)
TextFormat OutputFormat = "text"
// JSONFormat is output wrapped in a JSON object
JSONFormat OutputFormat = "json"
)
// IsValid checks if the output format is valid
func (f OutputFormat) IsValid() bool {
return f == TextFormat || f == JSONFormat
}
// String returns the string representation of the output format
func (f OutputFormat) String() string {
return string(f)
}
// FormatOutput formats the given content according to the specified format
func FormatOutput(content string, format OutputFormat) (string, error) {
switch format {
case TextFormat:
return content, nil
case JSONFormat:
jsonData := map[string]string{
"response": content,
}
jsonBytes, err := json.MarshalIndent(jsonData, "", " ")
if err != nil {
return "", fmt.Errorf("failed to marshal JSON: %w", err)
}
return string(jsonBytes), nil
default:
return "", fmt.Errorf("unsupported output format: %s", format)
}
}

View File

@@ -0,0 +1,90 @@
package format
import (
"testing"
)
func TestOutputFormat_IsValid(t *testing.T) {
t.Parallel()
tests := []struct {
name string
format OutputFormat
want bool
}{
{
name: "text format",
format: TextFormat,
want: true,
},
{
name: "json format",
format: JSONFormat,
want: true,
},
{
name: "invalid format",
format: "invalid",
want: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.format.IsValid(); got != tt.want {
t.Errorf("OutputFormat.IsValid() = %v, want %v", got, tt.want)
}
})
}
}
func TestFormatOutput(t *testing.T) {
t.Parallel()
tests := []struct {
name string
content string
format OutputFormat
want string
wantErr bool
}{
{
name: "text format",
content: "test content",
format: TextFormat,
want: "test content",
wantErr: false,
},
{
name: "json format",
content: "test content",
format: JSONFormat,
want: "{\n \"response\": \"test content\"\n}",
wantErr: false,
},
{
name: "invalid format",
content: "test content",
format: "invalid",
want: "",
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := FormatOutput(tt.content, tt.format)
if (err != nil) != tt.wantErr {
t.Errorf("FormatOutput() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("FormatOutput() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,113 @@
package pubsub
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
)
const defaultChannelBufferSize = 100
type Broker[T any] struct {
subs map[chan Event[T]]context.CancelFunc
mu sync.RWMutex
isClosed bool
}
func NewBroker[T any]() *Broker[T] {
return &Broker[T]{
subs: make(map[chan Event[T]]context.CancelFunc),
}
}
func (b *Broker[T]) Shutdown() {
b.mu.Lock()
if b.isClosed {
b.mu.Unlock()
return
}
b.isClosed = true
for ch, cancel := range b.subs {
cancel()
close(ch)
delete(b.subs, ch)
}
b.mu.Unlock()
slog.Debug("PubSub broker shut down", "type", fmt.Sprintf("%T", *new(T)))
}
func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] {
b.mu.Lock()
defer b.mu.Unlock()
if b.isClosed {
closedCh := make(chan Event[T])
close(closedCh)
return closedCh
}
subCtx, subCancel := context.WithCancel(ctx)
subscriberChannel := make(chan Event[T], defaultChannelBufferSize)
b.subs[subscriberChannel] = subCancel
go func() {
<-subCtx.Done()
b.mu.Lock()
defer b.mu.Unlock()
if _, ok := b.subs[subscriberChannel]; ok {
close(subscriberChannel)
delete(b.subs, subscriberChannel)
}
}()
return subscriberChannel
}
func (b *Broker[T]) Publish(eventType EventType, payload T) {
b.mu.RLock()
defer b.mu.RUnlock()
if b.isClosed {
slog.Warn("Attempted to publish on a closed pubsub broker", "type", eventType, "payload_type", fmt.Sprintf("%T", payload))
return
}
event := Event[T]{Type: eventType, Payload: payload}
for ch := range b.subs {
// Non-blocking send with a fallback to a goroutine to prevent slow subscribers
// from blocking the publisher.
select {
case ch <- event:
// Successfully sent
default:
// Subscriber channel is full or receiver is slow.
// Send in a new goroutine to avoid blocking the publisher.
// This might lead to out-of-order delivery for this specific slow subscriber.
go func(sChan chan Event[T], ev Event[T]) {
// Re-check if broker is closed before attempting send in goroutine
b.mu.RLock()
isBrokerClosed := b.isClosed
b.mu.RUnlock()
if isBrokerClosed {
return
}
select {
case sChan <- ev:
case <-time.After(2 * time.Second): // Timeout for slow subscriber
slog.Warn("PubSub: Dropped event for slow subscriber after timeout", "type", ev.Type)
}
}(ch, event)
}
}
}
func (b *Broker[T]) GetSubscriberCount() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.subs)
}

View File

@@ -0,0 +1,144 @@
package pubsub
import (
"context"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestBrokerSubscribe(t *testing.T) {
t.Parallel()
t.Run("with cancellable context", func(t *testing.T) {
t.Parallel()
broker := NewBroker[string]()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := broker.Subscribe(ctx)
assert.NotNil(t, ch)
assert.Equal(t, 1, broker.GetSubscriberCount())
// Cancel the context should remove the subscription
cancel()
time.Sleep(10 * time.Millisecond) // Give time for goroutine to process
assert.Equal(t, 0, broker.GetSubscriberCount())
})
t.Run("with background context", func(t *testing.T) {
t.Parallel()
broker := NewBroker[string]()
// Using context.Background() should not leak goroutines
ch := broker.Subscribe(context.Background())
assert.NotNil(t, ch)
assert.Equal(t, 1, broker.GetSubscriberCount())
// Shutdown should clean up all subscriptions
broker.Shutdown()
assert.Equal(t, 0, broker.GetSubscriberCount())
})
}
func TestBrokerPublish(t *testing.T) {
t.Parallel()
broker := NewBroker[string]()
ctx := t.Context()
ch := broker.Subscribe(ctx)
// Publish a message
broker.Publish(EventTypeCreated, "test message")
// Verify message is received
select {
case event := <-ch:
assert.Equal(t, EventTypeCreated, event.Type)
assert.Equal(t, "test message", event.Payload)
case <-time.After(100 * time.Millisecond):
t.Fatal("timeout waiting for message")
}
}
func TestBrokerShutdown(t *testing.T) {
t.Parallel()
broker := NewBroker[string]()
// Create multiple subscribers
ch1 := broker.Subscribe(context.Background())
ch2 := broker.Subscribe(context.Background())
assert.Equal(t, 2, broker.GetSubscriberCount())
// Shutdown should close all channels and clean up
broker.Shutdown()
// Verify channels are closed
_, ok1 := <-ch1
_, ok2 := <-ch2
assert.False(t, ok1, "channel 1 should be closed")
assert.False(t, ok2, "channel 2 should be closed")
// Verify subscriber count is reset
assert.Equal(t, 0, broker.GetSubscriberCount())
}
func TestBrokerConcurrency(t *testing.T) {
t.Parallel()
broker := NewBroker[int]()
// Create a large number of subscribers
const numSubscribers = 100
var wg sync.WaitGroup
wg.Add(numSubscribers)
// Create a channel to collect received events
receivedEvents := make(chan int, numSubscribers)
for i := range numSubscribers {
go func(id int) {
defer wg.Done()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := broker.Subscribe(ctx)
// Receive one message then cancel
select {
case event := <-ch:
receivedEvents <- event.Payload
case <-time.After(1 * time.Second):
t.Errorf("timeout waiting for message %d", id)
}
cancel()
}(i)
}
// Give subscribers time to set up
time.Sleep(10 * time.Millisecond)
// Publish messages to all subscribers
for i := range numSubscribers {
broker.Publish(EventTypeCreated, i)
}
// Wait for all subscribers to finish
wg.Wait()
close(receivedEvents)
// Give time for cleanup goroutines to run
time.Sleep(10 * time.Millisecond)
// Verify all subscribers are cleaned up
assert.Equal(t, 0, broker.GetSubscriberCount())
// Verify we received the expected number of events
count := 0
for range receivedEvents {
count++
}
assert.Equal(t, numSubscribers, count)
}

View File

@@ -0,0 +1,24 @@
package pubsub
import "context"
type EventType string
const (
EventTypeCreated EventType = "created"
EventTypeUpdated EventType = "updated"
EventTypeDeleted EventType = "deleted"
)
type Event[T any] struct {
Type EventType
Payload T
}
type Subscriber[T any] interface {
Subscribe(ctx context.Context) <-chan Event[T]
}
type Publisher[T any] interface {
Publish(eventType EventType, payload T)
}

View File

@@ -0,0 +1,142 @@
package status
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"github.com/sst/opencode/internal/pubsub"
)
type Level string
const (
LevelInfo Level = "info"
LevelWarn Level = "warn"
LevelError Level = "error"
LevelDebug Level = "debug"
)
type StatusMessage struct {
Level Level `json:"level"`
Message string `json:"message"`
Timestamp time.Time `json:"timestamp"`
Critical bool `json:"critical"`
Duration time.Duration `json:"duration"`
}
// StatusOption is a function that configures a status message
type StatusOption func(*StatusMessage)
// WithCritical marks a status message as critical, causing it to be displayed immediately
func WithCritical(critical bool) StatusOption {
return func(msg *StatusMessage) {
msg.Critical = critical
}
}
// WithDuration sets a custom display duration for a status message
func WithDuration(duration time.Duration) StatusOption {
return func(msg *StatusMessage) {
msg.Duration = duration
}
}
const (
EventStatusPublished pubsub.EventType = "status_published"
)
type Service interface {
pubsub.Subscriber[StatusMessage]
Info(message string, opts ...StatusOption)
Warn(message string, opts ...StatusOption)
Error(message string, opts ...StatusOption)
Debug(message string, opts ...StatusOption)
}
type service struct {
broker *pubsub.Broker[StatusMessage]
mu sync.RWMutex
}
var globalStatusService *service
func InitService() error {
if globalStatusService != nil {
return fmt.Errorf("status service already initialized")
}
broker := pubsub.NewBroker[StatusMessage]()
globalStatusService = &service{
broker: broker,
}
return nil
}
func GetService() Service {
if globalStatusService == nil {
panic("status service not initialized. Call status.InitService() at application startup.")
}
return globalStatusService
}
func (s *service) Info(message string, opts ...StatusOption) {
s.publish(LevelInfo, message, opts...)
slog.Info(message)
}
func (s *service) Warn(message string, opts ...StatusOption) {
s.publish(LevelWarn, message, opts...)
slog.Warn(message)
}
func (s *service) Error(message string, opts ...StatusOption) {
s.publish(LevelError, message, opts...)
slog.Error(message)
}
func (s *service) Debug(message string, opts ...StatusOption) {
s.publish(LevelDebug, message, opts...)
slog.Debug(message)
}
func (s *service) publish(level Level, messageText string, opts ...StatusOption) {
statusMsg := StatusMessage{
Level: level,
Message: messageText,
Timestamp: time.Now(),
}
// Apply all options
for _, opt := range opts {
opt(&statusMsg)
}
s.broker.Publish(EventStatusPublished, statusMsg)
}
func (s *service) Subscribe(ctx context.Context) <-chan pubsub.Event[StatusMessage] {
return s.broker.Subscribe(ctx)
}
func Info(message string, opts ...StatusOption) {
GetService().Info(message, opts...)
}
func Warn(message string, opts ...StatusOption) {
GetService().Warn(message, opts...)
}
func Error(message string, opts ...StatusOption) {
GetService().Error(message, opts...)
}
func Debug(message string, opts ...StatusOption) {
GetService().Debug(message, opts...)
}
func Subscribe(ctx context.Context) <-chan pubsub.Event[StatusMessage] {
return GetService().Subscribe(ctx)
}

View File

@@ -0,0 +1,215 @@
package app
import (
"context"
"fmt"
"log/slog"
tea "github.com/charmbracelet/bubbletea"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/fileutil"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/tui/state"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/tui/util"
"github.com/sst/opencode/pkg/client"
)
type App struct {
Client *client.ClientWithResponses
Events *client.Client
Provider *client.ProviderInfo
Model *client.ProviderModel
Session *client.SessionInfo
Messages []client.MessageInfo
Status status.Service
PrimaryAgentOLD AgentService
// UI state
filepickerOpen bool
completionDialogOpen bool
}
func New(ctx context.Context) (*App, error) {
// Initialize status service (still needed for UI notifications)
err := status.InitService()
if err != nil {
slog.Error("Failed to initialize status service", "error", err)
return nil, err
}
// Initialize file utilities
fileutil.Init()
// Create HTTP client
url := "http://localhost:16713"
httpClient, err := client.NewClientWithResponses(url)
if err != nil {
slog.Error("Failed to create client", "error", err)
return nil, err
}
eventClient, err := client.NewClient(url)
if err != nil {
slog.Error("Failed to create event client", "error", err)
return nil, err
}
// Create service bridges
agentBridge := NewAgentServiceBridge(httpClient)
app := &App{
Client: httpClient,
Events: eventClient,
Session: &client.SessionInfo{},
Messages: []client.MessageInfo{},
PrimaryAgentOLD: agentBridge,
Status: status.GetService(),
}
// Initialize theme based on configuration
app.initTheme()
return app, nil
}
type Attachment struct {
FilePath string
FileName string
MimeType string
Content []byte
}
// Create creates a new session
func (a *App) SendChatMessage(ctx context.Context, text string, attachments []Attachment) tea.Cmd {
var cmds []tea.Cmd
if a.Session.Id == "" {
resp, err := a.Client.PostSessionCreateWithResponse(ctx)
if err != nil {
status.Error(err.Error())
return nil
}
if resp.StatusCode() != 200 {
status.Error(fmt.Sprintf("failed to create session: %d", resp.StatusCode()))
return nil
}
info := resp.JSON200
a.Session = info
cmds = append(cmds, util.CmdHandler(state.SessionSelectedMsg(info)))
}
// TODO: Handle attachments when API supports them
if len(attachments) > 0 {
// For now, ignore attachments
// return "", fmt.Errorf("attachments not supported yet")
}
part := client.MessagePart{}
part.FromMessagePartText(client.MessagePartText{
Type: "text",
Text: text,
})
parts := []client.MessagePart{part}
go a.Client.PostSessionChatWithResponse(ctx, client.PostSessionChatJSONRequestBody{
SessionID: a.Session.Id,
Parts: parts,
ProviderID: a.Provider.Id,
ModelID: a.Model.Id,
})
// The actual response will come through SSE
// For now, just return success
return tea.Batch(cmds...)
}
func (a *App) ListSessions(ctx context.Context) ([]client.SessionInfo, error) {
resp, err := a.Client.PostSessionListWithResponse(ctx)
if err != nil {
return nil, err
}
if resp.StatusCode() != 200 {
return nil, fmt.Errorf("failed to list sessions: %d", resp.StatusCode())
}
if resp.JSON200 == nil {
return []client.SessionInfo{}, nil
}
sessions := *resp.JSON200
return sessions, nil
}
func (a *App) ListMessages(ctx context.Context, sessionId string) ([]client.MessageInfo, error) {
resp, err := a.Client.PostSessionMessagesWithResponse(ctx, client.PostSessionMessagesJSONRequestBody{SessionID: sessionId})
if err != nil {
return nil, err
}
if resp.StatusCode() != 200 {
return nil, fmt.Errorf("failed to list messages: %d", resp.StatusCode())
}
if resp.JSON200 == nil {
return []client.MessageInfo{}, nil
}
messages := *resp.JSON200
return messages, nil
}
func (a *App) ListProviders(ctx context.Context) ([]client.ProviderInfo, error) {
resp, err := a.Client.PostProviderListWithResponse(ctx)
if err != nil {
return nil, err
}
if resp.StatusCode() != 200 {
return nil, fmt.Errorf("failed to list sessions: %d", resp.StatusCode())
}
if resp.JSON200 == nil {
return []client.ProviderInfo{}, nil
}
providers := *resp.JSON200
return providers, nil
}
// initTheme sets the application theme based on the configuration
func (app *App) initTheme() {
cfg := config.Get()
if cfg == nil || cfg.TUI.Theme == "" {
return // Use default theme
}
// Try to set the theme from config
err := theme.SetTheme(cfg.TUI.Theme)
if err != nil {
slog.Warn("Failed to set theme from config, using default theme", "theme", cfg.TUI.Theme, "error", err)
} else {
slog.Debug("Set theme from config", "theme", cfg.TUI.Theme)
}
}
// IsFilepickerOpen returns whether the filepicker is currently open
func (app *App) IsFilepickerOpen() bool {
return app.filepickerOpen
}
// SetFilepickerOpen sets the state of the filepicker
func (app *App) SetFilepickerOpen(open bool) {
app.filepickerOpen = open
}
// IsCompletionDialogOpen returns whether the completion dialog is currently open
func (app *App) IsCompletionDialogOpen() bool {
return app.completionDialogOpen
}
// SetCompletionDialogOpen sets the state of the completion dialog
func (app *App) SetCompletionDialogOpen(open bool) {
app.completionDialogOpen = open
}
// Shutdown performs a clean shutdown of the application
func (app *App) Shutdown() {
// TODO: cleanup?
}

View File

@@ -0,0 +1,42 @@
package app
import (
"context"
"fmt"
"github.com/sst/opencode/pkg/client"
)
// AgentServiceBridge provides a minimal agent service that sends messages to the API
type AgentServiceBridge struct {
client *client.ClientWithResponses
}
// NewAgentServiceBridge creates a new agent service bridge
func NewAgentServiceBridge(client *client.ClientWithResponses) *AgentServiceBridge {
return &AgentServiceBridge{client: client}
}
// Cancel cancels the current generation - NOT IMPLEMENTED IN API YET
func (a *AgentServiceBridge) Cancel(sessionID string) error {
// TODO: Not implemented in TypeScript API yet
return nil
}
// IsBusy checks if the agent is busy - NOT IMPLEMENTED IN API YET
func (a *AgentServiceBridge) IsBusy() bool {
// TODO: Not implemented in TypeScript API yet
return false
}
// IsSessionBusy checks if the agent is busy for a specific session - NOT IMPLEMENTED IN API YET
func (a *AgentServiceBridge) IsSessionBusy(sessionID string) bool {
// TODO: Not implemented in TypeScript API yet
return false
}
// CompactSession compacts a session - NOT IMPLEMENTED IN API YET
func (a *AgentServiceBridge) CompactSession(ctx context.Context, sessionID string, force bool) error {
// TODO: Not implemented in TypeScript API yet
return fmt.Errorf("session compaction not implemented in API")
}

View File

@@ -0,0 +1,13 @@
package app
import (
"context"
)
// AgentService defines the interface for agent operations
type AgentService interface {
Cancel(sessionID string) error
IsBusy() bool
IsSessionBusy(sessionID string) bool
CompactSession(ctx context.Context, sessionID string, force bool) error
}

View File

@@ -0,0 +1,133 @@
package chat
import (
"fmt"
"sort"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/tui/app"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/version"
)
type SendMsg struct {
Text string
Attachments []app.Attachment
}
func header(width int) string {
return lipgloss.JoinVertical(
lipgloss.Top,
logo(width),
repo(width),
"",
cwd(width),
)
}
func lspsConfigured(width int) string {
// cfg := config.Get()
title := "LSP Servers"
title = ansi.Truncate(title, width, "…")
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
lsps := baseStyle.
Width(width).
Foreground(t.Primary()).
Bold(true).
Render(title)
// Get LSP names and sort them for consistent ordering
var lspNames []string
// for name := range cfg.LSP {
// lspNames = append(lspNames, name)
// }
sort.Strings(lspNames)
var lspViews []string
// for _, name := range lspNames {
// lsp := cfg.LSP[name]
// lspName := baseStyle.
// Foreground(t.Text()).
// Render(fmt.Sprintf("• %s", name))
// cmd := lsp.Command
// cmd = ansi.Truncate(cmd, width-lipgloss.Width(lspName)-3, "…")
// lspPath := baseStyle.
// Foreground(t.TextMuted()).
// Render(fmt.Sprintf(" (%s)", cmd))
// lspViews = append(lspViews,
// baseStyle.
// Width(width).
// Render(
// lipgloss.JoinHorizontal(
// lipgloss.Left,
// lspName,
// lspPath,
// ),
// ),
// )
// }
return baseStyle.
Width(width).
Render(
lipgloss.JoinVertical(
lipgloss.Left,
lsps,
lipgloss.JoinVertical(
lipgloss.Left,
lspViews...,
),
),
)
}
func logo(width int) string {
logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode")
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
versionText := baseStyle.
Foreground(t.TextMuted()).
Render(version.Version)
return baseStyle.
Bold(true).
Width(width).
Render(
lipgloss.JoinHorizontal(
lipgloss.Left,
logo,
" ",
versionText,
),
)
}
func repo(width int) string {
repo := "github.com/sst/opencode"
t := theme.CurrentTheme()
return styles.BaseStyle().
Foreground(t.TextMuted()).
Width(width).
Render(repo)
}
func cwd(width int) string {
cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory())
t := theme.CurrentTheme()
return styles.BaseStyle().
Foreground(t.TextMuted()).
Width(width).
Render(cwd)
}

View File

@@ -0,0 +1,406 @@
package chat
import (
"fmt"
"log/slog"
"os"
"os/exec"
"slices"
"strings"
"unicode"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/tui/app"
"github.com/sst/opencode/internal/tui/components/dialog"
"github.com/sst/opencode/internal/tui/image"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/tui/util"
)
type editorCmp struct {
width int
height int
app *app.App
textarea textarea.Model
attachments []app.Attachment
deleteMode bool
history []string
historyIndex int
currentMessage string
}
type EditorKeyMaps struct {
Send key.Binding
OpenEditor key.Binding
Paste key.Binding
HistoryUp key.Binding
HistoryDown key.Binding
}
type bluredEditorKeyMaps struct {
Send key.Binding
Focus key.Binding
OpenEditor key.Binding
}
type DeleteAttachmentKeyMaps struct {
AttachmentDeleteMode key.Binding
Escape key.Binding
DeleteAllAttachments key.Binding
}
var editorMaps = EditorKeyMaps{
Send: key.NewBinding(
key.WithKeys("enter", "ctrl+s"),
key.WithHelp("enter", "send message"),
),
OpenEditor: key.NewBinding(
key.WithKeys("ctrl+e"),
key.WithHelp("ctrl+e", "open editor"),
),
Paste: key.NewBinding(
key.WithKeys("ctrl+v"),
key.WithHelp("ctrl+v", "paste content"),
),
HistoryUp: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("up", "previous message"),
),
HistoryDown: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("down", "next message"),
),
}
var DeleteKeyMaps = DeleteAttachmentKeyMaps{
AttachmentDeleteMode: key.NewBinding(
key.WithKeys("ctrl+r"),
key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "cancel delete mode"),
),
DeleteAllAttachments: key.NewBinding(
key.WithKeys("r"),
key.WithHelp("ctrl+r+r", "delete all attachments"),
),
}
const (
maxAttachments = 5
)
func (m *editorCmp) openEditor(value string) tea.Cmd {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "nvim"
}
tmpfile, err := os.CreateTemp("", "msg_*.md")
tmpfile.WriteString(value)
if err != nil {
status.Error(err.Error())
return nil
}
tmpfile.Close()
c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
return tea.ExecProcess(c, func(err error) tea.Msg {
if err != nil {
status.Error(err.Error())
return nil
}
content, err := os.ReadFile(tmpfile.Name())
if err != nil {
status.Error(err.Error())
return nil
}
if len(content) == 0 {
status.Warn("Message is empty")
return nil
}
os.Remove(tmpfile.Name())
attachments := m.attachments
m.attachments = nil
return SendMsg{
Text: string(content),
Attachments: attachments,
}
})
}
func (m *editorCmp) Init() tea.Cmd {
return textarea.Blink
}
func (m *editorCmp) send() tea.Cmd {
value := m.textarea.Value()
m.textarea.Reset()
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
if value == "" {
return nil
}
return tea.Batch(
util.CmdHandler(SendMsg{
Text: value,
Attachments: attachments,
}),
)
}
func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case dialog.ThemeChangedMsg:
m.textarea = CreateTextArea(&m.textarea)
case dialog.CompletionSelectedMsg:
existingValue := m.textarea.Value()
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
m.textarea.SetValue(modifiedValue)
return m, nil
case dialog.AttachmentAddedMsg:
if len(m.attachments) >= maxAttachments {
status.Error(fmt.Sprintf("cannot add more than %d images", maxAttachments))
return m, cmd
}
m.attachments = append(m.attachments, msg.Attachment)
case tea.KeyMsg:
if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
m.deleteMode = true
return m, nil
}
if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
m.deleteMode = false
m.attachments = nil
return m, nil
}
if m.deleteMode && len(msg.Runes) > 0 && unicode.IsDigit(msg.Runes[0]) {
num := int(msg.Runes[0] - '0')
m.deleteMode = false
if num < 10 && len(m.attachments) > num {
if num == 0 {
m.attachments = m.attachments[num+1:]
} else {
m.attachments = slices.Delete(m.attachments, num, num+1)
}
return m, nil
}
}
if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
return m, nil
}
if key.Matches(msg, editorMaps.OpenEditor) {
// if m.app.PrimaryAgentOLD.IsSessionBusy(m.app.CurrentSessionOLD.ID) {
// status.Warn("Agent is working, please wait...")
// return m, nil
// }
value := m.textarea.Value()
m.textarea.Reset()
return m, m.openEditor(value)
}
if key.Matches(msg, DeleteKeyMaps.Escape) {
m.deleteMode = false
return m, nil
}
if key.Matches(msg, editorMaps.Paste) {
imageBytes, text, err := image.GetImageFromClipboard()
if err != nil {
slog.Error(err.Error())
return m, cmd
}
if len(imageBytes) != 0 {
attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"}
m.attachments = append(m.attachments, attachment)
} else {
m.textarea.SetValue(m.textarea.Value() + text)
}
return m, cmd
}
// Handle history navigation with up/down arrow keys
// Only handle history navigation if the filepicker is not open and completion dialog is not open
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryUp) && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
// Get the current line number
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
}
}
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryDown) && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
// Get the current line number and total lines
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
}
}
// Handle Enter key
if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
value := m.textarea.Value()
if len(value) > 0 && value[len(value)-1] == '\\' {
// If the last character is a backslash, remove it and add a newline
m.textarea.SetValue(value[:len(value)-1] + "\n")
return m, nil
} else {
// Otherwise, send the message
return m, m.send()
}
}
}
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
func (m *editorCmp) View() string {
t := theme.CurrentTheme()
// Style the prompt with theme colors
style := lipgloss.NewStyle().
Padding(0, 0, 0, 1).
Bold(true).
Foreground(t.Primary())
if len(m.attachments) == 0 {
return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
}
m.textarea.SetHeight(m.height - 1)
return lipgloss.JoinVertical(lipgloss.Top,
m.attachmentsContent(),
lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
m.textarea.View()),
)
}
func (m *editorCmp) SetSize(width, height int) tea.Cmd {
m.width = width
m.height = height
m.textarea.SetWidth(width - 3) // account for the prompt and padding right
m.textarea.SetHeight(height)
return nil
}
func (m *editorCmp) GetSize() (int, int) {
return m.textarea.Width(), m.textarea.Height()
}
func (m *editorCmp) attachmentsContent() string {
var styledAttachments []string
t := theme.CurrentTheme()
attachmentStyles := styles.BaseStyle().
MarginLeft(1).
Background(t.TextMuted()).
Foreground(t.Text())
for i, attachment := range m.attachments {
var filename string
if len(attachment.FileName) > 10 {
filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
} else {
filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
}
if m.deleteMode {
filename = fmt.Sprintf("%d%s", i, filename)
}
styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
}
content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
return content
}
func (m *editorCmp) BindingKeys() []key.Binding {
bindings := []key.Binding{}
bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
return bindings
}
func CreateTextArea(existing *textarea.Model) textarea.Model {
t := theme.CurrentTheme()
bgColor := t.Background()
textColor := t.Text()
textMutedColor := t.TextMuted()
ta := textarea.New()
ta.BlurredStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.BlurredStyle.CursorLine = styles.BaseStyle().Background(bgColor)
ta.BlurredStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
ta.BlurredStyle.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.FocusedStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.FocusedStyle.CursorLine = styles.BaseStyle().Background(bgColor)
ta.FocusedStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
ta.FocusedStyle.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.Prompt = " "
ta.ShowLineNumbers = false
ta.CharLimit = -1
if existing != nil {
ta.SetValue(existing.Value())
ta.SetWidth(existing.Width())
ta.SetHeight(existing.Height())
}
ta.Focus()
return ta
}
func NewEditorCmp(app *app.App) tea.Model {
ta := CreateTextArea(nil)
return &editorCmp{
app: app,
textarea: ta,
history: []string{},
historyIndex: 0,
currentMessage: "",
}
}

View File

@@ -0,0 +1,716 @@
package chat
import (
"fmt"
"path/filepath"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/diff"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/pkg/client"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
const (
maxResultHeight = 10
)
func toMarkdown(content string, width int) string {
r := styles.GetMarkdownRenderer(width)
rendered, _ := r.Render(content)
return strings.TrimSuffix(rendered, "\n")
}
func renderUserMessage(msg client.MessageInfo, width int) string {
t := theme.CurrentTheme()
style := styles.BaseStyle().
BorderLeft(true).
Foreground(t.TextMuted()).
BorderForeground(t.Secondary()).
BorderStyle(lipgloss.ThickBorder())
baseStyle := styles.BaseStyle()
// var styledAttachments []string
// attachmentStyles := baseStyle.
// MarginLeft(1).
// Background(t.TextMuted()).
// Foreground(t.Text())
// for _, attachment := range msg.BinaryContent() {
// file := filepath.Base(attachment.Path)
// var filename string
// if len(file) > 10 {
// filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, file[0:7])
// } else {
// filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, file)
// }
// styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
// }
// Add timestamp info
timestamp := time.UnixMilli(int64(msg.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
username, _ := config.GetUsername()
info := baseStyle.
Foreground(t.TextMuted()).
Render(fmt.Sprintf(" %s (%s)", username, timestamp))
content := ""
// if len(styledAttachments) > 0 {
// attachmentContent := baseStyle.Width(width).Render(lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...))
// content = renderMessage(msg.Content().String(), true, isFocused, width, append(info, attachmentContent)...)
// } else {
for _, p := range msg.Parts {
part, err := p.ValueByDiscriminator()
if err != nil {
continue //TODO: handle error?
}
switch part.(type) {
case client.MessagePartText:
textPart := part.(client.MessagePartText)
text := toMarkdown(textPart.Text, width)
content = style.Render(lipgloss.JoinVertical(lipgloss.Left, text, info))
}
}
return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
}
func convertToMap(input *any) (map[string]any, bool) {
if input == nil {
return nil, false // Handle nil pointer
}
value := *input // Dereference the pointer to get the interface value
m, ok := value.(map[string]any) // Type assertion
return m, ok
}
func renderAssistantMessage(
msg client.MessageInfo,
width int,
showToolMessages bool,
) string {
t := theme.CurrentTheme()
style := styles.BaseStyle().
BorderLeft(true).
Foreground(t.TextMuted()).
BorderForeground(t.Primary()).
BorderStyle(lipgloss.ThickBorder())
toolStyle := styles.BaseStyle().
BorderLeft(true).
Foreground(t.TextMuted()).
BorderForeground(t.TextMuted()).
BorderStyle(lipgloss.ThickBorder())
baseStyle := styles.BaseStyle()
messages := []string{}
// content := strings.TrimSpace(msg.Content().String())
// thinking := msg.IsThinking()
// thinkingContent := msg.ReasoningContent().Thinking
// finished := msg.IsFinished()
// finishData := msg.FinishPart()
// Add timestamp info
timestamp := time.UnixMilli(int64(msg.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
modelName := msg.Metadata.Assistant.ModelID
info := baseStyle.
Foreground(t.TextMuted()).
Render(fmt.Sprintf(" %s (%s)", modelName, timestamp))
for _, p := range msg.Parts {
part, err := p.ValueByDiscriminator()
if err != nil {
continue //TODO: handle error?
}
switch part.(type) {
case client.MessagePartText:
textPart := part.(client.MessagePartText)
text := toMarkdown(textPart.Text, width)
content := style.Render(lipgloss.JoinVertical(lipgloss.Left, text, info))
message := styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
messages = append(messages, message)
case client.MessagePartToolInvocation:
if !showToolMessages {
continue
}
toolInvocationPart := part.(client.MessagePartToolInvocation)
toolInvocation, _ := toolInvocationPart.ToolInvocation.ValueByDiscriminator()
switch toolInvocation.(type) {
case client.MessageToolInvocationToolCall:
toolCall := toolInvocation.(client.MessageToolInvocationToolCall)
toolName := renderToolName(toolCall.ToolName)
var toolArgs []string
toolMap, _ := convertToMap(toolCall.Args)
for _, arg := range toolMap {
toolArgs = append(toolArgs, fmt.Sprintf("%v", arg))
}
params := renderParams(width-lipgloss.Width(toolName)-1, toolArgs...)
title := styles.Padded().Render(fmt.Sprintf("%s: %s", toolName, params))
content := toolStyle.Render(lipgloss.JoinVertical(lipgloss.Left,
title,
" In progress...",
))
message := styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
messages = append(messages, message)
case client.MessageToolInvocationToolResult:
toolInvocationResult := toolInvocation.(client.MessageToolInvocationToolResult)
toolName := renderToolName(toolInvocationResult.ToolName)
var toolArgs []string
toolMap, _ := convertToMap(toolInvocationResult.Args)
for _, arg := range toolMap {
toolArgs = append(toolArgs, fmt.Sprintf("%v", arg))
}
params := renderParams(width-lipgloss.Width(toolName)-1, toolArgs...)
title := styles.Padded().Render(fmt.Sprintf("%s: %s", toolName, params))
metadata := msg.Metadata.Tool[toolInvocationResult.ToolCallId].(map[string]any)
var markdown string
if toolInvocationResult.ToolName == "edit" {
filename := toolMap["filePath"].(string)
title = styles.Padded().Render(fmt.Sprintf("%s: %s", toolName, filename))
oldString := toolMap["oldString"].(string)
newString := toolMap["newString"].(string)
patch, _, _ := diff.GenerateDiff(oldString, newString, filename)
formattedDiff, _ := diff.FormatDiff(patch, diff.WithTotalWidth(width))
markdown = strings.TrimSpace(formattedDiff)
message := toolStyle.Render(lipgloss.JoinVertical(lipgloss.Left,
title,
markdown,
))
messages = append(messages, message)
} else if toolInvocationResult.ToolName == "view" {
result := toolInvocationResult.Result
if metadata["preview"] != nil {
result = metadata["preview"].(string)
}
filename := toolMap["filePath"].(string)
ext := filepath.Ext(filename)
if ext == "" {
ext = ""
} else {
ext = strings.ToLower(ext[1:])
}
result = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(result, 10))
markdown = toMarkdown(result, width)
content := toolStyle.Render(lipgloss.JoinVertical(lipgloss.Left,
title,
markdown,
))
message := styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
messages = append(messages, message)
} else {
result := truncateHeight(strings.TrimSpace(toolInvocationResult.Result), 10)
markdown = toMarkdown(result, width)
content := toolStyle.Render(lipgloss.JoinVertical(lipgloss.Left,
title,
markdown,
))
message := styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
messages = append(messages, message)
}
}
}
}
// if finished {
// // Add finish info if available
// switch finishData.Reason {
// case message.FinishReasonCanceled:
// info = append(info, baseStyle.
// Width(width-1).
// Foreground(t.Warning()).
// Render("(canceled)"),
// )
// case message.FinishReasonError:
// info = append(info, baseStyle.
// Width(width-1).
// Foreground(t.Error()).
// Render("(error)"),
// )
// case message.FinishReasonPermissionDenied:
// info = append(info, baseStyle.
// Width(width-1).
// Foreground(t.Info()).
// Render("(permission denied)"),
// )
// }
// }
// if content != "" || (finished && finishData.Reason == message.FinishReasonEndTurn) {
// if content == "" {
// content = "*Finished without output*"
// }
//
// content = renderMessage(content, false, width, info...)
// messages = append(messages, content)
// // position += messages[0].height
// position++ // for the space
// } else if thinking && thinkingContent != "" {
// // Render the thinking content with timestamp
// content = renderMessage(thinkingContent, false, width, info...)
// messages = append(messages, content)
// position += lipgloss.Height(content)
// position++ // for the space
// }
// Only render tool messages if they should be shown
if showToolMessages {
// for i, toolCall := range msg.ToolCalls() {
// toolCallContent := renderToolMessage(
// toolCall,
// allMessages,
// messagesService,
// focusedUIMessageId,
// false,
// width,
// i+1,
// )
// messages = append(messages, toolCallContent)
// }
}
return strings.Join(messages, "\n\n")
}
func renderToolName(name string) string {
switch name {
// case agent.AgentToolName:
// return "Task"
case "ls":
return "List"
default:
return cases.Title(language.Und).String(name)
}
}
func renderToolAction(name string) string {
switch name {
// case agent.AgentToolName:
// return "Preparing prompt..."
case "bash":
return "Building command..."
case "edit":
return "Preparing edit..."
case "fetch":
return "Writing fetch..."
case "glob":
return "Finding files..."
case "grep":
return "Searching content..."
case "ls":
return "Listing directory..."
case "view":
return "Reading file..."
case "write":
return "Preparing write..."
case "patch":
return "Preparing patch..."
case "batch":
return "Running batch operations..."
}
return "Working..."
}
// renders params, params[0] (params[1]=params[2] ....)
func renderParams(paramsWidth int, params ...string) string {
if len(params) == 0 {
return ""
}
mainParam := params[0]
if len(mainParam) > paramsWidth {
mainParam = mainParam[:paramsWidth-3] + "..."
}
if len(params) == 1 {
return mainParam
}
otherParams := params[1:]
// create pairs of key/value
// if odd number of params, the last one is a key without value
if len(otherParams)%2 != 0 {
otherParams = append(otherParams, "")
}
parts := make([]string, 0, len(otherParams)/2)
for i := 0; i < len(otherParams); i += 2 {
key := otherParams[i]
value := otherParams[i+1]
if value == "" {
continue
}
parts = append(parts, fmt.Sprintf("%s=%s", key, value))
}
partsRendered := strings.Join(parts, ", ")
remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 5 // for the space
if remainingWidth < 30 {
// No space for the params, just show the main
return mainParam
}
if len(parts) > 0 {
mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
}
return ansi.Truncate(mainParam, paramsWidth, "...")
}
func removeWorkingDirPrefix(path string) string {
wd := config.WorkingDirectory()
if strings.HasPrefix(path, wd) {
path = strings.TrimPrefix(path, wd)
}
if strings.HasPrefix(path, "/") {
path = strings.TrimPrefix(path, "/")
}
if strings.HasPrefix(path, "./") {
path = strings.TrimPrefix(path, "./")
}
if strings.HasPrefix(path, "../") {
path = strings.TrimPrefix(path, "../")
}
return path
}
func renderToolParams(paramWidth int, toolCall any) string {
params := ""
switch toolCall {
// // case agent.AgentToolName:
// // var params agent.AgentParams
// // json.Unmarshal([]byte(toolCall.Input), &params)
// // prompt := strings.ReplaceAll(params.Prompt, "\n", " ")
// // return renderParams(paramWidth, prompt)
// case "bash":
// var params tools.BashParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// command := strings.ReplaceAll(params.Command, "\n", " ")
// return renderParams(paramWidth, command)
// case "edit":
// var params tools.EditParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// filePath := removeWorkingDirPrefix(params.FilePath)
// return renderParams(paramWidth, filePath)
// case "fetch":
// var params tools.FetchParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// url := params.URL
// toolParams := []string{
// url,
// }
// if params.Format != "" {
// toolParams = append(toolParams, "format", params.Format)
// }
// if params.Timeout != 0 {
// toolParams = append(toolParams, "timeout", (time.Duration(params.Timeout) * time.Second).String())
// }
// return renderParams(paramWidth, toolParams...)
// case tools.GlobToolName:
// var params tools.GlobParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// pattern := params.Pattern
// toolParams := []string{
// pattern,
// }
// if params.Path != "" {
// toolParams = append(toolParams, "path", params.Path)
// }
// return renderParams(paramWidth, toolParams...)
// case tools.GrepToolName:
// var params tools.GrepParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// pattern := params.Pattern
// toolParams := []string{
// pattern,
// }
// if params.Path != "" {
// toolParams = append(toolParams, "path", params.Path)
// }
// if params.Include != "" {
// toolParams = append(toolParams, "include", params.Include)
// }
// if params.LiteralText {
// toolParams = append(toolParams, "literal", "true")
// }
// return renderParams(paramWidth, toolParams...)
// case tools.LSToolName:
// var params tools.LSParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// path := params.Path
// if path == "" {
// path = "."
// }
// return renderParams(paramWidth, path)
// case tools.ViewToolName:
// var params tools.ViewParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// filePath := removeWorkingDirPrefix(params.FilePath)
// toolParams := []string{
// filePath,
// }
// if params.Limit != 0 {
// toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
// }
// if params.Offset != 0 {
// toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
// }
// return renderParams(paramWidth, toolParams...)
// case tools.WriteToolName:
// var params tools.WriteParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// filePath := removeWorkingDirPrefix(params.FilePath)
// return renderParams(paramWidth, filePath)
// case tools.BatchToolName:
// var params tools.BatchParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// return renderParams(paramWidth, fmt.Sprintf("%d parallel calls", len(params.Calls)))
// default:
// input := strings.ReplaceAll(toolCall, "\n", " ")
// params = renderParams(paramWidth, input)
}
return params
}
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 renderToolResponse(toolCall any, response any, width int) string {
return ""
// t := theme.CurrentTheme()
// baseStyle := styles.BaseStyle()
//
// if response.IsError {
// errContent := fmt.Sprintf("Error: %s", strings.ReplaceAll(response.Content, "\n", " "))
// errContent = ansi.Truncate(errContent, width-1, "...")
// return baseStyle.
// Width(width).
// Foreground(t.Error()).
// Render(errContent)
// }
//
// resultContent := truncateHeight(response.Content, maxResultHeight)
// switch toolCall.Name {
// case agent.AgentToolName:
// return styles.ForceReplaceBackgroundWithLipgloss(
// toMarkdown(resultContent, false, width),
// t.Background(),
// )
// case tools.BashToolName:
// resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent)
// return styles.ForceReplaceBackgroundWithLipgloss(
// toMarkdown(resultContent, width),
// t.Background(),
// )
// case tools.EditToolName:
// metadata := tools.EditResponseMetadata{}
// json.Unmarshal([]byte(response.Metadata), &metadata)
// formattedDiff, _ := diff.FormatDiff(metadata.Diff, diff.WithTotalWidth(width))
// return formattedDiff
// case tools.FetchToolName:
// var params tools.FetchParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// mdFormat := "markdown"
// switch params.Format {
// case "text":
// mdFormat = "text"
// case "html":
// mdFormat = "html"
// }
// resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent)
// return styles.ForceReplaceBackgroundWithLipgloss(
// toMarkdown(resultContent, width),
// t.Background(),
// )
// case tools.GlobToolName:
// return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
// case tools.GrepToolName:
// return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
// case tools.LSToolName:
// return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
// case tools.ViewToolName:
// metadata := tools.ViewResponseMetadata{}
// json.Unmarshal([]byte(response.Metadata), &metadata)
// ext := filepath.Ext(metadata.FilePath)
// if ext == "" {
// ext = ""
// } else {
// ext = strings.ToLower(ext[1:])
// }
// resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight))
// return styles.ForceReplaceBackgroundWithLipgloss(
// toMarkdown(resultContent, width),
// t.Background(),
// )
// case tools.WriteToolName:
// params := tools.WriteParams{}
// json.Unmarshal([]byte(toolCall.Input), &params)
// metadata := tools.WriteResponseMetadata{}
// json.Unmarshal([]byte(response.Metadata), &metadata)
// ext := filepath.Ext(params.FilePath)
// if ext == "" {
// ext = ""
// } else {
// ext = strings.ToLower(ext[1:])
// }
// resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight))
// return styles.ForceReplaceBackgroundWithLipgloss(
// toMarkdown(resultContent, width),
// t.Background(),
// )
// case tools.BatchToolName:
// var batchResult tools.BatchResult
// if err := json.Unmarshal([]byte(resultContent), &batchResult); err != nil {
// return baseStyle.Width(width).Foreground(t.Error()).Render(fmt.Sprintf("Error parsing batch result: %s", err))
// }
//
// var toolCalls []string
// for i, result := range batchResult.Results {
// toolName := renderToolName(result.ToolName)
//
// // Format the tool input as a string
// inputStr := string(result.ToolInput)
//
// // Format the result
// var resultStr string
// if result.Error != "" {
// resultStr = fmt.Sprintf("Error: %s", result.Error)
// } else {
// var toolResponse tools.ToolResponse
// if err := json.Unmarshal(result.Result, &toolResponse); err != nil {
// resultStr = "Error parsing tool response"
// } else {
// resultStr = truncateHeight(toolResponse.Content, 3)
// }
// }
//
// // Format the tool call
// toolCall := fmt.Sprintf("%d. %s: %s\n %s", i+1, toolName, inputStr, resultStr)
// toolCalls = append(toolCalls, toolCall)
// }
//
// return baseStyle.Width(width).Foreground(t.TextMuted()).Render(strings.Join(toolCalls, "\n\n"))
// default:
// resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
// return styles.ForceReplaceBackgroundWithLipgloss(
// toMarkdown(resultContent, width),
// t.Background(),
// )
// }
}
// func renderToolMessage(
// toolCall message.ToolCall,
// allMessages []message.Message,
// messagesService message.Service,
// focusedUIMessageId string,
// nested bool,
// width int,
// position int,
// ) string {
// if nested {
// width = width - 3
// }
//
// t := theme.CurrentTheme()
// baseStyle := styles.BaseStyle()
//
// style := baseStyle.
// Width(width - 1).
// BorderLeft(true).
// BorderStyle(lipgloss.ThickBorder()).
// PaddingLeft(1).
// BorderForeground(t.TextMuted())
//
// response := findToolResponse(toolCall.ID, allMessages)
// toolNameText := baseStyle.Foreground(t.TextMuted()).
// Render(fmt.Sprintf("%s: ", renderToolName(toolCall.Name)))
//
// if !toolCall.Finished {
// // Get a brief description of what the tool is doing
// toolAction := renderToolAction(toolCall.Name)
//
// progressText := baseStyle.
// Width(width - 2 - lipgloss.Width(toolNameText)).
// Foreground(t.TextMuted()).
// Render(fmt.Sprintf("%s", toolAction))
//
// content := style.Render(lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, progressText))
// return content
// }
//
// params := renderToolParams(width-1-lipgloss.Width(toolNameText), toolCall)
// responseContent := ""
// if response != nil {
// responseContent = renderToolResponse(toolCall, *response, width-2)
// responseContent = strings.TrimSuffix(responseContent, "\n")
// } else {
// responseContent = baseStyle.
// Italic(true).
// Width(width - 2).
// Foreground(t.TextMuted()).
// Render("Waiting for response...")
// }
//
// parts := []string{}
// if !nested {
// formattedParams := baseStyle.
// Width(width - 2 - lipgloss.Width(toolNameText)).
// Foreground(t.TextMuted()).
// Render(params)
//
// parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, formattedParams))
// } else {
// prefix := baseStyle.
// Foreground(t.TextMuted()).
// Render(" └ ")
// formattedParams := baseStyle.
// Width(width - 2 - lipgloss.Width(toolNameText)).
// Foreground(t.TextMuted()).
// Render(params)
// parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolNameText, formattedParams))
// }
//
// // if toolCall.Name == agent.AgentToolName {
// // taskMessages, _ := messagesService.List(context.Background(), toolCall.ID)
// // toolCalls := []message.ToolCall{}
// // for _, v := range taskMessages {
// // toolCalls = append(toolCalls, v.ToolCalls()...)
// // }
// // for _, call := range toolCalls {
// // rendered := renderToolMessage(call, []message.Message{}, messagesService, focusedUIMessageId, true, width, 0)
// // parts = append(parts, rendered.content)
// // }
// // }
// if responseContent != "" && !nested {
// parts = append(parts, responseContent)
// }
//
// content := style.Render(
// lipgloss.JoinVertical(
// lipgloss.Left,
// parts...,
// ),
// )
// if nested {
// content = lipgloss.JoinVertical(
// lipgloss.Left,
// parts...,
// )
// }
// return content
// }

View File

@@ -0,0 +1,344 @@
package chat
import (
"fmt"
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/tui/app"
"github.com/sst/opencode/internal/tui/components/dialog"
"github.com/sst/opencode/internal/tui/state"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/pkg/client"
)
type messagesCmp struct {
app *app.App
width, height int
viewport viewport.Model
spinner spinner.Model
rendering bool
attachments viewport.Model
showToolMessages bool
}
type renderFinishedMsg struct{}
type ToggleToolMessagesMsg struct{}
type MessageKeys struct {
PageDown key.Binding
PageUp key.Binding
HalfPageUp key.Binding
HalfPageDown key.Binding
}
var messageKeys = MessageKeys{
PageDown: key.NewBinding(
key.WithKeys("pgdown"),
key.WithHelp("f/pgdn", "page down"),
),
PageUp: key.NewBinding(
key.WithKeys("pgup"),
key.WithHelp("b/pgup", "page up"),
),
HalfPageUp: key.NewBinding(
key.WithKeys("ctrl+u"),
key.WithHelp("ctrl+u", "½ page up"),
),
HalfPageDown: key.NewBinding(
key.WithKeys("ctrl+d", "ctrl+d"),
key.WithHelp("ctrl+d", "½ page down"),
),
}
func (m *messagesCmp) Init() tea.Cmd {
return tea.Batch(m.viewport.Init(), m.spinner.Tick)
}
func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case dialog.ThemeChangedMsg:
m.renderView()
return m, nil
case ToggleToolMessagesMsg:
m.showToolMessages = !m.showToolMessages
m.renderView()
return m, nil
case state.SessionSelectedMsg:
cmd := m.Reload()
return m, cmd
case state.SessionClearedMsg:
cmd := m.Reload()
return m, cmd
case tea.KeyMsg:
if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
u, cmd := m.viewport.Update(msg)
m.viewport = u
cmds = append(cmds, cmd)
}
case renderFinishedMsg:
m.rendering = false
m.viewport.GotoBottom()
case state.StateUpdatedMsg:
m.renderView()
m.viewport.GotoBottom()
}
spinner, cmd := m.spinner.Update(msg)
m.spinner = spinner
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m *messagesCmp) renderView() {
if m.width == 0 {
return
}
messages := make([]string, 0)
for _, msg := range m.app.Messages {
switch msg.Role {
case client.User:
content := renderUserMessage(msg, m.width)
messages = append(messages, content+"\n")
case client.Assistant:
content := renderAssistantMessage(msg, m.width, m.showToolMessages)
messages = append(messages, content+"\n")
}
}
m.viewport.SetContent(
styles.BaseStyle().
Render(
lipgloss.JoinVertical(
lipgloss.Top,
messages...,
),
),
)
}
func (m *messagesCmp) View() string {
baseStyle := styles.BaseStyle()
if m.rendering {
return baseStyle.
Width(m.width).
Render(
lipgloss.JoinVertical(
lipgloss.Top,
"Loading...",
m.working(),
m.help(),
),
)
}
if len(m.app.Messages) == 0 {
content := baseStyle.
Width(m.width).
Height(m.height - 1).
Render(
m.initialScreen(),
)
return baseStyle.
Width(m.width).
Render(
lipgloss.JoinVertical(
lipgloss.Top,
content,
"",
m.help(),
),
)
}
return baseStyle.
Width(m.width).
Render(
lipgloss.JoinVertical(
lipgloss.Top,
m.viewport.View(),
m.working(),
m.help(),
),
)
}
// func hasToolsWithoutResponse(messages []message.Message) bool {
// toolCalls := make([]message.ToolCall, 0)
// toolResults := make([]message.ToolResult, 0)
// for _, m := range messages {
// toolCalls = append(toolCalls, m.ToolCalls()...)
// toolResults = append(toolResults, m.ToolResults()...)
// }
//
// for _, v := range toolCalls {
// found := false
// for _, r := range toolResults {
// if v.ID == r.ToolCallID {
// found = true
// break
// }
// }
// if !found && v.Finished {
// return true
// }
// }
// return false
// }
// func hasUnfinishedToolCalls(messages []message.Message) bool {
// toolCalls := make([]message.ToolCall, 0)
// for _, m := range messages {
// toolCalls = append(toolCalls, m.ToolCalls()...)
// }
// for _, v := range toolCalls {
// if !v.Finished {
// return true
// }
// }
// return false
// }
func (m *messagesCmp) working() string {
text := ""
if len(m.app.Messages) > 0 {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
task := ""
lastMessage := m.app.Messages[len(m.app.Messages)-1]
if lastMessage.Metadata.Time.Completed == nil {
task = "Working..."
}
// lastMessage := m.app.Messages[len(m.app.Messages)-1]
// if hasToolsWithoutResponse(m.app.Messages) {
// task = "Waiting for tool response..."
// } else if hasUnfinishedToolCalls(m.app.Messages) {
// task = "Building tool call..."
// } else if !lastMessage.IsFinished() {
// task = "Generating..."
// }
if task != "" {
text += baseStyle.
Width(m.width).
Foreground(t.Primary()).
Bold(true).
Render(fmt.Sprintf("%s %s ", m.spinner.View(), task))
}
}
return text
}
func (m *messagesCmp) help() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
text := ""
if m.app.PrimaryAgentOLD.IsBusy() {
text += lipgloss.JoinHorizontal(
lipgloss.Left,
baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "),
baseStyle.Foreground(t.Text()).Bold(true).Render("esc"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to interrupt"),
)
} else {
text += lipgloss.JoinHorizontal(
lipgloss.Left,
baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to send,"),
baseStyle.Foreground(t.Text()).Bold(true).Render(" \\"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render("+"),
baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for newline,"),
baseStyle.Foreground(t.Text()).Bold(true).Render(" ↑↓"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for history,"),
baseStyle.Foreground(t.Text()).Bold(true).Render(" ctrl+h"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to toggle tool messages"),
)
}
return baseStyle.
Width(m.width).
Render(text)
}
func (m *messagesCmp) initialScreen() string {
baseStyle := styles.BaseStyle()
return baseStyle.Width(m.width).Render(
lipgloss.JoinVertical(
lipgloss.Top,
header(m.width),
"",
lspsConfigured(m.width),
),
)
}
func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
if m.width == width && m.height == height {
return nil
}
m.width = width
m.height = height
m.viewport.Width = width
m.viewport.Height = height - 2
m.attachments.Width = width + 40
m.attachments.Height = 3
m.renderView()
return nil
}
func (m *messagesCmp) GetSize() (int, int) {
return m.width, m.height
}
func (m *messagesCmp) Reload() tea.Cmd {
m.rendering = true
return func() tea.Msg {
m.renderView()
return renderFinishedMsg{}
}
}
func (m *messagesCmp) BindingKeys() []key.Binding {
return []key.Binding{
m.viewport.KeyMap.PageDown,
m.viewport.KeyMap.PageUp,
m.viewport.KeyMap.HalfPageUp,
m.viewport.KeyMap.HalfPageDown,
}
}
func NewMessagesCmp(app *app.App) tea.Model {
customSpinner := spinner.Spinner{
Frames: []string{" ", "┃", "┃"},
FPS: time.Second / 3,
}
s := spinner.New(spinner.WithSpinner(customSpinner))
vp := viewport.New(0, 0)
attachments := viewport.New(0, 0)
vp.KeyMap.PageUp = messageKeys.PageUp
vp.KeyMap.PageDown = messageKeys.PageDown
vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown
return &messagesCmp{
app: app,
viewport: vp,
spinner: s,
attachments: attachments,
showToolMessages: true,
}
}

View File

@@ -0,0 +1,220 @@
package chat
import (
"fmt"
"sort"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/tui/app"
"github.com/sst/opencode/internal/tui/state"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
)
type sidebarCmp struct {
app *app.App
width, height int
modFiles map[string]struct {
additions int
removals int
}
}
func (m *sidebarCmp) Init() tea.Cmd {
// TODO: History service not implemented in API yet
// Initialize the modified files map
m.modFiles = make(map[string]struct {
additions int
removals int
})
return nil
}
func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case state.SessionSelectedMsg:
// TODO: History service not implemented in API yet
// ctx := context.Background()
// m.loadModifiedFiles(ctx)
// case pubsub.Event[history.File]:
// TODO: History service not implemented in API yet
// if msg.Payload.SessionID == m.app.CurrentSession.ID {
// // Process the individual file change instead of reloading all files
// ctx := context.Background()
// m.processFileChanges(ctx, msg.Payload)
// }
}
return m, nil
}
func (m *sidebarCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
shareUrl := ""
if m.app.Session.Share != nil {
shareUrl = baseStyle.Foreground(t.TextMuted()).Render(m.app.Session.Share.Url)
}
// qrcode := ""
// if m.app.Session.ShareID != nil {
// url := "https://dev.opencode.ai/share?id="
// qrcode, _, _ = qr.Generate(url + m.app.Session.Id)
// }
return baseStyle.
Width(m.width).
PaddingLeft(4).
PaddingRight(1).
Render(
lipgloss.JoinVertical(
lipgloss.Top,
header(m.width),
" ",
m.sessionSection(),
shareUrl,
),
)
}
func (m *sidebarCmp) sessionSection() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
sessionKey := baseStyle.
Foreground(t.Primary()).
Bold(true).
Render("Session")
sessionValue := baseStyle.
Foreground(t.Text()).
Render(fmt.Sprintf(": %s", m.app.Session.Title))
return sessionKey + sessionValue
}
func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
stats := ""
if additions > 0 && removals > 0 {
additionsStr := baseStyle.
Foreground(t.Success()).
PaddingLeft(1).
Render(fmt.Sprintf("+%d", additions))
removalsStr := baseStyle.
Foreground(t.Error()).
PaddingLeft(1).
Render(fmt.Sprintf("-%d", removals))
content := lipgloss.JoinHorizontal(lipgloss.Left, additionsStr, removalsStr)
stats = baseStyle.Width(lipgloss.Width(content)).Render(content)
} else if additions > 0 {
additionsStr := fmt.Sprintf(" %s", baseStyle.
PaddingLeft(1).
Foreground(t.Success()).
Render(fmt.Sprintf("+%d", additions)))
stats = baseStyle.Width(lipgloss.Width(additionsStr)).Render(additionsStr)
} else if removals > 0 {
removalsStr := fmt.Sprintf(" %s", baseStyle.
PaddingLeft(1).
Foreground(t.Error()).
Render(fmt.Sprintf("-%d", removals)))
stats = baseStyle.Width(lipgloss.Width(removalsStr)).Render(removalsStr)
}
filePathStr := baseStyle.Render(filePath)
return baseStyle.
Width(m.width).
Render(
lipgloss.JoinHorizontal(
lipgloss.Left,
filePathStr,
stats,
),
)
}
func (m *sidebarCmp) modifiedFiles() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
modifiedFiles := baseStyle.
Width(m.width).
Foreground(t.Primary()).
Bold(true).
Render("Modified Files:")
// If no modified files, show a placeholder message
if m.modFiles == nil || len(m.modFiles) == 0 {
message := "No modified files"
remainingWidth := m.width - lipgloss.Width(message)
if remainingWidth > 0 {
message += strings.Repeat(" ", remainingWidth)
}
return baseStyle.
Width(m.width).
Render(
lipgloss.JoinVertical(
lipgloss.Top,
modifiedFiles,
baseStyle.Foreground(t.TextMuted()).Render(message),
),
)
}
// Sort file paths alphabetically for consistent ordering
var paths []string
for path := range m.modFiles {
paths = append(paths, path)
}
sort.Strings(paths)
// Create views for each file in sorted order
var fileViews []string
for _, path := range paths {
stats := m.modFiles[path]
fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
}
return baseStyle.
Width(m.width).
Render(
lipgloss.JoinVertical(
lipgloss.Top,
modifiedFiles,
lipgloss.JoinVertical(
lipgloss.Left,
fileViews...,
),
),
)
}
func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
m.width = width
m.height = height
return nil
}
func (m *sidebarCmp) GetSize() (int, int) {
return m.width, m.height
}
func NewSidebarCmp(app *app.App) tea.Model {
return &sidebarCmp{
app: app,
}
}
// Helper function to get the display path for a file
func getDisplayPath(path string) string {
workingDir := config.WorkingDirectory()
displayPath := strings.TrimPrefix(path, workingDir)
return strings.TrimPrefix(displayPath, "/")
}

View File

@@ -0,0 +1,366 @@
package core
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/pubsub"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/tui/app"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
)
type StatusCmp interface {
tea.Model
SetHelpWidgetMsg(string)
}
type statusCmp struct {
app *app.App
queue []status.StatusMessage
width int
messageTTL time.Duration
activeUntil time.Time
}
// clearMessageCmd is a command that clears status messages after a timeout
func (m statusCmp) clearMessageCmd() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return statusCleanupMsg{time: t}
})
}
// statusCleanupMsg is a message that triggers cleanup of expired status messages
type statusCleanupMsg struct {
time time.Time
}
func (m statusCmp) Init() tea.Cmd {
return m.clearMessageCmd()
}
func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
return m, nil
case pubsub.Event[status.StatusMessage]:
if msg.Type == status.EventStatusPublished {
// If this is a critical message, move it to the front of the queue
if msg.Payload.Critical {
// Insert at the front of the queue
m.queue = append([]status.StatusMessage{msg.Payload}, m.queue...)
// Reset active time to show critical message immediately
m.activeUntil = time.Time{}
} else {
// Otherwise, just add it to the queue
m.queue = append(m.queue, msg.Payload)
// If this is the first message and nothing is active, activate it immediately
if len(m.queue) == 1 && m.activeUntil.IsZero() {
now := time.Now()
duration := m.messageTTL
if msg.Payload.Duration > 0 {
duration = msg.Payload.Duration
}
m.activeUntil = now.Add(duration)
}
}
}
case statusCleanupMsg:
now := msg.time
// If the active message has expired, remove it and activate the next one
if !m.activeUntil.IsZero() && m.activeUntil.Before(now) {
// Current message expired, remove it if we have one
if len(m.queue) > 0 {
m.queue = m.queue[1:]
}
m.activeUntil = time.Time{}
}
// If we have messages in queue but none are active, activate the first one
if len(m.queue) > 0 && m.activeUntil.IsZero() {
// Use custom duration if specified, otherwise use default
duration := m.messageTTL
if m.queue[0].Duration > 0 {
duration = m.queue[0].Duration
}
m.activeUntil = now.Add(duration)
}
return m, m.clearMessageCmd()
}
return m, nil
}
var helpWidget = ""
// getHelpWidget returns the help widget with current theme colors
func getHelpWidget(helpText string) string {
t := theme.CurrentTheme()
if helpText == "" {
helpText = "ctrl+? help"
}
return styles.Padded().
Background(t.TextMuted()).
Foreground(t.BackgroundDarker()).
Bold(true).
Render(helpText)
}
func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) string {
// Format tokens in human-readable format (e.g., 110K, 1.2M)
var formattedTokens string
switch {
case tokens >= 1_000_000:
formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
case tokens >= 1_000:
formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
default:
formattedTokens = fmt.Sprintf("%d", int(tokens))
}
// Remove .0 suffix if present
if strings.HasSuffix(formattedTokens, ".0K") {
formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
}
if strings.HasSuffix(formattedTokens, ".0M") {
formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
}
// Format cost with $ symbol and 2 decimal places
formattedCost := fmt.Sprintf("$%.2f", cost)
percentage := (float64(tokens) / float64(contextWindow)) * 100
return fmt.Sprintf("Tokens: %s (%d%%), Cost: %s", formattedTokens, int(percentage), formattedCost)
}
func (m statusCmp) View() string {
t := theme.CurrentTheme()
// modelID := config.Get().Agents[config.AgentPrimary].Model
// model := models.SupportedModels[modelID]
// Initialize the help widget
status := getHelpWidget("")
if m.app.Session.Id != "" {
tokens := float32(0)
cost := float32(0)
contextWindow := float32(200_000) // TODO: Get context window from model
for _, message := range m.app.Messages {
if message.Metadata.Assistant != nil {
cost += message.Metadata.Assistant.Cost
usage := message.Metadata.Assistant.Tokens
tokens += (usage.Input + usage.Output + usage.Reasoning)
}
}
tokensInfo := styles.Padded().
Background(t.Text()).
Foreground(t.BackgroundSecondary()).
Render(formatTokensAndCost(tokens, contextWindow, cost))
status += tokensInfo
}
diagnostics := styles.Padded().Background(t.BackgroundDarker()).Render(m.projectDiagnostics())
modelName := m.model()
statusWidth := max(
0,
m.width-
lipgloss.Width(status)-
lipgloss.Width(modelName)-
lipgloss.Width(diagnostics),
)
const minInlineWidth = 30
// Display the first status message if available
var statusMessage string
if len(m.queue) > 0 {
sm := m.queue[0]
infoStyle := styles.Padded().
Foreground(t.Background())
switch sm.Level {
case "info":
infoStyle = infoStyle.Background(t.Info())
case "warn":
infoStyle = infoStyle.Background(t.Warning())
case "error":
infoStyle = infoStyle.Background(t.Error())
case "debug":
infoStyle = infoStyle.Background(t.TextMuted())
}
// Truncate message if it's longer than available width
msg := sm.Message
availWidth := statusWidth - 10
// If we have enough space, show inline
if availWidth >= minInlineWidth {
if len(msg) > availWidth && availWidth > 0 {
msg = msg[:availWidth] + "..."
}
status += infoStyle.Width(statusWidth).Render(msg)
} else {
// Otherwise, prepare a full-width message to show above
if len(msg) > m.width-10 && m.width > 10 {
msg = msg[:m.width-10] + "..."
}
statusMessage = infoStyle.Width(m.width).Render(msg)
// Add empty space in the status bar
status += styles.Padded().
Foreground(t.Text()).
Background(t.BackgroundSecondary()).
Width(statusWidth).
Render("")
}
} else {
status += styles.Padded().
Foreground(t.Text()).
Background(t.BackgroundSecondary()).
Width(statusWidth).
Render("")
}
status += diagnostics
status += modelName
// If we have a separate status message, prepend it
if statusMessage != "" {
return statusMessage + "\n" + status
} else {
blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
return blank + "\n" + status
}
}
func (m *statusCmp) projectDiagnostics() string {
t := theme.CurrentTheme()
// Check if any LSP server is still initializing
initializing := false
// for _, client := range m.app.LSPClients {
// if client.GetServerState() == lsp.StateStarting {
// initializing = true
// break
// }
// }
// If any server is initializing, show that status
if initializing {
return lipgloss.NewStyle().
Foreground(t.Warning()).
Render(fmt.Sprintf("%s Initializing LSP...", styles.SpinnerIcon))
}
// errorDiagnostics := []protocol.Diagnostic{}
// warnDiagnostics := []protocol.Diagnostic{}
// hintDiagnostics := []protocol.Diagnostic{}
// infoDiagnostics := []protocol.Diagnostic{}
// for _, client := range m.app.LSPClients {
// for _, d := range client.GetDiagnostics() {
// for _, diag := range d {
// switch diag.Severity {
// case protocol.SeverityError:
// errorDiagnostics = append(errorDiagnostics, diag)
// case protocol.SeverityWarning:
// warnDiagnostics = append(warnDiagnostics, diag)
// case protocol.SeverityHint:
// hintDiagnostics = append(hintDiagnostics, diag)
// case protocol.SeverityInformation:
// infoDiagnostics = append(infoDiagnostics, diag)
// }
// }
// }
// }
return styles.ForceReplaceBackgroundWithLipgloss(
styles.Padded().Render("No diagnostics"),
t.BackgroundDarker(),
)
// if len(errorDiagnostics) == 0 &&
// len(warnDiagnostics) == 0 &&
// len(infoDiagnostics) == 0 &&
// len(hintDiagnostics) == 0 {
// return styles.ForceReplaceBackgroundWithLipgloss(
// styles.Padded().Render("No diagnostics"),
// t.BackgroundDarker(),
// )
// }
// diagnostics := []string{}
//
// errStr := lipgloss.NewStyle().
// Background(t.BackgroundDarker()).
// Foreground(t.Error()).
// Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
// diagnostics = append(diagnostics, errStr)
//
// warnStr := lipgloss.NewStyle().
// Background(t.BackgroundDarker()).
// Foreground(t.Warning()).
// Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
// diagnostics = append(diagnostics, warnStr)
//
// infoStr := lipgloss.NewStyle().
// Background(t.BackgroundDarker()).
// Foreground(t.Info()).
// Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
// diagnostics = append(diagnostics, infoStr)
//
// hintStr := lipgloss.NewStyle().
// Background(t.BackgroundDarker()).
// Foreground(t.Text()).
// Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
// diagnostics = append(diagnostics, hintStr)
//
// return styles.ForceReplaceBackgroundWithLipgloss(
// styles.Padded().Render(strings.Join(diagnostics, " ")),
// t.BackgroundDarker(),
// )
}
func (m statusCmp) model() string {
t := theme.CurrentTheme()
model := "None"
if m.app.Model != nil {
model = *m.app.Model.Name
}
return styles.Padded().
Background(t.Secondary()).
Foreground(t.Background()).
Render(model)
}
func (m statusCmp) SetHelpWidgetMsg(s string) {
// Update the help widget text using the getHelpWidget function
helpWidget = getHelpWidget(s)
}
func NewStatusCmp(app *app.App) StatusCmp {
// Initialize the help widget with default text
helpWidget = getHelpWidget("")
statusComponent := &statusCmp{
app: app,
queue: []status.StatusMessage{},
messageTTL: 4 * time.Second,
activeUntil: time.Time{},
}
return statusComponent
}

View File

@@ -0,0 +1,257 @@
package dialog
import (
"fmt"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/tui/util"
)
type argumentsDialogKeyMap struct {
Enter key.Binding
Escape key.Binding
}
// ShortHelp implements key.Map.
func (k argumentsDialogKeyMap) ShortHelp() []key.Binding {
return []key.Binding{
key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "confirm"),
),
key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "cancel"),
),
}
}
// FullHelp implements key.Map.
func (k argumentsDialogKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{k.ShortHelp()}
}
// ShowMultiArgumentsDialogMsg is a message that is sent to show the multi-arguments dialog.
type ShowMultiArgumentsDialogMsg struct {
CommandID string
Content string
ArgNames []string
}
// CloseMultiArgumentsDialogMsg is a message that is sent when the multi-arguments dialog is closed.
type CloseMultiArgumentsDialogMsg struct {
Submit bool
CommandID string
Content string
Args map[string]string
}
// MultiArgumentsDialogCmp is a component that asks the user for multiple command arguments.
type MultiArgumentsDialogCmp struct {
width, height int
inputs []textinput.Model
focusIndex int
keys argumentsDialogKeyMap
commandID string
content string
argNames []string
}
// NewMultiArgumentsDialogCmp creates a new MultiArgumentsDialogCmp.
func NewMultiArgumentsDialogCmp(commandID, content string, argNames []string) MultiArgumentsDialogCmp {
t := theme.CurrentTheme()
inputs := make([]textinput.Model, len(argNames))
for i, name := range argNames {
ti := textinput.New()
ti.Placeholder = fmt.Sprintf("Enter value for %s...", name)
ti.Width = 40
ti.Prompt = ""
ti.PlaceholderStyle = ti.PlaceholderStyle.Background(t.Background())
ti.PromptStyle = ti.PromptStyle.Background(t.Background())
ti.TextStyle = ti.TextStyle.Background(t.Background())
// Only focus the first input initially
if i == 0 {
ti.Focus()
ti.PromptStyle = ti.PromptStyle.Foreground(t.Primary())
ti.TextStyle = ti.TextStyle.Foreground(t.Primary())
} else {
ti.Blur()
}
inputs[i] = ti
}
return MultiArgumentsDialogCmp{
inputs: inputs,
keys: argumentsDialogKeyMap{},
commandID: commandID,
content: content,
argNames: argNames,
focusIndex: 0,
}
}
// Init implements tea.Model.
func (m MultiArgumentsDialogCmp) Init() tea.Cmd {
// Make sure only the first input is focused
for i := range m.inputs {
if i == 0 {
m.inputs[i].Focus()
} else {
m.inputs[i].Blur()
}
}
return textinput.Blink
}
// Update implements tea.Model.
func (m MultiArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
t := theme.CurrentTheme()
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{
Submit: false,
CommandID: m.commandID,
Content: m.content,
Args: nil,
})
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
// If we're on the last input, submit the form
if m.focusIndex == len(m.inputs)-1 {
args := make(map[string]string)
for i, name := range m.argNames {
args[name] = m.inputs[i].Value()
}
return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{
Submit: true,
CommandID: m.commandID,
Content: m.content,
Args: args,
})
}
// Otherwise, move to the next input
m.inputs[m.focusIndex].Blur()
m.focusIndex++
m.inputs[m.focusIndex].Focus()
m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
// Move to the next input
m.inputs[m.focusIndex].Blur()
m.focusIndex = (m.focusIndex + 1) % len(m.inputs)
m.inputs[m.focusIndex].Focus()
m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
case key.Matches(msg, key.NewBinding(key.WithKeys("shift+tab"))):
// Move to the previous input
m.inputs[m.focusIndex].Blur()
m.focusIndex = (m.focusIndex - 1 + len(m.inputs)) % len(m.inputs)
m.inputs[m.focusIndex].Focus()
m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
// Update the focused input
var cmd tea.Cmd
m.inputs[m.focusIndex], cmd = m.inputs[m.focusIndex].Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
// View implements tea.Model.
func (m MultiArgumentsDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
// Calculate width needed for content
maxWidth := 60 // Width for explanation text
title := lipgloss.NewStyle().
Foreground(t.Primary()).
Bold(true).
Width(maxWidth).
Padding(0, 1).
Background(t.Background()).
Render("Command Arguments")
explanation := lipgloss.NewStyle().
Foreground(t.Text()).
Width(maxWidth).
Padding(0, 1).
Background(t.Background()).
Render("This command requires multiple arguments. Please enter values for each:")
// Create input fields for each argument
inputFields := make([]string, len(m.inputs))
for i, input := range m.inputs {
// Highlight the label of the focused input
labelStyle := lipgloss.NewStyle().
Width(maxWidth).
Padding(1, 1, 0, 1).
Background(t.Background())
if i == m.focusIndex {
labelStyle = labelStyle.Foreground(t.Primary()).Bold(true)
} else {
labelStyle = labelStyle.Foreground(t.TextMuted())
}
label := labelStyle.Render(m.argNames[i] + ":")
field := lipgloss.NewStyle().
Foreground(t.Text()).
Width(maxWidth).
Padding(0, 1).
Background(t.Background()).
Render(input.View())
inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field)
}
maxWidth = min(maxWidth, m.width-10)
// Join all elements vertically
elements := []string{title, explanation}
elements = append(elements, inputFields...)
content := lipgloss.JoinVertical(
lipgloss.Left,
elements...,
)
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Background(t.Background()).
Width(lipgloss.Width(content) + 4).
Render(content)
}
// SetSize sets the size of the component.
func (m *MultiArgumentsDialogCmp) SetSize(width, height int) {
m.width = width
m.height = height
}
// Bindings implements layout.Bindings.
func (m MultiArgumentsDialogCmp) Bindings() []key.Binding {
return m.keys.ShortHelp()
}

View File

@@ -0,0 +1,180 @@
package dialog
import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
utilComponents "github.com/sst/opencode/internal/tui/components/util"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/tui/util"
)
// Command represents a command that can be executed
type Command struct {
ID string
Title string
Description string
Handler func(cmd Command) tea.Cmd
}
func (ci Command) Render(selected bool, width int) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
descStyle := baseStyle.Width(width).Foreground(t.TextMuted())
itemStyle := baseStyle.Width(width).
Foreground(t.Text()).
Background(t.Background())
if selected {
itemStyle = itemStyle.
Background(t.Primary()).
Foreground(t.Background()).
Bold(true)
descStyle = descStyle.
Background(t.Primary()).
Foreground(t.Background())
}
title := itemStyle.Padding(0, 1).Render(ci.Title)
if ci.Description != "" {
description := descStyle.Padding(0, 1).Render(ci.Description)
return lipgloss.JoinVertical(lipgloss.Left, title, description)
}
return title
}
// CommandSelectedMsg is sent when a command is selected
type CommandSelectedMsg struct {
Command Command
}
// CloseCommandDialogMsg is sent when the command dialog is closed
type CloseCommandDialogMsg struct{}
// CommandDialog interface for the command selection dialog
type CommandDialog interface {
tea.Model
layout.Bindings
SetCommands(commands []Command)
}
type commandDialogCmp struct {
listView utilComponents.SimpleList[Command]
width int
height int
}
type commandKeyMap struct {
Enter key.Binding
Escape key.Binding
}
var commandKeys = commandKeyMap{
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select command"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "close"),
),
}
func (c *commandDialogCmp) Init() tea.Cmd {
return c.listView.Init()
}
func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, commandKeys.Enter):
selectedItem, idx := c.listView.GetSelectedItem()
if idx != -1 {
return c, util.CmdHandler(CommandSelectedMsg{
Command: selectedItem,
})
}
case key.Matches(msg, commandKeys.Escape):
return c, util.CmdHandler(CloseCommandDialogMsg{})
}
case tea.WindowSizeMsg:
c.width = msg.Width
c.height = msg.Height
}
u, cmd := c.listView.Update(msg)
c.listView = u.(utilComponents.SimpleList[Command])
cmds = append(cmds, cmd)
return c, tea.Batch(cmds...)
}
func (c *commandDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
maxWidth := 40
commands := c.listView.GetItems()
for _, cmd := range commands {
if len(cmd.Title) > maxWidth-4 {
maxWidth = len(cmd.Title) + 4
}
if cmd.Description != "" {
if len(cmd.Description) > maxWidth-4 {
maxWidth = len(cmd.Description) + 4
}
}
}
c.listView.SetMaxWidth(maxWidth)
title := baseStyle.
Foreground(t.Primary()).
Bold(true).
Width(maxWidth).
Padding(0, 1).
Render("Commands")
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
baseStyle.Width(maxWidth).Render(""),
baseStyle.Width(maxWidth).Render(c.listView.View()),
baseStyle.Width(maxWidth).Render(""),
)
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(lipgloss.Width(content) + 4).
Render(content)
}
func (c *commandDialogCmp) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(commandKeys)
}
func (c *commandDialogCmp) SetCommands(commands []Command) {
c.listView.SetItems(commands)
}
// NewCommandDialogCmp creates a new command selection dialog
func NewCommandDialogCmp() CommandDialog {
listView := utilComponents.NewSimpleList[Command](
[]Command{},
10,
"No commands available",
true,
)
return &commandDialogCmp{
listView: listView,
}
}

View File

@@ -0,0 +1,263 @@
package dialog
import (
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/status"
utilComponents "github.com/sst/opencode/internal/tui/components/util"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/tui/util"
)
type CompletionItem struct {
title string
Title string
Value string
}
type CompletionItemI interface {
utilComponents.SimpleListItem
GetValue() string
DisplayValue() string
}
func (ci *CompletionItem) Render(selected bool, width int) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
itemStyle := baseStyle.
Width(width).
Padding(0, 1)
if selected {
itemStyle = itemStyle.
Background(t.Background()).
Foreground(t.Primary()).
Bold(true)
}
title := itemStyle.Render(
ci.GetValue(),
)
return title
}
func (ci *CompletionItem) DisplayValue() string {
return ci.Title
}
func (ci *CompletionItem) GetValue() string {
return ci.Value
}
func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
return &completionItem
}
type CompletionProvider interface {
GetId() string
GetEntry() CompletionItemI
GetChildEntries(query string) ([]CompletionItemI, error)
}
type CompletionSelectedMsg struct {
SearchString string
CompletionValue string
}
type CompletionDialogCompleteItemMsg struct {
Value string
}
type CompletionDialogCloseMsg struct{}
type CompletionDialog interface {
tea.Model
layout.Bindings
SetWidth(width int)
}
type completionDialogCmp struct {
query string
completionProvider CompletionProvider
width int
height int
pseudoSearchTextArea textarea.Model
listView utilComponents.SimpleList[CompletionItemI]
}
type completionDialogKeyMap struct {
Complete key.Binding
Cancel key.Binding
}
var completionDialogKeys = completionDialogKeyMap{
Complete: key.NewBinding(
key.WithKeys("tab", "enter"),
),
Cancel: key.NewBinding(
key.WithKeys(" ", "esc", "backspace"),
),
}
func (c *completionDialogCmp) Init() tea.Cmd {
return nil
}
func (c *completionDialogCmp) complete(item CompletionItemI) tea.Cmd {
value := c.pseudoSearchTextArea.Value()
if value == "" {
return nil
}
return tea.Batch(
util.CmdHandler(CompletionSelectedMsg{
SearchString: value,
CompletionValue: item.GetValue(),
}),
c.close(),
)
}
func (c *completionDialogCmp) close() tea.Cmd {
c.listView.SetItems([]CompletionItemI{})
c.pseudoSearchTextArea.Reset()
c.pseudoSearchTextArea.Blur()
return util.CmdHandler(CompletionDialogCloseMsg{})
}
func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
if c.pseudoSearchTextArea.Focused() {
if !key.Matches(msg, completionDialogKeys.Complete) {
var cmd tea.Cmd
c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
cmds = append(cmds, cmd)
var query string
query = c.pseudoSearchTextArea.Value()
if query != "" {
query = query[1:]
}
if query != c.query {
items, err := c.completionProvider.GetChildEntries(query)
if err != nil {
status.Error(err.Error())
}
c.listView.SetItems(items)
c.query = query
}
u, cmd := c.listView.Update(msg)
c.listView = u.(utilComponents.SimpleList[CompletionItemI])
cmds = append(cmds, cmd)
}
switch {
case key.Matches(msg, completionDialogKeys.Complete):
item, i := c.listView.GetSelectedItem()
if i == -1 {
return c, nil
}
cmd := c.complete(item)
return c, cmd
case key.Matches(msg, completionDialogKeys.Cancel):
// Only close on backspace when there are no characters left
if msg.String() != "backspace" || len(c.pseudoSearchTextArea.Value()) <= 0 {
return c, c.close()
}
}
return c, tea.Batch(cmds...)
} else {
items, err := c.completionProvider.GetChildEntries("")
if err != nil {
status.Error(err.Error())
}
c.listView.SetItems(items)
c.pseudoSearchTextArea.SetValue(msg.String())
return c, c.pseudoSearchTextArea.Focus()
}
case tea.WindowSizeMsg:
c.width = msg.Width
c.height = msg.Height
}
return c, tea.Batch(cmds...)
}
func (c *completionDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
maxWidth := 40
completions := c.listView.GetItems()
for _, cmd := range completions {
title := cmd.DisplayValue()
if len(title) > maxWidth-4 {
maxWidth = len(title) + 4
}
}
c.listView.SetMaxWidth(maxWidth)
return baseStyle.Padding(0, 0).
Border(lipgloss.NormalBorder()).
BorderBottom(false).
BorderRight(false).
BorderLeft(false).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(c.width).
Render(c.listView.View())
}
func (c *completionDialogCmp) SetWidth(width int) {
c.width = width
}
func (c *completionDialogCmp) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(completionDialogKeys)
}
func NewCompletionDialogCmp(completionProvider CompletionProvider) CompletionDialog {
ti := textarea.New()
items, err := completionProvider.GetChildEntries("")
if err != nil {
status.Error(err.Error())
}
li := utilComponents.NewSimpleList(
items,
7,
"No file matches found",
false,
)
return &completionDialogCmp{
query: "",
completionProvider: completionProvider,
pseudoSearchTextArea: ti,
listView: li,
}
}

View File

@@ -0,0 +1,186 @@
package dialog
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/tui/util"
)
// Command prefix constants
const (
UserCommandPrefix = "user:"
ProjectCommandPrefix = "project:"
)
// namedArgPattern is a regex pattern to find named arguments in the format $NAME
var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
// LoadCustomCommands loads custom commands from both XDG_CONFIG_HOME and project data directory
func LoadCustomCommands() ([]Command, error) {
cfg := config.Get()
if cfg == nil {
return nil, fmt.Errorf("config not loaded")
}
var commands []Command
// Load user commands from XDG_CONFIG_HOME/opencode/commands
xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
if xdgConfigHome == "" {
// Default to ~/.config if XDG_CONFIG_HOME is not set
home, err := os.UserHomeDir()
if err == nil {
xdgConfigHome = filepath.Join(home, ".config")
}
}
if xdgConfigHome != "" {
userCommandsDir := filepath.Join(xdgConfigHome, "opencode", "commands")
userCommands, err := loadCommandsFromDir(userCommandsDir, UserCommandPrefix)
if err != nil {
// Log error but continue - we'll still try to load other commands
fmt.Printf("Warning: failed to load user commands from XDG_CONFIG_HOME: %v\n", err)
} else {
commands = append(commands, userCommands...)
}
}
// Load commands from $HOME/.opencode/commands
home, err := os.UserHomeDir()
if err == nil {
homeCommandsDir := filepath.Join(home, ".opencode", "commands")
homeCommands, err := loadCommandsFromDir(homeCommandsDir, UserCommandPrefix)
if err != nil {
// Log error but continue - we'll still try to load other commands
fmt.Printf("Warning: failed to load home commands: %v\n", err)
} else {
commands = append(commands, homeCommands...)
}
}
// Load project commands from data directory
projectCommandsDir := filepath.Join(cfg.Data.Directory, "commands")
projectCommands, err := loadCommandsFromDir(projectCommandsDir, ProjectCommandPrefix)
if err != nil {
// Log error but return what we have so far
fmt.Printf("Warning: failed to load project commands: %v\n", err)
} else {
commands = append(commands, projectCommands...)
}
return commands, nil
}
// loadCommandsFromDir loads commands from a specific directory with the given prefix
func loadCommandsFromDir(commandsDir string, prefix string) ([]Command, error) {
// Check if the commands directory exists
if _, err := os.Stat(commandsDir); os.IsNotExist(err) {
// Create the commands directory if it doesn't exist
if err := os.MkdirAll(commandsDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create commands directory %s: %w", commandsDir, err)
}
// Return empty list since we just created the directory
return []Command{}, nil
}
var commands []Command
// Walk through the commands directory and load all .md files
err := filepath.Walk(commandsDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Skip directories
if info.IsDir() {
return nil
}
// Only process markdown files
if !strings.HasSuffix(strings.ToLower(info.Name()), ".md") {
return nil
}
// Read the file content
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read command file %s: %w", path, err)
}
// Get the command ID from the file name without the .md extension
commandID := strings.TrimSuffix(info.Name(), filepath.Ext(info.Name()))
// Get relative path from commands directory
relPath, err := filepath.Rel(commandsDir, path)
if err != nil {
return fmt.Errorf("failed to get relative path for %s: %w", path, err)
}
// Create the command ID from the relative path
// Replace directory separators with colons
commandIDPath := strings.ReplaceAll(filepath.Dir(relPath), string(filepath.Separator), ":")
if commandIDPath != "." {
commandID = commandIDPath + ":" + commandID
}
// Create a command
command := Command{
ID: prefix + commandID,
Title: prefix + commandID,
Description: fmt.Sprintf("Custom command from %s", relPath),
Handler: func(cmd Command) tea.Cmd {
commandContent := string(content)
// Check for named arguments
matches := namedArgPattern.FindAllStringSubmatch(commandContent, -1)
if len(matches) > 0 {
// Extract unique argument names
argNames := make([]string, 0)
argMap := make(map[string]bool)
for _, match := range matches {
argName := match[1] // Group 1 is the name without $
if !argMap[argName] {
argMap[argName] = true
argNames = append(argNames, argName)
}
}
// Show multi-arguments dialog for all named arguments
return util.CmdHandler(ShowMultiArgumentsDialogMsg{
CommandID: cmd.ID,
Content: commandContent,
ArgNames: argNames,
})
}
// No arguments needed, run command directly
return util.CmdHandler(CommandRunCustomMsg{
Content: commandContent,
Args: nil, // No arguments
})
},
}
commands = append(commands, command)
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to load custom commands from %s: %w", commandsDir, err)
}
return commands, nil
}
// CommandRunCustomMsg is sent when a custom command is executed
type CommandRunCustomMsg struct {
Content string
Args map[string]string // Map of argument names to values
}

View File

@@ -0,0 +1,106 @@
package dialog
import (
"testing"
"regexp"
)
func TestNamedArgPattern(t *testing.T) {
testCases := []struct {
input string
expected []string
}{
{
input: "This is a test with $ARGUMENTS placeholder",
expected: []string{"ARGUMENTS"},
},
{
input: "This is a test with $FOO and $BAR placeholders",
expected: []string{"FOO", "BAR"},
},
{
input: "This is a test with $FOO_BAR and $BAZ123 placeholders",
expected: []string{"FOO_BAR", "BAZ123"},
},
{
input: "This is a test with no placeholders",
expected: []string{},
},
{
input: "This is a test with $FOO appearing twice: $FOO",
expected: []string{"FOO"},
},
{
input: "This is a test with $1INVALID placeholder",
expected: []string{},
},
}
for _, tc := range testCases {
matches := namedArgPattern.FindAllStringSubmatch(tc.input, -1)
// Extract unique argument names
argNames := make([]string, 0)
argMap := make(map[string]bool)
for _, match := range matches {
argName := match[1] // Group 1 is the name without $
if !argMap[argName] {
argMap[argName] = true
argNames = append(argNames, argName)
}
}
// Check if we got the expected number of arguments
if len(argNames) != len(tc.expected) {
t.Errorf("Expected %d arguments, got %d for input: %s", len(tc.expected), len(argNames), tc.input)
continue
}
// Check if we got the expected argument names
for _, expectedArg := range tc.expected {
found := false
for _, actualArg := range argNames {
if actualArg == expectedArg {
found = true
break
}
}
if !found {
t.Errorf("Expected argument %s not found in %v for input: %s", expectedArg, argNames, tc.input)
}
}
}
}
func TestRegexPattern(t *testing.T) {
pattern := regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
validMatches := []string{
"$FOO",
"$BAR",
"$FOO_BAR",
"$BAZ123",
"$ARGUMENTS",
}
invalidMatches := []string{
"$foo",
"$1BAR",
"$_FOO",
"FOO",
"$",
}
for _, valid := range validMatches {
if !pattern.MatchString(valid) {
t.Errorf("Expected %s to match, but it didn't", valid)
}
}
for _, invalid := range invalidMatches {
if pattern.MatchString(invalid) {
t.Errorf("Expected %s not to match, but it did", invalid)
}
}
}

View File

@@ -0,0 +1,485 @@
package dialog
import (
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"log/slog"
"github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/tui/app"
"github.com/sst/opencode/internal/tui/image"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/tui/util"
)
const (
maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
downArrow = "down"
upArrow = "up"
)
type FilePrickerKeyMap struct {
Enter key.Binding
Down key.Binding
Up key.Binding
Forward key.Binding
Backward key.Binding
OpenFilePicker key.Binding
Esc key.Binding
InsertCWD key.Binding
Paste key.Binding
}
var filePickerKeyMap = FilePrickerKeyMap{
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select file/enter directory"),
),
Down: key.NewBinding(
key.WithKeys("j", downArrow),
key.WithHelp("↓/j", "down"),
),
Up: key.NewBinding(
key.WithKeys("k", upArrow),
key.WithHelp("↑/k", "up"),
),
Forward: key.NewBinding(
key.WithKeys("l"),
key.WithHelp("l", "enter directory"),
),
Backward: key.NewBinding(
key.WithKeys("h", "backspace"),
key.WithHelp("h/backspace", "go back"),
),
OpenFilePicker: key.NewBinding(
key.WithKeys("ctrl+f"),
key.WithHelp("ctrl+f", "open file picker"),
),
Esc: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "close/exit"),
),
InsertCWD: key.NewBinding(
key.WithKeys("i"),
key.WithHelp("i", "manual path input"),
),
Paste: key.NewBinding(
key.WithKeys("ctrl+v"),
key.WithHelp("ctrl+v", "paste file/directory path"),
),
}
type filepickerCmp struct {
basePath string
width int
height int
cursor int
err error
cursorChain stack
viewport viewport.Model
dirs []os.DirEntry
cwdDetails *DirNode
selectedFile string
cwd textinput.Model
ShowFilePicker bool
app *app.App
}
type DirNode struct {
parent *DirNode
child *DirNode
directory string
}
type stack []int
func (s stack) Push(v int) stack {
return append(s, v)
}
func (s stack) Pop() (stack, int) {
l := len(s)
return s[:l-1], s[l-1]
}
type AttachmentAddedMsg struct {
Attachment app.Attachment
}
func (f *filepickerCmp) Init() tea.Cmd {
return nil
}
func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
f.width = 60
f.height = 20
f.viewport.Width = 80
f.viewport.Height = 22
f.cursor = 0
f.getCurrentFileBelowCursor()
case tea.KeyMsg:
if f.cwd.Focused() {
f.cwd, cmd = f.cwd.Update(msg)
}
switch {
case key.Matches(msg, filePickerKeyMap.InsertCWD):
f.cwd.Focus()
return f, cmd
case key.Matches(msg, filePickerKeyMap.Esc):
if f.cwd.Focused() {
f.cwd.Blur()
}
case key.Matches(msg, filePickerKeyMap.Down):
if !f.cwd.Focused() || msg.String() == downArrow {
if f.cursor < len(f.dirs)-1 {
f.cursor++
f.getCurrentFileBelowCursor()
}
}
case key.Matches(msg, filePickerKeyMap.Up):
if !f.cwd.Focused() || msg.String() == upArrow {
if f.cursor > 0 {
f.cursor--
f.getCurrentFileBelowCursor()
}
}
case key.Matches(msg, filePickerKeyMap.Enter):
var path string
var isPathDir bool
if f.cwd.Focused() {
path = f.cwd.Value()
fileInfo, err := os.Stat(path)
if err != nil {
status.Error("Invalid path")
return f, cmd
}
isPathDir = fileInfo.IsDir()
} else {
path = filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
isPathDir = f.dirs[f.cursor].IsDir()
}
if isPathDir {
newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
f.cwdDetails.child = &newWorkingDir
f.cwdDetails = f.cwdDetails.child
f.cursorChain = f.cursorChain.Push(f.cursor)
f.dirs = readDir(f.cwdDetails.directory, false)
f.cursor = 0
f.cwd.SetValue(f.cwdDetails.directory)
f.getCurrentFileBelowCursor()
} else {
f.selectedFile = path
return f.addAttachmentToMessage()
}
case key.Matches(msg, filePickerKeyMap.Esc):
if !f.cwd.Focused() {
f.cursorChain = make(stack, 0)
f.cursor = 0
} else {
f.cwd.Blur()
}
case key.Matches(msg, filePickerKeyMap.Forward):
if !f.cwd.Focused() {
if f.dirs[f.cursor].IsDir() {
path := filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
f.cwdDetails.child = &newWorkingDir
f.cwdDetails = f.cwdDetails.child
f.cursorChain = f.cursorChain.Push(f.cursor)
f.dirs = readDir(f.cwdDetails.directory, false)
f.cursor = 0
f.cwd.SetValue(f.cwdDetails.directory)
f.getCurrentFileBelowCursor()
}
}
case key.Matches(msg, filePickerKeyMap.Backward):
if !f.cwd.Focused() {
if len(f.cursorChain) != 0 && f.cwdDetails.parent != nil {
f.cursorChain, f.cursor = f.cursorChain.Pop()
f.cwdDetails = f.cwdDetails.parent
f.cwdDetails.child = nil
f.dirs = readDir(f.cwdDetails.directory, false)
f.cwd.SetValue(f.cwdDetails.directory)
f.getCurrentFileBelowCursor()
}
}
case key.Matches(msg, filePickerKeyMap.Paste):
if f.cwd.Focused() {
val, err := clipboard.ReadAll()
if err != nil {
slog.Error("failed to read clipboard")
return f, cmd
}
f.cwd.SetValue(f.cwd.Value() + val)
}
case key.Matches(msg, filePickerKeyMap.OpenFilePicker):
f.dirs = readDir(f.cwdDetails.directory, false)
f.cursor = 0
f.getCurrentFileBelowCursor()
}
}
return f, cmd
}
func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) {
// modeInfo := GetSelectedModel(config.Get())
// if !modeInfo.SupportsAttachments {
// status.Error(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name))
// return f, nil
// }
selectedFilePath := f.selectedFile
if !isExtSupported(selectedFilePath) {
status.Error("Unsupported file")
return f, nil
}
isFileLarge, err := image.ValidateFileSize(selectedFilePath, maxAttachmentSize)
if err != nil {
status.Error("unable to read the image")
return f, nil
}
if isFileLarge {
status.Error("file too large, max 5MB")
return f, nil
}
content, err := os.ReadFile(selectedFilePath)
if err != nil {
status.Error("Unable read selected file")
return f, nil
}
mimeBufferSize := min(512, len(content))
mimeType := http.DetectContentType(content[:mimeBufferSize])
fileName := filepath.Base(selectedFilePath)
attachment := app.Attachment{FilePath: selectedFilePath, FileName: fileName, MimeType: mimeType, Content: content}
f.selectedFile = ""
return f, util.CmdHandler(AttachmentAddedMsg{attachment})
}
func (f *filepickerCmp) View() string {
t := theme.CurrentTheme()
const maxVisibleDirs = 20
const maxWidth = 80
adjustedWidth := maxWidth
for _, file := range f.dirs {
if len(file.Name()) > adjustedWidth-4 { // Account for padding
adjustedWidth = len(file.Name()) + 4
}
}
adjustedWidth = max(30, min(adjustedWidth, f.width-15)) + 1
files := make([]string, 0, maxVisibleDirs)
startIdx := 0
if len(f.dirs) > maxVisibleDirs {
halfVisible := maxVisibleDirs / 2
if f.cursor >= halfVisible && f.cursor < len(f.dirs)-halfVisible {
startIdx = f.cursor - halfVisible
} else if f.cursor >= len(f.dirs)-halfVisible {
startIdx = len(f.dirs) - maxVisibleDirs
}
}
endIdx := min(startIdx+maxVisibleDirs, len(f.dirs))
for i := startIdx; i < endIdx; i++ {
file := f.dirs[i]
itemStyle := styles.BaseStyle().Width(adjustedWidth)
if i == f.cursor {
itemStyle = itemStyle.
Background(t.Primary()).
Foreground(t.Background()).
Bold(true)
}
filename := file.Name()
if len(filename) > adjustedWidth-4 {
filename = filename[:adjustedWidth-7] + "..."
}
if file.IsDir() {
filename = filename + "/"
}
files = append(files, itemStyle.Padding(0, 1).Render(filename))
}
// Pad to always show exactly 21 lines
for len(files) < maxVisibleDirs {
files = append(files, styles.BaseStyle().Width(adjustedWidth).Render(""))
}
currentPath := styles.BaseStyle().
Height(1).
Width(adjustedWidth).
Render(f.cwd.View())
viewportstyle := lipgloss.NewStyle().
Width(f.viewport.Width).
Background(t.Background()).
Border(lipgloss.RoundedBorder()).
BorderForeground(t.TextMuted()).
BorderBackground(t.Background()).
Padding(2).
Render(f.viewport.View())
var insertExitText string
if f.IsCWDFocused() {
insertExitText = "Press esc to exit typing path"
} else {
insertExitText = "Press i to start typing path"
}
content := lipgloss.JoinVertical(
lipgloss.Left,
currentPath,
styles.BaseStyle().Width(adjustedWidth).Render(""),
styles.BaseStyle().Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)),
styles.BaseStyle().Width(adjustedWidth).Render(""),
styles.BaseStyle().Foreground(t.TextMuted()).Width(adjustedWidth).Render(insertExitText),
)
f.cwd.SetValue(f.cwd.Value())
contentStyle := styles.BaseStyle().Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(lipgloss.Width(content) + 4)
return lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle)
}
type FilepickerCmp interface {
tea.Model
ToggleFilepicker(showFilepicker bool)
IsCWDFocused() bool
}
func (f *filepickerCmp) ToggleFilepicker(showFilepicker bool) {
f.ShowFilePicker = showFilepicker
}
func (f *filepickerCmp) IsCWDFocused() bool {
return f.cwd.Focused()
}
func NewFilepickerCmp(app *app.App) FilepickerCmp {
homepath, err := os.UserHomeDir()
if err != nil {
slog.Error("error loading user files")
return nil
}
baseDir := DirNode{parent: nil, directory: homepath}
dirs := readDir(homepath, false)
viewport := viewport.New(0, 0)
currentDirectory := textinput.New()
currentDirectory.CharLimit = 200
currentDirectory.Width = 44
currentDirectory.Cursor.Blink = true
currentDirectory.SetValue(baseDir.directory)
return &filepickerCmp{cwdDetails: &baseDir, dirs: dirs, cursorChain: make(stack, 0), viewport: viewport, cwd: currentDirectory, app: app}
}
func (f *filepickerCmp) getCurrentFileBelowCursor() {
if len(f.dirs) == 0 || f.cursor < 0 || f.cursor >= len(f.dirs) {
slog.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor))
f.viewport.SetContent("Preview unavailable")
return
}
dir := f.dirs[f.cursor]
filename := dir.Name()
if !dir.IsDir() && isExtSupported(filename) {
fullPath := f.cwdDetails.directory + "/" + dir.Name()
go func() {
imageString, err := image.ImagePreview(f.viewport.Width-4, fullPath)
if err != nil {
slog.Error(err.Error())
f.viewport.SetContent("Preview unavailable")
return
}
f.viewport.SetContent(imageString)
}()
} else {
f.viewport.SetContent("Preview unavailable")
}
}
func readDir(path string, showHidden bool) []os.DirEntry {
slog.Info(fmt.Sprintf("Reading directory: %s", path))
entriesChan := make(chan []os.DirEntry, 1)
errChan := make(chan error, 1)
go func() {
dirEntries, err := os.ReadDir(path)
if err != nil {
status.Error(err.Error())
errChan <- err
return
}
entriesChan <- dirEntries
}()
select {
case dirEntries := <-entriesChan:
sort.Slice(dirEntries, func(i, j int) bool {
if dirEntries[i].IsDir() == dirEntries[j].IsDir() {
return dirEntries[i].Name() < dirEntries[j].Name()
}
return dirEntries[i].IsDir()
})
if showHidden {
return dirEntries
}
var sanitizedDirEntries []os.DirEntry
for _, dirEntry := range dirEntries {
isHidden, _ := IsHidden(dirEntry.Name())
if !isHidden {
if dirEntry.IsDir() || isExtSupported(dirEntry.Name()) {
sanitizedDirEntries = append(sanitizedDirEntries, dirEntry)
}
}
}
return sanitizedDirEntries
case <-errChan:
status.Error(fmt.Sprintf("Error reading directory %s", path))
return []os.DirEntry{}
case <-time.After(5 * time.Second):
status.Error(fmt.Sprintf("Timeout reading directory %s", path))
return []os.DirEntry{}
}
}
func IsHidden(file string) (bool, error) {
return strings.HasPrefix(file, "."), nil
}
func isExtSupported(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
return (ext == ".jpg" || ext == ".jpeg" || ext == ".webp" || ext == ".png")
}

View File

@@ -0,0 +1,200 @@
package dialog
import (
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
)
type helpCmp struct {
width int
height int
keys []key.Binding
}
func (h *helpCmp) Init() tea.Cmd {
return nil
}
func (h *helpCmp) SetBindings(k []key.Binding) {
h.keys = k
}
func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
h.width = 90
h.height = msg.Height
}
return h, nil
}
func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
seen := make(map[string]struct{})
result := make([]key.Binding, 0, len(bindings))
// Process bindings in reverse order
for i := len(bindings) - 1; i >= 0; i-- {
b := bindings[i]
k := strings.Join(b.Keys(), " ")
if _, ok := seen[k]; ok {
// duplicate, skip
continue
}
seen[k] = struct{}{}
// Add to the beginning of result to maintain original order
result = append([]key.Binding{b}, result...)
}
return result
}
func (h *helpCmp) render() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
helpKeyStyle := styles.Bold().
Background(t.Background()).
Foreground(t.Text()).
Padding(0, 1, 0, 0)
helpDescStyle := styles.Regular().
Background(t.Background()).
Foreground(t.TextMuted())
// Compile list of bindings to render
bindings := removeDuplicateBindings(h.keys)
// Enumerate through each group of bindings, populating a series of
// pairs of columns, one for keys, one for descriptions
var (
pairs []string
width int
rows = 12 - 2
)
for i := 0; i < len(bindings); i += rows {
var (
keys []string
descs []string
)
for j := i; j < min(i+rows, len(bindings)); j++ {
keys = append(keys, helpKeyStyle.Render(bindings[j].Help().Key))
descs = append(descs, helpDescStyle.Render(bindings[j].Help().Desc))
}
// Render pair of columns; beyond the first pair, render a three space
// left margin, in order to visually separate the pairs.
var cols []string
if len(pairs) > 0 {
cols = []string{baseStyle.Render(" ")}
}
maxDescWidth := 0
for _, desc := range descs {
if maxDescWidth < lipgloss.Width(desc) {
maxDescWidth = lipgloss.Width(desc)
}
}
for i := range descs {
remainingWidth := maxDescWidth - lipgloss.Width(descs[i])
if remainingWidth > 0 {
descs[i] = descs[i] + baseStyle.Render(strings.Repeat(" ", remainingWidth))
}
}
maxKeyWidth := 0
for _, key := range keys {
if maxKeyWidth < lipgloss.Width(key) {
maxKeyWidth = lipgloss.Width(key)
}
}
for i := range keys {
remainingWidth := maxKeyWidth - lipgloss.Width(keys[i])
if remainingWidth > 0 {
keys[i] = keys[i] + baseStyle.Render(strings.Repeat(" ", remainingWidth))
}
}
cols = append(cols,
strings.Join(keys, "\n"),
strings.Join(descs, "\n"),
)
pair := baseStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, cols...))
// check whether it exceeds the maximum width avail (the width of the
// terminal, subtracting 2 for the borders).
width += lipgloss.Width(pair)
if width > h.width-2 {
break
}
pairs = append(pairs, pair)
}
// https://github.com/charmbracelet/lipgloss/issues/209
if len(pairs) > 1 {
prefix := pairs[:len(pairs)-1]
lastPair := pairs[len(pairs)-1]
prefix = append(prefix, lipgloss.Place(
lipgloss.Width(lastPair), // width
lipgloss.Height(prefix[0]), // height
lipgloss.Left, // x
lipgloss.Top, // y
lastPair, // content
lipgloss.WithWhitespaceBackground(t.Background()),
))
content := baseStyle.Width(h.width).Render(
lipgloss.JoinHorizontal(
lipgloss.Top,
prefix...,
),
)
return content
}
// Join pairs of columns and enclose in a border
content := baseStyle.Width(h.width).Render(
lipgloss.JoinHorizontal(
lipgloss.Top,
pairs...,
),
)
return content
}
func (h *helpCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
content := h.render()
header := baseStyle.
Bold(true).
Width(lipgloss.Width(content)).
Foreground(t.Primary()).
Render("Keyboard Shortcuts")
return baseStyle.Padding(1).
Border(lipgloss.RoundedBorder()).
BorderForeground(t.TextMuted()).
Width(h.width).
BorderBackground(t.Background()).
Render(
lipgloss.JoinVertical(lipgloss.Center,
header,
baseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))),
content,
),
)
}
type HelpCmp interface {
tea.Model
SetBindings([]key.Binding)
}
func NewHelpCmp() HelpCmp {
return &helpCmp{}
}

View File

@@ -0,0 +1,189 @@
package dialog
import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/tui/util"
)
// InitDialogCmp is a component that asks the user if they want to initialize the project.
type InitDialogCmp struct {
width, height int
selected int
keys initDialogKeyMap
}
// NewInitDialogCmp creates a new InitDialogCmp.
func NewInitDialogCmp() InitDialogCmp {
return InitDialogCmp{
selected: 0,
keys: initDialogKeyMap{},
}
}
type initDialogKeyMap struct {
Tab key.Binding
Left key.Binding
Right key.Binding
Enter key.Binding
Escape key.Binding
Y key.Binding
N key.Binding
}
// ShortHelp implements key.Map.
func (k initDialogKeyMap) ShortHelp() []key.Binding {
return []key.Binding{
key.NewBinding(
key.WithKeys("tab", "left", "right"),
key.WithHelp("tab/←/→", "toggle selection"),
),
key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "confirm"),
),
key.NewBinding(
key.WithKeys("esc", "q"),
key.WithHelp("esc/q", "cancel"),
),
key.NewBinding(
key.WithKeys("y", "n"),
key.WithHelp("y/n", "yes/no"),
),
}
}
// FullHelp implements key.Map.
func (k initDialogKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{k.ShortHelp()}
}
// Init implements tea.Model.
func (m InitDialogCmp) Init() tea.Cmd {
return nil
}
// Update implements tea.Model.
func (m InitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
case key.Matches(msg, key.NewBinding(key.WithKeys("tab", "left", "right", "h", "l"))):
m.selected = (m.selected + 1) % 2
return m, nil
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
return m, util.CmdHandler(CloseInitDialogMsg{Initialize: m.selected == 0})
case key.Matches(msg, key.NewBinding(key.WithKeys("y"))):
return m, util.CmdHandler(CloseInitDialogMsg{Initialize: true})
case key.Matches(msg, key.NewBinding(key.WithKeys("n"))):
return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
return m, nil
}
// View implements tea.Model.
func (m InitDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
// Calculate width needed for content
maxWidth := 60 // Width for explanation text
title := baseStyle.
Foreground(t.Primary()).
Bold(true).
Width(maxWidth).
Padding(0, 1).
Render("Initialize Project")
explanation := baseStyle.
Foreground(t.Text()).
Width(maxWidth).
Padding(0, 1).
Render("Initialization generates a new CONTEXT.md file that contains information about your codebase, this file serves as memory for each project, you can freely add to it to help the agents be better at their job.")
question := baseStyle.
Foreground(t.Text()).
Width(maxWidth).
Padding(1, 1).
Render("Would you like to initialize this project?")
maxWidth = min(maxWidth, m.width-10)
yesStyle := baseStyle
noStyle := baseStyle
if m.selected == 0 {
yesStyle = yesStyle.
Background(t.Primary()).
Foreground(t.Background()).
Bold(true)
noStyle = noStyle.
Background(t.Background()).
Foreground(t.Primary())
} else {
noStyle = noStyle.
Background(t.Primary()).
Foreground(t.Background()).
Bold(true)
yesStyle = yesStyle.
Background(t.Background()).
Foreground(t.Primary())
}
yes := yesStyle.Padding(0, 3).Render("Yes")
no := noStyle.Padding(0, 3).Render("No")
buttons := lipgloss.JoinHorizontal(lipgloss.Center, yes, baseStyle.Render(" "), no)
buttons = baseStyle.
Width(maxWidth).
Padding(1, 0).
Render(buttons)
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
baseStyle.Width(maxWidth).Render(""),
explanation,
question,
buttons,
baseStyle.Width(maxWidth).Render(""),
)
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(lipgloss.Width(content) + 4).
Render(content)
}
// SetSize sets the size of the component.
func (m *InitDialogCmp) SetSize(width, height int) {
m.width = width
m.height = height
}
// Bindings implements layout.Bindings.
func (m InitDialogCmp) Bindings() []key.Binding {
return m.keys.ShortHelp()
}
// CloseInitDialogMsg is a message that is sent when the init dialog is closed.
type CloseInitDialogMsg struct {
Initialize bool
}
// ShowInitDialogMsg is a message that is sent to show the init dialog.
type ShowInitDialogMsg struct {
Show bool
}

View File

@@ -0,0 +1,327 @@
package dialog
import (
"context"
"fmt"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/tui/app"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/tui/util"
"github.com/sst/opencode/pkg/client"
)
const (
numVisibleModels = 10
maxDialogWidth = 40
)
// CloseModelDialogMsg is sent when a model is selected
type CloseModelDialogMsg struct {
Provider *client.ProviderInfo
Model *client.ProviderModel
}
// ModelDialog interface for the model selection dialog
type ModelDialog interface {
tea.Model
layout.Bindings
SetProviders(providers []client.ProviderInfo)
}
type modelDialogCmp struct {
app *app.App
availableProviders []client.ProviderInfo
provider client.ProviderInfo
model *client.ProviderModel
selectedIdx int
width int
height int
scrollOffset int
hScrollOffset int
hScrollPossible bool
}
type modelKeyMap struct {
Up key.Binding
Down key.Binding
Left key.Binding
Right key.Binding
Enter key.Binding
Escape key.Binding
J key.Binding
K key.Binding
H key.Binding
L key.Binding
}
var modelKeys = modelKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "previous model"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "next model"),
),
Left: key.NewBinding(
key.WithKeys("left"),
key.WithHelp("←", "scroll left"),
),
Right: key.NewBinding(
key.WithKeys("right"),
key.WithHelp("→", "scroll right"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select model"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "close"),
),
J: key.NewBinding(
key.WithKeys("j"),
key.WithHelp("j", "next model"),
),
K: key.NewBinding(
key.WithKeys("k"),
key.WithHelp("k", "previous model"),
),
H: key.NewBinding(
key.WithKeys("h"),
key.WithHelp("h", "scroll left"),
),
L: key.NewBinding(
key.WithKeys("l"),
key.WithHelp("l", "scroll right"),
),
}
func (m *modelDialogCmp) Init() tea.Cmd {
// cfg := config.Get()
// modelInfo := GetSelectedModel(cfg)
// m.availableProviders = getEnabledProviders(cfg)
// m.hScrollPossible = len(m.availableProviders) > 1
// m.provider = modelInfo.Provider
// m.hScrollOffset = findProviderIndex(m.availableProviders, m.provider)
// m.setupModelsForProvider(m.provider)
m.availableProviders, _ = m.app.ListProviders(context.Background())
m.hScrollOffset = 0
m.hScrollPossible = len(m.availableProviders) > 1
m.provider = m.availableProviders[m.hScrollOffset]
return nil
}
func (m *modelDialogCmp) SetProviders(providers []client.ProviderInfo) {
m.availableProviders = providers
}
func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, modelKeys.Up) || key.Matches(msg, modelKeys.K):
m.moveSelectionUp()
case key.Matches(msg, modelKeys.Down) || key.Matches(msg, modelKeys.J):
m.moveSelectionDown()
case key.Matches(msg, modelKeys.Left) || key.Matches(msg, modelKeys.H):
if m.hScrollPossible {
m.switchProvider(-1)
}
case key.Matches(msg, modelKeys.Right) || key.Matches(msg, modelKeys.L):
if m.hScrollPossible {
m.switchProvider(1)
}
case key.Matches(msg, modelKeys.Enter):
return m, util.CmdHandler(CloseModelDialogMsg{Provider: &m.provider, Model: &m.provider.Models[m.selectedIdx]})
case key.Matches(msg, modelKeys.Escape):
return m, util.CmdHandler(CloseModelDialogMsg{})
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
return m, nil
}
// moveSelectionUp moves the selection up or wraps to bottom
func (m *modelDialogCmp) moveSelectionUp() {
if m.selectedIdx > 0 {
m.selectedIdx--
} else {
m.selectedIdx = len(m.provider.Models) - 1
m.scrollOffset = max(0, len(m.provider.Models)-numVisibleModels)
}
// Keep selection visible
if m.selectedIdx < m.scrollOffset {
m.scrollOffset = m.selectedIdx
}
}
// moveSelectionDown moves the selection down or wraps to top
func (m *modelDialogCmp) moveSelectionDown() {
if m.selectedIdx < len(m.provider.Models)-1 {
m.selectedIdx++
} else {
m.selectedIdx = 0
m.scrollOffset = 0
}
// Keep selection visible
if m.selectedIdx >= m.scrollOffset+numVisibleModels {
m.scrollOffset = m.selectedIdx - (numVisibleModels - 1)
}
}
func (m *modelDialogCmp) switchProvider(offset int) {
newOffset := m.hScrollOffset + offset
// Ensure we stay within bounds
if newOffset < 0 {
newOffset = len(m.availableProviders) - 1
}
if newOffset >= len(m.availableProviders) {
newOffset = 0
}
m.hScrollOffset = newOffset
m.provider = m.availableProviders[m.hScrollOffset]
m.setupModelsForProvider(m.provider.Id)
}
func (m *modelDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
// Capitalize first letter of provider name
title := baseStyle.
Foreground(t.Primary()).
Bold(true).
Width(maxDialogWidth).
Padding(0, 0, 1).
Render(fmt.Sprintf("Select %s Model", m.provider.Name))
// Render visible models
endIdx := min(m.scrollOffset+numVisibleModels, len(m.provider.Models))
modelItems := make([]string, 0, endIdx-m.scrollOffset)
for i := m.scrollOffset; i < endIdx; i++ {
itemStyle := baseStyle.Width(maxDialogWidth)
if i == m.selectedIdx {
itemStyle = itemStyle.Background(t.Primary()).
Foreground(t.Background()).Bold(true)
}
modelItems = append(modelItems, itemStyle.Render(*m.provider.Models[i].Name))
}
scrollIndicator := m.getScrollIndicators(maxDialogWidth)
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
baseStyle.Width(maxDialogWidth).Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)),
scrollIndicator,
)
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(lipgloss.Width(content) + 4).
Render(content)
}
func (m *modelDialogCmp) getScrollIndicators(maxWidth int) string {
var indicator string
if len(m.provider.Models) > numVisibleModels {
if m.scrollOffset > 0 {
indicator += "↑ "
}
if m.scrollOffset+numVisibleModels < len(m.provider.Models) {
indicator += "↓ "
}
}
if m.hScrollPossible {
if m.hScrollOffset > 0 {
indicator = "← " + indicator
}
if m.hScrollOffset < len(m.availableProviders)-1 {
indicator += "→"
}
}
if indicator == "" {
return ""
}
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
return baseStyle.
Foreground(t.Primary()).
Width(maxWidth).
Align(lipgloss.Right).
Bold(true).
Render(indicator)
}
func (m *modelDialogCmp) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(modelKeys)
}
// findProviderIndex returns the index of the provider in the list, or -1 if not found
// func findProviderIndex(providers []string, provider string) int {
// for i, p := range providers {
// if p == provider {
// return i
// }
// }
// return -1
// }
func (m *modelDialogCmp) setupModelsForProvider(_ string) {
m.selectedIdx = 0
m.scrollOffset = 0
// cfg := config.Get()
// agentCfg := cfg.Agents[config.AgentPrimary]
// selectedModelId := agentCfg.Model
// m.provider = provider
// m.models = getModelsForProvider(provider)
// Try to select the current model if it belongs to this provider
// if provider == models.SupportedModels[selectedModelId].Provider {
// for i, model := range m.models {
// if model.ID == selectedModelId {
// m.selectedIdx = i
// // Adjust scroll position to keep selected model visible
// if m.selectedIdx >= numVisibleModels {
// m.scrollOffset = m.selectedIdx - (numVisibleModels - 1)
// }
// break
// }
// }
// }
}
func NewModelDialogCmp(app *app.App) ModelDialog {
return &modelDialogCmp{
app: app,
}
}

View File

@@ -0,0 +1,502 @@
package dialog
import (
"fmt"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/tui/util"
"strings"
)
type PermissionAction string
// Permission responses
const (
PermissionAllow PermissionAction = "allow"
PermissionAllowForSession PermissionAction = "allow_session"
PermissionDeny PermissionAction = "deny"
)
// PermissionResponseMsg represents the user's response to a permission request
type PermissionResponseMsg struct {
// Permission permission.PermissionRequest
Action PermissionAction
}
// PermissionDialogCmp interface for permission dialog component
type PermissionDialogCmp interface {
tea.Model
layout.Bindings
// SetPermissions(permission permission.PermissionRequest) tea.Cmd
}
type permissionsMapping struct {
Left key.Binding
Right key.Binding
EnterSpace key.Binding
Allow key.Binding
AllowSession key.Binding
Deny key.Binding
Tab key.Binding
}
var permissionsKeys = permissionsMapping{
Left: key.NewBinding(
key.WithKeys("left"),
key.WithHelp("←", "switch options"),
),
Right: key.NewBinding(
key.WithKeys("right"),
key.WithHelp("→", "switch options"),
),
EnterSpace: key.NewBinding(
key.WithKeys("enter", " "),
key.WithHelp("enter/space", "confirm"),
),
Allow: key.NewBinding(
key.WithKeys("a"),
key.WithHelp("a", "allow"),
),
AllowSession: key.NewBinding(
key.WithKeys("s"),
key.WithHelp("s", "allow for session"),
),
Deny: key.NewBinding(
key.WithKeys("d"),
key.WithHelp("d", "deny"),
),
Tab: key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("tab", "switch options"),
),
}
// permissionDialogCmp is the implementation of PermissionDialog
type permissionDialogCmp struct {
width int
height int
// permission permission.PermissionRequest
windowSize tea.WindowSizeMsg
contentViewPort viewport.Model
selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
diffCache map[string]string
markdownCache map[string]string
}
func (p *permissionDialogCmp) Init() tea.Cmd {
return p.contentViewPort.Init()
}
func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.windowSize = msg
cmd := p.SetSize()
cmds = append(cmds, cmd)
p.markdownCache = make(map[string]string)
p.diffCache = make(map[string]string)
// case tea.KeyMsg:
// switch {
// case key.Matches(msg, permissionsKeys.Right) || key.Matches(msg, permissionsKeys.Tab):
// p.selectedOption = (p.selectedOption + 1) % 3
// return p, nil
// case key.Matches(msg, permissionsKeys.Left):
// p.selectedOption = (p.selectedOption + 2) % 3
// case key.Matches(msg, permissionsKeys.EnterSpace):
// return p, p.selectCurrentOption()
// case key.Matches(msg, permissionsKeys.Allow):
// return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission})
// case key.Matches(msg, permissionsKeys.AllowSession):
// return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission})
// case key.Matches(msg, permissionsKeys.Deny):
// return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission})
// default:
// // Pass other keys to viewport
// viewPort, cmd := p.contentViewPort.Update(msg)
// p.contentViewPort = viewPort
// cmds = append(cmds, cmd)
// }
}
return p, tea.Batch(cmds...)
}
func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
var action PermissionAction
switch p.selectedOption {
case 0:
action = PermissionAllow
case 1:
action = PermissionAllowForSession
case 2:
action = PermissionDeny
}
return util.CmdHandler(PermissionResponseMsg{Action: action}) // , Permission: p.permission})
}
func (p *permissionDialogCmp) renderButtons() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
allowStyle := baseStyle
allowSessionStyle := baseStyle
denyStyle := baseStyle
spacerStyle := baseStyle.Background(t.Background())
// Style the selected button
switch p.selectedOption {
case 0:
allowStyle = allowStyle.Background(t.Primary()).Foreground(t.Background())
allowSessionStyle = allowSessionStyle.Background(t.Background()).Foreground(t.Primary())
denyStyle = denyStyle.Background(t.Background()).Foreground(t.Primary())
case 1:
allowStyle = allowStyle.Background(t.Background()).Foreground(t.Primary())
allowSessionStyle = allowSessionStyle.Background(t.Primary()).Foreground(t.Background())
denyStyle = denyStyle.Background(t.Background()).Foreground(t.Primary())
case 2:
allowStyle = allowStyle.Background(t.Background()).Foreground(t.Primary())
allowSessionStyle = allowSessionStyle.Background(t.Background()).Foreground(t.Primary())
denyStyle = denyStyle.Background(t.Primary()).Foreground(t.Background())
}
allowButton := allowStyle.Padding(0, 1).Render("Allow (a)")
allowSessionButton := allowSessionStyle.Padding(0, 1).Render("Allow for session (s)")
denyButton := denyStyle.Padding(0, 1).Render("Deny (d)")
content := lipgloss.JoinHorizontal(
lipgloss.Left,
allowButton,
spacerStyle.Render(" "),
allowSessionButton,
spacerStyle.Render(" "),
denyButton,
spacerStyle.Render(" "),
)
remainingWidth := p.width - lipgloss.Width(content)
if remainingWidth > 0 {
content = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + content
}
return content
}
func (p *permissionDialogCmp) renderHeader() string {
return "NOT IMPLEMENTED"
// t := theme.CurrentTheme()
// baseStyle := styles.BaseStyle()
//
// toolKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Tool")
// toolValue := baseStyle.
// Foreground(t.Text()).
// Width(p.width - lipgloss.Width(toolKey)).
// Render(fmt.Sprintf(": %s", p.permission.ToolName))
//
// pathKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Path")
//
// // Get the current working directory to display relative path
// relativePath := p.permission.Path
// if filepath.IsAbs(relativePath) {
// if cwd, err := filepath.Rel(config.WorkingDirectory(), relativePath); err == nil {
// relativePath = cwd
// }
// }
//
// pathValue := baseStyle.
// Foreground(t.Text()).
// Width(p.width - lipgloss.Width(pathKey)).
// Render(fmt.Sprintf(": %s", relativePath))
//
// headerParts := []string{
// lipgloss.JoinHorizontal(
// lipgloss.Left,
// toolKey,
// toolValue,
// ),
// baseStyle.Render(strings.Repeat(" ", p.width)),
// lipgloss.JoinHorizontal(
// lipgloss.Left,
// pathKey,
// pathValue,
// ),
// baseStyle.Render(strings.Repeat(" ", p.width)),
// }
//
// // Add tool-specific header information
// switch p.permission.ToolName {
// case "bash":
// headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Command"))
// case "edit":
// headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Diff"))
// case "write":
// headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Diff"))
// case "fetch":
// headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("URL"))
// }
//
// return lipgloss.NewStyle().Background(t.Background()).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
}
func (p *permissionDialogCmp) renderBashContent() string {
// t := theme.CurrentTheme()
// baseStyle := styles.BaseStyle()
//
// if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
// content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
//
// // Use the cache for markdown rendering
// renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
// r := styles.GetMarkdownRenderer(p.width - 10)
// s, err := r.Render(content)
// return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
// })
//
// finalContent := baseStyle.
// Width(p.contentViewPort.Width).
// Render(renderedContent)
// p.contentViewPort.SetContent(finalContent)
// return p.styleViewport()
// }
return ""
}
func (p *permissionDialogCmp) renderEditContent() string {
// if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
// diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
// return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
// })
//
// p.contentViewPort.SetContent(diff)
// return p.styleViewport()
// }
return ""
}
func (p *permissionDialogCmp) renderPatchContent() string {
// if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
// diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
// return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
// })
//
// p.contentViewPort.SetContent(diff)
// return p.styleViewport()
// }
return ""
}
func (p *permissionDialogCmp) renderWriteContent() string {
// if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
// // Use the cache for diff rendering
// diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
// return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
// })
//
// p.contentViewPort.SetContent(diff)
// return p.styleViewport()
// }
return ""
}
func (p *permissionDialogCmp) renderFetchContent() string {
// t := theme.CurrentTheme()
// baseStyle := styles.BaseStyle()
//
// if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
// content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
//
// // Use the cache for markdown rendering
// renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
// r := styles.GetMarkdownRenderer(p.width - 10)
// s, err := r.Render(content)
// return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
// })
//
// finalContent := baseStyle.
// Width(p.contentViewPort.Width).
// Render(renderedContent)
// p.contentViewPort.SetContent(finalContent)
// return p.styleViewport()
// }
return ""
}
func (p *permissionDialogCmp) renderDefaultContent() string {
// t := theme.CurrentTheme()
// baseStyle := styles.BaseStyle()
//
// content := p.permission.Description
//
// // Use the cache for markdown rendering
// renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
// r := styles.GetMarkdownRenderer(p.width - 10)
// s, err := r.Render(content)
// return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
// })
//
// finalContent := baseStyle.
// Width(p.contentViewPort.Width).
// Render(renderedContent)
// p.contentViewPort.SetContent(finalContent)
//
// if renderedContent == "" {
// return ""
// }
//
return p.styleViewport()
}
func (p *permissionDialogCmp) styleViewport() string {
t := theme.CurrentTheme()
contentStyle := lipgloss.NewStyle().
Background(t.Background())
return contentStyle.Render(p.contentViewPort.View())
}
func (p *permissionDialogCmp) render() string {
return "NOT IMPLEMENTED"
// t := theme.CurrentTheme()
// baseStyle := styles.BaseStyle()
//
// title := baseStyle.
// Bold(true).
// Width(p.width - 4).
// Foreground(t.Primary()).
// Render("Permission Required")
// // Render header
// headerContent := p.renderHeader()
// // Render buttons
// buttons := p.renderButtons()
//
// // Calculate content height dynamically based on window size
// p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(buttons) - 2 - lipgloss.Height(title)
// p.contentViewPort.Width = p.width - 4
//
// // Render content based on tool type
// var contentFinal string
// switch p.permission.ToolName {
// case "bash":
// contentFinal = p.renderBashContent()
// case "edit":
// contentFinal = p.renderEditContent()
// case "patch":
// contentFinal = p.renderPatchContent()
// case "write":
// contentFinal = p.renderWriteContent()
// case "fetch":
// contentFinal = p.renderFetchContent()
// default:
// contentFinal = p.renderDefaultContent()
// }
//
// content := lipgloss.JoinVertical(
// lipgloss.Top,
// title,
// baseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
// headerContent,
// contentFinal,
// buttons,
// baseStyle.Render(strings.Repeat(" ", p.width-4)),
// )
//
// return baseStyle.
// Padding(1, 0, 0, 1).
// Border(lipgloss.RoundedBorder()).
// BorderBackground(t.Background()).
// BorderForeground(t.TextMuted()).
// Width(p.width).
// Height(p.height).
// Render(
// content,
// )
}
func (p *permissionDialogCmp) View() string {
return p.render()
}
func (p *permissionDialogCmp) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(permissionsKeys)
}
func (p *permissionDialogCmp) SetSize() tea.Cmd {
// if p.permission.ID == "" {
// return nil
// }
// switch p.permission.ToolName {
// case "bash":
// p.width = int(float64(p.windowSize.Width) * 0.4)
// p.height = int(float64(p.windowSize.Height) * 0.3)
// case "edit":
// p.width = int(float64(p.windowSize.Width) * 0.8)
// p.height = int(float64(p.windowSize.Height) * 0.8)
// case "write":
// p.width = int(float64(p.windowSize.Width) * 0.8)
// p.height = int(float64(p.windowSize.Height) * 0.8)
// case "fetch":
// p.width = int(float64(p.windowSize.Width) * 0.4)
// p.height = int(float64(p.windowSize.Height) * 0.3)
// default:
// p.width = int(float64(p.windowSize.Width) * 0.7)
// p.height = int(float64(p.windowSize.Height) * 0.5)
// }
return nil
}
// func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) tea.Cmd {
// p.permission = permission
// return p.SetSize()
// }
// Helper to get or set cached diff content
func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
if cached, ok := c.diffCache[key]; ok {
return cached
}
content, err := generator()
if err != nil {
return fmt.Sprintf("Error formatting diff: %v", err)
}
c.diffCache[key] = content
return content
}
// Helper to get or set cached markdown content
func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
if cached, ok := c.markdownCache[key]; ok {
return cached
}
content, err := generator()
if err != nil {
return fmt.Sprintf("Error rendering markdown: %v", err)
}
c.markdownCache[key] = content
return content
}
func NewPermissionDialogCmp() PermissionDialogCmp {
// Create viewport for content
contentViewport := viewport.New(0, 0)
return &permissionDialogCmp{
contentViewPort: contentViewport,
selectedOption: 0, // Default to "Allow"
diffCache: make(map[string]string),
markdownCache: make(map[string]string),
}
}

View File

@@ -0,0 +1,136 @@
package dialog
import (
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/tui/util"
)
const question = "Are you sure you want to quit?"
type CloseQuitMsg struct{}
type QuitDialog interface {
tea.Model
layout.Bindings
}
type quitDialogCmp struct {
selectedNo bool
}
type helpMapping struct {
LeftRight key.Binding
EnterSpace key.Binding
Yes key.Binding
No key.Binding
Tab key.Binding
}
var helpKeys = helpMapping{
LeftRight: key.NewBinding(
key.WithKeys("left", "right"),
key.WithHelp("←/→", "switch options"),
),
EnterSpace: key.NewBinding(
key.WithKeys("enter", " "),
key.WithHelp("enter/space", "confirm"),
),
Yes: key.NewBinding(
key.WithKeys("y", "Y"),
key.WithHelp("y/Y", "yes"),
),
No: key.NewBinding(
key.WithKeys("n", "N"),
key.WithHelp("n/N", "no"),
),
Tab: key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("tab", "switch options"),
),
}
func (q *quitDialogCmp) Init() tea.Cmd {
return nil
}
func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, helpKeys.LeftRight) || key.Matches(msg, helpKeys.Tab):
q.selectedNo = !q.selectedNo
return q, nil
case key.Matches(msg, helpKeys.EnterSpace):
if !q.selectedNo {
return q, tea.Quit
}
return q, util.CmdHandler(CloseQuitMsg{})
case key.Matches(msg, helpKeys.Yes):
return q, tea.Quit
case key.Matches(msg, helpKeys.No):
return q, util.CmdHandler(CloseQuitMsg{})
}
}
return q, nil
}
func (q *quitDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
yesStyle := baseStyle
noStyle := baseStyle
spacerStyle := baseStyle.Background(t.Background())
if q.selectedNo {
noStyle = noStyle.Background(t.Primary()).Foreground(t.Background())
yesStyle = yesStyle.Background(t.Background()).Foreground(t.Primary())
} else {
yesStyle = yesStyle.Background(t.Primary()).Foreground(t.Background())
noStyle = noStyle.Background(t.Background()).Foreground(t.Primary())
}
yesButton := yesStyle.Padding(0, 1).Render("Yes")
noButton := noStyle.Padding(0, 1).Render("No")
buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, spacerStyle.Render(" "), noButton)
width := lipgloss.Width(question)
remainingWidth := width - lipgloss.Width(buttons)
if remainingWidth > 0 {
buttons = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + buttons
}
content := baseStyle.Render(
lipgloss.JoinVertical(
lipgloss.Center,
question,
"",
buttons,
),
)
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(lipgloss.Width(content) + 4).
Render(content)
}
func (q *quitDialogCmp) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(helpKeys)
}
func NewQuitCmp() QuitDialog {
return &quitDialogCmp{
selectedNo: true,
}
}

View File

@@ -0,0 +1,230 @@
package dialog
import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/tui/util"
"github.com/sst/opencode/pkg/client"
)
// CloseSessionDialogMsg is sent when the session dialog is closed
type CloseSessionDialogMsg struct {
Session *client.SessionInfo
}
// SessionDialog interface for the session switching dialog
type SessionDialog interface {
tea.Model
layout.Bindings
SetSessions(sessions []client.SessionInfo)
SetSelectedSession(sessionID string)
}
type sessionDialogCmp struct {
sessions []client.SessionInfo
selectedIdx int
width int
height int
selectedSessionID string
}
type sessionKeyMap struct {
Up key.Binding
Down key.Binding
Enter key.Binding
Escape key.Binding
J key.Binding
K key.Binding
}
var sessionKeys = sessionKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "previous session"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "next session"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select session"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "close"),
),
J: key.NewBinding(
key.WithKeys("j"),
key.WithHelp("j", "next session"),
),
K: key.NewBinding(
key.WithKeys("k"),
key.WithHelp("k", "previous session"),
),
}
func (s *sessionDialogCmp) Init() tea.Cmd {
return nil
}
func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
s.width = msg.Width
s.height = msg.Height
case tea.KeyMsg:
switch {
case key.Matches(msg, sessionKeys.Up) || key.Matches(msg, sessionKeys.K):
if s.selectedIdx > 0 {
s.selectedIdx--
}
return s, nil
case key.Matches(msg, sessionKeys.Down) || key.Matches(msg, sessionKeys.J):
if s.selectedIdx < len(s.sessions)-1 {
s.selectedIdx++
}
return s, nil
case key.Matches(msg, sessionKeys.Enter):
if len(s.sessions) > 0 {
selectedSession := s.sessions[s.selectedIdx]
s.selectedSessionID = selectedSession.Id
return s, util.CmdHandler(CloseSessionDialogMsg{
Session: &selectedSession,
})
}
case key.Matches(msg, sessionKeys.Escape):
return s, util.CmdHandler(CloseSessionDialogMsg{})
}
}
return s, nil
}
func (s *sessionDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
if len(s.sessions) == 0 {
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(40).
Render("No sessions available")
}
// Calculate max width needed for session titles
maxWidth := 40 // Minimum width
for _, sess := range s.sessions {
if len(sess.Title) > maxWidth-4 { // Account for padding
maxWidth = len(sess.Title) + 4
}
}
maxWidth = max(30, min(maxWidth, s.width-15)) // Limit width to avoid overflow
// Limit height to avoid taking up too much screen space
maxVisibleSessions := min(10, len(s.sessions))
// Build the session list
sessionItems := make([]string, 0, maxVisibleSessions)
startIdx := 0
// If we have more sessions than can be displayed, adjust the start index
if len(s.sessions) > maxVisibleSessions {
// Center the selected item when possible
halfVisible := maxVisibleSessions / 2
if s.selectedIdx >= halfVisible && s.selectedIdx < len(s.sessions)-halfVisible {
startIdx = s.selectedIdx - halfVisible
} else if s.selectedIdx >= len(s.sessions)-halfVisible {
startIdx = len(s.sessions) - maxVisibleSessions
}
}
endIdx := min(startIdx+maxVisibleSessions, len(s.sessions))
for i := startIdx; i < endIdx; i++ {
sess := s.sessions[i]
itemStyle := baseStyle.Width(maxWidth)
if i == s.selectedIdx {
itemStyle = itemStyle.
Background(t.Primary()).
Foreground(t.Background()).
Bold(true)
}
sessionItems = append(sessionItems, itemStyle.Padding(0, 1).Render(sess.Title))
}
title := baseStyle.
Foreground(t.Primary()).
Bold(true).
Width(maxWidth).
Padding(0, 1).
Render("Switch Session")
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
baseStyle.Width(maxWidth).Render(""),
baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, sessionItems...)),
baseStyle.Width(maxWidth).Render(""),
)
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(lipgloss.Width(content) + 4).
Render(content)
}
func (s *sessionDialogCmp) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(sessionKeys)
}
func (s *sessionDialogCmp) SetSessions(sessions []client.SessionInfo) {
s.sessions = sessions
// If we have a selected session ID, find its index
if s.selectedSessionID != "" {
for i, sess := range sessions {
if sess.Id == s.selectedSessionID {
s.selectedIdx = i
return
}
}
}
// Default to first session if selected not found
s.selectedIdx = 0
}
func (s *sessionDialogCmp) SetSelectedSession(sessionID string) {
s.selectedSessionID = sessionID
// Update the selected index if sessions are already loaded
if len(s.sessions) > 0 {
for i, sess := range s.sessions {
if sess.Id == sessionID {
s.selectedIdx = i
return
}
}
}
}
// NewSessionDialogCmp creates a new session switching dialog
func NewSessionDialogCmp() SessionDialog {
return &sessionDialogCmp{
sessions: []client.SessionInfo{},
selectedIdx: 0,
selectedSessionID: "",
}
}

View File

@@ -0,0 +1,199 @@
package dialog
import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/tui/util"
)
// ThemeChangedMsg is sent when the theme is changed
type ThemeChangedMsg struct {
ThemeName string
}
// CloseThemeDialogMsg is sent when the theme dialog is closed
type CloseThemeDialogMsg struct{}
// ThemeDialog interface for the theme switching dialog
type ThemeDialog interface {
tea.Model
layout.Bindings
}
type themeDialogCmp struct {
themes []string
selectedIdx int
width int
height int
currentTheme string
}
type themeKeyMap struct {
Up key.Binding
Down key.Binding
Enter key.Binding
Escape key.Binding
J key.Binding
K key.Binding
}
var themeKeys = themeKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "previous theme"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "next theme"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select theme"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "close"),
),
J: key.NewBinding(
key.WithKeys("j"),
key.WithHelp("j", "next theme"),
),
K: key.NewBinding(
key.WithKeys("k"),
key.WithHelp("k", "previous theme"),
),
}
func (t *themeDialogCmp) Init() tea.Cmd {
// Load available themes and update selectedIdx based on current theme
t.themes = theme.AvailableThemes()
t.currentTheme = theme.CurrentThemeName()
// Find the current theme in the list
for i, name := range t.themes {
if name == t.currentTheme {
t.selectedIdx = i
break
}
}
return nil
}
func (t *themeDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, themeKeys.Up) || key.Matches(msg, themeKeys.K):
if t.selectedIdx > 0 {
t.selectedIdx--
}
return t, nil
case key.Matches(msg, themeKeys.Down) || key.Matches(msg, themeKeys.J):
if t.selectedIdx < len(t.themes)-1 {
t.selectedIdx++
}
return t, nil
case key.Matches(msg, themeKeys.Enter):
if len(t.themes) > 0 {
previousTheme := theme.CurrentThemeName()
selectedTheme := t.themes[t.selectedIdx]
if previousTheme == selectedTheme {
return t, util.CmdHandler(CloseThemeDialogMsg{})
}
if err := theme.SetTheme(selectedTheme); err != nil {
status.Error(err.Error())
return t, nil
}
return t, util.CmdHandler(ThemeChangedMsg{
ThemeName: selectedTheme,
})
}
case key.Matches(msg, themeKeys.Escape):
return t, util.CmdHandler(CloseThemeDialogMsg{})
}
case tea.WindowSizeMsg:
t.width = msg.Width
t.height = msg.Height
}
return t, nil
}
func (t *themeDialogCmp) View() string {
currentTheme := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
if len(t.themes) == 0 {
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(currentTheme.Background()).
BorderForeground(currentTheme.TextMuted()).
Width(40).
Render("No themes available")
}
// Calculate max width needed for theme names
maxWidth := 40 // Minimum width
for _, themeName := range t.themes {
if len(themeName) > maxWidth-4 { // Account for padding
maxWidth = len(themeName) + 4
}
}
maxWidth = max(30, min(maxWidth, t.width-15)) // Limit width to avoid overflow
// Build the theme list
themeItems := make([]string, 0, len(t.themes))
for i, themeName := range t.themes {
itemStyle := baseStyle.Width(maxWidth)
if i == t.selectedIdx {
itemStyle = itemStyle.
Background(currentTheme.Primary()).
Foreground(currentTheme.Background()).
Bold(true)
}
themeItems = append(themeItems, itemStyle.Padding(0, 1).Render(themeName))
}
title := baseStyle.
Foreground(currentTheme.Primary()).
Bold(true).
Width(maxWidth).
Padding(0, 1).
Render("Select Theme")
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
baseStyle.Width(maxWidth).Render(""),
baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, themeItems...)),
baseStyle.Width(maxWidth).Render(""),
)
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(currentTheme.Background()).
BorderForeground(currentTheme.TextMuted()).
Width(lipgloss.Width(content) + 4).
Render(content)
}
func (t *themeDialogCmp) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(themeKeys)
}
// NewThemeDialogCmp creates a new theme switching dialog
func NewThemeDialogCmp() ThemeDialog {
return &themeDialogCmp{
themes: []string{},
selectedIdx: 0,
currentTheme: "",
}
}

View File

@@ -0,0 +1,178 @@
package dialog
import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
utilComponents "github.com/sst/opencode/internal/tui/components/util"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
)
const (
maxToolsDialogWidth = 60
maxVisibleTools = 15
)
// ToolsDialog interface for the tools list dialog
type ToolsDialog interface {
tea.Model
layout.Bindings
SetTools(tools []string)
}
// ShowToolsDialogMsg is sent to show the tools dialog
type ShowToolsDialogMsg struct {
Show bool
}
// CloseToolsDialogMsg is sent when the tools dialog is closed
type CloseToolsDialogMsg struct{}
type toolItem struct {
name string
}
func (t toolItem) Render(selected bool, width int) string {
th := theme.CurrentTheme()
baseStyle := styles.BaseStyle().
Width(width).
Background(th.Background())
if selected {
baseStyle = baseStyle.
Background(th.Primary()).
Foreground(th.Background()).
Bold(true)
} else {
baseStyle = baseStyle.
Foreground(th.Text())
}
return baseStyle.Render(t.name)
}
type toolsDialogCmp struct {
tools []toolItem
width int
height int
list utilComponents.SimpleList[toolItem]
}
type toolsKeyMap struct {
Up key.Binding
Down key.Binding
Escape key.Binding
J key.Binding
K key.Binding
}
var toolsKeys = toolsKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "previous tool"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "next tool"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "close"),
),
J: key.NewBinding(
key.WithKeys("j"),
key.WithHelp("j", "next tool"),
),
K: key.NewBinding(
key.WithKeys("k"),
key.WithHelp("k", "previous tool"),
),
}
func (m *toolsDialogCmp) Init() tea.Cmd {
return nil
}
func (m *toolsDialogCmp) SetTools(tools []string) {
var toolItems []toolItem
for _, name := range tools {
toolItems = append(toolItems, toolItem{name: name})
}
m.tools = toolItems
m.list.SetItems(toolItems)
}
func (m *toolsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, toolsKeys.Escape):
return m, func() tea.Msg { return CloseToolsDialogMsg{} }
// Pass other key messages to the list component
default:
var cmd tea.Cmd
listModel, cmd := m.list.Update(msg)
m.list = listModel.(utilComponents.SimpleList[toolItem])
return m, cmd
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
// For non-key messages
var cmd tea.Cmd
listModel, cmd := m.list.Update(msg)
m.list = listModel.(utilComponents.SimpleList[toolItem])
return m, cmd
}
func (m *toolsDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle().Background(t.Background())
title := baseStyle.
Foreground(t.Primary()).
Bold(true).
Width(maxToolsDialogWidth).
Padding(0, 0, 1).
Render("Available Tools")
// Calculate dialog width based on content
dialogWidth := min(maxToolsDialogWidth, m.width/2)
m.list.SetMaxWidth(dialogWidth)
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
m.list.View(),
)
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Background(t.Background()).
Width(lipgloss.Width(content) + 4).
Render(content)
}
func (m *toolsDialogCmp) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(toolsKeys)
}
func NewToolsDialogCmp() ToolsDialog {
list := utilComponents.NewSimpleList[toolItem](
[]toolItem{},
maxVisibleTools,
"No tools available",
true,
)
return &toolsDialogCmp{
list: list,
}
}

View File

@@ -0,0 +1,58 @@
package qr
import (
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/tui/theme"
"rsc.io/qr"
)
var tops_bottoms = []rune{' ', '▀', '▄', '█'}
// Generate a text string to a QR code, which you can write to a terminal or file.
func Generate(text string) (string, int, error) {
code, err := qr.Encode(text, qr.Level(0))
if err != nil {
return "", 0, err
}
t := theme.CurrentTheme()
if t == nil {
return "", 0, err
}
// Create lipgloss style for QR code with theme colors
qrStyle := lipgloss.NewStyle().
Foreground(t.Text()).
Background(t.Background())
var result strings.Builder
// content
for y := 0; y < code.Size-1; y += 2 {
var line strings.Builder
for x := 0; x < code.Size; x += 1 {
var num int8
if code.Black(x, y) {
num += 1
}
if code.Black(x, y+1) {
num += 2
}
line.WriteRune(tops_bottoms[num])
}
result.WriteString(qrStyle.Render(line.String()) + "\n")
}
// add lower border when required (only required when QR size is odd)
if code.Size%2 == 1 {
var borderLine strings.Builder
for range code.Size {
borderLine.WriteRune('▀')
}
result.WriteString(qrStyle.Render(borderLine.String()) + "\n")
}
return result.String(), code.Size, nil
}

View File

@@ -0,0 +1,127 @@
package spinner
import (
"context"
"fmt"
"os"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Spinner wraps the bubbles spinner for both interactive and non-interactive mode
type Spinner struct {
model spinner.Model
done chan struct{}
prog *tea.Program
ctx context.Context
cancel context.CancelFunc
}
// spinnerModel is the tea.Model for the spinner
type spinnerModel struct {
spinner spinner.Model
message string
quitting bool
}
func (m spinnerModel) Init() tea.Cmd {
return m.spinner.Tick
}
func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
m.quitting = true
return m, tea.Quit
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case quitMsg:
m.quitting = true
return m, tea.Quit
default:
return m, nil
}
}
func (m spinnerModel) View() string {
if m.quitting {
return ""
}
return fmt.Sprintf("%s %s", m.spinner.View(), m.message)
}
// quitMsg is sent when we want to quit the spinner
type quitMsg struct{}
// NewSpinner creates a new spinner with the given message
func NewSpinner(message string) *Spinner {
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = s.Style.Foreground(s.Style.GetForeground())
ctx, cancel := context.WithCancel(context.Background())
model := spinnerModel{
spinner: s,
message: message,
}
prog := tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutCatchPanics())
return &Spinner{
model: s,
done: make(chan struct{}),
prog: prog,
ctx: ctx,
cancel: cancel,
}
}
// NewThemedSpinner creates a new spinner with the given message and color
func NewThemedSpinner(message string, color lipgloss.AdaptiveColor) *Spinner {
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = s.Style.Foreground(color)
ctx, cancel := context.WithCancel(context.Background())
model := spinnerModel{
spinner: s,
message: message,
}
prog := tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutCatchPanics())
return &Spinner{
model: s,
done: make(chan struct{}),
prog: prog,
ctx: ctx,
cancel: cancel,
}
}
// Start begins the spinner animation
func (s *Spinner) Start() {
go func() {
defer close(s.done)
go func() {
<-s.ctx.Done()
s.prog.Send(quitMsg{})
}()
_, err := s.prog.Run()
if err != nil {
fmt.Fprintf(os.Stderr, "Error running spinner: %v\n", err)
}
}()
}
// Stop ends the spinner animation
func (s *Spinner) Stop() {
s.cancel()
<-s.done
}

View File

@@ -0,0 +1,24 @@
package spinner
import (
"testing"
"time"
)
func TestSpinner(t *testing.T) {
t.Parallel()
// Create a spinner
s := NewSpinner("Test spinner")
// Start the spinner
s.Start()
// Wait a bit to let it run
time.Sleep(100 * time.Millisecond)
// Stop the spinner
s.Stop()
// If we got here without panicking, the test passes
}

View File

@@ -0,0 +1,159 @@
package utilComponents
import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
)
type SimpleListItem interface {
Render(selected bool, width int) string
}
type SimpleList[T SimpleListItem] interface {
tea.Model
layout.Bindings
SetMaxWidth(maxWidth int)
GetSelectedItem() (item T, idx int)
SetItems(items []T)
GetItems() []T
}
type simpleListCmp[T SimpleListItem] struct {
fallbackMsg string
items []T
selectedIdx int
maxWidth int
maxVisibleItems int
useAlphaNumericKeys bool
width int
height int
}
type simpleListKeyMap struct {
Up key.Binding
Down key.Binding
UpAlpha key.Binding
DownAlpha key.Binding
}
var simpleListKeys = simpleListKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "previous list item"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "next list item"),
),
UpAlpha: key.NewBinding(
key.WithKeys("k"),
key.WithHelp("k", "previous list item"),
),
DownAlpha: key.NewBinding(
key.WithKeys("j"),
key.WithHelp("j", "next list item"),
),
}
func (c *simpleListCmp[T]) Init() tea.Cmd {
return nil
}
func (c *simpleListCmp[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)):
if c.selectedIdx > 0 {
c.selectedIdx--
}
return c, nil
case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)):
if c.selectedIdx < len(c.items)-1 {
c.selectedIdx++
}
return c, nil
}
}
return c, nil
}
func (c *simpleListCmp[T]) BindingKeys() []key.Binding {
return layout.KeyMapToSlice(simpleListKeys)
}
func (c *simpleListCmp[T]) GetSelectedItem() (T, int) {
if len(c.items) > 0 {
return c.items[c.selectedIdx], c.selectedIdx
}
var zero T
return zero, -1
}
func (c *simpleListCmp[T]) SetItems(items []T) {
c.selectedIdx = 0
c.items = items
}
func (c *simpleListCmp[T]) GetItems() []T {
return c.items
}
func (c *simpleListCmp[T]) SetMaxWidth(width int) {
c.maxWidth = width
}
func (c *simpleListCmp[T]) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
items := c.items
maxWidth := c.maxWidth
maxVisibleItems := min(c.maxVisibleItems, len(items))
startIdx := 0
if len(items) <= 0 {
return baseStyle.
Background(t.Background()).
Padding(0, 1).
Width(maxWidth).
Render(c.fallbackMsg)
}
if len(items) > maxVisibleItems {
halfVisible := maxVisibleItems / 2
if c.selectedIdx >= halfVisible && c.selectedIdx < len(items)-halfVisible {
startIdx = c.selectedIdx - halfVisible
} else if c.selectedIdx >= len(items)-halfVisible {
startIdx = len(items) - maxVisibleItems
}
}
endIdx := min(startIdx+maxVisibleItems, len(items))
listItems := make([]string, 0, maxVisibleItems)
for i := startIdx; i < endIdx; i++ {
item := items[i]
title := item.Render(i == c.selectedIdx, maxWidth)
listItems = append(listItems, title)
}
return lipgloss.JoinVertical(lipgloss.Left, listItems...)
}
func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] {
return &simpleListCmp[T]{
fallbackMsg: fallbackMsg,
items: items,
maxVisibleItems: maxVisibleItems,
useAlphaNumericKeys: useAlphaNumericKeys,
selectedIdx: 0,
}
}

View File

@@ -0,0 +1,49 @@
//go:build !windows
package image
import (
"bytes"
"fmt"
"image"
"github.com/atotto/clipboard"
)
func GetImageFromClipboard() ([]byte, string, error) {
text, err := clipboard.ReadAll()
if err != nil {
return nil, "", fmt.Errorf("Error reading clipboard")
}
if text == "" {
return nil, "", nil
}
binaryData := []byte(text)
imageBytes, err := binaryToImage(binaryData)
if err != nil {
return nil, text, nil
}
return imageBytes, "", nil
}
func binaryToImage(data []byte) ([]byte, error) {
reader := bytes.NewReader(data)
img, _, err := image.Decode(reader)
if err != nil {
return nil, fmt.Errorf("Unable to covert bytes to image")
}
return ImageToBytes(img)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -0,0 +1,192 @@
//go:build windows
package image
import (
"bytes"
"fmt"
"image"
"image/color"
"log/slog"
"syscall"
"unsafe"
)
var (
user32 = syscall.NewLazyDLL("user32.dll")
kernel32 = syscall.NewLazyDLL("kernel32.dll")
openClipboard = user32.NewProc("OpenClipboard")
closeClipboard = user32.NewProc("CloseClipboard")
getClipboardData = user32.NewProc("GetClipboardData")
isClipboardFormatAvailable = user32.NewProc("IsClipboardFormatAvailable")
globalLock = kernel32.NewProc("GlobalLock")
globalUnlock = kernel32.NewProc("GlobalUnlock")
globalSize = kernel32.NewProc("GlobalSize")
)
const (
CF_TEXT = 1
CF_UNICODETEXT = 13
CF_DIB = 8
)
type BITMAPINFOHEADER struct {
BiSize uint32
BiWidth int32
BiHeight int32
BiPlanes uint16
BiBitCount uint16
BiCompression uint32
BiSizeImage uint32
BiXPelsPerMeter int32
BiYPelsPerMeter int32
BiClrUsed uint32
BiClrImportant uint32
}
func GetImageFromClipboard() ([]byte, string, error) {
ret, _, _ := openClipboard.Call(0)
if ret == 0 {
return nil, "", fmt.Errorf("failed to open clipboard")
}
defer func(closeClipboard *syscall.LazyProc, a ...uintptr) {
_, _, err := closeClipboard.Call(a...)
if err != nil {
slog.Error("close clipboard failed")
return
}
}(closeClipboard)
isTextAvailable, _, _ := isClipboardFormatAvailable.Call(uintptr(CF_TEXT))
isUnicodeTextAvailable, _, _ := isClipboardFormatAvailable.Call(uintptr(CF_UNICODETEXT))
if isTextAvailable != 0 || isUnicodeTextAvailable != 0 {
// Get text from clipboard
var formatToUse uintptr = CF_TEXT
if isUnicodeTextAvailable != 0 {
formatToUse = CF_UNICODETEXT
}
hClipboardText, _, _ := getClipboardData.Call(formatToUse)
if hClipboardText != 0 {
textPtr, _, _ := globalLock.Call(hClipboardText)
if textPtr != 0 {
defer func(globalUnlock *syscall.LazyProc, a ...uintptr) {
_, _, err := globalUnlock.Call(a...)
if err != nil {
slog.Error("Global unlock failed")
return
}
}(globalUnlock, hClipboardText)
// Get clipboard text
var clipboardText string
if formatToUse == CF_UNICODETEXT {
// Convert wide string to Go string
clipboardText = syscall.UTF16ToString((*[1 << 20]uint16)(unsafe.Pointer(textPtr))[:])
} else {
// Get size of ANSI text
size, _, _ := globalSize.Call(hClipboardText)
if size > 0 {
// Convert ANSI string to Go string
textBytes := make([]byte, size)
copy(textBytes, (*[1 << 20]byte)(unsafe.Pointer(textPtr))[:size:size])
clipboardText = bytesToString(textBytes)
}
}
// Check if the text is not empty
if clipboardText != "" {
return nil, clipboardText, nil
}
}
}
}
hClipboardData, _, _ := getClipboardData.Call(uintptr(CF_DIB))
if hClipboardData == 0 {
return nil, "", fmt.Errorf("failed to get clipboard data")
}
dataPtr, _, _ := globalLock.Call(hClipboardData)
if dataPtr == 0 {
return nil, "", fmt.Errorf("failed to lock clipboard data")
}
defer func(globalUnlock *syscall.LazyProc, a ...uintptr) {
_, _, err := globalUnlock.Call(a...)
if err != nil {
slog.Error("Global unlock failed")
return
}
}(globalUnlock, hClipboardData)
bmiHeader := (*BITMAPINFOHEADER)(unsafe.Pointer(dataPtr))
width := int(bmiHeader.BiWidth)
height := int(bmiHeader.BiHeight)
if height < 0 {
height = -height
}
bitsPerPixel := int(bmiHeader.BiBitCount)
img := image.NewRGBA(image.Rect(0, 0, width, height))
var bitsOffset uintptr
if bitsPerPixel <= 8 {
numColors := uint32(1) << bitsPerPixel
if bmiHeader.BiClrUsed > 0 {
numColors = bmiHeader.BiClrUsed
}
bitsOffset = unsafe.Sizeof(*bmiHeader) + uintptr(numColors*4)
} else {
bitsOffset = unsafe.Sizeof(*bmiHeader)
}
for y := range height {
for x := range width {
srcY := height - y - 1
if bmiHeader.BiHeight < 0 {
srcY = y
}
var pixelPointer unsafe.Pointer
var r, g, b, a uint8
switch bitsPerPixel {
case 24:
stride := (width*3 + 3) &^ 3
pixelPointer = unsafe.Pointer(dataPtr + bitsOffset + uintptr(srcY*stride+x*3))
b = *(*byte)(pixelPointer)
g = *(*byte)(unsafe.Add(pixelPointer, 1))
r = *(*byte)(unsafe.Add(pixelPointer, 2))
a = 255
case 32:
pixelPointer = unsafe.Pointer(dataPtr + bitsOffset + uintptr(srcY*width*4+x*4))
b = *(*byte)(pixelPointer)
g = *(*byte)(unsafe.Add(pixelPointer, 1))
r = *(*byte)(unsafe.Add(pixelPointer, 2))
a = *(*byte)(unsafe.Add(pixelPointer, 3))
if a == 0 {
a = 255
}
default:
return nil, "", fmt.Errorf("unsupported bit count: %d", bitsPerPixel)
}
img.Set(x, y, color.RGBA{R: r, G: g, B: b, A: a})
}
}
imageBytes, err := ImageToBytes(img)
if err != nil {
return nil, "", err
}
return imageBytes, "", nil
}
func bytesToString(b []byte) string {
i := bytes.IndexByte(b, 0)
if i == -1 {
return string(b)
}
return string(b[:i])
}

View File

@@ -0,0 +1,85 @@
package image
import (
"bytes"
"fmt"
"image"
"image/png"
"os"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/disintegration/imaging"
"github.com/lucasb-eyer/go-colorful"
_ "golang.org/x/image/webp"
)
func ValidateFileSize(filePath string, sizeLimit int64) (bool, error) {
fileInfo, err := os.Stat(filePath)
if err != nil {
return false, fmt.Errorf("error getting file info: %w", err)
}
if fileInfo.Size() > sizeLimit {
return true, nil
}
return false, nil
}
func ToString(width int, img image.Image) string {
img = imaging.Resize(img, width, 0, imaging.Lanczos)
b := img.Bounds()
imageWidth := b.Max.X
h := b.Max.Y
str := strings.Builder{}
for heightCounter := 0; heightCounter < h; heightCounter += 2 {
for x := range imageWidth {
c1, _ := colorful.MakeColor(img.At(x, heightCounter))
color1 := lipgloss.Color(c1.Hex())
var color2 lipgloss.Color
if heightCounter+1 < h {
c2, _ := colorful.MakeColor(img.At(x, heightCounter+1))
color2 = lipgloss.Color(c2.Hex())
} else {
color2 = color1
}
str.WriteString(lipgloss.NewStyle().Foreground(color1).
Background(color2).Render("▀"))
}
str.WriteString("\n")
}
return str.String()
}
func ImagePreview(width int, filename string) (string, error) {
imageContent, err := os.Open(filename)
if err != nil {
return "", err
}
defer imageContent.Close()
img, _, err := image.Decode(imageContent)
if err != nil {
return "", err
}
imageString := ToString(width, img)
return imageString, nil
}
func ImageToBytes(image image.Image) ([]byte, error) {
buf := new(bytes.Buffer)
err := png.Encode(buf, image)
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}

View File

@@ -0,0 +1,230 @@
package layout
import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/tui/theme"
)
type Container interface {
tea.Model
Sizeable
Bindings
Focus()
Blur()
}
type container struct {
width int
height int
content tea.Model
paddingTop int
paddingRight int
paddingBottom int
paddingLeft int
borderTop bool
borderRight bool
borderBottom bool
borderLeft bool
borderStyle lipgloss.Border
focused bool
}
func (c *container) Init() tea.Cmd {
return c.content.Init()
}
func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
u, cmd := c.content.Update(msg)
c.content = u
return c, cmd
}
func (c *container) View() string {
t := theme.CurrentTheme()
style := lipgloss.NewStyle()
width := c.width
height := c.height
style = style.Background(t.Background())
// Apply border if any side is enabled
if c.borderTop || c.borderRight || c.borderBottom || c.borderLeft {
// Adjust width and height for borders
if c.borderTop {
height--
}
if c.borderBottom {
height--
}
if c.borderLeft {
width--
}
if c.borderRight {
width--
}
style = style.Border(c.borderStyle, c.borderTop, c.borderRight, c.borderBottom, c.borderLeft)
// Use primary color for border if focused
if c.focused {
style = style.BorderBackground(t.Background()).BorderForeground(t.Primary())
} else {
style = style.BorderBackground(t.Background()).BorderForeground(t.BorderNormal())
}
}
style = style.
Width(width).
Height(height).
PaddingTop(c.paddingTop).
PaddingRight(c.paddingRight).
PaddingBottom(c.paddingBottom).
PaddingLeft(c.paddingLeft)
return style.Render(c.content.View())
}
func (c *container) SetSize(width, height int) tea.Cmd {
c.width = width
c.height = height
// If the content implements Sizeable, adjust its size to account for padding and borders
if sizeable, ok := c.content.(Sizeable); ok {
// Calculate horizontal space taken by padding and borders
horizontalSpace := c.paddingLeft + c.paddingRight
if c.borderLeft {
horizontalSpace++
}
if c.borderRight {
horizontalSpace++
}
// Calculate vertical space taken by padding and borders
verticalSpace := c.paddingTop + c.paddingBottom
if c.borderTop {
verticalSpace++
}
if c.borderBottom {
verticalSpace++
}
// Set content size with adjusted dimensions
contentWidth := max(0, width-horizontalSpace)
contentHeight := max(0, height-verticalSpace)
return sizeable.SetSize(contentWidth, contentHeight)
}
return nil
}
func (c *container) GetSize() (int, int) {
return c.width, c.height
}
func (c *container) BindingKeys() []key.Binding {
if b, ok := c.content.(Bindings); ok {
return b.BindingKeys()
}
return []key.Binding{}
}
// Focus sets the container as focused
func (c *container) Focus() {
c.focused = true
// Pass focus to content if it supports it
if focusable, ok := c.content.(interface{ Focus() }); ok {
focusable.Focus()
}
}
// Blur removes focus from the container
func (c *container) Blur() {
c.focused = false
// Remove focus from content if it supports it
if blurable, ok := c.content.(interface{ Blur() }); ok {
blurable.Blur()
}
}
type ContainerOption func(*container)
func NewContainer(content tea.Model, options ...ContainerOption) Container {
c := &container{
content: content,
borderStyle: lipgloss.NormalBorder(),
}
for _, option := range options {
option(c)
}
return c
}
// Padding options
func WithPadding(top, right, bottom, left int) ContainerOption {
return func(c *container) {
c.paddingTop = top
c.paddingRight = right
c.paddingBottom = bottom
c.paddingLeft = left
}
}
func WithPaddingAll(padding int) ContainerOption {
return WithPadding(padding, padding, padding, padding)
}
func WithPaddingHorizontal(padding int) ContainerOption {
return func(c *container) {
c.paddingLeft = padding
c.paddingRight = padding
}
}
func WithPaddingVertical(padding int) ContainerOption {
return func(c *container) {
c.paddingTop = padding
c.paddingBottom = padding
}
}
func WithBorder(top, right, bottom, left bool) ContainerOption {
return func(c *container) {
c.borderTop = top
c.borderRight = right
c.borderBottom = bottom
c.borderLeft = left
}
}
func WithBorderAll() ContainerOption {
return WithBorder(true, true, true, true)
}
func WithBorderHorizontal() ContainerOption {
return WithBorder(true, false, true, false)
}
func WithBorderVertical() ContainerOption {
return WithBorder(false, true, false, true)
}
func WithBorderStyle(style lipgloss.Border) ContainerOption {
return func(c *container) {
c.borderStyle = style
}
}
func WithRoundedBorder() ContainerOption {
return WithBorderStyle(lipgloss.RoundedBorder())
}
func WithThickBorder() ContainerOption {
return WithBorderStyle(lipgloss.ThickBorder())
}
func WithDoubleBorder() ContainerOption {
return WithBorderStyle(lipgloss.DoubleBorder())
}

Some files were not shown because too many files have changed in this diff Show More