Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bee48a9bc | ||
|
|
10ddd654cf | ||
|
|
61396b93ed | ||
|
|
62b9a30a9c | ||
|
|
5706c6ad3a | ||
|
|
e8e03c895a | ||
|
|
38667682a7 | ||
|
|
d7d5fc39fb | ||
|
|
0caf25adee | ||
|
|
37febc6873 | ||
|
|
4169f0c412 | ||
|
|
b7f06bbc1f | ||
|
|
1b8cfe9e99 | ||
|
|
97837d2d23 | ||
|
|
9abc2a0cf8 | ||
|
|
9fb47bc855 | ||
|
|
73e9fb53d5 | ||
|
|
f03637b1fc | ||
|
|
2c376c5abc | ||
|
|
442e1b52ad |
@@ -1,23 +0,0 @@
|
||||
# Maintainer: dax
|
||||
# Maintainer: adam
|
||||
|
||||
pkgname='opencode-bin'
|
||||
pkgver={{VERSION}}
|
||||
pkgrel=1
|
||||
pkgdesc='The AI coding agent built for the terminal.'
|
||||
url='https://github.com/sst/opencode'
|
||||
arch=('aarch64' 'x86_64')
|
||||
license=('MIT')
|
||||
provides=('opencode')
|
||||
conflicts=('opencode')
|
||||
depends=('fzf' 'ripgrep')
|
||||
|
||||
source_aarch64=("${pkgname}_${pkgver}_aarch64.zip::{{ARM64_URL}}")
|
||||
sha256sums_aarch64=('{{ARM64_SHA}}')
|
||||
|
||||
source_x86_64=("${pkgname}_${pkgver}_x86_64.zip::{{X64_URL}}")
|
||||
sha256sums_x86_64=('{{X64_SHA}}')
|
||||
|
||||
package() {
|
||||
install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode"
|
||||
}
|
||||
@@ -118,30 +118,52 @@ if (!snapshot) {
|
||||
if (!dry)
|
||||
await $`gh release create v${version} --title "v${version}" --notes ${notes} ./dist/*.zip`
|
||||
|
||||
// Calculate SHA values
|
||||
const arm64Sha =
|
||||
await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
const x64Sha =
|
||||
await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
const macX64Sha =
|
||||
await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
const macArm64Sha =
|
||||
await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
|
||||
// AUR package
|
||||
const pkgbuildTemplate = await Bun.file("./script/PKGBUILD.template").text()
|
||||
const pkgbuild = pkgbuildTemplate
|
||||
.replace("{{VERSION}}", version.split("-")[0])
|
||||
.replace(
|
||||
"{{ARM64_URL}}",
|
||||
`https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-arm64.zip`,
|
||||
)
|
||||
.replace(
|
||||
"{{ARM64_SHA}}",
|
||||
await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim()),
|
||||
)
|
||||
.replace(
|
||||
"{{X64_URL}}",
|
||||
`https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-x64.zip`,
|
||||
)
|
||||
.replace(
|
||||
"{{X64_SHA}}",
|
||||
await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim()),
|
||||
)
|
||||
const pkgbuild = [
|
||||
"# Maintainer: dax",
|
||||
"# Maintainer: adam",
|
||||
"",
|
||||
"pkgname='opencode-bin'",
|
||||
`pkgver=${version.split("-")[0]}`,
|
||||
"options=('!debug' '!strip')",
|
||||
"pkgrel=1",
|
||||
"pkgdesc='The AI coding agent built for the terminal.'",
|
||||
"url='https://github.com/sst/opencode'",
|
||||
"arch=('aarch64' 'x86_64')",
|
||||
"license=('MIT')",
|
||||
"provides=('opencode')",
|
||||
"conflicts=('opencode')",
|
||||
"depends=('fzf' 'ripgrep')",
|
||||
"",
|
||||
`source_aarch64=("\${pkgname}_\${pkgver}_aarch64.zip::https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-arm64.zip")`,
|
||||
`sha256sums_aarch64=('${arm64Sha}')`,
|
||||
"",
|
||||
`source_x86_64=("\${pkgname}_\${pkgver}_x86_64.zip::https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-x64.zip")`,
|
||||
`sha256sums_x86_64=('${x64Sha}')`,
|
||||
"",
|
||||
"package() {",
|
||||
' install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode"',
|
||||
"}",
|
||||
"",
|
||||
].join("\n")
|
||||
|
||||
await $`rm -rf ./dist/aur-opencode-bin`
|
||||
|
||||
@@ -151,4 +173,62 @@ if (!snapshot) {
|
||||
await $`cd ./dist/aur-opencode-bin && git add PKGBUILD .SRCINFO`
|
||||
await $`cd ./dist/aur-opencode-bin && git commit -m "Update to v${version}"`
|
||||
if (!dry) await $`cd ./dist/aur-opencode-bin && git push`
|
||||
|
||||
// Homebrew formula
|
||||
const homebrewFormula = [
|
||||
"# typed: false",
|
||||
"# frozen_string_literal: true",
|
||||
"",
|
||||
"# This file was generated by GoReleaser. DO NOT EDIT.",
|
||||
"class Opencode < Formula",
|
||||
` desc "The AI coding agent built for the terminal."`,
|
||||
` homepage "https://github.com/sst/opencode"`,
|
||||
` version "${version.split("-")[0]}"`,
|
||||
"",
|
||||
" on_macos do",
|
||||
" if Hardware::CPU.intel?",
|
||||
` url "https://github.com/sst/opencode/releases/download/v${version}/opencode-darwin-x64.zip"`,
|
||||
` sha256 "${macX64Sha}"`,
|
||||
"",
|
||||
" def install",
|
||||
' bin.install "opencode"',
|
||||
" end",
|
||||
" end",
|
||||
" if Hardware::CPU.arm?",
|
||||
` url "https://github.com/sst/opencode/releases/download/v${version}/opencode-darwin-arm64.zip"`,
|
||||
` sha256 "${macArm64Sha}"`,
|
||||
"",
|
||||
" def install",
|
||||
' bin.install "opencode"',
|
||||
" end",
|
||||
" end",
|
||||
" end",
|
||||
"",
|
||||
" on_linux do",
|
||||
" if Hardware::CPU.intel? and Hardware::CPU.is_64_bit?",
|
||||
` url "https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-x64.zip"`,
|
||||
` sha256 "${x64Sha}"`,
|
||||
" def install",
|
||||
' bin.install "opencode"',
|
||||
" end",
|
||||
" end",
|
||||
" if Hardware::CPU.arm? and Hardware::CPU.is_64_bit?",
|
||||
` url "https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-arm64.zip"`,
|
||||
` sha256 "${arm64Sha}"`,
|
||||
" def install",
|
||||
' bin.install "opencode"',
|
||||
" end",
|
||||
" end",
|
||||
" end",
|
||||
"end",
|
||||
"",
|
||||
"",
|
||||
].join("\n")
|
||||
|
||||
await $`rm -rf ./dist/homebrew-tap`
|
||||
await $`git clone git@github.com:sst/homebrew-tap.git ./dist/homebrew-tap`
|
||||
await Bun.file("./dist/homebrew-tap/opencode.rb").write(homebrewFormula)
|
||||
await $`cd ./dist/homebrew-tap && git add opencode.rb`
|
||||
await $`cd ./dist/homebrew-tap && git commit -m "Update to v${version}"`
|
||||
if (!dry) await $`cd ./dist/homebrew-tap && git push`
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { z } from "zod"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { NamedError } from "../util/error"
|
||||
|
||||
export namespace BunProc {
|
||||
const log = Log.create({ service: "bun" })
|
||||
|
||||
@@ -30,4 +35,24 @@ export namespace BunProc {
|
||||
export function which() {
|
||||
return process.execPath
|
||||
}
|
||||
|
||||
export const InstallFailedError = NamedError.create(
|
||||
"BunInstallFailedError",
|
||||
z.object({
|
||||
pkg: z.string(),
|
||||
version: z.string(),
|
||||
}),
|
||||
)
|
||||
export async function install(pkg: string, version = "latest") {
|
||||
const dir = path.join(Global.Path.cache, `node_modules`, pkg)
|
||||
if (!(await Bun.file(path.join(dir, "package.json")).exists())) {
|
||||
log.info("installing", { pkg })
|
||||
await BunProc.run(["add", `${pkg}@${version}`], {
|
||||
cwd: Global.Path.cache,
|
||||
}).catch(() => {
|
||||
throw new InstallFailedError({ pkg, version })
|
||||
})
|
||||
}
|
||||
return dir
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as prompts from "@clack/prompts"
|
||||
import open from "open"
|
||||
import { UI } from "../ui"
|
||||
import { ModelsDev } from "../../provider/models"
|
||||
import { map, pipe, sort, sortBy, values } from "remeda"
|
||||
|
||||
export const AuthCommand = cmd({
|
||||
command: "auth",
|
||||
@@ -43,26 +44,53 @@ export const AuthLoginCommand = cmd({
|
||||
async handler() {
|
||||
UI.empty()
|
||||
prompts.intro("Add credential")
|
||||
const provider = await prompts.select({
|
||||
const providers = await ModelsDev.get()
|
||||
const priority: Record<string, number> = {
|
||||
anthropic: 0,
|
||||
openai: 1,
|
||||
google: 2,
|
||||
}
|
||||
let provider = await prompts.select({
|
||||
message: "Select provider",
|
||||
maxItems: 2,
|
||||
maxItems: 8,
|
||||
options: [
|
||||
...pipe(
|
||||
providers,
|
||||
values(),
|
||||
sortBy(
|
||||
(x) => priority[x.id] ?? 99,
|
||||
(x) => x.name ?? x.id,
|
||||
),
|
||||
map((x) => ({
|
||||
label: x.name,
|
||||
value: x.id,
|
||||
hint: priority[x.id] === 0 ? "recommended" : undefined,
|
||||
})),
|
||||
),
|
||||
{
|
||||
label: "Anthropic",
|
||||
value: "anthropic",
|
||||
},
|
||||
{
|
||||
label: "OpenAI",
|
||||
value: "openai",
|
||||
},
|
||||
{
|
||||
label: "Google",
|
||||
value: "google",
|
||||
value: "other",
|
||||
label: "Other",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
|
||||
if (provider === "other") {
|
||||
provider = await prompts.text({
|
||||
message: "Enter provider - must match @ai-sdk/<provider>",
|
||||
})
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
}
|
||||
|
||||
if (provider === "amazon-bedrock") {
|
||||
prompts.log.info(
|
||||
"Amazon bedrock can be configured with standard AWS environment variables like AWS_PROFILE or AWS_ACCESS_KEY_ID",
|
||||
)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
if (provider === "anthropic") {
|
||||
const method = await prompts.select({
|
||||
message: "Login method",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { App } from "../../app/app"
|
||||
import { LSP } from "../../lsp"
|
||||
import { VERSION } from "../version"
|
||||
import { cmd } from "./cmd"
|
||||
|
||||
@@ -6,9 +7,10 @@ export const ScrapCommand = cmd({
|
||||
command: "scrap <file>",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("file", { type: "string", demandOption: true }),
|
||||
async handler() {
|
||||
async handler(args) {
|
||||
await App.provide({ cwd: process.cwd(), version: VERSION }, async (app) => {
|
||||
Bun.resolveSync("typescript/lib/tsserver.js", app.path.cwd)
|
||||
await LSP.touchFile(args.file, true)
|
||||
console.log(await LSP.diagnostics())
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Log } from "../util/log"
|
||||
import { z } from "zod"
|
||||
import { App } from "../app/app"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { ModelsDev } from "../provider/models"
|
||||
|
||||
export namespace Config {
|
||||
const log = Log.create({ service: "config" })
|
||||
@@ -49,7 +50,14 @@ export namespace Config {
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
provider: z.record(z.string(), z.record(z.string(), z.any())).optional(),
|
||||
provider: z
|
||||
.record(
|
||||
ModelsDev.Provider.partial().extend({
|
||||
models: z.record(ModelsDev.Model.partial()),
|
||||
options: z.record(z.any()).optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
tool: z
|
||||
.object({
|
||||
provider: z.record(z.string(), z.string().array()).optional(),
|
||||
|
||||
@@ -1,17 +1,54 @@
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { z } from "zod"
|
||||
|
||||
export namespace ModelsDev {
|
||||
const log = Log.create({ service: "models.dev" })
|
||||
const filepath = path.join(Global.Path.cache, "models.json")
|
||||
|
||||
export const Model = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
attachment: z.boolean(),
|
||||
reasoning: z.boolean(),
|
||||
temperature: z.boolean(),
|
||||
cost: z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
inputCached: z.number(),
|
||||
outputCached: z.number(),
|
||||
}),
|
||||
limit: z.object({
|
||||
context: z.number(),
|
||||
output: z.number(),
|
||||
}),
|
||||
id: z.string(),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Model.Info",
|
||||
})
|
||||
export type Model = z.infer<typeof Model>
|
||||
|
||||
export const Provider = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
env: z.array(z.string()),
|
||||
id: z.string(),
|
||||
models: z.record(Model),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Provider.Info",
|
||||
})
|
||||
|
||||
export type Provider = z.infer<typeof Provider>
|
||||
|
||||
export async function get() {
|
||||
const file = Bun.file(filepath)
|
||||
const result = await file.json().catch(() => {})
|
||||
if (result) {
|
||||
refresh()
|
||||
return result
|
||||
return result as Record<string, Provider>
|
||||
}
|
||||
await refresh()
|
||||
return get()
|
||||
|
||||
@@ -4,8 +4,6 @@ import { Config } from "../config/config"
|
||||
import { mergeDeep, sortBy } from "remeda"
|
||||
import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
import { BunProc } from "../bun"
|
||||
import { BashTool } from "../tool/bash"
|
||||
import { EditTool } from "../tool/edit"
|
||||
@@ -29,106 +27,59 @@ import { TaskTool } from "../tool/task"
|
||||
export namespace Provider {
|
||||
const log = Log.create({ service: "provider" })
|
||||
|
||||
export const Model = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
attachment: z.boolean(),
|
||||
reasoning: z.boolean().optional(),
|
||||
cost: z.object({
|
||||
input: z.number(),
|
||||
inputCached: z.number(),
|
||||
output: z.number(),
|
||||
outputCached: z.number(),
|
||||
}),
|
||||
limit: z.object({
|
||||
context: z.number(),
|
||||
output: z.number(),
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Provider.Model",
|
||||
})
|
||||
export type Model = z.output<typeof Model>
|
||||
type CustomLoader = (
|
||||
provider: ModelsDev.Provider,
|
||||
) => Promise<Record<string, any> | false>
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
models: z.record(z.string(), Model),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Provider.Info",
|
||||
})
|
||||
export type Info = z.output<typeof Info>
|
||||
type Source = "env" | "config" | "custom" | "api"
|
||||
|
||||
type Autodetector = (provider: Info) => Promise<
|
||||
| {
|
||||
source: Source
|
||||
options: Record<string, any>
|
||||
}
|
||||
| false
|
||||
>
|
||||
|
||||
function env(...keys: string[]) {
|
||||
const result: Autodetector = async () => {
|
||||
for (const key of keys) {
|
||||
if (process.env[key])
|
||||
return {
|
||||
source: "env",
|
||||
options: {},
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
type Source = "oauth" | "env" | "config" | "api"
|
||||
|
||||
const AUTODETECT: Record<string, Autodetector> = {
|
||||
const CUSTOM_LOADERS: Record<string, CustomLoader> = {
|
||||
async anthropic(provider) {
|
||||
const access = await AuthAnthropic.access()
|
||||
if (access) {
|
||||
// claude sub doesn't have usage cost
|
||||
for (const model of Object.values(provider.models)) {
|
||||
model.cost = {
|
||||
input: 0,
|
||||
inputCached: 0,
|
||||
output: 0,
|
||||
outputCached: 0,
|
||||
}
|
||||
}
|
||||
return {
|
||||
source: "oauth",
|
||||
options: {
|
||||
apiKey: "",
|
||||
headers: {
|
||||
authorization: `Bearer ${access}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
},
|
||||
},
|
||||
if (!access) return false
|
||||
for (const model of Object.values(provider.models)) {
|
||||
model.cost = {
|
||||
input: 0,
|
||||
inputCached: 0,
|
||||
output: 0,
|
||||
outputCached: 0,
|
||||
}
|
||||
}
|
||||
return env("ANTHROPIC_API_KEY")(provider)
|
||||
return {
|
||||
apiKey: "",
|
||||
headers: {
|
||||
authorization: `Bearer ${access}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
},
|
||||
}
|
||||
},
|
||||
"amazon-bedrock": async () => {
|
||||
if (!process.env["AWS_PROFILE"]) return false
|
||||
const { fromNodeProviderChain } = await import(
|
||||
await BunProc.install("@aws-sdk/credential-providers")
|
||||
)
|
||||
return {
|
||||
region: process.env["AWS_REGION"] ?? "us-east-1",
|
||||
credentialProvider: fromNodeProviderChain(),
|
||||
}
|
||||
},
|
||||
google: env("GOOGLE_GENERATIVE_AI_API_KEY"),
|
||||
openai: env("OPENAI_API_KEY"),
|
||||
}
|
||||
|
||||
const state = App.state("provider", async () => {
|
||||
const config = await Config.get()
|
||||
const database: Record<string, Provider.Info> = await ModelsDev.get()
|
||||
const database = await ModelsDev.get()
|
||||
|
||||
const providers: {
|
||||
[providerID: string]: {
|
||||
source: Source
|
||||
info: Provider.Info
|
||||
info: ModelsDev.Provider
|
||||
options: Record<string, any>
|
||||
}
|
||||
} = {}
|
||||
const models = new Map<string, { info: Model; language: LanguageModel }>()
|
||||
const models = new Map<
|
||||
string,
|
||||
{ info: ModelsDev.Model; language: LanguageModel }
|
||||
>()
|
||||
const sdk = new Map<string, SDK>()
|
||||
|
||||
log.info("loading")
|
||||
@@ -142,11 +93,7 @@ export namespace Provider {
|
||||
if (!provider) {
|
||||
providers[id] = {
|
||||
source,
|
||||
info: database[id] ?? {
|
||||
id,
|
||||
name: id,
|
||||
models: [],
|
||||
},
|
||||
info: database[id],
|
||||
options,
|
||||
}
|
||||
return
|
||||
@@ -155,22 +102,68 @@ export namespace Provider {
|
||||
provider.source = source
|
||||
}
|
||||
|
||||
for (const [providerID, fn] of Object.entries(AUTODETECT)) {
|
||||
const provider = database[providerID]
|
||||
if (!provider) continue
|
||||
const result = await fn(provider)
|
||||
if (!result) continue
|
||||
mergeProvider(providerID, result.options, result.source)
|
||||
for (const [providerID, provider] of Object.entries(
|
||||
config.provider ?? {},
|
||||
)) {
|
||||
const existing = database[providerID]
|
||||
const parsed: ModelsDev.Provider = {
|
||||
id: providerID,
|
||||
name: provider.name ?? existing?.name ?? providerID,
|
||||
env: provider.env ?? existing?.env ?? [],
|
||||
models: existing?.models ?? {},
|
||||
}
|
||||
|
||||
for (const [modelID, model] of Object.entries(provider.models ?? {})) {
|
||||
const existing = parsed.models[modelID]
|
||||
const parsedModel: ModelsDev.Model = {
|
||||
id: modelID,
|
||||
name: model.name ?? existing?.name ?? modelID,
|
||||
attachment: model.attachment ?? existing?.attachment ?? false,
|
||||
reasoning: model.reasoning ?? existing?.reasoning ?? false,
|
||||
temperature: model.temperature ?? existing?.temperature ?? false,
|
||||
cost: model.cost ??
|
||||
existing?.cost ?? {
|
||||
input: 0,
|
||||
output: 0,
|
||||
inputCached: 0,
|
||||
outputCached: 0,
|
||||
},
|
||||
limit: model.limit ??
|
||||
existing?.limit ?? {
|
||||
context: 0,
|
||||
output: 0,
|
||||
},
|
||||
}
|
||||
parsed.models[modelID] = parsedModel
|
||||
}
|
||||
database[providerID] = parsed
|
||||
}
|
||||
|
||||
for (const [providerID, info] of Object.entries(await Auth.all())) {
|
||||
if (info.type === "api") {
|
||||
mergeProvider(providerID, { apiKey: info.key }, "api")
|
||||
// load env
|
||||
for (const [providerID, provider] of Object.entries(database)) {
|
||||
if (provider.env.some((item) => process.env[item])) {
|
||||
mergeProvider(providerID, {}, "env")
|
||||
}
|
||||
}
|
||||
|
||||
for (const [providerID, options] of Object.entries(config.provider ?? {})) {
|
||||
mergeProvider(providerID, options, "config")
|
||||
// load apikeys
|
||||
for (const [providerID, provider] of Object.entries(await Auth.all())) {
|
||||
if (provider.type === "api") {
|
||||
mergeProvider(providerID, { apiKey: provider.key }, "api")
|
||||
}
|
||||
}
|
||||
|
||||
// load custom
|
||||
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
|
||||
const result = await fn(database[providerID])
|
||||
if (result) mergeProvider(providerID, result, "custom")
|
||||
}
|
||||
|
||||
// load config
|
||||
for (const [providerID, provider] of Object.entries(
|
||||
config.provider ?? {},
|
||||
)) {
|
||||
mergeProvider(providerID, provider.options ?? {}, "config")
|
||||
}
|
||||
|
||||
for (const providerID of Object.keys(providers)) {
|
||||
@@ -193,21 +186,9 @@ export namespace Provider {
|
||||
const s = await state()
|
||||
const existing = s.sdk.get(providerID)
|
||||
if (existing) return existing
|
||||
const dir = path.join(
|
||||
Global.Path.cache,
|
||||
`node_modules`,
|
||||
`@ai-sdk`,
|
||||
providerID,
|
||||
const mod = await import(
|
||||
await BunProc.install(`@ai-sdk/${providerID}`, "alpha")
|
||||
)
|
||||
if (!(await Bun.file(path.join(dir, "package.json")).exists())) {
|
||||
log.info("installing", {
|
||||
providerID,
|
||||
})
|
||||
await BunProc.run(["add", `@ai-sdk/${providerID}@alpha`], {
|
||||
cwd: Global.Path.cache,
|
||||
})
|
||||
}
|
||||
const mod = await import(path.join(dir))
|
||||
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
|
||||
const loaded = fn(s.providers[providerID]?.options)
|
||||
s.sdk.set(providerID, loaded)
|
||||
@@ -261,7 +242,7 @@ export namespace Provider {
|
||||
}
|
||||
|
||||
const priority = ["gemini-2.5-pro-preview", "codex-mini", "claude-sonnet-4"]
|
||||
export function sort(models: Model[]) {
|
||||
export function sort(models: ModelsDev.Model[]) {
|
||||
return sortBy(
|
||||
models,
|
||||
[
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Global } from "../global"
|
||||
import { mapValues } from "remeda"
|
||||
import { NamedError } from "../util/error"
|
||||
import { Fzf } from "../external/fzf"
|
||||
import { ModelsDev } from "../provider/models"
|
||||
|
||||
const ERRORS = {
|
||||
400: {
|
||||
@@ -406,7 +407,7 @@ export namespace Server {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
providers: Provider.Info.array(),
|
||||
providers: ModelsDev.Provider.array(),
|
||||
default: z.record(z.string(), z.string()),
|
||||
}),
|
||||
),
|
||||
@@ -421,7 +422,7 @@ export namespace Server {
|
||||
)
|
||||
return c.json({
|
||||
providers: Object.values(providers),
|
||||
defaults: mapValues(
|
||||
default: mapValues(
|
||||
providers,
|
||||
(item) => Provider.sort(Object.values(item.models))[0].id,
|
||||
),
|
||||
|
||||
@@ -123,9 +123,6 @@ func main() {
|
||||
// Cancel subscriptions first
|
||||
cancelSubs()
|
||||
|
||||
// Then shutdown the app
|
||||
app_.Shutdown()
|
||||
|
||||
// Then cancel TUI message handler
|
||||
tuiCancel()
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"log/slog"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/fileutil"
|
||||
"github.com/sst/opencode/internal/state"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
@@ -23,14 +23,11 @@ type App struct {
|
||||
Config *config.Config
|
||||
Client *client.ClientWithResponses
|
||||
Provider *client.ProviderInfo
|
||||
Model *client.ProviderModel
|
||||
Model *client.ModelInfo
|
||||
Session *client.SessionInfo
|
||||
Messages []client.MessageInfo
|
||||
Status status.Service
|
||||
|
||||
// UI state
|
||||
filepickerOpen bool
|
||||
completionDialogOpen bool
|
||||
Commands commands.Registry
|
||||
}
|
||||
|
||||
type AppInfo struct {
|
||||
@@ -61,20 +58,25 @@ func New(ctx context.Context, version string, httpClient *client.ClientWithRespo
|
||||
}
|
||||
providers := []client.ProviderInfo{}
|
||||
var defaultProvider *client.ProviderInfo
|
||||
var defaultModel *client.ProviderModel
|
||||
var defaultModel *client.ModelInfo
|
||||
|
||||
for i, provider := range providersResponse.JSON200.Providers {
|
||||
if i == 0 || provider.Id == "anthropic" {
|
||||
defaultProvider = &providersResponse.JSON200.Providers[i]
|
||||
if match, ok := providersResponse.JSON200.Default[provider.Id]; ok {
|
||||
model := defaultProvider.Models[match]
|
||||
defaultModel = &model
|
||||
} else {
|
||||
for _, model := range provider.Models {
|
||||
defaultModel = &model
|
||||
break
|
||||
}
|
||||
}
|
||||
var anthropic *client.ProviderInfo
|
||||
for _, provider := range providersResponse.JSON200.Providers {
|
||||
if provider.Id == "anthropic" {
|
||||
anthropic = &provider
|
||||
}
|
||||
}
|
||||
|
||||
// default to anthropic if available
|
||||
if anthropic != nil {
|
||||
defaultProvider = anthropic
|
||||
defaultModel = getDefaultModel(providersResponse, *anthropic)
|
||||
}
|
||||
|
||||
for _, provider := range providersResponse.JSON200.Providers {
|
||||
if defaultProvider == nil || defaultModel == nil {
|
||||
defaultProvider = &provider
|
||||
defaultModel = getDefaultModel(providersResponse, provider)
|
||||
}
|
||||
providers = append(providers, provider)
|
||||
}
|
||||
@@ -82,7 +84,7 @@ func New(ctx context.Context, version string, httpClient *client.ClientWithRespo
|
||||
return nil, fmt.Errorf("no providers found")
|
||||
}
|
||||
|
||||
appConfigPath := filepath.Join(Info.Path.Config, "tui.toml")
|
||||
appConfigPath := filepath.Join(Info.Path.Config, "config")
|
||||
appConfig, err := config.LoadConfig(appConfigPath)
|
||||
if err != nil {
|
||||
slog.Info("No TUI config found, using default values", "error", err)
|
||||
@@ -91,7 +93,7 @@ func New(ctx context.Context, version string, httpClient *client.ClientWithRespo
|
||||
}
|
||||
|
||||
var currentProvider *client.ProviderInfo
|
||||
var currentModel *client.ProviderModel
|
||||
var currentModel *client.ModelInfo
|
||||
for _, provider := range providers {
|
||||
if provider.Id == appConfig.Provider {
|
||||
currentProvider = &provider
|
||||
@@ -113,14 +115,26 @@ func New(ctx context.Context, version string, httpClient *client.ClientWithRespo
|
||||
Session: &client.SessionInfo{},
|
||||
Messages: []client.MessageInfo{},
|
||||
Status: status.GetService(),
|
||||
Commands: commands.NewCommandRegistry(),
|
||||
}
|
||||
|
||||
theme.SetTheme(appConfig.Theme)
|
||||
fileutil.Init()
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func getDefaultModel(response *client.PostProviderListResponse, provider client.ProviderInfo) *client.ModelInfo {
|
||||
if match, ok := response.JSON200.Default[provider.Id]; ok {
|
||||
model := provider.Models[match]
|
||||
return &model
|
||||
} else {
|
||||
for _, model := range provider.Models {
|
||||
return &model
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
FilePath string
|
||||
FileName string
|
||||
@@ -309,28 +323,3 @@ func (a *App) ListProviders(ctx context.Context) ([]client.ProviderInfo, error)
|
||||
providers := *resp.JSON200
|
||||
return providers.Providers, nil
|
||||
}
|
||||
|
||||
// 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?
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
)
|
||||
|
||||
// Command represents a user-triggerable action.
|
||||
type Command struct {
|
||||
// Name is the identifier used for slash commands (e.g., "new").
|
||||
Name string
|
||||
// Description is a short explanation of what the command does.
|
||||
Description string
|
||||
// KeyBinding is the keyboard shortcut to trigger this command.
|
||||
KeyBinding key.Binding
|
||||
}
|
||||
|
||||
// Registry holds all the available commands.
|
||||
type Registry map[string]Command
|
||||
|
||||
// ExecuteCommandMsg is a message sent when a command should be executed.
|
||||
type ExecuteCommandMsg struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func NewCommandRegistry() Registry {
|
||||
return Registry{
|
||||
"help": {
|
||||
Name: "help",
|
||||
Description: "show help",
|
||||
KeyBinding: key.NewBinding(
|
||||
key.WithKeys("f1", "super+/", "super+h"),
|
||||
),
|
||||
},
|
||||
"new": {
|
||||
Name: "new",
|
||||
Description: "new session",
|
||||
KeyBinding: key.NewBinding(
|
||||
key.WithKeys("f2", "super+n"),
|
||||
),
|
||||
},
|
||||
"sessions": {
|
||||
Name: "sessions",
|
||||
Description: "switch session",
|
||||
KeyBinding: key.NewBinding(
|
||||
key.WithKeys("f3", "super+s"),
|
||||
),
|
||||
},
|
||||
"model": {
|
||||
Name: "model",
|
||||
Description: "switch model",
|
||||
KeyBinding: key.NewBinding(
|
||||
key.WithKeys("f4", "super+m"),
|
||||
),
|
||||
},
|
||||
"theme": {
|
||||
Name: "theme",
|
||||
Description: "switch theme",
|
||||
KeyBinding: key.NewBinding(
|
||||
key.WithKeys("f5", "super+t"),
|
||||
),
|
||||
},
|
||||
"quit": {
|
||||
Name: "quit",
|
||||
Description: "quit",
|
||||
KeyBinding: key.NewBinding(
|
||||
key.WithKeys("f10", "ctrl+c", "super+q"),
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
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/components/dialog"
|
||||
)
|
||||
|
||||
@@ -27,143 +19,8 @@ func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/image"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
@@ -111,12 +112,6 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
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:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
@@ -189,7 +184,9 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.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() {
|
||||
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryUp) {
|
||||
// TODO: fix this
|
||||
// && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
|
||||
// Get the current line number
|
||||
currentLine := m.textarea.Line()
|
||||
|
||||
@@ -209,7 +206,9 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryDown) && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
|
||||
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryDown) {
|
||||
// TODO: fix this
|
||||
// && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
|
||||
// Get the current line number and total lines
|
||||
currentLine := m.textarea.Line()
|
||||
value := m.textarea.Value()
|
||||
@@ -271,9 +270,11 @@ func (m *editorComponent) View() string {
|
||||
)
|
||||
textarea = styles.BaseStyle().
|
||||
Width(m.width).
|
||||
PaddingTop(1).
|
||||
PaddingBottom(1).
|
||||
Background(t.BackgroundElement()).
|
||||
Border(lipgloss.ThickBorder(), false, true).
|
||||
BorderForeground(t.BorderActive()).
|
||||
BorderForeground(t.BackgroundSubtle()).
|
||||
BorderBackground(t.Background()).
|
||||
Render(textarea)
|
||||
|
||||
@@ -284,7 +285,7 @@ func (m *editorComponent) View() string {
|
||||
|
||||
model := ""
|
||||
if m.app.Model != nil {
|
||||
model = base(*m.app.Model.Name) + muted(" • /model")
|
||||
model = base(m.app.Model.Name) + muted(" • /model")
|
||||
}
|
||||
|
||||
space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
|
||||
@@ -308,7 +309,7 @@ func (m *editorComponent) SetSize(width, height int) tea.Cmd {
|
||||
m.width = width
|
||||
m.height = height
|
||||
m.textarea.SetWidth(width - 5) // account for the prompt and padding right
|
||||
m.textarea.SetHeight(height - 2) // account for info underneath
|
||||
m.textarea.SetHeight(height - 4) // account for info underneath
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -316,13 +317,6 @@ func (m *editorComponent) GetSize() (int, int) {
|
||||
return m.width, m.height
|
||||
}
|
||||
|
||||
func (m *editorComponent) BindingKeys() []key.Binding {
|
||||
bindings := []key.Binding{}
|
||||
bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
|
||||
bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
|
||||
return bindings
|
||||
}
|
||||
|
||||
func (m *editorComponent) openEditor(value string) tea.Cmd {
|
||||
editor := os.Getenv("EDITOR")
|
||||
if editor == "" {
|
||||
@@ -365,7 +359,7 @@ func (m *editorComponent) openEditor(value string) tea.Cmd {
|
||||
}
|
||||
|
||||
func (m *editorComponent) send() tea.Cmd {
|
||||
value := m.textarea.Value()
|
||||
value := strings.TrimSpace(m.textarea.Value())
|
||||
m.textarea.Reset()
|
||||
attachments := m.attachments
|
||||
|
||||
@@ -382,6 +376,15 @@ func (m *editorComponent) send() tea.Cmd {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check for slash command
|
||||
if strings.HasPrefix(value, "/") {
|
||||
commandName := strings.TrimPrefix(value, "/")
|
||||
if _, ok := m.app.Commands[commandName]; ok {
|
||||
return util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName})
|
||||
}
|
||||
}
|
||||
|
||||
return tea.Batch(
|
||||
util.CmdHandler(SendMsg{
|
||||
Text: value,
|
||||
|
||||
@@ -55,6 +55,10 @@ type blockRenderer struct {
|
||||
fullWidth bool
|
||||
paddingTop int
|
||||
paddingBottom int
|
||||
paddingLeft int
|
||||
paddingRight int
|
||||
marginTop int
|
||||
marginBottom int
|
||||
}
|
||||
|
||||
type renderingOption func(*blockRenderer)
|
||||
@@ -77,6 +81,30 @@ func WithBorderColor(color compat.AdaptiveColor) renderingOption {
|
||||
}
|
||||
}
|
||||
|
||||
func WithMarginTop(padding int) renderingOption {
|
||||
return func(c *blockRenderer) {
|
||||
c.marginTop = padding
|
||||
}
|
||||
}
|
||||
|
||||
func WithMarginBottom(padding int) renderingOption {
|
||||
return func(c *blockRenderer) {
|
||||
c.marginBottom = padding
|
||||
}
|
||||
}
|
||||
|
||||
func WithPaddingLeft(padding int) renderingOption {
|
||||
return func(c *blockRenderer) {
|
||||
c.paddingLeft = padding
|
||||
}
|
||||
}
|
||||
|
||||
func WithPaddingRight(padding int) renderingOption {
|
||||
return func(c *blockRenderer) {
|
||||
c.paddingRight = padding
|
||||
}
|
||||
}
|
||||
|
||||
func WithPaddingTop(padding int) renderingOption {
|
||||
return func(c *blockRenderer) {
|
||||
c.paddingTop = padding
|
||||
@@ -92,17 +120,23 @@ func WithPaddingBottom(padding int) renderingOption {
|
||||
func renderContentBlock(content string, options ...renderingOption) string {
|
||||
t := theme.CurrentTheme()
|
||||
renderer := &blockRenderer{
|
||||
fullWidth: false,
|
||||
fullWidth: false,
|
||||
paddingTop: 1,
|
||||
paddingBottom: 1,
|
||||
paddingLeft: 2,
|
||||
paddingRight: 2,
|
||||
}
|
||||
for _, option := range options {
|
||||
option(renderer)
|
||||
}
|
||||
|
||||
style := styles.BaseStyle().
|
||||
PaddingTop(1).
|
||||
PaddingBottom(1).
|
||||
PaddingLeft(2).
|
||||
PaddingRight(2).
|
||||
MarginTop(renderer.marginTop).
|
||||
MarginBottom(renderer.marginBottom).
|
||||
PaddingTop(renderer.paddingTop).
|
||||
PaddingBottom(renderer.paddingBottom).
|
||||
PaddingLeft(renderer.paddingLeft).
|
||||
PaddingRight(renderer.paddingRight).
|
||||
Background(t.BackgroundSubtle()).
|
||||
Foreground(t.TextMuted()).
|
||||
BorderStyle(lipgloss.ThickBorder())
|
||||
@@ -142,12 +176,6 @@ func renderContentBlock(content string, options ...renderingOption) string {
|
||||
style = style.Width(layout.Current.Container.Width)
|
||||
}
|
||||
content = style.Render(content)
|
||||
if renderer.paddingTop > 0 {
|
||||
content = strings.Repeat("\n", renderer.paddingTop) + content
|
||||
}
|
||||
if renderer.paddingBottom > 0 {
|
||||
content = content + strings.Repeat("\n", renderer.paddingBottom)
|
||||
}
|
||||
content = lipgloss.PlaceHorizontal(
|
||||
layout.Current.Container.Width,
|
||||
align,
|
||||
@@ -165,13 +193,12 @@ func renderText(message client.MessageInfo, text string, author string) string {
|
||||
t := theme.CurrentTheme()
|
||||
width := layout.Current.Container.Width
|
||||
padding := 0
|
||||
switch layout.Current.Size {
|
||||
case layout.LayoutSizeSmall:
|
||||
if layout.Current.Viewport.Width < 80 {
|
||||
padding = 5
|
||||
case layout.LayoutSizeNormal:
|
||||
padding = 10
|
||||
case layout.LayoutSizeLarge:
|
||||
} else if layout.Current.Viewport.Width < 120 {
|
||||
padding = 15
|
||||
} else {
|
||||
padding = 20
|
||||
}
|
||||
|
||||
timestamp := time.UnixMilli(int64(message.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
|
||||
@@ -203,7 +230,7 @@ func renderText(message client.MessageInfo, text string, author string) string {
|
||||
case client.Assistant:
|
||||
return renderContentBlock(content,
|
||||
WithAlign(lipgloss.Left),
|
||||
WithBorderColor(t.Primary()),
|
||||
WithBorderColor(t.Accent()),
|
||||
)
|
||||
}
|
||||
return ""
|
||||
@@ -212,7 +239,7 @@ func renderText(message client.MessageInfo, text string, author string) string {
|
||||
func renderToolInvocation(
|
||||
toolCall client.MessageToolInvocationToolCall,
|
||||
result *string,
|
||||
metadata map[string]any,
|
||||
metadata client.MessageInfo_Metadata_Tool_AdditionalProperties,
|
||||
showResult bool,
|
||||
) string {
|
||||
ignoredTools := []string{"opencode_todoread"}
|
||||
@@ -220,16 +247,28 @@ func renderToolInvocation(
|
||||
return ""
|
||||
}
|
||||
|
||||
padding := 1
|
||||
outerWidth := layout.Current.Container.Width - 1 // subtract 1 for the border
|
||||
innerWidth := outerWidth - padding - 4 // -4 for the border and padding
|
||||
outerWidth := layout.Current.Container.Width
|
||||
innerWidth := outerWidth - 6
|
||||
paddingTop := 0
|
||||
paddingBottom := 0
|
||||
if showResult {
|
||||
paddingTop = 1
|
||||
if result == nil || *result == "" {
|
||||
paddingBottom = 1
|
||||
}
|
||||
}
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
style := styles.Muted().
|
||||
Width(outerWidth).
|
||||
PaddingLeft(padding).
|
||||
Background(t.BackgroundSubtle()).
|
||||
PaddingTop(paddingTop).
|
||||
PaddingBottom(paddingBottom).
|
||||
PaddingLeft(2).
|
||||
PaddingRight(2).
|
||||
BorderLeft(true).
|
||||
BorderForeground(t.BorderSubtle()).
|
||||
BorderRight(true).
|
||||
BorderForeground(t.BackgroundSubtle()).
|
||||
BorderStyle(lipgloss.ThickBorder())
|
||||
|
||||
if toolCall.State == "partial-call" {
|
||||
@@ -264,27 +303,33 @@ func renderToolInvocation(
|
||||
body = *result
|
||||
}
|
||||
|
||||
if metadata["error"] != nil && metadata["message"] != nil {
|
||||
body = ""
|
||||
error = styles.BaseStyle().
|
||||
Foreground(t.Error()).
|
||||
Render(metadata["message"].(string))
|
||||
error = renderContentBlock(error, WithBorderColor(t.Error()), WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
|
||||
if e, ok := metadata.Get("error"); ok && e.(bool) == true {
|
||||
if m, ok := metadata.Get("message"); ok {
|
||||
body = "" // don't show the body if there's an error
|
||||
style = style.BorderLeftForeground(t.Error())
|
||||
error = styles.BaseStyle().
|
||||
Background(t.BackgroundSubtle()).
|
||||
Foreground(t.Error()).
|
||||
Render(m.(string))
|
||||
error = renderContentBlock(
|
||||
error,
|
||||
WithFullWidth(),
|
||||
WithBorderColor(t.Error()),
|
||||
WithMarginBottom(1),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
elapsed := ""
|
||||
if metadata["time"] != nil {
|
||||
timeMap := metadata["time"].(map[string]any)
|
||||
start := timeMap["start"].(float64)
|
||||
end := timeMap["end"].(float64)
|
||||
durationMs := end - start
|
||||
duration := time.Duration(durationMs * float64(time.Millisecond))
|
||||
roundedDuration := time.Duration(duration.Round(time.Millisecond))
|
||||
if durationMs > 1000 {
|
||||
roundedDuration = time.Duration(duration.Round(time.Second))
|
||||
}
|
||||
elapsed = styles.Muted().Render(roundedDuration.String())
|
||||
start := metadata.Time.Start
|
||||
end := metadata.Time.End
|
||||
durationMs := end - start
|
||||
duration := time.Duration(durationMs * float32(time.Millisecond))
|
||||
roundedDuration := time.Duration(duration.Round(time.Millisecond))
|
||||
if durationMs > 1000 {
|
||||
roundedDuration = time.Duration(duration.Round(time.Second))
|
||||
}
|
||||
elapsed = styles.Muted().Render(roundedDuration.String())
|
||||
|
||||
title := ""
|
||||
switch toolCall.ToolName {
|
||||
@@ -292,26 +337,47 @@ func renderToolInvocation(
|
||||
toolArgs = renderArgs(&toolArgsMap, "filePath")
|
||||
title = fmt.Sprintf("Read: %s %s", toolArgs, elapsed)
|
||||
body = ""
|
||||
if metadata["preview"] != nil && toolArgsMap["filePath"] != nil {
|
||||
if preview, ok := metadata.Get("preview"); ok && toolArgsMap["filePath"] != nil {
|
||||
filename := toolArgsMap["filePath"].(string)
|
||||
body = metadata["preview"].(string)
|
||||
body = preview.(string)
|
||||
body = renderFile(filename, body, WithTruncate(6))
|
||||
}
|
||||
case "opencode_edit":
|
||||
filename := toolArgsMap["filePath"].(string)
|
||||
title = fmt.Sprintf("Edit: %s %s", relative(filename), elapsed)
|
||||
if metadata["diff"] != nil {
|
||||
patch := metadata["diff"].(string)
|
||||
diffWidth := min(layout.Current.Viewport.Width, 120)
|
||||
formattedDiff, _ := diff.FormatDiff(filename, patch, diff.WithTotalWidth(diffWidth))
|
||||
if d, ok := metadata.Get("diff"); ok {
|
||||
patch := d.(string)
|
||||
var formattedDiff string
|
||||
if layout.Current.Viewport.Width < 80 {
|
||||
formattedDiff, _ = diff.FormatUnifiedDiff(
|
||||
filename,
|
||||
patch,
|
||||
diff.WithWidth(layout.Current.Container.Width-2),
|
||||
)
|
||||
} else {
|
||||
diffWidth := min(layout.Current.Viewport.Width-2, 120)
|
||||
formattedDiff, _ = diff.FormatDiff(filename, patch, diff.WithTotalWidth(diffWidth))
|
||||
}
|
||||
formattedDiff = strings.TrimSpace(formattedDiff)
|
||||
formattedDiff = lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
BorderForeground(t.BackgroundSubtle()).
|
||||
BorderLeft(true).
|
||||
BorderRight(true).
|
||||
Render(formattedDiff)
|
||||
|
||||
if showResult {
|
||||
style = style.Width(lipgloss.Width(formattedDiff))
|
||||
title += "\n"
|
||||
}
|
||||
|
||||
body = strings.TrimSpace(formattedDiff)
|
||||
body = lipgloss.Place(
|
||||
layout.Current.Viewport.Width,
|
||||
lipgloss.Height(body)+2,
|
||||
lipgloss.Center,
|
||||
lipgloss.Height(body)+1,
|
||||
lipgloss.Center,
|
||||
lipgloss.Top,
|
||||
body,
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||
)
|
||||
}
|
||||
case "opencode_write":
|
||||
@@ -322,12 +388,12 @@ func renderToolInvocation(
|
||||
case "opencode_bash":
|
||||
description := toolArgsMap["description"].(string)
|
||||
title = fmt.Sprintf("Shell: %s %s", description, elapsed)
|
||||
if metadata["stdout"] != nil {
|
||||
if stdout, ok := metadata.Get("stdout"); ok {
|
||||
command := toolArgsMap["command"].(string)
|
||||
stdout := metadata["stdout"].(string)
|
||||
stdout := stdout.(string)
|
||||
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
|
||||
body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
|
||||
body = renderContentBlock(body, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
|
||||
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
|
||||
}
|
||||
case "opencode_webfetch":
|
||||
title = fmt.Sprintf("Fetching: %s %s", toolArgs, elapsed)
|
||||
@@ -336,12 +402,13 @@ func renderToolInvocation(
|
||||
if format == "html" || format == "markdown" {
|
||||
body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
|
||||
}
|
||||
body = renderContentBlock(body, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
|
||||
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
|
||||
case "opencode_todowrite":
|
||||
title = fmt.Sprintf("Planning... %s", elapsed)
|
||||
if finished && metadata["todos"] != nil {
|
||||
title = fmt.Sprintf("Planning %s", elapsed)
|
||||
|
||||
if to, ok := metadata.Get("todos"); ok && finished {
|
||||
body = ""
|
||||
todos := metadata["todos"].([]any)
|
||||
todos := to.([]any)
|
||||
for _, todo := range todos {
|
||||
t := todo.(map[string]any)
|
||||
content := t["content"].(string)
|
||||
@@ -355,13 +422,13 @@ func renderToolInvocation(
|
||||
}
|
||||
}
|
||||
body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
|
||||
body = renderContentBlock(body, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
|
||||
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
|
||||
}
|
||||
default:
|
||||
toolName := renderToolName(toolCall.ToolName)
|
||||
title = fmt.Sprintf("%s: %s %s", toolName, toolArgs, elapsed)
|
||||
body = truncateHeight(body, 10)
|
||||
body = renderContentBlock(body, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
|
||||
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
|
||||
}
|
||||
|
||||
content := style.Render(title)
|
||||
@@ -435,7 +502,7 @@ func renderFile(filename string, content string, options ...fileRenderingOption)
|
||||
content = fmt.Sprintf("```%s\n%s\n```", extension(renderer.filename), content)
|
||||
content = toMarkdown(content, width, t.BackgroundSubtle())
|
||||
|
||||
return renderContentBlock(content, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
|
||||
return renderContentBlock(content, WithFullWidth(), WithMarginBottom(1))
|
||||
}
|
||||
|
||||
func renderToolAction(name string) string {
|
||||
|
||||
@@ -118,7 +118,6 @@ type blockType int
|
||||
|
||||
const (
|
||||
none blockType = iota
|
||||
systemTextBlock
|
||||
userTextBlock
|
||||
assistantTextBlock
|
||||
toolInvocationBlock
|
||||
@@ -134,10 +133,6 @@ func (m *messagesComponent) renderView() {
|
||||
blocks := make([]string, 0)
|
||||
previousBlockType := none
|
||||
for _, message := range m.app.Messages {
|
||||
if message.Role == client.System {
|
||||
continue // ignoring system messages for now
|
||||
}
|
||||
|
||||
var content string
|
||||
var cached bool
|
||||
|
||||
@@ -174,15 +169,13 @@ func (m *messagesComponent) renderView() {
|
||||
previousBlockType = userTextBlock
|
||||
} else if message.Role == client.Assistant {
|
||||
previousBlockType = assistantTextBlock
|
||||
} else if message.Role == client.System {
|
||||
previousBlockType = systemTextBlock
|
||||
}
|
||||
case client.MessagePartToolInvocation:
|
||||
toolInvocationPart := part.(client.MessagePartToolInvocation)
|
||||
toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
|
||||
metadata := map[string]any{}
|
||||
metadata := client.MessageInfo_Metadata_Tool_AdditionalProperties{}
|
||||
if _, ok := message.Metadata.Tool[toolCall.ToolCallId]; ok {
|
||||
metadata = message.Metadata.Tool[toolCall.ToolCallId].(map[string]any)
|
||||
metadata = message.Metadata.Tool[toolCall.ToolCallId]
|
||||
}
|
||||
var result *string
|
||||
resultPart, resultError := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolResult()
|
||||
@@ -215,14 +208,16 @@ func (m *messagesComponent) renderView() {
|
||||
}
|
||||
|
||||
error := ""
|
||||
errorValue, _ := message.Metadata.Error.ValueByDiscriminator()
|
||||
switch errorValue.(type) {
|
||||
case client.UnknownError:
|
||||
clientError := errorValue.(client.UnknownError)
|
||||
error = clientError.Data.Message
|
||||
error = renderContentBlock(error, WithBorderColor(t.Error()), WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
|
||||
blocks = append(blocks, error)
|
||||
previousBlockType = errorBlock
|
||||
if message.Metadata.Error != nil {
|
||||
errorValue, _ := message.Metadata.Error.ValueByDiscriminator()
|
||||
switch errorValue.(type) {
|
||||
case client.UnknownError:
|
||||
clientError := errorValue.(client.UnknownError)
|
||||
error = clientError.Data.Message
|
||||
error = renderContentBlock(error, WithBorderColor(t.Error()), WithFullWidth(), WithMarginTop(1), WithMarginBottom(1))
|
||||
blocks = append(blocks, error)
|
||||
previousBlockType = errorBlock
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +245,7 @@ func (m *messagesComponent) header() string {
|
||||
base := styles.BaseStyle().Render
|
||||
muted := styles.Muted().Render
|
||||
headerLines := []string{}
|
||||
headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width, t.Background()))
|
||||
headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width-4, t.Background()))
|
||||
if m.app.Session.Share != nil && m.app.Session.Share.Url != "" {
|
||||
headerLines = append(headerLines, muted(m.app.Session.Share.Url))
|
||||
} else {
|
||||
@@ -260,20 +255,25 @@ func (m *messagesComponent) header() string {
|
||||
|
||||
header = styles.BaseStyle().
|
||||
Width(width).
|
||||
PaddingTop(1).
|
||||
BorderBottom(true).
|
||||
BorderForeground(t.BorderSubtle()).
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
Background(t.Background()).
|
||||
PaddingLeft(2).
|
||||
// Background(t.BackgroundElement()).
|
||||
BorderLeft(true).
|
||||
BorderRight(true).
|
||||
BorderBackground(t.Background()).
|
||||
BorderForeground(t.BackgroundSubtle()).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
Render(header)
|
||||
|
||||
return header
|
||||
return "\n" + header + "\n"
|
||||
}
|
||||
|
||||
func (m *messagesComponent) View() string {
|
||||
if len(m.app.Messages) == 0 || m.rendering {
|
||||
if len(m.app.Messages) == 0 {
|
||||
return m.home()
|
||||
}
|
||||
if m.rendering {
|
||||
return m.viewport.View()
|
||||
}
|
||||
return lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
lipgloss.PlaceHorizontal(m.width, lipgloss.Center, m.header()),
|
||||
@@ -306,8 +306,8 @@ func (m *messagesComponent) home() string {
|
||||
styles.Muted().Render(open),
|
||||
styles.BaseStyle().Render(code),
|
||||
)
|
||||
cwd := app.Info.Path.Cwd
|
||||
config := app.Info.Path.Config
|
||||
// cwd := app.Info.Path.Cwd
|
||||
// config := app.Info.Path.Config
|
||||
|
||||
commands := [][]string{
|
||||
{"/help", "show help"},
|
||||
@@ -334,9 +334,9 @@ func (m *messagesComponent) home() string {
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, logoAndVersion)
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, base("cwd ")+muted(cwd))
|
||||
lines = append(lines, base("config ")+muted(config))
|
||||
lines = append(lines, "")
|
||||
// lines = append(lines, base("cwd ")+muted(cwd))
|
||||
// lines = append(lines, base("config ")+muted(config))
|
||||
// lines = append(lines, "")
|
||||
lines = append(lines, commandLines...)
|
||||
lines = append(lines, "")
|
||||
if m.rendering {
|
||||
@@ -384,15 +384,6 @@ func (m *messagesComponent) Reload() tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *messagesComponent) BindingKeys() []key.Binding {
|
||||
return []key.Binding{
|
||||
m.viewport.KeyMap.PageDown,
|
||||
m.viewport.KeyMap.PageUp,
|
||||
m.viewport.KeyMap.HalfPageUp,
|
||||
m.viewport.KeyMap.HalfPageDown,
|
||||
}
|
||||
}
|
||||
|
||||
func NewMessagesComponent(app *app.App) layout.ModelWithView {
|
||||
customSpinner := spinner.Spinner{
|
||||
Frames: []string{" ", "┃", "┃"},
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
"github.com/charmbracelet/bubbles/v2/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/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.SetWidth(40)
|
||||
ti.Prompt = ""
|
||||
ti.Styles.Blurred.Placeholder = ti.Styles.Blurred.Placeholder.Background(t.Background())
|
||||
ti.Styles.Blurred.Text = ti.Styles.Blurred.Text.Background(t.Background())
|
||||
ti.Styles.Blurred.Prompt = ti.Styles.Blurred.Prompt.Foreground(t.Primary())
|
||||
|
||||
ti.Styles.Focused.Placeholder = ti.Styles.Focused.Placeholder.Background(t.Background())
|
||||
ti.Styles.Focused.Text = ti.Styles.Focused.Text.Background(t.Background())
|
||||
ti.Styles.Focused.Prompt = ti.Styles.Focused.Prompt.Foreground(t.Primary())
|
||||
|
||||
// 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()
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
utilComponents "github.com/sst/opencode/internal/components/util"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/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 {
|
||||
layout.ModelWithView
|
||||
layout.Bindings
|
||||
SetCommands(commands []Command)
|
||||
}
|
||||
|
||||
type commandDialogComponent 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 *commandDialogComponent) Init() tea.Cmd {
|
||||
return c.listView.Init()
|
||||
}
|
||||
|
||||
func (c *commandDialogComponent) 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 *commandDialogComponent) 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 *commandDialogComponent) BindingKeys() []key.Binding {
|
||||
return layout.KeyMapToSlice(commandKeys)
|
||||
}
|
||||
|
||||
func (c *commandDialogComponent) 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 &commandDialogComponent{
|
||||
listView: listView,
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ type CompletionItem struct {
|
||||
}
|
||||
|
||||
type CompletionItemI interface {
|
||||
utilComponents.SimpleListItem
|
||||
utilComponents.ListItem
|
||||
GetValue() string
|
||||
DisplayValue() string
|
||||
}
|
||||
@@ -78,8 +78,8 @@ type CompletionDialogCloseMsg struct{}
|
||||
|
||||
type CompletionDialog interface {
|
||||
layout.ModelWithView
|
||||
layout.Bindings
|
||||
SetWidth(width int)
|
||||
IsEmpty() bool
|
||||
}
|
||||
|
||||
type completionDialogComponent struct {
|
||||
@@ -88,7 +88,7 @@ type completionDialogComponent struct {
|
||||
width int
|
||||
height int
|
||||
pseudoSearchTextArea textarea.Model
|
||||
listView utilComponents.SimpleList[CompletionItemI]
|
||||
list utilComponents.List[CompletionItemI]
|
||||
}
|
||||
|
||||
type completionDialogKeyMap struct {
|
||||
@@ -126,7 +126,7 @@ func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) close() tea.Cmd {
|
||||
c.listView.SetItems([]CompletionItemI{})
|
||||
c.list.SetItems([]CompletionItemI{})
|
||||
c.pseudoSearchTextArea.Reset()
|
||||
c.pseudoSearchTextArea.Blur()
|
||||
|
||||
@@ -138,9 +138,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, 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)
|
||||
@@ -157,25 +155,23 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
status.Error(err.Error())
|
||||
}
|
||||
|
||||
c.listView.SetItems(items)
|
||||
c.list.SetItems(items)
|
||||
c.query = query
|
||||
}
|
||||
|
||||
u, cmd := c.listView.Update(msg)
|
||||
c.listView = u.(utilComponents.SimpleList[CompletionItemI])
|
||||
u, cmd := c.list.Update(msg)
|
||||
c.list = u.(utilComponents.List[CompletionItemI])
|
||||
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
switch {
|
||||
case key.Matches(msg, completionDialogKeys.Complete):
|
||||
item, i := c.listView.GetSelectedItem()
|
||||
item, i := c.list.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
|
||||
@@ -191,7 +187,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
status.Error(err.Error())
|
||||
}
|
||||
|
||||
c.listView.SetItems(items)
|
||||
c.list.SetItems(items)
|
||||
c.pseudoSearchTextArea.SetValue(msg.String())
|
||||
return c, c.pseudoSearchTextArea.Focus()
|
||||
}
|
||||
@@ -207,36 +203,37 @@ func (c *completionDialogComponent) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.BaseStyle()
|
||||
|
||||
maxWidth := 40
|
||||
// maxWidth := 40
|
||||
//
|
||||
// completions := c.list.GetItems()
|
||||
|
||||
completions := c.listView.GetItems()
|
||||
// for _, cmd := range completions {
|
||||
// title := cmd.DisplayValue()
|
||||
// if len(title) > maxWidth-4 {
|
||||
// maxWidth = len(title) + 4
|
||||
// }
|
||||
// }
|
||||
|
||||
for _, cmd := range completions {
|
||||
title := cmd.DisplayValue()
|
||||
if len(title) > maxWidth-4 {
|
||||
maxWidth = len(title) + 4
|
||||
}
|
||||
}
|
||||
|
||||
c.listView.SetMaxWidth(maxWidth)
|
||||
// c.list.SetMaxWidth(maxWidth)
|
||||
|
||||
return baseStyle.Padding(0, 0).
|
||||
Border(lipgloss.NormalBorder()).
|
||||
Background(t.BackgroundSubtle()).
|
||||
Border(lipgloss.ThickBorder()).
|
||||
BorderTop(false).
|
||||
BorderBottom(false).
|
||||
BorderRight(false).
|
||||
BorderLeft(false).
|
||||
BorderBackground(t.Background()).
|
||||
BorderForeground(t.TextMuted()).
|
||||
BorderRight(true).
|
||||
BorderLeft(true).
|
||||
BorderForeground(t.BackgroundSubtle()).
|
||||
Width(c.width).
|
||||
Render(c.listView.View())
|
||||
Render(c.list.View())
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) SetWidth(width int) {
|
||||
c.width = width
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) BindingKeys() []key.Binding {
|
||||
return layout.KeyMapToSlice(completionDialogKeys)
|
||||
func (c *completionDialogComponent) IsEmpty() bool {
|
||||
return c.list.IsEmpty()
|
||||
}
|
||||
|
||||
func NewCompletionDialogComponent(completionProvider CompletionProvider) CompletionDialog {
|
||||
@@ -247,10 +244,10 @@ func NewCompletionDialogComponent(completionProvider CompletionProvider) Complet
|
||||
status.Error(err.Error())
|
||||
}
|
||||
|
||||
li := utilComponents.NewSimpleList(
|
||||
li := utilComponents.NewListComponent(
|
||||
items,
|
||||
7,
|
||||
"No file matches found",
|
||||
"No matching files",
|
||||
false,
|
||||
)
|
||||
|
||||
@@ -258,6 +255,6 @@ func NewCompletionDialogComponent(completionProvider CompletionProvider) Complet
|
||||
query: "",
|
||||
completionProvider: completionProvider,
|
||||
pseudoSearchTextArea: ti,
|
||||
listView: li,
|
||||
list: li,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/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) {
|
||||
var commands []Command
|
||||
|
||||
homeCommandsDir := filepath.Join(app.Info.Path.Config, "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...)
|
||||
}
|
||||
|
||||
projectCommandsDir := filepath.Join(app.Info.Path.Root, ".opencode", "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
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,486 +0,0 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"log/slog"
|
||||
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
"github.com/charmbracelet/bubbles/v2/textinput"
|
||||
"github.com/charmbracelet/bubbles/v2/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/image"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/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 filepickerComponent 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 *filepickerComponent) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *filepickerComponent) 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.SetWidth(80)
|
||||
f.viewport.SetHeight(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 *filepickerComponent) 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 *filepickerComponent) 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 FilepickerComponent interface {
|
||||
layout.ModelWithView
|
||||
ToggleFilepicker(showFilepicker bool)
|
||||
IsCWDFocused() bool
|
||||
}
|
||||
|
||||
func (f *filepickerComponent) ToggleFilepicker(showFilepicker bool) {
|
||||
f.ShowFilePicker = showFilepicker
|
||||
}
|
||||
|
||||
func (f *filepickerComponent) IsCWDFocused() bool {
|
||||
return f.cwd.Focused()
|
||||
}
|
||||
|
||||
func NewFilepickerCmp(app *app.App) FilepickerComponent {
|
||||
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() // viewport.New(0, 0)
|
||||
currentDirectory := textinput.New()
|
||||
currentDirectory.CharLimit = 200
|
||||
currentDirectory.SetWidth(44)
|
||||
// currentDirectory.Cursor.Blink = true
|
||||
currentDirectory.SetValue(baseDir.directory)
|
||||
return &filepickerComponent{cwdDetails: &baseDir, dirs: dirs, cursorChain: make(stack, 0), viewport: viewport, cwd: currentDirectory, app: app}
|
||||
}
|
||||
|
||||
func (f *filepickerComponent) 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")
|
||||
}
|
||||
@@ -50,26 +50,6 @@ func (h *helpDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
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 *helpDialog) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
keyStyle := lipgloss.NewStyle().
|
||||
|
||||
@@ -173,11 +173,6 @@ func (m *InitDialogCmp) SetSize(width, height int) {
|
||||
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
|
||||
|
||||
@@ -112,8 +112,14 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
case key.Matches(msg, modelKeys.Enter):
|
||||
models := m.models()
|
||||
cmd := util.CmdHandler(state.ModelSelectedMsg{Provider: m.provider, Model: models[m.selectedIdx]})
|
||||
return m, tea.Batch(cmd, util.CmdHandler(modal.CloseModalMsg{}))
|
||||
return m, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(
|
||||
state.ModelSelectedMsg{
|
||||
Provider: m.provider,
|
||||
Model: models[m.selectedIdx],
|
||||
}),
|
||||
)
|
||||
case key.Matches(msg, modelKeys.Escape):
|
||||
return m, util.CmdHandler(modal.CloseModalMsg{})
|
||||
}
|
||||
@@ -125,9 +131,9 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *modelDialog) models() []client.ProviderModel {
|
||||
models := slices.SortedFunc(maps.Values(m.provider.Models), func(a, b client.ProviderModel) int {
|
||||
return strings.Compare(*a.Name, *b.Name)
|
||||
func (m *modelDialog) models() []client.ModelInfo {
|
||||
models := slices.SortedFunc(maps.Values(m.provider.Models), func(a, b client.ModelInfo) int {
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
})
|
||||
return models
|
||||
}
|
||||
@@ -205,7 +211,7 @@ func (m *modelDialog) View() string {
|
||||
Foreground(t.BackgroundElement()).
|
||||
Bold(true)
|
||||
}
|
||||
modelItems = append(modelItems, itemStyle.Render(*models[i].Name))
|
||||
modelItems = append(modelItems, itemStyle.Render(models[i].Name))
|
||||
}
|
||||
|
||||
scrollIndicator := m.getScrollIndicators(maxDialogWidth)
|
||||
|
||||
@@ -31,7 +31,6 @@ type PermissionResponseMsg struct {
|
||||
// PermissionDialogComponent interface for permission dialog component
|
||||
type PermissionDialogComponent interface {
|
||||
layout.ModelWithView
|
||||
layout.Bindings
|
||||
// SetPermissions(permission permission.PermissionRequest) tea.Cmd
|
||||
}
|
||||
|
||||
@@ -424,10 +423,6 @@ func (p *permissionDialogComponent) View() string {
|
||||
return p.render()
|
||||
}
|
||||
|
||||
func (p *permissionDialogComponent) BindingKeys() []key.Binding {
|
||||
return layout.KeyMapToSlice(permissionsKeys)
|
||||
}
|
||||
|
||||
func (p *permissionDialogComponent) SetSize() tea.Cmd {
|
||||
// if p.permission.ID == "" {
|
||||
// return nil
|
||||
|
||||
@@ -73,15 +73,11 @@ func (q *quitDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if !q.selectedNo {
|
||||
return q, tea.Quit
|
||||
}
|
||||
return q, tea.Batch(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
)
|
||||
return q, util.CmdHandler(modal.CloseModalMsg{})
|
||||
case key.Matches(msg, helpKeys.Yes):
|
||||
return q, tea.Quit
|
||||
case key.Matches(msg, helpKeys.No):
|
||||
return q, tea.Batch(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
)
|
||||
return q, util.CmdHandler(modal.CloseModalMsg{})
|
||||
}
|
||||
}
|
||||
return q, nil
|
||||
|
||||
@@ -48,7 +48,7 @@ type sessionDialog struct {
|
||||
height int
|
||||
modal *modal.Modal
|
||||
selectedSessionID string
|
||||
list components.SimpleList[sessionItem]
|
||||
list components.List[sessionItem]
|
||||
}
|
||||
|
||||
func (s *sessionDialog) Init() tea.Cmd {
|
||||
@@ -67,9 +67,9 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if item, idx := s.list.GetSelectedItem(); idx >= 0 {
|
||||
selectedSession := item.session
|
||||
s.selectedSessionID = selectedSession.Id
|
||||
return s, tea.Batch(
|
||||
util.CmdHandler(state.SessionSelectedMsg(&selectedSession)),
|
||||
return s, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(state.SessionSelectedMsg(&selectedSession)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
var cmd tea.Cmd
|
||||
listModel, cmd := s.list.Update(msg)
|
||||
s.list = listModel.(components.SimpleList[sessionItem])
|
||||
s.list = listModel.(components.List[sessionItem])
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ func NewSessionDialog(app *app.App) SessionDialog {
|
||||
sessionItems = append(sessionItems, sessionItem{session: sess})
|
||||
}
|
||||
|
||||
list := components.NewSimpleList(
|
||||
list := components.NewListComponent(
|
||||
sessionItems,
|
||||
10, // maxVisibleSessions
|
||||
"No sessions available",
|
||||
|
||||
@@ -49,7 +49,7 @@ type themeDialog struct {
|
||||
height int
|
||||
|
||||
modal *modal.Modal
|
||||
list components.SimpleList[themeItem]
|
||||
list components.List[themeItem]
|
||||
}
|
||||
|
||||
func (t *themeDialog) Init() tea.Cmd {
|
||||
@@ -74,9 +74,9 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
status.Error(err.Error())
|
||||
return t, nil
|
||||
}
|
||||
return t, tea.Batch(
|
||||
util.CmdHandler(ThemeChangedMsg{ThemeName: selectedTheme}),
|
||||
return t, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(ThemeChangedMsg{ThemeName: selectedTheme}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
var cmd tea.Cmd
|
||||
listModel, cmd := t.list.Update(msg)
|
||||
t.list = listModel.(components.SimpleList[themeItem])
|
||||
t.list = listModel.(components.List[themeItem])
|
||||
return t, cmd
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ func NewThemeDialog() ThemeDialog {
|
||||
}
|
||||
}
|
||||
|
||||
list := components.NewSimpleList(
|
||||
list := components.NewListComponent(
|
||||
themeItems,
|
||||
10, // maxVisibleThemes
|
||||
"No themes available",
|
||||
@@ -125,4 +125,3 @@ func NewThemeDialog() ThemeDialog {
|
||||
modal: modal.New(modal.WithTitle("Select Theme"), modal.WithMaxWidth(40)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
utilComponents "github.com/sst/opencode/internal/components/util"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
const (
|
||||
maxToolsDialogWidth = 60
|
||||
maxVisibleTools = 15
|
||||
)
|
||||
|
||||
// ToolsDialog interface for the tools list dialog
|
||||
type ToolsDialog interface {
|
||||
layout.ModelWithView
|
||||
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 toolsDialogComponent 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 *toolsDialogComponent) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *toolsDialogComponent) 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 *toolsDialogComponent) 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 *toolsDialogComponent) 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 *toolsDialogComponent) BindingKeys() []key.Binding {
|
||||
return layout.KeyMapToSlice(toolsKeys)
|
||||
}
|
||||
|
||||
func NewToolsDialogCmp() ToolsDialog {
|
||||
list := utilComponents.NewSimpleList[toolItem](
|
||||
[]toolItem{},
|
||||
maxVisibleTools,
|
||||
"No tools available",
|
||||
true,
|
||||
)
|
||||
|
||||
return &toolsDialogComponent{
|
||||
list: list,
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,40 @@ func WithTotalWidth(width int) SideBySideOption {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Unified Configuration
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// UnifiedConfig configures the rendering of unified diffs
|
||||
type UnifiedConfig struct {
|
||||
Width int
|
||||
}
|
||||
|
||||
// UnifiedOption modifies a UnifiedConfig
|
||||
type UnifiedOption func(*UnifiedConfig)
|
||||
|
||||
// NewUnifiedConfig creates a UnifiedConfig with default values
|
||||
func NewUnifiedConfig(opts ...UnifiedOption) UnifiedConfig {
|
||||
config := UnifiedConfig{
|
||||
Width: 80, // Default width for unified view
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(&config)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// WithWidth sets the width for unified view
|
||||
func WithWidth(width int) UnifiedOption {
|
||||
return func(u *UnifiedConfig) {
|
||||
if width > 0 {
|
||||
u.Width = width
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Diff Parsing
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -642,6 +676,101 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// renderLinePrefix renders the line number and marker prefix for a diff line
|
||||
func renderLinePrefix(dl DiffLine, lineNum string, marker string, lineNumberStyle lipgloss.Style, t theme.Theme) string {
|
||||
// Style the marker based on line type
|
||||
var styledMarker string
|
||||
switch dl.Kind {
|
||||
case LineRemoved:
|
||||
styledMarker = lipgloss.NewStyle().Background(t.DiffRemovedBg()).Foreground(t.DiffRemoved()).Render(marker)
|
||||
case LineAdded:
|
||||
styledMarker = lipgloss.NewStyle().Background(t.DiffAddedBg()).Foreground(t.DiffAdded()).Render(marker)
|
||||
case LineContext:
|
||||
styledMarker = lipgloss.NewStyle().Background(t.DiffContextBg()).Foreground(t.TextMuted()).Render(marker)
|
||||
default:
|
||||
styledMarker = marker
|
||||
}
|
||||
|
||||
return lineNumberStyle.Render(lineNum + " " + styledMarker)
|
||||
}
|
||||
|
||||
// renderLineContent renders the content of a diff line with syntax and intra-line highlighting
|
||||
func renderLineContent(fileName string, dl DiffLine, bgStyle lipgloss.Style, highlightColor compat.AdaptiveColor, width int, t theme.Theme) string {
|
||||
// Apply syntax highlighting
|
||||
content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
|
||||
|
||||
// Apply intra-line highlighting if needed
|
||||
if len(dl.Segments) > 0 && (dl.Kind == LineRemoved || dl.Kind == LineAdded) {
|
||||
content = applyHighlighting(content, dl.Segments, dl.Kind, highlightColor)
|
||||
}
|
||||
|
||||
// Add a padding space for added/removed lines
|
||||
if dl.Kind == LineRemoved || dl.Kind == LineAdded {
|
||||
content = bgStyle.Render(" ") + content
|
||||
}
|
||||
|
||||
// Create the final line and truncate if needed
|
||||
return bgStyle.MaxHeight(1).Width(width).Render(
|
||||
ansi.Truncate(
|
||||
content,
|
||||
width,
|
||||
lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// renderUnifiedLine renders a single line in unified diff format
|
||||
func renderUnifiedLine(fileName string, dl DiffLine, width int, t theme.Theme) string {
|
||||
removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t)
|
||||
|
||||
// Determine line style and marker based on line type
|
||||
var marker string
|
||||
var bgStyle lipgloss.Style
|
||||
var lineNum string
|
||||
var highlightColor compat.AdaptiveColor
|
||||
|
||||
switch dl.Kind {
|
||||
case LineRemoved:
|
||||
marker = "-"
|
||||
bgStyle = removedLineStyle
|
||||
lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
|
||||
highlightColor = t.DiffHighlightRemoved()
|
||||
if dl.OldLineNo > 0 {
|
||||
lineNum = fmt.Sprintf("%6d ", dl.OldLineNo)
|
||||
} else {
|
||||
lineNum = " "
|
||||
}
|
||||
case LineAdded:
|
||||
marker = "+"
|
||||
bgStyle = addedLineStyle
|
||||
lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
|
||||
highlightColor = t.DiffHighlightAdded()
|
||||
if dl.NewLineNo > 0 {
|
||||
lineNum = fmt.Sprintf(" %7d", dl.NewLineNo)
|
||||
} else {
|
||||
lineNum = " "
|
||||
}
|
||||
case LineContext:
|
||||
marker = " "
|
||||
bgStyle = contextLineStyle
|
||||
if dl.OldLineNo > 0 && dl.NewLineNo > 0 {
|
||||
lineNum = fmt.Sprintf("%6d %6d", dl.OldLineNo, dl.NewLineNo)
|
||||
} else {
|
||||
lineNum = " "
|
||||
}
|
||||
}
|
||||
|
||||
// Create the line prefix
|
||||
prefix := renderLinePrefix(dl, lineNum, marker, lineNumberStyle, t)
|
||||
|
||||
// Render the content
|
||||
prefixWidth := ansi.StringWidth(prefix)
|
||||
contentWidth := width - prefixWidth
|
||||
content := renderLineContent(fileName, dl, bgStyle, highlightColor, contentWidth, t)
|
||||
|
||||
return prefix + content
|
||||
}
|
||||
|
||||
// renderDiffColumnLine is a helper function that handles the common logic for rendering diff columns
|
||||
func renderDiffColumnLine(
|
||||
fileName string,
|
||||
@@ -661,7 +790,6 @@ func renderDiffColumnLine(
|
||||
var marker string
|
||||
var bgStyle lipgloss.Style
|
||||
var lineNum string
|
||||
var highlightType LineType
|
||||
var highlightColor compat.AdaptiveColor
|
||||
|
||||
if isLeftColumn {
|
||||
@@ -671,7 +799,6 @@ func renderDiffColumnLine(
|
||||
marker = "-"
|
||||
bgStyle = removedLineStyle
|
||||
lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
|
||||
highlightType = LineRemoved
|
||||
highlightColor = t.DiffHighlightRemoved()
|
||||
case LineAdded:
|
||||
marker = "?"
|
||||
@@ -692,7 +819,6 @@ func renderDiffColumnLine(
|
||||
marker = "+"
|
||||
bgStyle = addedLineStyle
|
||||
lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
|
||||
highlightType = LineAdded
|
||||
highlightColor = t.DiffHighlightAdded()
|
||||
case LineRemoved:
|
||||
marker = "?"
|
||||
@@ -708,44 +834,24 @@ func renderDiffColumnLine(
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
prefix := renderLinePrefix(*dl, lineNum, marker, lineNumberStyle, t)
|
||||
|
||||
// Apply syntax highlighting
|
||||
content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
|
||||
// Determine if we should render content
|
||||
shouldRenderContent := (dl.Kind == LineRemoved && isLeftColumn) ||
|
||||
(dl.Kind == LineAdded && !isLeftColumn) ||
|
||||
dl.Kind == LineContext
|
||||
|
||||
// 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)
|
||||
if !shouldRenderContent {
|
||||
return bgStyle.Width(colWidth).Render("")
|
||||
}
|
||||
|
||||
// Add a padding space for added/removed lines
|
||||
if (dl.Kind == LineRemoved && isLeftColumn) || (dl.Kind == LineAdded && !isLeftColumn) {
|
||||
content = bgStyle.Render(" ") + content
|
||||
}
|
||||
// Render the content
|
||||
prefixWidth := ansi.StringWidth(prefix)
|
||||
contentWidth := colWidth - prefixWidth
|
||||
content := renderLineContent(fileName, *dl, bgStyle, highlightColor, contentWidth, t)
|
||||
|
||||
// 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("..."),
|
||||
),
|
||||
)
|
||||
return prefix + content
|
||||
}
|
||||
|
||||
// renderLeftColumn formats the left side of a side-by-side diff
|
||||
@@ -762,6 +868,27 @@ func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// RenderUnifiedHunk formats a hunk for unified display
|
||||
func RenderUnifiedHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
|
||||
// Apply options to create the configuration
|
||||
config := NewUnifiedConfig(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)
|
||||
|
||||
var sb strings.Builder
|
||||
for _, line := range hunkCopy.Lines {
|
||||
sb.WriteString(renderUnifiedLine(fileName, line, config.Width, theme.CurrentTheme()))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// RenderSideBySideHunk formats a hunk for side-by-side display
|
||||
func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
|
||||
// Apply options to create the configuration
|
||||
@@ -792,6 +919,21 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// FormatUnifiedDiff creates a unified formatted view of a diff
|
||||
func FormatUnifiedDiff(filename string, diffText string, opts ...UnifiedOption) (string, error) {
|
||||
diffResult, err := ParseUnifiedDiff(diffText)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for _, h := range diffResult.Hunks {
|
||||
sb.WriteString(RenderUnifiedHunk(filename, h, opts...))
|
||||
}
|
||||
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// FormatDiff creates a side-by-side formatted view of a diff
|
||||
func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (string, error) {
|
||||
// t := theme.CurrentTheme()
|
||||
|
||||
@@ -6,24 +6,23 @@ import (
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
type SimpleListItem interface {
|
||||
type ListItem interface {
|
||||
Render(selected bool, width int) string
|
||||
}
|
||||
|
||||
type SimpleList[T SimpleListItem] interface {
|
||||
type List[T ListItem] interface {
|
||||
layout.ModelWithView
|
||||
layout.Bindings
|
||||
SetMaxWidth(maxWidth int)
|
||||
GetSelectedItem() (item T, idx int)
|
||||
SetItems(items []T)
|
||||
GetItems() []T
|
||||
SetSelectedIndex(idx int)
|
||||
IsEmpty() bool
|
||||
}
|
||||
|
||||
type simpleListComponent[T SimpleListItem] struct {
|
||||
type listComponent[T ListItem] struct {
|
||||
fallbackMsg string
|
||||
items []T
|
||||
selectedIdx int
|
||||
@@ -34,14 +33,14 @@ type simpleListComponent[T SimpleListItem] struct {
|
||||
height int
|
||||
}
|
||||
|
||||
type simpleListKeyMap struct {
|
||||
type listKeyMap struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
UpAlpha key.Binding
|
||||
DownAlpha key.Binding
|
||||
}
|
||||
|
||||
var simpleListKeys = simpleListKeyMap{
|
||||
var simpleListKeys = listKeyMap{
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up"),
|
||||
key.WithHelp("↑", "previous list item"),
|
||||
@@ -60,11 +59,11 @@ var simpleListKeys = simpleListKeyMap{
|
||||
),
|
||||
}
|
||||
|
||||
func (c *simpleListComponent[T]) Init() tea.Cmd {
|
||||
func (c *listComponent[T]) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *simpleListComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
func (c *listComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
@@ -84,11 +83,7 @@ func (c *simpleListComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *simpleListComponent[T]) BindingKeys() []key.Binding {
|
||||
return layout.KeyMapToSlice(simpleListKeys)
|
||||
}
|
||||
|
||||
func (c *simpleListComponent[T]) GetSelectedItem() (T, int) {
|
||||
func (c *listComponent[T]) GetSelectedItem() (T, int) {
|
||||
if len(c.items) > 0 {
|
||||
return c.items[c.selectedIdx], c.selectedIdx
|
||||
}
|
||||
@@ -97,27 +92,30 @@ func (c *simpleListComponent[T]) GetSelectedItem() (T, int) {
|
||||
return zero, -1
|
||||
}
|
||||
|
||||
func (c *simpleListComponent[T]) SetItems(items []T) {
|
||||
func (c *listComponent[T]) SetItems(items []T) {
|
||||
c.selectedIdx = 0
|
||||
c.items = items
|
||||
}
|
||||
|
||||
func (c *simpleListComponent[T]) GetItems() []T {
|
||||
func (c *listComponent[T]) GetItems() []T {
|
||||
return c.items
|
||||
}
|
||||
|
||||
func (c *simpleListComponent[T]) SetMaxWidth(width int) {
|
||||
func (c *listComponent[T]) IsEmpty() bool {
|
||||
return len(c.items) == 0
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) SetMaxWidth(width int) {
|
||||
c.maxWidth = width
|
||||
}
|
||||
|
||||
func (c *simpleListComponent[T]) SetSelectedIndex(idx int) {
|
||||
func (c *listComponent[T]) SetSelectedIndex(idx int) {
|
||||
if idx >= 0 && idx < len(c.items) {
|
||||
c.selectedIdx = idx
|
||||
}
|
||||
}
|
||||
|
||||
func (c *simpleListComponent[T]) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
func (c *listComponent[T]) View() string {
|
||||
baseStyle := styles.BaseStyle()
|
||||
|
||||
items := c.items
|
||||
@@ -127,7 +125,6 @@ func (c *simpleListComponent[T]) View() string {
|
||||
|
||||
if len(items) <= 0 {
|
||||
return baseStyle.
|
||||
Background(t.Background()).
|
||||
Padding(0, 1).
|
||||
Width(maxWidth).
|
||||
Render(c.fallbackMsg)
|
||||
@@ -155,8 +152,8 @@ func (c *simpleListComponent[T]) View() string {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, listItems...)
|
||||
}
|
||||
|
||||
func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] {
|
||||
return &simpleListComponent[T]{
|
||||
func NewListComponent[T ListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[T] {
|
||||
return &listComponent[T]{
|
||||
fallbackMsg: fallbackMsg,
|
||||
items: items,
|
||||
maxVisibleItems: maxVisibleItems,
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Theme string `toml:"Theme"`
|
||||
Provider string `toml:"Provider"`
|
||||
Model string `toml:"Model"`
|
||||
Theme string `toml:"theme"`
|
||||
Provider string `toml:"provider"`
|
||||
Model string `toml:"model"`
|
||||
}
|
||||
|
||||
// NewConfig creates a new Config instance with default values.
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
@@ -15,16 +14,18 @@ type ModelWithView interface {
|
||||
type Container interface {
|
||||
ModelWithView
|
||||
Sizeable
|
||||
Bindings
|
||||
Focus()
|
||||
Blur()
|
||||
MaxWidth() int
|
||||
Alignment() lipgloss.Position
|
||||
GetPosition() (x, y int)
|
||||
}
|
||||
|
||||
type container struct {
|
||||
width int
|
||||
height int
|
||||
x int
|
||||
y int
|
||||
|
||||
content ModelWithView
|
||||
|
||||
@@ -142,7 +143,7 @@ func (c *container) SetSize(width, height int) tea.Cmd {
|
||||
}
|
||||
|
||||
func (c *container) GetSize() (int, int) {
|
||||
return c.width, c.height
|
||||
return min(c.width, c.maxWidth), c.height
|
||||
}
|
||||
|
||||
func (c *container) MaxWidth() int {
|
||||
@@ -153,13 +154,6 @@ func (c *container) Alignment() lipgloss.Position {
|
||||
return c.align
|
||||
}
|
||||
|
||||
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
|
||||
@@ -178,6 +172,11 @@ func (c *container) Blur() {
|
||||
}
|
||||
}
|
||||
|
||||
// GetPosition returns the x, y coordinates of the container
|
||||
func (c *container) GetPosition() (x, y int) {
|
||||
return c.x, c.y
|
||||
}
|
||||
|
||||
type ContainerOption func(*container)
|
||||
|
||||
func NewContainer(content ModelWithView, options ...ContainerOption) Container {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
)
|
||||
@@ -27,7 +26,6 @@ func FlexPaneSizeFixed(size int) FlexPaneSize {
|
||||
type FlexLayout interface {
|
||||
ModelWithView
|
||||
Sizeable
|
||||
Bindings
|
||||
SetPanes(panes []Container) tea.Cmd
|
||||
SetPaneSizes(sizes []FlexPaneSize) tea.Cmd
|
||||
SetDirection(direction FlexDirection) tea.Cmd
|
||||
@@ -161,11 +159,51 @@ func (f *flexLayout) SetSize(width, height int) tea.Cmd {
|
||||
f.height = height
|
||||
|
||||
var cmds []tea.Cmd
|
||||
currentX, currentY := 0, 0
|
||||
|
||||
for i, pane := range f.panes {
|
||||
if pane != nil {
|
||||
paneWidth, paneHeight := f.calculatePaneSize(i)
|
||||
|
||||
// Calculate actual position based on alignment
|
||||
actualX, actualY := currentX, currentY
|
||||
|
||||
if f.direction == FlexDirectionHorizontal {
|
||||
// In horizontal layout, vertical alignment affects Y position
|
||||
// (lipgloss.Center is used for vertical alignment in JoinHorizontal)
|
||||
actualY = (f.height - paneHeight) / 2
|
||||
} else {
|
||||
// In vertical layout, horizontal alignment affects X position
|
||||
contentWidth := paneWidth
|
||||
if pane.MaxWidth() > 0 && contentWidth > pane.MaxWidth() {
|
||||
contentWidth = pane.MaxWidth()
|
||||
}
|
||||
|
||||
switch pane.Alignment() {
|
||||
case lipgloss.Center:
|
||||
actualX = (f.width - contentWidth) / 2
|
||||
case lipgloss.Right:
|
||||
actualX = f.width - contentWidth
|
||||
case lipgloss.Left:
|
||||
actualX = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Set position if the pane is a *container
|
||||
if c, ok := pane.(*container); ok {
|
||||
c.x = actualX
|
||||
c.y = actualY
|
||||
}
|
||||
|
||||
cmd := pane.SetSize(paneWidth, paneHeight)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
// Update position for next pane
|
||||
if f.direction == FlexDirectionHorizontal {
|
||||
currentX += paneWidth
|
||||
} else {
|
||||
currentY += paneHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
@@ -199,18 +237,6 @@ func (f *flexLayout) SetDirection(direction FlexDirection) tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *flexLayout) BindingKeys() []key.Binding {
|
||||
keys := []key.Binding{}
|
||||
for _, pane := range f.panes {
|
||||
if pane != nil {
|
||||
if b, ok := pane.(Bindings); ok {
|
||||
keys = append(keys, b.BindingKeys()...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func NewFlexLayout(options ...FlexLayoutOption) FlexLayout {
|
||||
layout := &flexLayout{
|
||||
direction: FlexDirectionHorizontal,
|
||||
|
||||
@@ -11,7 +11,6 @@ var Current *LayoutInfo
|
||||
|
||||
func init() {
|
||||
Current = &LayoutInfo{
|
||||
Size: LayoutSizeNormal,
|
||||
Viewport: Dimensions{Width: 80, Height: 25},
|
||||
Container: Dimensions{Width: 80, Height: 25},
|
||||
}
|
||||
@@ -19,19 +18,12 @@ func init() {
|
||||
|
||||
type LayoutSize string
|
||||
|
||||
const (
|
||||
LayoutSizeSmall LayoutSize = "small"
|
||||
LayoutSizeNormal LayoutSize = "normal"
|
||||
LayoutSizeLarge LayoutSize = "large"
|
||||
)
|
||||
|
||||
type Dimensions struct {
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
type LayoutInfo struct {
|
||||
Size LayoutSize
|
||||
Viewport Dimensions
|
||||
Container Dimensions
|
||||
}
|
||||
@@ -53,10 +45,6 @@ type Sizeable interface {
|
||||
GetSize() (int, int)
|
||||
}
|
||||
|
||||
type Bindings interface {
|
||||
BindingKeys() []key.Binding
|
||||
}
|
||||
|
||||
func KeyMapToSlice(t any) (bindings []key.Binding) {
|
||||
typ := reflect.TypeOf(t)
|
||||
if typ.Kind() != reflect.Struct {
|
||||
|
||||
@@ -2,7 +2,6 @@ package page
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
@@ -12,7 +11,6 @@ import (
|
||||
"github.com/sst/opencode/internal/components/chat"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
@@ -63,36 +61,13 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
cmd := p.layout.SetSize(msg.Width, msg.Height)
|
||||
cmds = append(cmds, cmd)
|
||||
case chat.SendMsg:
|
||||
p.showCompletionDialog = false
|
||||
cmd := p.sendMessage(msg.Text, msg.Attachments)
|
||||
if cmd != nil {
|
||||
return p, cmd
|
||||
}
|
||||
case dialog.CommandRunCustomMsg:
|
||||
// Check if the agent is busy before executing custom commands
|
||||
if p.app.IsBusy() {
|
||||
status.Warn("Agent is busy, please wait before executing a command...")
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Process the command content with arguments if any
|
||||
content := msg.Content
|
||||
if msg.Args != nil {
|
||||
// Replace all named arguments with their values
|
||||
for name, value := range msg.Args {
|
||||
placeholder := "$" + name
|
||||
content = strings.ReplaceAll(content, placeholder, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle custom command execution
|
||||
cmd := p.sendMessage(content, nil)
|
||||
if cmd != nil {
|
||||
return p, cmd
|
||||
}
|
||||
|
||||
case dialog.CompletionDialogCloseMsg:
|
||||
p.showCompletionDialog = false
|
||||
p.app.SetCompletionDialogOpen(false)
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
@@ -105,7 +80,6 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, keyMap.ShowCompletionDialog):
|
||||
p.showCompletionDialog = true
|
||||
p.app.SetCompletionDialogOpen(true)
|
||||
// Continue sending keys to layout->chat
|
||||
case key.Matches(msg, keyMap.Cancel):
|
||||
if p.app.Session.Id != "" {
|
||||
@@ -118,6 +92,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return p, util.CmdHandler(chat.ToggleToolMessagesMsg{})
|
||||
}
|
||||
}
|
||||
|
||||
if p.showCompletionDialog {
|
||||
context, contextCmd := p.completionDialog.Update(msg)
|
||||
p.completionDialog = context.(dialog.CompletionDialog)
|
||||
@@ -125,7 +100,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
// Doesn't forward event if enter key is pressed
|
||||
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
||||
if keyMsg.String() == "enter" {
|
||||
if keyMsg.String() == "enter" && !p.completionDialog.IsEmpty() {
|
||||
return p, tea.Batch(cmds...)
|
||||
}
|
||||
}
|
||||
@@ -155,17 +130,16 @@ func (p *chatPage) GetSize() (int, int) {
|
||||
func (p *chatPage) View() string {
|
||||
layoutView := p.layout.View()
|
||||
|
||||
// TODO: Fix this with our new layout
|
||||
if p.showCompletionDialog {
|
||||
_, layoutHeight := p.layout.GetSize()
|
||||
editorWidth, editorHeight := p.editor.GetSize()
|
||||
editorWidth, _ := p.editor.GetSize()
|
||||
editorX, editorY := p.editor.GetPosition()
|
||||
|
||||
p.completionDialog.SetWidth(editorWidth)
|
||||
overlay := p.completionDialog.View()
|
||||
|
||||
layoutView = layout.PlaceOverlay(
|
||||
0,
|
||||
layoutHeight-editorHeight-lipgloss.Height(overlay),
|
||||
editorX,
|
||||
editorY-lipgloss.Height(overlay)+2,
|
||||
overlay,
|
||||
layoutView,
|
||||
)
|
||||
@@ -174,13 +148,6 @@ func (p *chatPage) View() string {
|
||||
return layoutView
|
||||
}
|
||||
|
||||
func (p *chatPage) BindingKeys() []key.Binding {
|
||||
bindings := layout.KeyMapToSlice(keyMap)
|
||||
bindings = append(bindings, p.messages.BindingKeys()...)
|
||||
bindings = append(bindings, p.editor.BindingKeys()...)
|
||||
return bindings
|
||||
}
|
||||
|
||||
func NewChatPage(app *app.App) layout.ModelWithView {
|
||||
cg := completions.NewFileAndFolderContextGroup()
|
||||
completionDialog := dialog.NewCompletionDialogComponent(cg)
|
||||
@@ -202,7 +169,7 @@ func NewChatPage(app *app.App) layout.ModelWithView {
|
||||
layout.WithDirection(layout.FlexDirectionVertical),
|
||||
layout.WithPaneSizes(
|
||||
layout.FlexPaneSizeGrow,
|
||||
layout.FlexPaneSizeFixed(5),
|
||||
layout.FlexPaneSizeFixed(6),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
type SessionSelectedMsg = *client.SessionInfo
|
||||
type ModelSelectedMsg struct {
|
||||
Provider client.ProviderInfo
|
||||
Model client.ProviderModel
|
||||
Model client.ModelInfo
|
||||
}
|
||||
|
||||
type SessionClearedMsg struct{}
|
||||
@@ -17,5 +17,3 @@ type CompactSessionMsg struct{}
|
||||
type StateUpdatedMsg struct {
|
||||
State map[string]any
|
||||
}
|
||||
|
||||
// TODO: store in CONFIG/tui.yaml
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/alecthomas/chroma/v2/styles"
|
||||
// "github.com/alecthomas/chroma/v2/styles"
|
||||
)
|
||||
|
||||
@@ -45,22 +47,7 @@ func RegisterTheme(name string, theme Theme) {
|
||||
func SetTheme(name string) error {
|
||||
globalManager.mu.Lock()
|
||||
defer globalManager.mu.Unlock()
|
||||
// delete(styles.Registry, "charm")
|
||||
|
||||
// Handle custom theme
|
||||
// if name == "custom" {
|
||||
// cfg := config.Get()
|
||||
// if cfg == nil || cfg.TUI.CustomTheme == nil || len(cfg.TUI.CustomTheme) == 0 {
|
||||
// return fmt.Errorf("custom theme selected but no custom theme colors defined in config")
|
||||
// }
|
||||
//
|
||||
// customTheme, err := LoadCustomTheme(cfg.TUI.CustomTheme)
|
||||
// if err != nil {
|
||||
// return fmt.Errorf("failed to load custom theme: %w", err)
|
||||
// }
|
||||
//
|
||||
// // Register the custom theme
|
||||
// globalManager.themes["custom"] = customTheme
|
||||
delete(styles.Registry, "charm")
|
||||
|
||||
if _, exists := globalManager.themes[name]; !exists {
|
||||
return fmt.Errorf("theme '%s' not found", name)
|
||||
|
||||
@@ -2,7 +2,6 @@ package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/cursor"
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
@@ -11,55 +10,19 @@ import (
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/core"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/page"
|
||||
"github.com/sst/opencode/internal/state"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
"github.com/sst/opencode/pkg/client"
|
||||
)
|
||||
|
||||
type keyMap struct {
|
||||
Help key.Binding
|
||||
NewSession key.Binding
|
||||
SwitchSession key.Binding
|
||||
SwitchModel key.Binding
|
||||
SwitchTheme key.Binding
|
||||
Quit key.Binding
|
||||
}
|
||||
|
||||
var keys = keyMap{
|
||||
Help: key.NewBinding(
|
||||
key.WithKeys("f1", "super+/", "super+h"),
|
||||
key.WithHelp("/help", "show help"),
|
||||
),
|
||||
NewSession: key.NewBinding(
|
||||
key.WithKeys("f2", "super+n"),
|
||||
key.WithHelp("/new", "new session"),
|
||||
),
|
||||
SwitchSession: key.NewBinding(
|
||||
key.WithKeys("f3", "super+s"),
|
||||
key.WithHelp("/sessions", "switch session"),
|
||||
),
|
||||
SwitchModel: key.NewBinding(
|
||||
key.WithKeys("f4", "super+m"),
|
||||
key.WithHelp("/model", "switch model"),
|
||||
),
|
||||
SwitchTheme: key.NewBinding(
|
||||
key.WithKeys("f5", "super+t"),
|
||||
key.WithHelp("/theme", "switch theme"),
|
||||
),
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("f10", "ctrl+c", "super+q"),
|
||||
key.WithHelp("/quit", "quit"),
|
||||
),
|
||||
}
|
||||
|
||||
type appModel struct {
|
||||
width, height int
|
||||
currentPage page.PageID
|
||||
@@ -69,7 +32,6 @@ type appModel struct {
|
||||
status core.StatusComponent
|
||||
app *app.App
|
||||
modal layout.Modal
|
||||
commands []dialog.Command
|
||||
}
|
||||
|
||||
func (a appModel) Init() tea.Cmd {
|
||||
@@ -134,12 +96,12 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
isModalTrigger = key.Matches(msg, keys.NewSession) ||
|
||||
key.Matches(msg, keys.SwitchSession) ||
|
||||
key.Matches(msg, keys.SwitchModel) ||
|
||||
key.Matches(msg, keys.SwitchTheme) ||
|
||||
key.Matches(msg, keys.Help) ||
|
||||
key.Matches(msg, keys.Quit)
|
||||
for _, cmdDef := range a.app.Commands {
|
||||
if key.Matches(msg, cmdDef.KeyBinding) {
|
||||
isModalTrigger = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !isModalTrigger {
|
||||
@@ -151,6 +113,38 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
switch msg := msg.(type) {
|
||||
|
||||
case commands.ExecuteCommandMsg:
|
||||
switch msg.Name {
|
||||
case "quit":
|
||||
quitDialog := dialog.NewQuitDialog()
|
||||
a.modal = quitDialog
|
||||
case "new":
|
||||
a.app.Session = &client.SessionInfo{}
|
||||
a.app.Messages = []client.MessageInfo{}
|
||||
cmds = append(cmds, util.CmdHandler(state.SessionClearedMsg{}))
|
||||
case "sessions":
|
||||
sessionDialog := dialog.NewSessionDialog(a.app)
|
||||
a.modal = sessionDialog
|
||||
case "model":
|
||||
modelDialog := dialog.NewModelDialog(a.app)
|
||||
a.modal = modelDialog
|
||||
case "theme":
|
||||
themeDialog := dialog.NewThemeDialog()
|
||||
a.modal = themeDialog
|
||||
case "help":
|
||||
var helpBindings []key.Binding
|
||||
for _, cmd := range a.app.Commands {
|
||||
// Create a new binding for help display
|
||||
helpBindings = append(helpBindings, key.NewBinding(
|
||||
key.WithKeys(cmd.KeyBinding.Keys()...),
|
||||
key.WithHelp("/"+cmd.Name, cmd.Description),
|
||||
))
|
||||
}
|
||||
helpDialog := dialog.NewHelpDialog(helpBindings...)
|
||||
a.modal = helpDialog
|
||||
}
|
||||
return a, tea.Batch(cmds...)
|
||||
|
||||
case tea.BackgroundColorMsg:
|
||||
styles.Terminal = &styles.TerminalInfo{
|
||||
BackgroundIsDark: msg.IsDark(),
|
||||
@@ -184,18 +178,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
msg.Height -= 2 // Make space for the status bar
|
||||
a.width, a.height = msg.Width, msg.Height
|
||||
|
||||
size := layout.LayoutSizeNormal
|
||||
if a.width < 40 {
|
||||
size = layout.LayoutSizeSmall
|
||||
} else if a.width < 80 {
|
||||
size = layout.LayoutSizeNormal
|
||||
} else {
|
||||
size = layout.LayoutSizeLarge
|
||||
}
|
||||
|
||||
// TODO: move away from global state
|
||||
layout.Current = &layout.LayoutInfo{
|
||||
Size: size,
|
||||
Viewport: layout.Dimensions{
|
||||
Width: a.width,
|
||||
Height: a.height,
|
||||
@@ -271,12 +255,17 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
s, cmd := a.status.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
a.status = s.(core.StatusComponent)
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
|
||||
return a, tea.Batch(cmds...)
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
// give the editor a chance to clear input
|
||||
case "ctrl+c":
|
||||
updated, cmd := a.pages[a.currentPage].Update(msg)
|
||||
a.pages[a.currentPage] = updated.(layout.ModelWithView)
|
||||
@@ -285,51 +274,16 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case key.Matches(msg, keys.Help):
|
||||
helpDialog := dialog.NewHelpDialog(
|
||||
keys.Help,
|
||||
keys.NewSession,
|
||||
keys.SwitchSession,
|
||||
keys.SwitchModel,
|
||||
keys.SwitchTheme,
|
||||
keys.Quit,
|
||||
)
|
||||
a.modal = helpDialog
|
||||
return a, nil
|
||||
|
||||
case key.Matches(msg, keys.NewSession):
|
||||
a.app.Session = &client.SessionInfo{}
|
||||
a.app.Messages = []client.MessageInfo{}
|
||||
return a, tea.Batch(
|
||||
util.CmdHandler(state.SessionClearedMsg{}),
|
||||
)
|
||||
|
||||
case key.Matches(msg, keys.SwitchModel):
|
||||
modelDialog := dialog.NewModelDialog(a.app)
|
||||
a.modal = modelDialog
|
||||
return a, nil
|
||||
|
||||
case key.Matches(msg, keys.SwitchSession):
|
||||
sessionDialog := dialog.NewSessionDialog(a.app)
|
||||
a.modal = sessionDialog
|
||||
return a, nil
|
||||
|
||||
case key.Matches(msg, keys.SwitchTheme):
|
||||
themeDialog := dialog.NewThemeDialog()
|
||||
a.modal = themeDialog
|
||||
return a, nil
|
||||
|
||||
case key.Matches(msg, keys.Quit):
|
||||
quitDialog := dialog.NewQuitDialog()
|
||||
a.modal = quitDialog
|
||||
return a, nil
|
||||
// First, check for modal triggers from the command registry
|
||||
if a.modal == nil {
|
||||
for _, cmdDef := range a.app.Commands {
|
||||
if key.Matches(msg, cmdDef.KeyBinding) {
|
||||
// If a key matches, send an ExecuteCommandMsg to self.
|
||||
// This unifies keybinding and slash command handling.
|
||||
return a, util.CmdHandler(commands.ExecuteCommandMsg{Name: cmdDef.Name})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
// f, filepickerCmd := a.filepicker.Update(msg)
|
||||
// a.filepicker = f.(dialog.FilepickerComponent)
|
||||
// cmds = append(cmds, filepickerCmd)
|
||||
}
|
||||
|
||||
// update status bar
|
||||
@@ -344,11 +298,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return a, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// RegisterCommand adds a command to the command dialog
|
||||
func (a *appModel) RegisterCommand(cmd dialog.Command) {
|
||||
a.commands = append(a.commands, cmd)
|
||||
}
|
||||
|
||||
func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
if _, ok := a.loadedPages[pageID]; !ok {
|
||||
@@ -387,47 +336,10 @@ func NewModel(app *app.App) tea.Model {
|
||||
loadedPages: make(map[page.PageID]bool),
|
||||
status: core.NewStatusCmp(app),
|
||||
app: app,
|
||||
commands: []dialog.Command{},
|
||||
pages: map[page.PageID]layout.ModelWithView{
|
||||
page.ChatPage: page.NewChatPage(app),
|
||||
},
|
||||
}
|
||||
|
||||
model.RegisterCommand(dialog.Command{
|
||||
ID: "init",
|
||||
Title: "Initialize Project",
|
||||
Description: "Create/Update the AGENTS.md memory file",
|
||||
Handler: func(cmd dialog.Command) tea.Cmd {
|
||||
return app.InitializeProject(context.Background())
|
||||
},
|
||||
})
|
||||
|
||||
model.RegisterCommand(dialog.Command{
|
||||
ID: "compact_conversation",
|
||||
Title: "Compact Conversation",
|
||||
Description: "Summarize the current session to save tokens",
|
||||
Handler: func(cmd dialog.Command) tea.Cmd {
|
||||
// Get the current session from the appModel
|
||||
if model.currentPage != page.ChatPage {
|
||||
status.Warn("Please navigate to a chat session first.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return a message that will be handled by the chat page
|
||||
status.Info("Compacting conversation...")
|
||||
return util.CmdHandler(state.CompactSessionMsg{})
|
||||
},
|
||||
})
|
||||
|
||||
// Load custom commands
|
||||
customCommands, err := dialog.LoadCustomCommands()
|
||||
if err != nil {
|
||||
slog.Warn("Failed to load custom commands", "error", err)
|
||||
} else {
|
||||
for _, cmd := range customCommands {
|
||||
model.RegisterCommand(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
@@ -439,6 +439,45 @@
|
||||
"parameters": [],
|
||||
"description": "List all providers"
|
||||
}
|
||||
},
|
||||
"/file_search": {
|
||||
"post": {
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Search for files",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"operationId": "postFile_search",
|
||||
"parameters": [],
|
||||
"description": "Search for files",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"query"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -618,7 +657,6 @@
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"system",
|
||||
"user",
|
||||
"assistant"
|
||||
]
|
||||
@@ -668,17 +706,65 @@
|
||||
},
|
||||
"tool": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"time": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start": {
|
||||
"type": "number"
|
||||
},
|
||||
"end": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"start",
|
||||
"end"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"title",
|
||||
"time"
|
||||
],
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
"assistant": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"system": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
},
|
||||
"providerID": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": "string"
|
||||
},
|
||||
"root": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"cwd",
|
||||
"root"
|
||||
]
|
||||
},
|
||||
"cost": {
|
||||
"type": "number"
|
||||
},
|
||||
@@ -706,8 +792,10 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"system",
|
||||
"modelID",
|
||||
"providerID",
|
||||
"path",
|
||||
"cost",
|
||||
"tokens"
|
||||
]
|
||||
@@ -715,7 +803,6 @@
|
||||
},
|
||||
"required": [
|
||||
"time",
|
||||
"error",
|
||||
"sessionID",
|
||||
"tool"
|
||||
]
|
||||
@@ -1036,10 +1123,18 @@
|
||||
"properties": {
|
||||
"part": {
|
||||
"$ref": "#/components/schemas/Message.Part"
|
||||
},
|
||||
"sessionID": {
|
||||
"type": "string"
|
||||
},
|
||||
"messageID": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"part"
|
||||
"part",
|
||||
"sessionID",
|
||||
"messageID"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -1079,6 +1174,10 @@
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
"parentID": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
"share": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1146,10 +1245,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"error"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1220,31 +1316,35 @@
|
||||
"Provider.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"env": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"models": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/Provider.Model"
|
||||
"$ref": "#/components/schemas/Model.Info"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"env",
|
||||
"id",
|
||||
"models"
|
||||
]
|
||||
},
|
||||
"Provider.Model": {
|
||||
"Model.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1254,16 +1354,19 @@
|
||||
"reasoning": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"temperature": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"cost": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {
|
||||
"type": "number"
|
||||
},
|
||||
"inputCached": {
|
||||
"output": {
|
||||
"type": "number"
|
||||
},
|
||||
"output": {
|
||||
"inputCached": {
|
||||
"type": "number"
|
||||
},
|
||||
"outputCached": {
|
||||
@@ -1272,8 +1375,8 @@
|
||||
},
|
||||
"required": [
|
||||
"input",
|
||||
"inputCached",
|
||||
"output",
|
||||
"inputCached",
|
||||
"outputCached"
|
||||
]
|
||||
},
|
||||
@@ -1291,13 +1394,19 @@
|
||||
"context",
|
||||
"output"
|
||||
]
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"attachment",
|
||||
"reasoning",
|
||||
"temperature",
|
||||
"cost",
|
||||
"limit"
|
||||
"limit",
|
||||
"id"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
// Defines values for MessageInfoRole.
|
||||
const (
|
||||
Assistant MessageInfoRole = "assistant"
|
||||
System MessageInfoRole = "system"
|
||||
User MessageInfoRole = "user"
|
||||
)
|
||||
|
||||
@@ -61,7 +60,9 @@ type EventLspClientDiagnostics struct {
|
||||
// EventMessagePartUpdated defines model for Event.message.part.updated.
|
||||
type EventMessagePartUpdated struct {
|
||||
Properties struct {
|
||||
Part MessagePart `json:"part"`
|
||||
MessageID string `json:"messageID"`
|
||||
Part MessagePart `json:"part"`
|
||||
SessionID string `json:"sessionID"`
|
||||
} `json:"properties"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
@@ -83,7 +84,7 @@ type EventPermissionUpdated struct {
|
||||
// EventSessionError defines model for Event.session.error.
|
||||
type EventSessionError struct {
|
||||
Properties struct {
|
||||
Error EventSessionError_Properties_Error `json:"error"`
|
||||
Error *EventSessionError_Properties_Error `json:"error,omitempty"`
|
||||
} `json:"properties"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
@@ -115,23 +116,28 @@ type MessageInfo struct {
|
||||
Id string `json:"id"`
|
||||
Metadata struct {
|
||||
Assistant *struct {
|
||||
Cost float32 `json:"cost"`
|
||||
ModelID string `json:"modelID"`
|
||||
ProviderID string `json:"providerID"`
|
||||
Summary *bool `json:"summary,omitempty"`
|
||||
Cost float32 `json:"cost"`
|
||||
ModelID string `json:"modelID"`
|
||||
Path struct {
|
||||
Cwd string `json:"cwd"`
|
||||
Root string `json:"root"`
|
||||
} `json:"path"`
|
||||
ProviderID string `json:"providerID"`
|
||||
Summary *bool `json:"summary,omitempty"`
|
||||
System []string `json:"system"`
|
||||
Tokens struct {
|
||||
Input float32 `json:"input"`
|
||||
Output float32 `json:"output"`
|
||||
Reasoning float32 `json:"reasoning"`
|
||||
} `json:"tokens"`
|
||||
} `json:"assistant,omitempty"`
|
||||
Error MessageInfo_Metadata_Error `json:"error"`
|
||||
SessionID string `json:"sessionID"`
|
||||
Error *MessageInfo_Metadata_Error `json:"error,omitempty"`
|
||||
SessionID string `json:"sessionID"`
|
||||
Time struct {
|
||||
Completed *float32 `json:"completed,omitempty"`
|
||||
Created float32 `json:"created"`
|
||||
} `json:"time"`
|
||||
Tool map[string]interface{} `json:"tool"`
|
||||
Tool map[string]MessageInfo_Metadata_Tool_AdditionalProperties `json:"tool"`
|
||||
} `json:"metadata"`
|
||||
Parts []MessagePart `json:"parts"`
|
||||
Role MessageInfoRole `json:"role"`
|
||||
@@ -142,6 +148,16 @@ type MessageInfo_Metadata_Error struct {
|
||||
union json.RawMessage
|
||||
}
|
||||
|
||||
// MessageInfo_Metadata_Tool_AdditionalProperties defines model for MessageInfo.Metadata.Tool.AdditionalProperties.
|
||||
type MessageInfo_Metadata_Tool_AdditionalProperties struct {
|
||||
Time struct {
|
||||
End float32 `json:"end"`
|
||||
Start float32 `json:"start"`
|
||||
} `json:"time"`
|
||||
Title string `json:"title"`
|
||||
AdditionalProperties map[string]interface{} `json:"-"`
|
||||
}
|
||||
|
||||
// MessageInfoRole defines model for MessageInfo.Role.
|
||||
type MessageInfoRole string
|
||||
|
||||
@@ -224,15 +240,8 @@ type MessageToolInvocationToolResult struct {
|
||||
ToolName string `json:"toolName"`
|
||||
}
|
||||
|
||||
// ProviderInfo defines model for Provider.Info.
|
||||
type ProviderInfo struct {
|
||||
Id string `json:"id"`
|
||||
Models map[string]ProviderModel `json:"models"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// ProviderModel defines model for Provider.Model.
|
||||
type ProviderModel struct {
|
||||
// ModelInfo defines model for Model.Info.
|
||||
type ModelInfo struct {
|
||||
Attachment bool `json:"attachment"`
|
||||
Cost struct {
|
||||
Input float32 `json:"input"`
|
||||
@@ -245,8 +254,17 @@ type ProviderModel struct {
|
||||
Context float32 `json:"context"`
|
||||
Output float32 `json:"output"`
|
||||
} `json:"limit"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Reasoning *bool `json:"reasoning,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Reasoning bool `json:"reasoning"`
|
||||
Temperature bool `json:"temperature"`
|
||||
}
|
||||
|
||||
// ProviderInfo defines model for Provider.Info.
|
||||
type ProviderInfo struct {
|
||||
Env []string `json:"env"`
|
||||
Id string `json:"id"`
|
||||
Models map[string]ModelInfo `json:"models"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// ProviderAuthError defines model for ProviderAuthError.
|
||||
@@ -279,8 +297,9 @@ type PermissionInfo struct {
|
||||
|
||||
// SessionInfo defines model for session.info.
|
||||
type SessionInfo struct {
|
||||
Id string `json:"id"`
|
||||
Share *struct {
|
||||
Id string `json:"id"`
|
||||
ParentID *string `json:"parentID,omitempty"`
|
||||
Share *struct {
|
||||
Secret string `json:"secret"`
|
||||
Url string `json:"url"`
|
||||
} `json:"share,omitempty"`
|
||||
@@ -291,6 +310,11 @@ type SessionInfo struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// PostFileSearchJSONBody defines parameters for PostFileSearch.
|
||||
type PostFileSearchJSONBody struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
// PostSessionAbortJSONBody defines parameters for PostSessionAbort.
|
||||
type PostSessionAbortJSONBody struct {
|
||||
SessionID string `json:"sessionID"`
|
||||
@@ -328,6 +352,9 @@ type PostSessionSummarizeJSONBody struct {
|
||||
SessionID string `json:"sessionID"`
|
||||
}
|
||||
|
||||
// PostFileSearchJSONRequestBody defines body for PostFileSearch for application/json ContentType.
|
||||
type PostFileSearchJSONRequestBody PostFileSearchJSONBody
|
||||
|
||||
// PostSessionAbortJSONRequestBody defines body for PostSessionAbort for application/json ContentType.
|
||||
type PostSessionAbortJSONRequestBody PostSessionAbortJSONBody
|
||||
|
||||
@@ -346,6 +373,85 @@ type PostSessionShareJSONRequestBody PostSessionShareJSONBody
|
||||
// PostSessionSummarizeJSONRequestBody defines body for PostSessionSummarize for application/json ContentType.
|
||||
type PostSessionSummarizeJSONRequestBody PostSessionSummarizeJSONBody
|
||||
|
||||
// Getter for additional properties for MessageInfo_Metadata_Tool_AdditionalProperties. Returns the specified
|
||||
// element and whether it was found
|
||||
func (a MessageInfo_Metadata_Tool_AdditionalProperties) Get(fieldName string) (value interface{}, found bool) {
|
||||
if a.AdditionalProperties != nil {
|
||||
value, found = a.AdditionalProperties[fieldName]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Setter for additional properties for MessageInfo_Metadata_Tool_AdditionalProperties
|
||||
func (a *MessageInfo_Metadata_Tool_AdditionalProperties) Set(fieldName string, value interface{}) {
|
||||
if a.AdditionalProperties == nil {
|
||||
a.AdditionalProperties = make(map[string]interface{})
|
||||
}
|
||||
a.AdditionalProperties[fieldName] = value
|
||||
}
|
||||
|
||||
// Override default JSON handling for MessageInfo_Metadata_Tool_AdditionalProperties to handle AdditionalProperties
|
||||
func (a *MessageInfo_Metadata_Tool_AdditionalProperties) UnmarshalJSON(b []byte) error {
|
||||
object := make(map[string]json.RawMessage)
|
||||
err := json.Unmarshal(b, &object)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if raw, found := object["time"]; found {
|
||||
err = json.Unmarshal(raw, &a.Time)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading 'time': %w", err)
|
||||
}
|
||||
delete(object, "time")
|
||||
}
|
||||
|
||||
if raw, found := object["title"]; found {
|
||||
err = json.Unmarshal(raw, &a.Title)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading 'title': %w", err)
|
||||
}
|
||||
delete(object, "title")
|
||||
}
|
||||
|
||||
if len(object) != 0 {
|
||||
a.AdditionalProperties = make(map[string]interface{})
|
||||
for fieldName, fieldBuf := range object {
|
||||
var fieldVal interface{}
|
||||
err := json.Unmarshal(fieldBuf, &fieldVal)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err)
|
||||
}
|
||||
a.AdditionalProperties[fieldName] = fieldVal
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Override default JSON handling for MessageInfo_Metadata_Tool_AdditionalProperties to handle AdditionalProperties
|
||||
func (a MessageInfo_Metadata_Tool_AdditionalProperties) MarshalJSON() ([]byte, error) {
|
||||
var err error
|
||||
object := make(map[string]json.RawMessage)
|
||||
|
||||
object["time"], err = json.Marshal(a.Time)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error marshaling 'time': %w", err)
|
||||
}
|
||||
|
||||
object["title"], err = json.Marshal(a.Title)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error marshaling 'title': %w", err)
|
||||
}
|
||||
|
||||
for fieldName, field := range a.AdditionalProperties {
|
||||
object[fieldName], err = json.Marshal(field)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err)
|
||||
}
|
||||
}
|
||||
return json.Marshal(object)
|
||||
}
|
||||
|
||||
// AsEventStorageWrite returns the union data inside the Event as a EventStorageWrite
|
||||
func (t Event) AsEventStorageWrite() (EventStorageWrite, error) {
|
||||
var body EventStorageWrite
|
||||
@@ -1173,6 +1279,11 @@ type ClientInterface interface {
|
||||
// GetEvent request
|
||||
GetEvent(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// PostFileSearchWithBody request with any body
|
||||
PostFileSearchWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
PostFileSearch(ctx context.Context, body PostFileSearchJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// PostPathGet request
|
||||
PostPathGet(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
@@ -1252,6 +1363,30 @@ func (c *Client) GetEvent(ctx context.Context, reqEditors ...RequestEditorFn) (*
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) PostFileSearchWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewPostFileSearchRequestWithBody(c.Server, contentType, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) PostFileSearch(ctx context.Context, body PostFileSearchJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewPostFileSearchRequest(c.Server, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) PostPathGet(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewPostPathGetRequest(c.Server)
|
||||
if err != nil {
|
||||
@@ -1525,6 +1660,46 @@ func NewGetEventRequest(server string) (*http.Request, error) {
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewPostFileSearchRequest calls the generic PostFileSearch builder with application/json body
|
||||
func NewPostFileSearchRequest(server string, body PostFileSearchJSONRequestBody) (*http.Request, error) {
|
||||
var bodyReader io.Reader
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bodyReader = bytes.NewReader(buf)
|
||||
return NewPostFileSearchRequestWithBody(server, "application/json", bodyReader)
|
||||
}
|
||||
|
||||
// NewPostFileSearchRequestWithBody generates requests for PostFileSearch with any type of body
|
||||
func NewPostFileSearchRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) {
|
||||
var err error
|
||||
|
||||
serverURL, err := url.Parse(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
operationPath := fmt.Sprintf("/file_search")
|
||||
if operationPath[0] == '/' {
|
||||
operationPath = "." + operationPath
|
||||
}
|
||||
|
||||
queryURL, err := serverURL.Parse(operationPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", queryURL.String(), body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", contentType)
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewPostPathGetRequest generates requests for PostPathGet
|
||||
func NewPostPathGetRequest(server string) (*http.Request, error) {
|
||||
var err error
|
||||
@@ -1925,6 +2100,11 @@ type ClientWithResponsesInterface interface {
|
||||
// GetEventWithResponse request
|
||||
GetEventWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetEventResponse, error)
|
||||
|
||||
// PostFileSearchWithBodyWithResponse request with any body
|
||||
PostFileSearchWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostFileSearchResponse, error)
|
||||
|
||||
PostFileSearchWithResponse(ctx context.Context, body PostFileSearchJSONRequestBody, reqEditors ...RequestEditorFn) (*PostFileSearchResponse, error)
|
||||
|
||||
// PostPathGetWithResponse request
|
||||
PostPathGetWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostPathGetResponse, error)
|
||||
|
||||
@@ -2034,6 +2214,28 @@ func (r GetEventResponse) StatusCode() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
type PostFileSearchResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
JSON200 *[]string
|
||||
}
|
||||
|
||||
// Status returns HTTPResponse.Status
|
||||
func (r PostFileSearchResponse) Status() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Status
|
||||
}
|
||||
return http.StatusText(0)
|
||||
}
|
||||
|
||||
// StatusCode returns HTTPResponse.StatusCode
|
||||
func (r PostFileSearchResponse) StatusCode() int {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.StatusCode
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type PostPathGetResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
@@ -2290,6 +2492,23 @@ func (c *ClientWithResponses) GetEventWithResponse(ctx context.Context, reqEdito
|
||||
return ParseGetEventResponse(rsp)
|
||||
}
|
||||
|
||||
// PostFileSearchWithBodyWithResponse request with arbitrary body returning *PostFileSearchResponse
|
||||
func (c *ClientWithResponses) PostFileSearchWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostFileSearchResponse, error) {
|
||||
rsp, err := c.PostFileSearchWithBody(ctx, contentType, body, reqEditors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParsePostFileSearchResponse(rsp)
|
||||
}
|
||||
|
||||
func (c *ClientWithResponses) PostFileSearchWithResponse(ctx context.Context, body PostFileSearchJSONRequestBody, reqEditors ...RequestEditorFn) (*PostFileSearchResponse, error) {
|
||||
rsp, err := c.PostFileSearch(ctx, body, reqEditors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParsePostFileSearchResponse(rsp)
|
||||
}
|
||||
|
||||
// PostPathGetWithResponse request returning *PostPathGetResponse
|
||||
func (c *ClientWithResponses) PostPathGetWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostPathGetResponse, error) {
|
||||
rsp, err := c.PostPathGet(ctx, reqEditors...)
|
||||
@@ -2506,6 +2725,32 @@ func ParseGetEventResponse(rsp *http.Response) (*GetEventResponse, error) {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ParsePostFileSearchResponse parses an HTTP response from a PostFileSearchWithResponse call
|
||||
func ParsePostFileSearchResponse(rsp *http.Response) (*PostFileSearchResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
defer func() { _ = rsp.Body.Close() }()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := &PostFileSearchResponse{
|
||||
Body: bodyBytes,
|
||||
HTTPResponse: rsp,
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
|
||||
var dest []string
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON200 = &dest
|
||||
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ParsePostPathGetResponse parses an HTTP response from a PostPathGetWithResponse call
|
||||
func ParsePostPathGetResponse(rsp *http.Response) (*PostPathGetResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
|
||||
Reference in New Issue
Block a user