mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-24 11:14:23 +01:00
sync
This commit is contained in:
10
packages/function/package.json
Normal file
10
packages/function/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
167
packages/function/src/api.ts
Normal file
167
packages/function/src/api.ts
Normal 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
25
packages/function/sst-env.d.ts
vendored
Normal 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 {}
|
||||
9
packages/function/tsconfig.json
Normal file
9
packages/function/tsconfig.json
Normal 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
3
packages/opencode/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
gen
|
||||
15
packages/opencode/README.md
Normal file
15
packages/opencode/README.md
Normal 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.
|
||||
29
packages/opencode/bin/opencode.mjs
Normal file
29
packages/opencode/bin/opencode.mjs
Normal 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)
|
||||
}
|
||||
39
packages/opencode/package.json
Normal file
39
packages/opencode/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
30
packages/opencode/scrap.ts
Normal file
30
packages/opencode/scrap.ts
Normal 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'];
|
||||
}
|
||||
68
packages/opencode/script/release.ts
Executable file
68
packages/opencode/script/release.ts
Executable 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`
|
||||
78
packages/opencode/src/app/app.ts
Normal file
78
packages/opencode/src/app/app.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
11
packages/opencode/src/app/path.ts
Normal file
11
packages/opencode/src/app/path.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
28
packages/opencode/src/bun/index.ts
Normal file
28
packages/opencode/src/bun/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
101
packages/opencode/src/bus/index.ts
Normal file
101
packages/opencode/src/bus/index.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
}
|
||||
51
packages/opencode/src/config/config.ts
Normal file
51
packages/opencode/src/config/config.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
20
packages/opencode/src/global/index.ts
Normal file
20
packages/opencode/src/global/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
74
packages/opencode/src/id/id.ts
Normal file
74
packages/opencode/src/id/id.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
85
packages/opencode/src/index.ts
Normal file
85
packages/opencode/src/index.ts
Normal 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()
|
||||
172
packages/opencode/src/llm/llm.ts
Normal file
172
packages/opencode/src/llm/llm.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
208
packages/opencode/src/lsp/client.ts
Normal file
208
packages/opencode/src/lsp/client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
131
packages/opencode/src/lsp/index.ts
Normal file
131
packages/opencode/src/lsp/index.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
89
packages/opencode/src/lsp/language.ts
Normal file
89
packages/opencode/src/lsp/language.ts
Normal 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;
|
||||
35
packages/opencode/src/provider/provider.ts
Normal file
35
packages/opencode/src/provider/provider.ts
Normal 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>;
|
||||
}
|
||||
309
packages/opencode/src/server/server.ts
Normal file
309
packages/opencode/src/server/server.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
171
packages/opencode/src/session/message.ts
Normal file
171
packages/opencode/src/session/message.ts
Normal 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,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
95
packages/opencode/src/session/prompt/anthropic.txt
Normal file
95
packages/opencode/src/session/prompt/anthropic.txt
Normal 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.
|
||||
10
packages/opencode/src/session/prompt/summarize.txt
Normal file
10
packages/opencode/src/session/prompt/summarize.txt
Normal 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.
|
||||
7
packages/opencode/src/session/prompt/title.txt
Normal file
7
packages/opencode/src/session/prompt/title.txt
Normal 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
|
||||
498
packages/opencode/src/session/session.ts
Normal file
498
packages/opencode/src/session/session.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
67
packages/opencode/src/share/share.ts
Normal file
67
packages/opencode/src/share/share.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
55
packages/opencode/src/storage/storage.ts
Normal file
55
packages/opencode/src/storage/storage.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
199
packages/opencode/src/tool/bash.ts
Normal file
199
packages/opencode/src/tool/bash.ts
Normal 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"),
|
||||
};
|
||||
},
|
||||
});
|
||||
136
packages/opencode/src/tool/edit.ts
Normal file
136
packages/opencode/src/tool/edit.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
});
|
||||
137
packages/opencode/src/tool/fetch.ts
Normal file
137
packages/opencode/src/tool/fetch.ts
Normal 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);
|
||||
}
|
||||
96
packages/opencode/src/tool/glob.ts
Normal file
96
packages/opencode/src/tool/glob.ts
Normal 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"),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
345
packages/opencode/src/tool/grep.ts
Normal file
345
packages/opencode/src/tool/grep.ts
Normal 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"),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
9
packages/opencode/src/tool/index.ts
Normal file
9
packages/opencode/src/tool/index.ts
Normal 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";
|
||||
96
packages/opencode/src/tool/ls.ts
Normal file
96
packages/opencode/src/tool/ls.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
});
|
||||
53
packages/opencode/src/tool/lsp-diagnostics.ts
Normal file
53
packages/opencode/src/tool/lsp-diagnostics.ts
Normal 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",
|
||||
};
|
||||
},
|
||||
});
|
||||
38
packages/opencode/src/tool/lsp-hover.ts
Normal file
38
packages/opencode/src/tool/lsp-hover.ts
Normal 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),
|
||||
};
|
||||
},
|
||||
});
|
||||
420
packages/opencode/src/tool/patch.ts
Normal file
420
packages/opencode/src/tool/patch.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
});
|
||||
61
packages/opencode/src/tool/tool.ts
Normal file
61
packages/opencode/src/tool/tool.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
20
packages/opencode/src/tool/util/file-times.ts
Normal file
20
packages/opencode/src/tool/util/file-times.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
152
packages/opencode/src/tool/view.ts
Normal file
152
packages/opencode/src/tool/view.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
25
packages/opencode/src/util/context.ts
Normal file
25
packages/opencode/src/util/context.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
0
packages/opencode/src/util/event.ts
Normal file
0
packages/opencode/src/util/event.ts
Normal file
64
packages/opencode/src/util/log.ts
Normal file
64
packages/opencode/src/util/log.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
5
packages/opencode/src/util/scrap.ts
Normal file
5
packages/opencode/src/util/scrap.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const foo: string = "42";
|
||||
|
||||
export function dummyFunction(): void {
|
||||
console.log("This is a dummy function");
|
||||
}
|
||||
17
packages/opencode/test/tool/__snapshots__/tool.test.ts.snap
Normal file
17
packages/opencode/test/tool/__snapshots__/tool.test.ts.snap
Normal 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
|
||||
"
|
||||
`;
|
||||
55
packages/opencode/test/tool/tool.test.ts
Normal file
55
packages/opencode/test/tool/tool.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
5
packages/opencode/tsconfig.json
Normal file
5
packages/opencode/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {}
|
||||
}
|
||||
77
packages/tui/.goreleaser.yml
Normal file
77
packages/tui/.goreleaser.yml
Normal 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
8
packages/tui/app.log
Normal 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
258
packages/tui/cmd/root.go
Normal 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
105
packages/tui/go.mod
Normal 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
338
packages/tui/go.sum
Normal 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=
|
||||
191
packages/tui/internal/completions/files-folders.go
Normal file
191
packages/tui/internal/completions/files-folders.go
Normal 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",
|
||||
}
|
||||
}
|
||||
266
packages/tui/internal/config/config.go
Normal file
266
packages/tui/internal/config/config.go
Normal 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
|
||||
})
|
||||
}
|
||||
60
packages/tui/internal/config/init.go
Normal file
60
packages/tui/internal/config/init.go
Normal 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
|
||||
}
|
||||
869
packages/tui/internal/diff/diff.go
Normal file
869
packages/tui/internal/diff/diff.go
Normal 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
|
||||
}
|
||||
103
packages/tui/internal/diff/diff_test.go
Normal file
103
packages/tui/internal/diff/diff_test.go
Normal 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")
|
||||
}
|
||||
740
packages/tui/internal/diff/patch.go
Normal file
740
packages/tui/internal/diff/patch.go
Normal 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
|
||||
}
|
||||
163
packages/tui/internal/fileutil/fileutil.go
Normal file
163
packages/tui/internal/fileutil/fileutil.go
Normal 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
|
||||
}
|
||||
46
packages/tui/internal/format/format.go
Normal file
46
packages/tui/internal/format/format.go
Normal 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)
|
||||
}
|
||||
}
|
||||
90
packages/tui/internal/format/format_test.go
Normal file
90
packages/tui/internal/format/format_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
113
packages/tui/internal/pubsub/broker.go
Normal file
113
packages/tui/internal/pubsub/broker.go
Normal 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)
|
||||
}
|
||||
144
packages/tui/internal/pubsub/broker_test.go
Normal file
144
packages/tui/internal/pubsub/broker_test.go
Normal 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)
|
||||
}
|
||||
24
packages/tui/internal/pubsub/events.go
Normal file
24
packages/tui/internal/pubsub/events.go
Normal 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)
|
||||
}
|
||||
142
packages/tui/internal/status/status.go
Normal file
142
packages/tui/internal/status/status.go
Normal 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)
|
||||
}
|
||||
215
packages/tui/internal/tui/app/app.go
Normal file
215
packages/tui/internal/tui/app/app.go
Normal 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?
|
||||
}
|
||||
42
packages/tui/internal/tui/app/bridge.go
Normal file
42
packages/tui/internal/tui/app/bridge.go
Normal 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")
|
||||
}
|
||||
13
packages/tui/internal/tui/app/interfaces.go
Normal file
13
packages/tui/internal/tui/app/interfaces.go
Normal 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
|
||||
}
|
||||
133
packages/tui/internal/tui/components/chat/chat.go
Normal file
133
packages/tui/internal/tui/components/chat/chat.go
Normal 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)
|
||||
}
|
||||
406
packages/tui/internal/tui/components/chat/editor.go
Normal file
406
packages/tui/internal/tui/components/chat/editor.go
Normal 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: "",
|
||||
}
|
||||
}
|
||||
716
packages/tui/internal/tui/components/chat/message.go
Normal file
716
packages/tui/internal/tui/components/chat/message.go
Normal 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), ¶ms)
|
||||
// // prompt := strings.ReplaceAll(params.Prompt, "\n", " ")
|
||||
// // return renderParams(paramWidth, prompt)
|
||||
// case "bash":
|
||||
// var params tools.BashParams
|
||||
// json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
// command := strings.ReplaceAll(params.Command, "\n", " ")
|
||||
// return renderParams(paramWidth, command)
|
||||
// case "edit":
|
||||
// var params tools.EditParams
|
||||
// json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
// filePath := removeWorkingDirPrefix(params.FilePath)
|
||||
// return renderParams(paramWidth, filePath)
|
||||
// case "fetch":
|
||||
// var params tools.FetchParams
|
||||
// json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
// 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), ¶ms)
|
||||
// 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), ¶ms)
|
||||
// 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), ¶ms)
|
||||
// path := params.Path
|
||||
// if path == "" {
|
||||
// path = "."
|
||||
// }
|
||||
// return renderParams(paramWidth, path)
|
||||
// case tools.ViewToolName:
|
||||
// var params tools.ViewParams
|
||||
// json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
// 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), ¶ms)
|
||||
// filePath := removeWorkingDirPrefix(params.FilePath)
|
||||
// return renderParams(paramWidth, filePath)
|
||||
// case tools.BatchToolName:
|
||||
// var params tools.BatchParams
|
||||
// json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
// 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), ¶ms)
|
||||
// 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), ¶ms)
|
||||
// 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
|
||||
// }
|
||||
344
packages/tui/internal/tui/components/chat/messages.go
Normal file
344
packages/tui/internal/tui/components/chat/messages.go
Normal 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,
|
||||
}
|
||||
}
|
||||
220
packages/tui/internal/tui/components/chat/sidebar.go
Normal file
220
packages/tui/internal/tui/components/chat/sidebar.go
Normal 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, "/")
|
||||
}
|
||||
366
packages/tui/internal/tui/components/core/status.go
Normal file
366
packages/tui/internal/tui/components/core/status.go
Normal 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
|
||||
}
|
||||
257
packages/tui/internal/tui/components/dialog/arguments.go
Normal file
257
packages/tui/internal/tui/components/dialog/arguments.go
Normal 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()
|
||||
}
|
||||
180
packages/tui/internal/tui/components/dialog/commands.go
Normal file
180
packages/tui/internal/tui/components/dialog/commands.go
Normal 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,
|
||||
}
|
||||
}
|
||||
263
packages/tui/internal/tui/components/dialog/complete.go
Normal file
263
packages/tui/internal/tui/components/dialog/complete.go
Normal 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,
|
||||
}
|
||||
}
|
||||
186
packages/tui/internal/tui/components/dialog/custom_commands.go
Normal file
186
packages/tui/internal/tui/components/dialog/custom_commands.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
485
packages/tui/internal/tui/components/dialog/filepicker.go
Normal file
485
packages/tui/internal/tui/components/dialog/filepicker.go
Normal 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")
|
||||
}
|
||||
200
packages/tui/internal/tui/components/dialog/help.go
Normal file
200
packages/tui/internal/tui/components/dialog/help.go
Normal 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{}
|
||||
}
|
||||
189
packages/tui/internal/tui/components/dialog/init.go
Normal file
189
packages/tui/internal/tui/components/dialog/init.go
Normal 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
|
||||
}
|
||||
327
packages/tui/internal/tui/components/dialog/models.go
Normal file
327
packages/tui/internal/tui/components/dialog/models.go
Normal 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,
|
||||
}
|
||||
}
|
||||
502
packages/tui/internal/tui/components/dialog/permission.go
Normal file
502
packages/tui/internal/tui/components/dialog/permission.go
Normal 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),
|
||||
}
|
||||
}
|
||||
136
packages/tui/internal/tui/components/dialog/quit.go
Normal file
136
packages/tui/internal/tui/components/dialog/quit.go
Normal 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,
|
||||
}
|
||||
}
|
||||
230
packages/tui/internal/tui/components/dialog/session.go
Normal file
230
packages/tui/internal/tui/components/dialog/session.go
Normal 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: "",
|
||||
}
|
||||
}
|
||||
199
packages/tui/internal/tui/components/dialog/theme.go
Normal file
199
packages/tui/internal/tui/components/dialog/theme.go
Normal 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: "",
|
||||
}
|
||||
}
|
||||
178
packages/tui/internal/tui/components/dialog/tools.go
Normal file
178
packages/tui/internal/tui/components/dialog/tools.go
Normal 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,
|
||||
}
|
||||
}
|
||||
58
packages/tui/internal/tui/components/qr/qr.go
Normal file
58
packages/tui/internal/tui/components/qr/qr.go
Normal 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
|
||||
}
|
||||
127
packages/tui/internal/tui/components/spinner/spinner.go
Normal file
127
packages/tui/internal/tui/components/spinner/spinner.go
Normal 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
|
||||
}
|
||||
24
packages/tui/internal/tui/components/spinner/spinner_test.go
Normal file
24
packages/tui/internal/tui/components/spinner/spinner_test.go
Normal 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
|
||||
}
|
||||
159
packages/tui/internal/tui/components/util/simple-list.go
Normal file
159
packages/tui/internal/tui/components/util/simple-list.go
Normal 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,
|
||||
}
|
||||
}
|
||||
49
packages/tui/internal/tui/image/clipboard_unix.go
Normal file
49
packages/tui/internal/tui/image/clipboard_unix.go
Normal 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
|
||||
}
|
||||
192
packages/tui/internal/tui/image/clipboard_windows.go
Normal file
192
packages/tui/internal/tui/image/clipboard_windows.go
Normal 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])
|
||||
}
|
||||
85
packages/tui/internal/tui/image/images.go
Normal file
85
packages/tui/internal/tui/image/images.go
Normal 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
|
||||
}
|
||||
230
packages/tui/internal/tui/layout/container.go
Normal file
230
packages/tui/internal/tui/layout/container.go
Normal 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
Reference in New Issue
Block a user