mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-13 12:24:29 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99d6a28249 | ||
|
|
5eaf7ab586 | ||
|
|
e4f754eee7 | ||
|
|
f20ef61bc7 | ||
|
|
5611ef8b28 | ||
|
|
bec796e3c3 | ||
|
|
0bd8b2c72f | ||
|
|
5550ce47e1 | ||
|
|
2d84dadc0c | ||
|
|
45c0578b22 | ||
|
|
1ded535175 | ||
|
|
d957ab849b | ||
|
|
4b2e52c834 | ||
|
|
6867658c0f | ||
|
|
b8620395cb | ||
|
|
90d37c98f8 | ||
|
|
c9a40917c2 | ||
|
|
0aa0e740cd | ||
|
|
bb17d14665 | ||
|
|
cd0b2ae032 | ||
|
|
8e8796507d | ||
|
|
cef5c29583 | ||
|
|
acaed1f270 | ||
|
|
cda0dbc195 | ||
|
|
758425a8e4 | ||
|
|
93446df335 | ||
|
|
adc8b90e0f | ||
|
|
733c9903ec | ||
|
|
7306e20361 |
3
STATS.md
3
STATS.md
@@ -20,6 +20,7 @@
|
||||
| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) |
|
||||
| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) |
|
||||
| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) |
|
||||
| 2025-07-18 | 70,380 (+1) | 102,587 (+0) | 172,967 (+1) |
|
||||
| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) |
|
||||
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |
|
||||
| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) |
|
||||
| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) |
|
||||
|
||||
@@ -93,13 +93,16 @@ if (!snapshot) {
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.tag_name)
|
||||
|
||||
console.log("finding commits between", previous, "and", "HEAD")
|
||||
const commits = await fetch(`https://api.github.com/repos/sst/opencode/compare/${previous}...HEAD`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.commits || [])
|
||||
|
||||
const raw = commits.map((commit: any) => `- ${commit.commit.message.split("\n").join(" ")}`)
|
||||
console.log(raw)
|
||||
|
||||
const notes =
|
||||
commits
|
||||
.map((commit: any) => `- ${commit.commit.message.split("\n")[0]}`)
|
||||
raw
|
||||
.filter((x: string) => {
|
||||
const lower = x.toLowerCase()
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Provider } from "../../provider/provider"
|
||||
import { Server } from "../../server/server"
|
||||
import { Share } from "../../share/share"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { cmd } from "./cmd"
|
||||
|
||||
@@ -32,7 +31,6 @@ export const ServeCommand = cmd({
|
||||
const hostname = args.hostname
|
||||
const port = args.port
|
||||
|
||||
await Share.init()
|
||||
const server = Server.listen({
|
||||
port,
|
||||
hostname,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Bus } from "../../bus"
|
||||
import { Log } from "../../util/log"
|
||||
import { FileWatcher } from "../../file/watch"
|
||||
import { Mode } from "../../session/mode"
|
||||
import { Ide } from "../../ide"
|
||||
|
||||
export const TuiCommand = cmd({
|
||||
command: "$0 [project]",
|
||||
@@ -35,6 +36,17 @@ export const TuiCommand = cmd({
|
||||
.option("mode", {
|
||||
type: "string",
|
||||
describe: "mode to use",
|
||||
})
|
||||
.option("port", {
|
||||
type: "number",
|
||||
describe: "port to listen on",
|
||||
default: 0,
|
||||
})
|
||||
.option("hostname", {
|
||||
alias: ["h"],
|
||||
type: "string",
|
||||
describe: "hostname to listen on",
|
||||
default: "127.0.0.1",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
while (true) {
|
||||
@@ -53,8 +65,8 @@ export const TuiCommand = cmd({
|
||||
}
|
||||
|
||||
const server = Server.listen({
|
||||
port: 0,
|
||||
hostname: "127.0.0.1",
|
||||
port: args.port,
|
||||
hostname: args.hostname,
|
||||
})
|
||||
|
||||
let cmd = ["go", "run", "./main.go"]
|
||||
@@ -116,6 +128,16 @@ export const TuiCommand = cmd({
|
||||
})
|
||||
.catch(() => {})
|
||||
})()
|
||||
;(async () => {
|
||||
if (Ide.alreadyInstalled()) return
|
||||
const ide = await Ide.ide()
|
||||
if (ide === "unknown") return
|
||||
await Ide.install(ide)
|
||||
.then(() => {
|
||||
Bus.publish(Ide.Event.Installed, { ide })
|
||||
})
|
||||
.catch(() => {})
|
||||
})()
|
||||
|
||||
await proc.exited
|
||||
server.stop()
|
||||
|
||||
74
packages/opencode/src/ide/index.ts
Normal file
74
packages/opencode/src/ide/index.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { $ } from "bun"
|
||||
import { z } from "zod"
|
||||
import { NamedError } from "../util/error"
|
||||
import { Log } from "../util/log"
|
||||
import { Bus } from "../bus"
|
||||
|
||||
const SUPPORTED_IDES = ["Windsurf", "Visual Studio Code", "Cursor", "VSCodium"] as const
|
||||
|
||||
export namespace Ide {
|
||||
const log = Log.create({ service: "ide" })
|
||||
|
||||
export const Event = {
|
||||
Installed: Bus.event(
|
||||
"ide.installed",
|
||||
z.object({
|
||||
ide: z.string(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export type Ide = Awaited<ReturnType<typeof ide>>
|
||||
|
||||
export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", z.object({}))
|
||||
|
||||
export const InstallFailedError = NamedError.create(
|
||||
"InstallFailedError",
|
||||
z.object({
|
||||
stderr: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export async function ide() {
|
||||
if (process.env["TERM_PROGRAM"] === "vscode") {
|
||||
const v = process.env["GIT_ASKPASS"]
|
||||
for (const ide of SUPPORTED_IDES) {
|
||||
if (v?.includes(ide)) return ide
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
export function alreadyInstalled() {
|
||||
return process.env["OPENCODE_CALLER"] === "vscode"
|
||||
}
|
||||
|
||||
export async function install(ide: Ide) {
|
||||
const cmd = (() => {
|
||||
switch (ide) {
|
||||
case "Windsurf":
|
||||
return $`windsurf --install-extension sst-dev.opencode`
|
||||
case "Visual Studio Code":
|
||||
return $`code --install-extension sst-dev.opencode`
|
||||
case "Cursor":
|
||||
return $`cursor --install-extension sst-dev.opencode`
|
||||
case "VSCodium":
|
||||
return $`codium --install-extension sst-dev.opencode`
|
||||
default:
|
||||
throw new Error(`Unknown IDE: ${ide}`)
|
||||
}
|
||||
})()
|
||||
// TODO: check OPENCODE_CALLER
|
||||
const result = await cmd.quiet().throws(false)
|
||||
log.info("installed", {
|
||||
ide,
|
||||
stdout: result.stdout.toString(),
|
||||
stderr: result.stderr.toString(),
|
||||
})
|
||||
if (result.exitCode !== 0)
|
||||
throw new InstallFailedError({
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
})
|
||||
if (result.stdout.toString().includes("already installed")) throw new AlreadyInstalledError({})
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { File } from "../file"
|
||||
import { LSP } from "../lsp"
|
||||
import { MessageV2 } from "../session/message-v2"
|
||||
import { Mode } from "../session/mode"
|
||||
import { callTui, TuiRoute } from "./tui"
|
||||
|
||||
const ERRORS = {
|
||||
400: {
|
||||
@@ -703,6 +704,48 @@ export namespace Server {
|
||||
return c.json(modes)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/tui/prompt",
|
||||
describeRoute({
|
||||
description: "Send a prompt to the TUI",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Prompt processed successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
text: z.string(),
|
||||
parts: MessageV2.Part.array(),
|
||||
}),
|
||||
),
|
||||
async (c) => c.json(await callTui(c)),
|
||||
)
|
||||
.post(
|
||||
"/tui/open-help",
|
||||
describeRoute({
|
||||
description: "Open the help dialog",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Help dialog opened successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => c.json(await callTui(c)),
|
||||
)
|
||||
.route("/tui/control", TuiRoute)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
30
packages/opencode/src/server/tui.ts
Normal file
30
packages/opencode/src/server/tui.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Hono, type Context } from "hono"
|
||||
import { AsyncQueue } from "../util/queue"
|
||||
|
||||
interface Request {
|
||||
path: string
|
||||
body: any
|
||||
}
|
||||
|
||||
const request = new AsyncQueue<Request>()
|
||||
const response = new AsyncQueue<any>()
|
||||
|
||||
export async function callTui(ctx: Context) {
|
||||
const body = await ctx.req.json()
|
||||
request.push({
|
||||
path: ctx.req.path,
|
||||
body,
|
||||
})
|
||||
return response.next()
|
||||
}
|
||||
|
||||
export const TuiRoute = new Hono()
|
||||
.get("/next", async (c) => {
|
||||
const req = await request.next()
|
||||
return c.json(req)
|
||||
})
|
||||
.post("/response", async (c) => {
|
||||
const body = await c.req.json()
|
||||
response.push(body)
|
||||
return c.json(true)
|
||||
})
|
||||
@@ -118,11 +118,22 @@ export namespace Session {
|
||||
const sessions = new Map<string, Info>()
|
||||
const messages = new Map<string, MessageV2.Info[]>()
|
||||
const pending = new Map<string, AbortController>()
|
||||
const queued = new Map<
|
||||
string,
|
||||
{
|
||||
input: ChatInput
|
||||
message: MessageV2.User
|
||||
parts: MessageV2.Part[]
|
||||
processed: boolean
|
||||
callback: (input: { info: MessageV2.Assistant; parts: MessageV2.Part[] }) => void
|
||||
}[]
|
||||
>()
|
||||
|
||||
return {
|
||||
sessions,
|
||||
messages,
|
||||
pending,
|
||||
queued,
|
||||
}
|
||||
},
|
||||
async (state) => {
|
||||
@@ -351,64 +362,14 @@ export namespace Session {
|
||||
]),
|
||||
),
|
||||
})
|
||||
export type ChatInput = z.infer<typeof ChatInput>
|
||||
|
||||
export async function chat(input: z.infer<typeof ChatInput>) {
|
||||
export async function chat(
|
||||
input: z.infer<typeof ChatInput>,
|
||||
): Promise<{ info: MessageV2.Assistant; parts: MessageV2.Part[] }> {
|
||||
const l = log.clone().tag("session", input.sessionID)
|
||||
l.info("chatting")
|
||||
|
||||
const model = await Provider.getModel(input.providerID, input.modelID)
|
||||
let msgs = await messages(input.sessionID)
|
||||
const session = await get(input.sessionID)
|
||||
|
||||
if (session.revert) {
|
||||
const trimmed = []
|
||||
for (const msg of msgs) {
|
||||
if (
|
||||
msg.info.id > session.revert.messageID ||
|
||||
(msg.info.id === session.revert.messageID && session.revert.part === 0)
|
||||
) {
|
||||
await Storage.remove("session/message/" + input.sessionID + "/" + msg.info.id)
|
||||
await Bus.publish(MessageV2.Event.Removed, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: msg.info.id,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (msg.info.id === session.revert.messageID) {
|
||||
if (session.revert.part === 0) break
|
||||
msg.parts = msg.parts.slice(0, session.revert.part)
|
||||
}
|
||||
trimmed.push(msg)
|
||||
}
|
||||
msgs = trimmed
|
||||
await update(input.sessionID, (draft) => {
|
||||
draft.revert = undefined
|
||||
})
|
||||
}
|
||||
|
||||
const previous = msgs.filter((x) => x.info.role === "assistant").at(-1)?.info as MessageV2.Assistant
|
||||
const outputLimit = Math.min(model.info.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX
|
||||
|
||||
// auto summarize if too long
|
||||
if (previous && previous.tokens) {
|
||||
const tokens =
|
||||
previous.tokens.input + previous.tokens.cache.read + previous.tokens.cache.write + previous.tokens.output
|
||||
if (model.info.limit.context && tokens > Math.max((model.info.limit.context - outputLimit) * 0.9, 0)) {
|
||||
await summarize({
|
||||
sessionID: input.sessionID,
|
||||
providerID: input.providerID,
|
||||
modelID: input.modelID,
|
||||
})
|
||||
return chat(input)
|
||||
}
|
||||
}
|
||||
|
||||
using abort = lock(input.sessionID)
|
||||
|
||||
const lastSummary = msgs.findLast((msg) => msg.info.role === "assistant" && msg.info.summary === true)
|
||||
if (lastSummary) msgs = msgs.filter((msg) => msg.info.id >= lastSummary.info.id)
|
||||
|
||||
const userMsg: MessageV2.Info = {
|
||||
id: input.messageID ?? Identifier.ascending("message"),
|
||||
role: "user",
|
||||
@@ -469,7 +430,7 @@ export namespace Session {
|
||||
const args = { filePath, offset, limit }
|
||||
const result = await ReadTool.execute(args, {
|
||||
sessionID: input.sessionID,
|
||||
abort: abort.signal,
|
||||
abort: new AbortController().signal,
|
||||
messageID: userMsg.id,
|
||||
metadata: async () => {},
|
||||
})
|
||||
@@ -533,7 +494,6 @@ export namespace Session {
|
||||
]
|
||||
}),
|
||||
).then((x) => x.flat())
|
||||
|
||||
if (input.mode === "plan")
|
||||
userParts.push({
|
||||
id: Identifier.ascending("part"),
|
||||
@@ -544,7 +504,79 @@ export namespace Session {
|
||||
synthetic: true,
|
||||
})
|
||||
|
||||
if (msgs.length === 0 && !session.parentID) {
|
||||
await updateMessage(userMsg)
|
||||
for (const part of userParts) {
|
||||
await updatePart(part)
|
||||
}
|
||||
|
||||
if (isLocked(input.sessionID)) {
|
||||
return new Promise((resolve) => {
|
||||
const queue = state().queued.get(input.sessionID) ?? []
|
||||
queue.push({
|
||||
input: input,
|
||||
message: userMsg,
|
||||
parts: userParts,
|
||||
processed: false,
|
||||
callback: resolve,
|
||||
})
|
||||
state().queued.set(input.sessionID, queue)
|
||||
})
|
||||
}
|
||||
|
||||
const model = await Provider.getModel(input.providerID, input.modelID)
|
||||
let msgs = await messages(input.sessionID)
|
||||
const session = await get(input.sessionID)
|
||||
|
||||
if (session.revert) {
|
||||
const trimmed = []
|
||||
for (const msg of msgs) {
|
||||
if (
|
||||
msg.info.id > session.revert.messageID ||
|
||||
(msg.info.id === session.revert.messageID && session.revert.part === 0)
|
||||
) {
|
||||
await Storage.remove("session/message/" + input.sessionID + "/" + msg.info.id)
|
||||
await Bus.publish(MessageV2.Event.Removed, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: msg.info.id,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (msg.info.id === session.revert.messageID) {
|
||||
if (session.revert.part === 0) break
|
||||
msg.parts = msg.parts.slice(0, session.revert.part)
|
||||
}
|
||||
trimmed.push(msg)
|
||||
}
|
||||
msgs = trimmed
|
||||
await update(input.sessionID, (draft) => {
|
||||
draft.revert = undefined
|
||||
})
|
||||
}
|
||||
|
||||
const previous = msgs.filter((x) => x.info.role === "assistant").at(-1)?.info as MessageV2.Assistant
|
||||
const outputLimit = Math.min(model.info.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX
|
||||
|
||||
// auto summarize if too long
|
||||
if (previous && previous.tokens) {
|
||||
const tokens =
|
||||
previous.tokens.input + previous.tokens.cache.read + previous.tokens.cache.write + previous.tokens.output
|
||||
if (model.info.limit.context && tokens > Math.max((model.info.limit.context - outputLimit) * 0.9, 0)) {
|
||||
await summarize({
|
||||
sessionID: input.sessionID,
|
||||
providerID: input.providerID,
|
||||
modelID: input.modelID,
|
||||
})
|
||||
return chat(input)
|
||||
}
|
||||
}
|
||||
|
||||
using abort = lock(input.sessionID)
|
||||
|
||||
const lastSummary = msgs.findLast((msg) => msg.info.role === "assistant" && msg.info.summary === true)
|
||||
if (lastSummary) msgs = msgs.filter((msg) => msg.info.id >= lastSummary.info.id)
|
||||
|
||||
if (msgs.length === 1 && !session.parentID) {
|
||||
const small = (await Provider.getSmallModel(input.providerID)) ?? model
|
||||
generateText({
|
||||
maxOutputTokens: small.info.reasoning ? 1024 : 20,
|
||||
@@ -582,11 +614,6 @@ export namespace Session {
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
await updateMessage(userMsg)
|
||||
for (const part of userParts) {
|
||||
await updatePart(part)
|
||||
}
|
||||
msgs.push({ info: userMsg, parts: userParts })
|
||||
|
||||
const mode = await Mode.get(input.mode ?? "build")
|
||||
let system = input.providerID === "anthropic" ? [PROMPT_ANTHROPIC_SPOOF.trim()] : []
|
||||
@@ -692,6 +719,51 @@ export namespace Session {
|
||||
|
||||
const stream = streamText({
|
||||
onError() {},
|
||||
async prepareStep({ messages }) {
|
||||
const queue = (state().queued.get(input.sessionID) ?? []).filter((x) => !x.processed)
|
||||
if (queue.length) {
|
||||
for (const item of queue) {
|
||||
if (item.processed) continue
|
||||
messages.push(
|
||||
...MessageV2.toModelMessage([
|
||||
{
|
||||
info: item.message,
|
||||
parts: item.parts,
|
||||
},
|
||||
]),
|
||||
)
|
||||
item.processed = true
|
||||
}
|
||||
assistantMsg.time.completed = Date.now()
|
||||
await updateMessage(assistantMsg)
|
||||
Object.assign(assistantMsg, {
|
||||
id: Identifier.ascending("message"),
|
||||
role: "assistant",
|
||||
system,
|
||||
path: {
|
||||
cwd: app.path.cwd,
|
||||
root: app.path.root,
|
||||
},
|
||||
cost: 0,
|
||||
tokens: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
modelID: input.modelID,
|
||||
providerID: input.providerID,
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
await updateMessage(assistantMsg)
|
||||
}
|
||||
return {
|
||||
messages,
|
||||
}
|
||||
},
|
||||
maxRetries: 10,
|
||||
maxOutputTokens: outputLimit,
|
||||
abortSignal: abort.signal,
|
||||
@@ -726,6 +798,16 @@ export namespace Session {
|
||||
}),
|
||||
})
|
||||
const result = await processor.process(stream)
|
||||
const queued = state().queued.get(input.sessionID) ?? []
|
||||
const unprocessed = queued.find((x) => !x.processed)
|
||||
if (unprocessed) {
|
||||
unprocessed.processed = true
|
||||
return chat(unprocessed.input)
|
||||
}
|
||||
for (const item of queued) {
|
||||
item.callback(result)
|
||||
}
|
||||
state().queued.delete(input.sessionID)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1087,6 +1169,10 @@ export namespace Session {
|
||||
return result
|
||||
}
|
||||
|
||||
function isLocked(sessionID: string) {
|
||||
return state().pending.has(sessionID)
|
||||
}
|
||||
|
||||
function lock(sessionID: string) {
|
||||
log.info("locking", { sessionID })
|
||||
if (state().pending.has(sessionID)) throw new BusyError(sessionID)
|
||||
|
||||
@@ -14,6 +14,7 @@ export namespace Snapshot {
|
||||
|
||||
// not a git repo, check if too big to snapshot
|
||||
if (!app.git) {
|
||||
return
|
||||
const files = await Ripgrep.files({
|
||||
cwd: app.path.cwd,
|
||||
limit: 1000,
|
||||
|
||||
19
packages/opencode/src/util/queue.ts
Normal file
19
packages/opencode/src/util/queue.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export class AsyncQueue<T> implements AsyncIterable<T> {
|
||||
private queue: T[] = []
|
||||
private resolvers: ((value: T) => void)[] = []
|
||||
|
||||
push(item: T) {
|
||||
const resolve = this.resolvers.shift()
|
||||
if (resolve) resolve(item)
|
||||
else this.queue.push(item)
|
||||
}
|
||||
|
||||
async next(): Promise<T> {
|
||||
if (this.queue.length > 0) return this.queue.shift()!
|
||||
return new Promise((resolve) => this.resolvers.push(resolve))
|
||||
}
|
||||
|
||||
async *[Symbol.asyncIterator]() {
|
||||
while (true) yield await this.next()
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
flag "github.com/spf13/pflag"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode-sdk-go/option"
|
||||
"github.com/sst/opencode/internal/api"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/clipboard"
|
||||
"github.com/sst/opencode/internal/tui"
|
||||
@@ -70,7 +71,6 @@ func main() {
|
||||
}()
|
||||
|
||||
// Create main context for the application
|
||||
|
||||
app_, err := app.New(ctx, version, appInfo, modes, httpClient, model, prompt, mode)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@@ -79,7 +79,6 @@ func main() {
|
||||
program := tea.NewProgram(
|
||||
tui.NewModel(app_),
|
||||
tea.WithAltScreen(),
|
||||
// tea.WithKeyboardEnhancements(),
|
||||
tea.WithMouseCellMotion(),
|
||||
)
|
||||
|
||||
@@ -102,6 +101,8 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
go api.Start(ctx, program, httpClient)
|
||||
|
||||
// Handle signals in a separate goroutine
|
||||
go func() {
|
||||
sig := <-sigChan
|
||||
|
||||
41
packages/tui/internal/api/api.go
Normal file
41
packages/tui/internal/api/api.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
Path string `json:"path"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
}
|
||||
|
||||
func Start(ctx context.Context, program *tea.Program, client *opencode.Client) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
var req Request
|
||||
if err := client.Get(ctx, "/tui/control/next", nil, &req); err != nil {
|
||||
log.Printf("Error getting next request: %v", err)
|
||||
continue
|
||||
}
|
||||
program.Send(req)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Reply(ctx context.Context, client *opencode.Client, response interface{}) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := client.Post(ctx, "/tui/control/response", response, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,10 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"log/slog"
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"github.com/sst/opencode/internal/clipboard"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/toast"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/id"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
@@ -35,7 +34,7 @@ type App struct {
|
||||
StatePath string
|
||||
Config *opencode.Config
|
||||
Client *opencode.Client
|
||||
State *config.State
|
||||
State *State
|
||||
ModeIndex int
|
||||
Mode *opencode.Mode
|
||||
Provider *opencode.Provider
|
||||
@@ -61,10 +60,7 @@ type ModelSelectedMsg struct {
|
||||
}
|
||||
type SessionClearedMsg struct{}
|
||||
type CompactSessionMsg struct{}
|
||||
type SendMsg struct {
|
||||
Text string
|
||||
Attachments []opencode.FilePartInputParam
|
||||
}
|
||||
type SendPrompt = Prompt
|
||||
type SetEditorContentMsg struct {
|
||||
Text string
|
||||
}
|
||||
@@ -95,20 +91,25 @@ func New(
|
||||
}
|
||||
|
||||
appStatePath := filepath.Join(appInfo.Path.State, "tui")
|
||||
appState, err := config.LoadState(appStatePath)
|
||||
appState, err := LoadState(appStatePath)
|
||||
if err != nil {
|
||||
appState = config.NewState()
|
||||
config.SaveState(appStatePath, appState)
|
||||
appState = NewState()
|
||||
SaveState(appStatePath, appState)
|
||||
}
|
||||
|
||||
if appState.ModeModel == nil {
|
||||
appState.ModeModel = make(map[string]config.ModeModel)
|
||||
appState.ModeModel = make(map[string]ModeModel)
|
||||
}
|
||||
|
||||
if configInfo.Theme != "" {
|
||||
appState.Theme = configInfo.Theme
|
||||
}
|
||||
|
||||
themeEnv := os.Getenv("OPENCODE_THEME")
|
||||
if themeEnv != "" {
|
||||
appState.Theme = themeEnv
|
||||
}
|
||||
|
||||
var modeIndex int
|
||||
var mode *opencode.Mode
|
||||
modeName := "build"
|
||||
@@ -127,7 +128,7 @@ func New(
|
||||
mode = &modes[modeIndex]
|
||||
|
||||
if mode.Model.ModelID != "" {
|
||||
appState.ModeModel[mode.Name] = config.ModeModel{
|
||||
appState.ModeModel[mode.Name] = ModeModel{
|
||||
ProviderID: mode.Model.ProviderID,
|
||||
ModelID: mode.Model.ModelID,
|
||||
}
|
||||
@@ -191,7 +192,7 @@ func (a *App) Key(commandName commands.CommandName) string {
|
||||
return base(key) + muted(" "+command.Description)
|
||||
}
|
||||
|
||||
func (a *App) SetClipboard(text string) tea.Cmd {
|
||||
func SetClipboard(text string) tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
clipboard.Write(clipboard.FmtText, []byte(text))
|
||||
@@ -241,11 +242,7 @@ func (a *App) cycleMode(forward bool) (*App, tea.Cmd) {
|
||||
}
|
||||
|
||||
a.State.Mode = a.Mode.Name
|
||||
|
||||
return a, func() tea.Msg {
|
||||
a.SaveState()
|
||||
return nil
|
||||
}
|
||||
return a, a.SaveState()
|
||||
}
|
||||
|
||||
func (a *App) SwitchMode() (*App, tea.Cmd) {
|
||||
@@ -346,7 +343,7 @@ func (a *App) InitializeProvider() tea.Cmd {
|
||||
Model: *currentModel,
|
||||
}))
|
||||
if a.InitialPrompt != nil && *a.InitialPrompt != "" {
|
||||
cmds = append(cmds, util.CmdHandler(SendMsg{Text: *a.InitialPrompt}))
|
||||
cmds = append(cmds, util.CmdHandler(SendPrompt{Text: *a.InitialPrompt}))
|
||||
}
|
||||
return tea.Sequence(cmds...)
|
||||
}
|
||||
@@ -370,18 +367,20 @@ func (a *App) IsBusy() bool {
|
||||
if len(a.Messages) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
lastMessage := a.Messages[len(a.Messages)-1]
|
||||
if casted, ok := lastMessage.Info.(opencode.AssistantMessage); ok {
|
||||
return casted.Time.Completed == 0
|
||||
}
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *App) SaveState() {
|
||||
err := config.SaveState(a.StatePath, a.State)
|
||||
if err != nil {
|
||||
slog.Error("Failed to save state", "error", err)
|
||||
func (a *App) SaveState() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := SaveState(a.StatePath, a.State)
|
||||
if err != nil {
|
||||
slog.Error("Failed to save state", "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,11 +458,7 @@ func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) {
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (a *App) SendChatMessage(
|
||||
ctx context.Context,
|
||||
text string,
|
||||
attachments []opencode.FilePartInputParam,
|
||||
) (*App, tea.Cmd) {
|
||||
func (a *App) SendPrompt(ctx context.Context, prompt Prompt) (*App, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
if a.Session.ID == "" {
|
||||
session, err := a.CreateSession(ctx)
|
||||
@@ -474,65 +469,18 @@ func (a *App) SendChatMessage(
|
||||
cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
|
||||
}
|
||||
|
||||
message := opencode.UserMessage{
|
||||
ID: id.Ascending(id.Message),
|
||||
SessionID: a.Session.ID,
|
||||
Role: opencode.UserMessageRoleUser,
|
||||
Time: opencode.UserMessageTime{
|
||||
Created: float64(time.Now().UnixMilli()),
|
||||
},
|
||||
}
|
||||
messageID := id.Ascending(id.Message)
|
||||
message := prompt.ToMessage(messageID, a.Session.ID)
|
||||
|
||||
parts := []opencode.PartUnion{opencode.TextPart{
|
||||
ID: id.Ascending(id.Part),
|
||||
MessageID: message.ID,
|
||||
SessionID: a.Session.ID,
|
||||
Type: opencode.TextPartTypeText,
|
||||
Text: text,
|
||||
}}
|
||||
if len(attachments) > 0 {
|
||||
for _, attachment := range attachments {
|
||||
parts = append(parts, opencode.FilePart{
|
||||
ID: id.Ascending(id.Part),
|
||||
MessageID: message.ID,
|
||||
SessionID: a.Session.ID,
|
||||
Type: opencode.FilePartTypeFile,
|
||||
Filename: attachment.Filename.Value,
|
||||
Mime: attachment.Mime.Value,
|
||||
URL: attachment.URL.Value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
a.Messages = append(a.Messages, Message{Info: message, Parts: parts})
|
||||
a.Messages = append(a.Messages, message)
|
||||
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
partsParam := []opencode.SessionChatParamsPartUnion{}
|
||||
for _, part := range parts {
|
||||
switch casted := part.(type) {
|
||||
case opencode.TextPart:
|
||||
partsParam = append(partsParam, opencode.TextPartInputParam{
|
||||
ID: opencode.F(casted.ID),
|
||||
Type: opencode.F(opencode.TextPartInputType(casted.Type)),
|
||||
Text: opencode.F(casted.Text),
|
||||
})
|
||||
case opencode.FilePart:
|
||||
partsParam = append(partsParam, opencode.FilePartInputParam{
|
||||
ID: opencode.F(casted.ID),
|
||||
Mime: opencode.F(casted.Mime),
|
||||
Type: opencode.F(opencode.FilePartInputType(casted.Type)),
|
||||
URL: opencode.F(casted.URL),
|
||||
Filename: opencode.F(casted.Filename),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
|
||||
Parts: opencode.F(partsParam),
|
||||
MessageID: opencode.F(message.ID),
|
||||
ProviderID: opencode.F(a.Provider.ID),
|
||||
ModelID: opencode.F(a.Model.ID),
|
||||
Mode: opencode.F(a.Mode.Name),
|
||||
MessageID: opencode.F(messageID),
|
||||
Parts: opencode.F(message.ToSessionChatParams()),
|
||||
})
|
||||
if err != nil {
|
||||
errormsg := fmt.Sprintf("failed to send message: %v", err)
|
||||
@@ -557,7 +505,6 @@ func (a *App) Cancel(ctx context.Context, sessionID string) error {
|
||||
_, err := a.Client.Session.Abort(ctx, sessionID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to cancel session", "error", err)
|
||||
// status.Error(err.Error())
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
235
packages/tui/internal/app/prompt.go
Normal file
235
packages/tui/internal/app/prompt.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/attachment"
|
||||
"github.com/sst/opencode/internal/id"
|
||||
)
|
||||
|
||||
type Prompt struct {
|
||||
Text string `toml:"text"`
|
||||
Attachments []*attachment.Attachment `toml:"attachments"`
|
||||
}
|
||||
|
||||
func (p Prompt) ToMessage(
|
||||
messageID string,
|
||||
sessionID string,
|
||||
) Message {
|
||||
message := opencode.UserMessage{
|
||||
ID: messageID,
|
||||
SessionID: sessionID,
|
||||
Role: opencode.UserMessageRoleUser,
|
||||
Time: opencode.UserMessageTime{
|
||||
Created: float64(time.Now().UnixMilli()),
|
||||
},
|
||||
}
|
||||
|
||||
text := p.Text
|
||||
textAttachments := []*attachment.Attachment{}
|
||||
for _, attachment := range p.Attachments {
|
||||
if attachment.Type == "text" {
|
||||
textAttachments = append(textAttachments, attachment)
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(textAttachments)-1; i++ {
|
||||
for j := i + 1; j < len(textAttachments); j++ {
|
||||
if textAttachments[i].StartIndex < textAttachments[j].StartIndex {
|
||||
textAttachments[i], textAttachments[j] = textAttachments[j], textAttachments[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, att := range textAttachments {
|
||||
if source, ok := att.GetTextSource(); ok {
|
||||
text = text[:att.StartIndex] + source.Value + text[att.EndIndex:]
|
||||
}
|
||||
}
|
||||
|
||||
parts := []opencode.PartUnion{opencode.TextPart{
|
||||
ID: id.Ascending(id.Part),
|
||||
MessageID: messageID,
|
||||
SessionID: sessionID,
|
||||
Type: opencode.TextPartTypeText,
|
||||
Text: text,
|
||||
}}
|
||||
for _, attachment := range p.Attachments {
|
||||
text := opencode.FilePartSourceText{
|
||||
Start: int64(attachment.StartIndex),
|
||||
End: int64(attachment.EndIndex),
|
||||
Value: attachment.Display,
|
||||
}
|
||||
source := &opencode.FilePartSource{}
|
||||
switch attachment.Type {
|
||||
case "text":
|
||||
continue
|
||||
case "file":
|
||||
if fileSource, ok := attachment.GetFileSource(); ok {
|
||||
source = &opencode.FilePartSource{
|
||||
Text: text,
|
||||
Path: fileSource.Path,
|
||||
Type: opencode.FilePartSourceTypeFile,
|
||||
}
|
||||
}
|
||||
case "symbol":
|
||||
if symbolSource, ok := attachment.GetSymbolSource(); ok {
|
||||
source = &opencode.FilePartSource{
|
||||
Text: text,
|
||||
Path: symbolSource.Path,
|
||||
Type: opencode.FilePartSourceTypeSymbol,
|
||||
Kind: int64(symbolSource.Kind),
|
||||
Name: symbolSource.Name,
|
||||
Range: opencode.SymbolSourceRange{
|
||||
Start: opencode.SymbolSourceRangeStart{
|
||||
Line: float64(symbolSource.Range.Start.Line),
|
||||
Character: float64(symbolSource.Range.Start.Char),
|
||||
},
|
||||
End: opencode.SymbolSourceRangeEnd{
|
||||
Line: float64(symbolSource.Range.End.Line),
|
||||
Character: float64(symbolSource.Range.End.Char),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
parts = append(parts, opencode.FilePart{
|
||||
ID: id.Ascending(id.Part),
|
||||
MessageID: messageID,
|
||||
SessionID: sessionID,
|
||||
Type: opencode.FilePartTypeFile,
|
||||
Filename: attachment.Filename,
|
||||
Mime: attachment.MediaType,
|
||||
URL: attachment.URL,
|
||||
Source: *source,
|
||||
})
|
||||
}
|
||||
return Message{
|
||||
Info: message,
|
||||
Parts: parts,
|
||||
}
|
||||
}
|
||||
|
||||
func (m Message) ToSessionChatParams() []opencode.SessionChatParamsPartUnion {
|
||||
parts := []opencode.SessionChatParamsPartUnion{}
|
||||
for _, part := range m.Parts {
|
||||
switch p := part.(type) {
|
||||
case opencode.TextPart:
|
||||
parts = append(parts, opencode.TextPartInputParam{
|
||||
ID: opencode.F(p.ID),
|
||||
Type: opencode.F(opencode.TextPartInputTypeText),
|
||||
Text: opencode.F(p.Text),
|
||||
Synthetic: opencode.F(p.Synthetic),
|
||||
Time: opencode.F(opencode.TextPartInputTimeParam{
|
||||
Start: opencode.F(p.Time.Start),
|
||||
End: opencode.F(p.Time.End),
|
||||
}),
|
||||
})
|
||||
case opencode.FilePart:
|
||||
var source opencode.FilePartSourceUnionParam
|
||||
switch p.Source.Type {
|
||||
case "file":
|
||||
source = opencode.FileSourceParam{
|
||||
Type: opencode.F(opencode.FileSourceTypeFile),
|
||||
Path: opencode.F(p.Source.Path),
|
||||
Text: opencode.F(opencode.FilePartSourceTextParam{
|
||||
Start: opencode.F(int64(p.Source.Text.Start)),
|
||||
End: opencode.F(int64(p.Source.Text.End)),
|
||||
Value: opencode.F(p.Source.Text.Value),
|
||||
}),
|
||||
}
|
||||
case "symbol":
|
||||
source = opencode.SymbolSourceParam{
|
||||
Type: opencode.F(opencode.SymbolSourceTypeSymbol),
|
||||
Path: opencode.F(p.Source.Path),
|
||||
Name: opencode.F(p.Source.Name),
|
||||
Kind: opencode.F(p.Source.Kind),
|
||||
Range: opencode.F(opencode.SymbolSourceRangeParam{
|
||||
Start: opencode.F(opencode.SymbolSourceRangeStartParam{
|
||||
Line: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).Start.Line)),
|
||||
Character: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).Start.Character)),
|
||||
}),
|
||||
End: opencode.F(opencode.SymbolSourceRangeEndParam{
|
||||
Line: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).End.Line)),
|
||||
Character: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).End.Character)),
|
||||
}),
|
||||
}),
|
||||
Text: opencode.F(opencode.FilePartSourceTextParam{
|
||||
Value: opencode.F(p.Source.Text.Value),
|
||||
Start: opencode.F(p.Source.Text.Start),
|
||||
End: opencode.F(p.Source.Text.End),
|
||||
}),
|
||||
}
|
||||
}
|
||||
parts = append(parts, opencode.FilePartInputParam{
|
||||
ID: opencode.F(p.ID),
|
||||
Type: opencode.F(opencode.FilePartInputTypeFile),
|
||||
Mime: opencode.F(p.Mime),
|
||||
URL: opencode.F(p.URL),
|
||||
Filename: opencode.F(p.Filename),
|
||||
Source: opencode.F(source),
|
||||
})
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func (p Prompt) ToSessionChatParams() []opencode.SessionChatParamsPartUnion {
|
||||
parts := []opencode.SessionChatParamsPartUnion{
|
||||
opencode.TextPartInputParam{
|
||||
Type: opencode.F(opencode.TextPartInputTypeText),
|
||||
Text: opencode.F(p.Text),
|
||||
},
|
||||
}
|
||||
for _, att := range p.Attachments {
|
||||
filePart := opencode.FilePartInputParam{
|
||||
Type: opencode.F(opencode.FilePartInputTypeFile),
|
||||
Mime: opencode.F(att.MediaType),
|
||||
URL: opencode.F(att.URL),
|
||||
Filename: opencode.F(att.Filename),
|
||||
}
|
||||
switch att.Type {
|
||||
case "file":
|
||||
if fs, ok := att.GetFileSource(); ok {
|
||||
filePart.Source = opencode.F(
|
||||
opencode.FilePartSourceUnionParam(opencode.FileSourceParam{
|
||||
Type: opencode.F(opencode.FileSourceTypeFile),
|
||||
Path: opencode.F(fs.Path),
|
||||
Text: opencode.F(opencode.FilePartSourceTextParam{
|
||||
Start: opencode.F(int64(att.StartIndex)),
|
||||
End: opencode.F(int64(att.EndIndex)),
|
||||
Value: opencode.F(att.Display),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
case "symbol":
|
||||
if ss, ok := att.GetSymbolSource(); ok {
|
||||
filePart.Source = opencode.F(
|
||||
opencode.FilePartSourceUnionParam(opencode.SymbolSourceParam{
|
||||
Type: opencode.F(opencode.SymbolSourceTypeSymbol),
|
||||
Path: opencode.F(ss.Path),
|
||||
Name: opencode.F(ss.Name),
|
||||
Kind: opencode.F(int64(ss.Kind)),
|
||||
Range: opencode.F(opencode.SymbolSourceRangeParam{
|
||||
Start: opencode.F(opencode.SymbolSourceRangeStartParam{
|
||||
Line: opencode.F(float64(ss.Range.Start.Line)),
|
||||
Character: opencode.F(float64(ss.Range.Start.Char)),
|
||||
}),
|
||||
End: opencode.F(opencode.SymbolSourceRangeEndParam{
|
||||
Line: opencode.F(float64(ss.Range.End.Line)),
|
||||
Character: opencode.F(float64(ss.Range.End.Char)),
|
||||
}),
|
||||
}),
|
||||
Text: opencode.F(opencode.FilePartSourceTextParam{
|
||||
Start: opencode.F(int64(att.StartIndex)),
|
||||
End: opencode.F(int64(att.EndIndex)),
|
||||
Value: opencode.F(att.Display),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
parts = append(parts, filePart)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package config
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -30,6 +30,7 @@ type State struct {
|
||||
RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
|
||||
MessagesRight bool `toml:"messages_right"`
|
||||
SplitDiff bool `toml:"split_diff"`
|
||||
MessageHistory []Prompt `toml:"message_history"`
|
||||
}
|
||||
|
||||
func NewState() *State {
|
||||
@@ -38,6 +39,7 @@ func NewState() *State {
|
||||
Mode: "build",
|
||||
ModeModel: make(map[string]ModeModel),
|
||||
RecentlyUsedModels: make([]ModelUsage, 0),
|
||||
MessageHistory: make([]Prompt, 0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +80,13 @@ func (s *State) RemoveModelFromRecentlyUsed(providerID, modelID string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) AddPromptToHistory(prompt Prompt) {
|
||||
s.MessageHistory = append([]Prompt{prompt}, s.MessageHistory...)
|
||||
if len(s.MessageHistory) > 50 {
|
||||
s.MessageHistory = s.MessageHistory[:50]
|
||||
}
|
||||
}
|
||||
|
||||
// SaveState writes the provided Config struct to the specified TOML file.
|
||||
// It will create the file if it doesn't exist, or overwrite it if it does.
|
||||
func SaveState(filePath string, state *State) error {
|
||||
77
packages/tui/internal/attachment/attachment.go
Normal file
77
packages/tui/internal/attachment/attachment.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package attachment
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type TextSource struct {
|
||||
Value string `toml:"value"`
|
||||
}
|
||||
|
||||
type FileSource struct {
|
||||
Path string `toml:"path"`
|
||||
Mime string `toml:"mime"`
|
||||
Data []byte `toml:"data,omitempty"` // Optional for image data
|
||||
}
|
||||
|
||||
type SymbolSource struct {
|
||||
Path string `toml:"path"`
|
||||
Name string `toml:"name"`
|
||||
Kind int `toml:"kind"`
|
||||
Range SymbolRange `toml:"range"`
|
||||
}
|
||||
|
||||
type SymbolRange struct {
|
||||
Start Position `toml:"start"`
|
||||
End Position `toml:"end"`
|
||||
}
|
||||
|
||||
type Position struct {
|
||||
Line int `toml:"line"`
|
||||
Char int `toml:"char"`
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
ID string `toml:"id"`
|
||||
Type string `toml:"type"`
|
||||
Display string `toml:"display"`
|
||||
URL string `toml:"url"`
|
||||
Filename string `toml:"filename"`
|
||||
MediaType string `toml:"media_type"`
|
||||
StartIndex int `toml:"start_index"`
|
||||
EndIndex int `toml:"end_index"`
|
||||
Source any `toml:"source,omitempty"`
|
||||
}
|
||||
|
||||
// NewAttachment creates a new attachment with a unique ID
|
||||
func NewAttachment() *Attachment {
|
||||
return &Attachment{
|
||||
ID: uuid.NewString(),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Attachment) GetTextSource() (*TextSource, bool) {
|
||||
if a.Type != "text" {
|
||||
return nil, false
|
||||
}
|
||||
ts, ok := a.Source.(*TextSource)
|
||||
return ts, ok
|
||||
}
|
||||
|
||||
// GetFileSource returns the source as FileSource if the attachment is a file type
|
||||
func (a *Attachment) GetFileSource() (*FileSource, bool) {
|
||||
if a.Type != "file" {
|
||||
return nil, false
|
||||
}
|
||||
fs, ok := a.Source.(*FileSource)
|
||||
return fs, ok
|
||||
}
|
||||
|
||||
// GetSymbolSource returns the source as SymbolSource if the attachment is a symbol type
|
||||
func (a *Attachment) GetSymbolSource() (*SymbolSource, bool) {
|
||||
if a.Type != "symbol" {
|
||||
return nil, false
|
||||
}
|
||||
ss, ok := a.Source.(*SymbolSource)
|
||||
return ss, ok
|
||||
}
|
||||
@@ -63,17 +63,37 @@ func (r CommandRegistry) Sorted() []Command {
|
||||
commands = append(commands, command)
|
||||
}
|
||||
slices.SortFunc(commands, func(a, b Command) int {
|
||||
// Priority order: session_new, session_share, model_list, app_help first, app_exit last
|
||||
priorityOrder := map[CommandName]int{
|
||||
SessionNewCommand: 0,
|
||||
AppHelpCommand: 1,
|
||||
SessionShareCommand: 2,
|
||||
ModelListCommand: 3,
|
||||
}
|
||||
|
||||
aPriority, aHasPriority := priorityOrder[a.Name]
|
||||
bPriority, bHasPriority := priorityOrder[b.Name]
|
||||
|
||||
if aHasPriority && bHasPriority {
|
||||
return aPriority - bPriority
|
||||
}
|
||||
if aHasPriority {
|
||||
return -1
|
||||
}
|
||||
if bHasPriority {
|
||||
return 1
|
||||
}
|
||||
if a.Name == AppExitCommand {
|
||||
return 1
|
||||
}
|
||||
if b.Name == AppExitCommand {
|
||||
return -1
|
||||
}
|
||||
|
||||
return strings.Compare(string(a.Name), string(b.Name))
|
||||
})
|
||||
return commands
|
||||
}
|
||||
|
||||
func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
|
||||
var matched []Command
|
||||
for _, command := range r.Sorted() {
|
||||
@@ -349,6 +369,9 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
||||
continue
|
||||
}
|
||||
if keybind, ok := keybinds[string(command.Name)]; ok && keybind != "" {
|
||||
if keybind == "none" {
|
||||
continue
|
||||
}
|
||||
command.Keybindings = parseBindings(keybind)
|
||||
}
|
||||
registry[command.Name] = command
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/attachment"
|
||||
"github.com/sst/opencode/internal/clipboard"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
@@ -43,6 +44,7 @@ type EditorComponent interface {
|
||||
SetValueWithAttachments(value string)
|
||||
SetInterruptKeyInDebounce(inDebounce bool)
|
||||
SetExitKeyInDebounce(inDebounce bool)
|
||||
RestoreFromHistory(index int)
|
||||
}
|
||||
|
||||
type editorComponent struct {
|
||||
@@ -52,6 +54,9 @@ type editorComponent struct {
|
||||
spinner spinner.Model
|
||||
interruptKeyInDebounce bool
|
||||
exitKeyInDebounce bool
|
||||
historyIndex int // -1 means current (not in history)
|
||||
currentText string // Store current text when navigating history
|
||||
pasteCounter int
|
||||
}
|
||||
|
||||
func (m *editorComponent) Init() tea.Cmd {
|
||||
@@ -70,6 +75,51 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
case tea.KeyPressMsg:
|
||||
// Handle up/down arrows and ctrl+p/ctrl+n for history navigation
|
||||
switch msg.String() {
|
||||
case "up", "ctrl+p":
|
||||
// Only navigate history if cursor is at the first line and column (for arrow keys)
|
||||
// or allow ctrl+p from anywhere
|
||||
if (msg.String() == "ctrl+p" || (m.textarea.Line() == 0 && m.textarea.CursorColumn() == 0)) && len(m.app.State.MessageHistory) > 0 {
|
||||
if m.historyIndex == -1 {
|
||||
// Save current text before entering history
|
||||
m.currentText = m.textarea.Value()
|
||||
m.textarea.MoveToBegin()
|
||||
}
|
||||
// Move up in history (older messages)
|
||||
if m.historyIndex < len(m.app.State.MessageHistory)-1 {
|
||||
m.historyIndex++
|
||||
m.RestoreFromHistory(m.historyIndex)
|
||||
m.textarea.MoveToBegin()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
case "down", "ctrl+n":
|
||||
// Only navigate history if cursor is at the last line and we're in history navigation (for arrow keys)
|
||||
// or allow ctrl+n from anywhere if we're in history navigation
|
||||
if (msg.String() == "ctrl+n" || m.textarea.IsCursorAtEnd()) && m.historyIndex > -1 {
|
||||
// Move down in history (newer messages)
|
||||
m.historyIndex--
|
||||
if m.historyIndex == -1 {
|
||||
// Restore current text
|
||||
m.textarea.Reset()
|
||||
m.textarea.SetValue(m.currentText)
|
||||
m.currentText = ""
|
||||
} else {
|
||||
m.RestoreFromHistory(m.historyIndex)
|
||||
m.textarea.MoveToEnd()
|
||||
}
|
||||
return m, nil
|
||||
} else if m.historyIndex > -1 && msg.String() == "down" {
|
||||
m.textarea.MoveToEnd()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
// Reset history navigation on any other input
|
||||
if m.historyIndex != -1 {
|
||||
m.historyIndex = -1
|
||||
m.currentText = ""
|
||||
}
|
||||
// Maximize editor responsiveness for printable characters
|
||||
if msg.Text != "" {
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
@@ -82,12 +132,22 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
text, err := strconv.Unquote(`"` + text + `"`)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unquote text", "error", err)
|
||||
m.textarea.InsertRunesFromUserInput([]rune(msg))
|
||||
text := string(msg)
|
||||
if m.shouldSummarizePastedText(text) {
|
||||
m.handleLongPaste(text)
|
||||
} else {
|
||||
m.textarea.InsertRunesFromUserInput([]rune(msg))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if _, err := os.Stat(text); err != nil {
|
||||
slog.Error("Failed to paste file", "error", err)
|
||||
m.textarea.InsertRunesFromUserInput([]rune(msg))
|
||||
text := string(msg)
|
||||
if m.shouldSummarizePastedText(text) {
|
||||
m.handleLongPaste(text)
|
||||
} else {
|
||||
m.textarea.InsertRunesFromUserInput([]rune(msg))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -95,7 +155,11 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
attachment := m.createAttachmentFromFile(filePath)
|
||||
if attachment == nil {
|
||||
m.textarea.InsertRunesFromUserInput([]rune(msg))
|
||||
if m.shouldSummarizePastedText(text) {
|
||||
m.handleLongPaste(text)
|
||||
} else {
|
||||
m.textarea.InsertRunesFromUserInput([]rune(msg))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -103,11 +167,16 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.textarea.InsertString(" ")
|
||||
case tea.ClipboardMsg:
|
||||
text := string(msg)
|
||||
m.textarea.InsertRunesFromUserInput([]rune(text))
|
||||
// Check if the pasted text is long and should be summarized
|
||||
if m.shouldSummarizePastedText(text) {
|
||||
m.handleLongPaste(text)
|
||||
} else {
|
||||
m.textarea.InsertRunesFromUserInput([]rune(text))
|
||||
}
|
||||
case dialog.ThemeSelectedMsg:
|
||||
m.textarea = updateTextareaStyles(m.textarea)
|
||||
m.spinner = createSpinner()
|
||||
return m, tea.Batch(m.spinner.Tick, m.textarea.Focus())
|
||||
return m, m.textarea.Focus()
|
||||
case dialog.CompletionSelectedMsg:
|
||||
switch msg.Item.ProviderID {
|
||||
case "commands":
|
||||
@@ -151,12 +220,28 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
symbol := msg.Item.RawData.(opencode.Symbol)
|
||||
parts := strings.Split(symbol.Name, ".")
|
||||
lastPart := parts[len(parts)-1]
|
||||
attachment := &textarea.Attachment{
|
||||
attachment := &attachment.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
Type: "symbol",
|
||||
Display: "@" + lastPart,
|
||||
URL: msg.Item.Value,
|
||||
Filename: lastPart,
|
||||
MediaType: "text/plain",
|
||||
Source: &attachment.SymbolSource{
|
||||
Path: symbol.Location.Uri,
|
||||
Name: symbol.Name,
|
||||
Kind: int(symbol.Kind),
|
||||
Range: attachment.SymbolRange{
|
||||
Start: attachment.Position{
|
||||
Line: int(symbol.Location.Range.Start.Line),
|
||||
Char: int(symbol.Location.Range.Start.Character),
|
||||
},
|
||||
End: attachment.Position{
|
||||
Line: int(symbol.Location.Range.End.Line),
|
||||
Char: int(symbol.Location.Range.End.Character),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
@@ -311,28 +396,25 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
var cmds []tea.Cmd
|
||||
|
||||
attachments := m.textarea.GetAttachments()
|
||||
fileParts := make([]opencode.FilePartInputParam, 0)
|
||||
for _, attachment := range attachments {
|
||||
fileParts = append(fileParts, opencode.FilePartInputParam{
|
||||
Type: opencode.F(opencode.FilePartInputTypeFile),
|
||||
Mime: opencode.F(attachment.MediaType),
|
||||
URL: opencode.F(attachment.URL),
|
||||
Filename: opencode.F(attachment.Filename),
|
||||
})
|
||||
}
|
||||
|
||||
prompt := app.Prompt{Text: value, Attachments: attachments}
|
||||
m.app.State.AddPromptToHistory(prompt)
|
||||
cmds = append(cmds, m.app.SaveState())
|
||||
|
||||
updated, cmd := m.Clear()
|
||||
m = updated.(*editorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: fileParts}))
|
||||
cmds = append(cmds, util.CmdHandler(app.SendPrompt(prompt)))
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
|
||||
m.textarea.Reset()
|
||||
m.historyIndex = -1
|
||||
m.currentText = ""
|
||||
m.pasteCounter = 0
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -342,12 +424,18 @@ func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
|
||||
attachmentCount := len(m.textarea.GetAttachments())
|
||||
attachmentIndex := attachmentCount + 1
|
||||
base64EncodedFile := base64.StdEncoding.EncodeToString(imageBytes)
|
||||
attachment := &textarea.Attachment{
|
||||
attachment := &attachment.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
Type: "file",
|
||||
MediaType: "image/png",
|
||||
Display: fmt.Sprintf("[Image #%d]", attachmentIndex),
|
||||
Filename: fmt.Sprintf("image-%d.png", attachmentIndex),
|
||||
URL: fmt.Sprintf("data:image/png;base64,%s", base64EncodedFile),
|
||||
Source: &attachment.FileSource{
|
||||
Path: fmt.Sprintf("image-%d.png", attachmentIndex),
|
||||
Mime: "image/png",
|
||||
Data: imageBytes,
|
||||
},
|
||||
}
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
@@ -356,7 +444,13 @@ func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
|
||||
|
||||
textBytes := clipboard.Read(clipboard.FmtText)
|
||||
if textBytes != nil {
|
||||
m.textarea.InsertRunesFromUserInput([]rune(string(textBytes)))
|
||||
text := string(textBytes)
|
||||
// Check if the pasted text is long and should be summarized
|
||||
if m.shouldSummarizePastedText(text) {
|
||||
m.handleLongPaste(text)
|
||||
} else {
|
||||
m.textarea.InsertRunesFromUserInput([]rune(text))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -425,6 +519,48 @@ func (m *editorComponent) getExitKeyText() string {
|
||||
return m.app.Commands[commands.AppExitCommand].Keys()[0]
|
||||
}
|
||||
|
||||
// shouldSummarizePastedText determines if pasted text should be summarized
|
||||
func (m *editorComponent) shouldSummarizePastedText(text string) bool {
|
||||
lines := strings.Split(text, "\n")
|
||||
lineCount := len(lines)
|
||||
charCount := len(text)
|
||||
|
||||
// Consider text long if it has more than 3 lines or more than 150 characters
|
||||
return lineCount > 3 || charCount > 150
|
||||
}
|
||||
|
||||
// handleLongPaste handles long pasted text by creating a summary attachment
|
||||
func (m *editorComponent) handleLongPaste(text string) {
|
||||
lines := strings.Split(text, "\n")
|
||||
lineCount := len(lines)
|
||||
|
||||
// Increment paste counter
|
||||
m.pasteCounter++
|
||||
|
||||
// Create attachment with full text as base64 encoded data
|
||||
fileBytes := []byte(text)
|
||||
base64EncodedText := base64.StdEncoding.EncodeToString(fileBytes)
|
||||
url := fmt.Sprintf("data:text/plain;base64,%s", base64EncodedText)
|
||||
|
||||
fileName := fmt.Sprintf("pasted-text-%d.txt", m.pasteCounter)
|
||||
displayText := fmt.Sprintf("[pasted #%d %d+ lines]", m.pasteCounter, lineCount)
|
||||
|
||||
attachment := &attachment.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
Type: "text",
|
||||
MediaType: "text/plain",
|
||||
Display: displayText,
|
||||
URL: url,
|
||||
Filename: fileName,
|
||||
Source: &attachment.TextSource{
|
||||
Value: text,
|
||||
},
|
||||
}
|
||||
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
}
|
||||
|
||||
func updateTextareaStyles(ta textarea.Model) textarea.Model {
|
||||
t := theme.CurrentTheme()
|
||||
bgColor := t.BackgroundElement()
|
||||
@@ -485,11 +621,44 @@ func NewEditorComponent(app *app.App) EditorComponent {
|
||||
textarea: ta,
|
||||
spinner: s,
|
||||
interruptKeyInDebounce: false,
|
||||
historyIndex: -1,
|
||||
pasteCounter: 0,
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// RestoreFromHistory restores a message from history at the given index
|
||||
func (m *editorComponent) RestoreFromHistory(index int) {
|
||||
if index < 0 || index >= len(m.app.State.MessageHistory) {
|
||||
return
|
||||
}
|
||||
|
||||
entry := m.app.State.MessageHistory[index]
|
||||
|
||||
m.textarea.Reset()
|
||||
m.textarea.SetValue(entry.Text)
|
||||
|
||||
// Sort attachments by start index in reverse order (process from end to beginning)
|
||||
// This prevents index shifting issues
|
||||
attachmentsCopy := make([]*attachment.Attachment, len(entry.Attachments))
|
||||
copy(attachmentsCopy, entry.Attachments)
|
||||
|
||||
for i := 0; i < len(attachmentsCopy)-1; i++ {
|
||||
for j := i + 1; j < len(attachmentsCopy); j++ {
|
||||
if attachmentsCopy[i].StartIndex < attachmentsCopy[j].StartIndex {
|
||||
attachmentsCopy[i], attachmentsCopy[j] = attachmentsCopy[j], attachmentsCopy[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, att := range attachmentsCopy {
|
||||
m.textarea.SetCursorColumn(att.StartIndex)
|
||||
m.textarea.ReplaceRange(att.StartIndex, att.EndIndex, "")
|
||||
m.textarea.InsertAttachment(att)
|
||||
}
|
||||
}
|
||||
|
||||
func getMediaTypeFromExtension(ext string) string {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".jpg":
|
||||
@@ -503,18 +672,27 @@ func getMediaTypeFromExtension(ext string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *editorComponent) createAttachmentFromFile(filePath string) *textarea.Attachment {
|
||||
func (m *editorComponent) createAttachmentFromFile(filePath string) *attachment.Attachment {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
mediaType := getMediaTypeFromExtension(ext)
|
||||
absolutePath := filePath
|
||||
if !filepath.IsAbs(filePath) {
|
||||
absolutePath = filepath.Join(m.app.Info.Path.Cwd, filePath)
|
||||
}
|
||||
|
||||
// For text files, create a simple file reference
|
||||
if mediaType == "text/plain" {
|
||||
return &textarea.Attachment{
|
||||
return &attachment.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
Type: "file",
|
||||
Display: "@" + filePath,
|
||||
URL: fmt.Sprintf("file://./%s", filePath),
|
||||
Filename: filePath,
|
||||
MediaType: mediaType,
|
||||
Source: &attachment.FileSource{
|
||||
Path: absolutePath,
|
||||
Mime: mediaType,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,25 +711,38 @@ func (m *editorComponent) createAttachmentFromFile(filePath string) *textarea.At
|
||||
if strings.HasPrefix(mediaType, "image/") {
|
||||
label = "Image"
|
||||
}
|
||||
|
||||
return &textarea.Attachment{
|
||||
return &attachment.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
Type: "file",
|
||||
MediaType: mediaType,
|
||||
Display: fmt.Sprintf("[%s #%d]", label, attachmentIndex),
|
||||
URL: url,
|
||||
Filename: filePath,
|
||||
Source: &attachment.FileSource{
|
||||
Path: absolutePath,
|
||||
Mime: mediaType,
|
||||
Data: fileBytes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *editorComponent) createAttachmentFromPath(filePath string) *textarea.Attachment {
|
||||
func (m *editorComponent) createAttachmentFromPath(filePath string) *attachment.Attachment {
|
||||
extension := filepath.Ext(filePath)
|
||||
mediaType := getMediaTypeFromExtension(extension)
|
||||
|
||||
return &textarea.Attachment{
|
||||
absolutePath := filePath
|
||||
if !filepath.IsAbs(filePath) {
|
||||
absolutePath = filepath.Join(m.app.Info.Path.Cwd, filePath)
|
||||
}
|
||||
return &attachment.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
Type: "file",
|
||||
Display: "@" + filePath,
|
||||
URL: fmt.Sprintf("file://./%s", url.PathEscape(filePath)),
|
||||
Filename: filePath,
|
||||
MediaType: mediaType,
|
||||
Source: &attachment.FileSource{
|
||||
Path: absolutePath,
|
||||
Mime: mediaType,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,16 +196,20 @@ func renderText(
|
||||
case opencode.UserMessage:
|
||||
ts = time.UnixMilli(int64(casted.Time.Created))
|
||||
base := styles.NewStyle().Foreground(t.Text()).Background(backgroundColor)
|
||||
words := strings.Fields(text)
|
||||
for i, word := range words {
|
||||
if strings.HasPrefix(word, "@") {
|
||||
words[i] = base.Foreground(t.Secondary()).Render(word + " ")
|
||||
} else {
|
||||
words[i] = base.Render(word + " ")
|
||||
}
|
||||
}
|
||||
text = strings.Join(words, "")
|
||||
text = ansi.WordwrapWc(text, width-6, " -")
|
||||
lines := strings.Split(text, "\n")
|
||||
for i, line := range lines {
|
||||
words := strings.Fields(line)
|
||||
for i, word := range words {
|
||||
if strings.HasPrefix(word, "@") {
|
||||
words[i] = base.Foreground(t.Secondary()).Render(word + " ")
|
||||
} else {
|
||||
words[i] = base.Render(word + " ")
|
||||
}
|
||||
}
|
||||
lines[i] = strings.Join(words, "")
|
||||
}
|
||||
text = strings.Join(lines, "\n")
|
||||
content = base.Width(width - 6).Render(text)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@ package chat
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
@@ -36,6 +38,7 @@ type messagesComponent struct {
|
||||
app *app.App
|
||||
header string
|
||||
viewport viewport.Model
|
||||
clipboard []string
|
||||
cache *PartCache
|
||||
loading bool
|
||||
showToolDetails bool
|
||||
@@ -44,6 +47,43 @@ type messagesComponent struct {
|
||||
tail bool
|
||||
partCount int
|
||||
lineCount int
|
||||
selection *selection
|
||||
}
|
||||
|
||||
type selection struct {
|
||||
startX int
|
||||
endX int
|
||||
startY int
|
||||
endY int
|
||||
}
|
||||
|
||||
func (s selection) coords(offset int) *selection {
|
||||
// selecting backwards
|
||||
if s.startY > s.endY && s.endY >= 0 {
|
||||
return &selection{
|
||||
startX: max(0, s.endX-1),
|
||||
startY: s.endY - offset,
|
||||
endX: s.startX + 1,
|
||||
endY: s.startY - offset,
|
||||
}
|
||||
}
|
||||
|
||||
// selecting backwards same line
|
||||
if s.startY == s.endY && s.startX >= s.endX {
|
||||
return &selection{
|
||||
startY: s.startY - offset,
|
||||
startX: max(0, s.endX-1),
|
||||
endY: s.endY - offset,
|
||||
endX: s.startX + 1,
|
||||
}
|
||||
}
|
||||
|
||||
return &selection{
|
||||
startX: s.startX,
|
||||
startY: s.startY - offset,
|
||||
endX: s.endX,
|
||||
endY: s.endY - offset,
|
||||
}
|
||||
}
|
||||
|
||||
type ToggleToolDetailsMsg struct{}
|
||||
@@ -57,6 +97,43 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
defer measure("from", fmt.Sprintf("%T", msg))
|
||||
var cmds []tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case tea.MouseClickMsg:
|
||||
slog.Info("mouse", "x", msg.X, "y", msg.Y, "offset", m.viewport.YOffset)
|
||||
y := msg.Y + m.viewport.YOffset
|
||||
if y > 0 {
|
||||
m.selection = &selection{
|
||||
startY: y,
|
||||
startX: msg.X,
|
||||
endY: -1,
|
||||
endX: -1,
|
||||
}
|
||||
|
||||
slog.Info("mouse selection", "start", fmt.Sprintf("%d,%d", m.selection.startX, m.selection.startY), "end", fmt.Sprintf("%d,%d", m.selection.endX, m.selection.endY))
|
||||
return m, m.renderView()
|
||||
}
|
||||
|
||||
case tea.MouseMotionMsg:
|
||||
if m.selection != nil {
|
||||
m.selection = &selection{
|
||||
startX: m.selection.startX,
|
||||
startY: m.selection.startY,
|
||||
endX: msg.X + 1,
|
||||
endY: msg.Y + m.viewport.YOffset,
|
||||
}
|
||||
return m, m.renderView()
|
||||
}
|
||||
|
||||
case tea.MouseReleaseMsg:
|
||||
if m.selection != nil && len(m.clipboard) > 0 {
|
||||
content := strings.Join(m.clipboard, "\n")
|
||||
m.selection = nil
|
||||
m.clipboard = []string{}
|
||||
return m, tea.Sequence(
|
||||
m.renderView(),
|
||||
app.SetClipboard(content),
|
||||
toast.NewSuccessToast("Copied to clipboard"),
|
||||
)
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
effectiveWidth := msg.Width - 4
|
||||
// Clear cache on resize since width affects rendering
|
||||
@@ -68,7 +145,7 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.viewport.SetWidth(m.width)
|
||||
m.loading = true
|
||||
return m, m.renderView()
|
||||
case app.SendMsg:
|
||||
case app.SendPrompt:
|
||||
m.viewport.GotoBottom()
|
||||
m.tail = true
|
||||
return m, nil
|
||||
@@ -101,6 +178,7 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.partCount = msg.partCount
|
||||
m.lineCount = msg.lineCount
|
||||
m.rendering = false
|
||||
m.clipboard = msg.clipboard
|
||||
m.loading = false
|
||||
m.tail = m.viewport.AtBottom()
|
||||
m.viewport = msg.viewport
|
||||
@@ -120,6 +198,7 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
type renderCompleteMsg struct {
|
||||
viewport viewport.Model
|
||||
clipboard []string
|
||||
header string
|
||||
partCount int
|
||||
lineCount int
|
||||
@@ -154,6 +233,13 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
|
||||
width := m.width // always use full width
|
||||
|
||||
lastAssistantMessage := "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
|
||||
for _, msg := range slices.Backward(m.app.Messages) {
|
||||
if assistant, ok := msg.Info.(opencode.AssistantMessage); ok {
|
||||
lastAssistantMessage = assistant.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, message := range m.app.Messages {
|
||||
var content string
|
||||
var cached bool
|
||||
@@ -205,14 +291,18 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
flexItems...,
|
||||
)
|
||||
|
||||
key := m.cache.GenerateKey(casted.ID, part.Text, width, files)
|
||||
author := m.app.Config.Username
|
||||
if casted.ID > lastAssistantMessage {
|
||||
author += " [queued]"
|
||||
}
|
||||
key := m.cache.GenerateKey(casted.ID, part.Text, width, files, author)
|
||||
content, cached = m.cache.Get(key)
|
||||
if !cached {
|
||||
content = renderText(
|
||||
m.app,
|
||||
message.Info,
|
||||
part.Text,
|
||||
m.app.Config.Username,
|
||||
author,
|
||||
m.showToolDetails,
|
||||
width,
|
||||
files,
|
||||
@@ -234,7 +324,6 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
}
|
||||
|
||||
case opencode.AssistantMessage:
|
||||
messageMeasure := util.Measure("messages.Render")
|
||||
hasTextPart := false
|
||||
for partIndex, p := range message.Parts {
|
||||
switch part := p.(type) {
|
||||
@@ -366,7 +455,6 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
}
|
||||
}
|
||||
}
|
||||
messageMeasure()
|
||||
}
|
||||
|
||||
error := ""
|
||||
@@ -403,7 +491,48 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
content := "\n" + strings.Join(blocks, "\n\n")
|
||||
final := []string{}
|
||||
clipboard := []string{}
|
||||
var selection *selection
|
||||
if m.selection != nil {
|
||||
selection = m.selection.coords(lipgloss.Height(header) + 1)
|
||||
}
|
||||
for _, block := range blocks {
|
||||
lines := strings.Split(block, "\n")
|
||||
for index, line := range lines {
|
||||
if selection == nil || index == 0 || index == len(lines)-1 {
|
||||
final = append(final, line)
|
||||
continue
|
||||
}
|
||||
y := len(final)
|
||||
if y >= selection.startY && y <= selection.endY {
|
||||
left := 3
|
||||
if y == selection.startY {
|
||||
left = selection.startX - 2
|
||||
}
|
||||
left = max(3, left)
|
||||
|
||||
width := ansi.StringWidth(line)
|
||||
right := width - 1
|
||||
if y == selection.endY {
|
||||
right = min(selection.endX-2, right)
|
||||
}
|
||||
|
||||
prefix := ansi.Cut(line, 0, left)
|
||||
middle := strings.TrimRight(ansi.Strip(ansi.Cut(line, left, right)), " ")
|
||||
suffix := ansi.Cut(line, left+ansi.StringWidth(middle), width)
|
||||
clipboard = append(clipboard, middle)
|
||||
line = prefix + styles.NewStyle().Background(t.Accent()).Foreground(t.BackgroundPanel()).Render(middle) + suffix
|
||||
}
|
||||
final = append(final, line)
|
||||
}
|
||||
y := len(final)
|
||||
if selection != nil && y >= selection.startY && y < selection.endY {
|
||||
clipboard = append(clipboard, "")
|
||||
}
|
||||
final = append(final, "")
|
||||
}
|
||||
content := "\n" + strings.Join(final, "\n")
|
||||
viewport.SetHeight(m.height - lipgloss.Height(header))
|
||||
viewport.SetContent(content)
|
||||
if tail {
|
||||
@@ -412,6 +541,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
|
||||
return renderCompleteMsg{
|
||||
header: header,
|
||||
clipboard: clipboard,
|
||||
viewport: viewport,
|
||||
partCount: partCount,
|
||||
lineCount: lineCount,
|
||||
@@ -463,7 +593,11 @@ func (m *messagesComponent) renderHeader() string {
|
||||
Render(formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel))
|
||||
|
||||
shareEnabled := m.app.Config.Share != opencode.ConfigShareDisabled
|
||||
headerText := util.ToMarkdown("# "+m.app.Session.Title, headerWidth-len(sessionInfo), t.Background())
|
||||
headerText := util.ToMarkdown(
|
||||
"# "+m.app.Session.Title,
|
||||
headerWidth-len(sessionInfo),
|
||||
t.Background(),
|
||||
)
|
||||
|
||||
var items []layout.FlexItem
|
||||
if shareEnabled {
|
||||
@@ -634,7 +768,7 @@ func (m *messagesComponent) CopyLastMessage() (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
var cmds []tea.Cmd
|
||||
cmds = append(cmds, m.app.SetClipboard(lastTextPart.Text))
|
||||
cmds = append(cmds, app.SetClipboard(lastTextPart.Text))
|
||||
cmds = append(cmds, toast.NewSuccessToast("Message copied to clipboard"))
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
@@ -127,9 +127,9 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if item, ok := msg.Item.(modelItem); ok {
|
||||
if m.isModelInRecentSection(item.model, msg.Index) {
|
||||
m.app.State.RemoveModelFromRecentlyUsed(item.model.Provider.ID, item.model.Model.ID)
|
||||
m.app.SaveState()
|
||||
items := m.buildDisplayList(m.searchDialog.GetQuery())
|
||||
m.searchDialog.SetItems(items)
|
||||
return m, m.app.SaveState()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
@@ -425,7 +425,8 @@ func (m *modelDialog) isModelInRecentSection(model ModelWithProvider, index int)
|
||||
if index >= 1 && index <= len(recentModels) {
|
||||
if index-1 < len(recentModels) {
|
||||
recentModel := recentModels[index-1]
|
||||
return recentModel.Provider.ID == model.Provider.ID && recentModel.Model.ID == model.Model.ID
|
||||
return recentModel.Provider.ID == model.Provider.ID &&
|
||||
recentModel.Model.ID == model.Model.ID
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
112
packages/tui/internal/components/ide/ide.go
Normal file
112
packages/tui/internal/components/ide/ide.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package ide
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
type IdeComponent interface {
|
||||
tea.ViewModel
|
||||
SetSize(width, height int) tea.Cmd
|
||||
SetBackgroundColor(color compat.AdaptiveColor)
|
||||
}
|
||||
|
||||
type ideComponent struct {
|
||||
width, height int
|
||||
background *compat.AdaptiveColor
|
||||
}
|
||||
|
||||
func (c *ideComponent) SetSize(width, height int) tea.Cmd {
|
||||
c.width = width
|
||||
c.height = height
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ideComponent) SetBackgroundColor(color compat.AdaptiveColor) {
|
||||
c.background = &color
|
||||
}
|
||||
|
||||
func (c *ideComponent) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
triggerStyle := styles.NewStyle().Foreground(t.Primary()).Bold(true)
|
||||
descriptionStyle := styles.NewStyle().Foreground(t.Text())
|
||||
|
||||
if c.background != nil {
|
||||
triggerStyle = triggerStyle.Background(*c.background)
|
||||
descriptionStyle = descriptionStyle.Background(*c.background)
|
||||
}
|
||||
|
||||
// VSCode shortcuts data
|
||||
shortcuts := []struct {
|
||||
shortcut string
|
||||
description string
|
||||
}{
|
||||
{"Cmd+Esc", "open opencode in VS Code"},
|
||||
{"Cmd+Opt+K", "insert file from VS Code"},
|
||||
}
|
||||
|
||||
// Calculate column widths
|
||||
maxShortcutWidth := 0
|
||||
maxDescriptionWidth := 0
|
||||
|
||||
for _, shortcut := range shortcuts {
|
||||
if len(shortcut.shortcut) > maxShortcutWidth {
|
||||
maxShortcutWidth = len(shortcut.shortcut)
|
||||
}
|
||||
if len(shortcut.description) > maxDescriptionWidth {
|
||||
maxDescriptionWidth = len(shortcut.description)
|
||||
}
|
||||
}
|
||||
|
||||
// Add padding between columns
|
||||
columnPadding := 3
|
||||
|
||||
// Build the output
|
||||
var output strings.Builder
|
||||
|
||||
maxWidth := 0
|
||||
for _, shortcut := range shortcuts {
|
||||
// Pad each column to align properly
|
||||
shortcutText := fmt.Sprintf("%-*s", maxShortcutWidth, shortcut.shortcut)
|
||||
description := fmt.Sprintf("%-*s", maxDescriptionWidth, shortcut.description)
|
||||
|
||||
// Apply styles and combine
|
||||
line := triggerStyle.Render(shortcutText) +
|
||||
triggerStyle.Render(strings.Repeat(" ", columnPadding)) +
|
||||
descriptionStyle.Render(description)
|
||||
|
||||
output.WriteString(line + "\n")
|
||||
maxWidth = max(maxWidth, lipgloss.Width(line))
|
||||
}
|
||||
|
||||
// Remove trailing newline
|
||||
result := strings.TrimSuffix(output.String(), "\n")
|
||||
if c.background != nil {
|
||||
result = styles.NewStyle().Background(*c.background).Width(maxWidth).Render(result)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
type Option func(*ideComponent)
|
||||
|
||||
func WithBackground(background compat.AdaptiveColor) Option {
|
||||
return func(c *ideComponent) {
|
||||
c.background = &background
|
||||
}
|
||||
}
|
||||
|
||||
func New(opts ...Option) IdeComponent {
|
||||
c := &ideComponent{}
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
rw "github.com/mattn/go-runewidth"
|
||||
"github.com/rivo/uniseg"
|
||||
"github.com/sst/opencode/internal/attachment"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -32,15 +33,6 @@ const (
|
||||
maxLines = 10000
|
||||
)
|
||||
|
||||
// Attachment represents a special object within the text, distinct from regular characters.
|
||||
type Attachment struct {
|
||||
ID string // A unique identifier for this attachment instance
|
||||
Display string // e.g., "@filename.txt"
|
||||
URL string
|
||||
Filename string
|
||||
MediaType string
|
||||
}
|
||||
|
||||
// Helper functions for converting between runes and any slices
|
||||
|
||||
// runesToInterfaces converts a slice of runes to a slice of interfaces
|
||||
@@ -59,7 +51,7 @@ func interfacesToRunes(items []any) []rune {
|
||||
switch val := item.(type) {
|
||||
case rune:
|
||||
result = append(result, val)
|
||||
case *Attachment:
|
||||
case *attachment.Attachment:
|
||||
result = append(result, []rune(val.Display)...)
|
||||
}
|
||||
}
|
||||
@@ -80,7 +72,7 @@ func interfacesToString(items []any) string {
|
||||
switch val := item.(type) {
|
||||
case rune:
|
||||
s.WriteRune(val)
|
||||
case *Attachment:
|
||||
case *attachment.Attachment:
|
||||
s.WriteString(val.Display)
|
||||
}
|
||||
}
|
||||
@@ -90,7 +82,7 @@ func interfacesToString(items []any) string {
|
||||
// isAttachmentAtCursor checks if the cursor is positioned on or immediately after an attachment.
|
||||
// This allows for proper highlighting even when the cursor is technically at the position
|
||||
// after the attachment object in the underlying slice.
|
||||
func (m Model) isAttachmentAtCursor() (*Attachment, int, int) {
|
||||
func (m Model) isAttachmentAtCursor() (*attachment.Attachment, int, int) {
|
||||
if m.row >= len(m.value) {
|
||||
return nil, -1, -1
|
||||
}
|
||||
@@ -104,7 +96,7 @@ func (m Model) isAttachmentAtCursor() (*Attachment, int, int) {
|
||||
|
||||
// Check if the cursor is at the same index as an attachment.
|
||||
if col < len(row) {
|
||||
if att, ok := row[col].(*Attachment); ok {
|
||||
if att, ok := row[col].(*attachment.Attachment); ok {
|
||||
return att, col, col
|
||||
}
|
||||
}
|
||||
@@ -112,7 +104,7 @@ func (m Model) isAttachmentAtCursor() (*Attachment, int, int) {
|
||||
// Check if the cursor is immediately after an attachment. This is a common
|
||||
// state, for example, after just inserting one.
|
||||
if col > 0 && col <= len(row) {
|
||||
if att, ok := row[col-1].(*Attachment); ok {
|
||||
if att, ok := row[col-1].(*attachment.Attachment); ok {
|
||||
return att, col - 1, col - 1
|
||||
}
|
||||
}
|
||||
@@ -132,7 +124,7 @@ func (m Model) renderLineWithAttachments(
|
||||
switch val := item.(type) {
|
||||
case rune:
|
||||
s.WriteString(style.Render(string(val)))
|
||||
case *Attachment:
|
||||
case *attachment.Attachment:
|
||||
// Check if this is the attachment the cursor is currently on
|
||||
if currentAttachment != nil && currentAttachment.ID == val.ID {
|
||||
// Cursor is on this attachment, highlight it
|
||||
@@ -435,7 +427,7 @@ func (w line) Hash() string {
|
||||
switch v := item.(type) {
|
||||
case rune:
|
||||
s.WriteRune(v)
|
||||
case *Attachment:
|
||||
case *attachment.Attachment:
|
||||
s.WriteString(v.ID)
|
||||
}
|
||||
}
|
||||
@@ -661,7 +653,7 @@ func (m *Model) InsertRune(r rune) {
|
||||
}
|
||||
|
||||
// InsertAttachment inserts an attachment at the cursor position.
|
||||
func (m *Model) InsertAttachment(att *Attachment) {
|
||||
func (m *Model) InsertAttachment(att *attachment.Attachment) {
|
||||
if m.CharLimit > 0 {
|
||||
availSpace := m.CharLimit - m.Length()
|
||||
// If the char limit's been reached, cancel.
|
||||
@@ -716,16 +708,36 @@ func (m *Model) CurrentRowLength() int {
|
||||
return len(m.value[m.row])
|
||||
}
|
||||
|
||||
// GetAttachments returns all attachments in the textarea.
|
||||
func (m Model) GetAttachments() []*Attachment {
|
||||
var attachments []*Attachment
|
||||
for _, row := range m.value {
|
||||
// GetAttachments returns all attachments in the textarea with accurate position indices.
|
||||
func (m Model) GetAttachments() []*attachment.Attachment {
|
||||
var attachments []*attachment.Attachment
|
||||
position := 0 // Track absolute position in the text
|
||||
|
||||
for rowIdx, row := range m.value {
|
||||
colPosition := 0 // Track position within the current row
|
||||
|
||||
for _, item := range row {
|
||||
if att, ok := item.(*Attachment); ok {
|
||||
attachments = append(attachments, att)
|
||||
switch v := item.(type) {
|
||||
case *attachment.Attachment:
|
||||
// Clone the attachment to avoid modifying the original
|
||||
att := *v
|
||||
att.StartIndex = position + colPosition
|
||||
att.EndIndex = position + colPosition + len(v.Display)
|
||||
attachments = append(attachments, &att)
|
||||
colPosition += len(v.Display)
|
||||
case rune:
|
||||
colPosition++
|
||||
}
|
||||
}
|
||||
|
||||
// Add newline character position (except for last row)
|
||||
if rowIdx < len(m.value)-1 {
|
||||
position += colPosition + 1 // +1 for newline
|
||||
} else {
|
||||
position += colPosition
|
||||
}
|
||||
}
|
||||
|
||||
return attachments
|
||||
}
|
||||
|
||||
@@ -829,7 +841,7 @@ func (m Model) Value() string {
|
||||
switch val := item.(type) {
|
||||
case rune:
|
||||
v.WriteRune(val)
|
||||
case *Attachment:
|
||||
case *attachment.Attachment:
|
||||
v.WriteString(val.Display)
|
||||
}
|
||||
}
|
||||
@@ -847,7 +859,7 @@ func (m *Model) Length() int {
|
||||
switch val := item.(type) {
|
||||
case rune:
|
||||
l += rw.RuneWidth(val)
|
||||
case *Attachment:
|
||||
case *attachment.Attachment:
|
||||
l += uniseg.StringWidth(val.Display)
|
||||
}
|
||||
}
|
||||
@@ -911,7 +923,7 @@ func (m *Model) mapVisualOffsetToSliceIndex(row int, charOffset int) int {
|
||||
switch v := item.(type) {
|
||||
case rune:
|
||||
itemWidth = rw.RuneWidth(v)
|
||||
case *Attachment:
|
||||
case *attachment.Attachment:
|
||||
itemWidth = uniseg.StringWidth(v.Display)
|
||||
}
|
||||
|
||||
@@ -952,7 +964,7 @@ func (m *Model) CursorDown() {
|
||||
switch v := item.(type) {
|
||||
case rune:
|
||||
itemWidth = rw.RuneWidth(v)
|
||||
case *Attachment:
|
||||
case *attachment.Attachment:
|
||||
itemWidth = uniseg.StringWidth(v.Display)
|
||||
}
|
||||
if offset+itemWidth > charOffset {
|
||||
@@ -988,7 +1000,7 @@ func (m *Model) CursorDown() {
|
||||
switch v := item.(type) {
|
||||
case rune:
|
||||
itemWidth = rw.RuneWidth(v)
|
||||
case *Attachment:
|
||||
case *attachment.Attachment:
|
||||
itemWidth = uniseg.StringWidth(v.Display)
|
||||
}
|
||||
if offset+itemWidth > charOffset {
|
||||
@@ -1034,7 +1046,7 @@ func (m *Model) CursorUp() {
|
||||
switch v := item.(type) {
|
||||
case rune:
|
||||
itemWidth = rw.RuneWidth(v)
|
||||
case *Attachment:
|
||||
case *attachment.Attachment:
|
||||
itemWidth = uniseg.StringWidth(v.Display)
|
||||
}
|
||||
if offset+itemWidth > charOffset {
|
||||
@@ -1070,7 +1082,7 @@ func (m *Model) CursorUp() {
|
||||
switch v := item.(type) {
|
||||
case rune:
|
||||
itemWidth = rw.RuneWidth(v)
|
||||
case *Attachment:
|
||||
case *attachment.Attachment:
|
||||
itemWidth = uniseg.StringWidth(v.Display)
|
||||
}
|
||||
if offset+itemWidth > charOffset {
|
||||
@@ -1111,6 +1123,10 @@ func (m *Model) CursorEnd() {
|
||||
m.SetCursorColumn(len(m.value[m.row]))
|
||||
}
|
||||
|
||||
func (m *Model) IsCursorAtEnd() bool {
|
||||
return m.CursorColumn() == len(m.value[m.row])
|
||||
}
|
||||
|
||||
// Focused returns the focus state on the model.
|
||||
func (m Model) Focused() bool {
|
||||
return m.focus
|
||||
@@ -1414,14 +1430,14 @@ func (m Model) Width() int {
|
||||
return m.width
|
||||
}
|
||||
|
||||
// moveToBegin moves the cursor to the beginning of the input.
|
||||
func (m *Model) moveToBegin() {
|
||||
// MoveToBegin moves the cursor to the beginning of the input.
|
||||
func (m *Model) MoveToBegin() {
|
||||
m.row = 0
|
||||
m.SetCursorColumn(0)
|
||||
}
|
||||
|
||||
// moveToEnd moves the cursor to the end of the input.
|
||||
func (m *Model) moveToEnd() {
|
||||
// MoveToEnd moves the cursor to the end of the input.
|
||||
func (m *Model) MoveToEnd() {
|
||||
m.row = len(m.value) - 1
|
||||
m.SetCursorColumn(len(m.value[m.row]))
|
||||
}
|
||||
@@ -1610,9 +1626,9 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
case key.Matches(msg, m.KeyMap.WordBackward):
|
||||
m.wordLeft()
|
||||
case key.Matches(msg, m.KeyMap.InputBegin):
|
||||
m.moveToBegin()
|
||||
m.MoveToBegin()
|
||||
case key.Matches(msg, m.KeyMap.InputEnd):
|
||||
m.moveToEnd()
|
||||
m.MoveToEnd()
|
||||
case key.Matches(msg, m.KeyMap.LowercaseWordForward):
|
||||
m.lowercaseRight()
|
||||
case key.Matches(msg, m.KeyMap.UppercaseWordForward):
|
||||
@@ -1725,7 +1741,7 @@ func (m Model) View() string {
|
||||
} else if lineInfo.ColumnOffset < len(wrappedLine) {
|
||||
// Render the item under the cursor
|
||||
item := wrappedLine[lineInfo.ColumnOffset]
|
||||
if att, ok := item.(*Attachment); ok {
|
||||
if att, ok := item.(*attachment.Attachment); ok {
|
||||
// Item at cursor is an attachment. Render it with the selection style.
|
||||
// This becomes the "cursor" visually.
|
||||
s.WriteString(m.Styles.SelectedAttachment.Render(att.Display))
|
||||
@@ -2023,7 +2039,7 @@ func itemWidth(item any) int {
|
||||
switch v := item.(type) {
|
||||
case rune:
|
||||
return rw.RuneWidth(v)
|
||||
case *Attachment:
|
||||
case *attachment.Attachment:
|
||||
return uniseg.StringWidth(v.Display)
|
||||
}
|
||||
return 0
|
||||
@@ -2052,7 +2068,7 @@ func wrapInterfaces(content []any, width int) [][]any {
|
||||
isSpace = true
|
||||
}
|
||||
itemW = rw.RuneWidth(r)
|
||||
} else if att, ok := item.(*Attachment); ok {
|
||||
} else if att, ok := item.(*attachment.Attachment); ok {
|
||||
itemW = uniseg.StringWidth(att.Display)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/api"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/completions"
|
||||
@@ -22,10 +24,10 @@ import (
|
||||
cmdcomp "github.com/sst/opencode/internal/components/commands"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/components/fileviewer"
|
||||
"github.com/sst/opencode/internal/components/ide"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/components/status"
|
||||
"github.com/sst/opencode/internal/components/toast"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
@@ -57,7 +59,7 @@ const (
|
||||
const interruptDebounceTimeout = 1 * time.Second
|
||||
const exitDebounceTimeout = 1 * time.Second
|
||||
|
||||
type appModel struct {
|
||||
type Model struct {
|
||||
width, height int
|
||||
app *app.App
|
||||
modal layout.Modal
|
||||
@@ -78,7 +80,7 @@ type appModel struct {
|
||||
fileViewer fileviewer.Model
|
||||
}
|
||||
|
||||
func (a appModel) Init() tea.Cmd {
|
||||
func (a Model) Init() tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
// https://github.com/charmbracelet/bubbletea/issues/1440
|
||||
// https://github.com/sst/opencode/issues/127
|
||||
@@ -102,7 +104,7 @@ func (a appModel) Init() tea.Cmd {
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
measure := util.Measure("app.Update")
|
||||
defer measure("from", fmt.Sprintf("%T", msg))
|
||||
|
||||
@@ -331,9 +333,9 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
case error:
|
||||
return a, toast.NewErrorToast(msg.Error())
|
||||
case app.SendMsg:
|
||||
case app.SendPrompt:
|
||||
a.showCompletionDialog = false
|
||||
a.app, cmd = a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments)
|
||||
a.app, cmd = a.app.SendPrompt(context.Background(), msg)
|
||||
cmds = append(cmds, cmd)
|
||||
case app.SetEditorContentMsg:
|
||||
// Set the editor content without sending
|
||||
@@ -348,6 +350,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
"opencode updated to "+msg.Properties.Version+", restart to apply.",
|
||||
toast.WithTitle("New version installed"),
|
||||
)
|
||||
case opencode.EventListResponseEventIdeInstalled:
|
||||
return a, toast.NewSuccessToast(
|
||||
"Installed the opencode extension in "+msg.Properties.Ide,
|
||||
toast.WithTitle(msg.Properties.Ide+" extension installed"),
|
||||
)
|
||||
case opencode.EventListResponseEventSessionDeleted:
|
||||
if a.app.Session != nil && msg.Properties.Info.ID == a.app.Session.ID {
|
||||
a.app.Session = &opencode.Session{}
|
||||
@@ -467,15 +474,15 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case app.ModelSelectedMsg:
|
||||
a.app.Provider = &msg.Provider
|
||||
a.app.Model = &msg.Model
|
||||
a.app.State.ModeModel[a.app.Mode.Name] = config.ModeModel{
|
||||
a.app.State.ModeModel[a.app.Mode.Name] = app.ModeModel{
|
||||
ProviderID: msg.Provider.ID,
|
||||
ModelID: msg.Model.ID,
|
||||
}
|
||||
a.app.State.UpdateModelUsage(msg.Provider.ID, msg.Model.ID)
|
||||
a.app.SaveState()
|
||||
cmds = append(cmds, a.app.SaveState())
|
||||
case dialog.ThemeSelectedMsg:
|
||||
a.app.State.Theme = msg.ThemeName
|
||||
a.app.SaveState()
|
||||
cmds = append(cmds, a.app.SaveState())
|
||||
case toast.ShowToastMsg:
|
||||
tm, cmd := a.toastManager.Update(msg)
|
||||
a.toastManager = tm
|
||||
@@ -494,6 +501,26 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
a.editor.SetExitKeyInDebounce(false)
|
||||
case dialog.FindSelectedMsg:
|
||||
return a.openFile(msg.FilePath)
|
||||
|
||||
// API
|
||||
case api.Request:
|
||||
slog.Info("api", "path", msg.Path)
|
||||
var response any = true
|
||||
switch msg.Path {
|
||||
case "/tui/open-help":
|
||||
helpDialog := dialog.NewHelpDialog(a.app)
|
||||
a.modal = helpDialog
|
||||
case "/tui/prompt":
|
||||
var body struct {
|
||||
Text string `json:"text"`
|
||||
Parts []opencode.Part `json:"parts"`
|
||||
}
|
||||
json.Unmarshal((msg.Body), &body)
|
||||
a.editor.SetValue(body.Text)
|
||||
default:
|
||||
break
|
||||
}
|
||||
cmds = append(cmds, api.Reply(context.Background(), a.app.Client, response))
|
||||
}
|
||||
|
||||
s, cmd := a.status.Update(msg)
|
||||
@@ -527,7 +554,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return a, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (a appModel) View() string {
|
||||
func (a Model) View() string {
|
||||
measure := util.Measure("app.View")
|
||||
defer measure()
|
||||
t := theme.CurrentTheme()
|
||||
@@ -564,7 +591,7 @@ func (a appModel) View() string {
|
||||
return mainLayout + "\n" + a.status.View()
|
||||
}
|
||||
|
||||
func (a appModel) openFile(filepath string) (tea.Model, tea.Cmd) {
|
||||
func (a Model) openFile(filepath string) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
response, err := a.app.Client.File.Read(
|
||||
context.Background(),
|
||||
@@ -584,7 +611,7 @@ func (a appModel) openFile(filepath string) (tea.Model, tea.Cmd) {
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
func (a appModel) home() string {
|
||||
func (a Model) home() string {
|
||||
measure := util.Measure("home.View")
|
||||
defer measure()
|
||||
t := theme.CurrentTheme()
|
||||
@@ -624,10 +651,17 @@ func (a appModel) home() string {
|
||||
logoAndVersion,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
|
||||
// Use limit of 4 for vscode, 6 for others
|
||||
limit := 6
|
||||
if os.Getenv("OPENCODE_CALLER") == "vscode" {
|
||||
limit = 4
|
||||
}
|
||||
|
||||
commandsView := cmdcomp.New(
|
||||
a.app,
|
||||
cmdcomp.WithBackground(t.Background()),
|
||||
cmdcomp.WithLimit(6),
|
||||
cmdcomp.WithLimit(limit),
|
||||
)
|
||||
cmds := lipgloss.PlaceHorizontal(
|
||||
effectiveWidth,
|
||||
@@ -636,6 +670,19 @@ func (a appModel) home() string {
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
|
||||
// Add VSCode shortcuts if in VSCode environment
|
||||
var ideShortcuts string
|
||||
if os.Getenv("OPENCODE_CALLER") == "vscode" {
|
||||
ideView := ide.New()
|
||||
ideView.SetBackgroundColor(t.Background())
|
||||
ideShortcuts = lipgloss.PlaceHorizontal(
|
||||
effectiveWidth,
|
||||
lipgloss.Center,
|
||||
ideView.View(),
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
}
|
||||
|
||||
lines := []string{}
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "")
|
||||
@@ -643,6 +690,10 @@ func (a appModel) home() string {
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, cmds)
|
||||
if os.Getenv("OPENCODE_CALLER") == "vscode" {
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, ideShortcuts)
|
||||
}
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "")
|
||||
|
||||
@@ -697,7 +748,7 @@ func (a appModel) home() string {
|
||||
return mainLayout
|
||||
}
|
||||
|
||||
func (a appModel) chat() string {
|
||||
func (a Model) chat() string {
|
||||
measure := util.Measure("chat.View")
|
||||
defer measure()
|
||||
effectiveWidth := a.width - 4
|
||||
@@ -745,7 +796,7 @@ func (a appModel) chat() string {
|
||||
return mainLayout
|
||||
}
|
||||
|
||||
func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
|
||||
func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
cmds := []tea.Cmd{
|
||||
util.CmdHandler(commands.CommandExecutedMsg(command)),
|
||||
@@ -829,7 +880,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
|
||||
return a, toast.NewErrorToast("Failed to share session")
|
||||
}
|
||||
shareUrl := response.Share.URL
|
||||
cmds = append(cmds, a.app.SetClipboard(shareUrl))
|
||||
cmds = append(cmds, app.SetClipboard(shareUrl))
|
||||
cmds = append(cmds, toast.NewSuccessToast("Share URL copied to clipboard!"))
|
||||
case commands.SessionUnshareCommand:
|
||||
if a.app.Session.ID == "" {
|
||||
@@ -891,7 +942,8 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
|
||||
tmpfile.Close()
|
||||
|
||||
// Open in editor
|
||||
c := exec.Command(editor, tmpfile.Name())
|
||||
parts := strings.Fields(editor)
|
||||
c := exec.Command(parts[0], append(parts[1:], tmpfile.Name())...) //nolint:gosec
|
||||
c.Stdin = os.Stdin
|
||||
c.Stdout = os.Stdout
|
||||
c.Stderr = os.Stderr
|
||||
@@ -927,9 +979,9 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
|
||||
cmds = append(cmds, cmd)
|
||||
case commands.FileDiffToggleCommand:
|
||||
a.fileViewer, cmd = a.fileViewer.ToggleDiff()
|
||||
a.app.State.SplitDiff = a.fileViewer.DiffStyle() == fileviewer.DiffStyleSplit
|
||||
a.app.SaveState()
|
||||
cmds = append(cmds, cmd)
|
||||
a.app.State.SplitDiff = a.fileViewer.DiffStyle() == fileviewer.DiffStyleSplit
|
||||
cmds = append(cmds, a.app.SaveState())
|
||||
case commands.FileSearchCommand:
|
||||
return a, nil
|
||||
case commands.ProjectInitCommand:
|
||||
@@ -1000,7 +1052,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
|
||||
case commands.MessagesLayoutToggleCommand:
|
||||
a.messagesRight = !a.messagesRight
|
||||
a.app.State.MessagesRight = a.messagesRight
|
||||
a.app.SaveState()
|
||||
cmds = append(cmds, a.app.SaveState())
|
||||
case commands.MessagesCopyCommand:
|
||||
updated, cmd := a.messages.CopyLastMessage()
|
||||
a.messages = updated.(chat.MessagesComponent)
|
||||
@@ -1027,7 +1079,7 @@ func NewModel(app *app.App) tea.Model {
|
||||
leaderBinding = &binding
|
||||
}
|
||||
|
||||
model := &appModel{
|
||||
model := &Model{
|
||||
status: status.NewStatusCmp(app),
|
||||
app: app,
|
||||
editor: editor,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
configured_endpoints: 22
|
||||
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-e7f4ac9b5afd5c6db4741a27b5445167808b0a3b7c36dfd525bfb3446a11a253.yml
|
||||
openapi_spec_hash: 3e7b367a173d6de7924f35a41ac6b5a5
|
||||
config_hash: 6d56a7ca0d6ed899ecdb5c053a8278ae
|
||||
configured_endpoints: 24
|
||||
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-d10809ab68e48a338167e5504d69db2a0a80739adf6ecd3f065644a4139bc374.yml
|
||||
openapi_spec_hash: 4875565ef8df3446dbab11f450e04c51
|
||||
config_hash: 0032a76356d31c6b4c218b39fff635bb
|
||||
|
||||
@@ -19,7 +19,6 @@ Methods:
|
||||
Response Types:
|
||||
|
||||
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#App">App</a>
|
||||
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#LogLevel">LogLevel</a>
|
||||
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Mode">Mode</a>
|
||||
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Model">Model</a>
|
||||
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Provider">Provider</a>
|
||||
@@ -76,12 +75,23 @@ Methods:
|
||||
|
||||
Params Types:
|
||||
|
||||
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FilePartParam">FilePartParam</a>
|
||||
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FilePartInputParam">FilePartInputParam</a>
|
||||
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FilePartSourceUnionParam">FilePartSourceUnionParam</a>
|
||||
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FilePartSourceTextParam">FilePartSourceTextParam</a>
|
||||
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FileSourceParam">FileSourceParam</a>
|
||||
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#PartUnionParam">PartUnionParam</a>
|
||||
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SnapshotPartParam">SnapshotPartParam</a>
|
||||
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#StepFinishPartParam">StepFinishPartParam</a>
|
||||
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#StepStartPartParam">StepStartPartParam</a>
|
||||
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SymbolSourceParam">SymbolSourceParam</a>
|
||||
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#TextPartParam">TextPartParam</a>
|
||||
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#TextPartInputParam">TextPartInputParam</a>
|
||||
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ToolPartParam">ToolPartParam</a>
|
||||
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ToolStateCompletedParam">ToolStateCompletedParam</a>
|
||||
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ToolStateErrorParam">ToolStateErrorParam</a>
|
||||
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ToolStatePendingParam">ToolStatePendingParam</a>
|
||||
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ToolStateRunningParam">ToolStateRunningParam</a>
|
||||
|
||||
Response Types:
|
||||
|
||||
@@ -118,3 +128,10 @@ Methods:
|
||||
- <code title="post /session/{id}/share">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Share">Share</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="post /session/{id}/summarize">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Summarize">Summarize</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionSummarizeParams">SessionSummarizeParams</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="delete /session/{id}/share">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Unshare">Unshare</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
|
||||
# Tui
|
||||
|
||||
Methods:
|
||||
|
||||
- <code title="post /tui/open-help">client.Tui.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#TuiService.OpenHelp">OpenHelp</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="post /tui/prompt">client.Tui.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#TuiService.Prompt">Prompt</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#TuiPromptParams">TuiPromptParams</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
|
||||
@@ -145,24 +145,6 @@ func (r appTimeJSON) RawJSON() string {
|
||||
return r.raw
|
||||
}
|
||||
|
||||
// Log level
|
||||
type LogLevel string
|
||||
|
||||
const (
|
||||
LogLevelDebug LogLevel = "DEBUG"
|
||||
LogLevelInfo LogLevel = "INFO"
|
||||
LogLevelWarn LogLevel = "WARN"
|
||||
LogLevelError LogLevel = "ERROR"
|
||||
)
|
||||
|
||||
func (r LogLevel) IsKnown() bool {
|
||||
switch r {
|
||||
case LogLevelDebug, LogLevelInfo, LogLevelWarn, LogLevelError:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Mode struct {
|
||||
Name string `json:"name,required"`
|
||||
Tools map[string]bool `json:"tools,required"`
|
||||
|
||||
@@ -22,6 +22,7 @@ type Client struct {
|
||||
File *FileService
|
||||
Config *ConfigService
|
||||
Session *SessionService
|
||||
Tui *TuiService
|
||||
}
|
||||
|
||||
// DefaultClientOptions read from the environment (OPENCODE_BASE_URL). This should
|
||||
@@ -49,6 +50,7 @@ func NewClient(opts ...option.RequestOption) (r *Client) {
|
||||
r.File = NewFileService(opts...)
|
||||
r.Config = NewConfigService(opts...)
|
||||
r.Session = NewSessionService(opts...)
|
||||
r.Tui = NewTuiService(opts...)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -57,8 +57,6 @@ type Config struct {
|
||||
Keybinds KeybindsConfig `json:"keybinds"`
|
||||
// @deprecated Always uses stretch layout.
|
||||
Layout ConfigLayout `json:"layout"`
|
||||
// Minimum log level to write to log files
|
||||
LogLevel LogLevel `json:"log_level"`
|
||||
// MCP (Model Context Protocol) server configurations
|
||||
Mcp map[string]ConfigMcp `json:"mcp"`
|
||||
// Modes configuration, see https://opencode.ai/docs/modes
|
||||
@@ -90,7 +88,6 @@ type configJSON struct {
|
||||
Instructions apijson.Field
|
||||
Keybinds apijson.Field
|
||||
Layout apijson.Field
|
||||
LogLevel apijson.Field
|
||||
Mcp apijson.Field
|
||||
Mode apijson.Field
|
||||
Model apijson.Field
|
||||
|
||||
@@ -52,6 +52,7 @@ type EventListResponse struct {
|
||||
// [EventListResponseEventPermissionUpdatedProperties],
|
||||
// [EventListResponseEventFileEditedProperties],
|
||||
// [EventListResponseEventInstallationUpdatedProperties],
|
||||
// [EventListResponseEventIdeInstalledProperties],
|
||||
// [EventListResponseEventMessageUpdatedProperties],
|
||||
// [EventListResponseEventMessageRemovedProperties],
|
||||
// [EventListResponseEventMessagePartUpdatedProperties],
|
||||
@@ -96,6 +97,7 @@ func (r *EventListResponse) UnmarshalJSON(data []byte) (err error) {
|
||||
// [EventListResponseEventLspClientDiagnostics],
|
||||
// [EventListResponseEventPermissionUpdated], [EventListResponseEventFileEdited],
|
||||
// [EventListResponseEventInstallationUpdated],
|
||||
// [EventListResponseEventIdeInstalled],
|
||||
// [EventListResponseEventMessageUpdated], [EventListResponseEventMessageRemoved],
|
||||
// [EventListResponseEventMessagePartUpdated],
|
||||
// [EventListResponseEventStorageWrite], [EventListResponseEventSessionUpdated],
|
||||
@@ -109,6 +111,7 @@ func (r EventListResponse) AsUnion() EventListResponseUnion {
|
||||
// Union satisfied by [EventListResponseEventLspClientDiagnostics],
|
||||
// [EventListResponseEventPermissionUpdated], [EventListResponseEventFileEdited],
|
||||
// [EventListResponseEventInstallationUpdated],
|
||||
// [EventListResponseEventIdeInstalled],
|
||||
// [EventListResponseEventMessageUpdated], [EventListResponseEventMessageRemoved],
|
||||
// [EventListResponseEventMessagePartUpdated],
|
||||
// [EventListResponseEventStorageWrite], [EventListResponseEventSessionUpdated],
|
||||
@@ -143,6 +146,11 @@ func init() {
|
||||
Type: reflect.TypeOf(EventListResponseEventInstallationUpdated{}),
|
||||
DiscriminatorValue: "installation.updated",
|
||||
},
|
||||
apijson.UnionVariant{
|
||||
TypeFilter: gjson.JSON,
|
||||
Type: reflect.TypeOf(EventListResponseEventIdeInstalled{}),
|
||||
DiscriminatorValue: "ide.installed",
|
||||
},
|
||||
apijson.UnionVariant{
|
||||
TypeFilter: gjson.JSON,
|
||||
Type: reflect.TypeOf(EventListResponseEventMessageUpdated{}),
|
||||
@@ -462,6 +470,66 @@ func (r EventListResponseEventInstallationUpdatedType) IsKnown() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type EventListResponseEventIdeInstalled struct {
|
||||
Properties EventListResponseEventIdeInstalledProperties `json:"properties,required"`
|
||||
Type EventListResponseEventIdeInstalledType `json:"type,required"`
|
||||
JSON eventListResponseEventIdeInstalledJSON `json:"-"`
|
||||
}
|
||||
|
||||
// eventListResponseEventIdeInstalledJSON contains the JSON metadata for the
|
||||
// struct [EventListResponseEventIdeInstalled]
|
||||
type eventListResponseEventIdeInstalledJSON struct {
|
||||
Properties apijson.Field
|
||||
Type apijson.Field
|
||||
raw string
|
||||
ExtraFields map[string]apijson.Field
|
||||
}
|
||||
|
||||
func (r *EventListResponseEventIdeInstalled) UnmarshalJSON(data []byte) (err error) {
|
||||
return apijson.UnmarshalRoot(data, r)
|
||||
}
|
||||
|
||||
func (r eventListResponseEventIdeInstalledJSON) RawJSON() string {
|
||||
return r.raw
|
||||
}
|
||||
|
||||
func (r EventListResponseEventIdeInstalled) implementsEventListResponse() {}
|
||||
|
||||
type EventListResponseEventIdeInstalledProperties struct {
|
||||
Ide string `json:"ide,required"`
|
||||
JSON eventListResponseEventIdeInstalledPropertiesJSON `json:"-"`
|
||||
}
|
||||
|
||||
// eventListResponseEventIdeInstalledPropertiesJSON contains the JSON
|
||||
// metadata for the struct [EventListResponseEventIdeInstalledProperties]
|
||||
type eventListResponseEventIdeInstalledPropertiesJSON struct {
|
||||
Ide apijson.Field
|
||||
raw string
|
||||
ExtraFields map[string]apijson.Field
|
||||
}
|
||||
|
||||
func (r *EventListResponseEventIdeInstalledProperties) UnmarshalJSON(data []byte) (err error) {
|
||||
return apijson.UnmarshalRoot(data, r)
|
||||
}
|
||||
|
||||
func (r eventListResponseEventIdeInstalledPropertiesJSON) RawJSON() string {
|
||||
return r.raw
|
||||
}
|
||||
|
||||
type EventListResponseEventIdeInstalledType string
|
||||
|
||||
const (
|
||||
EventListResponseEventIdeInstalledTypeIdeInstalled EventListResponseEventIdeInstalledType = "ide.installed"
|
||||
)
|
||||
|
||||
func (r EventListResponseEventIdeInstalledType) IsKnown() bool {
|
||||
switch r {
|
||||
case EventListResponseEventIdeInstalledTypeIdeInstalled:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type EventListResponseEventMessageUpdated struct {
|
||||
Properties EventListResponseEventMessageUpdatedProperties `json:"properties,required"`
|
||||
Type EventListResponseEventMessageUpdatedType `json:"type,required"`
|
||||
@@ -1166,6 +1234,7 @@ const (
|
||||
EventListResponseTypePermissionUpdated EventListResponseType = "permission.updated"
|
||||
EventListResponseTypeFileEdited EventListResponseType = "file.edited"
|
||||
EventListResponseTypeInstallationUpdated EventListResponseType = "installation.updated"
|
||||
EventListResponseTypeIdeInstalled EventListResponseType = "ide.installed"
|
||||
EventListResponseTypeMessageUpdated EventListResponseType = "message.updated"
|
||||
EventListResponseTypeMessageRemoved EventListResponseType = "message.removed"
|
||||
EventListResponseTypeMessagePartUpdated EventListResponseType = "message.part.updated"
|
||||
@@ -1179,7 +1248,7 @@ const (
|
||||
|
||||
func (r EventListResponseType) IsKnown() bool {
|
||||
switch r {
|
||||
case EventListResponseTypeLspClientDiagnostics, EventListResponseTypePermissionUpdated, EventListResponseTypeFileEdited, EventListResponseTypeInstallationUpdated, EventListResponseTypeMessageUpdated, EventListResponseTypeMessageRemoved, EventListResponseTypeMessagePartUpdated, EventListResponseTypeStorageWrite, EventListResponseTypeSessionUpdated, EventListResponseTypeSessionDeleted, EventListResponseTypeSessionIdle, EventListResponseTypeSessionError, EventListResponseTypeFileWatcherUpdated:
|
||||
case EventListResponseTypeLspClientDiagnostics, EventListResponseTypePermissionUpdated, EventListResponseTypeFileEdited, EventListResponseTypeInstallationUpdated, EventListResponseTypeIdeInstalled, EventListResponseTypeMessageUpdated, EventListResponseTypeMessageRemoved, EventListResponseTypeMessagePartUpdated, EventListResponseTypeStorageWrite, EventListResponseTypeSessionUpdated, EventListResponseTypeSessionDeleted, EventListResponseTypeSessionIdle, EventListResponseTypeSessionError, EventListResponseTypeFileWatcherUpdated:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -483,6 +483,23 @@ func (r FilePartType) IsKnown() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type FilePartParam struct {
|
||||
ID param.Field[string] `json:"id,required"`
|
||||
MessageID param.Field[string] `json:"messageID,required"`
|
||||
Mime param.Field[string] `json:"mime,required"`
|
||||
SessionID param.Field[string] `json:"sessionID,required"`
|
||||
Type param.Field[FilePartType] `json:"type,required"`
|
||||
URL param.Field[string] `json:"url,required"`
|
||||
Filename param.Field[string] `json:"filename"`
|
||||
Source param.Field[FilePartSourceUnionParam] `json:"source"`
|
||||
}
|
||||
|
||||
func (r FilePartParam) MarshalJSON() (data []byte, err error) {
|
||||
return apijson.MarshalRoot(r)
|
||||
}
|
||||
|
||||
func (r FilePartParam) implementsPartUnionParam() {}
|
||||
|
||||
type FilePartInputParam struct {
|
||||
Mime param.Field[string] `json:"mime,required"`
|
||||
Type param.Field[FilePartInputType] `json:"type,required"`
|
||||
@@ -932,6 +949,38 @@ func (r PartType) IsKnown() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type PartParam struct {
|
||||
ID param.Field[string] `json:"id,required"`
|
||||
MessageID param.Field[string] `json:"messageID,required"`
|
||||
SessionID param.Field[string] `json:"sessionID,required"`
|
||||
Type param.Field[PartType] `json:"type,required"`
|
||||
CallID param.Field[string] `json:"callID"`
|
||||
Cost param.Field[float64] `json:"cost"`
|
||||
Filename param.Field[string] `json:"filename"`
|
||||
Mime param.Field[string] `json:"mime"`
|
||||
Snapshot param.Field[string] `json:"snapshot"`
|
||||
Source param.Field[FilePartSourceUnionParam] `json:"source"`
|
||||
State param.Field[interface{}] `json:"state"`
|
||||
Synthetic param.Field[bool] `json:"synthetic"`
|
||||
Text param.Field[string] `json:"text"`
|
||||
Time param.Field[interface{}] `json:"time"`
|
||||
Tokens param.Field[interface{}] `json:"tokens"`
|
||||
Tool param.Field[string] `json:"tool"`
|
||||
URL param.Field[string] `json:"url"`
|
||||
}
|
||||
|
||||
func (r PartParam) MarshalJSON() (data []byte, err error) {
|
||||
return apijson.MarshalRoot(r)
|
||||
}
|
||||
|
||||
func (r PartParam) implementsPartUnionParam() {}
|
||||
|
||||
// Satisfied by [TextPartParam], [FilePartParam], [ToolPartParam],
|
||||
// [StepStartPartParam], [StepFinishPartParam], [SnapshotPartParam], [PartParam].
|
||||
type PartUnionParam interface {
|
||||
implementsPartUnionParam()
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
ID string `json:"id,required"`
|
||||
Time SessionTime `json:"time,required"`
|
||||
@@ -1074,6 +1123,20 @@ func (r SnapshotPartType) IsKnown() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type SnapshotPartParam struct {
|
||||
ID param.Field[string] `json:"id,required"`
|
||||
MessageID param.Field[string] `json:"messageID,required"`
|
||||
SessionID param.Field[string] `json:"sessionID,required"`
|
||||
Snapshot param.Field[string] `json:"snapshot,required"`
|
||||
Type param.Field[SnapshotPartType] `json:"type,required"`
|
||||
}
|
||||
|
||||
func (r SnapshotPartParam) MarshalJSON() (data []byte, err error) {
|
||||
return apijson.MarshalRoot(r)
|
||||
}
|
||||
|
||||
func (r SnapshotPartParam) implementsPartUnionParam() {}
|
||||
|
||||
type StepFinishPart struct {
|
||||
ID string `json:"id,required"`
|
||||
Cost float64 `json:"cost,required"`
|
||||
@@ -1170,6 +1233,41 @@ func (r StepFinishPartType) IsKnown() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type StepFinishPartParam struct {
|
||||
ID param.Field[string] `json:"id,required"`
|
||||
Cost param.Field[float64] `json:"cost,required"`
|
||||
MessageID param.Field[string] `json:"messageID,required"`
|
||||
SessionID param.Field[string] `json:"sessionID,required"`
|
||||
Tokens param.Field[StepFinishPartTokensParam] `json:"tokens,required"`
|
||||
Type param.Field[StepFinishPartType] `json:"type,required"`
|
||||
}
|
||||
|
||||
func (r StepFinishPartParam) MarshalJSON() (data []byte, err error) {
|
||||
return apijson.MarshalRoot(r)
|
||||
}
|
||||
|
||||
func (r StepFinishPartParam) implementsPartUnionParam() {}
|
||||
|
||||
type StepFinishPartTokensParam struct {
|
||||
Cache param.Field[StepFinishPartTokensCacheParam] `json:"cache,required"`
|
||||
Input param.Field[float64] `json:"input,required"`
|
||||
Output param.Field[float64] `json:"output,required"`
|
||||
Reasoning param.Field[float64] `json:"reasoning,required"`
|
||||
}
|
||||
|
||||
func (r StepFinishPartTokensParam) MarshalJSON() (data []byte, err error) {
|
||||
return apijson.MarshalRoot(r)
|
||||
}
|
||||
|
||||
type StepFinishPartTokensCacheParam struct {
|
||||
Read param.Field[float64] `json:"read,required"`
|
||||
Write param.Field[float64] `json:"write,required"`
|
||||
}
|
||||
|
||||
func (r StepFinishPartTokensCacheParam) MarshalJSON() (data []byte, err error) {
|
||||
return apijson.MarshalRoot(r)
|
||||
}
|
||||
|
||||
type StepStartPart struct {
|
||||
ID string `json:"id,required"`
|
||||
MessageID string `json:"messageID,required"`
|
||||
@@ -1212,6 +1310,19 @@ func (r StepStartPartType) IsKnown() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type StepStartPartParam struct {
|
||||
ID param.Field[string] `json:"id,required"`
|
||||
MessageID param.Field[string] `json:"messageID,required"`
|
||||
SessionID param.Field[string] `json:"sessionID,required"`
|
||||
Type param.Field[StepStartPartType] `json:"type,required"`
|
||||
}
|
||||
|
||||
func (r StepStartPartParam) MarshalJSON() (data []byte, err error) {
|
||||
return apijson.MarshalRoot(r)
|
||||
}
|
||||
|
||||
func (r StepStartPartParam) implementsPartUnionParam() {}
|
||||
|
||||
type SymbolSource struct {
|
||||
Kind int64 `json:"kind,required"`
|
||||
Name string `json:"name,required"`
|
||||
@@ -1439,6 +1550,31 @@ func (r textPartTimeJSON) RawJSON() string {
|
||||
return r.raw
|
||||
}
|
||||
|
||||
type TextPartParam struct {
|
||||
ID param.Field[string] `json:"id,required"`
|
||||
MessageID param.Field[string] `json:"messageID,required"`
|
||||
SessionID param.Field[string] `json:"sessionID,required"`
|
||||
Text param.Field[string] `json:"text,required"`
|
||||
Type param.Field[TextPartType] `json:"type,required"`
|
||||
Synthetic param.Field[bool] `json:"synthetic"`
|
||||
Time param.Field[TextPartTimeParam] `json:"time"`
|
||||
}
|
||||
|
||||
func (r TextPartParam) MarshalJSON() (data []byte, err error) {
|
||||
return apijson.MarshalRoot(r)
|
||||
}
|
||||
|
||||
func (r TextPartParam) implementsPartUnionParam() {}
|
||||
|
||||
type TextPartTimeParam struct {
|
||||
Start param.Field[float64] `json:"start,required"`
|
||||
End param.Field[float64] `json:"end"`
|
||||
}
|
||||
|
||||
func (r TextPartTimeParam) MarshalJSON() (data []byte, err error) {
|
||||
return apijson.MarshalRoot(r)
|
||||
}
|
||||
|
||||
type TextPartInputParam struct {
|
||||
Text param.Field[string] `json:"text,required"`
|
||||
Type param.Field[TextPartInputType] `json:"type,required"`
|
||||
@@ -1625,6 +1761,44 @@ func (r ToolPartType) IsKnown() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type ToolPartParam struct {
|
||||
ID param.Field[string] `json:"id,required"`
|
||||
CallID param.Field[string] `json:"callID,required"`
|
||||
MessageID param.Field[string] `json:"messageID,required"`
|
||||
SessionID param.Field[string] `json:"sessionID,required"`
|
||||
State param.Field[ToolPartStateUnionParam] `json:"state,required"`
|
||||
Tool param.Field[string] `json:"tool,required"`
|
||||
Type param.Field[ToolPartType] `json:"type,required"`
|
||||
}
|
||||
|
||||
func (r ToolPartParam) MarshalJSON() (data []byte, err error) {
|
||||
return apijson.MarshalRoot(r)
|
||||
}
|
||||
|
||||
func (r ToolPartParam) implementsPartUnionParam() {}
|
||||
|
||||
type ToolPartStateParam struct {
|
||||
Status param.Field[ToolPartStateStatus] `json:"status,required"`
|
||||
Error param.Field[string] `json:"error"`
|
||||
Input param.Field[interface{}] `json:"input"`
|
||||
Metadata param.Field[interface{}] `json:"metadata"`
|
||||
Output param.Field[string] `json:"output"`
|
||||
Time param.Field[interface{}] `json:"time"`
|
||||
Title param.Field[string] `json:"title"`
|
||||
}
|
||||
|
||||
func (r ToolPartStateParam) MarshalJSON() (data []byte, err error) {
|
||||
return apijson.MarshalRoot(r)
|
||||
}
|
||||
|
||||
func (r ToolPartStateParam) implementsToolPartStateUnionParam() {}
|
||||
|
||||
// Satisfied by [ToolStatePendingParam], [ToolStateRunningParam],
|
||||
// [ToolStateCompletedParam], [ToolStateErrorParam], [ToolPartStateParam].
|
||||
type ToolPartStateUnionParam interface {
|
||||
implementsToolPartStateUnionParam()
|
||||
}
|
||||
|
||||
type ToolStateCompleted struct {
|
||||
Input map[string]interface{} `json:"input,required"`
|
||||
Metadata map[string]interface{} `json:"metadata,required"`
|
||||
@@ -1695,6 +1869,30 @@ func (r toolStateCompletedTimeJSON) RawJSON() string {
|
||||
return r.raw
|
||||
}
|
||||
|
||||
type ToolStateCompletedParam struct {
|
||||
Input param.Field[map[string]interface{}] `json:"input,required"`
|
||||
Metadata param.Field[map[string]interface{}] `json:"metadata,required"`
|
||||
Output param.Field[string] `json:"output,required"`
|
||||
Status param.Field[ToolStateCompletedStatus] `json:"status,required"`
|
||||
Time param.Field[ToolStateCompletedTimeParam] `json:"time,required"`
|
||||
Title param.Field[string] `json:"title,required"`
|
||||
}
|
||||
|
||||
func (r ToolStateCompletedParam) MarshalJSON() (data []byte, err error) {
|
||||
return apijson.MarshalRoot(r)
|
||||
}
|
||||
|
||||
func (r ToolStateCompletedParam) implementsToolPartStateUnionParam() {}
|
||||
|
||||
type ToolStateCompletedTimeParam struct {
|
||||
End param.Field[float64] `json:"end,required"`
|
||||
Start param.Field[float64] `json:"start,required"`
|
||||
}
|
||||
|
||||
func (r ToolStateCompletedTimeParam) MarshalJSON() (data []byte, err error) {
|
||||
return apijson.MarshalRoot(r)
|
||||
}
|
||||
|
||||
type ToolStateError struct {
|
||||
Error string `json:"error,required"`
|
||||
Input map[string]interface{} `json:"input,required"`
|
||||
@@ -1760,6 +1958,28 @@ func (r toolStateErrorTimeJSON) RawJSON() string {
|
||||
return r.raw
|
||||
}
|
||||
|
||||
type ToolStateErrorParam struct {
|
||||
Error param.Field[string] `json:"error,required"`
|
||||
Input param.Field[map[string]interface{}] `json:"input,required"`
|
||||
Status param.Field[ToolStateErrorStatus] `json:"status,required"`
|
||||
Time param.Field[ToolStateErrorTimeParam] `json:"time,required"`
|
||||
}
|
||||
|
||||
func (r ToolStateErrorParam) MarshalJSON() (data []byte, err error) {
|
||||
return apijson.MarshalRoot(r)
|
||||
}
|
||||
|
||||
func (r ToolStateErrorParam) implementsToolPartStateUnionParam() {}
|
||||
|
||||
type ToolStateErrorTimeParam struct {
|
||||
End param.Field[float64] `json:"end,required"`
|
||||
Start param.Field[float64] `json:"start,required"`
|
||||
}
|
||||
|
||||
func (r ToolStateErrorTimeParam) MarshalJSON() (data []byte, err error) {
|
||||
return apijson.MarshalRoot(r)
|
||||
}
|
||||
|
||||
type ToolStatePending struct {
|
||||
Status ToolStatePendingStatus `json:"status,required"`
|
||||
JSON toolStatePendingJSON `json:"-"`
|
||||
@@ -1797,6 +2017,16 @@ func (r ToolStatePendingStatus) IsKnown() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type ToolStatePendingParam struct {
|
||||
Status param.Field[ToolStatePendingStatus] `json:"status,required"`
|
||||
}
|
||||
|
||||
func (r ToolStatePendingParam) MarshalJSON() (data []byte, err error) {
|
||||
return apijson.MarshalRoot(r)
|
||||
}
|
||||
|
||||
func (r ToolStatePendingParam) implementsToolPartStateUnionParam() {}
|
||||
|
||||
type ToolStateRunning struct {
|
||||
Status ToolStateRunningStatus `json:"status,required"`
|
||||
Time ToolStateRunningTime `json:"time,required"`
|
||||
@@ -1863,6 +2093,28 @@ func (r toolStateRunningTimeJSON) RawJSON() string {
|
||||
return r.raw
|
||||
}
|
||||
|
||||
type ToolStateRunningParam struct {
|
||||
Status param.Field[ToolStateRunningStatus] `json:"status,required"`
|
||||
Time param.Field[ToolStateRunningTimeParam] `json:"time,required"`
|
||||
Input param.Field[interface{}] `json:"input"`
|
||||
Metadata param.Field[map[string]interface{}] `json:"metadata"`
|
||||
Title param.Field[string] `json:"title"`
|
||||
}
|
||||
|
||||
func (r ToolStateRunningParam) MarshalJSON() (data []byte, err error) {
|
||||
return apijson.MarshalRoot(r)
|
||||
}
|
||||
|
||||
func (r ToolStateRunningParam) implementsToolPartStateUnionParam() {}
|
||||
|
||||
type ToolStateRunningTimeParam struct {
|
||||
Start param.Field[float64] `json:"start,required"`
|
||||
}
|
||||
|
||||
func (r ToolStateRunningTimeParam) MarshalJSON() (data []byte, err error) {
|
||||
return apijson.MarshalRoot(r)
|
||||
}
|
||||
|
||||
type UserMessage struct {
|
||||
ID string `json:"id,required"`
|
||||
Role UserMessageRole `json:"role,required"`
|
||||
@@ -1954,6 +2206,7 @@ type SessionChatParams struct {
|
||||
ProviderID param.Field[string] `json:"providerID,required"`
|
||||
MessageID param.Field[string] `json:"messageID"`
|
||||
Mode param.Field[string] `json:"mode"`
|
||||
Tools param.Field[map[string]bool] `json:"tools"`
|
||||
}
|
||||
|
||||
func (r SessionChatParams) MarshalJSON() (data []byte, err error) {
|
||||
|
||||
@@ -131,6 +131,9 @@ func TestSessionChatWithOptionalParams(t *testing.T) {
|
||||
ProviderID: opencode.F("providerID"),
|
||||
MessageID: opencode.F("msg"),
|
||||
Mode: opencode.F("mode"),
|
||||
Tools: opencode.F(map[string]bool{
|
||||
"foo": true,
|
||||
}),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
57
packages/tui/sdk/tui.go
Normal file
57
packages/tui/sdk/tui.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
|
||||
|
||||
package opencode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/sst/opencode-sdk-go/internal/apijson"
|
||||
"github.com/sst/opencode-sdk-go/internal/param"
|
||||
"github.com/sst/opencode-sdk-go/internal/requestconfig"
|
||||
"github.com/sst/opencode-sdk-go/option"
|
||||
)
|
||||
|
||||
// TuiService contains methods and other services that help with interacting with
|
||||
// the opencode API.
|
||||
//
|
||||
// Note, unlike clients, this service does not read variables from the environment
|
||||
// automatically. You should not instantiate this service directly, and instead use
|
||||
// the [NewTuiService] method instead.
|
||||
type TuiService struct {
|
||||
Options []option.RequestOption
|
||||
}
|
||||
|
||||
// NewTuiService generates a new service that applies the given options to each
|
||||
// request. These options are applied after the parent client's options (if there
|
||||
// is one), and before any request-specific options.
|
||||
func NewTuiService(opts ...option.RequestOption) (r *TuiService) {
|
||||
r = &TuiService{}
|
||||
r.Options = opts
|
||||
return
|
||||
}
|
||||
|
||||
// Open the help dialog
|
||||
func (r *TuiService) OpenHelp(ctx context.Context, opts ...option.RequestOption) (res *bool, err error) {
|
||||
opts = append(r.Options[:], opts...)
|
||||
path := "tui/open-help"
|
||||
err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, nil, &res, opts...)
|
||||
return
|
||||
}
|
||||
|
||||
// Send a prompt to the TUI
|
||||
func (r *TuiService) Prompt(ctx context.Context, body TuiPromptParams, opts ...option.RequestOption) (res *bool, err error) {
|
||||
opts = append(r.Options[:], opts...)
|
||||
path := "tui/prompt"
|
||||
err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...)
|
||||
return
|
||||
}
|
||||
|
||||
type TuiPromptParams struct {
|
||||
Parts param.Field[[]PartUnionParam] `json:"parts,required"`
|
||||
Text param.Field[string] `json:"text,required"`
|
||||
}
|
||||
|
||||
func (r TuiPromptParams) MarshalJSON() (data []byte, err error) {
|
||||
return apijson.MarshalRoot(r)
|
||||
}
|
||||
72
packages/tui/sdk/tui_test.go
Normal file
72
packages/tui/sdk/tui_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
|
||||
|
||||
package opencode_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode-sdk-go/internal/testutil"
|
||||
"github.com/sst/opencode-sdk-go/option"
|
||||
)
|
||||
|
||||
func TestTuiOpenHelp(t *testing.T) {
|
||||
t.Skip("skipped: tests are disabled for the time being")
|
||||
baseURL := "http://localhost:4010"
|
||||
if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
|
||||
baseURL = envURL
|
||||
}
|
||||
if !testutil.CheckTestServer(t, baseURL) {
|
||||
return
|
||||
}
|
||||
client := opencode.NewClient(
|
||||
option.WithBaseURL(baseURL),
|
||||
)
|
||||
_, err := client.Tui.OpenHelp(context.TODO())
|
||||
if err != nil {
|
||||
var apierr *opencode.Error
|
||||
if errors.As(err, &apierr) {
|
||||
t.Log(string(apierr.DumpRequest(true)))
|
||||
}
|
||||
t.Fatalf("err should be nil: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTuiPrompt(t *testing.T) {
|
||||
t.Skip("skipped: tests are disabled for the time being")
|
||||
baseURL := "http://localhost:4010"
|
||||
if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
|
||||
baseURL = envURL
|
||||
}
|
||||
if !testutil.CheckTestServer(t, baseURL) {
|
||||
return
|
||||
}
|
||||
client := opencode.NewClient(
|
||||
option.WithBaseURL(baseURL),
|
||||
)
|
||||
_, err := client.Tui.Prompt(context.TODO(), opencode.TuiPromptParams{
|
||||
Parts: opencode.F([]opencode.PartUnionParam{opencode.TextPartParam{
|
||||
ID: opencode.F("id"),
|
||||
MessageID: opencode.F("messageID"),
|
||||
SessionID: opencode.F("sessionID"),
|
||||
Text: opencode.F("text"),
|
||||
Type: opencode.F(opencode.TextPartTypeText),
|
||||
Synthetic: opencode.F(true),
|
||||
Time: opencode.F(opencode.TextPartTimeParam{
|
||||
Start: opencode.F(0.000000),
|
||||
End: opencode.F(0.000000),
|
||||
}),
|
||||
}}),
|
||||
Text: opencode.F("text"),
|
||||
})
|
||||
if err != nil {
|
||||
var apierr *opencode.Error
|
||||
if errors.As(err, &apierr) {
|
||||
t.Log(string(apierr.DumpRequest(true)))
|
||||
}
|
||||
t.Fatalf("err should be nil: %s", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -92,24 +92,6 @@ You can configure the theme you want to use in your opencode config through the
|
||||
|
||||
---
|
||||
|
||||
### Layout
|
||||
|
||||
You can configure the layout of the TUI with the `layout` option.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"layout": "stretch"
|
||||
}
|
||||
```
|
||||
|
||||
This takes:
|
||||
|
||||
- `"auto"`: Centers content with padding. This is the default.
|
||||
- `"stretch"`: Uses full terminal width.
|
||||
|
||||
---
|
||||
|
||||
### Logging
|
||||
|
||||
Logs are written to:
|
||||
|
||||
@@ -9,7 +9,6 @@ opencode has a list of keybinds that you can customize through the opencode conf
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"keybinds": {
|
||||
|
||||
"leader": "ctrl+x",
|
||||
"app_help": "<leader>h",
|
||||
"switch_mode": "tab",
|
||||
@@ -28,10 +27,6 @@ opencode has a list of keybinds that you can customize through the opencode conf
|
||||
"theme_list": "<leader>t",
|
||||
"project_init": "<leader>i",
|
||||
|
||||
"file_list": "<leader>f",
|
||||
"file_close": "esc",
|
||||
"file_diff_toggle": "<leader>v",
|
||||
|
||||
"input_clear": "ctrl+c",
|
||||
"input_paste": "ctrl+v",
|
||||
"input_submit": "enter",
|
||||
@@ -41,13 +36,10 @@ opencode has a list of keybinds that you can customize through the opencode conf
|
||||
"messages_page_down": "pgdown",
|
||||
"messages_half_page_up": "ctrl+alt+u",
|
||||
"messages_half_page_down": "ctrl+alt+d",
|
||||
"messages_previous": "ctrl+up",
|
||||
"messages_next": "ctrl+down",
|
||||
"messages_first": "ctrl+g",
|
||||
"messages_last": "ctrl+alt+g",
|
||||
"messages_layout_toggle": "<leader>p",
|
||||
"messages_copy": "<leader>y",
|
||||
"messages_revert": "<leader>r",
|
||||
|
||||
"app_exit": "ctrl+c,<leader>q"
|
||||
}
|
||||
}
|
||||
@@ -60,3 +52,16 @@ opencode uses a `leader` key for most keybinds. This avoids conflicts in your te
|
||||
By default, `ctrl+x` is the leader key and most actions require you to first press the leader key and then the shortcut. For example, to start a new session you first press `ctrl+x` and then press `n`.
|
||||
|
||||
You don't need to use a leader key for your keybinds but we recommend doing so.
|
||||
|
||||
## Disable a keybind
|
||||
|
||||
You can disable a keybind by adding the key to your config with a value of "none".
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"keybinds": {
|
||||
"session_compact": "none",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -117,27 +117,3 @@ export DISPLAY=:99.0
|
||||
|
||||
opencode will detect if you're using Wayland and prefer `wl-clipboard`, otherwise it will try to find clipboard tools in order of: `xclip` and `xsel`.
|
||||
|
||||
---
|
||||
|
||||
### How to select and copy text in the TUI
|
||||
|
||||
There are several ways to copy text from opencode's TUI:
|
||||
|
||||
- **Copy latest message**: Use `<leader>y` to copy the most recent message in your current session to the clipboard
|
||||
- **Export session**: Use `/export` (or `<leader>x`) to open the current session as plain text in your `$EDITOR` (requires the `EDITOR` environment variable to be set)
|
||||
|
||||
We're working on adding click & drag text selection in a future update.
|
||||
|
||||
---
|
||||
|
||||
### TUI not rendering full width
|
||||
|
||||
By default, opencode's TUI uses an "auto" layout that centers content with padding. If you want the TUI to use the full width of your terminal, you can configure the layout setting:
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"layout": "stretch"
|
||||
}
|
||||
```
|
||||
|
||||
Read more about this in the [config docs](/docs/config#layout).
|
||||
|
||||
4
sdks/vscode/images/button-dark.svg
Normal file
4
sdks/vscode/images/button-dark.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="70" height="70" viewBox="0 0 70 70" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z" fill="black"/>
|
||||
<path d="M43.75 13H70V22.1957H52.5V48.701H70V57.8967H43.75V13Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 306 B |
4
sdks/vscode/images/button-light.svg
Normal file
4
sdks/vscode/images/button-light.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="70" height="70" viewBox="0 0 70 70" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z" fill="white"/>
|
||||
<path d="M43.75 13H70V22.1957H52.5V48.701H70V57.8967H43.75V13Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 306 B |
@@ -26,13 +26,26 @@
|
||||
"commands": [
|
||||
{
|
||||
"command": "opencode.openTerminal",
|
||||
"title": "Open Terminal with Opencode"
|
||||
"title": "Open Terminal with Opencode",
|
||||
"icon": {
|
||||
"light": "images/button-dark.svg",
|
||||
"dark": "images/button-light.svg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "opencode.addFilepathToTerminal",
|
||||
"title": "Add Filepath to Terminal"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
"editor/title": [
|
||||
{
|
||||
"command": "opencode.openTerminal",
|
||||
"when": "editorTextFocus",
|
||||
"group": "navigation"
|
||||
}
|
||||
]
|
||||
},
|
||||
"keybindings": [
|
||||
{
|
||||
"command": "opencode.openTerminal",
|
||||
@@ -62,7 +75,7 @@
|
||||
"test": "vscode-test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/vscode": "^1.102.0",
|
||||
"@types/vscode": "^1.94.0",
|
||||
"@types/mocha": "^10.0.10",
|
||||
"@types/node": "20.x",
|
||||
"@typescript-eslint/eslint-plugin": "^8.31.1",
|
||||
|
||||
@@ -7,73 +7,64 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
const TERMINAL_NAME = "opencode Terminal";
|
||||
|
||||
// Register command to open terminal in split screen and run opencode
|
||||
let openTerminalDisposable = vscode.commands.registerCommand(
|
||||
"opencode.openTerminal",
|
||||
async () => {
|
||||
// Create a new terminal in split screen
|
||||
const terminal = vscode.window.createTerminal({
|
||||
name: TERMINAL_NAME,
|
||||
location: {
|
||||
viewColumn: vscode.ViewColumn.Beside,
|
||||
preserveFocus: false,
|
||||
},
|
||||
});
|
||||
let openTerminalDisposable = vscode.commands.registerCommand("opencode.openTerminal", async () => {
|
||||
// Create a new terminal in split screen
|
||||
const terminal = vscode.window.createTerminal({
|
||||
name: TERMINAL_NAME,
|
||||
location: {
|
||||
viewColumn: vscode.ViewColumn.Beside,
|
||||
preserveFocus: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Show the terminal
|
||||
terminal.show();
|
||||
|
||||
// Send the opencode command to the terminal
|
||||
terminal.sendText("opencode");
|
||||
}
|
||||
);
|
||||
terminal.show();
|
||||
terminal.sendText("OPENCODE_THEME=system OPENCODE_CALLER=vscode opencode");
|
||||
});
|
||||
|
||||
// Register command to add filepath to terminal
|
||||
let addFilepathDisposable = vscode.commands.registerCommand(
|
||||
"opencode.addFilepathToTerminal",
|
||||
async () => {
|
||||
const activeEditor = vscode.window.activeTextEditor;
|
||||
let addFilepathDisposable = vscode.commands.registerCommand("opencode.addFilepathToTerminal", async () => {
|
||||
const activeEditor = vscode.window.activeTextEditor;
|
||||
|
||||
if (!activeEditor) {
|
||||
vscode.window.showInformationMessage("No active file to get path from");
|
||||
return;
|
||||
}
|
||||
if (!activeEditor) {
|
||||
vscode.window.showInformationMessage("No active file to get path from");
|
||||
return;
|
||||
}
|
||||
|
||||
const document = activeEditor.document;
|
||||
const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri);
|
||||
const document = activeEditor.document;
|
||||
const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri);
|
||||
|
||||
if (!workspaceFolder) {
|
||||
vscode.window.showInformationMessage("File is not in a workspace");
|
||||
return;
|
||||
}
|
||||
if (!workspaceFolder) {
|
||||
vscode.window.showInformationMessage("File is not in a workspace");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the relative path from workspace root
|
||||
const relativePath = vscode.workspace.asRelativePath(document.uri);
|
||||
let filepathWithAt = `@${relativePath}`;
|
||||
// Get the relative path from workspace root
|
||||
const relativePath = vscode.workspace.asRelativePath(document.uri);
|
||||
let filepathWithAt = `@${relativePath}`;
|
||||
|
||||
// Check if there's a selection and add line numbers
|
||||
const selection = activeEditor.selection;
|
||||
if (!selection.isEmpty) {
|
||||
// Convert to 1-based line numbers
|
||||
const startLine = selection.start.line + 1;
|
||||
const endLine = selection.end.line + 1;
|
||||
// Check if there's a selection and add line numbers
|
||||
const selection = activeEditor.selection;
|
||||
if (!selection.isEmpty) {
|
||||
// Convert to 1-based line numbers
|
||||
const startLine = selection.start.line + 1;
|
||||
const endLine = selection.end.line + 1;
|
||||
|
||||
if (startLine === endLine) {
|
||||
// Single line selection
|
||||
filepathWithAt += `#L${startLine}`;
|
||||
} else {
|
||||
// Multi-line selection
|
||||
filepathWithAt += `#L${startLine}-${endLine}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Get or create terminal
|
||||
let terminal = vscode.window.activeTerminal;
|
||||
if (terminal?.name === TERMINAL_NAME) {
|
||||
terminal.sendText(filepathWithAt);
|
||||
terminal.show();
|
||||
if (startLine === endLine) {
|
||||
// Single line selection
|
||||
filepathWithAt += `#L${startLine}`;
|
||||
} else {
|
||||
// Multi-line selection
|
||||
filepathWithAt += `#L${startLine}-${endLine}`;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get or create terminal
|
||||
let terminal = vscode.window.activeTerminal;
|
||||
if (terminal?.name === TERMINAL_NAME) {
|
||||
terminal.sendText(filepathWithAt);
|
||||
terminal.show();
|
||||
}
|
||||
});
|
||||
|
||||
context.subscriptions.push(openTerminalDisposable, addFilepathDisposable);
|
||||
}
|
||||
|
||||
@@ -121,6 +121,11 @@ resources:
|
||||
messages: get /session/{id}/message
|
||||
chat: post /session/{id}/message
|
||||
|
||||
tui:
|
||||
methods:
|
||||
prompt: post /tui/prompt
|
||||
openHelp: post /tui/open-help
|
||||
|
||||
settings:
|
||||
disable_mock_tests: true
|
||||
license: Apache-2.0
|
||||
|
||||
Reference in New Issue
Block a user