Compare commits

...
20 Commits
Author SHA1 Message Date
Dax Raad 2bee48a9bc homebrew
publish / publish (push) Has been cancelled
2025-06-13 16:17:27 -04:00
adamdottv 10ddd654cf wip: refactoring tui
publish / publish (push) Has been cancelled
2025-06-13 11:27:05 -05:00
adamdottv 61396b93ed wip: refactoring tui 2025-06-13 11:18:46 -05:00
adamdottv 62b9a30a9c wip: refactoring tui 2025-06-13 10:47:51 -05:00
adamdottv 5706c6ad3a wip: refactoring tui 2025-06-13 09:57:54 -05:00
adamdottv e8e03c895a wip: refactoring tui 2025-06-13 09:44:09 -05:00
adamdottv 38667682a7 wip: refactoring tui 2025-06-13 09:19:51 -05:00
adamdottv d7d5fc39fb wip: refactoring tui 2025-06-13 08:30:57 -05:00
adamdottv 0caf25adee wip: refactoring tui 2025-06-13 08:30:56 -05:00
Dax Raad 37febc6873 do not strip aur package
publish / publish (push) Has been cancelled
2025-06-13 08:27:17 -04:00
adamdottv 4169f0c412 wip: refactoring tui
publish / publish (push) Has been cancelled
2025-06-13 07:01:26 -05:00
adamdottv b7f06bbc1f wip: refactoring tui 2025-06-13 06:56:12 -05:00
adamdottv 1b8cfe9e99 wip: refactoring tui 2025-06-13 06:49:59 -05:00
adamdottv 97837d2d23 wip: refactoring tui 2025-06-13 06:23:12 -05:00
Dax Raad 9abc2a0cf8 load API keys
publish / publish (push) Has been cancelled
2025-06-13 00:53:46 -04:00
Dax RaadandOpenCode 9fb47bc855 Enhance auth command with dynamic provider selection
- Add support for dynamically loading providers from ModelsDev
- Prioritize anthropic as recommended provider
- Add "other" provider option for manual entry
- Include special handling for amazon-bedrock with AWS config guidance
- Expand provider selection UI to show up to 8 providers

🤖 Generated with [OpenCode](https://opencode.ai)

Co-Authored-By: OpenCode <noreply@opencode.ai>
2025-06-13 00:33:54 -04:00
Dax Raad 73e9fb53d5 sync 2025-06-13 00:06:15 -04:00
Dax RaadandOpenCode f03637b1fc Refactor AI SDK provider loading to use BunProc.install
Simplifies provider installation by using BunProc.install() instead of manual path construction and file system checks.

🤖 Generated with [OpenCode](https://opencode.ai)

Co-Authored-By: OpenCode <noreply@opencode.ai>
2025-06-12 23:50:26 -04:00
Dax Raad 2c376c5abc bedrock loader
publish / publish (push) Has been cancelled
2025-06-12 23:39:52 -04:00
Dax RaadandOpenCode 442e1b52ad Update provider configuration and server handling
🤖 Generated with [OpenCode](https://opencode.ai)

Co-Authored-By: OpenCode <noreply@opencode.ai>
2025-06-12 23:10:03 -04:00
43 changed files with 1371 additions and 2451 deletions
@@ -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"
}
+103 -23
View File
@@ -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`
}
+25
View File
@@ -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
}
}
+40 -12
View File
@@ -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",
+4 -2
View File
@@ -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())
})
},
})
+9 -1
View File
@@ -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(),
+38 -1
View File
@@ -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()
+96 -115
View File
@@ -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,
[
+3 -2
View File
@@ -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,
),
-3
View File
@@ -123,9 +123,6 @@ func main() {
// Cancel subscriptions first
cancelSubs()
// Then shutdown the app
app_.Shutdown()
// Then cancel TUI message handler
tuiCancel()
+36 -47
View File
@@ -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?
}
+71
View File
@@ -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) {
+22 -19
View File
@@ -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,
+126 -59
View File
@@ -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,
}
}
+177 -35
View File
@@ -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,
+3 -3
View File
@@ -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.
-163
View File
@@ -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
}
+9 -10
View File
@@ -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 {
+40 -14
View File
@@ -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,
-12
View File
@@ -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 {
+8 -41
View File
@@ -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),
),
),
}
+1 -3
View File
@@ -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
+3 -16
View File
@@ -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)
+53 -141
View File
@@ -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
}
+130 -21
View File
@@ -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"
]
}
}
+268 -23
View File
@@ -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)