Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8b4aed446 | ||
|
|
03de0c406d | ||
|
|
faf8da8743 | ||
|
|
3386908fd6 | ||
|
|
5a8847952a | ||
|
|
87d21ebf2b | ||
|
|
a524fc545c | ||
|
|
4316edaf43 | ||
|
|
d845924e8b | ||
|
|
a29b322bdd | ||
|
|
9723ffa7a6 | ||
|
|
f06cd88773 | ||
|
|
9af92b6914 | ||
|
|
8f64c4b312 | ||
|
|
a32877e908 | ||
|
|
6465c9c44a | ||
|
|
4699739814 | ||
|
|
c1d87c32a2 | ||
|
|
9c5d9be33a | ||
|
|
97d9c851e6 | ||
|
|
76bd702992 | ||
|
|
50c453e577 | ||
|
|
86d5b25d18 | ||
|
|
2b44dbdbf1 |
@@ -493,7 +493,7 @@
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="],
|
||||
"@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="],
|
||||
|
||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||
|
||||
@@ -627,7 +627,7 @@
|
||||
|
||||
"buffer": ["buffer@4.9.2", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4", "isarray": "^1.0.0" } }, "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="],
|
||||
"bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="],
|
||||
|
||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"openrouter": {
|
||||
"models": {
|
||||
"moonshotai/kimi-k2": {
|
||||
"options": {
|
||||
"provider": {
|
||||
"order": ["baseten"],
|
||||
"allow_fallbacks": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
"weather": {
|
||||
"type": "local",
|
||||
|
||||
@@ -97,20 +97,21 @@ if (!snapshot) {
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.commits || [])
|
||||
|
||||
const notes = commits
|
||||
.map((commit: any) => `- ${commit.commit.message.split("\n")[0]}`)
|
||||
.filter((x: string) => {
|
||||
const lower = x.toLowerCase()
|
||||
return (
|
||||
!lower.includes("ignore:") &&
|
||||
!lower.includes("chore:") &&
|
||||
!lower.includes("ci:") &&
|
||||
!lower.includes("wip:") &&
|
||||
!lower.includes("docs:") &&
|
||||
!lower.includes("doc:")
|
||||
)
|
||||
})
|
||||
.join("\n")
|
||||
const notes =
|
||||
commits
|
||||
.map((commit: any) => `- ${commit.commit.message.split("\n")[0]}`)
|
||||
.filter((x: string) => {
|
||||
const lower = x.toLowerCase()
|
||||
return (
|
||||
!lower.includes("ignore:") &&
|
||||
!lower.includes("chore:") &&
|
||||
!lower.includes("ci:") &&
|
||||
!lower.includes("wip:") &&
|
||||
!lower.includes("docs:") &&
|
||||
!lower.includes("doc:")
|
||||
)
|
||||
})
|
||||
.join("\n") || "No notable changes"
|
||||
|
||||
if (!dry) await $`gh release create v${version} --title "v${version}" --notes ${notes} ./dist/*.zip`
|
||||
|
||||
@@ -152,6 +153,7 @@ if (!snapshot) {
|
||||
for (const pkg of ["opencode", "opencode-bin"]) {
|
||||
await $`rm -rf ./dist/aur-${pkg}`
|
||||
await $`git clone ssh://aur@aur.archlinux.org/${pkg}.git ./dist/aur-${pkg}`
|
||||
await $`cd ./dist/aur-${pkg} && git checkout master`
|
||||
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(pkgbuild.replace("${pkg}", pkg))
|
||||
await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO`
|
||||
await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO`
|
||||
|
||||
@@ -4,11 +4,12 @@ import path from "path"
|
||||
|
||||
export const AuthCopilot = lazy(async () => {
|
||||
const file = Bun.file(path.join(Global.Path.state, "plugin", "copilot.ts"))
|
||||
const exists = await file.exists()
|
||||
const response = fetch("https://raw.githubusercontent.com/sst/opencode-github-copilot/refs/heads/main/auth.ts")
|
||||
.then((x) => Bun.write(file, x))
|
||||
.catch(() => {})
|
||||
|
||||
if (!file.exists()) {
|
||||
if (!exists) {
|
||||
const worked = await response
|
||||
if (!worked) return
|
||||
}
|
||||
|
||||
@@ -167,7 +167,6 @@ export namespace Config {
|
||||
.catchall(Mode)
|
||||
.optional()
|
||||
.describe("Modes configuration, see https://opencode.ai/docs/modes"),
|
||||
log_level: Log.Level.optional().describe("Minimum log level to write to log files"),
|
||||
provider: z
|
||||
.record(
|
||||
ModelsDev.Provider.partial().extend({
|
||||
|
||||
@@ -31,9 +31,13 @@ export namespace ConfigHooks {
|
||||
}
|
||||
})
|
||||
|
||||
Bus.subscribe(Session.Event.Idle, async () => {
|
||||
Bus.subscribe(Session.Event.Idle, async (payload) => {
|
||||
const cfg = await Config.get()
|
||||
if (cfg.experimental?.hook?.session_completed) {
|
||||
const session = await Session.get(payload.properties.sessionID)
|
||||
// Only fire hook for top-level sessions (not subagent sessions)
|
||||
if (session.parentID) return
|
||||
|
||||
for (const item of cfg.experimental.hook.session_completed) {
|
||||
log.info("session_completed", {
|
||||
command: item.command,
|
||||
|
||||
@@ -44,25 +44,21 @@ const cli = yargs(hideBin(process.argv))
|
||||
describe: "print logs to stderr",
|
||||
type: "boolean",
|
||||
})
|
||||
.middleware(async () => {
|
||||
await Log.init({ print: process.argv.includes("--print-logs"), dev: Installation.isDev() })
|
||||
|
||||
try {
|
||||
const { Config } = await import("./config/config")
|
||||
const { App } = await import("./app/app")
|
||||
|
||||
App.provide({ cwd: process.cwd() }, async () => {
|
||||
const cfg = await Config.get()
|
||||
if (cfg.log_level) {
|
||||
Log.setLevel(cfg.log_level as Log.Level)
|
||||
} else {
|
||||
const defaultLevel = Installation.isDev() ? "DEBUG" : "INFO"
|
||||
Log.setLevel(defaultLevel)
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
Log.Default.error("failed to load config", { error: e })
|
||||
}
|
||||
.option("log-level", {
|
||||
describe: "log level",
|
||||
type: "string",
|
||||
choices: ["DEBUG", "INFO", "WARN", "ERROR"],
|
||||
})
|
||||
.middleware(async (opts) => {
|
||||
await Log.init({
|
||||
print: process.argv.includes("--print-logs"),
|
||||
dev: Installation.isDev(),
|
||||
level: (() => {
|
||||
if (opts.logLevel) return opts.logLevel as Log.Level
|
||||
if (Installation.isDev()) return "DEBUG"
|
||||
return "INFO"
|
||||
})(),
|
||||
})
|
||||
|
||||
Log.Default.info("opencode", {
|
||||
version: Installation.VERSION,
|
||||
|
||||
@@ -367,7 +367,10 @@ export namespace Provider {
|
||||
const pkg = provider.npm ?? provider.id
|
||||
const mod = await import(await BunProc.install(pkg, "beta"))
|
||||
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
|
||||
const loaded = fn(s.providers[provider.id]?.options)
|
||||
const loaded = fn({
|
||||
name: provider.id,
|
||||
...s.providers[provider.id]?.options,
|
||||
})
|
||||
s.sdk.set(provider.id, loaded)
|
||||
return loaded as SDK
|
||||
})().catch((e) => {
|
||||
|
||||
@@ -13,22 +13,15 @@ export namespace ProviderTransform {
|
||||
anthropic: {
|
||||
cacheControl: { type: "ephemeral" },
|
||||
},
|
||||
openaiCompatible: {
|
||||
openrouter: {
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
if (providerID === "amazon-bedrock" || modelID.includes("anthropic")) {
|
||||
const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)
|
||||
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
|
||||
|
||||
for (const msg of unique([...system, ...final])) {
|
||||
msg.providerOptions = {
|
||||
...msg.providerOptions,
|
||||
bedrock: {
|
||||
cachePoint: { type: "ephemeral" },
|
||||
},
|
||||
openaiCompatible: {
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +325,7 @@ export namespace Session {
|
||||
providerID: z.string(),
|
||||
modelID: z.string(),
|
||||
mode: z.string().optional(),
|
||||
tools: z.record(z.boolean()).optional(),
|
||||
parts: z.array(
|
||||
z.discriminatedUnion("type", [
|
||||
MessageV2.TextPart.omit({
|
||||
@@ -539,8 +540,10 @@ export namespace Session {
|
||||
if (msgs.length === 0 && !session.parentID) {
|
||||
const small = (await Provider.getSmallModel(input.providerID)) ?? model
|
||||
generateText({
|
||||
maxOutputTokens: input.providerID === "google" ? 1024 : 20,
|
||||
providerOptions: small.info.options,
|
||||
maxOutputTokens: small.info.reasoning ? 1024 : 20,
|
||||
providerOptions: {
|
||||
[input.providerID]: small.info.options,
|
||||
},
|
||||
messages: [
|
||||
...SystemPrompt.title(input.providerID).map(
|
||||
(x): ModelMessage => ({
|
||||
@@ -616,6 +619,7 @@ export namespace Session {
|
||||
|
||||
for (const item of await Provider.tools(input.providerID)) {
|
||||
if (mode.tools[item.id] === false) continue
|
||||
if (input.tools?.[item.id] === false) continue
|
||||
if (session.parentID && item.id === "task") continue
|
||||
tools[item.id] = tool({
|
||||
id: item.id as any,
|
||||
@@ -685,7 +689,9 @@ export namespace Session {
|
||||
maxOutputTokens: outputLimit,
|
||||
abortSignal: abort.signal,
|
||||
stopWhen: stepCountIs(1000),
|
||||
providerOptions: model.info.options,
|
||||
providerOptions: {
|
||||
[input.providerID]: model.info.options,
|
||||
},
|
||||
messages: [
|
||||
...system.map(
|
||||
(x): ModelMessage => ({
|
||||
|
||||
@@ -41,6 +41,10 @@ export const TaskTool = Tool.define({
|
||||
sessionID: session.id,
|
||||
modelID: msg.modelID,
|
||||
providerID: msg.providerID,
|
||||
tools: {
|
||||
todoread: false,
|
||||
todowrite: false,
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
|
||||
@@ -14,18 +14,10 @@ export namespace Log {
|
||||
ERROR: 3,
|
||||
}
|
||||
|
||||
let currentLevel: Level = "INFO"
|
||||
let level: Level = "INFO"
|
||||
|
||||
export function setLevel(level: Level) {
|
||||
currentLevel = level
|
||||
}
|
||||
|
||||
export function getLevel(): Level {
|
||||
return currentLevel
|
||||
}
|
||||
|
||||
function shouldLog(level: Level): boolean {
|
||||
return levelPriority[level] >= levelPriority[currentLevel]
|
||||
function shouldLog(input: Level): boolean {
|
||||
return levelPriority[input] >= levelPriority[level]
|
||||
}
|
||||
|
||||
export type Logger = {
|
||||
@@ -60,6 +52,7 @@ export namespace Log {
|
||||
}
|
||||
|
||||
export async function init(options: Options) {
|
||||
if (options.level) level = options.level
|
||||
const dir = path.join(Global.Path.data, "log")
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
cleanup(dir)
|
||||
|
||||
@@ -91,6 +91,9 @@ func main() {
|
||||
stream := httpClient.Event.ListStreaming(ctx)
|
||||
for stream.Next() {
|
||||
evt := stream.Current().AsUnion()
|
||||
if _, ok := evt.(opencode.EventListResponseEventStorageWrite); ok {
|
||||
continue
|
||||
}
|
||||
program.Send(evt)
|
||||
}
|
||||
if err := stream.Err(); err != nil {
|
||||
|
||||
+2
-1
@@ -5,12 +5,12 @@ go 1.24.0
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.5.0
|
||||
github.com/alecthomas/chroma/v2 v2.18.0
|
||||
github.com/charmbracelet/bubbles v0.21.0
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3
|
||||
github.com/charmbracelet/x/ansi v0.9.3
|
||||
github.com/charmbracelet/x/input v0.3.7
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/lithammer/fuzzysearch v1.1.8
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
|
||||
@@ -34,6 +34,7 @@ require (
|
||||
github.com/atombender/go-jsonschema v0.20.0 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||
github.com/charmbracelet/x/input v0.3.7 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.1 // indirect
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
||||
|
||||
@@ -20,6 +20,8 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp
|
||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 h1:UgUuKKvBwgqm2ZEL+sKv/OLeavrUb4gfHgdxe6oIOno=
|
||||
|
||||
@@ -264,6 +264,8 @@ func renderToolDetails(
|
||||
toolCall opencode.ToolPart,
|
||||
width int,
|
||||
) string {
|
||||
measure := util.Measure("chat.renderToolDetails")
|
||||
defer measure("tool", toolCall.Tool)
|
||||
ignoredTools := []string{"todoread"}
|
||||
if slices.Contains(ignoredTools, toolCall.Tool) {
|
||||
return ""
|
||||
@@ -368,12 +370,14 @@ func renderToolDetails(
|
||||
}
|
||||
}
|
||||
case "bash":
|
||||
command := toolInputMap["command"].(string)
|
||||
body = fmt.Sprintf("```console\n$ %s\n", command)
|
||||
stdout := metadata["stdout"]
|
||||
if stdout != nil {
|
||||
command := toolInputMap["command"].(string)
|
||||
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
|
||||
body = util.ToMarkdown(body, width, backgroundColor)
|
||||
body += ansi.Strip(fmt.Sprintf("%s", stdout))
|
||||
}
|
||||
body += "```"
|
||||
body = util.ToMarkdown(body, width, backgroundColor)
|
||||
case "webfetch":
|
||||
if format, ok := toolInputMap["format"].(string); ok && result != nil {
|
||||
body = *result
|
||||
|
||||
@@ -2,9 +2,9 @@ package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
"github.com/sst/opencode/internal/viewport"
|
||||
)
|
||||
|
||||
type MessagesComponent interface {
|
||||
@@ -52,6 +53,8 @@ func (m *messagesComponent) Init() tea.Cmd {
|
||||
}
|
||||
|
||||
func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
measure := util.Measure("messages.Update")
|
||||
defer measure("from", fmt.Sprintf("%T", msg))
|
||||
var cmds []tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
@@ -99,34 +102,33 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.lineCount = msg.lineCount
|
||||
m.rendering = false
|
||||
m.loading = false
|
||||
m.viewport.SetHeight(m.height - lipgloss.Height(m.header))
|
||||
m.viewport.SetContent(msg.content)
|
||||
if m.tail {
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
m.tail = m.viewport.AtBottom()
|
||||
m.viewport = msg.viewport
|
||||
m.header = msg.header
|
||||
if m.dirty {
|
||||
cmds = append(cmds, m.renderView())
|
||||
}
|
||||
}
|
||||
|
||||
m.tail = m.viewport.AtBottom()
|
||||
viewport, cmd := m.viewport.Update(msg)
|
||||
m.viewport = viewport
|
||||
m.tail = m.viewport.AtBottom()
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
type renderCompleteMsg struct {
|
||||
content string
|
||||
viewport viewport.Model
|
||||
header string
|
||||
partCount int
|
||||
lineCount int
|
||||
}
|
||||
|
||||
func (m *messagesComponent) renderView() tea.Cmd {
|
||||
m.header = m.renderHeader()
|
||||
|
||||
if m.rendering {
|
||||
slog.Debug("pending render, skipping")
|
||||
m.dirty = true
|
||||
return func() tea.Msg {
|
||||
return nil
|
||||
@@ -135,7 +137,11 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
m.dirty = false
|
||||
m.rendering = true
|
||||
|
||||
viewport := m.viewport
|
||||
tail := m.tail
|
||||
|
||||
return func() tea.Msg {
|
||||
header := m.renderHeader()
|
||||
measure := util.Measure("messages.renderView")
|
||||
defer measure()
|
||||
|
||||
@@ -228,6 +234,7 @@ 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) {
|
||||
@@ -359,6 +366,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
}
|
||||
}
|
||||
}
|
||||
messageMeasure()
|
||||
}
|
||||
|
||||
error := ""
|
||||
@@ -396,8 +404,15 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
}
|
||||
|
||||
content := "\n" + strings.Join(blocks, "\n\n")
|
||||
viewport.SetHeight(m.height - lipgloss.Height(m.header))
|
||||
viewport.SetContent(content)
|
||||
if tail {
|
||||
viewport.GotoBottom()
|
||||
}
|
||||
|
||||
return renderCompleteMsg{
|
||||
content: content,
|
||||
header: header,
|
||||
viewport: viewport,
|
||||
partCount: partCount,
|
||||
lineCount: lineCount,
|
||||
}
|
||||
@@ -414,18 +429,6 @@ func (m *messagesComponent) renderHeader() string {
|
||||
t := theme.CurrentTheme()
|
||||
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
|
||||
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
|
||||
headerLines := []string{}
|
||||
headerLines = append(
|
||||
headerLines,
|
||||
util.ToMarkdown("# "+m.app.Session.Title, headerWidth-6, t.Background()),
|
||||
)
|
||||
|
||||
share := ""
|
||||
if m.app.Session.Share.URL != "" {
|
||||
share = muted(m.app.Session.Share.URL + " /unshare")
|
||||
} else {
|
||||
share = base("/share") + muted(" to create a shareable link")
|
||||
}
|
||||
|
||||
sessionInfo := ""
|
||||
tokens := float64(0)
|
||||
@@ -459,30 +462,38 @@ func (m *messagesComponent) renderHeader() string {
|
||||
Background(t.Background()).
|
||||
Render(formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel))
|
||||
|
||||
background := t.Background()
|
||||
shareEnabled := m.app.Config.Share != opencode.ConfigShareDisabled
|
||||
headerText := util.ToMarkdown("# "+m.app.Session.Title, headerWidth-len(sessionInfo), t.Background())
|
||||
|
||||
var items []layout.FlexItem
|
||||
justify := layout.JustifyEnd
|
||||
|
||||
if m.app.Config.Share != opencode.ConfigShareDisabled {
|
||||
items = append(items, layout.FlexItem{View: share})
|
||||
justify = layout.JustifySpaceBetween
|
||||
if shareEnabled {
|
||||
share := base("/share") + muted(" to create a shareable link")
|
||||
if m.app.Session.Share.URL != "" {
|
||||
share = muted(m.app.Session.Share.URL + " /unshare")
|
||||
}
|
||||
items = []layout.FlexItem{{View: share}, {View: sessionInfo}}
|
||||
} else {
|
||||
items = []layout.FlexItem{{View: headerText}, {View: sessionInfo}}
|
||||
}
|
||||
|
||||
items = append(items, layout.FlexItem{View: sessionInfo})
|
||||
|
||||
background := t.Background()
|
||||
headerRow := layout.Render(
|
||||
layout.FlexOptions{
|
||||
Background: &background,
|
||||
Direction: layout.Row,
|
||||
Justify: justify,
|
||||
Justify: layout.JustifySpaceBetween,
|
||||
Align: layout.AlignStretch,
|
||||
Width: headerWidth - 6,
|
||||
},
|
||||
items...,
|
||||
)
|
||||
|
||||
headerLines = append(headerLines, headerRow)
|
||||
var headerLines []string
|
||||
if shareEnabled {
|
||||
headerLines = []string{headerText, headerRow}
|
||||
} else {
|
||||
headerLines = []string{headerRow}
|
||||
}
|
||||
|
||||
header := strings.Join(headerLines, "\n")
|
||||
header = styles.NewStyle().
|
||||
@@ -566,9 +577,12 @@ func (m *messagesComponent) View() string {
|
||||
)
|
||||
}
|
||||
|
||||
measure := util.Measure("messages.View")
|
||||
viewport := m.viewport.View()
|
||||
measure()
|
||||
return styles.NewStyle().
|
||||
Background(t.Background()).
|
||||
Render(m.header + "\n" + m.viewport.View())
|
||||
Render(m.header + "\n" + viewport)
|
||||
}
|
||||
|
||||
func (m *messagesComponent) Reload() tea.Cmd {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/v2/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
commandsComponent "github.com/sst/opencode/internal/components/commands"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/viewport"
|
||||
)
|
||||
|
||||
type helpDialog struct {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
|
||||
"github.com/sst/opencode/internal/app"
|
||||
@@ -15,6 +14,7 @@ import (
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
"github.com/sst/opencode/internal/viewport"
|
||||
)
|
||||
|
||||
type DiffStyle int
|
||||
|
||||
@@ -103,6 +103,9 @@ func (a appModel) Init() tea.Cmd {
|
||||
}
|
||||
|
||||
func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
measure := util.Measure("app.Update")
|
||||
defer measure("from", fmt.Sprintf("%T", msg))
|
||||
|
||||
var cmd tea.Cmd
|
||||
var cmds []tea.Cmd
|
||||
|
||||
@@ -525,6 +528,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (a appModel) View() string {
|
||||
measure := util.Measure("app.View")
|
||||
defer measure()
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
var mainLayout string
|
||||
@@ -691,6 +696,8 @@ func (a appModel) home() string {
|
||||
}
|
||||
|
||||
func (a appModel) chat() string {
|
||||
measure := util.Measure("chat.View")
|
||||
defer measure()
|
||||
effectiveWidth := a.width - 4
|
||||
t := theme.CurrentTheme()
|
||||
editorView := a.editor.View()
|
||||
@@ -775,7 +782,8 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
|
||||
return a, toast.NewErrorToast("Something went wrong, couldn't open editor")
|
||||
}
|
||||
tmpfile.Close()
|
||||
c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
|
||||
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
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
)
|
||||
|
||||
// PreventHyphenBreaks replaces regular hyphens with non-breaking hyphens to prevent
|
||||
// sparse word breaks in hyphenated terms like "claude-code-action".
|
||||
// This improves readability by keeping hyphenated words together.
|
||||
// Only preserves hyphens within words, not markdown syntax like bullet points.
|
||||
func PreventHyphenBreaks(text string) string {
|
||||
// Use regex to match hyphens that are between word characters
|
||||
// This preserves hyphens in words like "claude-code-action" but not in "- [ ]"
|
||||
re := regexp.MustCompile(`(\w)-(\w)`)
|
||||
return re.ReplaceAllString(text, "$1\u2011$2")
|
||||
}
|
||||
|
||||
// RestoreHyphens converts non-breaking hyphens back to regular hyphens.
|
||||
// This should be called after text processing (like word wrapping) is complete.
|
||||
func RestoreHyphens(text string) string {
|
||||
return strings.ReplaceAll(text, "\u2011", "-")
|
||||
}
|
||||
|
||||
// ProcessTextWithHyphens applies hyphen preservation to text during processing.
|
||||
// It wraps the provided processFunc with hyphen handling.
|
||||
func ProcessTextWithHyphens(text string, processFunc func(string) string) string {
|
||||
preserved := PreventHyphenBreaks(text)
|
||||
processed := processFunc(preserved)
|
||||
return RestoreHyphens(processed)
|
||||
}
|
||||
|
||||
// GetMessageContainerFrame calculates the actual horizontal frame size
|
||||
// (padding + borders) for message containers based on current theme.
|
||||
func GetMessageContainerFrame() int {
|
||||
style := lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
BorderLeft(true).
|
||||
BorderRight(true).
|
||||
PaddingLeft(2).
|
||||
PaddingRight(2)
|
||||
return style.GetHorizontalFrameSize()
|
||||
}
|
||||
|
||||
// GetMarkdownContainerFrame calculates the actual horizontal frame size
|
||||
// for markdown containers based on current theme.
|
||||
func GetMarkdownContainerFrame() int {
|
||||
// Markdown containers use the same styling as message containers
|
||||
return GetMessageContainerFrame()
|
||||
}
|
||||
@@ -40,8 +40,8 @@ func IsWsl() bool {
|
||||
|
||||
func Measure(tag string) func(...any) {
|
||||
startTime := time.Now()
|
||||
return func(tags ...any) {
|
||||
args := append([]any{"timeTakenMs", time.Since(startTime).Milliseconds()}, tags...)
|
||||
return func(args ...any) {
|
||||
args = append(args, []any{"timeTakenMs", time.Since(startTime).Milliseconds()}...)
|
||||
slog.Debug(tag, args...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package viewport
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/rivo/uniseg"
|
||||
)
|
||||
|
||||
// parseMatches converts the given matches into highlight ranges.
|
||||
//
|
||||
// Assumptions:
|
||||
// - matches are measured in bytes, e.g. what [regex.FindAllStringIndex] would return
|
||||
// - matches were made against the given content
|
||||
// - matches are in order
|
||||
// - matches do not overlap
|
||||
// - content is line terminated with \n only
|
||||
//
|
||||
// We'll then convert the ranges into [highlightInfo]s, which hold the starting
|
||||
// line and the grapheme positions.
|
||||
func parseMatches(
|
||||
content string,
|
||||
matches [][]int,
|
||||
) []highlightInfo {
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
line := 0
|
||||
graphemePos := 0
|
||||
previousLinesOffset := 0
|
||||
bytePos := 0
|
||||
|
||||
highlights := make([]highlightInfo, 0, len(matches))
|
||||
gr := uniseg.NewGraphemes(ansi.Strip(content))
|
||||
|
||||
for _, match := range matches {
|
||||
byteStart, byteEnd := match[0], match[1]
|
||||
|
||||
// hilight for this match:
|
||||
hi := highlightInfo{
|
||||
lines: map[int][2]int{},
|
||||
}
|
||||
|
||||
// find the beginning of this byte range, setup current line and
|
||||
// grapheme position.
|
||||
for byteStart > bytePos {
|
||||
if !gr.Next() {
|
||||
break
|
||||
}
|
||||
if content[bytePos] == '\n' {
|
||||
previousLinesOffset = graphemePos + 1
|
||||
line++
|
||||
}
|
||||
graphemePos += max(1, gr.Width())
|
||||
bytePos += len(gr.Str())
|
||||
}
|
||||
|
||||
hi.lineStart = line
|
||||
hi.lineEnd = line
|
||||
|
||||
graphemeStart := graphemePos
|
||||
|
||||
// loop until we find the end
|
||||
for byteEnd > bytePos {
|
||||
if !gr.Next() {
|
||||
break
|
||||
}
|
||||
|
||||
// if it ends with a new line, add the range, increase line, and continue
|
||||
if content[bytePos] == '\n' {
|
||||
colstart := max(0, graphemeStart-previousLinesOffset)
|
||||
colend := max(graphemePos-previousLinesOffset+1, colstart) // +1 its \n itself
|
||||
|
||||
if colend > colstart {
|
||||
hi.lines[line] = [2]int{colstart, colend}
|
||||
hi.lineEnd = line
|
||||
}
|
||||
|
||||
previousLinesOffset = graphemePos + 1
|
||||
line++
|
||||
}
|
||||
|
||||
graphemePos += max(1, gr.Width())
|
||||
bytePos += len(gr.Str())
|
||||
}
|
||||
|
||||
// we found it!, add highlight and continue
|
||||
if bytePos == byteEnd {
|
||||
colstart := max(0, graphemeStart-previousLinesOffset)
|
||||
colend := max(graphemePos-previousLinesOffset, colstart)
|
||||
|
||||
if colend > colstart {
|
||||
hi.lines[line] = [2]int{colstart, colend}
|
||||
hi.lineEnd = line
|
||||
}
|
||||
}
|
||||
|
||||
highlights = append(highlights, hi)
|
||||
}
|
||||
|
||||
return highlights
|
||||
}
|
||||
|
||||
type highlightInfo struct {
|
||||
// in which line this highlight starts and ends
|
||||
lineStart, lineEnd int
|
||||
|
||||
// the grapheme highlight ranges for each of these lines
|
||||
lines map[int][2]int
|
||||
}
|
||||
|
||||
// coords returns the line x column of this highlight.
|
||||
func (hi highlightInfo) coords() (int, int, int) {
|
||||
for i := hi.lineStart; i <= hi.lineEnd; i++ {
|
||||
hl, ok := hi.lines[i]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
return i, hl[0], hl[1]
|
||||
}
|
||||
return hi.lineStart, 0, 0
|
||||
}
|
||||
|
||||
func makeHighlightRanges(
|
||||
highlights []highlightInfo,
|
||||
line int,
|
||||
style lipgloss.Style,
|
||||
) []lipgloss.Range {
|
||||
result := []lipgloss.Range{}
|
||||
for _, hi := range highlights {
|
||||
lihi, ok := hi.lines[line]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if lihi == [2]int{} {
|
||||
continue
|
||||
}
|
||||
result = append(result, lipgloss.NewRange(lihi[0], lihi[1], style))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package viewport
|
||||
|
||||
import "github.com/charmbracelet/bubbles/v2/key"
|
||||
|
||||
// KeyMap defines the keybindings for the viewport. Note that you don't
|
||||
// necessary need to use keybindings at all; the viewport can be controlled
|
||||
// programmatically with methods like Model.LineDown(1). See the GoDocs for
|
||||
// details.
|
||||
type KeyMap struct {
|
||||
PageDown key.Binding
|
||||
PageUp key.Binding
|
||||
HalfPageUp key.Binding
|
||||
HalfPageDown key.Binding
|
||||
Down key.Binding
|
||||
Up key.Binding
|
||||
Left key.Binding
|
||||
Right key.Binding
|
||||
}
|
||||
|
||||
// DefaultKeyMap returns a set of pager-like default keybindings.
|
||||
func DefaultKeyMap() KeyMap {
|
||||
return KeyMap{
|
||||
PageDown: key.NewBinding(
|
||||
key.WithKeys("pgdown", "space", "f"),
|
||||
key.WithHelp("f/pgdn", "page down"),
|
||||
),
|
||||
PageUp: key.NewBinding(
|
||||
key.WithKeys("pgup", "b"),
|
||||
key.WithHelp("b/pgup", "page up"),
|
||||
),
|
||||
HalfPageUp: key.NewBinding(
|
||||
key.WithKeys("u", "ctrl+u"),
|
||||
key.WithHelp("u", "½ page up"),
|
||||
),
|
||||
HalfPageDown: key.NewBinding(
|
||||
key.WithKeys("d", "ctrl+d"),
|
||||
key.WithHelp("d", "½ page down"),
|
||||
),
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up", "k"),
|
||||
key.WithHelp("↑/k", "up"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("down", "j"),
|
||||
key.WithHelp("↓/j", "down"),
|
||||
),
|
||||
Left: key.NewBinding(
|
||||
key.WithKeys("left", "h"),
|
||||
key.WithHelp("←/h", "move left"),
|
||||
),
|
||||
Right: key.NewBinding(
|
||||
key.WithKeys("right", "l"),
|
||||
key.WithHelp("→/l", "move right"),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,803 @@
|
||||
package viewport
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHorizontalStep = 6
|
||||
)
|
||||
|
||||
// Option is a configuration option that works in conjunction with [New]. For
|
||||
// example:
|
||||
//
|
||||
// timer := New(WithWidth(10, WithHeight(5)))
|
||||
type Option func(*Model)
|
||||
|
||||
// WithWidth is an initialization option that sets the width of the
|
||||
// viewport. Pass as an argument to [New].
|
||||
func WithWidth(w int) Option {
|
||||
return func(m *Model) {
|
||||
m.width = w
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeight is an initialization option that sets the height of the
|
||||
// viewport. Pass as an argument to [New].
|
||||
func WithHeight(h int) Option {
|
||||
return func(m *Model) {
|
||||
m.height = h
|
||||
}
|
||||
}
|
||||
|
||||
// New returns a new model with the given width and height as well as default
|
||||
// key mappings.
|
||||
func New(opts ...Option) (m Model) {
|
||||
for _, opt := range opts {
|
||||
opt(&m)
|
||||
}
|
||||
m.setInitialValues()
|
||||
m.memo = &Memo{}
|
||||
return m
|
||||
}
|
||||
|
||||
type Memo struct {
|
||||
dirty bool
|
||||
cache string
|
||||
}
|
||||
|
||||
func (m *Memo) View(render func() string) string {
|
||||
if m.dirty {
|
||||
// slog.Debug("memo dirty")
|
||||
m.cache = render()
|
||||
m.dirty = false
|
||||
return m.cache
|
||||
}
|
||||
// slog.Debug("memo cache")
|
||||
return m.cache
|
||||
}
|
||||
|
||||
func (m *Memo) Invalidate() {
|
||||
m.dirty = true
|
||||
}
|
||||
|
||||
// Model is the Bubble Tea model for this viewport element.
|
||||
type Model struct {
|
||||
memo *Memo
|
||||
width int
|
||||
height int
|
||||
KeyMap KeyMap
|
||||
|
||||
// Whether or not to wrap text. If false, it'll allow horizontal scrolling
|
||||
// instead.
|
||||
SoftWrap bool
|
||||
|
||||
// Whether or not to fill to the height of the viewport with empty lines.
|
||||
FillHeight bool
|
||||
|
||||
// Whether or not to respond to the mouse. The mouse must be enabled in
|
||||
// Bubble Tea for this to work. For details, see the Bubble Tea docs.
|
||||
MouseWheelEnabled bool
|
||||
|
||||
// The number of lines the mouse wheel will scroll. By default, this is 3.
|
||||
MouseWheelDelta int
|
||||
|
||||
// YOffset is the vertical scroll position.
|
||||
YOffset int
|
||||
|
||||
// xOffset is the horizontal scroll position.
|
||||
xOffset int
|
||||
|
||||
// horizontalStep is the number of columns we move left or right during a
|
||||
// default horizontal scroll.
|
||||
horizontalStep int
|
||||
|
||||
// YPosition is the position of the viewport in relation to the terminal
|
||||
// window. It's used in high performance rendering only.
|
||||
YPosition int
|
||||
|
||||
// Style applies a lipgloss style to the viewport. Realistically, it's most
|
||||
// useful for setting borders, margins and padding.
|
||||
Style lipgloss.Style
|
||||
|
||||
// LeftGutterFunc allows to define a [GutterFunc] that adds a column into
|
||||
// the left of the viewport, which is kept when horizontal scrolling.
|
||||
// This can be used for things like line numbers, selection indicators,
|
||||
// show statuses, etc.
|
||||
LeftGutterFunc GutterFunc
|
||||
|
||||
initialized bool
|
||||
lines []string
|
||||
longestLineWidth int
|
||||
|
||||
// HighlightStyle highlights the ranges set with [SetHighligths].
|
||||
HighlightStyle lipgloss.Style
|
||||
|
||||
// SelectedHighlightStyle highlights the highlight range focused during
|
||||
// navigation.
|
||||
// Use [SetHighligths] to set the highlight ranges, and [HightlightNext]
|
||||
// and [HihglightPrevious] to navigate.
|
||||
SelectedHighlightStyle lipgloss.Style
|
||||
|
||||
// StyleLineFunc allows to return a [lipgloss.Style] for each line.
|
||||
// The argument is the line index.
|
||||
StyleLineFunc func(int) lipgloss.Style
|
||||
|
||||
highlights []highlightInfo
|
||||
hiIdx int
|
||||
}
|
||||
|
||||
// GutterFunc can be implemented and set into [Model.LeftGutterFunc].
|
||||
//
|
||||
// Example implementation showing line numbers:
|
||||
//
|
||||
// func(info GutterContext) string {
|
||||
// if info.Soft {
|
||||
// return " │ "
|
||||
// }
|
||||
// if info.Index >= info.TotalLines {
|
||||
// return " ~ │ "
|
||||
// }
|
||||
// return fmt.Sprintf("%4d │ ", info.Index+1)
|
||||
// }
|
||||
type GutterFunc func(GutterContext) string
|
||||
|
||||
// NoGutter is the default gutter used.
|
||||
var NoGutter = func(GutterContext) string { return "" }
|
||||
|
||||
// GutterContext provides context to a [GutterFunc].
|
||||
type GutterContext struct {
|
||||
Index int
|
||||
TotalLines int
|
||||
Soft bool
|
||||
}
|
||||
|
||||
func (m *Model) setInitialValues() {
|
||||
m.KeyMap = DefaultKeyMap()
|
||||
m.MouseWheelEnabled = true
|
||||
m.MouseWheelDelta = 3
|
||||
m.initialized = true
|
||||
m.horizontalStep = defaultHorizontalStep
|
||||
m.LeftGutterFunc = NoGutter
|
||||
}
|
||||
|
||||
// Init exists to satisfy the tea.Model interface for composability purposes.
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Height returns the height of the viewport.
|
||||
func (m Model) Height() int {
|
||||
return m.height
|
||||
}
|
||||
|
||||
// SetHeight sets the height of the viewport.
|
||||
func (m *Model) SetHeight(h int) {
|
||||
m.height = h
|
||||
m.memo.Invalidate()
|
||||
}
|
||||
|
||||
// Width returns the width of the viewport.
|
||||
func (m Model) Width() int {
|
||||
return m.width
|
||||
}
|
||||
|
||||
// SetWidth sets the width of the viewport.
|
||||
func (m *Model) SetWidth(w int) {
|
||||
m.width = w
|
||||
m.memo.Invalidate()
|
||||
}
|
||||
|
||||
// AtTop returns whether or not the viewport is at the very top position.
|
||||
func (m Model) AtTop() bool {
|
||||
return m.YOffset <= 0
|
||||
}
|
||||
|
||||
// AtBottom returns whether or not the viewport is at or past the very bottom
|
||||
// position.
|
||||
func (m Model) AtBottom() bool {
|
||||
return m.YOffset >= m.maxYOffset()
|
||||
}
|
||||
|
||||
// PastBottom returns whether or not the viewport is scrolled beyond the last
|
||||
// line. This can happen when adjusting the viewport height.
|
||||
func (m Model) PastBottom() bool {
|
||||
return m.YOffset > m.maxYOffset()
|
||||
}
|
||||
|
||||
// ScrollPercent returns the amount scrolled as a float between 0 and 1.
|
||||
func (m Model) ScrollPercent() float64 {
|
||||
count := m.lineCount()
|
||||
if m.Height() >= count {
|
||||
return 1.0
|
||||
}
|
||||
y := float64(m.YOffset)
|
||||
h := float64(m.Height())
|
||||
t := float64(count)
|
||||
v := y / (t - h)
|
||||
return math.Max(0.0, math.Min(1.0, v))
|
||||
}
|
||||
|
||||
// HorizontalScrollPercent returns the amount horizontally scrolled as a float
|
||||
// between 0 and 1.
|
||||
func (m Model) HorizontalScrollPercent() float64 {
|
||||
if m.xOffset >= m.longestLineWidth-m.Width() {
|
||||
return 1.0
|
||||
}
|
||||
y := float64(m.xOffset)
|
||||
h := float64(m.Width())
|
||||
t := float64(m.longestLineWidth)
|
||||
v := y / (t - h)
|
||||
return math.Max(0.0, math.Min(1.0, v))
|
||||
}
|
||||
|
||||
// SetContent set the pager's text content.
|
||||
// Line endings will be normalized to '\n'.
|
||||
func (m *Model) SetContent(s string) {
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n") // normalize line endings
|
||||
m.SetContentLines(strings.Split(s, "\n"))
|
||||
m.memo.Invalidate()
|
||||
}
|
||||
|
||||
// SetContentLines allows to set the lines to be shown instead of the content.
|
||||
// If a given line has a \n in it, it'll be considered a [Model.SoftWrap].
|
||||
// See also [Model.SetContent].
|
||||
func (m *Model) SetContentLines(lines []string) {
|
||||
// if there's no content, set content to actual nil instead of one empty
|
||||
// line.
|
||||
m.lines = lines
|
||||
if len(m.lines) == 1 && ansi.StringWidth(m.lines[0]) == 0 {
|
||||
m.lines = nil
|
||||
}
|
||||
m.longestLineWidth = maxLineWidth(m.lines)
|
||||
m.ClearHighlights()
|
||||
|
||||
if m.YOffset > m.maxYOffset() {
|
||||
m.GotoBottom()
|
||||
}
|
||||
m.memo.Invalidate()
|
||||
}
|
||||
|
||||
// GetContent returns the entire content as a single string.
|
||||
// Line endings are normalized to '\n'.
|
||||
func (m Model) GetContent() string {
|
||||
return strings.Join(m.lines, "\n")
|
||||
}
|
||||
|
||||
// calculateLine taking soft wraping into account, returns the total viewable
|
||||
// lines and the real-line index for the given yoffset.
|
||||
func (m Model) calculateLine(yoffset int) (total, idx int) {
|
||||
if !m.SoftWrap {
|
||||
for i, line := range m.lines {
|
||||
adjust := max(1, lipgloss.Height(line))
|
||||
if yoffset >= total && yoffset < total+adjust {
|
||||
idx = i
|
||||
}
|
||||
total += adjust
|
||||
}
|
||||
if yoffset >= total {
|
||||
idx = len(m.lines)
|
||||
}
|
||||
return total, idx
|
||||
}
|
||||
|
||||
maxWidth := m.maxWidth()
|
||||
var gutterSize int
|
||||
if m.LeftGutterFunc != nil {
|
||||
gutterSize = lipgloss.Width(m.LeftGutterFunc(GutterContext{}))
|
||||
}
|
||||
for i, line := range m.lines {
|
||||
adjust := max(1, lipgloss.Width(line)/(maxWidth-gutterSize))
|
||||
if yoffset >= total && yoffset < total+adjust {
|
||||
idx = i
|
||||
}
|
||||
total += adjust
|
||||
}
|
||||
if yoffset >= total {
|
||||
idx = len(m.lines)
|
||||
}
|
||||
return total, idx
|
||||
}
|
||||
|
||||
// lineToIndex taking soft wrappign into account, return the real line index
|
||||
// for the given line.
|
||||
func (m Model) lineToIndex(y int) int {
|
||||
_, idx := m.calculateLine(y)
|
||||
return idx
|
||||
}
|
||||
|
||||
// lineCount taking soft wrapping into account, return the total viewable line
|
||||
// count (real lines + soft wrapped line).
|
||||
func (m Model) lineCount() int {
|
||||
total, _ := m.calculateLine(0)
|
||||
return total
|
||||
}
|
||||
|
||||
// maxYOffset returns the maximum possible value of the y-offset based on the
|
||||
// viewport's content and set height.
|
||||
func (m Model) maxYOffset() int {
|
||||
return max(0, m.lineCount()-m.Height()+m.Style.GetVerticalFrameSize())
|
||||
}
|
||||
|
||||
// maxXOffset returns the maximum possible value of the x-offset based on the
|
||||
// viewport's content and set width.
|
||||
func (m Model) maxXOffset() int {
|
||||
return max(0, m.longestLineWidth-m.Width())
|
||||
}
|
||||
|
||||
func (m Model) maxWidth() int {
|
||||
var gutterSize int
|
||||
if m.LeftGutterFunc != nil {
|
||||
gutterSize = lipgloss.Width(m.LeftGutterFunc(GutterContext{}))
|
||||
}
|
||||
return m.Width() -
|
||||
m.Style.GetHorizontalFrameSize() -
|
||||
gutterSize
|
||||
}
|
||||
|
||||
func (m Model) maxHeight() int {
|
||||
return m.Height() - m.Style.GetVerticalFrameSize()
|
||||
}
|
||||
|
||||
// visibleLines returns the lines that should currently be visible in the
|
||||
// viewport.
|
||||
func (m Model) visibleLines() (lines []string) {
|
||||
maxHeight := m.maxHeight()
|
||||
maxWidth := m.maxWidth()
|
||||
|
||||
if m.lineCount() > 0 {
|
||||
pos := m.lineToIndex(m.YOffset)
|
||||
top := max(0, pos)
|
||||
bottom := clamp(pos+maxHeight, top, len(m.lines))
|
||||
lines = make([]string, bottom-top)
|
||||
copy(lines, m.lines[top:bottom])
|
||||
lines = m.styleLines(lines, top)
|
||||
lines = m.highlightLines(lines, top)
|
||||
}
|
||||
|
||||
for m.FillHeight && len(lines) < maxHeight {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
// if longest line fit within width, no need to do anything else.
|
||||
if (m.xOffset == 0 && m.longestLineWidth <= maxWidth) || maxWidth == 0 {
|
||||
return m.setupGutter(lines)
|
||||
}
|
||||
|
||||
if m.SoftWrap {
|
||||
return m.softWrap(lines, maxWidth)
|
||||
}
|
||||
|
||||
for i, line := range lines {
|
||||
sublines := strings.Split(line, "\n") // will only have more than 1 if caller used [Model.SetContentLines].
|
||||
for j := range sublines {
|
||||
sublines[j] = ansi.Cut(sublines[j], m.xOffset, m.xOffset+maxWidth)
|
||||
}
|
||||
lines[i] = strings.Join(sublines, "\n")
|
||||
}
|
||||
return m.setupGutter(lines)
|
||||
}
|
||||
|
||||
// styleLines styles the lines using [Model.StyleLineFunc].
|
||||
func (m Model) styleLines(lines []string, offset int) []string {
|
||||
if m.StyleLineFunc == nil {
|
||||
return lines
|
||||
}
|
||||
for i := range lines {
|
||||
lines[i] = m.StyleLineFunc(i + offset).Render(lines[i])
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// highlightLines highlights the lines with [Model.HighlightStyle] and
|
||||
// [Model.SelectedHighlightStyle].
|
||||
func (m Model) highlightLines(lines []string, offset int) []string {
|
||||
if len(m.highlights) == 0 {
|
||||
return lines
|
||||
}
|
||||
for i := range lines {
|
||||
ranges := makeHighlightRanges(
|
||||
m.highlights,
|
||||
i+offset,
|
||||
m.HighlightStyle,
|
||||
)
|
||||
lines[i] = lipgloss.StyleRanges(lines[i], ranges...)
|
||||
if m.hiIdx < 0 {
|
||||
continue
|
||||
}
|
||||
sel := m.highlights[m.hiIdx]
|
||||
if hi, ok := sel.lines[i+offset]; ok {
|
||||
lines[i] = lipgloss.StyleRanges(lines[i], lipgloss.NewRange(
|
||||
hi[0],
|
||||
hi[1],
|
||||
m.SelectedHighlightStyle,
|
||||
))
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func (m Model) softWrap(lines []string, maxWidth int) []string {
|
||||
var wrappedLines []string
|
||||
total := m.TotalLineCount()
|
||||
for i, line := range lines {
|
||||
idx := 0
|
||||
for ansi.StringWidth(line) >= idx {
|
||||
truncatedLine := ansi.Cut(line, idx, maxWidth+idx)
|
||||
if m.LeftGutterFunc != nil {
|
||||
truncatedLine = m.LeftGutterFunc(GutterContext{
|
||||
Index: i + m.YOffset,
|
||||
TotalLines: total,
|
||||
Soft: idx > 0,
|
||||
}) + truncatedLine
|
||||
}
|
||||
wrappedLines = append(wrappedLines, truncatedLine)
|
||||
idx += maxWidth
|
||||
}
|
||||
}
|
||||
return wrappedLines
|
||||
}
|
||||
|
||||
// setupGutter sets up the left gutter using [Moddel.LeftGutterFunc].
|
||||
func (m Model) setupGutter(lines []string) []string {
|
||||
if m.LeftGutterFunc == nil {
|
||||
return lines
|
||||
}
|
||||
|
||||
offset := max(0, m.lineToIndex(m.YOffset))
|
||||
total := m.TotalLineCount()
|
||||
result := make([]string, len(lines))
|
||||
for i := range lines {
|
||||
var line []string
|
||||
for j, realLine := range strings.Split(lines[i], "\n") {
|
||||
line = append(line, m.LeftGutterFunc(GutterContext{
|
||||
Index: i + offset,
|
||||
TotalLines: total,
|
||||
Soft: j > 0,
|
||||
})+realLine)
|
||||
}
|
||||
result[i] = strings.Join(line, "\n")
|
||||
}
|
||||
m.memo.Invalidate()
|
||||
return result
|
||||
}
|
||||
|
||||
// SetYOffset sets the Y offset.
|
||||
func (m *Model) SetYOffset(n int) {
|
||||
m.YOffset = clamp(n, 0, m.maxYOffset())
|
||||
m.memo.Invalidate()
|
||||
}
|
||||
|
||||
// SetXOffset sets the X offset.
|
||||
// No-op when soft wrap is enabled.
|
||||
func (m *Model) SetXOffset(n int) {
|
||||
if m.SoftWrap {
|
||||
return
|
||||
}
|
||||
m.xOffset = clamp(n, 0, m.maxXOffset())
|
||||
m.memo.Invalidate()
|
||||
}
|
||||
|
||||
// EnsureVisible ensures that the given line and column are in the viewport.
|
||||
func (m *Model) EnsureVisible(line, colstart, colend int) {
|
||||
maxWidth := m.maxWidth()
|
||||
if colend <= maxWidth {
|
||||
m.SetXOffset(0)
|
||||
} else {
|
||||
m.SetXOffset(colstart - m.horizontalStep) // put one step to the left, feels more natural
|
||||
}
|
||||
|
||||
if line < m.YOffset || line >= m.YOffset+m.maxHeight() {
|
||||
m.SetYOffset(line)
|
||||
}
|
||||
|
||||
m.visibleLines()
|
||||
}
|
||||
|
||||
// ViewDown moves the view down by the number of lines in the viewport.
|
||||
// Basically, "page down".
|
||||
func (m *Model) ViewDown() {
|
||||
if m.AtBottom() {
|
||||
return
|
||||
}
|
||||
|
||||
m.LineDown(m.Height())
|
||||
m.memo.Invalidate()
|
||||
}
|
||||
|
||||
// ViewUp moves the view up by one height of the viewport. Basically, "page up".
|
||||
func (m *Model) ViewUp() {
|
||||
if m.AtTop() {
|
||||
return
|
||||
}
|
||||
|
||||
m.LineUp(m.Height())
|
||||
m.memo.Invalidate()
|
||||
}
|
||||
|
||||
// HalfViewDown moves the view down by half the height of the viewport.
|
||||
func (m *Model) HalfViewDown() {
|
||||
if m.AtBottom() {
|
||||
return
|
||||
}
|
||||
|
||||
m.LineDown(m.Height() / 2) //nolint:mnd
|
||||
m.memo.Invalidate()
|
||||
}
|
||||
|
||||
// HalfViewUp moves the view up by half the height of the viewport.
|
||||
func (m *Model) HalfViewUp() {
|
||||
if m.AtTop() {
|
||||
return
|
||||
}
|
||||
|
||||
m.LineUp(m.Height() / 2) //nolint:mnd
|
||||
m.memo.Invalidate()
|
||||
}
|
||||
|
||||
// LineDown moves the view down by the given number of lines.
|
||||
func (m *Model) LineDown(n int) {
|
||||
if m.AtBottom() || n == 0 || len(m.lines) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Make sure the number of lines by which we're going to scroll isn't
|
||||
// greater than the number of lines we actually have left before we reach
|
||||
// the bottom.
|
||||
m.SetYOffset(m.YOffset + n)
|
||||
m.hiIdx = m.findNearedtMatch()
|
||||
m.memo.Invalidate()
|
||||
}
|
||||
|
||||
// LineUp moves the view down by the given number of lines. Returns the new
|
||||
// lines to show.
|
||||
func (m *Model) LineUp(n int) {
|
||||
if m.AtTop() || n == 0 || len(m.lines) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Make sure the number of lines by which we're going to scroll isn't
|
||||
// greater than the number of lines we are from the top.
|
||||
m.SetYOffset(m.YOffset - n)
|
||||
m.hiIdx = m.findNearedtMatch()
|
||||
m.memo.Invalidate()
|
||||
}
|
||||
|
||||
// TotalLineCount returns the total number of lines (both hidden and visible) within the viewport.
|
||||
func (m Model) TotalLineCount() int {
|
||||
return m.lineCount()
|
||||
}
|
||||
|
||||
// VisibleLineCount returns the number of the visible lines within the viewport.
|
||||
func (m Model) VisibleLineCount() int {
|
||||
return len(m.visibleLines())
|
||||
}
|
||||
|
||||
// GotoTop sets the viewport to the top position.
|
||||
func (m *Model) GotoTop() (lines []string) {
|
||||
if m.AtTop() {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.SetYOffset(0)
|
||||
m.hiIdx = m.findNearedtMatch()
|
||||
m.memo.Invalidate()
|
||||
return m.visibleLines()
|
||||
}
|
||||
|
||||
// GotoBottom sets the viewport to the bottom position.
|
||||
func (m *Model) GotoBottom() (lines []string) {
|
||||
m.SetYOffset(m.maxYOffset())
|
||||
m.hiIdx = m.findNearedtMatch()
|
||||
m.memo.Invalidate()
|
||||
return m.visibleLines()
|
||||
}
|
||||
|
||||
// SetHorizontalStep sets the amount of cells that the viewport moves in the
|
||||
// default viewport keymapping. If set to 0 or less, horizontal scrolling is
|
||||
// disabled.
|
||||
func (m *Model) SetHorizontalStep(n int) {
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
|
||||
m.horizontalStep = n
|
||||
m.memo.Invalidate()
|
||||
}
|
||||
|
||||
// MoveLeft moves the viewport to the left by the given number of columns.
|
||||
func (m *Model) MoveLeft(cols int) {
|
||||
m.xOffset -= cols
|
||||
if m.xOffset < 0 {
|
||||
m.xOffset = 0
|
||||
m.memo.Invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
// MoveRight moves viewport to the right by the given number of columns.
|
||||
func (m *Model) MoveRight(cols int) {
|
||||
// prevents over scrolling to the right
|
||||
w := m.maxWidth()
|
||||
if m.xOffset > m.longestLineWidth-w {
|
||||
return
|
||||
}
|
||||
m.xOffset += cols
|
||||
}
|
||||
|
||||
// Resets lines indent to zero.
|
||||
func (m *Model) ResetIndent() {
|
||||
m.xOffset = 0
|
||||
m.memo.Invalidate()
|
||||
}
|
||||
|
||||
// SetHighlights sets ranges of characters to highlight.
|
||||
// For instance, `[]int{[]int{2, 10}, []int{20, 30}}` will highlight characters
|
||||
// 2 to 10 and 20 to 30.
|
||||
// Note that highlights are not expected to transpose each other, and are also
|
||||
// expected to be in order.
|
||||
// Use [Model.SetHighlights] to set the highlight ranges, and
|
||||
// [Model.HighlightNext] and [Model.HighlightPrevious] to navigate.
|
||||
// Use [Model.ClearHighlights] to remove all highlights.
|
||||
func (m *Model) SetHighlights(matches [][]int) {
|
||||
if len(matches) == 0 || len(m.lines) == 0 {
|
||||
return
|
||||
}
|
||||
m.highlights = parseMatches(m.GetContent(), matches)
|
||||
m.hiIdx = m.findNearedtMatch()
|
||||
m.showHighlight()
|
||||
m.memo.Invalidate()
|
||||
}
|
||||
|
||||
// ClearHighlights clears previously set highlights.
|
||||
func (m *Model) ClearHighlights() {
|
||||
m.highlights = nil
|
||||
m.hiIdx = -1
|
||||
m.memo.Invalidate()
|
||||
}
|
||||
|
||||
func (m *Model) showHighlight() {
|
||||
if m.hiIdx == -1 {
|
||||
return
|
||||
}
|
||||
line, colstart, colend := m.highlights[m.hiIdx].coords()
|
||||
m.EnsureVisible(line, colstart, colend)
|
||||
m.memo.Invalidate()
|
||||
}
|
||||
|
||||
// HighlightNext highlights the next match.
|
||||
func (m *Model) HighlightNext() {
|
||||
if m.highlights == nil {
|
||||
return
|
||||
}
|
||||
|
||||
m.hiIdx = (m.hiIdx + 1) % len(m.highlights)
|
||||
m.showHighlight()
|
||||
m.memo.Invalidate()
|
||||
}
|
||||
|
||||
// HighlightPrevious highlights the previous match.
|
||||
func (m *Model) HighlightPrevious() {
|
||||
if m.highlights == nil {
|
||||
return
|
||||
}
|
||||
|
||||
m.hiIdx = (m.hiIdx - 1 + len(m.highlights)) % len(m.highlights)
|
||||
m.showHighlight()
|
||||
m.memo.Invalidate()
|
||||
}
|
||||
|
||||
func (m Model) findNearedtMatch() int {
|
||||
for i, match := range m.highlights {
|
||||
if match.lineStart >= m.YOffset {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// Update handles standard message-based viewport updates.
|
||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
m = m.updateAsModel(msg)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Author's note: this method has been broken out to make it easier to
|
||||
// potentially transition Update to satisfy tea.Model.
|
||||
func (m Model) updateAsModel(msg tea.Msg) Model {
|
||||
if !m.initialized {
|
||||
m.setInitialValues()
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyPressMsg:
|
||||
switch {
|
||||
case key.Matches(msg, m.KeyMap.PageDown):
|
||||
m.ViewDown()
|
||||
|
||||
case key.Matches(msg, m.KeyMap.PageUp):
|
||||
m.ViewUp()
|
||||
|
||||
case key.Matches(msg, m.KeyMap.HalfPageDown):
|
||||
m.HalfViewDown()
|
||||
|
||||
case key.Matches(msg, m.KeyMap.HalfPageUp):
|
||||
m.HalfViewUp()
|
||||
|
||||
case key.Matches(msg, m.KeyMap.Down):
|
||||
m.LineDown(1)
|
||||
|
||||
case key.Matches(msg, m.KeyMap.Up):
|
||||
m.LineUp(1)
|
||||
|
||||
case key.Matches(msg, m.KeyMap.Left):
|
||||
m.MoveLeft(m.horizontalStep)
|
||||
|
||||
case key.Matches(msg, m.KeyMap.Right):
|
||||
m.MoveRight(m.horizontalStep)
|
||||
}
|
||||
|
||||
case tea.MouseWheelMsg:
|
||||
if !m.MouseWheelEnabled {
|
||||
break
|
||||
}
|
||||
|
||||
switch msg.Button {
|
||||
case tea.MouseWheelDown:
|
||||
m.LineDown(m.MouseWheelDelta)
|
||||
|
||||
case tea.MouseWheelUp:
|
||||
m.LineUp(m.MouseWheelDelta)
|
||||
}
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// View renders the viewport into a string.
|
||||
func (m *Model) render() {
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
return m.memo.View(func() string {
|
||||
w, h := m.Width(), m.Height()
|
||||
if sw := m.Style.GetWidth(); sw != 0 {
|
||||
w = min(w, sw)
|
||||
}
|
||||
if sh := m.Style.GetHeight(); sh != 0 {
|
||||
h = min(h, sh)
|
||||
}
|
||||
contentWidth := w - m.Style.GetHorizontalFrameSize()
|
||||
contentHeight := h - m.Style.GetVerticalFrameSize()
|
||||
visible := m.visibleLines()
|
||||
contents := lipgloss.NewStyle().
|
||||
Width(contentWidth). // pad to width.
|
||||
Height(contentHeight). // pad to height.
|
||||
MaxHeight(contentHeight). // truncate height if taller.
|
||||
MaxWidth(contentWidth). // truncate width if wider.
|
||||
Render(strings.Join(visible, "\n"))
|
||||
return m.Style.
|
||||
UnsetWidth().UnsetHeight(). // Style size already applied in contents.
|
||||
Render(contents)
|
||||
})
|
||||
}
|
||||
|
||||
func clamp(v, low, high int) int {
|
||||
if high < low {
|
||||
low, high = high, low
|
||||
}
|
||||
return min(high, max(low, v))
|
||||
}
|
||||
|
||||
func maxLineWidth(lines []string) int {
|
||||
result := 0
|
||||
for _, line := range lines {
|
||||
result = max(result, lipgloss.Width(line))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -61,6 +61,49 @@ You can customize the base URL for any provider by setting the `baseURL` option.
|
||||
|
||||
---
|
||||
|
||||
### OpenRouter
|
||||
|
||||
Many OpenRouter models are preloaded by default - you can customize these or add your own.
|
||||
|
||||
Here's an example of specifying a provider
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"openrouter": {
|
||||
"models": {
|
||||
"moonshotai/kimi-k2": {
|
||||
"options": {
|
||||
"provider": {
|
||||
"order": ["baseten"],
|
||||
"allow_fallbacks": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can also add additional models
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"openrouter": {
|
||||
"models": {
|
||||
"somecoolnewmodel": {},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Local
|
||||
|
||||
You can configure local model like ones served through LM Studio or Ollama. To
|
||||
|
||||
Reference in New Issue
Block a user