mirror of
https://github.com/sst/opencode.git
synced 2025-07-07 16:14:59 +00:00
Compare commits
23 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
d87922c0eb | ||
![]() |
2446483df5 | ||
![]() |
f4c453155d | ||
![]() |
969ad80ed2 | ||
![]() |
af064b41d7 | ||
![]() |
ea6bfef21a | ||
![]() |
107363b1d9 | ||
![]() |
85214d7c59 | ||
![]() |
997cb2d945 | ||
![]() |
45b139390c | ||
![]() |
994368de15 | ||
![]() |
143fd8e076 | ||
![]() |
06dba28bd6 | ||
![]() |
b8d276a049 | ||
![]() |
ee01f01271 | ||
![]() |
32d5db4f0a | ||
![]() |
f6108b7be8 | ||
![]() |
94ef341c9d | ||
![]() |
f9abc7c84f | ||
![]() |
891ed6ebc0 | ||
![]() |
163e23a68b | ||
![]() |
f13b0af491 | ||
![]() |
4a0be45d3d |
33 changed files with 1149 additions and 607 deletions
2
STATS.md
2
STATS.md
|
@ -8,3 +8,5 @@
|
|||
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
|
||||
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
|
||||
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
|
||||
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
|
||||
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
|
||||
|
|
8
bun.lock
8
bun.lock
|
@ -31,7 +31,6 @@
|
|||
"@openauthjs/openauth": "0.4.3",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"ai": "catalog:",
|
||||
"air": "0.4.14",
|
||||
"decimal.js": "10.5.0",
|
||||
"diff": "8.0.2",
|
||||
"env-paths": "3.0.0",
|
||||
|
@ -79,6 +78,7 @@
|
|||
"lang-map": "0.4.0",
|
||||
"luxon": "3.6.1",
|
||||
"marked": "15.0.12",
|
||||
"marked-shiki": "1.2.0",
|
||||
"rehype-autolink-headings": "7.1.0",
|
||||
"sharp": "0.32.5",
|
||||
"shiki": "3.4.2",
|
||||
|
@ -517,8 +517,6 @@
|
|||
|
||||
"ai": ["ai@4.3.16", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/react": "1.2.12", "@ai-sdk/ui-utils": "1.2.11", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["react"] }, "sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g=="],
|
||||
|
||||
"air": ["air@0.4.14", "", { "dependencies": { "zephyr": "~1.3.5" } }, "sha512-E8bl9LlSGSQqjxxjeGIrpYpf8jVyJplsdK1bTobh61F7ks+3aLeXL4KbGSJIFsiaSSz5ZExLU51DGztmQSlZTQ=="],
|
||||
|
||||
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
|
||||
|
@ -1055,6 +1053,8 @@
|
|||
|
||||
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||
|
||||
"marked-shiki": ["marked-shiki@1.2.0", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-N924hp8veE6Mc91g5/kCNVoTU7TkeJfB2G2XEWb+k1fVA0Bck2T0rVt93d39BlOYH6ohP4Q9BFlPk+UkblhXbg=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mdast-util-definitions": ["mdast-util-definitions@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ=="],
|
||||
|
@ -1703,8 +1703,6 @@
|
|||
|
||||
"youch": ["youch@3.3.4", "", { "dependencies": { "cookie": "^0.7.1", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg=="],
|
||||
|
||||
"zephyr": ["zephyr@1.3.6", "", {}, "sha512-oYH52DGZzIbXNrkijskaR8YpVKnXAe8jNgH1KirglVBnTFOn6mK9/0SVCxGn+73l0Hjhr4UYNzYkO07LXSWy6w=="],
|
||||
|
||||
"zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
|
||||
|
||||
"zod-openapi": ["zod-openapi@4.2.4", "", { "peerDependencies": { "zod": "^3.21.4" } }, "sha512-tsrQpbpqFCXqVXUzi3TPwFhuMtLN3oNZobOtYnK6/5VkXsNdnIgyNr4r8no4wmYluaxzN3F7iS+8xCW8BmMQ8g=="],
|
||||
|
|
|
@ -35,6 +35,15 @@ export const UpgradeCommand = {
|
|||
}
|
||||
prompts.log.info("Using method: " + method)
|
||||
const target = args.target ?? (await Installation.latest())
|
||||
|
||||
if (Installation.VERSION === target) {
|
||||
prompts.log.warn(
|
||||
`opencode upgrade skipped: ${target} is already installed`,
|
||||
)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
prompts.log.info(`From ${Installation.VERSION} → ${target}`)
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Upgrading...")
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { App } from "../app/app"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
export namespace FileTime {
|
||||
const log = Log.create({ service: "file.time" })
|
||||
export const state = App.state("tool.filetimes", () => {
|
||||
const read: {
|
||||
[sessionID: string]: {
|
||||
|
@ -13,6 +15,7 @@ export namespace FileTime {
|
|||
})
|
||||
|
||||
export function read(sessionID: string, file: string) {
|
||||
log.info("read", { sessionID, file })
|
||||
const { read } = state()
|
||||
read[sessionID] = read[sessionID] || {}
|
||||
read[sessionID][file] = new Date()
|
||||
|
|
|
@ -66,6 +66,7 @@ export namespace LSPClient {
|
|||
log.info("sending initialize", { id: serverID })
|
||||
await withTimeout(
|
||||
connection.sendRequest("initialize", {
|
||||
rootUri: "file://" + app.path.cwd,
|
||||
processId: server.process.pid,
|
||||
workspaceFolders: [
|
||||
{
|
||||
|
|
|
@ -17,6 +17,9 @@ export namespace ProviderTransform {
|
|||
anthropic: {
|
||||
cacheControl: { type: "ephemeral" },
|
||||
},
|
||||
openaiCompatible: {
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,40 +1,42 @@
|
|||
import path from "path"
|
||||
import { App } from "../app/app"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Storage } from "../storage/storage"
|
||||
import { Log } from "../util/log"
|
||||
import path from "node:path"
|
||||
import { Decimal } from "decimal.js"
|
||||
import { z, ZodSchema } from "zod"
|
||||
import {
|
||||
generateText,
|
||||
LoadAPIKeyError,
|
||||
convertToCoreMessages,
|
||||
streamText,
|
||||
tool,
|
||||
wrapLanguageModel,
|
||||
type Tool as AITool,
|
||||
type LanguageModelUsage,
|
||||
type CoreMessage,
|
||||
type UIMessage,
|
||||
type ProviderMetadata,
|
||||
wrapLanguageModel,
|
||||
type Attachment,
|
||||
} from "ai"
|
||||
import { z, ZodSchema } from "zod"
|
||||
import { Decimal } from "decimal.js"
|
||||
|
||||
import PROMPT_INITIALIZE from "../session/prompt/initialize.txt"
|
||||
|
||||
import { Share } from "../share/share"
|
||||
import { Message } from "./message"
|
||||
import { App } from "../app/app"
|
||||
import { Bus } from "../bus"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { MCP } from "../mcp"
|
||||
import { NamedError } from "../util/error"
|
||||
import type { Tool } from "../tool/tool"
|
||||
import { SystemPrompt } from "./system"
|
||||
import { Flag } from "../flag/flag"
|
||||
import type { ModelsDev } from "../provider/models"
|
||||
import { Installation } from "../installation"
|
||||
import { Config } from "../config/config"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Installation } from "../installation"
|
||||
import { MCP } from "../mcp"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { ProviderTransform } from "../provider/transform"
|
||||
import type { ModelsDev } from "../provider/models"
|
||||
import { Share } from "../share/share"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import { Storage } from "../storage/storage"
|
||||
import type { Tool } from "../tool/tool"
|
||||
import { Log } from "../util/log"
|
||||
import { NamedError } from "../util/error"
|
||||
import { Message } from "./message"
|
||||
import { SystemPrompt } from "./system"
|
||||
import { FileTime } from "../file/time"
|
||||
|
||||
export namespace Session {
|
||||
const log = Log.create({ service: "session" })
|
||||
|
@ -187,7 +189,6 @@ export namespace Session {
|
|||
export async function unshare(id: string) {
|
||||
const share = await getShare(id)
|
||||
if (!share) return
|
||||
console.log("share", share)
|
||||
await Storage.remove("session/share/" + id)
|
||||
await update(id, (draft) => {
|
||||
draft.share = undefined
|
||||
|
@ -361,6 +362,60 @@ export namespace Session {
|
|||
if (lastSummary) msgs = msgs.filter((msg) => msg.id >= lastSummary.id)
|
||||
|
||||
const app = App.info()
|
||||
input.parts = await Promise.all(
|
||||
input.parts.map(async (part): Promise<Message.MessagePart[]> => {
|
||||
if (part.type === "file") {
|
||||
const url = new URL(part.url)
|
||||
switch (url.protocol) {
|
||||
case "file:":
|
||||
const filepath = path.join(app.path.cwd, url.pathname)
|
||||
let file = Bun.file(filepath)
|
||||
|
||||
if (part.mediaType === "text/plain") {
|
||||
let text = await file.text()
|
||||
const range = {
|
||||
start: url.searchParams.get("start"),
|
||||
end: url.searchParams.get("end"),
|
||||
}
|
||||
if (range.start != null && part.mediaType === "text/plain") {
|
||||
const lines = text.split("\n")
|
||||
const start = parseInt(range.start)
|
||||
const end = range.end ? parseInt(range.end) : lines.length
|
||||
text = lines.slice(start, end).join("\n")
|
||||
}
|
||||
FileTime.read(input.sessionID, filepath)
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"Called the Read tool on " + url.pathname,
|
||||
"<results>",
|
||||
text,
|
||||
"</results>",
|
||||
].join("\n"),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
text: ["Called the Read tool on " + url.pathname].join("\n"),
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
url:
|
||||
`data:${part.mediaType};base64,` +
|
||||
Buffer.from(await file.bytes()).toString("base64url"),
|
||||
mediaType: part.mediaType,
|
||||
filename: part.filename!,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
return [part]
|
||||
}),
|
||||
).then((x) => x.flat())
|
||||
if (msgs.length === 0 && !session.parentID) {
|
||||
generateText({
|
||||
maxTokens: input.providerID === "google" ? 1024 : 20,
|
||||
|
@ -376,7 +431,7 @@ export namespace Session {
|
|||
{
|
||||
role: "user",
|
||||
content: "",
|
||||
parts: toParts(input.parts),
|
||||
parts: toParts(input.parts).parts,
|
||||
},
|
||||
]),
|
||||
],
|
||||
|
@ -1028,7 +1083,7 @@ function toUIMessage(msg: Message.Info): UIMessage {
|
|||
id: msg.id,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
parts: toParts(msg.parts),
|
||||
...toParts(msg.parts),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1037,35 +1092,41 @@ function toUIMessage(msg: Message.Info): UIMessage {
|
|||
id: msg.id,
|
||||
role: "user",
|
||||
content: "",
|
||||
parts: toParts(msg.parts),
|
||||
...toParts(msg.parts),
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("not implemented")
|
||||
}
|
||||
|
||||
function toParts(parts: Message.MessagePart[]): UIMessage["parts"] {
|
||||
const result: UIMessage["parts"] = []
|
||||
function toParts(parts: Message.MessagePart[]) {
|
||||
const result: {
|
||||
parts: UIMessage["parts"]
|
||||
experimental_attachments: Attachment[]
|
||||
} = {
|
||||
parts: [],
|
||||
experimental_attachments: [],
|
||||
}
|
||||
for (const part of parts) {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
result.push({ type: "text", text: part.text })
|
||||
result.parts.push({ type: "text", text: part.text })
|
||||
break
|
||||
case "file":
|
||||
result.push({
|
||||
type: "file",
|
||||
data: part.url,
|
||||
mimeType: part.mediaType,
|
||||
result.experimental_attachments.push({
|
||||
url: part.url,
|
||||
contentType: part.mediaType,
|
||||
name: part.filename,
|
||||
})
|
||||
break
|
||||
case "tool-invocation":
|
||||
result.push({
|
||||
result.parts.push({
|
||||
type: "tool-invocation",
|
||||
toolInvocation: part.toolInvocation,
|
||||
})
|
||||
break
|
||||
case "step-start":
|
||||
result.push({
|
||||
result.parts.push({
|
||||
type: "step-start",
|
||||
})
|
||||
break
|
||||
|
|
|
@ -4,25 +4,6 @@ import DESCRIPTION from "./bash.txt"
|
|||
import { App } from "../app/app"
|
||||
|
||||
const MAX_OUTPUT_LENGTH = 30000
|
||||
const BANNED_COMMANDS = [
|
||||
"alias",
|
||||
"curl",
|
||||
"curlie",
|
||||
"wget",
|
||||
"axel",
|
||||
"aria2c",
|
||||
"nc",
|
||||
"telnet",
|
||||
"lynx",
|
||||
"w3m",
|
||||
"links",
|
||||
"httpie",
|
||||
"xh",
|
||||
"http-prompt",
|
||||
"chrome",
|
||||
"firefox",
|
||||
"safari",
|
||||
]
|
||||
const DEFAULT_TIMEOUT = 1 * 60 * 1000
|
||||
const MAX_TIMEOUT = 10 * 60 * 1000
|
||||
|
||||
|
@ -45,8 +26,6 @@ export const BashTool = Tool.define({
|
|||
}),
|
||||
async execute(params, ctx) {
|
||||
const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT)
|
||||
if (BANNED_COMMANDS.some((item) => params.command.startsWith(item)))
|
||||
throw new Error(`Command '${params.command}' is not allowed`)
|
||||
|
||||
const process = Bun.spawn({
|
||||
cmd: ["bash", "-c", params.command],
|
||||
|
|
|
@ -37,6 +37,7 @@ require (
|
|||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/goccy/go-yaml v1.17.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/invopop/yaml v0.3.1 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
|
|
|
@ -92,6 +92,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
|||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
|
|
|
@ -44,7 +44,7 @@ type SessionClearedMsg struct{}
|
|||
type CompactSessionMsg struct{}
|
||||
type SendMsg struct {
|
||||
Text string
|
||||
Attachments []Attachment
|
||||
Attachments []opencode.FilePartParam
|
||||
}
|
||||
type OptimisticMessageAddedMsg struct {
|
||||
Message opencode.Message
|
||||
|
@ -217,13 +217,6 @@ func getDefaultModel(
|
|||
return nil
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
FilePath string
|
||||
FileName string
|
||||
MimeType string
|
||||
Content []byte
|
||||
}
|
||||
|
||||
func (a *App) IsBusy() bool {
|
||||
if len(a.Messages) == 0 {
|
||||
return false
|
||||
|
@ -296,24 +289,40 @@ func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) {
|
|||
return session, nil
|
||||
}
|
||||
|
||||
func (a *App) SendChatMessage(ctx context.Context, text string, attachments []Attachment) tea.Cmd {
|
||||
func (a *App) SendChatMessage(
|
||||
ctx context.Context,
|
||||
text string,
|
||||
attachments []opencode.FilePartParam,
|
||||
) (*App, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
if a.Session.ID == "" {
|
||||
session, err := a.CreateSession(ctx)
|
||||
if err != nil {
|
||||
return toast.NewErrorToast(err.Error())
|
||||
return a, toast.NewErrorToast(err.Error())
|
||||
}
|
||||
a.Session = session
|
||||
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
|
||||
}
|
||||
|
||||
optimisticParts := []opencode.MessagePart{{
|
||||
Type: opencode.MessagePartTypeText,
|
||||
Text: text,
|
||||
}}
|
||||
if len(attachments) > 0 {
|
||||
for _, attachment := range attachments {
|
||||
optimisticParts = append(optimisticParts, opencode.MessagePart{
|
||||
Type: opencode.MessagePartTypeFile,
|
||||
Filename: attachment.Filename.Value,
|
||||
MediaType: attachment.MediaType.Value,
|
||||
URL: attachment.URL.Value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
optimisticMessage := opencode.Message{
|
||||
ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
|
||||
Role: opencode.MessageRoleUser,
|
||||
Parts: []opencode.MessagePart{{
|
||||
Type: opencode.MessagePartTypeText,
|
||||
Text: text,
|
||||
}},
|
||||
ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
|
||||
Role: opencode.MessageRoleUser,
|
||||
Parts: optimisticParts,
|
||||
Metadata: opencode.MessageMetadata{
|
||||
SessionID: a.Session.ID,
|
||||
Time: opencode.MessageMetadataTime{
|
||||
|
@ -326,13 +335,25 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
|
|||
cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage}))
|
||||
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
parts := []opencode.MessagePartUnionParam{
|
||||
opencode.TextPartParam{
|
||||
Type: opencode.F(opencode.TextPartTypeText),
|
||||
Text: opencode.F(text),
|
||||
},
|
||||
}
|
||||
if len(attachments) > 0 {
|
||||
for _, attachment := range attachments {
|
||||
parts = append(parts, opencode.FilePartParam{
|
||||
MediaType: attachment.MediaType,
|
||||
Type: attachment.Type,
|
||||
URL: attachment.URL,
|
||||
Filename: attachment.Filename,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
|
||||
Parts: opencode.F([]opencode.MessagePartUnionParam{
|
||||
opencode.TextPartParam{
|
||||
Type: opencode.F(opencode.TextPartTypeText),
|
||||
Text: opencode.F(text),
|
||||
},
|
||||
}),
|
||||
Parts: opencode.F(parts),
|
||||
ProviderID: opencode.F(a.Provider.ID),
|
||||
ModelID: opencode.F(a.Model.ID),
|
||||
})
|
||||
|
@ -346,7 +367,7 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
|
|||
|
||||
// The actual response will come through SSE
|
||||
// For now, just return success
|
||||
return tea.Batch(cmds...)
|
||||
return a, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (a *App) Cancel(ctx context.Context, sessionID string) error {
|
||||
|
|
|
@ -16,12 +16,11 @@ import (
|
|||
|
||||
type filesAndFoldersContextGroup struct {
|
||||
app *app.App
|
||||
prefix string
|
||||
gitFiles []dialog.CompletionItemI
|
||||
}
|
||||
|
||||
func (cg *filesAndFoldersContextGroup) GetId() string {
|
||||
return cg.prefix
|
||||
return "files"
|
||||
}
|
||||
|
||||
func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
|
||||
|
@ -107,9 +106,10 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(
|
|||
|
||||
func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
|
||||
cg := &filesAndFoldersContextGroup{
|
||||
app: app,
|
||||
prefix: "file",
|
||||
app: app,
|
||||
}
|
||||
cg.gitFiles = cg.getGitFiles()
|
||||
go func() {
|
||||
cg.gitFiles = cg.getGitFiles()
|
||||
}()
|
||||
return cg
|
||||
}
|
||||
|
|
|
@ -3,11 +3,14 @@ package chat
|
|||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
|
@ -37,7 +40,6 @@ type EditorComponent interface {
|
|||
type editorComponent struct {
|
||||
app *app.App
|
||||
textarea textarea.Model
|
||||
attachments []app.Attachment
|
||||
spinner spinner.Model
|
||||
interruptKeyInDebounce bool
|
||||
}
|
||||
|
@ -66,17 +68,54 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
m.spinner = createSpinner()
|
||||
return m, tea.Batch(m.spinner.Tick, m.textarea.Focus())
|
||||
case dialog.CompletionSelectedMsg:
|
||||
if msg.IsCommand {
|
||||
switch msg.ProviderID {
|
||||
case "commands":
|
||||
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
|
||||
updated, cmd := m.Clear()
|
||||
m = updated.(*editorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
|
||||
return m, tea.Batch(cmds...)
|
||||
} else {
|
||||
existingValue := m.textarea.Value()
|
||||
case "files":
|
||||
atIndex := m.textarea.LastRuneIndex('@')
|
||||
if atIndex == -1 {
|
||||
// Should not happen, but as a fallback, just insert.
|
||||
m.textarea.InsertString(msg.CompletionValue + " ")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Replace the current token (after last space)
|
||||
// The range to replace is from the '@' up to the current cursor position.
|
||||
// Replace the search term (e.g., "@search") with an empty string first.
|
||||
cursorCol := m.textarea.CursorColumn()
|
||||
m.textarea.ReplaceRange(atIndex, cursorCol, "")
|
||||
|
||||
// Now, insert the attachment at the position where the '@' was.
|
||||
// The cursor is now at `atIndex` after the replacement.
|
||||
filePath := msg.CompletionValue
|
||||
extension := filepath.Ext(filePath)
|
||||
mediaType := ""
|
||||
switch extension {
|
||||
case ".jpg":
|
||||
mediaType = "image/jpeg"
|
||||
case ".png", ".jpeg", ".gif", ".webp":
|
||||
mediaType = "image/" + extension[1:]
|
||||
case ".pdf":
|
||||
mediaType = "application/pdf"
|
||||
default:
|
||||
mediaType = "text/plain"
|
||||
}
|
||||
attachment := &textarea.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
Display: "@" + filePath,
|
||||
URL: fmt.Sprintf("file://./%s", filePath),
|
||||
Filename: filePath,
|
||||
MediaType: mediaType,
|
||||
}
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
return m, nil
|
||||
default:
|
||||
existingValue := m.textarea.Value()
|
||||
lastSpaceIndex := strings.LastIndex(existingValue, " ")
|
||||
if lastSpaceIndex == -1 {
|
||||
m.textarea.SetValue(msg.CompletionValue + " ")
|
||||
|
@ -128,7 +167,15 @@ func (m *editorComponent) Content(width int) string {
|
|||
if m.app.IsBusy() {
|
||||
keyText := m.getInterruptKeyText()
|
||||
if m.interruptKeyInDebounce {
|
||||
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText+" again") + muted(" interrupt")
|
||||
hint = muted(
|
||||
"working",
|
||||
) + m.spinner.View() + muted(
|
||||
" ",
|
||||
) + base(
|
||||
keyText+" again",
|
||||
) + muted(
|
||||
" interrupt",
|
||||
)
|
||||
} else {
|
||||
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
|
||||
}
|
||||
|
@ -190,19 +237,29 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
|
|||
}
|
||||
if len(value) > 0 && value[len(value)-1] == '\\' {
|
||||
// If the last character is a backslash, remove it and add a newline
|
||||
m.textarea.SetValue(value[:len(value)-1] + "\n")
|
||||
m.textarea.ReplaceRange(len(value)-1, len(value), "")
|
||||
m.textarea.InsertString("\n")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var cmds []tea.Cmd
|
||||
|
||||
attachments := m.textarea.GetAttachments()
|
||||
fileParts := make([]opencode.FilePartParam, 0)
|
||||
for _, attachment := range attachments {
|
||||
fileParts = append(fileParts, opencode.FilePartParam{
|
||||
Type: opencode.F(opencode.FilePartTypeFile),
|
||||
MediaType: opencode.F(attachment.MediaType),
|
||||
URL: opencode.F(attachment.URL),
|
||||
Filename: opencode.F(attachment.Filename),
|
||||
})
|
||||
}
|
||||
|
||||
updated, cmd := m.Clear()
|
||||
m = updated.(*editorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
attachments := m.attachments
|
||||
m.attachments = nil
|
||||
|
||||
cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments}))
|
||||
cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: fileParts}))
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
|
@ -212,18 +269,23 @@ func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
|
|||
}
|
||||
|
||||
func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
|
||||
imageBytes, text, err := image.GetImageFromClipboard()
|
||||
_, text, err := image.GetImageFromClipboard()
|
||||
if err != nil {
|
||||
slog.Error(err.Error())
|
||||
return m, nil
|
||||
}
|
||||
if len(imageBytes) != 0 {
|
||||
attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
|
||||
attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"}
|
||||
m.attachments = append(m.attachments, attachment)
|
||||
} else {
|
||||
m.textarea.SetValue(m.textarea.Value() + text)
|
||||
}
|
||||
// if len(imageBytes) != 0 {
|
||||
// attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
|
||||
// attachment := app.Attachment{
|
||||
// FilePath: attachmentName,
|
||||
// FileName: attachmentName,
|
||||
// Content: imageBytes,
|
||||
// MimeType: "image/png",
|
||||
// }
|
||||
// m.attachments = append(m.attachments, attachment)
|
||||
// } else {
|
||||
m.textarea.InsertString(text)
|
||||
// }
|
||||
return m, nil
|
||||
}
|
||||
|
||||
|
@ -254,12 +316,26 @@ func createTextArea(existing *textarea.Model) textarea.Model {
|
|||
|
||||
ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||
ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
|
||||
ta.Styles.Blurred.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
|
||||
ta.Styles.Blurred.Placeholder = styles.NewStyle().
|
||||
Foreground(textMutedColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||
ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||
ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
|
||||
ta.Styles.Focused.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
|
||||
ta.Styles.Focused.Placeholder = styles.NewStyle().
|
||||
Foreground(textMutedColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||
ta.Styles.Attachment = styles.NewStyle().
|
||||
Foreground(t.Secondary()).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ta.Styles.SelectedAttachment = styles.NewStyle().
|
||||
Foreground(t.Text()).
|
||||
Background(t.Secondary()).
|
||||
Lipgloss()
|
||||
ta.Styles.Cursor.Color = t.Primary()
|
||||
|
||||
ta.Prompt = " "
|
||||
|
|
|
@ -223,6 +223,7 @@ func renderText(
|
|||
showToolDetails bool,
|
||||
highlight bool,
|
||||
width int,
|
||||
extra string,
|
||||
toolCalls ...opencode.ToolInvocationPart,
|
||||
) string {
|
||||
t := theme.CurrentTheme()
|
||||
|
@ -269,7 +270,11 @@ func renderText(
|
|||
}
|
||||
}
|
||||
|
||||
content = strings.Join([]string{content, info}, "\n")
|
||||
sections := []string{content, info}
|
||||
if extra != "" {
|
||||
sections = append(sections, "\n"+extra)
|
||||
}
|
||||
content = strings.Join(sections, "\n")
|
||||
|
||||
switch message.Role {
|
||||
case opencode.MessageRoleUser:
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
|
@ -67,11 +68,9 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
m.selectedPart = -1
|
||||
return m, nil
|
||||
case app.OptimisticMessageAddedMsg:
|
||||
m.renderView(m.width)
|
||||
if m.tail {
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
return m, nil
|
||||
m.tail = true
|
||||
m.rendering = true
|
||||
return m, m.Reload()
|
||||
case dialog.ThemeSelectedMsg:
|
||||
m.cache.Clear()
|
||||
m.rendering = true
|
||||
|
@ -133,10 +132,50 @@ func (m *messagesComponent) renderView(width int) {
|
|||
|
||||
switch message.Role {
|
||||
case opencode.MessageRoleUser:
|
||||
for _, part := range message.Parts {
|
||||
userLoop:
|
||||
for partIndex, part := range message.Parts {
|
||||
switch part := part.AsUnion().(type) {
|
||||
case opencode.TextPart:
|
||||
key := m.cache.GenerateKey(message.ID, part.Text, width, m.selectedPart == m.partCount)
|
||||
remainingParts := message.Parts[partIndex+1:]
|
||||
fileParts := make([]opencode.FilePart, 0)
|
||||
for _, part := range remainingParts {
|
||||
switch part := part.AsUnion().(type) {
|
||||
case opencode.FilePart:
|
||||
fileParts = append(fileParts, part)
|
||||
}
|
||||
}
|
||||
flexItems := []layout.FlexItem{}
|
||||
if len(fileParts) > 0 {
|
||||
fileStyle := styles.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Padding(0, 1)
|
||||
mediaTypeStyle := styles.NewStyle().Background(t.Secondary()).Foreground(t.BackgroundPanel()).Padding(0, 1)
|
||||
for _, filePart := range fileParts {
|
||||
mediaType := ""
|
||||
switch filePart.MediaType {
|
||||
case "text/plain":
|
||||
mediaType = "txt"
|
||||
case "image/png", "image/jpeg", "image/gif", "image/webp":
|
||||
mediaType = "img"
|
||||
mediaTypeStyle = mediaTypeStyle.Background(t.Accent())
|
||||
case "application/pdf":
|
||||
mediaType = "pdf"
|
||||
mediaTypeStyle = mediaTypeStyle.Background(t.Primary())
|
||||
}
|
||||
flexItems = append(flexItems, layout.FlexItem{
|
||||
View: mediaTypeStyle.Render(mediaType) + fileStyle.Render(filePart.Filename),
|
||||
})
|
||||
}
|
||||
}
|
||||
bgColor := t.BackgroundPanel()
|
||||
files := layout.Render(
|
||||
layout.FlexOptions{
|
||||
Background: &bgColor,
|
||||
Width: width - 6,
|
||||
Direction: layout.Column,
|
||||
},
|
||||
flexItems...,
|
||||
)
|
||||
|
||||
key := m.cache.GenerateKey(message.ID, part.Text, width, m.selectedPart == m.partCount, files)
|
||||
content, cached = m.cache.Get(key)
|
||||
if !cached {
|
||||
content = renderText(
|
||||
|
@ -147,6 +186,7 @@ func (m *messagesComponent) renderView(width int) {
|
|||
m.showToolDetails,
|
||||
m.partCount == m.selectedPart,
|
||||
width,
|
||||
files,
|
||||
)
|
||||
m.cache.Set(key, content)
|
||||
}
|
||||
|
@ -154,6 +194,8 @@ func (m *messagesComponent) renderView(width int) {
|
|||
m = m.updateSelected(content, part.Text)
|
||||
blocks = append(blocks, content)
|
||||
}
|
||||
// Only render the first text part
|
||||
break userLoop
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -206,6 +248,7 @@ func (m *messagesComponent) renderView(width int) {
|
|||
m.showToolDetails,
|
||||
m.partCount == m.selectedPart,
|
||||
width,
|
||||
"",
|
||||
toolCallParts...,
|
||||
)
|
||||
m.cache.Set(key, content)
|
||||
|
@ -219,6 +262,7 @@ func (m *messagesComponent) renderView(width int) {
|
|||
m.showToolDetails,
|
||||
m.partCount == m.selectedPart,
|
||||
width,
|
||||
"",
|
||||
toolCallParts...,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -64,7 +64,7 @@ type CompletionProvider interface {
|
|||
type CompletionSelectedMsg struct {
|
||||
SearchString string
|
||||
CompletionValue string
|
||||
IsCommand bool
|
||||
ProviderID string
|
||||
}
|
||||
|
||||
type CompletionDialogCompleteItemMsg struct {
|
||||
|
@ -121,9 +121,6 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
|
||||
var query string
|
||||
query = c.pseudoSearchTextArea.Value()
|
||||
if query != "" {
|
||||
query = query[1:]
|
||||
}
|
||||
|
||||
if query != c.query {
|
||||
c.query = query
|
||||
|
@ -183,8 +180,9 @@ func (c *completionDialogComponent) View() string {
|
|||
|
||||
for _, cmd := range completions {
|
||||
title := cmd.DisplayValue()
|
||||
if len(title) > maxWidth-4 {
|
||||
maxWidth = len(title) + 4
|
||||
width := lipgloss.Width(title)
|
||||
if width > maxWidth-4 {
|
||||
maxWidth = width + 4
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -213,14 +211,11 @@ func (c *completionDialogComponent) IsEmpty() bool {
|
|||
func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
|
||||
value := c.pseudoSearchTextArea.Value()
|
||||
|
||||
// Check if this is a command completion
|
||||
isCommand := c.completionProvider.GetId() == "commands"
|
||||
|
||||
return tea.Batch(
|
||||
util.CmdHandler(CompletionSelectedMsg{
|
||||
SearchString: value,
|
||||
CompletionValue: item.GetValue(),
|
||||
IsCommand: isCommand,
|
||||
ProviderID: c.completionProvider.GetId(),
|
||||
}),
|
||||
c.close(),
|
||||
)
|
||||
|
|
|
@ -124,7 +124,7 @@ func (f *findDialogComponent) View() string {
|
|||
f.list.SetMaxWidth(f.width - 4)
|
||||
inputView := f.textInput.View()
|
||||
inputView = styles.NewStyle().
|
||||
Background(t.BackgroundPanel()).
|
||||
Background(t.BackgroundElement()).
|
||||
Height(1).
|
||||
Width(f.width-4).
|
||||
Padding(0, 0).
|
||||
|
@ -171,7 +171,7 @@ func (f *findDialogComponent) Close() tea.Cmd {
|
|||
|
||||
func createTextInput(existing *textinput.Model) textinput.Model {
|
||||
t := theme.CurrentTheme()
|
||||
bgColor := t.BackgroundPanel()
|
||||
bgColor := t.BackgroundElement()
|
||||
textColor := t.Text()
|
||||
textMutedColor := t.TextMuted()
|
||||
|
||||
|
|
|
@ -56,24 +56,24 @@ func (m ModelItem) Render(selected bool, width int) string {
|
|||
displayText := fmt.Sprintf("%s (%s)", m.ModelName, m.ProviderName)
|
||||
return styles.NewStyle().
|
||||
Background(t.Primary()).
|
||||
Foreground(t.BackgroundElement()).
|
||||
Foreground(t.BackgroundPanel()).
|
||||
Width(width).
|
||||
PaddingLeft(1).
|
||||
Render(displayText)
|
||||
} else {
|
||||
modelStyle := styles.NewStyle().
|
||||
Foreground(t.Text()).
|
||||
Background(t.BackgroundElement())
|
||||
Background(t.BackgroundPanel())
|
||||
providerStyle := styles.NewStyle().
|
||||
Foreground(t.TextMuted()).
|
||||
Background(t.BackgroundElement())
|
||||
Background(t.BackgroundPanel())
|
||||
|
||||
modelPart := modelStyle.Render(m.ModelName)
|
||||
providerPart := providerStyle.Render(fmt.Sprintf(" (%s)", m.ProviderName))
|
||||
|
||||
combinedText := modelPart + providerPart
|
||||
return styles.NewStyle().
|
||||
Background(t.BackgroundElement()).
|
||||
Background(t.BackgroundPanel()).
|
||||
PaddingLeft(1).
|
||||
Render(combinedText)
|
||||
}
|
||||
|
|
|
@ -158,7 +158,12 @@ func (c *listComponent[T]) View() string {
|
|||
return strings.Join(listItems, "\n")
|
||||
}
|
||||
|
||||
func NewListComponent[T ListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[T] {
|
||||
func NewListComponent[T ListItem](
|
||||
items []T,
|
||||
maxVisibleItems int,
|
||||
fallbackMsg string,
|
||||
useAlphaNumericKeys bool,
|
||||
) List[T] {
|
||||
return &listComponent[T]{
|
||||
fallbackMsg: fallbackMsg,
|
||||
items: items,
|
||||
|
@ -194,7 +199,12 @@ func (s StringItem) Render(selected bool, width int) string {
|
|||
}
|
||||
|
||||
// NewStringList creates a new list component with string items
|
||||
func NewStringList(items []string, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[StringItem] {
|
||||
func NewStringList(
|
||||
items []string,
|
||||
maxVisibleItems int,
|
||||
fallbackMsg string,
|
||||
useAlphaNumericKeys bool,
|
||||
) List[StringItem] {
|
||||
stringItems := make([]StringItem, len(items))
|
||||
for i, item := range items {
|
||||
stringItems[i] = StringItem(item)
|
||||
|
|
|
@ -90,7 +90,7 @@ func (m *Modal) Render(contentView string, background string) string {
|
|||
|
||||
innerWidth := outerWidth - 4
|
||||
|
||||
baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement())
|
||||
baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel())
|
||||
|
||||
var finalContent string
|
||||
if m.title != "" {
|
||||
|
@ -140,6 +140,6 @@ func (m *Modal) Render(contentView string, background string) string {
|
|||
modalView,
|
||||
background,
|
||||
layout.WithOverlayBorder(),
|
||||
layout.WithOverlayBorderColor(t.BorderActive()),
|
||||
layout.WithOverlayBorderColor(t.Primary()),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ func Generate(text string) (string, int, error) {
|
|||
}
|
||||
|
||||
// Create lipgloss style for QR code with theme colors
|
||||
qrStyle := styles.NewStyleWithColors(t.Text(), t.Background())
|
||||
qrStyle := styles.NewStyle().Foreground(t.Text()).Background(t.Background())
|
||||
|
||||
var result strings.Builder
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,41 +0,0 @@
|
|||
package layout_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
)
|
||||
|
||||
func ExampleRender_withGap() {
|
||||
// Create a horizontal layout with 3px gap between items
|
||||
result := layout.Render(
|
||||
layout.FlexOptions{
|
||||
Direction: layout.Row,
|
||||
Width: 30,
|
||||
Height: 1,
|
||||
Gap: 3,
|
||||
},
|
||||
layout.FlexItem{View: "Item1"},
|
||||
layout.FlexItem{View: "Item2"},
|
||||
layout.FlexItem{View: "Item3"},
|
||||
)
|
||||
fmt.Println(result)
|
||||
// Output: Item1 Item2 Item3
|
||||
}
|
||||
|
||||
func ExampleRender_withGapAndJustify() {
|
||||
// Create a horizontal layout with gap and space-between justification
|
||||
result := layout.Render(
|
||||
layout.FlexOptions{
|
||||
Direction: layout.Row,
|
||||
Width: 30,
|
||||
Height: 1,
|
||||
Gap: 2,
|
||||
Justify: layout.JustifySpaceBetween,
|
||||
},
|
||||
layout.FlexItem{View: "A"},
|
||||
layout.FlexItem{View: "B"},
|
||||
layout.FlexItem{View: "C"},
|
||||
)
|
||||
fmt.Println(result)
|
||||
// Output: A B C
|
||||
}
|
|
@ -1,90 +0,0 @@
|
|||
package layout
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFlexGap(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts FlexOptions
|
||||
items []FlexItem
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Row with gap",
|
||||
opts: FlexOptions{
|
||||
Direction: Row,
|
||||
Width: 20,
|
||||
Height: 1,
|
||||
Gap: 2,
|
||||
},
|
||||
items: []FlexItem{
|
||||
{View: "A"},
|
||||
{View: "B"},
|
||||
{View: "C"},
|
||||
},
|
||||
expected: "A B C",
|
||||
},
|
||||
{
|
||||
name: "Column with gap",
|
||||
opts: FlexOptions{
|
||||
Direction: Column,
|
||||
Width: 1,
|
||||
Height: 5,
|
||||
Gap: 1,
|
||||
Align: AlignStart,
|
||||
},
|
||||
items: []FlexItem{
|
||||
{View: "A", FixedSize: 1},
|
||||
{View: "B", FixedSize: 1},
|
||||
{View: "C", FixedSize: 1},
|
||||
},
|
||||
expected: "A\n \nB\n \nC",
|
||||
},
|
||||
{
|
||||
name: "Row with gap and justify space between",
|
||||
opts: FlexOptions{
|
||||
Direction: Row,
|
||||
Width: 15,
|
||||
Height: 1,
|
||||
Gap: 1,
|
||||
Justify: JustifySpaceBetween,
|
||||
},
|
||||
items: []FlexItem{
|
||||
{View: "A"},
|
||||
{View: "B"},
|
||||
{View: "C"},
|
||||
},
|
||||
expected: "A B C",
|
||||
},
|
||||
{
|
||||
name: "No gap specified",
|
||||
opts: FlexOptions{
|
||||
Direction: Row,
|
||||
Width: 10,
|
||||
Height: 1,
|
||||
},
|
||||
items: []FlexItem{
|
||||
{View: "A"},
|
||||
{View: "B"},
|
||||
{View: "C"},
|
||||
},
|
||||
expected: "ABC",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := Render(tt.opts, tt.items...)
|
||||
// Trim any trailing spaces for comparison
|
||||
result = strings.TrimRight(result, " ")
|
||||
expected := strings.TrimRight(tt.expected, " ")
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Render() = %q, want %q", result, expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -52,7 +52,9 @@ type appModel struct {
|
|||
messages chat.MessagesComponent
|
||||
completions dialog.CompletionDialog
|
||||
commandProvider dialog.CompletionProvider
|
||||
fileProvider dialog.CompletionProvider
|
||||
showCompletionDialog bool
|
||||
fileCompletionActive bool
|
||||
leaderBinding *key.Binding
|
||||
isLeaderSequence bool
|
||||
toastManager *toast.ToastManager
|
||||
|
@ -180,11 +182,33 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
!a.showCompletionDialog &&
|
||||
a.editor.Value() == "" {
|
||||
a.showCompletionDialog = true
|
||||
a.fileCompletionActive = false
|
||||
|
||||
updated, cmd := a.editor.Update(msg)
|
||||
a.editor = updated.(chat.EditorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
// Set command provider for command completion
|
||||
a.completions = dialog.NewCompletionDialogComponent(a.commandProvider)
|
||||
updated, cmd = a.completions.Update(msg)
|
||||
a.completions = updated.(dialog.CompletionDialog)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return a, tea.Sequence(cmds...)
|
||||
}
|
||||
|
||||
// Handle file completions trigger
|
||||
if keyString == "@" &&
|
||||
!a.showCompletionDialog {
|
||||
a.showCompletionDialog = true
|
||||
a.fileCompletionActive = true
|
||||
|
||||
updated, cmd := a.editor.Update(msg)
|
||||
a.editor = updated.(chat.EditorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
// Set file provider for file completion
|
||||
a.completions = dialog.NewCompletionDialogComponent(a.fileProvider)
|
||||
updated, cmd = a.completions.Update(msg)
|
||||
a.completions = updated.(dialog.CompletionDialog)
|
||||
cmds = append(cmds, cmd)
|
||||
|
@ -194,7 +218,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
|
||||
if a.showCompletionDialog {
|
||||
switch keyString {
|
||||
case "tab", "enter", "esc", "ctrl+c":
|
||||
case "tab", "enter", "esc", "ctrl+c", "up", "down":
|
||||
updated, cmd := a.completions.Update(msg)
|
||||
a.completions = updated.(dialog.CompletionDialog)
|
||||
cmds = append(cmds, cmd)
|
||||
|
@ -326,10 +350,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return a, toast.NewErrorToast(msg.Error())
|
||||
case app.SendMsg:
|
||||
a.showCompletionDialog = false
|
||||
cmd := a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments)
|
||||
a.app, cmd = a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments)
|
||||
cmds = append(cmds, cmd)
|
||||
case dialog.CompletionDialogCloseMsg:
|
||||
a.showCompletionDialog = false
|
||||
a.fileCompletionActive = false
|
||||
case opencode.EventListResponseEventInstallationUpdated:
|
||||
return a, toast.NewSuccessToast(
|
||||
"opencode updated to "+msg.Properties.Version+", restart to apply.",
|
||||
|
@ -778,11 +803,8 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
|
|||
return nil
|
||||
}
|
||||
os.Remove(tmpfile.Name())
|
||||
// attachments := m.attachments
|
||||
// m.attachments = nil
|
||||
return app.SendMsg{
|
||||
Text: string(content),
|
||||
Attachments: []app.Attachment{}, // attachments,
|
||||
Text: string(content),
|
||||
}
|
||||
})
|
||||
cmds = append(cmds, cmd)
|
||||
|
@ -954,6 +976,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
|
|||
|
||||
func NewModel(app *app.App) tea.Model {
|
||||
commandProvider := completions.NewCommandCompletionProvider(app)
|
||||
fileProvider := completions.NewFileAndFolderContextGroup(app)
|
||||
|
||||
messages := chat.NewMessagesComponent(app)
|
||||
editor := chat.NewEditorComponent(app)
|
||||
|
@ -972,9 +995,11 @@ func NewModel(app *app.App) tea.Model {
|
|||
messages: messages,
|
||||
completions: completions,
|
||||
commandProvider: commandProvider,
|
||||
fileProvider: fileProvider,
|
||||
leaderBinding: leaderBinding,
|
||||
isLeaderSequence: false,
|
||||
showCompletionDialog: false,
|
||||
fileCompletionActive: false,
|
||||
toastManager: toast.NewToastManager(),
|
||||
interruptKeyState: InterruptKeyIdle,
|
||||
fileViewer: fileviewer.New(app),
|
||||
|
|
|
@ -83,7 +83,7 @@ func Extension(path string) string {
|
|||
}
|
||||
|
||||
func ToMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string {
|
||||
r := styles.GetMarkdownRenderer(width-7, backgroundColor)
|
||||
r := styles.GetMarkdownRenderer(width-6, backgroundColor)
|
||||
content = strings.ReplaceAll(content, RootPath+"/", "")
|
||||
rendered, _ := r.Render(content)
|
||||
lines := strings.Split(rendered, "\n")
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
"lang-map": "0.4.0",
|
||||
"luxon": "3.6.1",
|
||||
"marked": "15.0.12",
|
||||
"marked-shiki": "1.2.0",
|
||||
"rehype-autolink-headings": "7.1.0",
|
||||
"sharp": "0.32.5",
|
||||
"shiki": "3.4.2",
|
||||
|
|
|
@ -1,21 +1,39 @@
|
|||
import { type JSX, splitProps, createResource } from "solid-js"
|
||||
import { marked } from "marked"
|
||||
import markedShiki from "marked-shiki"
|
||||
import { codeToHtml } from "shiki"
|
||||
import { transformerNotationDiff } from "@shikijs/transformers"
|
||||
import styles from "./markdownview.module.css"
|
||||
|
||||
interface MarkdownViewProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
||||
markdown: string
|
||||
}
|
||||
|
||||
const markedWithShiki = marked.use(
|
||||
markedShiki({
|
||||
highlight(code, lang) {
|
||||
return codeToHtml(code, {
|
||||
lang: lang || "text",
|
||||
themes: {
|
||||
light: "github-light",
|
||||
dark: "github-dark",
|
||||
},
|
||||
transformers: [transformerNotationDiff()],
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
function MarkdownView(props: MarkdownViewProps) {
|
||||
const [local, rest] = splitProps(props, ["markdown"])
|
||||
const [html] = createResource(() => local.markdown, async (markdown) => {
|
||||
return marked.parse(markdown)
|
||||
})
|
||||
|
||||
return (
|
||||
<div innerHTML={html()} class={styles["markdown-body"]} {...rest} />
|
||||
const [html] = createResource(
|
||||
() => local.markdown,
|
||||
async (markdown) => {
|
||||
return markedWithShiki.parse(markdown)
|
||||
},
|
||||
)
|
||||
|
||||
return <div innerHTML={html()} class={styles["markdown-body"]} {...rest} />
|
||||
}
|
||||
|
||||
export default MarkdownView
|
||||
|
||||
|
|
|
@ -243,6 +243,44 @@ function getStatusText(status: [Status, string?]): string {
|
|||
}
|
||||
}
|
||||
|
||||
function checkOverflow(getEl: () => HTMLElement | undefined, watch?: () => any) {
|
||||
const [needsToggle, setNeedsToggle] = createSignal(false)
|
||||
|
||||
function measure() {
|
||||
const el = getEl()
|
||||
if (!el) return
|
||||
setNeedsToggle(el.scrollHeight > el.clientHeight + 1)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let raf = 0
|
||||
|
||||
function probe() {
|
||||
const el = getEl()
|
||||
if (el && el.offsetParent !== null && el.getBoundingClientRect().height) {
|
||||
measure()
|
||||
}
|
||||
else {
|
||||
raf = requestAnimationFrame(probe)
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(probe)
|
||||
|
||||
const ro = new ResizeObserver(measure)
|
||||
const el = getEl()
|
||||
if (el) ro.observe(el)
|
||||
|
||||
onCleanup(() => {
|
||||
cancelAnimationFrame(raf)
|
||||
ro.disconnect()
|
||||
})
|
||||
})
|
||||
|
||||
if (watch) createEffect(measure)
|
||||
|
||||
return needsToggle
|
||||
}
|
||||
|
||||
function ProviderIcon(props: { provider: string; size?: number }) {
|
||||
const size = props.size || 16
|
||||
return (
|
||||
|
@ -294,50 +332,21 @@ function ResultsButton(props: ResultsButtonProps) {
|
|||
interface TextPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
||||
text: string
|
||||
expand?: boolean
|
||||
invert?: boolean
|
||||
highlight?: boolean
|
||||
}
|
||||
function TextPart(props: TextPartProps) {
|
||||
const [local, rest] = splitProps(props, [
|
||||
"text",
|
||||
"expand",
|
||||
"invert",
|
||||
"highlight",
|
||||
])
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const [overflowed, setOverflowed] = createSignal(false)
|
||||
let preEl: HTMLPreElement | undefined
|
||||
|
||||
function checkOverflow() {
|
||||
if (preEl && !local.expand) {
|
||||
setOverflowed(preEl.scrollHeight > preEl.clientHeight + 1)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
checkOverflow()
|
||||
window.addEventListener("resize", checkOverflow)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
local.text
|
||||
local.expand
|
||||
setTimeout(checkOverflow, 0)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("resize", checkOverflow)
|
||||
})
|
||||
const [local, rest] = splitProps(props, ["text", "expand"])
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const overflowed = checkOverflow(() => preEl, () => local.expand)
|
||||
|
||||
return (
|
||||
<div
|
||||
class={styles["message-text"]}
|
||||
data-invert={local.invert}
|
||||
data-highlight={local.highlight}
|
||||
data-expanded={expanded() || local.expand === true}
|
||||
{...rest}
|
||||
>
|
||||
<pre ref={(el) => (preEl = el)}>{local.text}</pre>
|
||||
<pre ref={preEl}>{local.text}</pre>
|
||||
{((!local.expand && overflowed()) || expanded()) && (
|
||||
<button
|
||||
type="button"
|
||||
|
@ -355,31 +364,11 @@ interface ErrorPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
|||
expand?: boolean
|
||||
}
|
||||
function ErrorPart(props: ErrorPartProps) {
|
||||
let preEl: HTMLDivElement | undefined
|
||||
|
||||
const [local, rest] = splitProps(props, ["expand", "children"])
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const [overflowed, setOverflowed] = createSignal(false)
|
||||
let preEl: HTMLElement | undefined
|
||||
|
||||
function checkOverflow() {
|
||||
if (preEl && !local.expand) {
|
||||
setOverflowed(preEl.scrollHeight > preEl.clientHeight + 1)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
checkOverflow()
|
||||
window.addEventListener("resize", checkOverflow)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
local.children
|
||||
local.expand
|
||||
setTimeout(checkOverflow, 0)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("resize", checkOverflow)
|
||||
})
|
||||
const overflowed = checkOverflow(() => preEl, () => local.expand)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -387,7 +376,7 @@ function ErrorPart(props: ErrorPartProps) {
|
|||
data-expanded={expanded() || local.expand === true}
|
||||
{...rest}
|
||||
>
|
||||
<div data-section="content" ref={(el) => (preEl = el)}>
|
||||
<div data-section="content" ref={preEl}>
|
||||
{local.children}
|
||||
</div>
|
||||
{((!local.expand && overflowed()) || expanded()) && (
|
||||
|
@ -409,31 +398,11 @@ interface MarkdownPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
|||
highlight?: boolean
|
||||
}
|
||||
function MarkdownPart(props: MarkdownPartProps) {
|
||||
const [local, rest] = splitProps(props, ["text", "expand", "highlight"])
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const [overflowed, setOverflowed] = createSignal(false)
|
||||
let divEl: HTMLDivElement | undefined
|
||||
|
||||
function checkOverflow() {
|
||||
if (divEl && !local.expand) {
|
||||
setOverflowed(divEl.scrollHeight > divEl.clientHeight + 1)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
checkOverflow()
|
||||
window.addEventListener("resize", checkOverflow)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
local.text
|
||||
local.expand
|
||||
setTimeout(checkOverflow, 0)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("resize", checkOverflow)
|
||||
})
|
||||
const [local, rest] = splitProps(props, ["text", "expand", "highlight"])
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const overflowed = checkOverflow(() => divEl, () => local.expand)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -475,36 +444,16 @@ function TerminalPart(props: TerminalPartProps) {
|
|||
"desc",
|
||||
"expand",
|
||||
])
|
||||
let preEl: HTMLDivElement | undefined
|
||||
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const [overflowed, setOverflowed] = createSignal(false)
|
||||
let preEl: HTMLElement | undefined
|
||||
|
||||
function checkOverflow() {
|
||||
if (!preEl) return
|
||||
|
||||
const code = preEl.getElementsByTagName("code")[0]
|
||||
|
||||
if (code && !local.expand) {
|
||||
setOverflowed(preEl.clientHeight < code.offsetHeight)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
local.command
|
||||
local.result
|
||||
local.error
|
||||
local.expand
|
||||
setTimeout(checkOverflow, 0)
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
checkOverflow()
|
||||
window.addEventListener("resize", checkOverflow)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("resize", checkOverflow)
|
||||
})
|
||||
const overflowed = checkOverflow(
|
||||
() => {
|
||||
if (!preEl) return
|
||||
return preEl.getElementsByTagName("pre")[0]
|
||||
},
|
||||
() => local.expand
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -521,16 +470,16 @@ function TerminalPart(props: TerminalPartProps) {
|
|||
<Switch>
|
||||
<Match when={local.error}>
|
||||
<CodeBlock
|
||||
data-section="error"
|
||||
ref={preEl}
|
||||
lang="text"
|
||||
ref={(el) => (preEl = el)}
|
||||
data-section="error"
|
||||
code={local.error || ""}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={local.result}>
|
||||
<CodeBlock
|
||||
ref={preEl}
|
||||
lang="console"
|
||||
ref={(el) => (preEl = el)}
|
||||
code={local.result || ""}
|
||||
/>
|
||||
</Match>
|
||||
|
@ -607,6 +556,7 @@ export default function Share(props: {
|
|||
messages: Record<string, Message.Info>
|
||||
}) {
|
||||
let lastScrollY = 0
|
||||
let hasScrolledToAnchor = false
|
||||
let scrollTimeout: number | undefined
|
||||
let scrollSentinel: HTMLElement | undefined
|
||||
let scrollObserver: IntersectionObserver | undefined
|
||||
|
@ -960,9 +910,11 @@ export default function Share(props: {
|
|||
// Wait till all parts are loaded
|
||||
if (
|
||||
hash !== ""
|
||||
&& !hasScrolledToAnchor
|
||||
&& msg.parts.length === partIndex() + 1
|
||||
&& data().messages.length === msgIndex() + 1
|
||||
) {
|
||||
hasScrolledToAnchor = true
|
||||
scrollToAnchor(hash)
|
||||
}
|
||||
})
|
||||
|
@ -991,9 +943,9 @@ export default function Share(props: {
|
|||
</div>
|
||||
<div data-section="content">
|
||||
<TextPart
|
||||
invert
|
||||
text={part().text}
|
||||
expand={isLastPart()}
|
||||
data-background="blue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1021,7 +973,6 @@ export default function Share(props: {
|
|||
</div>
|
||||
<div data-section="content">
|
||||
<MarkdownPart
|
||||
highlight
|
||||
expand={isLastPart()}
|
||||
text={stripEnclosingTag(part().text)}
|
||||
/>
|
||||
|
|
|
@ -40,11 +40,17 @@
|
|||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
--shiki-dark-bg: var(--sl-color-bg-surface) !important;
|
||||
background-color: var(--sl-color-bg-surface) !important;
|
||||
padding: 0.5rem 0.75rem;
|
||||
line-height: 1.6;
|
||||
font-size: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
|
||||
span {
|
||||
white-space: break-spaces;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
|
@ -61,4 +67,40 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid var(--sl-color-border);
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
border-bottom: 1px solid var(--sl-color-border);
|
||||
}
|
||||
|
||||
/* Remove outer borders */
|
||||
table tr:first-child th,
|
||||
table tr:first-child td {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
table th:first-child,
|
||||
table td:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
table th:last-child,
|
||||
table td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -253,7 +253,7 @@
|
|||
line-height: 18px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--sl-color-text-secondary);
|
||||
max-width: var(--sm-tool-width);
|
||||
max-width: var(--md-tool-width);
|
||||
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
@ -493,9 +493,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
&[data-highlight="true"] {
|
||||
background-color: var(--sl-color-blue-low);
|
||||
}
|
||||
&[data-background="none"] { background-color: transparent; }
|
||||
&[data-background="blue"] { background-color: var(--sl-color-blue-low); }
|
||||
|
||||
&[data-expanded="true"] {
|
||||
pre {
|
||||
|
@ -669,7 +668,7 @@
|
|||
}
|
||||
|
||||
.message-markdown {
|
||||
background-color: var(--sl-color-bg-surface);
|
||||
border: 1px solid var(--sl-color-blue-high);
|
||||
padding: 0.5rem calc(0.5rem + 3px);
|
||||
border-radius: 0.25rem;
|
||||
display: flex;
|
||||
|
|
|
@ -30,6 +30,7 @@ Add a local MCP servers under `mcp.localmcp`.
|
|||
"enabled": true,
|
||||
"environment": {
|
||||
"MY_ENV_VAR": "my_env_var_value"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -73,3 +73,29 @@ So when opencode starts, it looks for:
|
|||
2. **Global file** by checking `~/.config/opencode/AGENTS.md`
|
||||
|
||||
If you have both global and project-specific rules, opencode will combine them together.
|
||||
|
||||
---
|
||||
|
||||
## Custom Instructions
|
||||
|
||||
You can also specify custom instruction files using the `instructions` configuration in your `opencode.json` or global `~/.config/opencode/config.json`:
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"instructions": [".cursor/rules/*.md"]
|
||||
}
|
||||
```
|
||||
|
||||
You can specify multiple files like `CONTRIBUTING.md` and `docs/guidelines.md`, and use glob patterns to match multiple files.
|
||||
|
||||
For example, to reuse your existing Cursor rules:
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"instructions": [".cursor/rules/*.md"]
|
||||
}
|
||||
```
|
||||
|
||||
All instruction files are combined with your `AGENTS.md` files.
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue