Compare commits

...

7 Commits

Author SHA1 Message Date
Dax Raad ec1c9f8cd1 use production share url 2025-06-19 14:21:00 -04:00
Dax Raad a85b0a370e ci: share 2025-06-19 13:26:15 -04:00
Dax Raad e7784d2864 add schema descriptions to config fields
Enhance configuration schema with descriptive text for all fields to improve developer experience and auto-generated documentation.

🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-19 13:12:13 -04:00
Dax Raad 97c4815444 fix task agent performance issues 2025-06-19 13:00:57 -04:00
Dax Raad 7d1a1663c8 allow selecting model and continuing previous session for opencode run 2025-06-19 13:00:57 -04:00
adamdottv 24c0ce6e53 fix(tui): vscode and mac terminal colors 2025-06-19 11:46:08 -05:00
adamdottv 4cdc86612c fix(tui): overlay border backgrounds 2025-06-19 11:41:30 -05:00
20 changed files with 458 additions and 150 deletions
+2 -1
View File
@@ -4,6 +4,7 @@ on:
push:
branches:
- dev
- production
workflow_dispatch:
concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -20,6 +21,6 @@ jobs:
- run: bun install
- run: bun sst deploy --stage=dev
- run: bun sst deploy --stage=${{ github.ref_name }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1
View File
@@ -1,4 +1,5 @@
{
"$schema": "https://opencode.ai/config.json",
"keybinds": {},
"mcp": {}
}
+1 -1
View File
@@ -120,7 +120,7 @@ export default {
return new Response(
JSON.stringify({
secret,
url: "https://dev.opencode.ai/s/" + short,
url: "https://opencode.ai/s/" + short,
}),
{
headers: { "Content-Type": "application/json" },
+74 -37
View File
@@ -2,97 +2,127 @@
"type": "object",
"properties": {
"$schema": {
"type": "string"
"type": "string",
"description": "JSON schema reference for configuration validation"
},
"theme": {
"type": "string"
"type": "string",
"description": "Theme name to use for the interface"
},
"keybinds": {
"type": "object",
"properties": {
"leader": {
"type": "string"
"type": "string",
"description": "Leader key for keybind combinations"
},
"help": {
"type": "string"
"type": "string",
"description": "Show help dialog"
},
"editor_open": {
"type": "string"
"type": "string",
"description": "Open external editor"
},
"session_new": {
"type": "string"
"type": "string",
"description": "Create a new session"
},
"session_list": {
"type": "string"
"type": "string",
"description": "List all sessions"
},
"session_share": {
"type": "string"
"type": "string",
"description": "Share current session"
},
"session_interrupt": {
"type": "string"
"type": "string",
"description": "Interrupt current session"
},
"session_compact": {
"type": "string"
"type": "string",
"description": "Toggle compact mode for session"
},
"tool_details": {
"type": "string"
"type": "string",
"description": "Show tool details"
},
"model_list": {
"type": "string"
"type": "string",
"description": "List available models"
},
"theme_list": {
"type": "string"
"type": "string",
"description": "List available themes"
},
"project_init": {
"type": "string"
"type": "string",
"description": "Initialize project configuration"
},
"input_clear": {
"type": "string"
"type": "string",
"description": "Clear input field"
},
"input_paste": {
"type": "string"
"type": "string",
"description": "Paste from clipboard"
},
"input_submit": {
"type": "string"
"type": "string",
"description": "Submit input"
},
"input_newline": {
"type": "string"
"type": "string",
"description": "Insert newline in input"
},
"history_previous": {
"type": "string"
"type": "string",
"description": "Navigate to previous history item"
},
"history_next": {
"type": "string"
"type": "string",
"description": "Navigate to next history item"
},
"messages_page_up": {
"type": "string"
"type": "string",
"description": "Scroll messages up by one page"
},
"messages_page_down": {
"type": "string"
"type": "string",
"description": "Scroll messages down by one page"
},
"messages_half_page_up": {
"type": "string"
"type": "string",
"description": "Scroll messages up by half page"
},
"messages_half_page_down": {
"type": "string"
"type": "string",
"description": "Scroll messages down by half page"
},
"messages_previous": {
"type": "string"
"type": "string",
"description": "Navigate to previous message"
},
"messages_next": {
"type": "string"
"type": "string",
"description": "Navigate to next message"
},
"messages_first": {
"type": "string"
"type": "string",
"description": "Navigate to first message"
},
"messages_last": {
"type": "string"
"type": "string",
"description": "Navigate to last message"
},
"app_exit": {
"type": "string"
"type": "string",
"description": "Exit the application"
}
},
"additionalProperties": false
"additionalProperties": false,
"description": "Custom keybind configurations"
},
"autoshare": {
"type": "boolean",
@@ -207,7 +237,8 @@
"models"
],
"additionalProperties": false
}
},
"description": "Custom provider configurations and model overrides"
},
"mcp": {
"type": "object",
@@ -218,19 +249,22 @@
"properties": {
"type": {
"type": "string",
"const": "local"
"const": "local",
"description": "Type of MCP server connection"
},
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"description": "Command and arguments to run the MCP server"
},
"environment": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"description": "Environment variables to set when running the MCP server"
}
},
"required": [
@@ -244,10 +278,12 @@
"properties": {
"type": {
"type": "string",
"const": "remote"
"const": "remote",
"description": "Type of MCP server connection"
},
"url": {
"type": "string"
"type": "string",
"description": "URL of the remote MCP server"
}
},
"required": [
@@ -257,7 +293,8 @@
"additionalProperties": false
}
]
}
},
"description": "MCP (Model Context Protocol) server configurations"
}
},
"additionalProperties": false,
+2 -2
View File
@@ -80,9 +80,9 @@ function main() {
// Create symlink to the actual binary
fs.symlinkSync(binaryPath, binScript)
console.log(`OpenCode binary symlinked: ${binScript} -> ${binaryPath}`)
console.log(`opencode binary symlinked: ${binScript} -> ${binaryPath}`)
} catch (error) {
console.error("Failed to create OpenCode binary symlink:", error.message)
console.error("Failed to create opencode binary symlink:", error.message)
process.exit(1)
}
}
+33 -6
View File
@@ -20,11 +20,12 @@ const TOOL: Record<string, [string, string]> = {
list: ["List", UI.Style.TEXT_INFO_BOLD],
read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD],
write: ["Write", UI.Style.TEXT_SUCCESS_BOLD],
websearch: ["Search", UI.Style.TEXT_MUTED_BOLD],
}
export const RunCommand = cmd({
command: "run [message..]",
describe: "Run OpenCode with a message",
describe: "Run opencode with a message",
builder: (yargs: Argv) => {
return yargs
.positional("message", {
@@ -33,7 +34,13 @@ export const RunCommand = cmd({
array: true,
default: [],
})
.option("continue", {
alias: ["c"],
describe: "Continue the last session",
type: "boolean",
})
.option("session", {
alias: ["s"],
describe: "Session ID to continue",
type: "string",
})
@@ -41,6 +48,11 @@ export const RunCommand = cmd({
type: "boolean",
describe: "Share the session",
})
.option("model", {
type: "string",
alias: ["m"],
describe: "Model to use in the format of provider/model",
})
},
handler: async (args) => {
const message = args.message.join(" ")
@@ -50,9 +62,22 @@ export const RunCommand = cmd({
},
async () => {
await Share.init()
const session = args.session
? await Session.get(args.session)
: await Session.create()
const session = await (async () => {
if (args.continue) {
const first = await Session.list().next()
if (first.done) return
return first.value
}
if (args.session) return Session.get(args.session)
return Session.create()
})()
if (!session) {
UI.error("Session not found")
return
}
UI.empty()
UI.println(UI.logo())
@@ -65,13 +90,15 @@ export const RunCommand = cmd({
await Session.share(session.id)
UI.println(
UI.Style.TEXT_INFO_BOLD +
"~ https://dev.opencode.ai/s/" +
"~ https://opencode.ai/s/" +
session.id.slice(-8),
)
}
UI.empty()
const { providerID, modelID } = await Provider.defaultModel()
const { providerID, modelID } = args.model
? Provider.parseModel(args.model)
: await Provider.defaultModel()
UI.println(
UI.Style.TEXT_NORMAL_BOLD + "@ ",
UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`,
+4
View File
@@ -71,4 +71,8 @@ export namespace UI {
})
})
}
export function error(message: string) {
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
}
}
+38 -37
View File
@@ -44,9 +44,9 @@ export namespace Config {
export const McpLocal = z
.object({
type: z.literal("local"),
command: z.string().array(),
environment: z.record(z.string(), z.string()).optional(),
type: z.literal("local").describe("Type of MCP server connection"),
command: z.string().array().describe("Command and arguments to run the MCP server"),
environment: z.record(z.string(), z.string()).optional().describe("Environment variables to set when running the MCP server"),
})
.openapi({
ref: "Config.McpLocal",
@@ -54,8 +54,8 @@ export namespace Config {
export const McpRemote = z
.object({
type: z.literal("remote"),
url: z.string(),
type: z.literal("remote").describe("Type of MCP server connection"),
url: z.string().describe("URL of the remote MCP server"),
})
.openapi({
ref: "Config.McpRemote",
@@ -66,42 +66,42 @@ export namespace Config {
export const Keybinds = z
.object({
leader: z.string().optional(),
help: z.string().optional(),
editor_open: z.string().optional(),
session_new: z.string().optional(),
session_list: z.string().optional(),
session_share: z.string().optional(),
session_interrupt: z.string().optional(),
session_compact: z.string().optional(),
tool_details: z.string().optional(),
model_list: z.string().optional(),
theme_list: z.string().optional(),
project_init: z.string().optional(),
input_clear: z.string().optional(),
input_paste: z.string().optional(),
input_submit: z.string().optional(),
input_newline: z.string().optional(),
history_previous: z.string().optional(),
history_next: z.string().optional(),
messages_page_up: z.string().optional(),
messages_page_down: z.string().optional(),
messages_half_page_up: z.string().optional(),
messages_half_page_down: z.string().optional(),
messages_previous: z.string().optional(),
messages_next: z.string().optional(),
messages_first: z.string().optional(),
messages_last: z.string().optional(),
app_exit: z.string().optional(),
leader: z.string().optional().describe("Leader key for keybind combinations"),
help: z.string().optional().describe("Show help dialog"),
editor_open: z.string().optional().describe("Open external editor"),
session_new: z.string().optional().describe("Create a new session"),
session_list: z.string().optional().describe("List all sessions"),
session_share: z.string().optional().describe("Share current session"),
session_interrupt: z.string().optional().describe("Interrupt current session"),
session_compact: z.string().optional().describe("Toggle compact mode for session"),
tool_details: z.string().optional().describe("Show tool details"),
model_list: z.string().optional().describe("List available models"),
theme_list: z.string().optional().describe("List available themes"),
project_init: z.string().optional().describe("Initialize project configuration"),
input_clear: z.string().optional().describe("Clear input field"),
input_paste: z.string().optional().describe("Paste from clipboard"),
input_submit: z.string().optional().describe("Submit input"),
input_newline: z.string().optional().describe("Insert newline in input"),
history_previous: z.string().optional().describe("Navigate to previous history item"),
history_next: z.string().optional().describe("Navigate to next history item"),
messages_page_up: z.string().optional().describe("Scroll messages up by one page"),
messages_page_down: z.string().optional().describe("Scroll messages down by one page"),
messages_half_page_up: z.string().optional().describe("Scroll messages up by half page"),
messages_half_page_down: z.string().optional().describe("Scroll messages down by half page"),
messages_previous: z.string().optional().describe("Navigate to previous message"),
messages_next: z.string().optional().describe("Navigate to next message"),
messages_first: z.string().optional().describe("Navigate to first message"),
messages_last: z.string().optional().describe("Navigate to last message"),
app_exit: z.string().optional().describe("Exit the application"),
})
.openapi({
ref: "Config.Keybinds",
})
export const Info = z
.object({
$schema: z.string().optional(),
theme: z.string().optional(),
keybinds: Keybinds.optional(),
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
theme: z.string().optional().describe("Theme name to use for the interface"),
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
autoshare: z
.boolean()
.optional()
@@ -127,8 +127,9 @@ export namespace Config {
options: z.record(z.any()).optional(),
}),
)
.optional(),
mcp: z.record(z.string(), Mcp).optional(),
.optional()
.describe("Custom provider configurations and model overrides"),
mcp: z.record(z.string(), Mcp).optional().describe("MCP (Model Context Protocol) server configurations"),
})
.openapi({
ref: "Config.Info",
@@ -41,6 +41,10 @@ export namespace Installation {
return VERSION.startsWith("0.0.0")
}
export function isDev() {
return VERSION === "dev"
}
export async function method() {
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl"
const exec = process.execPath.toLowerCase()
+14 -1
View File
@@ -272,9 +272,14 @@ export namespace Provider {
export async function defaultModel() {
const cfg = await Config.get()
if (cfg.model) return parseModel(cfg.model)
const provider = await list()
.then((val) => Object.values(val))
.then((x) => x.find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.info.id)))
.then((x) =>
x.find(
(p) => !cfg.provider || Object.keys(cfg.provider).includes(p.info.id),
),
)
if (!provider) throw new Error("no providers found")
const [model] = sort(Object.values(provider.info.models))
if (!model) throw new Error("no models found")
@@ -284,6 +289,14 @@ export namespace Provider {
}
}
export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/")
return {
providerID: providerID,
modelID: rest.join("/"),
}
}
const TOOLS = [
BashTool,
EditTool,
+2 -1
View File
@@ -584,7 +584,7 @@ export namespace Session {
break
case "tool-call-delta":
break
continue
// for some reason ai sdk claims to not send this part but it does
// @ts-expect-error
@@ -619,6 +619,7 @@ export namespace Session {
l.info("unhandled", {
type: value.type,
})
continue
}
await updateMessage(next)
}
+5 -1
View File
@@ -1,5 +1,6 @@
import { App } from "../app/app"
import { Bus } from "../bus"
import { Installation } from "../installation"
import { Session } from "../session"
import { Storage } from "../storage/storage"
import { Log } from "../util/log"
@@ -56,7 +57,10 @@ export namespace Share {
}
export const URL =
process.env["OPENCODE_API"] ?? "https://api.dev.opencode.ai"
process.env["OPENCODE_API"] ??
(Installation.isSnapshot() || Installation.isDev()
? "https://api.dev.opencode.ai"
: "https://api.opencode.ai")
export async function create(sessionID: string) {
return fetch(`${URL}/share_create`, {
+4 -1
View File
@@ -34,13 +34,16 @@ export const TaskTool = Tool.define({
}
const unsub = Bus.subscribe(Message.Event.Updated, async (evt) => {
if (evt.properties.info.metadata.sessionID !== ctx.sessionID) return
if (evt.properties.info.metadata.sessionID !== session.id) return
ctx.metadata({
title: params.description,
summary: summary(evt.properties.info),
})
})
ctx.abort.addEventListener("abort", () => {
Session.abort(session.id)
})
const result = await Session.chat({
sessionID: session.id,
modelID: metadata.modelID,
+1 -1
View File
@@ -1,5 +1,5 @@
- Allows OpenCode to search the web and use the results to inform responses
- Allows opencode to search the web and use the results to inform responses
- Provides up-to-date information for current events and recent data
- Returns search result information formatted as search result blocks
- Use this tool for accessing information beyond Claude's knowledge cutoff
@@ -110,9 +110,6 @@ func (m *editorComponent) Content() string {
PaddingTop(1).
PaddingBottom(1).
Background(t.BackgroundElement()).
Border(lipgloss.ThickBorder(), false, true).
BorderForeground(t.BackgroundElement()).
BorderBackground(t.Background()).
Render(textarea)
hint := base("enter") + muted(" send ")
@@ -6,7 +6,6 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/textarea"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/styles"
@@ -203,13 +202,6 @@ func (c *completionDialogComponent) View() string {
return baseStyle.Padding(0, 0).
Background(t.BackgroundElement()).
Border(lipgloss.ThickBorder()).
BorderTop(false).
BorderBottom(false).
BorderRight(true).
BorderLeft(true).
BorderBackground(t.Background()).
BorderForeground(t.BackgroundElement()).
Width(c.width).
Render(c.list.View())
}
@@ -100,9 +100,9 @@ func (m *Modal) Render(contentView string, background string) string {
if m.title != "" {
titleStyle := baseStyle.
Foreground(t.Primary()).
Bold(true)
Bold(true).
Padding(0, 1)
// titleView := titleStyle.Render(m.title)
escStyle := baseStyle.Foreground(t.TextMuted()).Bold(false)
escText := escStyle.Render("esc")
@@ -123,14 +123,7 @@ func (m *Modal) Render(contentView string, background string) string {
PaddingTop(1).
PaddingBottom(1).
PaddingLeft(2).
PaddingRight(2).
BorderStyle(lipgloss.ThickBorder()).
BorderLeft(true).
BorderRight(true).
BorderLeftForeground(t.BackgroundSubtle()).
BorderLeftBackground(t.Background()).
BorderRightForeground(t.BackgroundSubtle()).
BorderRightBackground(t.Background())
PaddingRight(2)
modalView := modalStyle.
Width(outerWidth).
@@ -150,5 +143,7 @@ func (m *Modal) Render(contentView string, background string) string {
row,
modalView,
background,
layout.WithOverlayBorder(),
layout.WithOverlayBorderColor(t.Primary()),
)
}
+19 -24
View File
@@ -93,12 +93,7 @@ func (tm *ToastManager) renderSingleToast(toast Toast) string {
baseStyle := styles.BaseStyle().
Background(t.BackgroundElement()).
Foreground(t.Text()).
Padding(1, 2).
BorderStyle(lipgloss.ThickBorder()).
BorderBackground(t.Background()).
BorderForeground(toast.Color).
BorderLeft(true).
BorderRight(true)
Padding(1, 2)
maxWidth := max(40, layout.Current.Viewport.Width/3)
contentMaxWidth := max(maxWidth-6, 20)
@@ -137,9 +132,7 @@ func (tm *ToastManager) View() string {
toastViews = append(toastViews, toastView+"\n")
}
t := theme.CurrentTheme()
content := lipgloss.JoinVertical(lipgloss.Right, toastViews...)
return lipgloss.NewStyle().Background(t.Background()).Render(content)
return strings.Join(toastViews, "\n")
}
// RenderOverlay renders the toasts as an overlay on the given background
@@ -151,38 +144,40 @@ func (tm *ToastManager) RenderOverlay(background string) string {
bgWidth := lipgloss.Width(background)
bgHeight := lipgloss.Height(background)
result := background
// Start from top with 2 character padding
currentY := 2
// Render each toast individually
for _, toast := range tm.toasts {
// Render individual toast
toastView := tm.renderSingleToast(toast)
toastWidth := lipgloss.Width(toastView)
toastHeight := lipgloss.Height(toastView)
// Position at top-right with 2 character padding from right edge
x := bgWidth - toastWidth - 2
// Ensure we don't go negative
if x < 0 {
x = 0
}
x := max(bgWidth-toastWidth-4, 0)
// Check if toast fits vertically
if currentY + toastHeight > bgHeight - 2 {
if currentY+toastHeight > bgHeight-2 {
// No more room for toasts
break
}
// Place this toast
result = layout.PlaceOverlay(x, currentY, toastView, result)
result = layout.PlaceOverlay(
x,
currentY,
toastView,
result,
layout.WithOverlayBorder(),
layout.WithOverlayBorderColor(toast.Color),
)
// Move down for next toast (add 1 for spacing between toasts)
currentY += toastHeight + 1
}
return result
}
+248 -15
View File
@@ -1,9 +1,13 @@
package layout
import (
"fmt"
"regexp"
"strings"
"unicode/utf8"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
chAnsi "github.com/charmbracelet/x/ansi"
"github.com/muesli/ansi"
"github.com/muesli/reflow/truncate"
@@ -23,29 +27,58 @@ func getLines(s string) (lines []string, widest int) {
return lines, widest
}
// overlayOptions holds configuration for overlay rendering
type overlayOptions struct {
whitespace *whitespace
border bool
borderColor *compat.AdaptiveColor
}
// OverlayOption sets options for overlay rendering
type OverlayOption func(*overlayOptions)
// PlaceOverlay places fg on top of bg.
func PlaceOverlay(
x, y int,
fg, bg string,
opts ...WhitespaceOption,
opts ...OverlayOption,
) string {
fgLines, fgWidth := getLines(fg)
bgLines, bgWidth := getLines(bg)
bgHeight := len(bgLines)
fgHeight := len(fgLines)
if fgWidth >= bgWidth && fgHeight >= bgHeight {
// FIXME: return fg or bg?
return fg
// Parse options
options := &overlayOptions{
whitespace: &whitespace{},
}
for _, opt := range opts {
opt(options)
}
// TODO: allow placement outside of the bg box?
x = util.Clamp(x, 0, bgWidth-fgWidth)
y = util.Clamp(y, 0, bgHeight-fgHeight)
// Adjust for borders if enabled
if options.border {
// Add space for left and right borders
adjustedFgWidth := fgWidth + 2
// Adjust placement to account for borders
x = util.Clamp(x, 0, bgWidth-adjustedFgWidth)
y = util.Clamp(y, 0, bgHeight-fgHeight)
ws := &whitespace{}
for _, opt := range opts {
opt(ws)
// Pad all foreground lines to the same width for consistent borders
for i := range fgLines {
lineWidth := ansi.PrintableRuneWidth(fgLines[i])
if lineWidth < fgWidth {
fgLines[i] += strings.Repeat(" ", fgWidth-lineWidth)
}
}
} else {
if fgWidth >= bgWidth && fgHeight >= bgHeight {
// FIXME: return fg or bg?
return fg
}
// TODO: allow placement outside of the bg box?
x = util.Clamp(x, 0, bgWidth-fgWidth)
y = util.Clamp(y, 0, bgHeight-fgHeight)
}
var b strings.Builder
@@ -59,25 +92,62 @@ func PlaceOverlay(
}
pos := 0
// Handle left side of the line up to the overlay
if x > 0 {
left := truncate.String(bgLine, uint(x))
pos = ansi.PrintableRuneWidth(left)
b.WriteString(left)
if pos < x {
b.WriteString(ws.render(x - pos))
b.WriteString(options.whitespace.render(x - pos))
pos = x
}
}
fgLine := fgLines[i-y]
b.WriteString(fgLine)
pos += ansi.PrintableRuneWidth(fgLine)
// Render the overlay content with optional borders
if options.border {
// Get the foreground line
fgLine := fgLines[i-y]
fgLineWidth := ansi.PrintableRuneWidth(fgLine)
// Extract the styles at the border positions
leftStyle := getStyleAtPosition(bgLine, pos)
rightStyle := getStyleAtPosition(bgLine, pos + 1 + fgLineWidth)
// Left border - combine background from original with border foreground
leftSeq := combineStyles(leftStyle, options.borderColor)
if leftSeq != "" {
b.WriteString(leftSeq)
}
b.WriteString("┃")
b.WriteString("\x1b[0m") // Reset all styles
pos++
// Content
b.WriteString(fgLine)
pos += fgLineWidth
// Right border - combine background from original with border foreground
rightSeq := combineStyles(rightStyle, options.borderColor)
if rightSeq != "" {
b.WriteString(rightSeq)
}
b.WriteString("┃")
b.WriteString("\x1b[0m") // Reset all styles
pos++
} else {
// No border, just render the content
fgLine := fgLines[i-y]
b.WriteString(fgLine)
pos += ansi.PrintableRuneWidth(fgLine)
}
// Handle right side of the line after the overlay
right := cutLeft(bgLine, pos)
bgWidth := ansi.PrintableRuneWidth(bgLine)
rightWidth := ansi.PrintableRuneWidth(right)
if rightWidth <= bgWidth-pos {
b.WriteString(ws.render(bgWidth - rightWidth - pos))
b.WriteString(options.whitespace.render(bgWidth - rightWidth - pos))
}
b.WriteString(right)
@@ -92,6 +162,146 @@ func cutLeft(s string, cutWidth int) string {
return chAnsi.Cut(s, cutWidth, lipgloss.Width(s))
}
// ansiStyle represents parsed ANSI style attributes
type ansiStyle struct {
fgColor string
bgColor string
attrs []string
}
// parseANSISequence parses an ANSI escape sequence into its components
func parseANSISequence(seq string) ansiStyle {
style := ansiStyle{}
// Extract the parameters from the sequence (e.g., \x1b[38;5;123;48;5;456m -> "38;5;123;48;5;456")
if !strings.HasPrefix(seq, "\x1b[") || !strings.HasSuffix(seq, "m") {
return style
}
params := seq[2 : len(seq)-1]
if params == "" {
return style
}
parts := strings.Split(params, ";")
i := 0
for i < len(parts) {
switch parts[i] {
case "0": // Reset
style = ansiStyle{}
case "1", "2", "3", "4", "5", "6", "7", "8", "9": // Various attributes
style.attrs = append(style.attrs, parts[i])
case "38": // Foreground color
if i+1 < len(parts) && parts[i+1] == "5" && i+2 < len(parts) {
// 256 color mode
style.fgColor = strings.Join(parts[i:i+3], ";")
i += 2
} else if i+1 < len(parts) && parts[i+1] == "2" && i+4 < len(parts) {
// RGB color mode
style.fgColor = strings.Join(parts[i:i+5], ";")
i += 4
}
case "48": // Background color
if i+1 < len(parts) && parts[i+1] == "5" && i+2 < len(parts) {
// 256 color mode
style.bgColor = strings.Join(parts[i:i+3], ";")
i += 2
} else if i+1 < len(parts) && parts[i+1] == "2" && i+4 < len(parts) {
// RGB color mode
style.bgColor = strings.Join(parts[i:i+5], ";")
i += 4
}
case "30", "31", "32", "33", "34", "35", "36", "37": // Standard foreground colors
style.fgColor = parts[i]
case "40", "41", "42", "43", "44", "45", "46", "47": // Standard background colors
style.bgColor = parts[i]
case "90", "91", "92", "93", "94", "95", "96", "97": // Bright foreground colors
style.fgColor = parts[i]
case "100", "101", "102", "103", "104", "105", "106", "107": // Bright background colors
style.bgColor = parts[i]
}
i++
}
return style
}
// combineStyles creates an ANSI sequence that combines background from one style with foreground from another
func combineStyles(bgStyle ansiStyle, fgColor *compat.AdaptiveColor) string {
if fgColor == nil && bgStyle.bgColor == "" && len(bgStyle.attrs) == 0 {
return ""
}
var parts []string
// Add attributes
parts = append(parts, bgStyle.attrs...)
// Add background color from the original style
if bgStyle.bgColor != "" {
parts = append(parts, bgStyle.bgColor)
}
// Add foreground color if specified
if fgColor != nil {
// Use the light color (could be improved to detect terminal background)
color := (*fgColor).Light
// Use RGBA to get color components
r, g, b, _ := color.RGBA()
// RGBA returns 16-bit values, we need 8-bit
parts = append(parts, fmt.Sprintf("38;2;%d;%d;%d", r>>8, g>>8, b>>8))
}
if len(parts) == 0 {
return ""
}
return fmt.Sprintf("\x1b[%sm", strings.Join(parts, ";"))
}
// getStyleAtPosition extracts the active ANSI style at a given visual position
func getStyleAtPosition(s string, targetPos int) ansiStyle {
// ANSI escape sequence regex
ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
visualPos := 0
currentStyle := ansiStyle{}
i := 0
for i < len(s) && visualPos <= targetPos {
// Check if we're at an ANSI escape sequence
if match := ansiRegex.FindStringIndex(s[i:]); match != nil && match[0] == 0 {
// Found an ANSI sequence at current position
seq := s[i : i+match[1]]
parsedStyle := parseANSISequence(seq)
// Update current style (merge with existing)
if parsedStyle.fgColor != "" {
currentStyle.fgColor = parsedStyle.fgColor
}
if parsedStyle.bgColor != "" {
currentStyle.bgColor = parsedStyle.bgColor
}
if len(parsedStyle.attrs) > 0 {
currentStyle.attrs = parsedStyle.attrs
}
i += match[1]
} else if i < len(s) {
// Regular character
if visualPos == targetPos {
return currentStyle
}
_, size := utf8.DecodeRuneInString(s[i:])
i += size
visualPos++
}
}
return currentStyle
}
type whitespace struct {
style termenv.Style
chars string
@@ -129,3 +339,26 @@ func (w whitespace) render(width int) string {
// WhitespaceOption sets a styling rule for rendering whitespace.
type WhitespaceOption func(*whitespace)
// WithWhitespace sets whitespace options for the overlay
func WithWhitespace(opts ...WhitespaceOption) OverlayOption {
return func(o *overlayOptions) {
for _, opt := range opts {
opt(o.whitespace)
}
}
}
// WithOverlayBorder enables border rendering for the overlay
func WithOverlayBorder() OverlayOption {
return func(o *overlayOptions) {
o.border = true
}
}
// WithOverlayBorderColor sets the border color for the overlay
func WithOverlayBorderColor(color compat.AdaptiveColor) OverlayOption {
return func(o *overlayOptions) {
o.borderColor = &color
}
}
+1 -1
View File
@@ -8,6 +8,6 @@ var Terminal *TerminalInfo
func init() {
Terminal = &TerminalInfo{
BackgroundIsDark: false,
BackgroundIsDark: true,
}
}