Compare commits

..

70 Commits

Author SHA1 Message Date
Mike Wallio db24bf87c0 Fix undefined is not an object (evaluating 'G.title') (#395) 2025-06-25 19:40:09 -04:00
Juhani Pelli f4c0d2d2fd fix: guard against large output limit causing infinite summarize loop (#399) 2025-06-25 19:39:51 -04:00
Dax Raad d240f4c676 more edit tool fixes 2025-06-25 19:22:54 -04:00
Dax Raad 9c90cdbe08 integrate gemini-cli strategies for edit tool 2025-06-25 17:56:14 -04:00
Dax Raad fc7af31fe5 make edit tool more robust 2025-06-25 17:10:48 -04:00
Dax Raad 2f8d23ec66 ignore: typo 2025-06-25 11:02:57 -04:00
Dax Raad 77ae3fb9b9 add output length errors 2025-06-25 11:02:09 -04:00
Dax Raad 4e7f6c47fd fix codex not working 2025-06-25 10:01:35 -04:00
Dax Raad 50469ed750 redirect uncaught errors to log file 2025-06-25 08:41:10 -04:00
Dax Raad aaab785493 better error message when bad directory is specified to start in 2025-06-24 22:28:25 -04:00
Dax Raad 9751937894 Enhance auth command with environment variable display and add models command
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-24 22:24:55 -04:00
Dax Raad 0fc8dfc77e do not print error on ctrl+c during prompts 2025-06-24 22:09:43 -04:00
Dax Raad 81b7df61ec ci: bun lock 2025-06-24 21:14:32 -04:00
Dax Raad 8217b96d4a ci: fix type issue 2025-06-24 21:12:32 -04:00
Dax Raad 7dd0918d32 remove accidental opanai autoloader 2025-06-24 21:11:11 -04:00
Dax Raad 4b26b43855 added opencode serve command 2025-06-24 20:52:09 -04:00
Jay V 9d7cfda9fe docs: share page styles 2025-06-24 19:34:35 -04:00
Jay V a3cf18c905 docs: share page bash tool output 2025-06-24 19:28:51 -04:00
Aiden Cline 0b1a8ae699 fix: file completions replaced wrong text when paths overlap (#378) 2025-06-24 18:13:15 -05:00
Dax Raad eb70b1e5c8 docs: windows instructions 2025-06-24 18:54:59 -04:00
Dax Raad 00a3d818b6 ci: windows 2025-06-24 18:46:43 -04:00
Dax Raad 2384c7e734 ci: windows 2025-06-24 18:40:36 -04:00
Dax Raad 1bad3d9894 ci: windows 2025-06-24 18:27:57 -04:00
Dax Raad 4f715e66dc ci: windows 2025-06-24 18:13:15 -04:00
Dax ec001ca02f windows fixes (#374)
Co-authored-by: Matthew Glazar <strager.nds@gmail.com>
2025-06-24 18:05:04 -04:00
Jay a2d3b9f0c8 docs: Share page diff view improvements (#373) 2025-06-24 17:11:43 -04:00
Dax Raad 9cfb6ff964 ignore: revert 2025-06-24 14:59:27 -04:00
Dax Raad 6ed661c140 ci: upgrade bun 2025-06-24 14:42:25 -04:00
Dax Raad 9dc00edfc9 potential fix for failing to install provider package on first run 2025-06-24 14:33:35 -04:00
Jay V e063bf888e docs: share code blocks in markdown 2025-06-24 13:53:59 -04:00
Adam 6f18475428 feat: delete sessions (#362)
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-24 11:07:41 -05:00
Dax Raad 3664b09812 remove debug code writing to /tmp/message.json 2025-06-24 11:16:17 -04:00
Dax Raad 7050cc0ac3 ignore: fix type errors 2025-06-24 11:09:36 -04:00
Dax Raad 4d3d63294d externalize github copilot code 2025-06-24 10:42:19 -04:00
Tom 6bc61cbc2d feat(tui): add debounce logic to escape key interrupt (#169)
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-24 06:31:02 -05:00
Dax Raad 01d351bebe add HOMEBREW_NO_AUTO_UPDATE to brew upgrades 2025-06-23 20:36:08 -04:00
Dax Raad dbba4a97aa force use npm registry 2025-06-23 20:23:37 -04:00
GitMurf 0dc586faef fix: typescript error (any) from models (#347) 2025-06-23 18:44:57 -04:00
Dax Raad f19c6b05f2 glob tool should respect .gitignore 2025-06-23 17:37:32 -04:00
Dax Raad bc34f08333 bundle models.dev at build time and ignore refresh errors 2025-06-23 14:50:19 -04:00
Dax Raad b7ee16aabd ignore: remove opencode.json 2025-06-23 14:32:57 -04:00
Lucas Grzegorczyk ed1b0d97bf Fix project folder name starting with "-" in data (#323). Note old session data will still be in the old format in ~/.local/share/opencode/projects - you can remove the leading dash to recover the, 2025-06-23 14:31:51 -04:00
adamdottv 8d3b2fb821 feat(tui): optimistically render user messages 2025-06-23 12:30:20 -05:00
Jay V fa991920bc fix help copy 2025-06-23 13:00:24 -04:00
adamdottv 5e79e3d7a5 fix(tui): less incorrect escapingn of < and > 2025-06-23 11:32:32 -05:00
adamdottv 966015c9ae fix: overlay border color issues 2025-06-23 11:21:49 -05:00
adamdottv 61f057337a fix: markdown wrapping issue 2025-06-23 11:20:44 -05:00
adamdottv 0b261054a2 chore: unused import 2025-06-23 10:21:57 -05:00
adamdottv e2e481cbb5 docs: disabled_providers 2025-06-23 10:21:25 -05:00
GitMurf 5140e83012 feat(copilot): edit headers for better rate limit avoidance (#321) 2025-06-23 10:44:19 -04:00
Dax Raad 100d6212be more graceful mcp failures 2025-06-22 21:10:05 -04:00
Dax Raad f0e19a6542 aws autoload include more env vars 2025-06-22 20:16:10 -04:00
Dax Raad 00c4d4f9f8 fix double entry of github copilot in auth login 2025-06-22 19:13:25 -04:00
Martin Palma 6e6fe6e013 Add Github Copilot OAuth authentication flow (#305) 2025-06-22 19:11:37 -04:00
Dax Raad d05b60291e docs: contributing 2025-06-22 17:55:10 -04:00
adamdottv 5162361372 fix(tui): color contrast fixes for nord 2025-06-22 15:17:18 -05:00
adamdottv d271b9f75b fix(tui): help dialog visuals 2025-06-22 14:28:16 -05:00
Márk Magyar 333569bed3 ignore: fix typos and formatting (#294) 2025-06-22 14:26:46 -04:00
Tom 09b89fdb23 fix: resolve test failures by adding missing zod-openapi import (#301)
Co-authored-by: opencode <noreply@opencode.ai>
2025-06-22 14:25:02 -04:00
Tom 0e8c3359d1 combine stdout and stderr in bash tool output (#300)
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: Dax Raad <d@ironbay.co>
2025-06-22 14:24:35 -04:00
Adam 37e0a7050f fix(tui): mouse wheel escape codes leaking into input 2025-06-22 10:26:44 -05:00
adamdottv 774dcb6980 fix(tui): cleanup help dialog 2025-06-22 06:44:23 -05:00
phantomreactor 28bc49ad17 fix: invisible html tags and compact long delay (#304) 2025-06-22 06:29:04 -05:00
adamdottv dc1947838c fix(tui): cleanup modal visuals 2025-06-22 06:09:23 -05:00
adamdottv 3ea2daaa4c fix(tui): theme dialog visuals 2025-06-22 05:34:22 -05:00
Márk Magyar 137e964131 fix: session title generation (#293) 2025-06-21 14:32:11 -05:00
tyrellshawn 8efbe497fd Created a Theme inspired by the matrix (#285) 2025-06-21 07:29:49 -05:00
Thomas Meire 119d2d966c Add error handling on the calls to the server to debug issue #132 (#137) 2025-06-21 07:24:39 -05:00
Dax Raad 194415e785 footer clarifies it's showing context usage, not input token usage 2025-06-20 22:52:51 -04:00
Dax Raad 1684042fb6 huge optimization for token usage with anthropic 2025-06-20 22:43:04 -04:00
72 changed files with 3280 additions and 884 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
- uses: oven-sh/setup-bun@v1 - uses: oven-sh/setup-bun@v1
with: with:
bun-version: latest bun-version: 1.2.17
- run: bun install - run: bun install
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
- uses: oven-sh/setup-bun@v2 - uses: oven-sh/setup-bun@v2
with: with:
bun-version: 1.2.16 bun-version: 1.2.17
- name: Install makepkg - name: Install makepkg
run: | run: |
+2
View File
@@ -38,6 +38,8 @@ For more info on how to configure opencode [**head over to our docs**](https://o
### Contributing ### Contributing
For any new features we'd appreciate it if you could open an issue first to discuss what you'd like to implement. We're pretty responsive there and it'll save you from working on something that we don't end up using. No need to do this for simpler fixes.
To run opencode locally you need. To run opencode locally you need.
- Bun - Bun
+5 -2
View File
@@ -20,6 +20,9 @@
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "0.0.0", "version": "0.0.0",
"bin": {
"opencode": "./bin/opencode",
},
"dependencies": { "dependencies": {
"@clack/prompts": "0.11.0", "@clack/prompts": "0.11.0",
"@flystorage/file-storage": "1.1.0", "@flystorage/file-storage": "1.1.0",
@@ -458,7 +461,7 @@
"@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="], "@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="],
"@types/bun": ["@types/bun@1.2.16", "", { "dependencies": { "bun-types": "1.2.16" } }, "sha512-1aCZJ/6nSiViw339RsaNhkNoEloLaPzZhxMOYEa7OzRzO41IGg5n/7I43/ZIAW/c+Q6cT12Vf7fOZOoVIzb5BQ=="], "@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
@@ -596,7 +599,7 @@
"buffer": ["buffer@4.9.2", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4", "isarray": "^1.0.0" } }, "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg=="], "buffer": ["buffer@4.9.2", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4", "isarray": "^1.0.0" } }, "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg=="],
"bun-types": ["bun-types@1.2.16", "", { "dependencies": { "@types/node": "*" } }, "sha512-ciXLrHV4PXax9vHvUrkvun9VPVGOVwbbbBF/Ev1cXz12lyEZMoJpIJABOfPcN9gDJRaiKF9MVbSygLg4NXu3/A=="], "bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
+56
View File
@@ -0,0 +1,56 @@
@echo off
setlocal enabledelayedexpansion
if defined OPENCODE_BIN_PATH (
set "resolved=%OPENCODE_BIN_PATH%"
goto :execute
)
rem Get the directory of this script
set "script_dir=%~dp0"
set "script_dir=%script_dir:~0,-1%"
rem Detect platform and architecture
set "platform=win32"
rem Detect architecture
if "%PROCESSOR_ARCHITECTURE%"=="AMD64" (
set "arch=x64"
) else if "%PROCESSOR_ARCHITECTURE%"=="ARM64" (
set "arch=arm64"
) else if "%PROCESSOR_ARCHITECTURE%"=="x86" (
set "arch=x86"
) else (
set "arch=x64"
)
set "name=opencode-!platform!-!arch!"
set "binary=opencode.exe"
rem Search for the binary starting from script location
set "resolved="
set "current_dir=%script_dir%"
:search_loop
set "candidate=%current_dir%\node_modules\%name%\bin\%binary%"
if exist "%candidate%" (
set "resolved=%candidate%"
goto :execute
)
rem Move up one directory
for %%i in ("%current_dir%") do set "parent_dir=%%~dpi"
set "parent_dir=%parent_dir:~0,-1%"
rem Check if we've reached the root
if "%current_dir%"=="%parent_dir%" goto :not_found
set "current_dir=%parent_dir%"
goto :search_loop
:not_found
echo It seems that your package manager failed to install the right version of the OpenCode CLI for your platform. You can try manually installing the "%name%" package >&2
exit /b 1
:execute
rem Execute the binary with all arguments
"%resolved%" %*
+4 -2
View File
@@ -1,13 +1,15 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "0.0.0", "version": "0.0.5", "name": "opencode",
"name": "opencode",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"dev": "bun run ./src/index.ts" "dev": "bun run ./src/index.ts"
}, },
"bin": {
"opencode": "./bin/opencode"
},
"exports": { "exports": {
"./*": "./src/*.ts" "./*": "./src/*.ts"
}, },
+1 -1
View File
@@ -29,7 +29,7 @@ const targets = [
["linux", "x64"], ["linux", "x64"],
["darwin", "x64"], ["darwin", "x64"],
["darwin", "arm64"], ["darwin", "arm64"],
// ["windows", "x64"], ["windows", "x64"],
] ]
await $`rm -rf dist` await $`rm -rf dist`
+11 -1
View File
@@ -1,3 +1,4 @@
import "zod-openapi/extend"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Context } from "../util/context" import { Context } from "../util/context"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
@@ -45,7 +46,7 @@ export namespace App {
const data = path.join( const data = path.join(
Global.Path.data, Global.Path.data,
"project", "project",
git ? git.split(path.sep).join("-") : "global", git ? directory(git) : "global",
) )
const stateFile = Bun.file(path.join(data, APP_JSON)) const stateFile = Bun.file(path.join(data, APP_JSON))
const state = (await stateFile.json().catch(() => ({}))) as { const state = (await stateFile.json().catch(() => ({}))) as {
@@ -132,4 +133,13 @@ export namespace App {
}), }),
) )
} }
function directory(input: string): string {
return input
.split(path.sep)
.filter(Boolean)
.join("-")
.replace(/[^A-Za-z0-9_]/g, "-")
}
} }
+20
View File
@@ -0,0 +1,20 @@
import { Global } from "../global"
import { lazy } from "../util/lazy"
import path from "path"
export const AuthCopilot = lazy(async () => {
const file = Bun.file(path.join(Global.Path.state, "plugin", "copilot.ts"))
const response = fetch(
"https://raw.githubusercontent.com/sst/opencode-github-copilot/refs/heads/main/auth.ts",
)
.then((x) => Bun.write(file, x))
.catch(() => {})
if (!file.exists()) {
const worked = await response
if (!worked) return
}
const result = await import(file.name!).catch(() => {})
if (!result) return
return result.AuthCopilot
})
@@ -0,0 +1,150 @@
import { z } from "zod"
import { Auth } from "./index"
import { NamedError } from "../util/error"
export namespace AuthGithubCopilot {
const CLIENT_ID = "Iv1.b507a08c87ecfe98"
const DEVICE_CODE_URL = "https://github.com/login/device/code"
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
const COPILOT_API_KEY_URL = "https://api.github.com/copilot_internal/v2/token"
interface DeviceCodeResponse {
device_code: string
user_code: string
verification_uri: string
expires_in: number
interval: number
}
interface AccessTokenResponse {
access_token?: string
error?: string
error_description?: string
}
interface CopilotTokenResponse {
token: string
expires_at: number
refresh_in: number
endpoints: {
api: string
}
}
export async function authorize() {
const deviceResponse = await fetch(DEVICE_CODE_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "GitHubCopilotChat/0.26.7",
},
body: JSON.stringify({
client_id: CLIENT_ID,
scope: "read:user",
}),
})
const deviceData: DeviceCodeResponse = await deviceResponse.json()
return {
device: deviceData.device_code,
user: deviceData.user_code,
verification: deviceData.verification_uri,
interval: deviceData.interval || 5,
expiry: deviceData.expires_in,
}
}
export async function poll(device_code: string) {
const response = await fetch(ACCESS_TOKEN_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "GitHubCopilotChat/0.26.7",
},
body: JSON.stringify({
client_id: CLIENT_ID,
device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
})
if (!response.ok) return "failed"
const data: AccessTokenResponse = await response.json()
if (data.access_token) {
// Store the GitHub OAuth token
await Auth.set("github-copilot", {
type: "oauth",
refresh: data.access_token,
access: "",
expires: 0,
})
return "complete"
}
if (data.error === "authorization_pending") return "pending"
if (data.error) return "failed"
return "pending"
}
export async function access() {
const info = await Auth.get("github-copilot")
if (!info || info.type !== "oauth") return
if (info.access && info.expires > Date.now()) return info.access
// Get new Copilot API token
const response = await fetch(COPILOT_API_KEY_URL, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${info.refresh}`,
"User-Agent": "GitHubCopilotChat/0.26.7",
"Editor-Version": "vscode/1.99.3",
"Editor-Plugin-Version": "copilot-chat/0.26.7",
},
})
if (!response.ok) return
const tokenData: CopilotTokenResponse = await response.json()
// Store the Copilot API token
await Auth.set("github-copilot", {
type: "oauth",
refresh: info.refresh,
access: tokenData.token,
expires: tokenData.expires_at * 1000,
})
return tokenData.token
}
export const DeviceCodeError = NamedError.create(
"DeviceCodeError",
z.object({}),
)
export const TokenExchangeError = NamedError.create(
"TokenExchangeError",
z.object({
message: z.string(),
}),
)
export const AuthenticationError = NamedError.create(
"AuthenticationError",
z.object({
message: z.string(),
}),
)
export const CopilotTokenError = NamedError.create(
"CopilotTokenError",
z.object({
message: z.string(),
}),
)
}
+11 -2
View File
@@ -13,7 +13,7 @@ export namespace BunProc {
) { ) {
log.info("running", { log.info("running", {
cmd: [which(), ...cmd], cmd: [which(), ...cmd],
options, ...options,
}) })
const result = Bun.spawn([which(), ...cmd], { const result = Bun.spawn([which(), ...cmd], {
...options, ...options,
@@ -26,6 +26,15 @@ export namespace BunProc {
}, },
}) })
const code = await result.exited const code = await result.exited
// @ts-ignore
const stdout = await result.stdout.text()
// @ts-ignore
const stderr = await result.stderr.text()
log.info("done", {
code,
stdout,
stderr,
})
if (code !== 0) { if (code !== 0) {
throw new Error(`Command failed with exit code ${result.exitCode}`) throw new Error(`Command failed with exit code ${result.exitCode}`)
} }
@@ -53,7 +62,7 @@ export namespace BunProc {
if (parsed.dependencies[pkg] === version) return mod if (parsed.dependencies[pkg] === version) return mod
parsed.dependencies[pkg] = version parsed.dependencies[pkg] = version
await Bun.write(pkgjson, JSON.stringify(parsed, null, 2)) await Bun.write(pkgjson, JSON.stringify(parsed, null, 2))
await BunProc.run(["install"], { await BunProc.run(["install", "--registry=https://registry.npmjs.org"], {
cwd: Global.Path.cache, cwd: Global.Path.cache,
}).catch((e) => { }).catch((e) => {
new InstallFailedError( new InstallFailedError(
+81 -8
View File
@@ -1,4 +1,5 @@
import { AuthAnthropic } from "../../auth/anthropic" import { AuthAnthropic } from "../../auth/anthropic"
import { AuthCopilot } from "../../auth/copilot"
import { Auth } from "../../auth" import { Auth } from "../../auth"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import * as prompts from "@clack/prompts" import * as prompts from "@clack/prompts"
@@ -6,17 +7,20 @@ import open from "open"
import { UI } from "../ui" import { UI } from "../ui"
import { ModelsDev } from "../../provider/models" import { ModelsDev } from "../../provider/models"
import { map, pipe, sortBy, values } from "remeda" import { map, pipe, sortBy, values } from "remeda"
import path from "path"
import os from "os"
import { Global } from "../../global"
export const AuthCommand = cmd({ export const AuthCommand = cmd({
command: "auth", command: "auth",
describe: "Manage credentials", describe: "manage credentials",
builder: (yargs) => builder: (yargs) =>
yargs yargs
.command(AuthLoginCommand) .command(AuthLoginCommand)
.command(AuthLogoutCommand) .command(AuthLogoutCommand)
.command(AuthListCommand) .command(AuthListCommand)
.demandCommand(), .demandCommand(),
async handler() { }, async handler() {},
}) })
export const AuthListCommand = cmd({ export const AuthListCommand = cmd({
@@ -25,30 +29,61 @@ export const AuthListCommand = cmd({
describe: "list providers", describe: "list providers",
async handler() { async handler() {
UI.empty() UI.empty()
prompts.intro("Credentials") const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir)
? authPath.replace(homedir, "~")
: authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = await Auth.all().then((x) => Object.entries(x)) const results = await Auth.all().then((x) => Object.entries(x))
const database = await ModelsDev.get() const database = await ModelsDev.get()
for (const [providerID, result] of results) { for (const [providerID, result] of results) {
const name = database[providerID]?.name || providerID const name = database[providerID]?.name || providerID
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}(${result.type})`) prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
} }
prompts.outro(`${results.length} credentials`) prompts.outro(`${results.length} credentials`)
// Environment variables section
const activeEnvVars: Array<{ provider: string, envVar: string }> = []
for (const [providerID, provider] of Object.entries(database)) {
for (const envVar of provider.env) {
if (process.env[envVar]) {
activeEnvVars.push({
provider: provider.name || providerID,
envVar
})
}
}
}
if (activeEnvVars.length > 0) {
UI.empty()
prompts.intro("Environment")
for (const { provider, envVar } of activeEnvVars) {
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
}
prompts.outro(`${activeEnvVars.length} environment variables`)
}
}, },
}) })
export const AuthLoginCommand = cmd({ export const AuthLoginCommand = cmd({
command: "login", command: "login",
describe: "login to a provider", describe: "log in to a provider",
async handler() { async handler() {
UI.empty() UI.empty()
prompts.intro("Add credential") prompts.intro("Add credential")
const providers = await ModelsDev.get() const providers = await ModelsDev.get()
const priority: Record<string, number> = { const priority: Record<string, number> = {
anthropic: 0, anthropic: 0,
openai: 1, "github-copilot": 1,
google: 2, openai: 2,
google: 3,
} }
let provider = await prompts.select({ let provider = await prompts.select({
message: "Select provider", message: "Select provider",
@@ -146,6 +181,44 @@ export const AuthLoginCommand = cmd({
} }
} }
const copilot = await AuthCopilot()
if (provider === "github-copilot" && copilot) {
await new Promise((resolve) => setTimeout(resolve, 10))
const deviceInfo = await copilot.authorize()
prompts.note(
`Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`,
)
const spinner = prompts.spinner()
spinner.start("Waiting for authorization...")
while (true) {
await new Promise((resolve) =>
setTimeout(resolve, deviceInfo.interval * 1000),
)
const response = await copilot.poll(deviceInfo.device)
if (response.status === "pending") continue
if (response.status === "success") {
await Auth.set("github-copilot", {
type: "oauth",
refresh: response.refresh,
access: response.access,
expires: response.expires,
})
spinner.stop("Login successful")
break
}
if (response.status === "failed") {
spinner.stop("Failed to authorize", 1)
break
}
}
prompts.outro("Done")
return
}
const key = await prompts.password({ const key = await prompts.password({
message: "Enter your API key", message: "Enter your API key",
validate: (x) => (x.length > 0 ? undefined : "Required"), validate: (x) => (x.length > 0 ? undefined : "Required"),
@@ -162,7 +235,7 @@ export const AuthLoginCommand = cmd({
export const AuthLogoutCommand = cmd({ export const AuthLogoutCommand = cmd({
command: "logout", command: "logout",
describe: "logout from a configured provider", describe: "log out from a configured provider",
async handler() { async handler() {
UI.empty() UI.empty()
const credentials = await Auth.all().then((x) => Object.entries(x)) const credentials = await Auth.all().then((x) => Object.entries(x))
@@ -1,20 +0,0 @@
import { AuthAnthropic } from "../../auth/anthropic"
import { UI } from "../ui"
// Example: https://claude.ai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fconsole.anthropic.com%2Foauth%2Fcode%2Fcallback&scope=org%3Acreate_api_key+user%3Aprofile+user%3Ainference&code_challenge=MdFtFgFap23AWDSN0oa3-eaKjQRFE4CaEhXx8M9fHZg&code_challenge_method=S256&state=rKLtaDzm88GSwekyEqdi0wXX-YqIr13tSzYymSzpvfs
export const LoginAnthropicCommand = {
command: "anthropic",
describe: "Login to Anthropic",
handler: 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)
},
}
+19
View File
@@ -0,0 +1,19 @@
import { App } from "../../app/app"
import { Provider } from "../../provider/provider"
import { cmd } from "./cmd"
export const ModelsCommand = cmd({
command: "models",
describe: "list all available models",
handler: async () => {
await App.provide({ cwd: process.cwd() }, async () => {
const providers = await Provider.list()
for (const [providerID, provider] of Object.entries(providers)) {
for (const modelID of Object.keys(provider.info.models)) {
console.log(`${providerID}/${modelID}`)
}
}
})
},
})
+5 -5
View File
@@ -25,7 +25,7 @@ const TOOL: Record<string, [string, string]> = {
export const RunCommand = cmd({ export const RunCommand = cmd({
command: "run [message..]", command: "run [message..]",
describe: "Run opencode with a message", describe: "run opencode with a message",
builder: (yargs: Argv) => { builder: (yargs: Argv) => {
return yargs return yargs
.positional("message", { .positional("message", {
@@ -36,12 +36,12 @@ export const RunCommand = cmd({
}) })
.option("continue", { .option("continue", {
alias: ["c"], alias: ["c"],
describe: "Continue the last session", describe: "continue the last session",
type: "boolean", type: "boolean",
}) })
.option("session", { .option("session", {
alias: ["s"], alias: ["s"],
describe: "Session ID to continue", describe: "session id to continue",
type: "string", type: "string",
}) })
.option("share", { .option("share", {
@@ -51,7 +51,7 @@ export const RunCommand = cmd({
.option("model", { .option("model", {
type: "string", type: "string",
alias: ["m"], alias: ["m"],
describe: "Model to use in the format of provider/model", describe: "model to use in the format of provider/model",
}) })
}, },
handler: async (args) => { handler: async (args) => {
@@ -134,7 +134,7 @@ export const RunCommand = cmd({
part.toolInvocation.toolName, part.toolInvocation.toolName,
UI.Style.TEXT_INFO_BOLD, UI.Style.TEXT_INFO_BOLD,
] ]
printEvent(color, tool, metadata.title) printEvent(color, tool, metadata?.title || 'Unknown')
} }
if (part.type === "text") { if (part.type === "text") {
+50
View File
@@ -0,0 +1,50 @@
import { App } from "../../app/app"
import { Provider } from "../../provider/provider"
import { Server } from "../../server/server"
import { Share } from "../../share/share"
import { cmd } from "./cmd"
export const ServeCommand = cmd({
command: "serve",
builder: (yargs) =>
yargs
.option("port", {
alias: ["p"],
type: "number",
describe: "port to listen on",
default: 4096,
})
.option("hostname", {
alias: ["h"],
type: "string",
describe: "hostname to listen on",
default: "127.0.0.1",
}),
describe: "starts a headless opencode server",
handler: async (args) => {
const cwd = process.cwd()
await App.provide({ cwd }, async () => {
const providers = await Provider.list()
if (Object.keys(providers).length === 0) {
return "needs_provider"
}
const hostname = args.hostname
const port = args.port
await Share.init()
const server = Server.listen({
port,
hostname,
})
console.log(
`opencode server listening on http://${server.hostname}:${server.port}`,
)
await new Promise(() => {})
server.stop()
})
},
})
+3 -3
View File
@@ -5,16 +5,16 @@ import { Installation } from "../../installation"
export const UpgradeCommand = { export const UpgradeCommand = {
command: "upgrade [target]", command: "upgrade [target]",
describe: "upgrade opencode to the latest version or a specific version", describe: "upgrade opencode to the latest or a specific version",
builder: (yargs: Argv) => { builder: (yargs: Argv) => {
return yargs return yargs
.positional("target", { .positional("target", {
describe: "specific version to upgrade to (e.g., '0.1.48' or 'v0.1.48')", describe: "version to upgrade to, for ex '0.1.48' or 'v0.1.48'",
type: "string", type: "string",
}) })
.option("method", { .option("method", {
alias: "m", alias: "m",
describe: "installation method to use (curl, npm, pnpm, bun, brew)", describe: "installation method to use",
type: "string", type: "string",
choices: ["curl", "npm", "pnpm", "bun", "brew"], choices: ["curl", "npm", "pnpm", "bun", "brew"],
}) })
+6
View File
@@ -1,6 +1,10 @@
import { Config } from "../config/config" import { Config } from "../config/config"
import { MCP } from "../mcp"
import { UI } from "./ui"
export function FormatError(input: unknown) { export function FormatError(input: unknown) {
if (MCP.Failed.isInstance(input))
return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.`
if (Config.JsonError.isInstance(input)) if (Config.JsonError.isInstance(input))
return `Config file at ${input.data.path} is not valid JSON` return `Config file at ${input.data.path} is not valid JSON`
if (Config.InvalidError.isInstance(input)) if (Config.InvalidError.isInstance(input))
@@ -10,4 +14,6 @@ export function FormatError(input: unknown) {
(issue) => "↳ " + issue.message + " " + issue.path.join("."), (issue) => "↳ " + issue.message + " " + issue.path.join("."),
) ?? []), ) ?? []),
].join("\n") ].join("\n")
if (UI.CancelledError.isInstance(input)) return ""
} }
+3 -2
View File
@@ -1,4 +1,5 @@
import { z } from "zod" import { z } from "zod"
import { EOL } from "os"
import { NamedError } from "../util/error" import { NamedError } from "../util/error"
export namespace UI { export namespace UI {
@@ -29,7 +30,7 @@ export namespace UI {
export function println(...message: string[]) { export function println(...message: string[]) {
print(...message) print(...message)
Bun.stderr.write("\n") Bun.stderr.write(EOL)
} }
export function print(...message: string[]) { export function print(...message: string[]) {
@@ -52,7 +53,7 @@ export namespace UI {
result.push(row[0]) result.push(row[0])
result.push("\x1b[0m") result.push("\x1b[0m")
result.push(row[1]) result.push(row[1])
result.push("\n") result.push(EOL)
} }
return result.join("").trimEnd() return result.join("").trimEnd()
} }
+5 -2
View File
@@ -116,14 +116,17 @@ export namespace Ripgrep {
export async function files(input: { export async function files(input: {
cwd: string cwd: string
query?: string query?: string
glob?: string
limit?: number limit?: number
}) { }) {
const commands = [`${await filepath()} --files --hidden --glob='!.git/*'`] const commands = [
`${await filepath()} --files --hidden --glob='!.git/*' ${input.glob ? `--glob='${input.glob}'` : ``}`,
]
if (input.query) if (input.query)
commands.push(`${await Fzf.filepath()} --filter=${input.query}`) commands.push(`${await Fzf.filepath()} --filter=${input.query}`)
if (input.limit) commands.push(`head -n ${input.limit}`) if (input.limit) commands.push(`head -n ${input.limit}`)
const joined = commands.join(" | ") const joined = commands.join(" | ")
const result = await $`${{ raw: joined }}`.cwd(input.cwd).text() const result = await $`${{ raw: joined }}`.cwd(input.cwd).nothrow().text()
return result.split("\n").filter(Boolean) return result.split("\n").filter(Boolean)
} }
} }
+48 -9
View File
@@ -4,6 +4,7 @@ import { Server } from "./server/server"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { Share } from "./share/share" import { Share } from "./share/share"
import url from "node:url"
import { Global } from "./global" import { Global } from "./global"
import yargs from "yargs" import yargs from "yargs"
import { hideBin } from "yargs/helpers" import { hideBin } from "yargs/helpers"
@@ -13,6 +14,7 @@ import { ScrapCommand } from "./cli/cmd/scrap"
import { Log } from "./util/log" import { Log } from "./util/log"
import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth" import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth"
import { UpgradeCommand } from "./cli/cmd/upgrade" import { UpgradeCommand } from "./cli/cmd/upgrade"
import { ModelsCommand } from "./cli/cmd/models"
import { Provider } from "./provider/provider" import { Provider } from "./provider/provider"
import { UI } from "./cli/ui" import { UI } from "./cli/ui"
import { Installation } from "./installation" import { Installation } from "./installation"
@@ -20,12 +22,29 @@ import { Bus } from "./bus"
import { Config } from "./config/config" import { Config } from "./config/config"
import { NamedError } from "./util/error" import { NamedError } from "./util/error"
import { FormatError } from "./cli/error" import { FormatError } from "./cli/error"
import { ServeCommand } from "./cli/cmd/serve"
const cancel = new AbortController()
process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
e: e instanceof Error ? e.message : e,
})
})
process.on("uncaughtException", (e) => {
Log.Default.error("exception", {
e: e instanceof Error ? e.message : e,
})
})
const cli = yargs(hideBin(process.argv)) const cli = yargs(hideBin(process.argv))
.scriptName("opencode") .scriptName("opencode")
.version(Installation.VERSION) .help("help", "show help")
.version("version", "show version number", Installation.VERSION)
.alias("version", "v")
.option("print-logs", { .option("print-logs", {
describe: "Print logs to stderr", describe: "print logs to stderr",
type: "boolean", type: "boolean",
}) })
.middleware(async () => { .middleware(async () => {
@@ -38,7 +57,7 @@ const cli = yargs(hideBin(process.argv))
.usage("\n" + UI.logo()) .usage("\n" + UI.logo())
.command({ .command({
command: "$0 [project]", command: "$0 [project]",
describe: "start opencode TUI", describe: "start opencode tui",
builder: (yargs) => builder: (yargs) =>
yargs.positional("project", { yargs.positional("project", {
type: "string", type: "string",
@@ -47,7 +66,12 @@ const cli = yargs(hideBin(process.argv))
handler: async (args) => { handler: async (args) => {
while (true) { while (true) {
const cwd = args.project ? path.resolve(args.project) : process.cwd() const cwd = args.project ? path.resolve(args.project) : process.cwd()
process.chdir(cwd) try {
process.chdir(cwd)
} catch (e) {
UI.error("Failed to change directory to " + cwd)
return
}
const result = await App.provide({ cwd }, async (app) => { const result = await App.provide({ cwd }, async (app) => {
const providers = await Provider.list() const providers = await Provider.list()
if (Object.keys(providers).length === 0) { if (Object.keys(providers).length === 0) {
@@ -55,13 +79,22 @@ const cli = yargs(hideBin(process.argv))
} }
await Share.init() await Share.init()
const server = Server.listen() const server = Server.listen({
port: 0,
hostname: "127.0.0.1",
})
let cmd = ["go", "run", "./main.go"] let cmd = ["go", "run", "./main.go"]
let cwd = new URL("../../tui/cmd/opencode", import.meta.url).pathname let cwd = url.fileURLToPath(
new URL("../../tui/cmd/opencode", import.meta.url),
)
if (Bun.embeddedFiles.length > 0) { if (Bun.embeddedFiles.length > 0) {
const blob = Bun.embeddedFiles[0] as File const blob = Bun.embeddedFiles[0] as File
const binary = path.join(Global.Path.cache, "tui", blob.name) let binaryName = blob.name
if (process.platform === "win32" && !binaryName.endsWith(".exe")) {
binaryName += ".exe"
}
const binary = path.join(Global.Path.cache, "tui", binaryName)
const file = Bun.file(binary) const file = Bun.file(binary)
if (!(await file.exists())) { if (!(await file.exists())) {
await Bun.write(file, blob, { mode: 0o755 }) await Bun.write(file, blob, { mode: 0o755 })
@@ -72,6 +105,7 @@ const cli = yargs(hideBin(process.argv))
} }
const proc = Bun.spawn({ const proc = Bun.spawn({
cmd: [...cmd, ...process.argv.slice(2)], cmd: [...cmd, ...process.argv.slice(2)],
signal: cancel.signal,
cwd, cwd,
stdout: "inherit", stdout: "inherit",
stderr: "inherit", stderr: "inherit",
@@ -91,7 +125,8 @@ const cli = yargs(hideBin(process.argv))
if (Installation.isSnapshot()) return if (Installation.isSnapshot()) return
const config = await Config.global() const config = await Config.global()
if (config.autoupdate === false) return if (config.autoupdate === false) return
const latest = await Installation.latest() const latest = await Installation.latest().catch(() => {})
if (!latest) return
if (Installation.VERSION === latest) return if (Installation.VERSION === latest) return
const method = await Installation.method() const method = await Installation.method()
if (method === "unknown") return if (method === "unknown") return
@@ -122,6 +157,8 @@ const cli = yargs(hideBin(process.argv))
.command(ScrapCommand) .command(ScrapCommand)
.command(AuthCommand) .command(AuthCommand)
.command(UpgradeCommand) .command(UpgradeCommand)
.command(ServeCommand)
.command(ModelsCommand)
.fail((msg) => { .fail((msg) => {
if ( if (
msg.startsWith("Unknown argument") || msg.startsWith("Unknown argument") ||
@@ -152,8 +189,10 @@ try {
Log.Default.error("fatal", data) Log.Default.error("fatal", data)
const formatted = FormatError(e) const formatted = FormatError(e)
if (formatted) UI.error(formatted) if (formatted) UI.error(formatted)
if (!formatted) if (formatted === undefined)
UI.error( UI.error(
"Unexpected error, check log file at " + Log.file() + " for more details", "Unexpected error, check log file at " + Log.file() + " for more details",
) )
} }
cancel.abort()
+3 -1
View File
@@ -115,7 +115,9 @@ export namespace Installation {
case "bun": case "bun":
return $`bun install -g opencode-ai@${target}` return $`bun install -g opencode-ai@${target}`
case "brew": case "brew":
return $`brew install sst/tap/opencode` return $`brew install sst/tap/opencode`.env({
HOMEBREW_NO_AUTO_UPDATE: "1",
})
default: default:
throw new Error(`Unknown method: ${method}`) throw new Error(`Unknown method: ${method}`)
} }
+48 -5
View File
@@ -2,8 +2,22 @@ import { experimental_createMCPClient, type Tool } from "ai"
import { Experimental_StdioMCPTransport } from "ai/mcp-stdio" import { Experimental_StdioMCPTransport } from "ai/mcp-stdio"
import { App } from "../app/app" import { App } from "../app/app"
import { Config } from "../config/config" import { Config } from "../config/config"
import { Log } from "../util/log"
import { NamedError } from "../util/error"
import { z } from "zod"
import { Session } from "../session"
import { Bus } from "../bus"
export namespace MCP { export namespace MCP {
const log = Log.create({ service: "mcp" })
export const Failed = NamedError.create(
"MCPFailed",
z.object({
name: z.string(),
}),
)
const state = App.state( const state = App.state(
"mcp", "mcp",
async () => { async () => {
@@ -12,27 +26,56 @@ export namespace MCP {
[name: string]: Awaited<ReturnType<typeof experimental_createMCPClient>> [name: string]: Awaited<ReturnType<typeof experimental_createMCPClient>>
} = {} } = {}
for (const [key, mcp] of Object.entries(cfg.mcp ?? {})) { for (const [key, mcp] of Object.entries(cfg.mcp ?? {})) {
log.info("found", { key, type: mcp.type })
if (mcp.type === "remote") { if (mcp.type === "remote") {
clients[key] = await experimental_createMCPClient({ const client = await experimental_createMCPClient({
name: key, name: key,
transport: { transport: {
type: "sse", type: "sse",
url: mcp.url, url: mcp.url,
}, },
}) }).catch(() => {})
if (!client) {
Bus.publish(Session.Event.Error, {
error: {
name: "UnknownError",
data: {
message: `MCP server ${key} failed to start`,
},
},
})
continue
}
clients[key] = client
} }
if (mcp.type === "local") { if (mcp.type === "local") {
const [cmd, ...args] = mcp.command const [cmd, ...args] = mcp.command
clients[key] = await experimental_createMCPClient({ const client = await experimental_createMCPClient({
name: key, name: key,
transport: new Experimental_StdioMCPTransport({ transport: new Experimental_StdioMCPTransport({
stderr: "ignore", stderr: "ignore",
command: cmd, command: cmd,
args, args,
env: mcp.environment, env: {
...process.env,
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
...mcp.environment,
},
}), }),
}) }).catch(() => {})
if (!client) {
Bus.publish(Session.Event.Error, {
error: {
name: "UnknownError",
data: {
message: `MCP server ${key} failed to start`,
},
},
})
continue
}
clients[key] = client
} }
} }
@@ -0,0 +1,4 @@
export async function data() {
const json = await fetch("https://models.dev/api.json").then((x) => x.text())
return json
}
+6 -6
View File
@@ -2,6 +2,7 @@ import { Global } from "../global"
import { Log } from "../util/log" import { Log } from "../util/log"
import path from "path" import path from "path"
import { z } from "zod" import { z } from "zod"
import { data } from "./models-macro" with { type: "macro" }
export namespace ModelsDev { export namespace ModelsDev {
const log = Log.create({ service: "models.dev" }) const log = Log.create({ service: "models.dev" })
@@ -54,16 +55,15 @@ export namespace ModelsDev {
refresh() refresh()
return result as Record<string, Provider> return result as Record<string, Provider>
} }
await refresh() refresh()
return get() const json = await data()
return JSON.parse(json) as Record<string, Provider>
} }
async function refresh() { async function refresh() {
const file = Bun.file(filepath) const file = Bun.file(filepath)
log.info("refreshing") log.info("refreshing")
const result = await fetch("https://models.dev/api.json") const result = await fetch("https://models.dev/api.json").catch(() => {})
if (!result.ok) if (result && result.ok) await Bun.write(file, result)
throw new Error(`Failed to fetch models.dev: ${result.statusText}`)
await Bun.write(file, result)
} }
} }
+70 -11
View File
@@ -19,6 +19,7 @@ import type { Tool } from "../tool/tool"
import { WriteTool } from "../tool/write" import { WriteTool } from "../tool/write"
import { TodoReadTool, TodoWriteTool } from "../tool/todo" import { TodoReadTool, TodoWriteTool } from "../tool/todo"
import { AuthAnthropic } from "../auth/anthropic" import { AuthAnthropic } from "../auth/anthropic"
import { AuthCopilot } from "../auth/copilot"
import { ModelsDev } from "./models" import { ModelsDev } from "./models"
import { NamedError } from "../util/error" import { NamedError } from "../util/error"
import { Auth } from "../auth" import { Auth } from "../auth"
@@ -27,20 +28,21 @@ import { TaskTool } from "../tool/task"
export namespace Provider { export namespace Provider {
const log = Log.create({ service: "provider" }) const log = Log.create({ service: "provider" })
type CustomLoader = (provider: ModelsDev.Provider) => Promise< type CustomLoader = (
| { provider: ModelsDev.Provider,
getModel?: (sdk: any, modelID: string) => Promise<any> api?: string,
options: Record<string, any> ) => Promise<{
} autoload: boolean
| false getModel?: (sdk: any, modelID: string) => Promise<any>
> options?: Record<string, any>
}>
type Source = "env" | "config" | "custom" | "api" type Source = "env" | "config" | "custom" | "api"
const CUSTOM_LOADERS: Record<string, CustomLoader> = { const CUSTOM_LOADERS: Record<string, CustomLoader> = {
async anthropic(provider) { async anthropic(provider) {
const access = await AuthAnthropic.access() const access = await AuthAnthropic.access()
if (!access) return false if (!access) return { autoload: false }
for (const model of Object.values(provider.models)) { for (const model of Object.values(provider.models)) {
model.cost = { model.cost = {
input: 0, input: 0,
@@ -48,6 +50,7 @@ export namespace Provider {
} }
} }
return { return {
autoload: true,
options: { options: {
apiKey: "", apiKey: "",
async fetch(input: any, init: any) { async fetch(input: any, init: any) {
@@ -66,8 +69,56 @@ export namespace Provider {
}, },
} }
}, },
"github-copilot": async (provider) => {
const copilot = await AuthCopilot()
if (!copilot) return { autoload: false }
let info = await Auth.get("github-copilot")
if (!info || info.type !== "oauth") return { autoload: false }
if (provider && provider.models) {
for (const model of Object.values(provider.models)) {
model.cost = {
input: 0,
output: 0,
}
}
}
return {
autoload: true,
options: {
apiKey: "",
async fetch(input: any, init: any) {
const info = await Auth.get("github-copilot")
if (!info || info.type !== "oauth") return
if (!info.access || info.expires < Date.now()) {
const tokens = await copilot.access(info.refresh)
if (!tokens)
throw new Error("GitHub Copilot authentication expired")
await Auth.set("github-copilot", {
type: "oauth",
...tokens,
})
info.access = tokens.access
}
const headers = {
...init.headers,
...copilot.HEADERS,
Authorization: `Bearer ${info.access}`,
"Openai-Intent": "conversation-edits",
}
delete headers["x-api-key"]
return fetch(input, {
...init,
headers,
})
},
},
}
},
openai: async () => { openai: async () => {
return { return {
autoload: false,
async getModel(sdk: any, modelID: string) { async getModel(sdk: any, modelID: string) {
return sdk.responses(modelID) return sdk.responses(modelID)
}, },
@@ -75,7 +126,8 @@ export namespace Provider {
} }
}, },
"amazon-bedrock": async () => { "amazon-bedrock": async () => {
if (!process.env["AWS_PROFILE"]) return false if (!process.env["AWS_PROFILE"] && !process.env["AWS_ACCESS_KEY_ID"])
return { autoload: false }
const region = process.env["AWS_REGION"] ?? "us-east-1" const region = process.env["AWS_REGION"] ?? "us-east-1"
@@ -83,6 +135,7 @@ export namespace Provider {
await BunProc.install("@aws-sdk/credential-providers") await BunProc.install("@aws-sdk/credential-providers")
) )
return { return {
autoload: true,
options: { options: {
region, region,
credentialProvider: fromNodeProviderChain(), credentialProvider: fromNodeProviderChain(),
@@ -208,8 +261,14 @@ export namespace Provider {
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) { for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
if (disabled.has(providerID)) continue if (disabled.has(providerID)) continue
const result = await fn(database[providerID]) const result = await fn(database[providerID])
if (result) if (result && (result.autoload || providers[providerID])) {
mergeProvider(providerID, result.options, "custom", result.getModel) mergeProvider(
providerID,
result.options ?? {},
"custom",
result.getModel,
)
}
} }
// load config // load config
+15 -14
View File
@@ -1,24 +1,25 @@
import type { CoreMessage } from "ai" import type { LanguageModelV1Prompt } from "ai"
import { unique } from "remeda"
export namespace ProviderTransform { export namespace ProviderTransform {
export function message( export function message(
msg: CoreMessage, msgs: LanguageModelV1Prompt,
index: number,
providerID: string, providerID: string,
modelID: string, modelID: string,
) { ) {
if ( if (providerID === "anthropic" || modelID.includes("anthropic")) {
(providerID === "anthropic" || modelID.includes("anthropic")) && const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)
index < 4 const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
) {
msg.providerOptions = { for (const msg of unique([...system, ...final])) {
...msg.providerOptions, msg.providerMetadata = {
anthropic: { ...msg.providerMetadata,
cacheControl: { type: "ephemeral" }, anthropic: {
}, cacheControl: { type: "ephemeral" },
},
}
} }
} }
return msgs
return msg
} }
} }
+30 -3
View File
@@ -390,6 +390,33 @@ export namespace Server {
return c.json(Session.abort(body.sessionID)) return c.json(Session.abort(body.sessionID))
}, },
) )
.post(
"/session_delete",
describeRoute({
description: "Delete a session and all its data",
responses: {
200: {
description: "Successfully deleted session",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json")
await Session.remove(body.sessionID)
return c.json(true)
},
)
.post( .post(
"/session_summarize", "/session_summarize",
describeRoute({ describeRoute({
@@ -552,10 +579,10 @@ export namespace Server {
return result return result
} }
export function listen() { export function listen(opts: { port: number; hostname: string }) {
const server = Bun.serve({ const server = Bun.serve({
port: 0, port: opts.port,
hostname: "0.0.0.0", hostname: opts.hostname,
idleTimeout: 0, idleTimeout: 0,
fetch: app().fetch, fetch: app().fetch,
}) })
+123 -36
View File
@@ -14,6 +14,7 @@ import {
type CoreMessage, type CoreMessage,
type UIMessage, type UIMessage,
type ProviderMetadata, type ProviderMetadata,
wrapLanguageModel,
} from "ai" } from "ai"
import { z, ZodSchema } from "zod" import { z, ZodSchema } from "zod"
import { Decimal } from "decimal.js" import { Decimal } from "decimal.js"
@@ -71,6 +72,12 @@ export namespace Session {
info: Info, info: Info,
}), }),
), ),
Deleted: Bus.event(
"session.deleted",
z.object({
info: Info,
}),
),
Error: Bus.event( Error: Bus.event(
"session.error", "session.error",
z.object({ z.object({
@@ -205,6 +212,17 @@ export namespace Session {
} }
} }
export async function children(parentID: string) {
const result = [] as Session.Info[]
for await (const item of Storage.list("session/info")) {
const sessionID = path.basename(item, ".json")
const session = await get(sessionID)
if (session.parentID !== parentID) continue
result.push(session)
}
return result
}
export function abort(sessionID: string) { export function abort(sessionID: string) {
const controller = state().pending.get(sessionID) const controller = state().pending.get(sessionID)
if (!controller) return false if (!controller) return false
@@ -213,6 +231,28 @@ export namespace Session {
return true return true
} }
export async function remove(sessionID: string, emitEvent = true) {
try {
abort(sessionID)
const session = await get(sessionID)
for (const child of await children(sessionID)) {
await remove(child.id, false)
}
await unshare(sessionID).catch(() => {})
await Storage.remove(`session/info/${sessionID}`).catch(() => {})
await Storage.removeDir(`session/message/${sessionID}/`).catch(() => {})
state().sessions.delete(sessionID)
state().messages.delete(sessionID)
if (emitEvent) {
Bus.publish(Event.Deleted, {
info: session,
})
}
} catch (e) {
log.error(e)
}
}
async function updateMessage(msg: Message.Info) { async function updateMessage(msg: Message.Info) {
await Storage.writeJSON( await Storage.writeJSON(
"session/message/" + msg.metadata.sessionID + "/" + msg.id, "session/message/" + msg.metadata.sessionID + "/" + msg.id,
@@ -247,7 +287,10 @@ export namespace Session {
if ( if (
model.info.limit.context && model.info.limit.context &&
tokens > tokens >
(model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9 Math.max(
(model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9,
0,
)
) { ) {
await summarize({ await summarize({
sessionID: input.sessionID, sessionID: input.sessionID,
@@ -285,9 +328,7 @@ export namespace Session {
parts: toParts(input.parts), parts: toParts(input.parts),
}, },
]), ]),
].map((msg, i) => ],
ProviderTransform.message(msg, i, input.providerID, input.modelID),
),
model: model.language, model: model.language,
}) })
.then((result) => { .then((result) => {
@@ -434,24 +475,6 @@ export namespace Session {
} }
let text: Message.TextPart | undefined let text: Message.TextPart | undefined
await Bun.write(
"/tmp/message.json",
JSON.stringify(
[
...system.map(
(x): CoreMessage => ({
role: "system",
content: x,
}),
),
...convertToCoreMessages(
msgs.map(toUIMessage).filter((x) => x.parts.length > 0),
),
],
null,
2,
),
)
const result = streamText({ const result = streamText({
onStepFinish: async (step) => { onStepFinish: async (step) => {
log.info("step finish", { finishReason: step.finishReason }) log.info("step finish", { finishReason: step.finishReason })
@@ -527,12 +550,26 @@ export namespace Session {
...convertToCoreMessages( ...convertToCoreMessages(
msgs.map(toUIMessage).filter((x) => x.parts.length > 0), msgs.map(toUIMessage).filter((x) => x.parts.length > 0),
), ),
].map((msg, i) => ],
ProviderTransform.message(msg, i, input.providerID, input.modelID),
),
temperature: model.info.temperature ? 0 : undefined, temperature: model.info.temperature ? 0 : undefined,
tools: model.info.tool_call === false ? undefined : tools, tools: model.info.tool_call === false ? undefined : tools,
model: model.language, model: wrapLanguageModel({
model: model.language,
middleware: [
{
async transformParams(args) {
if (args.type === "stream") {
args.params.prompt = ProviderTransform.message(
args.params.prompt,
input.providerID,
input.modelID,
)
}
return args.params
},
},
],
}),
}) })
try { try {
for await (const value of result.fullStream) { for await (const value of result.fullStream) {
@@ -623,6 +660,21 @@ export namespace Session {
} }
break break
case "finish":
log.info("message finish", {
reason: value.finishReason,
})
const assistant = next.metadata!.assistant!
const usage = getUsage(
model.info,
value.usage,
value.providerMetadata,
)
assistant.cost = usage.cost
await updateMessage(next)
if (value.finishReason === "length")
throw new Message.OutputLengthError({})
break
default: default:
l.info("unhandled", { l.info("unhandled", {
type: value.type, type: value.type,
@@ -636,6 +688,9 @@ export namespace Session {
error: e, error: e,
}) })
switch (true) { switch (true) {
case Message.OutputLengthError.isInstance(e):
next.metadata.error = e
break
case LoadAPIKeyError.isInstance(e): case LoadAPIKeyError.isInstance(e):
next.metadata.error = new Provider.AuthError( next.metadata.error = new Provider.AuthError(
{ {
@@ -723,7 +778,9 @@ export namespace Session {
}, },
} }
await updateMessage(next) await updateMessage(next)
const result = await generateText({
let text: Message.TextPart | undefined
const result = streamText({
abortSignal: abort.signal, abortSignal: abort.signal,
model: model.language, model: model.language,
messages: [ messages: [
@@ -744,16 +801,46 @@ export namespace Session {
], ],
}, },
], ],
onStepFinish: async (step) => {
const assistant = next.metadata!.assistant!
const usage = getUsage(model.info, step.usage, step.providerMetadata)
assistant.cost += usage.cost
assistant.tokens = usage.tokens
await updateMessage(next)
if (text) {
Bus.publish(Message.Event.PartUpdated, {
part: text,
messageID: next.id,
sessionID: next.metadata.sessionID,
})
}
text = undefined
},
async onFinish(input) {
const assistant = next.metadata!.assistant!
const usage = getUsage(model.info, input.usage, input.providerMetadata)
assistant.cost = usage.cost
assistant.tokens = usage.tokens
next.metadata!.time.completed = Date.now()
await updateMessage(next)
},
}) })
next.parts.push({
type: "text", for await (const value of result.fullStream) {
text: result.text, switch (value.type) {
}) case "text-delta":
const assistant = next.metadata!.assistant! if (!text) {
const usage = getUsage(model.info, result.usage, result.providerMetadata) text = {
assistant.cost = usage.cost type: "text",
assistant.tokens = usage.tokens text: value.textDelta,
await updateMessage(next) }
next.parts.push(text)
} else text.text += value.textDelta
await updateMessage(next)
break
}
}
} }
function lock(sessionID: string) { function lock(sessionID: string) {
+52 -44
View File
@@ -4,6 +4,11 @@ import { Provider } from "../provider/provider"
import { NamedError } from "../util/error" import { NamedError } from "../util/error"
export namespace Message { export namespace Message {
export const OutputLengthError = NamedError.create(
"MessageOutputLengthError",
z.object({}),
)
export const ToolCall = z export const ToolCall = z
.object({ .object({
state: z.literal("call"), state: z.literal("call"),
@@ -135,53 +140,56 @@ export namespace Message {
id: z.string(), id: z.string(),
role: z.enum(["user", "assistant"]), role: z.enum(["user", "assistant"]),
parts: z.array(Part), parts: z.array(Part),
metadata: z.object({ metadata: z
time: z.object({ .object({
created: z.number(), time: z.object({
completed: z.number().optional(), created: z.number(),
}), completed: z.number().optional(),
error: z }),
.discriminatedUnion("name", [ error: z
Provider.AuthError.Schema, .discriminatedUnion("name", [
NamedError.Unknown.Schema, Provider.AuthError.Schema,
]) NamedError.Unknown.Schema,
.optional(), OutputLengthError.Schema,
sessionID: z.string(), ])
tool: z.record( .optional(),
z.string(), sessionID: z.string(),
z tool: z.record(
z.string(),
z
.object({
title: z.string(),
time: z.object({
start: z.number(),
end: z.number(),
}),
})
.catchall(z.any()),
),
assistant: z
.object({ .object({
title: z.string(), system: z.string().array(),
time: z.object({ modelID: z.string(),
start: z.number(), providerID: z.string(),
end: z.number(), path: z.object({
cwd: z.string(),
root: z.string(),
}),
cost: z.number(),
summary: z.boolean().optional(),
tokens: z.object({
input: z.number(),
output: z.number(),
reasoning: z.number(),
cache: z.object({
read: z.number(),
write: z.number(),
}),
}), }),
}) })
.catchall(z.any()), .optional(),
), })
assistant: z .openapi({ ref: "Message.Metadata" }),
.object({
system: z.string().array(),
modelID: z.string(),
providerID: z.string(),
path: z.object({
cwd: z.string(),
root: z.string(),
}),
cost: z.number(),
summary: z.boolean().optional(),
tokens: z.object({
input: z.number(),
output: z.number(),
reasoning: z.number(),
cache: z.object({
read: z.number(),
write: z.number(),
}),
}),
})
.optional(),
}),
}) })
.openapi({ .openapi({
ref: "Message.Info", ref: "Message.Info",
+11 -7
View File
@@ -1,7 +1,11 @@
You will generate a short title based on the first message a user begins a conversation with Generate a short title based on the first message a user begins a conversation with. CRITICAL: Your response must be EXACTLY one line with NO line breaks, newlines, or multiple sentences.
- ensure it is not more than 50 characters long
- the title should be a summary of the user's message Requirements:
- it should be one line long - Maximum 50 characters
- do not use quotes or colons - Single line only - NO newlines or line breaks
- the entire text you return will be used as the title - Summary of the user's message
- never return anything that is more than one sentence (one line) long - No quotes, colons, or special formatting
- Do not include explanatory text like "summary:" or similar
- Your entire response becomes the title
IMPORTANT: Return only the title text on a single line. Do not add any explanations, formatting, or additional text.
+5
View File
@@ -29,6 +29,11 @@ export namespace Storage {
await fs.unlink(target).catch(() => {}) await fs.unlink(target).catch(() => {})
} }
export async function removeDir(key: string) {
const target = path.join(state().dir, key)
await fs.rm(target, { recursive: true, force: true }).catch(() => {})
}
export async function readJSON<T>(key: string) { export async function readJSON<T>(key: string) {
return Bun.file(path.join(state().dir, key + ".json")).json() as Promise<T> return Bun.file(path.join(state().dir, key + ".json")).json() as Promise<T>
} }
+9 -1
View File
@@ -63,10 +63,18 @@ export const BashTool = Tool.define({
metadata: { metadata: {
stderr, stderr,
stdout, stdout,
exit: process.exitCode,
description: params.description, description: params.description,
title: params.command, title: params.command,
}, },
output: stdout.replaceAll(/\x1b\[[0-9;]*m/g, ""), output: [
`<stdout>`,
stdout ?? "",
`</stdout>`,
`<stderr>`,
stderr ?? "",
`</stderr>`,
].join("\n"),
} }
}, },
}) })
+376 -25
View File
@@ -1,3 +1,7 @@
// the approaches in this edit tool are sourced from
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts
// https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts
import { z } from "zod" import { z } from "zod"
import * as path from "path" import * as path from "path"
import { Tool } from "./tool" import { Tool } from "./tool"
@@ -22,13 +26,17 @@ export const EditTool = Tool.define({
replaceAll: z replaceAll: z
.boolean() .boolean()
.optional() .optional()
.describe("Replace all occurences of old_string (default false)"), .describe("Replace all occurrences of old_string (default false)"),
}), }),
async execute(params, ctx) { async execute(params, ctx) {
if (!params.filePath) { if (!params.filePath) {
throw new Error("filePath is required") throw new Error("filePath is required")
} }
if (params.oldString === params.newString) {
throw new Error("oldString and newString must be different")
}
const app = App.info() const app = App.info()
const filepath = path.isAbsolute(params.filePath) const filepath = path.isAbsolute(params.filePath)
? params.filePath ? params.filePath
@@ -55,35 +63,19 @@ export const EditTool = Tool.define({
} }
const file = Bun.file(filepath) const file = Bun.file(filepath)
if (!(await file.exists())) throw new Error(`File ${filepath} not found`) const stats = await file.stat().catch(() => {})
const stats = await file.stat() if (!stats) throw new Error(`File ${filepath} not found`)
if (stats.isDirectory()) if (stats.isDirectory())
throw new Error(`Path is a directory, not a file: ${filepath}`) throw new Error(`Path is a directory, not a file: ${filepath}`)
await FileTimes.assert(ctx.sessionID, filepath) await FileTimes.assert(ctx.sessionID, filepath)
contentOld = await file.text() contentOld = await file.text()
const index = contentOld.indexOf(params.oldString)
if (index === -1)
throw new Error(
`oldString not found in file. Make sure it matches exactly, including whitespace and line breaks`,
)
if (params.replaceAll) {
contentNew = contentOld.replaceAll(params.oldString, params.newString)
}
if (!params.replaceAll) {
const lastIndex = contentOld.lastIndexOf(params.oldString)
if (index !== lastIndex)
throw new Error(
`oldString appears multiple times in the file. Please provide more context to ensure a unique match`,
)
contentNew =
contentOld.substring(0, index) +
params.newString +
contentOld.substring(index + params.oldString.length)
}
contentNew = replace(
contentOld,
params.oldString,
params.newString,
params.replaceAll,
)
await file.write(contentNew) await file.write(contentNew)
})() })()
@@ -116,6 +108,326 @@ export const EditTool = Tool.define({
}, },
}) })
export type Replacer = (
content: string,
find: string,
) => Generator<string, void, unknown>
export const SimpleReplacer: Replacer = function* (_content, find) {
yield find
}
export const LineTrimmedReplacer: Replacer = function* (content, find) {
const originalLines = content.split("\n")
const searchLines = find.split("\n")
if (searchLines[searchLines.length - 1] === "") {
searchLines.pop()
}
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
let matches = true
for (let j = 0; j < searchLines.length; j++) {
const originalTrimmed = originalLines[i + j].trim()
const searchTrimmed = searchLines[j].trim()
if (originalTrimmed !== searchTrimmed) {
matches = false
break
}
}
if (matches) {
let matchStartIndex = 0
for (let k = 0; k < i; k++) {
matchStartIndex += originalLines[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = 0; k < searchLines.length; k++) {
matchEndIndex += originalLines[i + k].length + 1
}
yield content.substring(matchStartIndex, matchEndIndex)
}
}
}
export const BlockAnchorReplacer: Replacer = function* (content, find) {
const originalLines = content.split("\n")
const searchLines = find.split("\n")
if (searchLines.length < 3) {
return
}
if (searchLines[searchLines.length - 1] === "") {
searchLines.pop()
}
const firstLineSearch = searchLines[0].trim()
const lastLineSearch = searchLines[searchLines.length - 1].trim()
// Find blocks where first line matches the search first line
for (let i = 0; i < originalLines.length; i++) {
if (originalLines[i].trim() !== firstLineSearch) {
continue
}
// Look for the matching last line after this first line
for (let j = i + 2; j < originalLines.length; j++) {
if (originalLines[j].trim() === lastLineSearch) {
// Found a potential block from i to j
let matchStartIndex = 0
for (let k = 0; k < i; k++) {
matchStartIndex += originalLines[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = 0; k <= j - i; k++) {
matchEndIndex += originalLines[i + k].length
if (k < j - i) {
matchEndIndex += 1 // Add newline character except for the last line
}
}
yield content.substring(matchStartIndex, matchEndIndex)
break // Only match the first occurrence of the last line
}
}
}
}
export const WhitespaceNormalizedReplacer: Replacer = function* (
content,
find,
) {
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()
const normalizedFind = normalizeWhitespace(find)
// Handle single line matches
const lines = content.split("\n")
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (normalizeWhitespace(line) === normalizedFind) {
yield line
}
// Also check for substring matches within lines
const normalizedLine = normalizeWhitespace(line)
if (normalizedLine.includes(normalizedFind)) {
// Find the actual substring in the original line that matches
const words = find.trim().split(/\s+/)
if (words.length > 0) {
const pattern = words
.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join("\\s+")
try {
const regex = new RegExp(pattern)
const match = line.match(regex)
if (match) {
yield match[0]
}
} catch (e) {
// Invalid regex pattern, skip
}
}
}
}
// Handle multi-line matches
const findLines = find.split("\n")
if (findLines.length > 1) {
for (let i = 0; i <= lines.length - findLines.length; i++) {
const block = lines.slice(i, i + findLines.length)
if (normalizeWhitespace(block.join("\n")) === normalizedFind) {
yield block.join("\n")
}
}
}
}
export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
const removeIndentation = (text: string) => {
const lines = text.split("\n")
const nonEmptyLines = lines.filter((line) => line.trim().length > 0)
if (nonEmptyLines.length === 0) return text
const minIndent = Math.min(
...nonEmptyLines.map((line) => {
const match = line.match(/^(\s*)/)
return match ? match[1].length : 0
}),
)
return lines
.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent)))
.join("\n")
}
const normalizedFind = removeIndentation(find)
const contentLines = content.split("\n")
const findLines = find.split("\n")
for (let i = 0; i <= contentLines.length - findLines.length; i++) {
const block = contentLines.slice(i, i + findLines.length).join("\n")
if (removeIndentation(block) === normalizedFind) {
yield block
}
}
}
export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
const unescapeString = (str: string): string => {
return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => {
switch (capturedChar) {
case "n":
return "\n"
case "t":
return "\t"
case "r":
return "\r"
case "'":
return "'"
case '"':
return '"'
case "`":
return "`"
case "\\":
return "\\"
case "\n":
return "\n"
case "$":
return "$"
default:
return match
}
})
}
const unescapedFind = unescapeString(find)
// Try direct match with unescaped find string
if (content.includes(unescapedFind)) {
yield unescapedFind
}
// Also try finding escaped versions in content that match unescaped find
const lines = content.split("\n")
const findLines = unescapedFind.split("\n")
for (let i = 0; i <= lines.length - findLines.length; i++) {
const block = lines.slice(i, i + findLines.length).join("\n")
const unescapedBlock = unescapeString(block)
if (unescapedBlock === unescapedFind) {
yield block
}
}
}
export const MultiOccurrenceReplacer: Replacer = function* (content, find) {
// This replacer yields all exact matches, allowing the replace function
// to handle multiple occurrences based on replaceAll parameter
let startIndex = 0
while (true) {
const index = content.indexOf(find, startIndex)
if (index === -1) break
yield find
startIndex = index + find.length
}
}
export const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
const trimmedFind = find.trim()
if (trimmedFind === find) {
// Already trimmed, no point in trying
return
}
// Try to find the trimmed version
if (content.includes(trimmedFind)) {
yield trimmedFind
}
// Also try finding blocks where trimmed content matches
const lines = content.split("\n")
const findLines = find.split("\n")
for (let i = 0; i <= lines.length - findLines.length; i++) {
const block = lines.slice(i, i + findLines.length).join("\n")
if (block.trim() === trimmedFind) {
yield block
}
}
}
export const ContextAwareReplacer: Replacer = function* (content, find) {
const findLines = find.split("\n")
if (findLines.length < 3) {
// Need at least 3 lines to have meaningful context
return
}
// Remove trailing empty line if present
if (findLines[findLines.length - 1] === "") {
findLines.pop()
}
const contentLines = content.split("\n")
// Extract first and last lines as context anchors
const firstLine = findLines[0].trim()
const lastLine = findLines[findLines.length - 1].trim()
// Find blocks that start and end with the context anchors
for (let i = 0; i < contentLines.length; i++) {
if (contentLines[i].trim() !== firstLine) continue
// Look for the matching last line
for (let j = i + 2; j < contentLines.length; j++) {
if (contentLines[j].trim() === lastLine) {
// Found a potential context block
const blockLines = contentLines.slice(i, j + 1)
const block = blockLines.join("\n")
// Check if the middle content has reasonable similarity
// (simple heuristic: at least 50% of non-empty lines should match when trimmed)
if (blockLines.length === findLines.length) {
let matchingLines = 0
let totalNonEmptyLines = 0
for (let k = 1; k < blockLines.length - 1; k++) {
const blockLine = blockLines[k].trim()
const findLine = findLines[k].trim()
if (blockLine.length > 0 || findLine.length > 0) {
totalNonEmptyLines++
if (blockLine === findLine) {
matchingLines++
}
}
}
if (
totalNonEmptyLines === 0 ||
matchingLines / totalNonEmptyLines >= 0.5
) {
yield block
break // Only match the first occurrence
}
}
break
}
}
}
}
function trimDiff(diff: string): string { function trimDiff(diff: string): string {
const lines = diff.split("\n") const lines = diff.split("\n")
const contentLines = lines.filter( const contentLines = lines.filter(
@@ -151,3 +463,42 @@ function trimDiff(diff: string): string {
return trimmedLines.join("\n") return trimmedLines.join("\n")
} }
export function replace(
content: string,
oldString: string,
newString: string,
replaceAll = false,
): string {
if (oldString === newString) {
throw new Error("oldString and newString must be different")
}
for (const replacer of [
SimpleReplacer,
LineTrimmedReplacer,
BlockAnchorReplacer,
WhitespaceNormalizedReplacer,
IndentationFlexibleReplacer,
EscapeNormalizedReplacer,
TrimmedBoundaryReplacer,
ContextAwareReplacer,
MultiOccurrenceReplacer,
]) {
for (const search of replacer(content, oldString)) {
const index = content.indexOf(search)
if (index === -1) continue
if (replaceAll) {
return content.replaceAll(search, newString)
}
const lastIndex = content.lastIndexOf(search)
if (index !== lastIndex) continue
return (
content.substring(0, index) +
newString +
content.substring(index + search.length)
)
}
}
throw new Error("oldString not found in content or was found multiple times")
}
+5 -2
View File
@@ -3,6 +3,7 @@ import path from "path"
import { Tool } from "./tool" import { Tool } from "./tool"
import { App } from "../app/app" import { App } from "../app/app"
import DESCRIPTION from "./glob.txt" import DESCRIPTION from "./glob.txt"
import { Ripgrep } from "../external/ripgrep"
export const GlobTool = Tool.define({ export const GlobTool = Tool.define({
id: "glob", id: "glob",
@@ -24,10 +25,12 @@ export const GlobTool = Tool.define({
: path.resolve(app.path.cwd, search) : path.resolve(app.path.cwd, search)
const limit = 100 const limit = 100
const glob = new Bun.Glob(params.pattern)
const files = [] const files = []
let truncated = false let truncated = false
for await (const file of glob.scan({ cwd: search, dot: true })) { for (const file of await Ripgrep.files({
cwd: search,
glob: params.pattern,
})) {
if (files.length >= limit) { if (files.length >= limit) {
truncated = true truncated = true
break break
+1 -1
View File
@@ -10,7 +10,7 @@ To make multiple file edits, provide the following:
2. edits: An array of edit operations to perform, where each edit contains: 2. edits: An array of edit operations to perform, where each edit contains:
- old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation) - old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation)
- new_string: The edited text to replace the old_string - new_string: The edited text to replace the old_string
- replace_all: Replace all occurences of old_string. This parameter is optional and defaults to false. - replace_all: Replace all occurrences of old_string. This parameter is optional and defaults to false.
IMPORTANT: IMPORTANT:
- All edits are applied in sequence, in the order they are provided - All edits are applied in sequence, in the order they are provided
+1 -1
View File
@@ -19,7 +19,7 @@ export namespace Log {
await fs.mkdir(dir, { recursive: true }) await fs.mkdir(dir, { recursive: true })
cleanup(dir) cleanup(dir)
if (options.print) return if (options.print) return
logpath = path.join(dir, new Date().toISOString().split(".")[0] + ".log") logpath = path.join(dir, new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log")
const logfile = Bun.file(logpath) const logfile = Bun.file(logpath)
await fs.truncate(logpath).catch(() => {}) await fs.truncate(logpath).catch(() => {})
const writer = logfile.writer() const writer = logfile.writer()
+413
View File
@@ -0,0 +1,413 @@
import { describe, expect, test } from "bun:test"
import { replace } from "../../src/tool/edit"
interface TestCase {
content: string
find: string
replace: string
all?: boolean
fail?: boolean
}
const testCases: TestCase[] = [
// SimpleReplacer cases
{
content: ["function hello() {", ' console.log("world");', "}"].join("\n"),
find: 'console.log("world");',
replace: 'console.log("universe");',
},
{
content: [
"if (condition) {",
" doSomething();",
" doSomethingElse();",
"}",
].join("\n"),
find: [" doSomething();", " doSomethingElse();"].join("\n"),
replace: [" doNewThing();", " doAnotherThing();"].join("\n"),
},
// LineTrimmedReplacer cases
{
content: ["function test() {", ' console.log("hello");', "}"].join("\n"),
find: 'console.log("hello");',
replace: 'console.log("goodbye");',
},
{
content: ["const x = 5; ", "const y = 10;"].join("\n"),
find: "const x = 5;",
replace: "const x = 15;",
},
{
content: [" if (true) {", " return false;", " }"].join("\n"),
find: ["if (true) {", "return false;", "}"].join("\n"),
replace: ["if (false) {", "return true;", "}"].join("\n"),
},
// BlockAnchorReplacer cases
{
content: [
"function calculate(a, b) {",
" const temp = a + b;",
" const result = temp * 2;",
" return result;",
"}",
].join("\n"),
find: [
"function calculate(a, b) {",
" // different middle content",
" return result;",
"}",
].join("\n"),
replace: ["function calculate(a, b) {", " return a * b * 2;", "}"].join(
"\n",
),
},
{
content: [
"class MyClass {",
" constructor() {",
" this.value = 0;",
" }",
" ",
" getValue() {",
" return this.value;",
" }",
"}",
].join("\n"),
find: ["class MyClass {", " // different implementation", "}"].join("\n"),
replace: [
"class MyClass {",
" constructor() {",
" this.value = 42;",
" }",
"}",
].join("\n"),
},
// WhitespaceNormalizedReplacer cases
{
content: ["function test() {", '\tconsole.log("hello");', "}"].join("\n"),
find: ' console.log("hello");',
replace: ' console.log("world");',
},
{
content: "const x = 5;",
find: "const x = 5;",
replace: "const x = 10;",
},
{
content: "if\t( condition\t) {",
find: "if ( condition ) {",
replace: "if (newCondition) {",
},
// IndentationFlexibleReplacer cases
{
content: [
" function nested() {",
' console.log("deeply nested");',
" return true;",
" }",
].join("\n"),
find: [
"function nested() {",
' console.log("deeply nested");',
" return true;",
"}",
].join("\n"),
replace: [
"function nested() {",
' console.log("updated");',
" return false;",
"}",
].join("\n"),
},
{
content: [
" if (true) {",
' console.log("level 1");',
' console.log("level 2");',
" }",
].join("\n"),
find: [
"if (true) {",
'console.log("level 1");',
' console.log("level 2");',
"}",
].join("\n"),
replace: ["if (true) {", 'console.log("updated");', "}"].join("\n"),
},
// replaceAll option cases
{
content: [
'console.log("test");',
'console.log("test");',
'console.log("test");',
].join("\n"),
find: 'console.log("test");',
replace: 'console.log("updated");',
all: true,
},
{
content: ['console.log("test");', 'console.log("test");'].join("\n"),
find: 'console.log("test");',
replace: 'console.log("updated");',
all: false,
},
// Error cases
{
content: 'console.log("hello");',
find: "nonexistent string",
replace: "updated",
fail: true,
},
{
content: ["test", "test", "different content", "test"].join("\n"),
find: "test",
replace: "updated",
all: false,
fail: true,
},
// Edge cases
{
content: "",
find: "",
replace: "new content",
},
{
content: "const regex = /[.*+?^${}()|[\\\\]\\\\\\\\]/g;",
find: "/[.*+?^${}()|[\\\\]\\\\\\\\]/g",
replace: "/\\\\w+/g",
},
{
content: 'const message = "Hello 世界! 🌍";',
find: "Hello 世界! 🌍",
replace: "Hello World! 🌎",
},
// EscapeNormalizedReplacer cases
{
content: 'console.log("Hello\nWorld");',
find: 'console.log("Hello\\nWorld");',
replace: 'console.log("Hello\nUniverse");',
},
{
content: "const str = 'It's working';",
find: "const str = 'It\\'s working';",
replace: "const str = 'It's fixed';",
},
{
content: "const template = `Hello ${name}`;",
find: "const template = `Hello \\${name}`;",
replace: "const template = `Hi ${name}`;",
},
{
content: "const path = 'C:\\Users\\test';",
find: "const path = 'C:\\\\Users\\\\test';",
replace: "const path = 'C:\\Users\\admin';",
},
// MultiOccurrenceReplacer cases (with replaceAll)
{
content: ["debug('start');", "debug('middle');", "debug('end');"].join(
"\n",
),
find: "debug",
replace: "log",
all: true,
},
{
content: "const x = 1; const y = 1; const z = 1;",
find: "1",
replace: "2",
all: true,
},
// TrimmedBoundaryReplacer cases
{
content: [" function test() {", " return true;", " }"].join("\n"),
find: ["function test() {", " return true;", "}"].join("\n"),
replace: ["function test() {", " return false;", "}"].join("\n"),
},
{
content: "\n const value = 42; \n",
find: "const value = 42;",
replace: "const value = 24;",
},
{
content: ["", " if (condition) {", " doSomething();", " }", ""].join(
"\n",
),
find: ["if (condition) {", " doSomething();", "}"].join("\n"),
replace: ["if (condition) {", " doNothing();", "}"].join("\n"),
},
// ContextAwareReplacer cases
{
content: [
"function calculate(a, b) {",
" const temp = a + b;",
" const result = temp * 2;",
" return result;",
"}",
].join("\n"),
find: [
"function calculate(a, b) {",
" // some different content here",
" // more different content",
" return result;",
"}",
].join("\n"),
replace: ["function calculate(a, b) {", " return (a + b) * 2;", "}"].join(
"\n",
),
},
{
content: [
"class TestClass {",
" constructor() {",
" this.value = 0;",
" }",
" ",
" method() {",
" return this.value;",
" }",
"}",
].join("\n"),
find: [
"class TestClass {",
" // different implementation",
" // with multiple lines",
"}",
].join("\n"),
replace: ["class TestClass {", " getValue() { return 42; }", "}"].join(
"\n",
),
},
// Combined edge cases for new replacers
{
content: '\tconsole.log("test");\t',
find: 'console.log("test");',
replace: 'console.log("updated");',
},
{
content: [" ", "function test() {", " return 'value';", "}", " "].join(
"\n",
),
find: ["function test() {", "return 'value';", "}"].join("\n"),
replace: ["function test() {", "return 'new value';", "}"].join("\n"),
},
// Test for same oldString and newString (should fail)
{
content: 'console.log("test");',
find: 'console.log("test");',
replace: 'console.log("test");',
fail: true,
},
// Additional tests for fixes made
// WhitespaceNormalizedReplacer - test regex special characters that could cause errors
{
content: 'const pattern = "test[123]";',
find: 'test[123]',
replace: 'test[456]',
},
{
content: 'const regex = "^start.*end$";',
find: '^start.*end$',
replace: '^begin.*finish$',
},
// EscapeNormalizedReplacer - test single backslash vs double backslash
{
content: 'const path = "C:\\Users";',
find: 'const path = "C:\\Users";',
replace: 'const path = "D:\\Users";',
},
{
content: 'console.log("Line1\\nLine2");',
find: 'console.log("Line1\\nLine2");',
replace: 'console.log("First\\nSecond");',
},
// BlockAnchorReplacer - test edge case with exact newline boundaries
{
content: ["function test() {", " return true;", "}"].join("\n"),
find: ["function test() {", " // middle", "}"].join("\n"),
replace: ["function test() {", " return false;", "}"].join("\n"),
},
// ContextAwareReplacer - test with trailing newline in find string
{
content: [
"class Test {",
" method1() {",
" return 1;",
" }",
"}",
].join("\n"),
find: [
"class Test {",
" // different content",
"}",
"", // trailing empty line
].join("\n"),
replace: ["class Test {", " method2() { return 2; }", "}"].join("\n"),
},
// Test validation for empty strings with same oldString and newString
{
content: "",
find: "",
replace: "",
fail: true,
},
// Test multiple occurrences with replaceAll=false (should fail)
{
content: ["const a = 1;", "const b = 1;", "const c = 1;"].join("\n"),
find: "= 1",
replace: "= 2",
all: false,
fail: true,
},
// Test whitespace normalization with multiple spaces and tabs mixed
{
content: "if\t \t( \tcondition\t )\t{",
find: "if ( condition ) {",
replace: "if (newCondition) {",
},
// Test escape sequences in template literals
{
content: "const msg = `Hello\\tWorld`;",
find: "const msg = `Hello\\tWorld`;",
replace: "const msg = `Hi\\tWorld`;",
},
]
describe("EditTool Replacers", () => {
test.each(testCases)("case %#", (testCase) => {
if (testCase.fail) {
expect(() => {
replace(testCase.content, testCase.find, testCase.replace, testCase.all)
}).toThrow()
} else {
const result = replace(
testCase.content,
testCase.find,
testCase.replace,
testCase.all,
)
expect(result).toContain(testCase.replace)
}
})
})
+2 -2
View File
@@ -14,7 +14,7 @@ describe("tool.glob", () => {
await App.provide({ cwd: process.cwd() }, async () => { await App.provide({ cwd: process.cwd() }, async () => {
let result = await GlobTool.execute( let result = await GlobTool.execute(
{ {
pattern: "./node_modules/**/*", pattern: "../../node_modules/**/*",
path: undefined, path: undefined,
}, },
ctx, ctx,
@@ -33,7 +33,7 @@ describe("tool.glob", () => {
) )
expect(result.metadata).toMatchObject({ expect(result.metadata).toMatchObject({
truncated: false, truncated: false,
count: 2, count: 3,
}) })
}) })
}) })
+1
View File
@@ -0,0 +1 @@
opencode-test
+5 -1
View File
@@ -26,7 +26,11 @@ func main() {
appInfoStr := os.Getenv("OPENCODE_APP_INFO") appInfoStr := os.Getenv("OPENCODE_APP_INFO")
var appInfo client.AppInfo var appInfo client.AppInfo
json.Unmarshal([]byte(appInfoStr), &appInfo) err := json.Unmarshal([]byte(appInfoStr), &appInfo)
if err != nil {
slog.Error("Failed to unmarshal app info", "error", err)
os.Exit(1)
}
logfile := filepath.Join(appInfo.Path.Data, "log", "tui.log") logfile := filepath.Join(appInfo.Path.Data, "log", "tui.log")
if _, err := os.Stat(filepath.Dir(logfile)); os.IsNotExist(err) { if _, err := os.Stat(filepath.Dir(logfile)); os.IsNotExist(err) {
+65 -26
View File
@@ -6,11 +6,13 @@ import (
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"time"
"log/slog" "log/slog"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/internal/commands" "github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/toast"
"github.com/sst/opencode/internal/config" "github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util" "github.com/sst/opencode/internal/util"
@@ -44,9 +46,12 @@ type SendMsg struct {
Text string Text string
Attachments []Attachment Attachments []Attachment
} }
type CompletionDialogTriggerdMsg struct { type CompletionDialogTriggeredMsg struct {
InitialValue string InitialValue string
} }
type OptimisticMessageAddedMsg struct {
Message client.MessageInfo
}
func New( func New(
ctx context.Context, ctx context.Context,
@@ -126,6 +131,10 @@ func (a *App) InitializeProvider() tea.Cmd {
// TODO: notify user // TODO: notify user
return nil return nil
} }
if providersResponse != nil && providersResponse.StatusCode() != 200 {
slog.Error("failed to retrieve providers", "status", providersResponse.StatusCode(), "message", string(providersResponse.Body))
return nil
}
providers := []client.ProviderInfo{} providers := []client.ProviderInfo{}
var defaultProvider *client.ProviderInfo var defaultProvider *client.ProviderInfo
var defaultModel *client.ModelInfo var defaultModel *client.ModelInfo
@@ -248,17 +257,19 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
} }
func (a *App) CompactSession(ctx context.Context) tea.Cmd { func (a *App) CompactSession(ctx context.Context) tea.Cmd {
response, err := a.Client.PostSessionSummarizeWithResponse(ctx, client.PostSessionSummarizeJSONRequestBody{ go func() {
SessionID: a.Session.Id, response, err := a.Client.PostSessionSummarizeWithResponse(ctx, client.PostSessionSummarizeJSONRequestBody{
ProviderID: a.Provider.Id, SessionID: a.Session.Id,
ModelID: a.Model.Id, ProviderID: a.Provider.Id,
}) ModelID: a.Model.Id,
if err != nil { })
slog.Error("Failed to compact session", "error", err) if err != nil {
} slog.Error("Failed to compact session", "error", err)
if response != nil && response.StatusCode() != 200 { }
slog.Error("Failed to compact session", "error", response.StatusCode) if response != nil && response.StatusCode() != 200 {
} slog.Error("Failed to compact session", "error", response.StatusCode)
}
}()
return nil return nil
} }
@@ -291,19 +302,12 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
if a.Session.Id == "" { if a.Session.Id == "" {
session, err := a.CreateSession(ctx) session, err := a.CreateSession(ctx)
if err != nil { if err != nil {
// status.Error(err.Error()) return toast.NewErrorToast(err.Error())
return nil
} }
a.Session = session a.Session = session
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session))) cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
} }
// TODO: Handle attachments when API supports them
if len(attachments) > 0 {
// For now, ignore attachments
// return "", fmt.Errorf("attachments not supported yet")
}
part := client.MessagePart{} part := client.MessagePart{}
part.FromMessagePartText(client.MessagePartText{ part.FromMessagePartText(client.MessagePartText{
Type: "text", Type: "text",
@@ -311,7 +315,26 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
}) })
parts := []client.MessagePart{part} parts := []client.MessagePart{part}
go func() { optimisticMessage := client.MessageInfo{
Id: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
Role: client.User,
Parts: parts,
Metadata: client.MessageMetadata{
SessionID: a.Session.Id,
Time: struct {
Completed *float32 `json:"completed,omitempty"`
Created float32 `json:"created"`
}{
Created: float32(time.Now().Unix()),
},
Tool: make(map[string]client.MessageMetadata_Tool_AdditionalProperties),
},
}
a.Messages = append(a.Messages, optimisticMessage)
cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage}))
cmds = append(cmds, func() tea.Msg {
response, err := a.Client.PostSessionChat(ctx, client.PostSessionChatJSONRequestBody{ response, err := a.Client.PostSessionChat(ctx, client.PostSessionChatJSONRequestBody{
SessionID: a.Session.Id, SessionID: a.Session.Id,
Parts: parts, Parts: parts,
@@ -319,14 +342,17 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
ModelID: a.Model.Id, ModelID: a.Model.Id,
}) })
if err != nil { if err != nil {
slog.Error("Failed to send message", "error", err) errormsg := fmt.Sprintf("failed to send message: %v", err)
// status.Error(err.Error()) slog.Error(errormsg)
return toast.NewErrorToast(errormsg)()
} }
if response != nil && response.StatusCode != 200 { if response != nil && response.StatusCode != 200 {
slog.Error("Failed to send message", "error", fmt.Sprintf("failed to send message: %d", response.StatusCode)) errormsg := fmt.Sprintf("failed to send message: %d", response.StatusCode)
// status.Error(fmt.Sprintf("failed to send message: %d", response.StatusCode)) slog.Error(errormsg)
return toast.NewErrorToast(errormsg)()
} }
}() return nil
})
// The actual response will come through SSE // The actual response will come through SSE
// For now, just return success // For now, just return success
@@ -370,6 +396,19 @@ func (a *App) ListSessions(ctx context.Context) ([]client.SessionInfo, error) {
return sessions, nil return sessions, nil
} }
func (a *App) DeleteSession(ctx context.Context, sessionID string) error {
resp, err := a.Client.PostSessionDeleteWithResponse(ctx, client.PostSessionDeleteJSONRequestBody{
SessionID: sessionID,
})
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return fmt.Errorf("failed to delete session: %d", resp.StatusCode())
}
return nil
}
func (a *App) ListMessages(ctx context.Context, sessionId string) ([]client.MessageInfo, error) { func (a *App) ListMessages(ctx context.Context, sessionId string) ([]client.MessageInfo, error) {
resp, err := a.Client.PostSessionMessagesWithResponse(ctx, client.PostSessionMessagesJSONRequestBody{SessionID: sessionId}) resp, err := a.Client.PostSessionMessagesWithResponse(ctx, client.PostSessionMessagesJSONRequestBody{SessionID: sessionId})
if err != nil { if err != nil {
+45 -18
View File
@@ -32,17 +32,19 @@ type EditorComponent interface {
Newline() (tea.Model, tea.Cmd) Newline() (tea.Model, tea.Cmd)
Previous() (tea.Model, tea.Cmd) Previous() (tea.Model, tea.Cmd)
Next() (tea.Model, tea.Cmd) Next() (tea.Model, tea.Cmd)
SetInterruptKeyInDebounce(inDebounce bool)
} }
type editorComponent struct { type editorComponent struct {
app *app.App app *app.App
width, height int width, height int
textarea textarea.Model textarea textarea.Model
attachments []app.Attachment attachments []app.Attachment
history []string history []string
historyIndex int historyIndex int
currentMessage string currentMessage string
spinner spinner.Model spinner spinner.Model
interruptKeyInDebounce bool
} }
func (m *editorComponent) Init() tea.Cmd { func (m *editorComponent) Init() tea.Cmd {
@@ -78,8 +80,15 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
} else { } else {
existingValue := m.textarea.Value() existingValue := m.textarea.Value()
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
m.textarea.SetValue(modifiedValue + " ") // Replace the current token (after last space)
lastSpaceIndex := strings.LastIndex(existingValue, " ")
if lastSpaceIndex == -1 {
m.textarea.SetValue(msg.CompletionValue + " ")
} else {
modifiedValue := existingValue[:lastSpaceIndex+1] + msg.CompletionValue
m.textarea.SetValue(modifiedValue + " ")
}
return m, nil return m, nil
} }
} }
@@ -115,9 +124,14 @@ func (m *editorComponent) Content() string {
Background(t.BackgroundElement()). Background(t.BackgroundElement()).
Render(textarea) Render(textarea)
hint := base("enter") + muted(" send ") hint := base(m.getSubmitKeyText()) + muted(" send ")
if m.app.IsBusy() { if m.app.IsBusy() {
hint = muted("working") + m.spinner.View() + muted(" ") + base("esc") + muted(" interrupt") keyText := m.getInterruptKeyText()
if m.interruptKeyInDebounce {
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText+" again") + muted(" interrupt")
} else {
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
}
} }
model := "" model := ""
@@ -263,6 +277,18 @@ func (m *editorComponent) Next() (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
m.interruptKeyInDebounce = inDebounce
}
func (m *editorComponent) getInterruptKeyText() string {
return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
}
func (m *editorComponent) getSubmitKeyText() string {
return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
}
func createTextArea(existing *textarea.Model) textarea.Model { func createTextArea(existing *textarea.Model) textarea.Model {
t := theme.CurrentTheme() t := theme.CurrentTheme()
bgColor := t.BackgroundElement() bgColor := t.BackgroundElement()
@@ -311,11 +337,12 @@ func NewEditorComponent(app *app.App) EditorComponent {
ta := createTextArea(nil) ta := createTextArea(nil)
return &editorComponent{ return &editorComponent{
app: app, app: app,
textarea: ta, textarea: ta,
history: []string{}, history: []string{},
historyIndex: 0, historyIndex: 0,
currentMessage: "", currentMessage: "",
spinner: s, spinner: s,
interruptKeyInDebounce: false,
} }
} }
@@ -44,7 +44,6 @@ func toMarkdown(content string, width int, backgroundColor compat.AdaptiveColor)
} }
} }
} }
content = strings.Join(lines, "\n") content = strings.Join(lines, "\n")
return strings.TrimSuffix(content, "\n") return strings.TrimSuffix(content, "\n")
} }
@@ -227,7 +226,11 @@ func renderText(message client.MessageInfo, text string, author string) string {
textWidth := max(lipgloss.Width(text), lipgloss.Width(info)) textWidth := max(lipgloss.Width(text), lipgloss.Width(info))
markdownWidth := min(textWidth, width-padding-4) // -4 for the border and padding markdownWidth := min(textWidth, width-padding-4) // -4 for the border and padding
if message.Role == client.Assistant { if message.Role == client.Assistant {
markdownWidth = width - padding - 4 markdownWidth = width - padding - 4 - 2
}
if message.Role == client.User {
text = strings.ReplaceAll(text, "<", "\\<")
text = strings.ReplaceAll(text, ">", "\\>")
} }
content := toMarkdown(text, markdownWidth, t.BackgroundPanel()) content := toMarkdown(text, markdownWidth, t.BackgroundPanel())
content = strings.Join([]string{content, info}, "\n") content = strings.Join([]string{content, info}, "\n")
@@ -250,7 +253,7 @@ func renderText(message client.MessageInfo, text string, author string) string {
func renderToolInvocation( func renderToolInvocation(
toolCall client.MessageToolInvocationToolCall, toolCall client.MessageToolInvocationToolCall,
result *string, result *string,
metadata client.MessageInfo_Metadata_Tool_AdditionalProperties, metadata client.MessageMetadata_Tool_AdditionalProperties,
showDetails bool, showDetails bool,
isLast bool, isLast bool,
contentOnly bool, contentOnly bool,
@@ -463,7 +466,7 @@ func renderToolInvocation(
if metadata, ok := call["metadata"].(map[string]any); ok { if metadata, ok := call["metadata"].(map[string]any); ok {
data, _ = json.Marshal(metadata) data, _ = json.Marshal(metadata)
var toolMetadata client.MessageInfo_Metadata_Tool_AdditionalProperties var toolMetadata client.MessageMetadata_Tool_AdditionalProperties
_ = json.Unmarshal(data, &toolMetadata) _ = json.Unmarshal(data, &toolMetadata)
step := renderToolInvocation( step := renderToolInvocation(
@@ -58,6 +58,12 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.viewport.GotoBottom() m.viewport.GotoBottom()
m.tail = true m.tail = true
return m, nil return m, nil
case app.OptimisticMessageAddedMsg:
m.renderView()
if m.tail {
m.viewport.GotoBottom()
}
return m, nil
case dialog.ThemeSelectedMsg: case dialog.ThemeSelectedMsg:
m.cache.Clear() m.cache.Clear()
return m, m.Reload() return m, m.Reload()
@@ -171,7 +177,7 @@ func (m *messagesComponent) renderView() {
isLastToolInvocation := slices.Contains(lastToolIndices, i) isLastToolInvocation := slices.Contains(lastToolIndices, i)
toolInvocationPart := part.(client.MessagePartToolInvocation) toolInvocationPart := part.(client.MessagePartToolInvocation)
toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall() toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
metadata := client.MessageInfo_Metadata_Tool_AdditionalProperties{} metadata := client.MessageMetadata_Tool_AdditionalProperties{}
if _, ok := message.Metadata.Tool[toolCall.ToolCallId]; ok { if _, ok := message.Metadata.Tool[toolCall.ToolCallId]; ok {
metadata = message.Metadata.Tool[toolCall.ToolCallId] metadata = message.Metadata.Tool[toolCall.ToolCallId]
} }
@@ -25,6 +25,7 @@ type commandsComponent struct {
app *app.App app *app.App
width, height int width, height int
showKeybinds bool showKeybinds bool
showAll bool
background *compat.AdaptiveColor background *compat.AdaptiveColor
limit *int limit *int
} }
@@ -75,17 +76,32 @@ func (c *commandsComponent) View() string {
keybindStyle = keybindStyle.Background(*c.background) keybindStyle = keybindStyle.Background(*c.background)
} }
var commandsWithTriggers []commands.Command var commandsToShow []commands.Command
var triggeredCommands []commands.Command
var untriggeredCommands []commands.Command
for _, cmd := range c.app.Commands.Sorted() { for _, cmd := range c.app.Commands.Sorted() {
if cmd.Trigger != "" { if c.showAll || cmd.Trigger != "" {
commandsWithTriggers = append(commandsWithTriggers, cmd) if cmd.Trigger != "" {
triggeredCommands = append(triggeredCommands, cmd)
} else if c.showAll {
untriggeredCommands = append(untriggeredCommands, cmd)
}
} }
} }
if c.limit != nil && len(commandsWithTriggers) > *c.limit {
commandsWithTriggers = commandsWithTriggers[:*c.limit] // Combine triggered commands first, then untriggered
commandsToShow = append(commandsToShow, triggeredCommands...)
commandsToShow = append(commandsToShow, untriggeredCommands...)
if c.limit != nil && len(commandsToShow) > *c.limit {
commandsToShow = commandsToShow[:*c.limit]
} }
if len(commandsWithTriggers) == 0 { if len(commandsToShow) == 0 {
if c.showAll {
return styles.Muted().Render("No commands available")
}
return styles.Muted().Render("No commands with triggers available") return styles.Muted().Render("No commands with triggers available")
} }
@@ -101,10 +117,15 @@ func (c *commandsComponent) View() string {
keybinds string keybinds string
} }
rows := make([]commandRow, 0, len(commandsWithTriggers)) rows := make([]commandRow, 0, len(commandsToShow))
for _, cmd := range commandsWithTriggers { for _, cmd := range commandsToShow {
trigger := "/" + cmd.Trigger trigger := ""
if cmd.Trigger != "" {
trigger = "/" + cmd.Trigger
} else {
trigger = string(cmd.Name)
}
description := cmd.Description description := cmd.Description
// Format keybindings // Format keybindings
@@ -144,6 +165,7 @@ func (c *commandsComponent) View() string {
// Build the output // Build the output
var output strings.Builder var output strings.Builder
maxWidth := 0
for _, row := range rows { for _, row := range rows {
// Pad each column to align properly // Pad each column to align properly
trigger := fmt.Sprintf("%-*s", maxTriggerWidth, row.trigger) trigger := fmt.Sprintf("%-*s", maxTriggerWidth, row.trigger)
@@ -160,10 +182,14 @@ func (c *commandsComponent) View() string {
} }
output.WriteString(line + "\n") output.WriteString(line + "\n")
maxWidth = max(maxWidth, lipgloss.Width(line))
} }
// Remove trailing newline // Remove trailing newline
result := strings.TrimSuffix(output.String(), "\n") result := strings.TrimSuffix(output.String(), "\n")
if c.background != nil {
result = lipgloss.NewStyle().Background(c.background).Width(maxWidth).Render(result)
}
return result return result
} }
@@ -188,11 +214,18 @@ func WithLimit(limit int) Option {
} }
} }
func WithShowAll(showAll bool) Option {
return func(c *commandsComponent) {
c.showAll = showAll
}
}
func New(app *app.App, opts ...Option) CommandsComponent { func New(app *app.App, opts ...Option) CommandsComponent {
c := &commandsComponent{ c := &commandsComponent{
app: app, app: app,
background: nil, background: nil,
showKeybinds: true, showKeybinds: true,
showAll: false,
} }
for _, opt := range opts { for _, opt := range opts {
opt(c) opt(c)
@@ -116,7 +116,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case []CompletionItemI: case []CompletionItemI:
c.list.SetItems(msg) c.list.SetItems(msg)
case app.CompletionDialogTriggerdMsg: case app.CompletionDialogTriggeredMsg:
c.pseudoSearchTextArea.SetValue(msg.InitialValue) c.pseudoSearchTextArea.SetValue(msg.InitialValue)
case tea.KeyMsg: case tea.KeyMsg:
if c.pseudoSearchTextArea.Focused() { if c.pseudoSearchTextArea.Focused() {
+43 -45
View File
@@ -1,71 +1,62 @@
package dialog package dialog
import ( import (
"strings" "github.com/charmbracelet/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands" commandsComponent "github.com/sst/opencode/internal/components/commands"
"github.com/sst/opencode/internal/components/modal" "github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
) )
type helpDialog struct { type helpDialog struct {
width int width int
height int height int
modal *modal.Modal modal *modal.Modal
commands []commands.Command app *app.App
commandsComponent commandsComponent.CommandsComponent
viewport viewport.Model
} }
func (h *helpDialog) Init() tea.Cmd { func (h *helpDialog) Init() tea.Cmd {
return nil return tea.Batch(
h.commandsComponent.Init(),
h.viewport.Init(),
)
} }
func (h *helpDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (h *helpDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
h.width = msg.Width h.width = msg.Width
h.height = msg.Height h.height = msg.Height
// Set viewport size with some padding for the modal
h.viewport = viewport.New(viewport.WithWidth(msg.Width-4), viewport.WithHeight(msg.Height-6))
h.commandsComponent.SetSize(msg.Width-4, msg.Height-6)
} }
return h, nil
// Update commands component first to get the latest content
_, cmdCmd := h.commandsComponent.Update(msg)
cmds = append(cmds, cmdCmd)
// Update viewport content
h.viewport.SetContent(h.commandsComponent.View())
// Update viewport
var vpCmd tea.Cmd
h.viewport, vpCmd = h.viewport.Update(msg)
cmds = append(cmds, vpCmd)
return h, tea.Batch(cmds...)
} }
func (h *helpDialog) View() string { func (h *helpDialog) View() string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
keyStyle := lipgloss.NewStyle(). h.commandsComponent.SetBackgroundColor(t.BackgroundElement())
Background(t.BackgroundElement()). return h.viewport.View()
Foreground(t.Text()).
Bold(true)
descStyle := lipgloss.NewStyle().
Background(t.BackgroundElement()).
Foreground(t.TextMuted())
contentStyle := lipgloss.NewStyle().
PaddingLeft(1).Background(t.BackgroundElement())
lines := []string{}
for _, b := range h.commands {
// Only interested in slash commands
if b.Trigger == "" {
continue
}
content := keyStyle.Render("/" + b.Trigger)
content += descStyle.Render(" " + b.Description)
// for i, key := range b.Keybindings {
// if i == 0 {
// keyString := " (" + key.Key + ")"
// space := max(h.width-lipgloss.Width(content)-lipgloss.Width(keyString), 0)
// spacer := strings.Repeat(" ", space)
// content += descStyle.Render(spacer)
// content += descStyle.Render(keyString)
// }
// }
lines = append(lines, contentStyle.Render(content))
}
return strings.Join(lines, "\n")
} }
func (h *helpDialog) Render(background string) string { func (h *helpDialog) Render(background string) string {
@@ -80,9 +71,16 @@ type HelpDialog interface {
layout.Modal layout.Modal
} }
func NewHelpDialog(commands []commands.Command) HelpDialog { func NewHelpDialog(app *app.App) HelpDialog {
vp := viewport.New(viewport.WithHeight(12))
return &helpDialog{ return &helpDialog{
commands: commands, app: app,
commandsComponent: commandsComponent.New(app,
commandsComponent.WithBackground(theme.CurrentTheme().BackgroundElement()),
commandsComponent.WithShowAll(true),
commandsComponent.WithKeybinds(true),
),
modal: modal.New(modal.WithTitle("Help")), modal: modal.New(modal.WithTitle("Help")),
viewport: vp,
} }
} }
+69 -151
View File
@@ -11,6 +11,7 @@ import (
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal" "github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
@@ -33,20 +34,15 @@ type modelDialog struct {
app *app.App app *app.App
availableProviders []client.ProviderInfo availableProviders []client.ProviderInfo
provider client.ProviderInfo provider client.ProviderInfo
width int
selectedIdx int height int
width int hScrollOffset int
height int hScrollPossible bool
scrollOffset int modal *modal.Modal
hScrollOffset int modelList list.List[list.StringItem]
hScrollPossible bool
modal *modal.Modal
} }
type modelKeyMap struct { type modelKeyMap struct {
Up key.Binding
Down key.Binding
Left key.Binding Left key.Binding
Right key.Binding Right key.Binding
Enter key.Binding Enter key.Binding
@@ -54,14 +50,6 @@ type modelKeyMap struct {
} }
var modelKeys = modelKeyMap{ var modelKeys = modelKeyMap{
Up: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑", "previous model"),
),
Down: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓", "next model"),
),
Left: key.NewBinding( Left: key.NewBinding(
key.WithKeys("left", "h"), key.WithKeys("left", "h"),
key.WithHelp("←", "scroll left"), key.WithHelp("←", "scroll left"),
@@ -81,15 +69,7 @@ var modelKeys = modelKeyMap{
} }
func (m *modelDialog) Init() tea.Cmd { func (m *modelDialog) Init() tea.Cmd {
// cfg := config.Get() m.setupModelsForProvider(m.provider.Id)
// modelInfo := GetSelectedModel(cfg)
// m.availableProviders = getEnabledProviders(cfg)
// m.hScrollPossible = len(m.availableProviders) > 1
// m.provider = modelInfo.Provider
// m.hScrollOffset = findProviderIndex(m.availableProviders, m.provider)
// m.setupModelsForProvider(m.provider)
return nil return nil
} }
@@ -97,26 +77,32 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
switch { switch {
case key.Matches(msg, modelKeys.Up):
m.moveSelectionUp()
case key.Matches(msg, modelKeys.Down):
m.moveSelectionDown()
case key.Matches(msg, modelKeys.Left): case key.Matches(msg, modelKeys.Left):
if m.hScrollPossible { if m.hScrollPossible {
m.switchProvider(-1) m.switchProvider(-1)
} }
return m, nil
case key.Matches(msg, modelKeys.Right): case key.Matches(msg, modelKeys.Right):
if m.hScrollPossible { if m.hScrollPossible {
m.switchProvider(1) m.switchProvider(1)
} }
return m, nil
case key.Matches(msg, modelKeys.Enter): case key.Matches(msg, modelKeys.Enter):
selectedItem, _ := m.modelList.GetSelectedItem()
models := m.models() models := m.models()
var selectedModel client.ModelInfo
for _, model := range models {
if model.Name == string(selectedItem) {
selectedModel = model
break
}
}
return m, tea.Sequence( return m, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}), util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler( util.CmdHandler(
app.ModelSelectedMsg{ app.ModelSelectedMsg{
Provider: m.provider, Provider: m.provider,
Model: models[m.selectedIdx], Model: selectedModel,
}), }),
) )
case key.Matches(msg, modelKeys.Escape): case key.Matches(msg, modelKeys.Escape):
@@ -127,7 +113,10 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.height = msg.Height m.height = msg.Height
} }
return m, nil // Update the list component
updatedList, cmd := m.modelList.Update(msg)
m.modelList = updatedList.(list.List[list.StringItem])
return m, cmd
} }
func (m *modelDialog) models() []client.ModelInfo { func (m *modelDialog) models() []client.ModelInfo {
@@ -137,40 +126,9 @@ func (m *modelDialog) models() []client.ModelInfo {
return models return models
} }
// moveSelectionUp moves the selection up or wraps to bottom
func (m *modelDialog) moveSelectionUp() {
if m.selectedIdx > 0 {
m.selectedIdx--
} else {
m.selectedIdx = len(m.provider.Models) - 1
m.scrollOffset = max(0, len(m.provider.Models)-numVisibleModels)
}
// Keep selection visible
if m.selectedIdx < m.scrollOffset {
m.scrollOffset = m.selectedIdx
}
}
// moveSelectionDown moves the selection down or wraps to top
func (m *modelDialog) moveSelectionDown() {
if m.selectedIdx < len(m.provider.Models)-1 {
m.selectedIdx++
} else {
m.selectedIdx = 0
m.scrollOffset = 0
}
// Keep selection visible
if m.selectedIdx >= m.scrollOffset+numVisibleModels {
m.scrollOffset = m.selectedIdx - (numVisibleModels - 1)
}
}
func (m *modelDialog) switchProvider(offset int) { func (m *modelDialog) switchProvider(offset int) {
newOffset := m.hScrollOffset + offset newOffset := m.hScrollOffset + offset
// Ensure we stay within bounds
if newOffset < 0 { if newOffset < 0 {
newOffset = len(m.availableProviders) - 1 newOffset = len(m.availableProviders) - 1
} }
@@ -185,105 +143,46 @@ func (m *modelDialog) switchProvider(offset int) {
} }
func (m *modelDialog) View() string { func (m *modelDialog) View() string {
t := theme.CurrentTheme() listView := m.modelList.View()
baseStyle := lipgloss.NewStyle().
Background(t.BackgroundElement()).
Foreground(t.Text())
// Render visible models
endIdx := min(m.scrollOffset+numVisibleModels, len(m.provider.Models))
modelItems := make([]string, 0, endIdx-m.scrollOffset)
models := m.models()
for i := m.scrollOffset; i < endIdx; i++ {
itemStyle := baseStyle.Width(maxDialogWidth)
if i == m.selectedIdx {
itemStyle = itemStyle.
Background(t.Primary()).
Foreground(t.BackgroundElement()).
Bold(true)
}
modelItems = append(modelItems, itemStyle.Render(models[i].Name))
}
scrollIndicator := m.getScrollIndicators(maxDialogWidth) scrollIndicator := m.getScrollIndicators(maxDialogWidth)
return strings.Join([]string{listView, scrollIndicator}, "\n")
content := lipgloss.JoinVertical(
lipgloss.Left,
baseStyle.
Width(maxDialogWidth).
Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)),
scrollIndicator,
)
return content
} }
func (m *modelDialog) getScrollIndicators(maxWidth int) string { func (m *modelDialog) getScrollIndicators(maxWidth int) string {
var indicator string var indicator string
if len(m.provider.Models) > numVisibleModels {
if m.scrollOffset > 0 {
indicator += "↑ "
}
if m.scrollOffset+numVisibleModels < len(m.provider.Models) {
indicator += "↓ "
}
}
if m.hScrollPossible { if m.hScrollPossible {
indicator = "← " + indicator + "→" indicator = "← → (switch provider) "
} }
if indicator == "" { if indicator == "" {
return "" return ""
} }
t := theme.CurrentTheme() t := theme.CurrentTheme()
baseStyle := styles.BaseStyle() return styles.BaseStyle().
Foreground(t.TextMuted()).
return baseStyle.
Foreground(t.Primary()).
Width(maxWidth). Width(maxWidth).
Align(lipgloss.Right). Align(lipgloss.Right).
Bold(true).
Render(indicator) Render(indicator)
} }
// findProviderIndex returns the index of the provider in the list, or -1 if not found func (m *modelDialog) setupModelsForProvider(providerId string) {
// func findProviderIndex(providers []string, provider string) int { models := m.models()
// for i, p := range providers { modelNames := make([]string, len(models))
// if p == provider { for i, model := range models {
// return i modelNames[i] = model.Name
// } }
// }
// return -1
// }
func (m *modelDialog) setupModelsForProvider(_ string) { m.modelList = list.NewStringList(modelNames, numVisibleModels, "No models available", true)
m.selectedIdx = 0 m.modelList.SetMaxWidth(maxDialogWidth)
m.scrollOffset = 0
// cfg := config.Get() if m.app.Provider != nil && m.app.Model != nil && m.app.Provider.Id == providerId {
// agentCfg := cfg.Agents[config.AgentPrimary] for i, model := range models {
// selectedModelId := agentCfg.Model if model.Id == m.app.Model.Id {
m.modelList.SetSelectedIndex(i)
// m.provider = provider break
// m.models = getModelsForProvider(provider) }
}
// Try to select the current model if it belongs to this provider }
// if provider == models.SupportedModels[selectedModelId].Provider {
// for i, model := range m.models {
// if model.ID == selectedModelId {
// m.selectedIdx = i
// // Adjust scroll position to keep selected model visible
// if m.selectedIdx >= numVisibleModels {
// m.scrollOffset = m.selectedIdx - (numVisibleModels - 1)
// }
// break
// }
// }
// }
} }
func (m *modelDialog) Render(background string) string { func (m *modelDialog) Render(background string) string {
@@ -297,11 +196,30 @@ func (s *modelDialog) Close() tea.Cmd {
func NewModelDialog(app *app.App) ModelDialog { func NewModelDialog(app *app.App) ModelDialog {
availableProviders, _ := app.ListProviders(context.Background()) availableProviders, _ := app.ListProviders(context.Background())
return &modelDialog{ currentProvider := availableProviders[0]
availableProviders: availableProviders, hScrollOffset := 0
hScrollOffset: 0, if app.Provider != nil {
hScrollPossible: len(availableProviders) > 1, for i, provider := range availableProviders {
provider: availableProviders[0], if provider.Id == app.Provider.Id {
modal: modal.New(modal.WithTitle(fmt.Sprintf("Select %s Model", availableProviders[0].Name))), currentProvider = provider
hScrollOffset = i
break
}
}
} }
dialog := &modelDialog{
app: app,
availableProviders: availableProviders,
hScrollOffset: hScrollOffset,
hScrollPossible: len(availableProviders) > 1,
provider: currentProvider,
modal: modal.New(
modal.WithTitle(fmt.Sprintf("Select %s Model", currentProvider.Name)),
modal.WithMaxWidth(maxDialogWidth+4),
),
}
dialog.setupModelsForProvider(currentProvider.Id)
return dialog
} }
@@ -2,11 +2,17 @@ package dialog
import ( import (
"context" "context"
"strings"
"slices"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/muesli/reflow/truncate"
"github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list" "github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal" "github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/components/toast"
"github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
@@ -19,33 +25,65 @@ type SessionDialog interface {
layout.Modal layout.Modal
} }
type sessionItem client.SessionInfo // sessionItem is a custom list item for sessions that can show delete confirmation
type sessionItem struct {
title string
isDeleteConfirming bool
}
func (s sessionItem) Render(selected bool, width int) string { func (s sessionItem) Render(selected bool, width int) string {
t := theme.CurrentTheme() t := theme.CurrentTheme()
baseStyle := styles.BaseStyle(). baseStyle := styles.BaseStyle()
Width(width - 4).
Background(t.BackgroundElement())
if selected { var text string
baseStyle = baseStyle. if s.isDeleteConfirming {
Background(t.Primary()). text = "Press again to confirm delete"
Foreground(t.BackgroundElement()).
Bold(true)
} else { } else {
baseStyle = baseStyle. text = s.title
Foreground(t.Text())
} }
return baseStyle.Padding(0, 1).Render(s.Title) truncatedStr := truncate.StringWithTail(text, uint(width-1), "...")
var itemStyle lipgloss.Style
if selected {
if s.isDeleteConfirming {
// Red background for delete confirmation
itemStyle = baseStyle.
Background(t.Error()).
Foreground(t.Background()).
Width(width).
PaddingLeft(1)
} else {
// Normal selection
itemStyle = baseStyle.
Background(t.Primary()).
Foreground(t.Background()).
Width(width).
PaddingLeft(1)
}
} else {
if s.isDeleteConfirming {
// Red text for delete confirmation when not selected
itemStyle = baseStyle.
Foreground(t.Error()).
PaddingLeft(1)
} else {
itemStyle = baseStyle.
PaddingLeft(1)
}
}
return itemStyle.Render(truncatedStr)
} }
type sessionDialog struct { type sessionDialog struct {
width int width int
height int height int
modal *modal.Modal modal *modal.Modal
selectedSessionID string sessions []client.SessionInfo
list list.List[sessionItem] list list.List[sessionItem]
app *app.App
deleteConfirmation int // -1 means no confirmation, >= 0 means confirming deletion of session at this index
} }
func (s *sessionDialog) Init() tea.Cmd { func (s *sessionDialog) Init() tea.Cmd {
@@ -61,13 +99,45 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyPressMsg: case tea.KeyPressMsg:
switch msg.String() { switch msg.String() {
case "enter": case "enter":
if item, idx := s.list.GetSelectedItem(); idx >= 0 { if s.deleteConfirmation >= 0 {
s.selectedSessionID = item.Id s.deleteConfirmation = -1
s.updateListItems()
return s, nil
}
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
selectedSession := s.sessions[idx]
return s, tea.Sequence( return s, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}), util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(app.SessionSelectedMsg(&item)), util.CmdHandler(app.SessionSelectedMsg(&selectedSession)),
) )
} }
case "x", "delete", "backspace":
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
if s.deleteConfirmation == idx {
// Second press - actually delete the session
sessionToDelete := s.sessions[idx]
return s, tea.Sequence(
func() tea.Msg {
s.sessions = slices.Delete(s.sessions, idx, idx+1)
s.deleteConfirmation = -1
s.updateListItems()
return nil
},
s.deleteSession(sessionToDelete.Id),
)
} else {
// First press - enter delete confirmation mode
s.deleteConfirmation = idx
s.updateListItems()
return s, nil
}
}
case "esc":
if s.deleteConfirmation >= 0 {
s.deleteConfirmation = -1
s.updateListItems()
return s, nil
}
} }
} }
@@ -78,7 +148,42 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (s *sessionDialog) Render(background string) string { func (s *sessionDialog) Render(background string) string {
return s.modal.Render(s.list.View(), background) listView := s.list.View()
t := theme.CurrentTheme()
helpStyle := styles.BaseStyle().PaddingLeft(1).PaddingTop(1)
helpText := styles.BaseStyle().Foreground(t.Text()).Render("x/del")
helpText = helpText + styles.BaseStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Render(" delete session")
helpText = helpStyle.Render(helpText)
content := strings.Join([]string{listView, helpText}, "\n")
return s.modal.Render(content, background)
}
func (s *sessionDialog) updateListItems() {
_, currentIdx := s.list.GetSelectedItem()
var items []sessionItem
for i, sess := range s.sessions {
item := sessionItem{
title: sess.Title,
isDeleteConfirming: s.deleteConfirmation == i,
}
items = append(items, item)
}
s.list.SetItems(items)
s.list.SetSelectedIndex(currentIdx)
}
func (s *sessionDialog) deleteSession(sessionID string) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
if err := s.app.DeleteSession(ctx, sessionID); err != nil {
return toast.NewErrorToast("Failed to delete session: " + err.Error())()
}
return nil
}
} }
func (s *sessionDialog) Close() tea.Cmd { func (s *sessionDialog) Close() tea.Cmd {
@@ -89,23 +194,36 @@ func (s *sessionDialog) Close() tea.Cmd {
func NewSessionDialog(app *app.App) SessionDialog { func NewSessionDialog(app *app.App) SessionDialog {
sessions, _ := app.ListSessions(context.Background()) sessions, _ := app.ListSessions(context.Background())
var sessionItems []sessionItem var filteredSessions []client.SessionInfo
var items []sessionItem
for _, sess := range sessions { for _, sess := range sessions {
if sess.ParentID != nil { if sess.ParentID != nil {
continue continue
} }
sessionItems = append(sessionItems, sessionItem(sess)) filteredSessions = append(filteredSessions, sess)
items = append(items, sessionItem{
title: sess.Title,
isDeleteConfirming: false,
})
} }
list := list.NewListComponent( // Create a generic list component
sessionItems, listComponent := list.NewListComponent(
items,
10, // maxVisibleSessions 10, // maxVisibleSessions
"No sessions available", "No sessions available",
true, // useAlphaNumericKeys true, // useAlphaNumericKeys
) )
listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
return &sessionDialog{ return &sessionDialog{
list: list, sessions: filteredSessions,
modal: modal.New(modal.WithTitle("Switch Session"), modal.WithMaxWidth(80)), list: listComponent,
app: app,
deleteConfirmation: -1,
modal: modal.New(
modal.WithTitle("Switch Session"),
modal.WithMaxWidth(layout.Current.Container.Width-8),
),
} }
} }
@@ -5,7 +5,6 @@ import (
list "github.com/sst/opencode/internal/components/list" list "github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal" "github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util" "github.com/sst/opencode/internal/util"
) )
@@ -20,35 +19,12 @@ type ThemeDialog interface {
layout.Modal layout.Modal
} }
type themeItem struct {
name string
}
func (t themeItem) Render(selected bool, width int) string {
th := theme.CurrentTheme()
baseStyle := styles.BaseStyle().
Width(width - 2).
Background(th.BackgroundElement())
if selected {
baseStyle = baseStyle.
Background(th.Primary()).
Foreground(th.BackgroundElement()).
Bold(true)
} else {
baseStyle = baseStyle.
Foreground(th.Text())
}
return baseStyle.Padding(0, 1).Render(t.name)
}
type themeDialog struct { type themeDialog struct {
width int width int
height int height int
modal *modal.Modal modal *modal.Modal
list list.List[themeItem] list list.List[list.StringItem]
originalTheme string originalTheme string
themeApplied bool themeApplied bool
} }
@@ -66,7 +42,7 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "enter": case "enter":
if item, idx := t.list.GetSelectedItem(); idx >= 0 { if item, idx := t.list.GetSelectedItem(); idx >= 0 {
selectedTheme := item.name selectedTheme := string(item)
if err := theme.SetTheme(selectedTheme); err != nil { if err := theme.SetTheme(selectedTheme); err != nil {
// status.Error(err.Error()) // status.Error(err.Error())
return t, nil return t, nil
@@ -85,11 +61,11 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd var cmd tea.Cmd
listModel, cmd := t.list.Update(msg) listModel, cmd := t.list.Update(msg)
t.list = listModel.(list.List[themeItem]) t.list = listModel.(list.List[list.StringItem])
if item, newIdx := t.list.GetSelectedItem(); newIdx >= 0 && newIdx != prevIdx { if item, newIdx := t.list.GetSelectedItem(); newIdx >= 0 && newIdx != prevIdx {
theme.SetTheme(item.name) theme.SetTheme(string(item))
return t, util.CmdHandler(ThemeSelectedMsg{ThemeName: item.name}) return t, util.CmdHandler(ThemeSelectedMsg{ThemeName: string(item)})
} }
return t, cmd return t, cmd
} }
@@ -101,6 +77,7 @@ func (t *themeDialog) Render(background string) string {
func (t *themeDialog) Close() tea.Cmd { func (t *themeDialog) Close() tea.Cmd {
if !t.themeApplied { if !t.themeApplied {
theme.SetTheme(t.originalTheme) theme.SetTheme(t.originalTheme)
return util.CmdHandler(ThemeSelectedMsg{ThemeName: t.originalTheme})
} }
return nil return nil
} }
@@ -110,17 +87,15 @@ func NewThemeDialog() ThemeDialog {
themes := theme.AvailableThemes() themes := theme.AvailableThemes()
currentTheme := theme.CurrentThemeName() currentTheme := theme.CurrentThemeName()
var themeItems []themeItem
var selectedIdx int var selectedIdx int
for i, name := range themes { for i, name := range themes {
themeItems = append(themeItems, themeItem{name: name})
if name == currentTheme { if name == currentTheme {
selectedIdx = i selectedIdx = i
} }
} }
list := list.NewListComponent( list := list.NewStringList(
themeItems, themes,
10, // maxVisibleThemes 10, // maxVisibleThemes
"No themes available", "No themes available",
true, true,
@@ -128,6 +103,9 @@ func NewThemeDialog() ThemeDialog {
// Set the initial selection to the current theme // Set the initial selection to the current theme
list.SetSelectedIndex(selectedIdx) list.SetSelectedIndex(selectedIdx)
// Set the max width for the list to match the modal width
list.SetMaxWidth(36) // 40 (modal max width) - 4 (modal padding)
return &themeDialog{ return &themeDialog{
list: list, list: list,
@@ -5,6 +5,10 @@ import (
"github.com/charmbracelet/bubbles/v2/key" "github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/muesli/reflow/truncate"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
) )
type ListItem interface { type ListItem interface {
@@ -123,6 +127,9 @@ func (c *listComponent[T]) SetSelectedIndex(idx int) {
func (c *listComponent[T]) View() string { func (c *listComponent[T]) View() string {
items := c.items items := c.items
maxWidth := c.maxWidth maxWidth := c.maxWidth
if maxWidth == 0 {
maxWidth = 80 // Default width if not set
}
maxVisibleItems := min(c.maxVisibleItems, len(items)) maxVisibleItems := min(c.maxVisibleItems, len(items))
startIdx := 0 startIdx := 0
@@ -161,3 +168,36 @@ func NewListComponent[T ListItem](items []T, maxVisibleItems int, fallbackMsg st
selectedIdx: 0, selectedIdx: 0,
} }
} }
// StringItem is a simple implementation of ListItem for string values
type StringItem string
func (s StringItem) Render(selected bool, width int) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
truncatedStr := truncate.StringWithTail(string(s), uint(width-1), "...")
var itemStyle lipgloss.Style
if selected {
itemStyle = baseStyle.
Background(t.Primary()).
Foreground(t.Background()).
Width(width).
PaddingLeft(1)
} else {
itemStyle = baseStyle.
PaddingLeft(1)
}
return itemStyle.Render(truncatedStr)
}
// NewStringList creates a new list component with string items
func NewStringList(items []string, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[StringItem] {
stringItems := make([]StringItem, len(items))
for i, item := range items {
stringItems[i] = StringItem(item)
}
return NewListComponent(stringItems, maxVisibleItems, fallbackMsg, useAlphaNumericKeys)
}
@@ -103,13 +103,13 @@ func (m *Modal) Render(contentView string, background string) string {
Bold(true). Bold(true).
Padding(0, 1) Padding(0, 1)
escStyle := baseStyle.Foreground(t.TextMuted()).Bold(false) escStyle := baseStyle.Foreground(t.TextMuted())
escText := escStyle.Render("esc") escText := escStyle.Render("esc")
// Calculate position for esc text // Calculate position for esc text
titleWidth := lipgloss.Width(m.title) titleWidth := lipgloss.Width(m.title)
escWidth := lipgloss.Width(escText) escWidth := lipgloss.Width(escText)
spacesNeeded := max(0, innerWidth-titleWidth-escWidth-3) spacesNeeded := max(0, innerWidth-titleWidth-escWidth-2)
spacer := strings.Repeat(" ", spacesNeeded) spacer := strings.Repeat(" ", spacesNeeded)
titleLine := m.title + spacer + escText titleLine := m.title + spacer + escText
titleLine = titleStyle.Render(titleLine) titleLine = titleStyle.Render(titleLine)
@@ -71,7 +71,7 @@ func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) st
formattedCost := fmt.Sprintf("$%.2f", cost) formattedCost := fmt.Sprintf("$%.2f", cost)
percentage := (float64(tokens) / float64(contextWindow)) * 100 percentage := (float64(tokens) / float64(contextWindow)) * 100
return fmt.Sprintf("Tokens: %s (%d%%), Cost: %s", formattedTokens, int(percentage), formattedCost) return fmt.Sprintf("Context: %s (%d%%), Cost: %s", formattedTokens, int(percentage), formattedCost)
} }
func (m statusComponent) View() string { func (m statusComponent) View() string {
@@ -5,8 +5,8 @@ package image
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"image"
"github.com/atotto/clipboard" "github.com/atotto/clipboard"
"image"
) )
func GetImageFromClipboard() ([]byte, string, error) { func GetImageFromClipboard() ([]byte, string, error) {
@@ -28,8 +28,6 @@ func GetImageFromClipboard() ([]byte, string, error) {
} }
func binaryToImage(data []byte) ([]byte, error) { func binaryToImage(data []byte) ([]byte, error) {
reader := bytes.NewReader(data) reader := bytes.NewReader(data)
img, _, err := image.Decode(reader) img, _, err := image.Decode(reader)
@@ -40,7 +38,6 @@ func binaryToImage(data []byte) ([]byte, error) {
return ImageToBytes(img) return ImageToBytes(img)
} }
func min(a, b int) int { func min(a, b int) int {
if a < b { if a < b {
return a return a
+52 -36
View File
@@ -109,18 +109,26 @@ func PlaceOverlay(
// Get the foreground line // Get the foreground line
fgLine := fgLines[i-y] fgLine := fgLines[i-y]
fgLineWidth := ansi.PrintableRuneWidth(fgLine) fgLineWidth := ansi.PrintableRuneWidth(fgLine)
// Extract the styles at the border positions // Extract the styles at the border positions
leftStyle := getStyleAtPosition(bgLine, pos) // We need to get the style just before the border position to preserve background
rightStyle := getStyleAtPosition(bgLine, pos + 1 + fgLineWidth) leftStyle := ansiStyle{}
if pos > 0 {
leftStyle = getStyleAtPosition(bgLine, pos-1)
} else {
leftStyle = getStyleAtPosition(bgLine, pos)
}
rightStyle := getStyleAtPosition(bgLine, pos+fgLineWidth)
// Left border - combine background from original with border foreground // Left border - combine background from original with border foreground
leftSeq := combineStyles(leftStyle, options.borderColor) leftSeq := combineStyles(leftStyle, options.borderColor)
if leftSeq != "" { if leftSeq != "" {
b.WriteString(leftSeq) b.WriteString(leftSeq)
} }
b.WriteString("┃") b.WriteString("┃")
b.WriteString("\x1b[0m") // Reset all styles if leftSeq != "" {
b.WriteString("\x1b[0m") // Reset all styles only if we applied any
}
pos++ pos++
// Content // Content
@@ -133,7 +141,9 @@ func PlaceOverlay(
b.WriteString(rightSeq) b.WriteString(rightSeq)
} }
b.WriteString("┃") b.WriteString("┃")
b.WriteString("\x1b[0m") // Reset all styles if rightSeq != "" {
b.WriteString("\x1b[0m") // Reset all styles only if we applied any
}
pos++ pos++
} else { } else {
// No border, just render the content // No border, just render the content
@@ -172,23 +182,25 @@ type ansiStyle struct {
// parseANSISequence parses an ANSI escape sequence into its components // parseANSISequence parses an ANSI escape sequence into its components
func parseANSISequence(seq string) ansiStyle { func parseANSISequence(seq string) ansiStyle {
style := ansiStyle{} style := ansiStyle{}
// Extract the parameters from the sequence (e.g., \x1b[38;5;123;48;5;456m -> "38;5;123;48;5;456") // Extract the parameters from the sequence (e.g., \x1b[38;5;123;48;5;456m -> "38;5;123;48;5;456")
if !strings.HasPrefix(seq, "\x1b[") || !strings.HasSuffix(seq, "m") { if !strings.HasPrefix(seq, "\x1b[") || !strings.HasSuffix(seq, "m") {
return style return style
} }
params := seq[2 : len(seq)-1] params := seq[2 : len(seq)-1]
if params == "" { if params == "" {
return style return style
} }
parts := strings.Split(params, ";") parts := strings.Split(params, ";")
i := 0 i := 0
for i < len(parts) { for i < len(parts) {
switch parts[i] { switch parts[i] {
case "0": // Reset case "0": // Reset
style = ansiStyle{} // Mark this as a reset by adding it to attrs
style.attrs = append(style.attrs, "0")
// Don't clear the style here, let the caller handle it
case "1", "2", "3", "4", "5", "6", "7", "8", "9": // Various attributes case "1", "2", "3", "4", "5", "6", "7", "8", "9": // Various attributes
style.attrs = append(style.attrs, parts[i]) style.attrs = append(style.attrs, parts[i])
case "38": // Foreground color case "38": // Foreground color
@@ -222,7 +234,7 @@ func parseANSISequence(seq string) ansiStyle {
} }
i++ i++
} }
return style return style
} }
@@ -231,32 +243,30 @@ func combineStyles(bgStyle ansiStyle, fgColor *compat.AdaptiveColor) string {
if fgColor == nil && bgStyle.bgColor == "" && len(bgStyle.attrs) == 0 { if fgColor == nil && bgStyle.bgColor == "" && len(bgStyle.attrs) == 0 {
return "" return ""
} }
var parts []string var parts []string
// Add attributes // Add attributes
parts = append(parts, bgStyle.attrs...) parts = append(parts, bgStyle.attrs...)
// Add background color from the original style // Add background color from the original style
if bgStyle.bgColor != "" { if bgStyle.bgColor != "" {
parts = append(parts, bgStyle.bgColor) parts = append(parts, bgStyle.bgColor)
} }
// Add foreground color if specified // Add foreground color if specified
if fgColor != nil { if fgColor != nil {
// Use the light color (could be improved to detect terminal background) // Use the adaptive color which automatically selects based on terminal background
color := (*fgColor).Light // The RGBA method already handles light/dark selection
r, g, b, _ := fgColor.RGBA()
// Use RGBA to get color components
r, g, b, _ := color.RGBA()
// RGBA returns 16-bit values, we need 8-bit // RGBA returns 16-bit values, we need 8-bit
parts = append(parts, fmt.Sprintf("38;2;%d;%d;%d", r>>8, g>>8, b>>8)) parts = append(parts, fmt.Sprintf("38;2;%d;%d;%d", r>>8, g>>8, b>>8))
} }
if len(parts) == 0 { if len(parts) == 0 {
return "" return ""
} }
return fmt.Sprintf("\x1b[%sm", strings.Join(parts, ";")) return fmt.Sprintf("\x1b[%sm", strings.Join(parts, ";"))
} }
@@ -264,10 +274,10 @@ func combineStyles(bgStyle ansiStyle, fgColor *compat.AdaptiveColor) string {
func getStyleAtPosition(s string, targetPos int) ansiStyle { func getStyleAtPosition(s string, targetPos int) ansiStyle {
// ANSI escape sequence regex // ANSI escape sequence regex
ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`) ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
visualPos := 0 visualPos := 0
currentStyle := ansiStyle{} currentStyle := ansiStyle{}
i := 0 i := 0
for i < len(s) && visualPos <= targetPos { for i < len(s) && visualPos <= targetPos {
// Check if we're at an ANSI escape sequence // Check if we're at an ANSI escape sequence
@@ -275,18 +285,24 @@ func getStyleAtPosition(s string, targetPos int) ansiStyle {
// Found an ANSI sequence at current position // Found an ANSI sequence at current position
seq := s[i : i+match[1]] seq := s[i : i+match[1]]
parsedStyle := parseANSISequence(seq) parsedStyle := parseANSISequence(seq)
// Update current style (merge with existing) // Check if this is a reset sequence
if parsedStyle.fgColor != "" { if len(parsedStyle.attrs) > 0 && parsedStyle.attrs[0] == "0" {
currentStyle.fgColor = parsedStyle.fgColor // Reset all styles
currentStyle = ansiStyle{}
} else {
// Update current style (merge with existing)
if parsedStyle.fgColor != "" {
currentStyle.fgColor = parsedStyle.fgColor
}
if parsedStyle.bgColor != "" {
currentStyle.bgColor = parsedStyle.bgColor
}
if len(parsedStyle.attrs) > 0 {
currentStyle.attrs = parsedStyle.attrs
}
} }
if parsedStyle.bgColor != "" {
currentStyle.bgColor = parsedStyle.bgColor
}
if len(parsedStyle.attrs) > 0 {
currentStyle.attrs = parsedStyle.attrs
}
i += match[1] i += match[1]
} else if i < len(s) { } else if i < len(s) {
// Regular character // Regular character
@@ -298,7 +314,7 @@ func getStyleAtPosition(s string, targetPos int) ansiStyle {
visualPos++ visualPos++
} }
} }
return currentStyle return currentStyle
} }
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"image/color" "image/color"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -42,7 +43,7 @@ func LoadThemesFromJSON() error {
continue continue
} }
themeName := strings.TrimSuffix(entry.Name(), ".json") themeName := strings.TrimSuffix(entry.Name(), ".json")
data, err := themesFS.ReadFile(filepath.Join("themes", entry.Name())) data, err := themesFS.ReadFile(path.Join("themes", entry.Name()))
if err != nil { if err != nil {
return fmt.Errorf("failed to read theme file %s: %w", entry.Name(), err) return fmt.Errorf("failed to read theme file %s: %w", entry.Name(), err)
} }
@@ -133,4 +133,3 @@ func TestLoadThemesFromDirectories(t *testing.T) {
t.Error("Override theme not properly loaded") t.Error("Override theme not properly loaded")
} }
} }
@@ -0,0 +1,77 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"matrixInk0": "#0a0e0a",
"matrixInk1": "#0e130d",
"matrixInk2": "#141c12",
"matrixInk3": "#1e2a1b",
"rainGreen": "#2eff6a",
"rainGreenDim": "#1cc24b",
"rainGreenHi": "#62ff94",
"rainCyan": "#00efff",
"rainTeal": "#24f6d9",
"rainPurple": "#c770ff",
"rainOrange": "#ffa83d",
"alertRed": "#ff4b4b",
"alertYellow": "#e6ff57",
"alertBlue": "#30b3ff",
"rainGray": "#8ca391",
"lightBg": "#eef3ea",
"lightPaper": "#e4ebe1",
"lightInk1": "#dae1d7",
"lightText": "#203022",
"lightGray": "#748476"
},
"theme": {
"primary": { "dark": "rainGreen", "light": "rainGreenDim" },
"secondary": { "dark": "rainCyan", "light": "rainTeal" },
"accent": { "dark": "rainPurple", "light": "rainPurple" },
"error": { "dark": "alertRed", "light": "alertRed" },
"warning": { "dark": "alertYellow", "light": "alertYellow" },
"success": { "dark": "rainGreenHi", "light": "rainGreenDim" },
"info": { "dark": "alertBlue", "light": "alertBlue" },
"text": { "dark": "rainGreenHi", "light": "lightText" },
"textMuted": { "dark": "rainGray", "light": "lightGray" },
"background": { "dark": "matrixInk0", "light": "lightBg" },
"backgroundPanel": { "dark": "matrixInk1", "light": "lightPaper" },
"backgroundElement": { "dark": "matrixInk2", "light": "lightInk1" },
"border": { "dark": "matrixInk3", "light": "lightGray" },
"borderActive": { "dark": "rainGreen", "light": "rainGreenDim" },
"borderSubtle": { "dark": "matrixInk2", "light": "lightInk1" },
"diffAdded": { "dark": "rainGreenDim", "light": "rainGreenDim" },
"diffRemoved": { "dark": "alertRed", "light": "alertRed" },
"diffContext": { "dark": "rainGray", "light": "lightGray" },
"diffHunkHeader": { "dark": "alertBlue", "light": "alertBlue" },
"diffHighlightAdded": { "dark": "#77ffaf", "light": "#5dac7e" },
"diffHighlightRemoved": { "dark": "#ff7171", "light": "#d53a3a" },
"diffAddedBg": { "dark": "#132616", "light": "#e0efde" },
"diffRemovedBg": { "dark": "#261212", "light": "#f9e5e5" },
"diffContextBg": { "dark": "matrixInk1", "light": "lightPaper" },
"diffLineNumber": { "dark": "matrixInk3", "light": "lightGray" },
"diffAddedLineNumberBg": { "dark": "#0f1b11", "light": "#d6e7d2" },
"diffRemovedLineNumberBg": { "dark": "#1b1414", "light": "#f2d2d2" },
"markdownText": { "dark": "rainGreenHi", "light": "lightText" },
"markdownHeading": { "dark": "rainCyan", "light": "rainTeal" },
"markdownLink": { "dark": "alertBlue", "light": "alertBlue" },
"markdownLinkText": { "dark": "rainTeal", "light": "rainTeal" },
"markdownCode": { "dark": "rainGreenDim", "light": "rainGreenDim" },
"markdownBlockQuote": { "dark": "rainGray", "light": "lightGray" },
"markdownEmph": { "dark": "rainOrange", "light": "rainOrange" },
"markdownStrong": { "dark": "alertYellow", "light": "alertYellow" },
"markdownHorizontalRule": { "dark": "rainGray", "light": "lightGray" },
"markdownListItem": { "dark": "alertBlue", "light": "alertBlue" },
"markdownListEnumeration": { "dark": "rainTeal", "light": "rainTeal" },
"markdownImage": { "dark": "alertBlue", "light": "alertBlue" },
"markdownImageText": { "dark": "rainTeal", "light": "rainTeal" },
"markdownCodeBlock": { "dark": "rainGreenHi", "light": "lightText" },
"syntaxComment": { "dark": "rainGray", "light": "lightGray" },
"syntaxKeyword": { "dark": "rainPurple", "light": "rainPurple" },
"syntaxFunction": { "dark": "alertBlue", "light": "alertBlue" },
"syntaxVariable": { "dark": "rainGreenHi", "light": "lightText" },
"syntaxString": { "dark": "rainGreenDim", "light": "rainGreenDim" },
"syntaxNumber": { "dark": "rainOrange", "light": "rainOrange" },
"syntaxType": { "dark": "alertYellow", "light": "alertYellow" },
"syntaxOperator": { "dark": "rainTeal", "light": "rainTeal" },
"syntaxPunctuation": { "dark": "rainGreenHi", "light": "lightText" }
}
}
+8 -8
View File
@@ -48,11 +48,11 @@
"light": "nord10" "light": "nord10"
}, },
"text": { "text": {
"dark": "nord4", "dark": "nord6",
"light": "nord0" "light": "nord0"
}, },
"textMuted": { "textMuted": {
"dark": "nord3", "dark": "#8B95A7",
"light": "nord1" "light": "nord1"
}, },
"background": { "background": {
@@ -64,7 +64,7 @@
"light": "nord5" "light": "nord5"
}, },
"backgroundElement": { "backgroundElement": {
"dark": "nord1", "dark": "nord2",
"light": "nord4" "light": "nord4"
}, },
"border": { "border": {
@@ -88,11 +88,11 @@
"light": "nord11" "light": "nord11"
}, },
"diffContext": { "diffContext": {
"dark": "nord3", "dark": "#8B95A7",
"light": "nord3" "light": "nord3"
}, },
"diffHunkHeader": { "diffHunkHeader": {
"dark": "nord3", "dark": "#8B95A7",
"light": "nord3" "light": "nord3"
}, },
"diffHighlightAdded": { "diffHighlightAdded": {
@@ -148,7 +148,7 @@
"light": "nord14" "light": "nord14"
}, },
"markdownBlockQuote": { "markdownBlockQuote": {
"dark": "nord3", "dark": "#8B95A7",
"light": "nord3" "light": "nord3"
}, },
"markdownEmph": { "markdownEmph": {
@@ -160,7 +160,7 @@
"light": "nord13" "light": "nord13"
}, },
"markdownHorizontalRule": { "markdownHorizontalRule": {
"dark": "nord3", "dark": "#8B95A7",
"light": "nord3" "light": "nord3"
}, },
"markdownListItem": { "markdownListItem": {
@@ -184,7 +184,7 @@
"light": "nord0" "light": "nord0"
}, },
"syntaxComment": { "syntaxComment": {
"dark": "nord3", "dark": "#8B95A7",
"light": "nord3" "light": "nord3"
}, },
"syntaxKeyword": { "syntaxKeyword": {
+79 -8
View File
@@ -6,6 +6,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
"time"
"github.com/charmbracelet/bubbles/v2/key" "github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
@@ -25,6 +26,19 @@ import (
"github.com/sst/opencode/pkg/client" "github.com/sst/opencode/pkg/client"
) )
// InterruptDebounceTimeoutMsg is sent when the interrupt key debounce timeout expires
type InterruptDebounceTimeoutMsg struct{}
// InterruptKeyState tracks the state of interrupt key presses for debouncing
type InterruptKeyState int
const (
InterruptKeyIdle InterruptKeyState = iota
InterruptKeyFirstPress
)
const interruptDebounceTimeout = 1 * time.Second
type appModel struct { type appModel struct {
width, height int width, height int
app *app.App app *app.App
@@ -40,6 +54,7 @@ type appModel struct {
leaderBinding *key.Binding leaderBinding *key.Binding
isLeaderSequence bool isLeaderSequence bool
toastManager *toast.ToastManager toastManager *toast.ToastManager
interruptKeyState InterruptKeyState
} }
func (a appModel) Init() tea.Cmd { func (a appModel) Init() tea.Cmd {
@@ -117,7 +132,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
updated, cmd := a.completions.Update( updated, cmd := a.completions.Update(
app.CompletionDialogTriggerdMsg{ app.CompletionDialogTriggeredMsg{
InitialValue: initialValue, InitialValue: initialValue,
}, },
) )
@@ -171,14 +186,37 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil return a, nil
} }
// 6. Check again for commands that don't require leader // 6. Handle interrupt key debounce for session interrupt
interruptCommand := a.app.Commands[commands.SessionInterruptCommand]
if interruptCommand.Matches(msg, a.isLeaderSequence) && a.app.IsBusy() {
switch a.interruptKeyState {
case InterruptKeyIdle:
// First interrupt key press - start debounce timer
a.interruptKeyState = InterruptKeyFirstPress
a.editor.SetInterruptKeyInDebounce(true)
return a, tea.Tick(interruptDebounceTimeout, func(t time.Time) tea.Msg {
return InterruptDebounceTimeoutMsg{}
})
case InterruptKeyFirstPress:
// Second interrupt key press within timeout - actually interrupt
a.interruptKeyState = InterruptKeyIdle
a.editor.SetInterruptKeyInDebounce(false)
return a, util.CmdHandler(commands.ExecuteCommandMsg(interruptCommand))
}
}
// 7. Check again for commands that don't require leader (excluding interrupt when busy)
matches := a.app.Commands.Matches(msg, a.isLeaderSequence) matches := a.app.Commands.Matches(msg, a.isLeaderSequence)
if len(matches) > 0 { if len(matches) > 0 {
// Skip interrupt key if we're in debounce mode and app is busy
if interruptCommand.Matches(msg, a.isLeaderSequence) && a.app.IsBusy() && a.interruptKeyState != InterruptKeyIdle {
return a, nil
}
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches)) return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
} }
// 7. Fallback to editor. This is for other characters // 7. Fallback to editor. This is for other characters
// likek backspace, tab, etc. // like backspace, tab, etc.
updatedEditor, cmd := a.editor.Update(msg) updatedEditor, cmd := a.editor.Update(msg)
a.editor = updatedEditor.(chat.EditorComponent) a.editor = updatedEditor.(chat.EditorComponent)
return a, cmd return a, cmd
@@ -189,6 +227,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
updated, cmd := a.messages.Update(msg) updated, cmd := a.messages.Update(msg)
a.messages = updated.(chat.MessagesComponent) a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
return a, tea.Batch(cmds...)
case tea.BackgroundColorMsg: case tea.BackgroundColorMsg:
styles.Terminal = &styles.TerminalInfo{ styles.Terminal = &styles.TerminalInfo{
BackgroundIsDark: msg.IsDark(), BackgroundIsDark: msg.IsDark(),
@@ -222,6 +261,12 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
"opencode updated to "+msg.Properties.Version+", restart to apply.", "opencode updated to "+msg.Properties.Version+", restart to apply.",
toast.WithTitle("New version installed"), toast.WithTitle("New version installed"),
) )
case client.EventSessionDeleted:
if a.app.Session != nil && msg.Properties.Info.Id == a.app.Session.Id {
a.app.Session = &client.SessionInfo{}
a.app.Messages = []client.MessageInfo{}
}
return a, toast.NewSuccessToast("Session deleted successfully")
case client.EventSessionUpdated: case client.EventSessionUpdated:
if msg.Properties.Info.Id == a.app.Session.Id { if msg.Properties.Info.Id == a.app.Session.Id {
a.app.Session = &msg.Properties.Info a.app.Session = &msg.Properties.Info
@@ -229,12 +274,33 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case client.EventMessageUpdated: case client.EventMessageUpdated:
if msg.Properties.Info.Metadata.SessionID == a.app.Session.Id { if msg.Properties.Info.Metadata.SessionID == a.app.Session.Id {
exists := false exists := false
for i, m := range a.app.Messages { optimisticReplaced := false
if m.Id == msg.Properties.Info.Id {
a.app.Messages[i] = msg.Properties.Info // First check if this is replacing an optimistic message
exists = true if msg.Properties.Info.Role == client.User {
// Look for optimistic messages to replace
for i, m := range a.app.Messages {
if strings.HasPrefix(m.Id, "optimistic-") && m.Role == client.User {
// Replace the optimistic message with the real one
a.app.Messages[i] = msg.Properties.Info
exists = true
optimisticReplaced = true
break
}
} }
} }
// If not replacing optimistic, check for existing message with same ID
if !optimisticReplaced {
for i, m := range a.app.Messages {
if m.Id == msg.Properties.Info.Id {
a.app.Messages[i] = msg.Properties.Info
exists = true
break
}
}
}
if !exists { if !exists {
a.app.Messages = append(a.app.Messages, msg.Properties.Info) a.app.Messages = append(a.app.Messages, msg.Properties.Info)
} }
@@ -283,6 +349,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
tm, cmd := a.toastManager.Update(msg) tm, cmd := a.toastManager.Update(msg)
a.toastManager = tm a.toastManager = tm
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
case InterruptDebounceTimeoutMsg:
// Reset interrupt key state after timeout
a.interruptKeyState = InterruptKeyIdle
a.editor.SetInterruptKeyInDebounce(false)
} }
// update status bar // update status bar
@@ -363,7 +433,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
} }
switch command.Name { switch command.Name {
case commands.AppHelpCommand: case commands.AppHelpCommand:
helpDialog := dialog.NewHelpDialog(a.app.Commands.Sorted()) helpDialog := dialog.NewHelpDialog(a.app)
a.modal = helpDialog a.modal = helpDialog
case commands.EditorOpenCommand: case commands.EditorOpenCommand:
if a.app.IsBusy() { if a.app.IsBusy() {
@@ -575,6 +645,7 @@ func NewModel(app *app.App) tea.Model {
showCompletionDialog: false, showCompletionDialog: false,
editorContainer: editorContainer, editorContainer: editorContainer,
toastManager: toast.NewToastManager(), toastManager: toast.NewToastManager(),
interruptKeyState: InterruptKeyIdle,
layout: layout.NewFlexLayout( layout: layout.NewFlexLayout(
[]tea.ViewModel{messagesContainer, editorContainer}, []tea.ViewModel{messagesContainer, editorContainer},
layout.WithDirection(layout.FlexDirectionVertical), layout.WithDirection(layout.FlexDirectionVertical),
+282 -159
View File
@@ -230,6 +230,42 @@
} }
} }
}, },
"/session_unshare": {
"post": {
"responses": {
"200": {
"description": "Successfully unshared session",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/session.info"
}
}
}
}
},
"operationId": "postSession_unshare",
"parameters": [],
"description": "Unshare the session",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"sessionID": {
"type": "string"
}
},
"required": [
"sessionID"
]
}
}
}
}
}
},
"/session_messages": { "/session_messages": {
"post": { "post": {
"responses": { "responses": {
@@ -327,6 +363,42 @@
} }
} }
}, },
"/session_delete": {
"post": {
"responses": {
"200": {
"description": "Successfully deleted session",
"content": {
"application/json": {
"schema": {
"type": "boolean"
}
}
}
}
},
"operationId": "postSession_delete",
"parameters": [],
"description": "Delete a session and all its data",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"sessionID": {
"type": "string"
}
},
"required": [
"sessionID"
]
}
}
}
}
}
},
"/session_summarize": { "/session_summarize": {
"post": { "post": {
"responses": { "responses": {
@@ -543,6 +615,9 @@
{ {
"$ref": "#/components/schemas/Event.session.updated" "$ref": "#/components/schemas/Event.session.updated"
}, },
{
"$ref": "#/components/schemas/Event.session.deleted"
},
{ {
"$ref": "#/components/schemas/Event.session.error" "$ref": "#/components/schemas/Event.session.error"
} }
@@ -557,6 +632,7 @@
"message.updated": "#/components/schemas/Event.message.updated", "message.updated": "#/components/schemas/Event.message.updated",
"message.part.updated": "#/components/schemas/Event.message.part.updated", "message.part.updated": "#/components/schemas/Event.message.part.updated",
"session.updated": "#/components/schemas/Event.session.updated", "session.updated": "#/components/schemas/Event.session.updated",
"session.deleted": "#/components/schemas/Event.session.deleted",
"session.error": "#/components/schemas/Event.session.error" "session.error": "#/components/schemas/Event.session.error"
} }
} }
@@ -734,160 +810,7 @@
} }
}, },
"metadata": { "metadata": {
"type": "object", "$ref": "#/components/schemas/Message.Metadata"
"properties": {
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
},
"completed": {
"type": "number"
}
},
"required": [
"created"
]
},
"error": {
"oneOf": [
{
"$ref": "#/components/schemas/ProviderAuthError"
},
{
"$ref": "#/components/schemas/UnknownError"
}
],
"discriminator": {
"propertyName": "name",
"mapping": {
"ProviderAuthError": "#/components/schemas/ProviderAuthError",
"UnknownError": "#/components/schemas/UnknownError"
}
}
},
"sessionID": {
"type": "string"
},
"tool": {
"type": "object",
"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"
},
"summary": {
"type": "boolean"
},
"tokens": {
"type": "object",
"properties": {
"input": {
"type": "number"
},
"output": {
"type": "number"
},
"reasoning": {
"type": "number"
},
"cache": {
"type": "object",
"properties": {
"read": {
"type": "number"
},
"write": {
"type": "number"
}
},
"required": [
"read",
"write"
]
}
},
"required": [
"input",
"output",
"reasoning",
"cache"
]
}
},
"required": [
"system",
"modelID",
"providerID",
"path",
"cost",
"tokens"
]
}
},
"required": [
"time",
"sessionID",
"tool"
]
} }
}, },
"required": [ "required": [
@@ -1141,6 +1064,162 @@
"type" "type"
] ]
}, },
"Message.Metadata": {
"type": "object",
"properties": {
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
},
"completed": {
"type": "number"
}
},
"required": [
"created"
]
},
"error": {
"oneOf": [
{
"$ref": "#/components/schemas/ProviderAuthError"
},
{
"$ref": "#/components/schemas/UnknownError"
}
],
"discriminator": {
"propertyName": "name",
"mapping": {
"ProviderAuthError": "#/components/schemas/ProviderAuthError",
"UnknownError": "#/components/schemas/UnknownError"
}
}
},
"sessionID": {
"type": "string"
},
"tool": {
"type": "object",
"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"
},
"summary": {
"type": "boolean"
},
"tokens": {
"type": "object",
"properties": {
"input": {
"type": "number"
},
"output": {
"type": "number"
},
"reasoning": {
"type": "number"
},
"cache": {
"type": "object",
"properties": {
"read": {
"type": "number"
},
"write": {
"type": "number"
}
},
"required": [
"read",
"write"
]
}
},
"required": [
"input",
"output",
"reasoning",
"cache"
]
}
},
"required": [
"system",
"modelID",
"providerID",
"path",
"cost",
"tokens"
]
}
},
"required": [
"time",
"sessionID",
"tool"
]
},
"ProviderAuthError": { "ProviderAuthError": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1300,6 +1379,30 @@
"time" "time"
] ]
}, },
"Event.session.deleted": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "session.deleted"
},
"properties": {
"type": "object",
"properties": {
"info": {
"$ref": "#/components/schemas/session.info"
}
},
"required": [
"info"
]
}
},
"required": [
"type",
"properties"
]
},
"Event.session.error": { "Event.session.error": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1461,6 +1564,9 @@
"temperature": { "temperature": {
"type": "boolean" "type": "boolean"
}, },
"tool_call": {
"type": "boolean"
},
"cost": { "cost": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1499,6 +1605,10 @@
}, },
"id": { "id": {
"type": "string" "type": "string"
},
"options": {
"type": "object",
"additionalProperties": {}
} }
} }
} }
@@ -1535,7 +1645,8 @@
}, },
"description": "MCP (Model Context Protocol) server configurations" "description": "MCP (Model Context Protocol) server configurations"
} }
} },
"additionalProperties": false
}, },
"Config.Keybinds": { "Config.Keybinds": {
"type": "object", "type": "object",
@@ -1648,7 +1759,8 @@
"type": "string", "type": "string",
"description": "Exit the application" "description": "Exit the application"
} }
} },
"additionalProperties": false
}, },
"Provider.Info": { "Provider.Info": {
"type": "object", "type": "object",
@@ -1700,6 +1812,9 @@
"temperature": { "temperature": {
"type": "boolean" "type": "boolean"
}, },
"tool_call": {
"type": "boolean"
},
"cost": { "cost": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1738,6 +1853,10 @@
}, },
"id": { "id": {
"type": "string" "type": "string"
},
"options": {
"type": "object",
"additionalProperties": {}
} }
}, },
"required": [ "required": [
@@ -1745,9 +1864,11 @@
"attachment", "attachment",
"reasoning", "reasoning",
"temperature", "temperature",
"tool_call",
"cost", "cost",
"limit", "limit",
"id" "id",
"options"
] ]
}, },
"Config.McpLocal": { "Config.McpLocal": {
@@ -1776,7 +1897,8 @@
"required": [ "required": [
"type", "type",
"command" "command"
] ],
"additionalProperties": false
}, },
"Config.McpRemote": { "Config.McpRemote": {
"type": "object", "type": "object",
@@ -1794,7 +1916,8 @@
"required": [ "required": [
"type", "type",
"url" "url"
] ],
"additionalProperties": false
}, },
"Error": { "Error": {
"type": "object", "type": "object",
+408 -69
View File
@@ -78,9 +78,11 @@ type ConfigInfo struct {
Context float32 `json:"context"` Context float32 `json:"context"`
Output float32 `json:"output"` Output float32 `json:"output"`
} `json:"limit,omitempty"` } `json:"limit,omitempty"`
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
Reasoning *bool `json:"reasoning,omitempty"` Options *map[string]interface{} `json:"options,omitempty"`
Temperature *bool `json:"temperature,omitempty"` Reasoning *bool `json:"reasoning,omitempty"`
Temperature *bool `json:"temperature,omitempty"`
ToolCall *bool `json:"tool_call,omitempty"`
} `json:"models"` } `json:"models"`
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
Npm *string `json:"npm,omitempty"` Npm *string `json:"npm,omitempty"`
@@ -252,6 +254,14 @@ type EventPermissionUpdated struct {
Type string `json:"type"` Type string `json:"type"`
} }
// EventSessionDeleted defines model for Event.session.deleted.
type EventSessionDeleted struct {
Properties struct {
Info SessionInfo `json:"info"`
} `json:"properties"`
Type string `json:"type"`
}
// EventSessionError defines model for Event.session.error. // EventSessionError defines model for Event.session.error.
type EventSessionError struct { type EventSessionError struct {
Properties struct { Properties struct {
@@ -290,47 +300,53 @@ type InstallationInfo struct {
// MessageInfo defines model for Message.Info. // MessageInfo defines model for Message.Info.
type MessageInfo struct { type MessageInfo struct {
Id string `json:"id"` Id string `json:"id"`
Metadata struct { Metadata MessageMetadata `json:"metadata"`
Assistant *struct { Parts []MessagePart `json:"parts"`
Cost float32 `json:"cost"` Role MessageInfoRole `json:"role"`
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 {
Cache struct {
Read float32 `json:"read"`
Write float32 `json:"write"`
} `json:"cache"`
Input float32 `json:"input"`
Output float32 `json:"output"`
Reasoning float32 `json:"reasoning"`
} `json:"tokens"`
} `json:"assistant,omitempty"`
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]MessageInfo_Metadata_Tool_AdditionalProperties `json:"tool"`
} `json:"metadata"`
Parts []MessagePart `json:"parts"`
Role MessageInfoRole `json:"role"`
} }
// MessageInfo_Metadata_Error defines model for MessageInfo.Metadata.Error. // MessageInfoRole defines model for MessageInfo.Role.
type MessageInfo_Metadata_Error struct { type MessageInfoRole string
// MessageMetadata defines model for Message.Metadata.
type MessageMetadata struct {
Assistant *struct {
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 {
Cache struct {
Read float32 `json:"read"`
Write float32 `json:"write"`
} `json:"cache"`
Input float32 `json:"input"`
Output float32 `json:"output"`
Reasoning float32 `json:"reasoning"`
} `json:"tokens"`
} `json:"assistant,omitempty"`
Error *MessageMetadata_Error `json:"error,omitempty"`
SessionID string `json:"sessionID"`
Time struct {
Completed *float32 `json:"completed,omitempty"`
Created float32 `json:"created"`
} `json:"time"`
Tool map[string]MessageMetadata_Tool_AdditionalProperties `json:"tool"`
}
// MessageMetadata_Error defines model for MessageMetadata.Error.
type MessageMetadata_Error struct {
union json.RawMessage union json.RawMessage
} }
// MessageInfo_Metadata_Tool_AdditionalProperties defines model for MessageInfo.Metadata.Tool.AdditionalProperties. // MessageMetadata_Tool_AdditionalProperties defines model for Message.Metadata.tool.AdditionalProperties.
type MessageInfo_Metadata_Tool_AdditionalProperties struct { type MessageMetadata_Tool_AdditionalProperties struct {
Time struct { Time struct {
End float32 `json:"end"` End float32 `json:"end"`
Start float32 `json:"start"` Start float32 `json:"start"`
@@ -339,9 +355,6 @@ type MessageInfo_Metadata_Tool_AdditionalProperties struct {
AdditionalProperties map[string]interface{} `json:"-"` AdditionalProperties map[string]interface{} `json:"-"`
} }
// MessageInfoRole defines model for MessageInfo.Role.
type MessageInfoRole string
// MessagePart defines model for Message.Part. // MessagePart defines model for Message.Part.
type MessagePart struct { type MessagePart struct {
union json.RawMessage union json.RawMessage
@@ -435,9 +448,11 @@ type ModelInfo struct {
Context float32 `json:"context"` Context float32 `json:"context"`
Output float32 `json:"output"` Output float32 `json:"output"`
} `json:"limit"` } `json:"limit"`
Name string `json:"name"` Name string `json:"name"`
Reasoning bool `json:"reasoning"` Options map[string]interface{} `json:"options"`
Temperature bool `json:"temperature"` Reasoning bool `json:"reasoning"`
Temperature bool `json:"temperature"`
ToolCall bool `json:"tool_call"`
} }
// ProviderInfo defines model for Provider.Info. // ProviderInfo defines model for Provider.Info.
@@ -511,6 +526,11 @@ type PostSessionChatJSONBody struct {
SessionID string `json:"sessionID"` SessionID string `json:"sessionID"`
} }
// PostSessionDeleteJSONBody defines parameters for PostSessionDelete.
type PostSessionDeleteJSONBody struct {
SessionID string `json:"sessionID"`
}
// PostSessionInitializeJSONBody defines parameters for PostSessionInitialize. // PostSessionInitializeJSONBody defines parameters for PostSessionInitialize.
type PostSessionInitializeJSONBody struct { type PostSessionInitializeJSONBody struct {
ModelID string `json:"modelID"` ModelID string `json:"modelID"`
@@ -535,6 +555,11 @@ type PostSessionSummarizeJSONBody struct {
SessionID string `json:"sessionID"` SessionID string `json:"sessionID"`
} }
// PostSessionUnshareJSONBody defines parameters for PostSessionUnshare.
type PostSessionUnshareJSONBody struct {
SessionID string `json:"sessionID"`
}
// PostFileSearchJSONRequestBody defines body for PostFileSearch for application/json ContentType. // PostFileSearchJSONRequestBody defines body for PostFileSearch for application/json ContentType.
type PostFileSearchJSONRequestBody PostFileSearchJSONBody type PostFileSearchJSONRequestBody PostFileSearchJSONBody
@@ -544,6 +569,9 @@ type PostSessionAbortJSONRequestBody PostSessionAbortJSONBody
// PostSessionChatJSONRequestBody defines body for PostSessionChat for application/json ContentType. // PostSessionChatJSONRequestBody defines body for PostSessionChat for application/json ContentType.
type PostSessionChatJSONRequestBody PostSessionChatJSONBody type PostSessionChatJSONRequestBody PostSessionChatJSONBody
// PostSessionDeleteJSONRequestBody defines body for PostSessionDelete for application/json ContentType.
type PostSessionDeleteJSONRequestBody PostSessionDeleteJSONBody
// PostSessionInitializeJSONRequestBody defines body for PostSessionInitialize for application/json ContentType. // PostSessionInitializeJSONRequestBody defines body for PostSessionInitialize for application/json ContentType.
type PostSessionInitializeJSONRequestBody PostSessionInitializeJSONBody type PostSessionInitializeJSONRequestBody PostSessionInitializeJSONBody
@@ -556,25 +584,28 @@ type PostSessionShareJSONRequestBody PostSessionShareJSONBody
// PostSessionSummarizeJSONRequestBody defines body for PostSessionSummarize for application/json ContentType. // PostSessionSummarizeJSONRequestBody defines body for PostSessionSummarize for application/json ContentType.
type PostSessionSummarizeJSONRequestBody PostSessionSummarizeJSONBody type PostSessionSummarizeJSONRequestBody PostSessionSummarizeJSONBody
// Getter for additional properties for MessageInfo_Metadata_Tool_AdditionalProperties. Returns the specified // PostSessionUnshareJSONRequestBody defines body for PostSessionUnshare for application/json ContentType.
type PostSessionUnshareJSONRequestBody PostSessionUnshareJSONBody
// Getter for additional properties for MessageMetadata_Tool_AdditionalProperties. Returns the specified
// element and whether it was found // element and whether it was found
func (a MessageInfo_Metadata_Tool_AdditionalProperties) Get(fieldName string) (value interface{}, found bool) { func (a MessageMetadata_Tool_AdditionalProperties) Get(fieldName string) (value interface{}, found bool) {
if a.AdditionalProperties != nil { if a.AdditionalProperties != nil {
value, found = a.AdditionalProperties[fieldName] value, found = a.AdditionalProperties[fieldName]
} }
return return
} }
// Setter for additional properties for MessageInfo_Metadata_Tool_AdditionalProperties // Setter for additional properties for MessageMetadata_Tool_AdditionalProperties
func (a *MessageInfo_Metadata_Tool_AdditionalProperties) Set(fieldName string, value interface{}) { func (a *MessageMetadata_Tool_AdditionalProperties) Set(fieldName string, value interface{}) {
if a.AdditionalProperties == nil { if a.AdditionalProperties == nil {
a.AdditionalProperties = make(map[string]interface{}) a.AdditionalProperties = make(map[string]interface{})
} }
a.AdditionalProperties[fieldName] = value a.AdditionalProperties[fieldName] = value
} }
// Override default JSON handling for MessageInfo_Metadata_Tool_AdditionalProperties to handle AdditionalProperties // Override default JSON handling for MessageMetadata_Tool_AdditionalProperties to handle AdditionalProperties
func (a *MessageInfo_Metadata_Tool_AdditionalProperties) UnmarshalJSON(b []byte) error { func (a *MessageMetadata_Tool_AdditionalProperties) UnmarshalJSON(b []byte) error {
object := make(map[string]json.RawMessage) object := make(map[string]json.RawMessage)
err := json.Unmarshal(b, &object) err := json.Unmarshal(b, &object)
if err != nil { if err != nil {
@@ -611,8 +642,8 @@ func (a *MessageInfo_Metadata_Tool_AdditionalProperties) UnmarshalJSON(b []byte)
return nil return nil
} }
// Override default JSON handling for MessageInfo_Metadata_Tool_AdditionalProperties to handle AdditionalProperties // Override default JSON handling for MessageMetadata_Tool_AdditionalProperties to handle AdditionalProperties
func (a MessageInfo_Metadata_Tool_AdditionalProperties) MarshalJSON() ([]byte, error) { func (a MessageMetadata_Tool_AdditionalProperties) MarshalJSON() ([]byte, error) {
var err error var err error
object := make(map[string]json.RawMessage) object := make(map[string]json.RawMessage)
@@ -920,6 +951,34 @@ func (t *Event) MergeEventSessionUpdated(v EventSessionUpdated) error {
return err return err
} }
// AsEventSessionDeleted returns the union data inside the Event as a EventSessionDeleted
func (t Event) AsEventSessionDeleted() (EventSessionDeleted, error) {
var body EventSessionDeleted
err := json.Unmarshal(t.union, &body)
return body, err
}
// FromEventSessionDeleted overwrites any union data inside the Event as the provided EventSessionDeleted
func (t *Event) FromEventSessionDeleted(v EventSessionDeleted) error {
v.Type = "session.deleted"
b, err := json.Marshal(v)
t.union = b
return err
}
// MergeEventSessionDeleted performs a merge with any union data inside the Event, using the provided EventSessionDeleted
func (t *Event) MergeEventSessionDeleted(v EventSessionDeleted) error {
v.Type = "session.deleted"
b, err := json.Marshal(v)
if err != nil {
return err
}
merged, err := runtime.JSONMerge(t.union, b)
t.union = merged
return err
}
// AsEventSessionError returns the union data inside the Event as a EventSessionError // AsEventSessionError returns the union data inside the Event as a EventSessionError
func (t Event) AsEventSessionError() (EventSessionError, error) { func (t Event) AsEventSessionError() (EventSessionError, error) {
var body EventSessionError var body EventSessionError
@@ -972,6 +1031,8 @@ func (t Event) ValueByDiscriminator() (interface{}, error) {
return t.AsEventMessageUpdated() return t.AsEventMessageUpdated()
case "permission.updated": case "permission.updated":
return t.AsEventPermissionUpdated() return t.AsEventPermissionUpdated()
case "session.deleted":
return t.AsEventSessionDeleted()
case "session.error": case "session.error":
return t.AsEventSessionError() return t.AsEventSessionError()
case "session.updated": case "session.updated":
@@ -1082,23 +1143,23 @@ func (t *EventSessionError_Properties_Error) UnmarshalJSON(b []byte) error {
return err return err
} }
// AsProviderAuthError returns the union data inside the MessageInfo_Metadata_Error as a ProviderAuthError // AsProviderAuthError returns the union data inside the MessageMetadata_Error as a ProviderAuthError
func (t MessageInfo_Metadata_Error) AsProviderAuthError() (ProviderAuthError, error) { func (t MessageMetadata_Error) AsProviderAuthError() (ProviderAuthError, error) {
var body ProviderAuthError var body ProviderAuthError
err := json.Unmarshal(t.union, &body) err := json.Unmarshal(t.union, &body)
return body, err return body, err
} }
// FromProviderAuthError overwrites any union data inside the MessageInfo_Metadata_Error as the provided ProviderAuthError // FromProviderAuthError overwrites any union data inside the MessageMetadata_Error as the provided ProviderAuthError
func (t *MessageInfo_Metadata_Error) FromProviderAuthError(v ProviderAuthError) error { func (t *MessageMetadata_Error) FromProviderAuthError(v ProviderAuthError) error {
v.Name = "ProviderAuthError" v.Name = "ProviderAuthError"
b, err := json.Marshal(v) b, err := json.Marshal(v)
t.union = b t.union = b
return err return err
} }
// MergeProviderAuthError performs a merge with any union data inside the MessageInfo_Metadata_Error, using the provided ProviderAuthError // MergeProviderAuthError performs a merge with any union data inside the MessageMetadata_Error, using the provided ProviderAuthError
func (t *MessageInfo_Metadata_Error) MergeProviderAuthError(v ProviderAuthError) error { func (t *MessageMetadata_Error) MergeProviderAuthError(v ProviderAuthError) error {
v.Name = "ProviderAuthError" v.Name = "ProviderAuthError"
b, err := json.Marshal(v) b, err := json.Marshal(v)
if err != nil { if err != nil {
@@ -1110,23 +1171,23 @@ func (t *MessageInfo_Metadata_Error) MergeProviderAuthError(v ProviderAuthError)
return err return err
} }
// AsUnknownError returns the union data inside the MessageInfo_Metadata_Error as a UnknownError // AsUnknownError returns the union data inside the MessageMetadata_Error as a UnknownError
func (t MessageInfo_Metadata_Error) AsUnknownError() (UnknownError, error) { func (t MessageMetadata_Error) AsUnknownError() (UnknownError, error) {
var body UnknownError var body UnknownError
err := json.Unmarshal(t.union, &body) err := json.Unmarshal(t.union, &body)
return body, err return body, err
} }
// FromUnknownError overwrites any union data inside the MessageInfo_Metadata_Error as the provided UnknownError // FromUnknownError overwrites any union data inside the MessageMetadata_Error as the provided UnknownError
func (t *MessageInfo_Metadata_Error) FromUnknownError(v UnknownError) error { func (t *MessageMetadata_Error) FromUnknownError(v UnknownError) error {
v.Name = "UnknownError" v.Name = "UnknownError"
b, err := json.Marshal(v) b, err := json.Marshal(v)
t.union = b t.union = b
return err return err
} }
// MergeUnknownError performs a merge with any union data inside the MessageInfo_Metadata_Error, using the provided UnknownError // MergeUnknownError performs a merge with any union data inside the MessageMetadata_Error, using the provided UnknownError
func (t *MessageInfo_Metadata_Error) MergeUnknownError(v UnknownError) error { func (t *MessageMetadata_Error) MergeUnknownError(v UnknownError) error {
v.Name = "UnknownError" v.Name = "UnknownError"
b, err := json.Marshal(v) b, err := json.Marshal(v)
if err != nil { if err != nil {
@@ -1138,7 +1199,7 @@ func (t *MessageInfo_Metadata_Error) MergeUnknownError(v UnknownError) error {
return err return err
} }
func (t MessageInfo_Metadata_Error) Discriminator() (string, error) { func (t MessageMetadata_Error) Discriminator() (string, error) {
var discriminator struct { var discriminator struct {
Discriminator string `json:"name"` Discriminator string `json:"name"`
} }
@@ -1146,7 +1207,7 @@ func (t MessageInfo_Metadata_Error) Discriminator() (string, error) {
return discriminator.Discriminator, err return discriminator.Discriminator, err
} }
func (t MessageInfo_Metadata_Error) ValueByDiscriminator() (interface{}, error) { func (t MessageMetadata_Error) ValueByDiscriminator() (interface{}, error) {
discriminator, err := t.Discriminator() discriminator, err := t.Discriminator()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1161,12 +1222,12 @@ func (t MessageInfo_Metadata_Error) ValueByDiscriminator() (interface{}, error)
} }
} }
func (t MessageInfo_Metadata_Error) MarshalJSON() ([]byte, error) { func (t MessageMetadata_Error) MarshalJSON() ([]byte, error) {
b, err := t.union.MarshalJSON() b, err := t.union.MarshalJSON()
return b, err return b, err
} }
func (t *MessageInfo_Metadata_Error) UnmarshalJSON(b []byte) error { func (t *MessageMetadata_Error) UnmarshalJSON(b []byte) error {
err := t.union.UnmarshalJSON(b) err := t.union.UnmarshalJSON(b)
return err return err
} }
@@ -1611,6 +1672,11 @@ type ClientInterface interface {
// PostSessionCreate request // PostSessionCreate request
PostSessionCreate(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) PostSessionCreate(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
// PostSessionDeleteWithBody request with any body
PostSessionDeleteWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
PostSessionDelete(ctx context.Context, body PostSessionDeleteJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
// PostSessionInitializeWithBody request with any body // PostSessionInitializeWithBody request with any body
PostSessionInitializeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) PostSessionInitializeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -1633,6 +1699,11 @@ type ClientInterface interface {
PostSessionSummarizeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) PostSessionSummarizeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
PostSessionSummarize(ctx context.Context, body PostSessionSummarizeJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) PostSessionSummarize(ctx context.Context, body PostSessionSummarizeJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
// PostSessionUnshareWithBody request with any body
PostSessionUnshareWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
PostSessionUnshare(ctx context.Context, body PostSessionUnshareJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
} }
func (c *Client) PostAppInfo(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { func (c *Client) PostAppInfo(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
@@ -1803,6 +1874,30 @@ func (c *Client) PostSessionCreate(ctx context.Context, reqEditors ...RequestEdi
return c.Client.Do(req) return c.Client.Do(req)
} }
func (c *Client) PostSessionDeleteWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewPostSessionDeleteRequestWithBody(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) PostSessionDelete(ctx context.Context, body PostSessionDeleteJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewPostSessionDeleteRequest(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) PostSessionInitializeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { func (c *Client) PostSessionInitializeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewPostSessionInitializeRequestWithBody(c.Server, contentType, body) req, err := NewPostSessionInitializeRequestWithBody(c.Server, contentType, body)
if err != nil { if err != nil {
@@ -1911,6 +2006,30 @@ func (c *Client) PostSessionSummarize(ctx context.Context, body PostSessionSumma
return c.Client.Do(req) return c.Client.Do(req)
} }
func (c *Client) PostSessionUnshareWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewPostSessionUnshareRequestWithBody(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) PostSessionUnshare(ctx context.Context, body PostSessionUnshareJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewPostSessionUnshareRequest(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)
}
// NewPostAppInfoRequest generates requests for PostAppInfo // NewPostAppInfoRequest generates requests for PostAppInfo
func NewPostAppInfoRequest(server string) (*http.Request, error) { func NewPostAppInfoRequest(server string) (*http.Request, error) {
var err error var err error
@@ -2247,6 +2366,46 @@ func NewPostSessionCreateRequest(server string) (*http.Request, error) {
return req, nil return req, nil
} }
// NewPostSessionDeleteRequest calls the generic PostSessionDelete builder with application/json body
func NewPostSessionDeleteRequest(server string, body PostSessionDeleteJSONRequestBody) (*http.Request, error) {
var bodyReader io.Reader
buf, err := json.Marshal(body)
if err != nil {
return nil, err
}
bodyReader = bytes.NewReader(buf)
return NewPostSessionDeleteRequestWithBody(server, "application/json", bodyReader)
}
// NewPostSessionDeleteRequestWithBody generates requests for PostSessionDelete with any type of body
func NewPostSessionDeleteRequestWithBody(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("/session_delete")
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
}
// NewPostSessionInitializeRequest calls the generic PostSessionInitialize builder with application/json body // NewPostSessionInitializeRequest calls the generic PostSessionInitialize builder with application/json body
func NewPostSessionInitializeRequest(server string, body PostSessionInitializeJSONRequestBody) (*http.Request, error) { func NewPostSessionInitializeRequest(server string, body PostSessionInitializeJSONRequestBody) (*http.Request, error) {
var bodyReader io.Reader var bodyReader io.Reader
@@ -2434,6 +2593,46 @@ func NewPostSessionSummarizeRequestWithBody(server string, contentType string, b
return req, nil return req, nil
} }
// NewPostSessionUnshareRequest calls the generic PostSessionUnshare builder with application/json body
func NewPostSessionUnshareRequest(server string, body PostSessionUnshareJSONRequestBody) (*http.Request, error) {
var bodyReader io.Reader
buf, err := json.Marshal(body)
if err != nil {
return nil, err
}
bodyReader = bytes.NewReader(buf)
return NewPostSessionUnshareRequestWithBody(server, "application/json", bodyReader)
}
// NewPostSessionUnshareRequestWithBody generates requests for PostSessionUnshare with any type of body
func NewPostSessionUnshareRequestWithBody(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("/session_unshare")
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
}
func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error {
for _, r := range c.RequestEditors { for _, r := range c.RequestEditors {
if err := r(ctx, req); err != nil { if err := r(ctx, req); err != nil {
@@ -2516,6 +2715,11 @@ type ClientWithResponsesInterface interface {
// PostSessionCreateWithResponse request // PostSessionCreateWithResponse request
PostSessionCreateWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostSessionCreateResponse, error) PostSessionCreateWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostSessionCreateResponse, error)
// PostSessionDeleteWithBodyWithResponse request with any body
PostSessionDeleteWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostSessionDeleteResponse, error)
PostSessionDeleteWithResponse(ctx context.Context, body PostSessionDeleteJSONRequestBody, reqEditors ...RequestEditorFn) (*PostSessionDeleteResponse, error)
// PostSessionInitializeWithBodyWithResponse request with any body // PostSessionInitializeWithBodyWithResponse request with any body
PostSessionInitializeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostSessionInitializeResponse, error) PostSessionInitializeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostSessionInitializeResponse, error)
@@ -2538,6 +2742,11 @@ type ClientWithResponsesInterface interface {
PostSessionSummarizeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostSessionSummarizeResponse, error) PostSessionSummarizeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostSessionSummarizeResponse, error)
PostSessionSummarizeWithResponse(ctx context.Context, body PostSessionSummarizeJSONRequestBody, reqEditors ...RequestEditorFn) (*PostSessionSummarizeResponse, error) PostSessionSummarizeWithResponse(ctx context.Context, body PostSessionSummarizeJSONRequestBody, reqEditors ...RequestEditorFn) (*PostSessionSummarizeResponse, error)
// PostSessionUnshareWithBodyWithResponse request with any body
PostSessionUnshareWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostSessionUnshareResponse, error)
PostSessionUnshareWithResponse(ctx context.Context, body PostSessionUnshareJSONRequestBody, reqEditors ...RequestEditorFn) (*PostSessionUnshareResponse, error)
} }
type PostAppInfoResponse struct { type PostAppInfoResponse struct {
@@ -2791,6 +3000,28 @@ func (r PostSessionCreateResponse) StatusCode() int {
return 0 return 0
} }
type PostSessionDeleteResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *bool
}
// Status returns HTTPResponse.Status
func (r PostSessionDeleteResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r PostSessionDeleteResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
type PostSessionInitializeResponse struct { type PostSessionInitializeResponse struct {
Body []byte Body []byte
HTTPResponse *http.Response HTTPResponse *http.Response
@@ -2901,6 +3132,28 @@ func (r PostSessionSummarizeResponse) StatusCode() int {
return 0 return 0
} }
type PostSessionUnshareResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *SessionInfo
}
// Status returns HTTPResponse.Status
func (r PostSessionUnshareResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r PostSessionUnshareResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
// PostAppInfoWithResponse request returning *PostAppInfoResponse // PostAppInfoWithResponse request returning *PostAppInfoResponse
func (c *ClientWithResponses) PostAppInfoWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostAppInfoResponse, error) { func (c *ClientWithResponses) PostAppInfoWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostAppInfoResponse, error) {
rsp, err := c.PostAppInfo(ctx, reqEditors...) rsp, err := c.PostAppInfo(ctx, reqEditors...)
@@ -3024,6 +3277,23 @@ func (c *ClientWithResponses) PostSessionCreateWithResponse(ctx context.Context,
return ParsePostSessionCreateResponse(rsp) return ParsePostSessionCreateResponse(rsp)
} }
// PostSessionDeleteWithBodyWithResponse request with arbitrary body returning *PostSessionDeleteResponse
func (c *ClientWithResponses) PostSessionDeleteWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostSessionDeleteResponse, error) {
rsp, err := c.PostSessionDeleteWithBody(ctx, contentType, body, reqEditors...)
if err != nil {
return nil, err
}
return ParsePostSessionDeleteResponse(rsp)
}
func (c *ClientWithResponses) PostSessionDeleteWithResponse(ctx context.Context, body PostSessionDeleteJSONRequestBody, reqEditors ...RequestEditorFn) (*PostSessionDeleteResponse, error) {
rsp, err := c.PostSessionDelete(ctx, body, reqEditors...)
if err != nil {
return nil, err
}
return ParsePostSessionDeleteResponse(rsp)
}
// PostSessionInitializeWithBodyWithResponse request with arbitrary body returning *PostSessionInitializeResponse // PostSessionInitializeWithBodyWithResponse request with arbitrary body returning *PostSessionInitializeResponse
func (c *ClientWithResponses) PostSessionInitializeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostSessionInitializeResponse, error) { func (c *ClientWithResponses) PostSessionInitializeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostSessionInitializeResponse, error) {
rsp, err := c.PostSessionInitializeWithBody(ctx, contentType, body, reqEditors...) rsp, err := c.PostSessionInitializeWithBody(ctx, contentType, body, reqEditors...)
@@ -3101,6 +3371,23 @@ func (c *ClientWithResponses) PostSessionSummarizeWithResponse(ctx context.Conte
return ParsePostSessionSummarizeResponse(rsp) return ParsePostSessionSummarizeResponse(rsp)
} }
// PostSessionUnshareWithBodyWithResponse request with arbitrary body returning *PostSessionUnshareResponse
func (c *ClientWithResponses) PostSessionUnshareWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostSessionUnshareResponse, error) {
rsp, err := c.PostSessionUnshareWithBody(ctx, contentType, body, reqEditors...)
if err != nil {
return nil, err
}
return ParsePostSessionUnshareResponse(rsp)
}
func (c *ClientWithResponses) PostSessionUnshareWithResponse(ctx context.Context, body PostSessionUnshareJSONRequestBody, reqEditors ...RequestEditorFn) (*PostSessionUnshareResponse, error) {
rsp, err := c.PostSessionUnshare(ctx, body, reqEditors...)
if err != nil {
return nil, err
}
return ParsePostSessionUnshareResponse(rsp)
}
// ParsePostAppInfoResponse parses an HTTP response from a PostAppInfoWithResponse call // ParsePostAppInfoResponse parses an HTTP response from a PostAppInfoWithResponse call
func ParsePostAppInfoResponse(rsp *http.Response) (*PostAppInfoResponse, error) { func ParsePostAppInfoResponse(rsp *http.Response) (*PostAppInfoResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body) bodyBytes, err := io.ReadAll(rsp.Body)
@@ -3402,6 +3689,32 @@ func ParsePostSessionCreateResponse(rsp *http.Response) (*PostSessionCreateRespo
return response, nil return response, nil
} }
// ParsePostSessionDeleteResponse parses an HTTP response from a PostSessionDeleteWithResponse call
func ParsePostSessionDeleteResponse(rsp *http.Response) (*PostSessionDeleteResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &PostSessionDeleteResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest bool
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON200 = &dest
}
return response, nil
}
// ParsePostSessionInitializeResponse parses an HTTP response from a PostSessionInitializeWithResponse call // ParsePostSessionInitializeResponse parses an HTTP response from a PostSessionInitializeWithResponse call
func ParsePostSessionInitializeResponse(rsp *http.Response) (*PostSessionInitializeResponse, error) { func ParsePostSessionInitializeResponse(rsp *http.Response) (*PostSessionInitializeResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body) bodyBytes, err := io.ReadAll(rsp.Body)
@@ -3531,3 +3844,29 @@ func ParsePostSessionSummarizeResponse(rsp *http.Response) (*PostSessionSummariz
return response, nil return response, nil
} }
// ParsePostSessionUnshareResponse parses an HTTP response from a PostSessionUnshareWithResponse call
func ParsePostSessionUnshareResponse(rsp *http.Response) (*PostSessionUnshareResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &PostSessionUnshareResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest SessionInfo
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON200 = &dest
}
return response, nil
}
+74 -20
View File
@@ -113,29 +113,83 @@ const DiffView: Component<DiffViewProps> = (props) => {
return ( return (
<div class={`${styles.diff} ${props.class ?? ""}`}> <div class={`${styles.diff} ${props.class ?? ""}`}>
<div class={styles.column}> {rows().map((r) => (
{rows().map((r) => ( <div class={styles.row}>
<CodeBlock <div class={styles.beforeColumn}>
code={r.left} <CodeBlock
lang={props.lang} code={r.left}
data-section="cell" lang={props.lang}
data-diff-type={r.type === "removed" || r.type === "modified" ? "removed" : ""} data-section="cell"
/> data-diff-type={r.type === "removed" || r.type === "modified" ? "removed" : ""}
))} data-display-mobile={r.type === "added" && !r.left ? "false" : undefined}
</div> />
{(r.type === "added" || r.type === "modified") && r.right !== undefined && (
<CodeBlock
code={r.right}
lang={props.lang}
data-section="cell"
data-diff-type="added"
data-display-mobile="true"
/>
)}
</div>
<div class={styles.column}> <div class={styles.afterColumn}>
{rows().map((r) => ( <CodeBlock
<CodeBlock code={r.right}
code={r.right} lang={props.lang}
lang={props.lang} data-section="cell"
data-section="cell" data-diff-type={r.type === "added" || r.type === "modified" ? "added" : ""}
data-diff-type={r.type === "added" || r.type === "modified" ? "added" : ""} />
/> </div>
))} </div>
</div> ))}
</div> </div>
) )
} }
export default DiffView export default DiffView
// String to test diff viewer with
const testDiff = `--- combined_before.txt 2025-06-24 16:38:08
+++ combined_after.txt 2025-06-24 16:38:12
@@ -1,21 +1,25 @@
unchanged line
-deleted line
-old content
+added line
+new content
-removed empty line below
+added empty line above
- tab indented
-trailing spaces
-very long line that will definitely wrap in most editors and cause potential alignment issues when displayed in a two column diff view
-unicode content: 🚀 ✨ 中文
-mixed content with tabs and spaces
+ space indented
+no trailing spaces
+short line
+very long replacement line that will also wrap and test how the diff viewer handles long line additions after short line removals
+different unicode: 🎉 💻 日本語
+normalized content with consistent spacing
+newline to content
-content to remove
-whitespace only:
-multiple
-consecutive
-deletions
-single deletion
+
+single addition
+first addition
+second addition
+third addition
line before addition
+first added line
+
+third added line
line after addition
final unchanged line`
+38 -18
View File
@@ -444,7 +444,7 @@ function MarkdownPart(props: MarkdownPartProps) {
{...rest} {...rest}
> >
<MarkdownView <MarkdownView
data-elment-markdown data-element-markdown
markdown={local.text} markdown={local.text}
ref={(el) => (divEl = el)} ref={(el) => (divEl = el)}
/> />
@@ -463,6 +463,7 @@ function MarkdownPart(props: MarkdownPartProps) {
interface TerminalPartProps extends JSX.HTMLAttributes<HTMLDivElement> { interface TerminalPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
command: string command: string
error?: string
result?: string result?: string
desc?: string desc?: string
expand?: boolean expand?: boolean
@@ -470,6 +471,7 @@ interface TerminalPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
function TerminalPart(props: TerminalPartProps) { function TerminalPart(props: TerminalPartProps) {
const [local, rest] = splitProps(props, [ const [local, rest] = splitProps(props, [
"command", "command",
"error",
"result", "result",
"desc", "desc",
"expand", "expand",
@@ -508,12 +510,25 @@ function TerminalPart(props: TerminalPartProps) {
</div> </div>
<div data-section="content"> <div data-section="content">
<CodeBlock lang="bash" code={local.command} /> <CodeBlock lang="bash" code={local.command} />
<CodeBlock <Switch>
lang="console" <Match when={local.error}>
onRendered={checkOverflow} <CodeBlock
ref={(el) => (preEl = el)} data-section="error"
code={local.result || ""} lang="text"
/> onRendered={checkOverflow}
ref={(el) => (preEl = el)}
code={local.error || ""}
/>
</Match>
<Match when={local.result}>
<CodeBlock
lang="console"
onRendered={checkOverflow}
ref={(el) => (preEl = el)}
code={local.result || ""}
/>
</Match>
</Switch>
</div> </div>
</div> </div>
{((!local.expand && overflowed()) || expanded()) && ( {((!local.expand && overflowed()) || expanded()) && (
@@ -726,7 +741,7 @@ export default function Share(props: {
for (let i = 0; i < messages().length; i++) { for (let i = 0; i < messages().length; i++) {
const msg = messages()[i] const msg = messages()[i]
// TODO: Cleaup // TODO: Cleanup
// const system = result.messages.length === 0 && msg.role === "system" // const system = result.messages.length === 0 && msg.role === "system"
const assistant = msg.metadata?.assistant const assistant = msg.metadata?.assistant
@@ -1601,8 +1616,10 @@ export default function Share(props: {
} }
> >
{(_part) => { {(_part) => {
const command = () => toolData()?.args.command const command = () => toolData()?.metadata?.title
const desc = () => toolData()?.args.description const desc = () => toolData()?.metadata?.description
const result = () => toolData()?.metadata?.stdout
const error = () => toolData()?.metadata?.stderr
return ( return (
<div <div
@@ -1617,14 +1634,17 @@ export default function Share(props: {
<div></div> <div></div>
</div> </div>
<div data-section="content"> <div data-section="content">
<div data-part-tool-body> {command() && (
<TerminalPart <div data-part-tool-body>
desc={desc()} <TerminalPart
data-size="sm" desc={desc()}
command={command()} data-size="sm"
result={toolData()?.result} command={command()!}
/> result={result()}
</div> error={error()}
/>
</div>
)}
<ToolFooter <ToolFooter
time={toolData()?.duration || 0} time={toolData()?.duration || 0}
/> />
@@ -2,6 +2,10 @@
pre { pre {
--shiki-dark-bg: var(--sl-color-bg-surface) !important; --shiki-dark-bg: var(--sl-color-bg-surface) !important;
background-color: var(--sl-color-bg-surface) !important; background-color: var(--sl-color-bg-surface) !important;
span {
white-space: break-spaces;
}
} }
} }
+51 -14
View File
@@ -1,39 +1,52 @@
.diff { .diff {
display: grid; display: flex;
grid-template-columns: 1fr 1fr; flex-direction: column;
border: 1px solid var(--sl-color-divider); border: 1px solid var(--sl-color-divider);
background-color: var(--sl-color-bg-surface); background-color: var(--sl-color-bg-surface);
border-radius: 0.25rem; border-radius: 0.25rem;
} }
.column { .row {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: stretch;
}
.beforeColumn,
.afterColumn {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-x: visible; overflow-x: visible;
min-width: 0; min-width: 0;
align-items: stretch; align-items: stretch;
}
&:first-child { .beforeColumn {
border-right: 1px solid var(--sl-color-divider); border-right: 1px solid var(--sl-color-divider);
} }
& > [data-section="cell"]:first-child { .diff > .row:first-child [data-section="cell"]:first-child {
padding-top: 0.5rem; padding-top: 0.5rem;
} }
& > [data-section="cell"]:last-child {
padding-bottom: 0.5rem; .diff > .row:last-child [data-section="cell"]:last-child {
} padding-bottom: 0.5rem;
} }
[data-section="cell"] { [data-section="cell"] {
position: relative; position: relative;
flex: none; flex: 1;
display: flex;
flex-direction: column;
width: 100%; width: 100%;
padding: 0.1875rem 0.5rem 0.1875rem 2.2ch; padding: 0.1875rem 0.5rem 0.1875rem 2.2ch;
margin: 0; margin: 0;
&[data-display-mobile="true"] {
display: none;
}
pre { pre {
--shiki-dark-bg: var(--sl-color-bg-surface) !important; --shiki-dark-bg: var(--sl-color-bg-surface) !important;
background-color: var(--sl-color-bg-surface) !important; background-color: var(--sl-color-bg-surface) !important;
@@ -83,3 +96,27 @@
color: var(--sl-color-green-high); color: var(--sl-color-green-high);
} }
} }
@media (max-width: 40rem) {
.row {
grid-template-columns: 1fr;
}
.afterColumn {
display: none;
}
.beforeColumn {
border-right: none;
}
[data-section="cell"] {
&[data-display-mobile="true"] {
display: flex;
}
&[data-display-mobile="false"] {
display: none;
}
}
}
@@ -37,16 +37,26 @@
margin-bottom: 0; margin-bottom: 0;
} }
pre {
white-space: pre-wrap;
border-radius: 0.25rem;
border: 1px solid rgba(0, 0, 0, 0.2);
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
}
code { code {
font-weight: 500; font-weight: 500;
&::before { &:not(pre code) {
content: "`"; &::before {
font-weight: 600; content: "`";
} font-weight: 700;
&::after { }
content: "`"; &::after {
font-weight: 600; content: "`";
font-weight: 700;
}
} }
} }
} }
+9 -2
View File
@@ -616,6 +616,13 @@
} }
} }
[data-section="error"] {
pre {
color: var(--sl-color-red) !important;
--shiki-dark: var(--sl-color-red) !important;
}
}
&[data-expanded="true"] { &[data-expanded="true"] {
pre { pre {
display: block; display: block;
@@ -659,12 +666,12 @@
} }
&[data-expanded="true"] { &[data-expanded="true"] {
[data-elment-markdown] { [data-element-markdown] {
display: block; display: block;
} }
} }
&[data-expanded="false"] { &[data-expanded="false"] {
[data-elment-markdown] { [data-element-markdown] {
display: -webkit-box; display: -webkit-box;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
@@ -89,3 +89,21 @@ You can configure MCP servers you want to use through the `mcp` option.
``` ```
[Learn more here](/docs/mcp-servers). [Learn more here](/docs/mcp-servers).
---
### Disabled providers
You can disable providers that are loaded automatically through the `disabled_providers` option. This is useful when you want to prevent certain providers from being loaded even if their credentials are available.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"disabled_providers": ["openai", "gemini"]
}
```
The `disabled_providers` option accepts an array of provider IDs. When a provider is disabled:
- It won't be loaded even if environment variables are set
- It won't be loaded even if API keys are configured through `opencode auth login`
- The provider's models won't appear in the model selection list
@@ -64,6 +64,10 @@ paru -S opencode-bin
--- ---
##### Windows
Right now the automatic installation methods do not work properly on Windows. However you can grab the binary from the [Releases](https://github.com/sst/opencode/releases).
## Providers ## Providers
We recommend signing up for Claude Pro or Max, running `opencode auth login` and selecting Anthropic. It's the most cost-effective way to use opencode. We recommend signing up for Claude Pro or Max, running `opencode auth login` and selecting Anthropic. It's the most cost-effective way to use opencode.
+16
View File
@@ -0,0 +1,16 @@
@echo off
if not exist ".git" (
exit /b 0
)
if not exist ".git\hooks" (
mkdir ".git\hooks"
)
(
echo #!/bin/sh
echo bun run typecheck
) > ".git\hooks\pre-push"
echo ✅ Pre-push hook installed