Compare commits

..

10 Commits

Author SHA1 Message Date
adamdottv 77a6b3bdd6 fix: background color rendering issues 2025-06-15 15:07:05 -05:00
Pierre B. 7effff56c0 fix: spelling, grammar and typos (#121) 2025-06-15 14:23:18 -05:00
Dax Raad e30fba0d3c Improve LSP server initialization with timeout handling and skip failed servers
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-15 13:52:57 -04:00
Dax Raad 7fbb2ca9a6 ignore: add timer log helper 2025-06-15 13:33:24 -04:00
Dax Raad 230d0a1510 fix postinstall script for node 2025-06-15 13:11:11 -04:00
Pierre B. 46ff2c0ae0 chore: ignore intellij, vscode (#122) 2025-06-15 10:40:34 -05:00
adamdottv b8a89dab0f fix: background color rendering issues 2025-06-15 05:57:15 -05:00
szymon 7351e12886 remove .DS_Store (#112) 2025-06-15 05:34:46 -05:00
Dax Raad 38879dee2d beginning of upgrade command 2025-06-14 22:05:41 -04:00
Dax Raad c4ff8dd205 revert ctrl+d - conflicts with page down 2025-06-14 21:29:02 -04:00
22 changed files with 354 additions and 441 deletions
+2
View File
@@ -3,3 +3,5 @@ node_modules
.opencode
.sst
.env
.idea
.vscode
+5 -5
View File
@@ -2,7 +2,7 @@
AI coding agent, built for the terminal.
⚠️ **Note:** version 0.1.x is a full rewrite and we do not have proper documentation for it yet. Should have this out week of June 17th 2025 📚
⚠️ **Note:** version 0.1.x is a full rewrite, and we do not have proper documentation for it yet. Should have this out week of June 17th 2025 📚
### Installation
@@ -20,9 +20,9 @@ paru -S opencode-bin # Arch Linux
### Providers
The recommended approach is to sign up for claude pro or max and do `opencode auth login` and select Anthropic. It is the most cost effective way to use this tool.
The recommended approach is to sign up for claude pro or max and do `opencode auth login` and select Anthropic. It is the most cost-effective way to use this tool.
Additionally opencode is powered by the provider list at [models.dev](https://models.dev) so you can use `opencode auth login` to configure api keys for any provider you'd like to use. This is stored in `~/.local/share/opencode/auth.json`
Additionally, opencode is powered by the provider list at [models.dev](https://models.dev) so you can use `opencode auth login` to configure api keys for any provider you'd like to use. This is stored in `~/.local/share/opencode/auth.json`
```bash
$ opencode auth login
@@ -47,7 +47,7 @@ If there are additional providers you want to use you can submit a PR to the [mo
### Project Config
Project configuration is optional. You can place an `opencode.json` file in the root of your repo and it will be loaded.
Project configuration is optional. You can place an `opencode.json` file in the root of your repo, and it will be loaded.
```json title="opencode.json"
{
@@ -118,7 +118,7 @@ $ bun run src/index.ts
#### How do I use this with OpenRouter
OpenRouter is not yet in the models.dev database but you can configure it manually.
OpenRouter is not yet in the models.dev database, but you can configure it manually.
```json title="opencode.json"
{
+2 -2
View File
@@ -1,4 +1,4 @@
# OpenCode Agent Guidelines
# opencode agent guidelines
## Build/Test Commands
@@ -19,7 +19,7 @@
## IMPORTANT
- Try to keep things in one function unless composable or reusable
- Try to keep things in one function unless composable or reusablte
- DO NOT do unnecessary destructuring of variables
- DO NOT use else statements unless necessary
- DO NOT use try catch if it can be avoided
+2 -2
View File
@@ -64,7 +64,7 @@ for (const [os, arch] of targets) {
await $`mkdir -p ./dist/${pkg.name}`
await $`cp -r ./bin ./dist/${pkg.name}/bin`
await $`cp ./script/postinstall.js ./dist/${pkg.name}/postinstall.js`
await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs`
await Bun.file(`./dist/${pkg.name}/package.json`).write(
JSON.stringify(
{
@@ -73,7 +73,7 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
[pkg.name]: `./bin/${pkg.name}`,
},
scripts: {
postinstall: "node ./postinstall.js",
postinstall: "node ./postinstall.mjs",
},
version,
optionalDependencies,
-3
View File
@@ -115,9 +115,6 @@ export const RunCommand = {
})
const { providerID, modelID } = await Provider.defaultModel()
setTimeout(() => {
Session.abort(session.id)
}, 8000)
await Session.chat({
sessionID: session.id,
providerID,
+192
View File
@@ -0,0 +1,192 @@
import type { Argv } from "yargs"
import { UI } from "../ui"
import { VERSION } from "../version"
import path from "path"
import fs from "fs/promises"
import os from "os"
import * as prompts from "@clack/prompts"
import { Global } from "../../global"
const API = "https://api.github.com/repos/sst/opencode"
interface Release {
tag_name: string
name: string
assets: Array<{
name: string
browser_download_url: string
}>
}
function asset(): string {
const platform = os.platform()
const arch = os.arch()
if (platform === "darwin") {
return arch === "arm64"
? "opencode-darwin-arm64.zip"
: "opencode-darwin-x64.zip"
}
if (platform === "linux") {
return arch === "arm64"
? "opencode-linux-arm64.zip"
: "opencode-linux-x64.zip"
}
if (platform === "win32") {
return "opencode-windows-x64.zip"
}
throw new Error(`Unsupported platform: ${platform}-${arch}`)
}
function compare(current: string, latest: string): number {
const a = current.replace(/^v/, "")
const b = latest.replace(/^v/, "")
const aParts = a.split(".").map(Number)
const bParts = b.split(".").map(Number)
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const aPart = aParts[i] || 0
const bPart = bParts[i] || 0
if (aPart < bPart) return -1
if (aPart > bPart) return 1
}
return 0
}
async function latest(): Promise<Release> {
const response = await fetch(`${API}/releases/latest`)
if (!response.ok) {
throw new Error(`Failed to fetch latest release: ${response.statusText}`)
}
return response.json()
}
async function specific(version: string): Promise<Release> {
const tag = version.startsWith("v") ? version : `v${version}`
const response = await fetch(`${API}/releases/tags/${tag}`)
if (!response.ok) {
throw new Error(`Failed to fetch release ${tag}: ${response.statusText}`)
}
return response.json()
}
async function download(url: string): Promise<string> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to download: ${response.statusText}`)
}
const buffer = await response.arrayBuffer()
const temp = path.join(Global.Path.cache, `opencode-update-${Date.now()}.zip`)
await Bun.write(temp, buffer)
const extractDir = path.join(
Global.Path.cache,
`opencode-extract-${Date.now()}`,
)
await fs.mkdir(extractDir, { recursive: true })
const proc = Bun.spawn(["unzip", "-o", temp, "-d", extractDir], {
stdout: "pipe",
stderr: "pipe",
})
const result = await proc.exited
if (result !== 0) {
throw new Error("Failed to extract update")
}
await fs.unlink(temp)
const binary = path.join(extractDir, "opencode")
await fs.chmod(binary, 0o755)
return binary
}
export const UpgradeCommand = {
command: "upgrade [target]",
describe: "Upgrade opencode to the latest version or a specific version",
builder: (yargs: Argv) => {
return yargs.positional("target", {
describe: "Specific version to upgrade to (e.g., '0.1.48' or 'v0.1.48')",
type: "string",
})
},
handler: async (args: { target?: string }) => {
UI.empty()
UI.println(UI.logo(" "))
UI.empty()
prompts.intro("Upgrade")
if (!process.execPath.includes(path.join(".opencode", "bin")) && false) {
prompts.log.error(
`opencode is installed to ${process.execPath} and seems to be managed by a package manager`,
)
prompts.outro("Done")
return
}
const release = args.target
? await specific(args.target).catch(() => {})
: await latest().catch(() => {})
if (!release) {
prompts.log.error("Failed to fetch release information")
prompts.outro("Done")
return
}
const target = release.tag_name
if (VERSION !== "dev" && compare(VERSION, target) >= 0) {
prompts.log.success(`Already up to date`)
prompts.outro("Done")
return
}
prompts.log.info(`From ${VERSION}${target}`)
const name = asset()
const found = release.assets.find((a) => a.name === name)
if (!found) {
prompts.log.error(`No binary found for platform: ${name}`)
prompts.outro("Done")
return
}
const spinner = prompts.spinner()
spinner.start("Downloading update...")
const downloadPath = await download(found.browser_download_url).catch(
() => {},
)
if (!downloadPath) {
spinner.stop("Download failed")
prompts.log.error("Download failed")
prompts.outro("Done")
return
}
spinner.stop("Download complete")
const renamed = await fs
.rename(downloadPath, process.execPath)
.catch(() => {})
if (renamed === undefined) {
prompts.log.error("Install failed")
await fs.unlink(downloadPath).catch(() => {})
prompts.outro("Done")
return
}
prompts.log.success(`Successfully upgraded to ${target}`)
prompts.outro("Done")
},
}
-193
View File
@@ -1,193 +0,0 @@
import { createCli, type TrpcCliMeta } from "trpc-cli"
import { initTRPC } from "@trpc/server"
import { z } from "zod"
import { Server } from "../server/server"
import { AuthAnthropic } from "../auth/anthropic"
import { UI } from "./ui"
import { App } from "../app/app"
import { Bus } from "../bus"
import { Provider } from "../provider/provider"
import { Session } from "../session"
import { Share } from "../share/share"
import { Message } from "../session/message"
import { VERSION } from "./version"
import { LSP } from "../lsp"
import fs from "fs/promises"
import path from "path"
const t = initTRPC.meta<TrpcCliMeta>().create()
export const router = t.router({
generate: t.procedure
.meta({
description: "Generate OpenAPI and event specs",
})
.input(z.object({}))
.mutation(async () => {
const specs = await Server.openapi()
const dir = "gen"
await fs.rmdir(dir, { recursive: true }).catch(() => {})
await fs.mkdir(dir, { recursive: true })
await Bun.write(
path.join(dir, "openapi.json"),
JSON.stringify(specs, null, 2),
)
return "Generated OpenAPI specs in gen/ directory"
}),
run: t.procedure
.meta({
description: "Run OpenCode with a message",
})
.input(
z.object({
message: z.array(z.string()).default([]).describe("Message to send"),
session: z.string().optional().describe("Session ID to continue"),
}),
)
.mutation(
async ({ input }: { input: { message: string[]; session?: string } }) => {
const message = input.message.join(" ")
await App.provide(
{
cwd: process.cwd(),
version: "0.0.0",
},
async () => {
await Share.init()
const session = input.session
? await Session.get(input.session)
: await Session.create()
UI.println(UI.Style.TEXT_HIGHLIGHT_BOLD + "◍ OpenCode", VERSION)
UI.empty()
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
UI.empty()
UI.println(
UI.Style.TEXT_INFO_BOLD +
"~ https://dev.opencode.ai/s?id=" +
session.id.slice(-8),
)
UI.empty()
function printEvent(color: string, type: string, title: string) {
UI.println(
color + `|`,
UI.Style.TEXT_NORMAL +
UI.Style.TEXT_DIM +
` ${type.padEnd(7, " ")}`,
"",
UI.Style.TEXT_NORMAL + title,
)
}
Bus.subscribe(Message.Event.PartUpdated, async (message) => {
const part = message.properties.part
if (
part.type === "tool-invocation" &&
part.toolInvocation.state === "result"
) {
if (part.toolInvocation.toolName === "opencode_todowrite")
return
const args = part.toolInvocation.args as any
const tool = part.toolInvocation.toolName
if (tool === "opencode_edit")
printEvent(UI.Style.TEXT_SUCCESS_BOLD, "Edit", args.filePath)
if (tool === "opencode_bash")
printEvent(
UI.Style.TEXT_WARNING_BOLD,
"Execute",
args.command,
)
if (tool === "opencode_read")
printEvent(UI.Style.TEXT_INFO_BOLD, "Read", args.filePath)
if (tool === "opencode_write")
printEvent(
UI.Style.TEXT_SUCCESS_BOLD,
"Create",
args.filePath,
)
if (tool === "opencode_list")
printEvent(UI.Style.TEXT_INFO_BOLD, "List", args.path)
if (tool === "opencode_glob")
printEvent(
UI.Style.TEXT_INFO_BOLD,
"Glob",
args.pattern + (args.path ? " in " + args.path : ""),
)
}
if (part.type === "text") {
if (part.text.includes("\n")) {
UI.empty()
UI.println(part.text)
UI.empty()
return
}
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
}
})
const { providerID, modelID } = await Provider.defaultModel()
await Session.chat({
sessionID: session.id,
providerID,
modelID,
parts: [
{
type: "text",
text: message,
},
],
})
UI.empty()
},
)
return "Session completed"
},
),
scrap: t.procedure
.meta({
description: "Test command for scraping files",
})
.input(
z.object({
file: z.string().describe("File to process"),
}),
)
.mutation(async ({ input }: { input: { file: string } }) => {
await App.provide({ cwd: process.cwd(), version: VERSION }, async () => {
await LSP.touchFile(input.file, true)
await LSP.diagnostics()
})
return `Processed file: ${input.file}`
}),
login: t.router({
anthropic: t.procedure
.meta({
description: "Login to Anthropic",
})
.input(z.object({}))
.mutation(async () => {
const { url, verifier } = await AuthAnthropic.authorize()
UI.println("Login to Anthropic")
UI.println("Open the following URL in your browser:")
UI.println(url)
UI.println("")
const code = await UI.input("Paste the authorization code here: ")
await AuthAnthropic.exchange(code, verifier)
return "Successfully logged in to Anthropic"
}),
}),
})
export function createOpenCodeCli() {
return createCli({ router })
}
+3 -1
View File
@@ -13,6 +13,7 @@ import { VERSION } from "./cli/version"
import { ScrapCommand } from "./cli/cmd/scrap"
import { Log } from "./util/log"
import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth"
import { UpgradeCommand } from "./cli/cmd/upgrade"
import { Provider } from "./provider/provider"
import { UI } from "./cli/ui"
@@ -33,7 +34,7 @@ const cli = yargs(hideBin(process.argv))
.usage("\n" + UI.logo())
.command({
command: "$0 [project]",
describe: "Start OpenCode TUI",
describe: "Start opencode TUI",
builder: (yargs) =>
yargs.positional("project", {
type: "string",
@@ -102,6 +103,7 @@ const cli = yargs(hideBin(process.argv))
.command(GenerateCommand)
.command(ScrapCommand)
.command(AuthCommand)
.command(UpgradeCommand)
.fail((msg, err) => {
if (
msg.startsWith("Unknown argument") ||
+39 -23
View File
@@ -11,6 +11,7 @@ import { LANGUAGE_EXTENSIONS } from "./language"
import { Bus } from "../bus"
import z from "zod"
import type { LSPServer } from "./server"
import { NamedError } from "../util/error"
export namespace LSPClient {
const log = Log.create({ service: "lsp.client" })
@@ -19,6 +20,13 @@ export namespace LSPClient {
export type Diagnostic = VSCodeDiagnostic
export const InitializeError = NamedError.create(
"LSPInitializeError",
z.object({
serverID: z.string(),
}),
)
export const Event = {
Diagnostics: Bus.event(
"lsp.client.diagnostics",
@@ -52,32 +60,40 @@ export namespace LSPClient {
})
connection.listen()
await connection.sendRequest("initialize", {
processId: server.process.pid,
workspaceFolders: [
{
name: "workspace",
uri: "file://" + app.path.cwd,
},
],
initializationOptions: {
...server.initialization,
},
capabilities: {
workspace: {
configuration: true,
},
textDocument: {
synchronization: {
didOpen: true,
didChange: true,
log.info("sending initialize", { id: serverID })
await Promise.race([
connection.sendRequest("initialize", {
processId: server.process.pid,
workspaceFolders: [
{
name: "workspace",
uri: "file://" + app.path.cwd,
},
publishDiagnostics: {
versionSupport: true,
],
initializationOptions: {
...server.initialization,
},
capabilities: {
workspace: {
configuration: true,
},
textDocument: {
synchronization: {
didOpen: true,
didChange: true,
},
publishDiagnostics: {
versionSupport: true,
},
},
},
},
})
}),
new Promise((_, reject) => {
setTimeout(() => {
reject(new InitializeError({ serverID }))
}, 5_000)
}),
])
await connection.sendNotification("initialized", {})
log.info("initialized")
+12 -3
View File
@@ -12,9 +12,10 @@ export namespace LSP {
async () => {
log.info("initializing")
const clients = new Map<string, LSPClient.Info>()
const skip = new Set<string>()
return {
clients,
skip,
}
},
async (state) => {
@@ -31,11 +32,19 @@ export namespace LSP {
x.extensions.includes(extension),
)
for (const match of matches) {
if (s.skip.has(match.id)) continue
const existing = s.clients.get(match.id)
if (existing) continue
const handle = await match.spawn(App.info())
if (!handle) continue
const client = await LSPClient.create(match.id, handle)
if (!handle) {
s.skip.add(match.id)
continue
}
const client = await LSPClient.create(match.id, handle).catch(() => {})
if (!client) {
s.skip.add(match.id)
continue
}
s.clients.set(match.id, client)
}
if (waitForDiagnostics) {
+6 -3
View File
@@ -82,7 +82,7 @@ export namespace Provider {
>()
const sdk = new Map<string, SDK>()
log.info("loading")
log.info("init")
function mergeProvider(
id: string,
@@ -167,7 +167,7 @@ export namespace Provider {
}
for (const providerID of Object.keys(providers)) {
log.info("loaded", { providerID })
log.info("found", { providerID })
}
return {
@@ -183,6 +183,9 @@ export namespace Provider {
async function getSDK(providerID: string) {
return (async () => {
using _ = log.time("getSDK", {
providerID,
})
const s = await state()
const existing = s.sdk.get(providerID)
if (existing) return existing
@@ -202,7 +205,7 @@ export namespace Provider {
const s = await state()
if (s.models.has(key)) return s.models.get(key)!
log.info("loading", {
log.info("getModel", {
providerID,
modelID,
})
+1 -2
View File
@@ -41,8 +41,7 @@ export const ListTool = Tool.define({
const files = []
for await (const file of glob.scan({ cwd: searchPath, dot: true })) {
if (file.startsWith(".") || IGNORE_PATTERNS.some((p) => file.includes(p)))
continue
if (IGNORE_PATTERNS.some((p) => file.includes(p))) continue
if (params.ignore?.some((pattern) => new Bun.Glob(pattern).match(file)))
continue
files.push(file)
+17
View File
@@ -83,6 +83,23 @@ export namespace Log {
clone() {
return Log.create({ ...tags })
},
time(message: string, extra?: Record<string, any>) {
const now = Date.now()
result.info(message, { status: "started", ...extra })
function stop() {
result.info(message, {
status: "completed",
duration: Date.now() - now,
...extra,
})
}
return {
stop,
[Symbol.dispose]() {
stop()
},
}
},
}
return result
Binary file not shown.
@@ -124,11 +124,6 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return nil
}
}
case "ctrl+d":
if m.textarea.Value() != "" {
return m, nil
}
return m, tea.Quit
case "shift+enter":
value := m.textarea.Value()
m.textarea.SetValue(value + "\n")
@@ -263,8 +258,8 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *editorComponent) View() string {
t := theme.CurrentTheme()
base := styles.BaseStyle().Render
muted := styles.Muted().Render
base := styles.BaseStyle().Background(t.Background()).Render
muted := styles.Muted().Background(t.Background()).Render
promptStyle := lipgloss.NewStyle().
Padding(0, 0, 0, 1).
Bold(true).
@@ -286,7 +281,7 @@ func (m *editorComponent) View() string {
BorderBackground(t.Background()).
Render(textarea)
hint := base("enter") + muted(" send ") + base("shift") + muted("+") + base("enter") + muted(" newline")
hint := base("enter") + muted(" send ")
if m.app.IsBusy() {
hint = muted("working") + m.spinner.View() + muted(" ") + base("esc") + muted(" interrupt")
}
@@ -297,18 +292,12 @@ func (m *editorComponent) View() string {
}
space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
spacer := lipgloss.NewStyle().Width(space).Render("")
spacer := lipgloss.NewStyle().Background(t.Background()).Width(space).Render("")
info := lipgloss.JoinHorizontal(lipgloss.Left, hint, spacer, model)
info = styles.Padded().Render(info)
info := hint + spacer + model
info = styles.Padded().Background(t.Background()).Render(info)
content := lipgloss.JoinVertical(
lipgloss.Top,
// m.attachmentsContent(),
"",
textarea,
info,
)
content := strings.Join([]string{"", textarea, info}, "\n")
return content
}
@@ -2,7 +2,6 @@ package chat
import (
"fmt"
"log/slog"
"path/filepath"
"slices"
"strings"
@@ -131,8 +130,8 @@ func renderContentBlock(content string, options ...renderingOption) string {
}
style := styles.BaseStyle().
MarginTop(renderer.marginTop).
MarginBottom(renderer.marginBottom).
// MarginTop(renderer.marginTop).
// MarginBottom(renderer.marginBottom).
PaddingTop(renderer.paddingTop).
PaddingBottom(renderer.paddingBottom).
PaddingLeft(renderer.paddingLeft).
@@ -180,12 +179,25 @@ func renderContentBlock(content string, options ...renderingOption) string {
layout.Current.Container.Width,
align,
content,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
content = lipgloss.PlaceHorizontal(
layout.Current.Viewport.Width,
lipgloss.Center,
content,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
if renderer.marginTop > 0 {
for range renderer.marginTop {
content = "\n" + content
}
}
if renderer.marginBottom > 0 {
for range renderer.marginBottom {
content = content + "\n"
}
}
return content
}
@@ -208,18 +220,11 @@ func renderText(message client.MessageInfo, text string, author string) string {
}
info := fmt.Sprintf("%s (%s)", author, timestamp)
align := lipgloss.Left
switch message.Role {
case client.User:
align = lipgloss.Right
case client.Assistant:
align = lipgloss.Left
}
textWidth := max(lipgloss.Width(text), lipgloss.Width(info))
markdownWidth := min(textWidth, width-padding-4) // -4 for the border and padding
content := toMarkdown(text, markdownWidth, t.BackgroundSubtle())
content = lipgloss.JoinVertical(align, content, info)
content = strings.Join([]string{content, info}, "\n")
// content = lipgloss.JoinVertical(align, content, info)
switch message.Role {
case client.User:
@@ -268,6 +273,7 @@ func renderToolInvocation(
PaddingRight(2).
BorderLeft(true).
BorderRight(true).
BorderBackground(t.Background()).
BorderForeground(t.BackgroundSubtle()).
BorderStyle(lipgloss.ThickBorder())
@@ -292,10 +298,6 @@ func renderToolInvocation(
}
}
if len(toolArgsMap) == 0 {
slog.Debug("no args")
}
body := ""
error := ""
finished := result != nil && *result != ""
@@ -356,6 +358,7 @@ func renderToolInvocation(
formattedDiff = strings.TrimSpace(formattedDiff)
formattedDiff = lipgloss.NewStyle().
BorderStyle(lipgloss.ThickBorder()).
BorderBackground(t.Background()).
BorderForeground(t.BackgroundSubtle()).
BorderLeft(true).
BorderRight(true).
@@ -373,6 +376,7 @@ func renderToolInvocation(
lipgloss.Center,
lipgloss.Top,
body,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
}
}
@@ -440,7 +444,12 @@ func renderToolInvocation(
}
content := style.Render(title)
content = lipgloss.PlaceHorizontal(layout.Current.Viewport.Width, lipgloss.Center, content)
content = lipgloss.PlaceHorizontal(
layout.Current.Viewport.Width,
lipgloss.Center,
content,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
if showResult && body != "" && error == "" {
content += "\n" + body
}
@@ -242,8 +242,8 @@ func (m *messagesComponent) header() string {
t := theme.CurrentTheme()
width := layout.Current.Container.Width
base := styles.BaseStyle().Render
muted := styles.Muted().Render
base := styles.BaseStyle().Background(t.Background()).Render
muted := styles.Muted().Background(t.Background()).Render
headerLines := []string{}
headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width-6, t.Background()))
if m.app.Session.Share != nil && m.app.Session.Share.Url != "" {
@@ -257,7 +257,7 @@ func (m *messagesComponent) header() string {
Width(width).
PaddingLeft(2).
PaddingRight(2).
// Background(t.BackgroundElement()).
Background(t.Background()).
BorderLeft(true).
BorderRight(true).
BorderBackground(t.Background()).
@@ -275,23 +275,25 @@ func (m *messagesComponent) View() string {
if m.rendering {
return m.viewport.View()
}
t := theme.CurrentTheme()
return lipgloss.JoinVertical(
lipgloss.Left,
lipgloss.PlaceHorizontal(m.width, lipgloss.Center, m.header()),
lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
m.header(),
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
),
m.viewport.View(),
)
}
func (m *messagesComponent) home() string {
// t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle().Background(t.Background())
base := baseStyle.Render
muted := styles.Muted().Render
muted := styles.Muted().Background(t.Background()).Render
// mark := `
// ███▀▀█
// ███ █
// ▀▀▀▀▀▀ `
open := `
█▀▀█ █▀▀█ █▀▀ █▀▀▄
█░░█ █░░█ █▀▀ █░░█
@@ -303,9 +305,8 @@ func (m *messagesComponent) home() string {
logo := lipgloss.JoinHorizontal(
lipgloss.Top,
// styles.BaseStyle().Foreground(t.Primary()).Render(mark),
styles.Muted().Render(open),
styles.BaseStyle().Render(code),
muted(open),
base(code),
)
// cwd := app.Info.Path.Cwd
// config := app.Info.Path.Config
@@ -321,7 +322,7 @@ func (m *messagesComponent) home() string {
commandLines := []string{}
for _, command := range commands {
commandLines = append(commandLines, (base(command[0]) + " " + muted(command[1])))
commandLines = append(commandLines, (base(command[0]+" ") + muted(command[1])))
}
logoAndVersion := lipgloss.JoinVertical(
@@ -341,18 +342,21 @@ func (m *messagesComponent) home() string {
lines = append(lines, commandLines...)
lines = append(lines, "")
if m.rendering {
lines = append(lines, styles.Muted().Render("Loading session..."))
lines = append(lines, base("Loading session..."))
} else {
lines = append(lines, "")
}
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
return lipgloss.Place(
m.width,
m.height,
lipgloss.Center,
lipgloss.Center,
baseStyle.Width(lipgloss.Width(logoAndVersion)).Render(
lipgloss.JoinVertical(
lipgloss.Top,
lines...,
),
))
strings.Join(lines, "\n"),
),
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
}
func (m *messagesComponent) SetSize(width, height int) tea.Cmd {
@@ -140,14 +140,15 @@ func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) st
}
func (m statusComponent) View() string {
t := theme.CurrentTheme()
if m.app.Session.Id == "" {
return styles.BaseStyle().
Background(t.Background()).
Width(m.width).
Height(2).
Render("")
}
t := theme.CurrentTheme()
logo := logo()
cwd := styles.Padded().
@@ -227,6 +227,7 @@ func (c *completionDialogComponent) View() string {
BorderBottom(false).
BorderRight(true).
BorderLeft(true).
BorderBackground(t.Background()).
BorderForeground(t.BackgroundSubtle()).
Width(c.width).
Render(c.list.View())
@@ -1,139 +0,0 @@
package dialog
import (
"strings"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
const question = "Are you sure you want to quit?"
// QuitDialog interface for the quit confirmation dialog
type QuitDialog interface {
layout.Modal
IsQuitDialog() bool
}
type quitDialog struct {
width int
height int
modal *modal.Modal
selectedNo bool
}
type helpMapping struct {
LeftRight key.Binding
EnterSpace key.Binding
Yes key.Binding
No key.Binding
}
var helpKeys = helpMapping{
LeftRight: key.NewBinding(
key.WithKeys("left", "right", "h", "l", "tab"),
key.WithHelp("←/→", "switch options"),
),
EnterSpace: key.NewBinding(
key.WithKeys("enter", " "),
key.WithHelp("enter/space", "confirm"),
),
Yes: key.NewBinding(
key.WithKeys("y", "Y", "ctrl+c"),
key.WithHelp("y/Y", "yes"),
),
No: key.NewBinding(
key.WithKeys("n", "N"),
key.WithHelp("n/N", "no"),
),
}
func (q *quitDialog) Init() tea.Cmd {
return nil
}
func (q *quitDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
q.width = msg.Width
q.height = msg.Height
case tea.KeyMsg:
switch {
case key.Matches(msg, helpKeys.LeftRight):
q.selectedNo = !q.selectedNo
return q, nil
case key.Matches(msg, helpKeys.EnterSpace):
if !q.selectedNo {
return q, tea.Quit
}
return q, util.CmdHandler(modal.CloseModalMsg{})
case key.Matches(msg, helpKeys.Yes):
return q, tea.Quit
case key.Matches(msg, helpKeys.No):
return q, util.CmdHandler(modal.CloseModalMsg{})
}
}
return q, nil
}
func (q *quitDialog) Render(background string) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
yesStyle := baseStyle
noStyle := baseStyle
spacerStyle := baseStyle.Background(t.BackgroundElement())
if q.selectedNo {
noStyle = noStyle.Background(t.Primary()).Foreground(t.BackgroundElement())
yesStyle = yesStyle.Background(t.BackgroundElement()).Foreground(t.Primary())
} else {
yesStyle = yesStyle.Background(t.Primary()).Foreground(t.BackgroundElement())
noStyle = noStyle.Background(t.BackgroundElement()).Foreground(t.Primary())
}
yesButton := yesStyle.Padding(0, 1).Render("Yes")
noButton := noStyle.Padding(0, 1).Render("No")
buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, spacerStyle.Render(" "), noButton)
width := lipgloss.Width(question)
remainingWidth := width - lipgloss.Width(buttons)
if remainingWidth > 0 {
buttons = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + buttons
}
content := baseStyle.Render(
lipgloss.JoinVertical(
lipgloss.Center,
question,
"",
buttons,
),
)
return q.modal.Render(content, background)
}
func (q *quitDialog) Close() tea.Cmd {
return nil
}
func (q *quitDialog) IsQuitDialog() bool {
return true
}
// NewQuitDialog creates a new quit confirmation dialog
func NewQuitDialog() QuitDialog {
return &quitDialog{
selectedNo: true,
modal: modal.New(),
}
}
+11 -7
View File
@@ -3,6 +3,7 @@ package layout
import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/theme"
)
type FlexDirection int
@@ -76,6 +77,7 @@ func (f *flexLayout) View() string {
return ""
}
t := theme.CurrentTheme()
views := make([]string, 0, len(f.panes))
for i, pane := range f.panes {
if pane == nil {
@@ -89,6 +91,7 @@ func (f *flexLayout) View() string {
paneWidth,
pane.Alignment(),
pane.View(),
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
views = append(views, view)
} else {
@@ -99,6 +102,7 @@ func (f *flexLayout) View() string {
lipgloss.Center,
pane.Alignment(),
pane.View(),
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
views = append(views, view)
}
@@ -160,14 +164,14 @@ func (f *flexLayout) SetSize(width, height int) tea.Cmd {
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)
@@ -178,7 +182,7 @@ func (f *flexLayout) SetSize(width, height int) tea.Cmd {
if pane.MaxWidth() > 0 && contentWidth > pane.MaxWidth() {
contentWidth = pane.MaxWidth()
}
switch pane.Alignment() {
case lipgloss.Center:
actualX = (f.width - contentWidth) / 2
@@ -188,16 +192,16 @@ func (f *flexLayout) SetSize(width, height int) tea.Cmd {
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