Compare commits

..

11 Commits

Author SHA1 Message Date
Dax Raad 00c4d4f9f8 fix double entry of github copilot in auth login 2025-06-22 19:13:25 -04:00
Martin Palma 6e6fe6e013 Add Github Copilot OAuth authentication flow (#305) 2025-06-22 19:11:37 -04:00
Dax Raad d05b60291e docs: contributing 2025-06-22 17:55:10 -04:00
adamdottv 5162361372 fix(tui): color contrast fixes for nord 2025-06-22 15:17:18 -05:00
adamdottv d271b9f75b fix(tui): help dialog visuals 2025-06-22 14:28:16 -05:00
Márk Magyar 333569bed3 ignore: fix typos and formatting (#294) 2025-06-22 14:26:46 -04:00
Tom 09b89fdb23 fix: resolve test failures by adding missing zod-openapi import (#301)
Co-authored-by: opencode <noreply@opencode.ai>
2025-06-22 14:25:02 -04:00
Tom 0e8c3359d1 combine stdout and stderr in bash tool output (#300)
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: Dax Raad <d@ironbay.co>
2025-06-22 14:24:35 -04:00
Adam 37e0a7050f fix(tui): mouse wheel escape codes leaking into input 2025-06-22 10:26:44 -05:00
adamdottv 774dcb6980 fix(tui): cleanup help dialog 2025-06-22 06:44:23 -05:00
phantomreactor 28bc49ad17 fix: invisible html tags and compact long delay (#304) 2025-06-22 06:29:04 -05:00
24 changed files with 424 additions and 150 deletions
+2
View File
@@ -38,6 +38,8 @@ For more info on how to configure opencode [**head over to our docs**](https://o
### Contributing
For any new features we'd appreciate it if you could open an issue first to discuss what you'd like to implement. We're pretty responsive there and it'll save you from working on something that we don't end up using. No need to do this for simpler fixes.
To run opencode locally you need.
- Bun
+1
View File
@@ -1,3 +1,4 @@
import "zod-openapi/extend"
import { Log } from "../util/log"
import { Context } from "../util/context"
import { Filesystem } from "../util/filesystem"
@@ -0,0 +1,150 @@
import { z } from "zod"
import { Auth } from "./index"
import { NamedError } from "../util/error"
export namespace AuthGithubCopilot {
const CLIENT_ID = "Iv1.b507a08c87ecfe98"
const DEVICE_CODE_URL = "https://github.com/login/device/code"
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
const COPILOT_API_KEY_URL = "https://api.github.com/copilot_internal/v2/token"
interface DeviceCodeResponse {
device_code: string
user_code: string
verification_uri: string
expires_in: number
interval: number
}
interface AccessTokenResponse {
access_token?: string
error?: string
error_description?: string
}
interface CopilotTokenResponse {
token: string
expires_at: number
refresh_in: number
endpoints: {
api: string
}
}
export async function authorize() {
const deviceResponse = await fetch(DEVICE_CODE_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "GithubCopilot/1.155.0",
},
body: JSON.stringify({
client_id: CLIENT_ID,
scope: "read:user",
}),
})
const deviceData: DeviceCodeResponse = await deviceResponse.json()
return {
device: deviceData.device_code,
user: deviceData.user_code,
verification: deviceData.verification_uri,
interval: deviceData.interval || 5,
expiry: deviceData.expires_in,
}
}
export async function poll(device_code: string) {
const response = await fetch(ACCESS_TOKEN_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "GithubCopilot/1.155.0",
},
body: JSON.stringify({
client_id: CLIENT_ID,
device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
})
if (!response.ok) return "failed"
const data: AccessTokenResponse = await response.json()
if (data.access_token) {
// Store the GitHub OAuth token
await Auth.set("github-copilot", {
type: "oauth",
refresh: data.access_token,
access: "",
expires: 0,
})
return "complete"
}
if (data.error === "authorization_pending") return "pending"
if (data.error) return "failed"
return "pending"
}
export async function access() {
const info = await Auth.get("github-copilot")
if (!info || info.type !== "oauth") return
if (info.access && info.expires > Date.now()) return info.access
// Get new Copilot API token
const response = await fetch(COPILOT_API_KEY_URL, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${info.refresh}`,
"User-Agent": "GithubCopilot/1.155.0",
"Editor-Version": "vscode/1.85.1",
"Editor-Plugin-Version": "copilot/1.155.0",
},
})
if (!response.ok) return
const tokenData: CopilotTokenResponse = await response.json()
// Store the Copilot API token
await Auth.set("github-copilot", {
type: "oauth",
refresh: info.refresh,
access: tokenData.token,
expires: tokenData.expires_at * 1000,
})
return tokenData.token
}
export const DeviceCodeError = NamedError.create(
"DeviceCodeError",
z.object({}),
)
export const TokenExchangeError = NamedError.create(
"TokenExchangeError",
z.object({
message: z.string(),
}),
)
export const AuthenticationError = NamedError.create(
"AuthenticationError",
z.object({
message: z.string(),
}),
)
export const CopilotTokenError = NamedError.create(
"CopilotTokenError",
z.object({
message: z.string(),
}),
)
}
+36 -3
View File
@@ -1,4 +1,5 @@
import { AuthAnthropic } from "../../auth/anthropic"
import { AuthGithubCopilot } from "../../auth/github-copilot"
import { Auth } from "../../auth"
import { cmd } from "./cmd"
import * as prompts from "@clack/prompts"
@@ -16,7 +17,7 @@ export const AuthCommand = cmd({
.command(AuthLogoutCommand)
.command(AuthListCommand)
.demandCommand(),
async handler() { },
async handler() {},
})
export const AuthListCommand = cmd({
@@ -47,8 +48,9 @@ export const AuthLoginCommand = cmd({
const providers = await ModelsDev.get()
const priority: Record<string, number> = {
anthropic: 0,
openai: 1,
google: 2,
"github-copilot": 1,
openai: 2,
google: 3,
}
let provider = await prompts.select({
message: "Select provider",
@@ -146,6 +148,37 @@ export const AuthLoginCommand = cmd({
}
}
if (provider === "github-copilot") {
await new Promise((resolve) => setTimeout(resolve, 10))
const deviceInfo = await AuthGithubCopilot.authorize()
prompts.note(
`Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`,
)
const spinner = prompts.spinner()
spinner.start("Waiting for authorization...")
while (true) {
await new Promise((resolve) =>
setTimeout(resolve, deviceInfo.interval * 1000),
)
const status = await AuthGithubCopilot.poll(deviceInfo.device)
if (status === "pending") continue
if (status === "complete") {
spinner.stop("Login successful")
break
}
if (status === "failed") {
spinner.stop("Failed to authorize", 1)
break
}
}
prompts.outro("Done")
return
}
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x.length > 0 ? undefined : "Required"),
@@ -1,20 +0,0 @@
import { AuthAnthropic } from "../../auth/anthropic"
import { UI } from "../ui"
// Example: https://claude.ai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fconsole.anthropic.com%2Foauth%2Fcode%2Fcallback&scope=org%3Acreate_api_key+user%3Aprofile+user%3Ainference&code_challenge=MdFtFgFap23AWDSN0oa3-eaKjQRFE4CaEhXx8M9fHZg&code_challenge_method=S256&state=rKLtaDzm88GSwekyEqdi0wXX-YqIr13tSzYymSzpvfs
export const LoginAnthropicCommand = {
command: "anthropic",
describe: "Login to Anthropic",
handler: async () => {
const { url, verifier } = await AuthAnthropic.authorize()
UI.println("Login to Anthropic")
UI.println("Open the following URL in your browser:")
UI.println(url)
UI.println("")
const code = await UI.input("Paste the authorization code here: ")
await AuthAnthropic.exchange(code, verifier)
},
}
+38 -1
View File
@@ -19,6 +19,7 @@ import type { Tool } from "../tool/tool"
import { WriteTool } from "../tool/write"
import { TodoReadTool, TodoWriteTool } from "../tool/todo"
import { AuthAnthropic } from "../auth/anthropic"
import { AuthGithubCopilot } from "../auth/github-copilot"
import { ModelsDev } from "./models"
import { NamedError } from "../util/error"
import { Auth } from "../auth"
@@ -66,6 +67,41 @@ export namespace Provider {
},
}
},
"github-copilot": async (provider) => {
const info = await AuthGithubCopilot.access()
if (!info) return false
if (provider && provider.models) {
for (const model of Object.values(provider.models)) {
model.cost = {
input: 0,
output: 0,
}
}
}
return {
options: {
apiKey: "",
async fetch(input: any, init: any) {
const token = await AuthGithubCopilot.access()
if (!token) throw new Error("GitHub Copilot authentication expired")
const headers = {
...init.headers,
Authorization: `Bearer ${token}`,
"User-Agent": "GithubCopilot/1.155.0",
"Editor-Version": "vscode/1.85.1",
"Editor-Plugin-Version": "copilot/1.155.0",
}
delete headers["x-api-key"]
return fetch(input, {
...init,
headers,
})
},
},
}
},
openai: async () => {
return {
async getModel(sdk: any, modelID: string) {
@@ -208,8 +244,9 @@ export namespace Provider {
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
if (disabled.has(providerID)) continue
const result = await fn(database[providerID])
if (result)
if (result) {
mergeProvider(providerID, result.options, "custom", result.getModel)
}
}
// load config
+45 -13
View File
@@ -248,7 +248,7 @@ export namespace Session {
if (
model.info.limit.context &&
tokens >
(model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9
(model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9
) {
await summarize({
sessionID: input.sessionID,
@@ -295,7 +295,7 @@ export namespace Session {
draft.title = result.text
})
})
.catch(() => {})
.catch(() => { })
}
const msg: Message.Info = {
role: "user",
@@ -572,7 +572,7 @@ export namespace Session {
case "tool-call": {
const [match] = next.parts.flatMap((p) =>
p.type === "tool-invocation" &&
p.toolInvocation.toolCallId === value.toolCallId
p.toolInvocation.toolCallId === value.toolCallId
? [p]
: [],
)
@@ -736,7 +736,9 @@ export namespace Session {
},
}
await updateMessage(next)
const result = await generateText({
let text: Message.TextPart | undefined
const result = streamText({
abortSignal: abort.signal,
model: model.language,
messages: [
@@ -757,16 +759,46 @@ export namespace Session {
],
},
],
onStepFinish: async (step) => {
const assistant = next.metadata!.assistant!
const usage = getUsage(model.info, step.usage, step.providerMetadata)
assistant.cost += usage.cost
assistant.tokens = usage.tokens
await updateMessage(next)
if (text) {
Bus.publish(Message.Event.PartUpdated, {
part: text,
messageID: next.id,
sessionID: next.metadata.sessionID,
})
}
text = undefined
},
async onFinish(input) {
const assistant = next.metadata!.assistant!
const usage = getUsage(model.info, input.usage, input.providerMetadata)
assistant.cost = usage.cost
assistant.tokens = usage.tokens
next.metadata!.time.completed = Date.now()
await updateMessage(next)
},
})
next.parts.push({
type: "text",
text: result.text,
})
const assistant = next.metadata!.assistant!
const usage = getUsage(model.info, result.usage, result.providerMetadata)
assistant.cost = usage.cost
assistant.tokens = usage.tokens
await updateMessage(next)
for await (const value of result.fullStream) {
switch (value.type) {
case "text-delta":
if (!text) {
text = {
type: "text",
text: value.textDelta,
}
next.parts.push(text)
} else text.text += value.textDelta
await updateMessage(next)
break
}
}
}
function lock(sessionID: string) {
+9 -1
View File
@@ -63,10 +63,18 @@ export const BashTool = Tool.define({
metadata: {
stderr,
stdout,
exit: process.exitCode,
description: params.description,
title: params.command,
},
output: stdout.replaceAll(/\x1b\[[0-9;]*m/g, ""),
output: [
`<stdout>`,
stdout ?? "",
`</stdout>`,
`<stderr>`,
stderr ?? "",
`</stderr>`,
].join("\n"),
}
},
})
+1 -1
View File
@@ -22,7 +22,7 @@ export const EditTool = Tool.define({
replaceAll: z
.boolean()
.optional()
.describe("Replace all occurences of old_string (default false)"),
.describe("Replace all occurrences of old_string (default false)"),
}),
async execute(params, ctx) {
if (!params.filePath) {
+1 -1
View File
@@ -10,7 +10,7 @@ To make multiple file edits, provide the following:
2. edits: An array of edit operations to perform, where each edit contains:
- old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation)
- new_string: The edited text to replace the old_string
- replace_all: Replace all occurences of old_string. This parameter is optional and defaults to false.
- replace_all: Replace all occurrences of old_string. This parameter is optional and defaults to false.
IMPORTANT:
- All edits are applied in sequence, in the order they are provided
+2 -2
View File
@@ -14,7 +14,7 @@ describe("tool.glob", () => {
await App.provide({ cwd: process.cwd() }, async () => {
let result = await GlobTool.execute(
{
pattern: "./node_modules/**/*",
pattern: "../../node_modules/**/*",
path: undefined,
},
ctx,
@@ -33,7 +33,7 @@ describe("tool.glob", () => {
)
expect(result.metadata).toMatchObject({
truncated: false,
count: 2,
count: 3,
})
})
})
+14 -12
View File
@@ -44,7 +44,7 @@ type SendMsg struct {
Text string
Attachments []Attachment
}
type CompletionDialogTriggerdMsg struct {
type CompletionDialogTriggeredMsg struct {
InitialValue string
}
@@ -252,17 +252,19 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
}
func (a *App) CompactSession(ctx context.Context) tea.Cmd {
response, err := a.Client.PostSessionSummarizeWithResponse(ctx, client.PostSessionSummarizeJSONRequestBody{
SessionID: a.Session.Id,
ProviderID: a.Provider.Id,
ModelID: a.Model.Id,
})
if err != nil {
slog.Error("Failed to compact session", "error", err)
}
if response != nil && response.StatusCode() != 200 {
slog.Error("Failed to compact session", "error", response.StatusCode)
}
go func() {
response, err := a.Client.PostSessionSummarizeWithResponse(ctx, client.PostSessionSummarizeJSONRequestBody{
SessionID: a.Session.Id,
ProviderID: a.Provider.Id,
ModelID: a.Model.Id,
})
if err != nil {
slog.Error("Failed to compact session", "error", err)
}
if response != nil && response.StatusCode() != 200 {
slog.Error("Failed to compact session", "error", response.StatusCode)
}
}()
return nil
}
@@ -25,6 +25,8 @@ import (
func toMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string {
r := styles.GetMarkdownRenderer(width, backgroundColor)
content = strings.ReplaceAll(content, app.RootPath+"/", "")
content = strings.ReplaceAll(content, "<", "\\<")
content = strings.ReplaceAll(content, ">", "\\>")
rendered, _ := r.Render(content)
lines := strings.Split(rendered, "\n")
@@ -44,7 +46,6 @@ func toMarkdown(content string, width int, backgroundColor compat.AdaptiveColor)
}
}
}
content = strings.Join(lines, "\n")
return strings.TrimSuffix(content, "\n")
}
@@ -25,6 +25,7 @@ type commandsComponent struct {
app *app.App
width, height int
showKeybinds bool
showAll bool
background *compat.AdaptiveColor
limit *int
}
@@ -75,17 +76,32 @@ func (c *commandsComponent) View() string {
keybindStyle = keybindStyle.Background(*c.background)
}
var commandsWithTriggers []commands.Command
var commandsToShow []commands.Command
var triggeredCommands []commands.Command
var untriggeredCommands []commands.Command
for _, cmd := range c.app.Commands.Sorted() {
if cmd.Trigger != "" {
commandsWithTriggers = append(commandsWithTriggers, cmd)
if c.showAll || cmd.Trigger != "" {
if cmd.Trigger != "" {
triggeredCommands = append(triggeredCommands, cmd)
} else if c.showAll {
untriggeredCommands = append(untriggeredCommands, cmd)
}
}
}
if c.limit != nil && len(commandsWithTriggers) > *c.limit {
commandsWithTriggers = commandsWithTriggers[:*c.limit]
// Combine triggered commands first, then untriggered
commandsToShow = append(commandsToShow, triggeredCommands...)
commandsToShow = append(commandsToShow, untriggeredCommands...)
if c.limit != nil && len(commandsToShow) > *c.limit {
commandsToShow = commandsToShow[:*c.limit]
}
if len(commandsWithTriggers) == 0 {
if len(commandsToShow) == 0 {
if c.showAll {
return styles.Muted().Render("No commands available")
}
return styles.Muted().Render("No commands with triggers available")
}
@@ -101,10 +117,15 @@ func (c *commandsComponent) View() string {
keybinds string
}
rows := make([]commandRow, 0, len(commandsWithTriggers))
rows := make([]commandRow, 0, len(commandsToShow))
for _, cmd := range commandsWithTriggers {
trigger := "/" + cmd.Trigger
for _, cmd := range commandsToShow {
trigger := ""
if cmd.Trigger != "" {
trigger = "/" + cmd.Trigger
} else {
trigger = string(cmd.Name)
}
description := cmd.Description
// Format keybindings
@@ -144,6 +165,7 @@ func (c *commandsComponent) View() string {
// Build the output
var output strings.Builder
maxWidth := 0
for _, row := range rows {
// Pad each column to align properly
trigger := fmt.Sprintf("%-*s", maxTriggerWidth, row.trigger)
@@ -160,10 +182,14 @@ func (c *commandsComponent) View() string {
}
output.WriteString(line + "\n")
maxWidth = max(maxWidth, lipgloss.Width(line))
}
// Remove trailing newline
result := strings.TrimSuffix(output.String(), "\n")
if c.background != nil {
result = lipgloss.NewStyle().Background(c.background).Width(maxWidth).Render(result)
}
return result
}
@@ -188,11 +214,18 @@ func WithLimit(limit int) Option {
}
}
func WithShowAll(showAll bool) Option {
return func(c *commandsComponent) {
c.showAll = showAll
}
}
func New(app *app.App, opts ...Option) CommandsComponent {
c := &commandsComponent{
app: app,
background: nil,
showKeybinds: true,
showAll: false,
}
for _, opt := range opts {
opt(c)
@@ -116,7 +116,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case []CompletionItemI:
c.list.SetItems(msg)
case app.CompletionDialogTriggerdMsg:
case app.CompletionDialogTriggeredMsg:
c.pseudoSearchTextArea.SetValue(msg.InitialValue)
case tea.KeyMsg:
if c.pseudoSearchTextArea.Focused() {
+43 -45
View File
@@ -1,71 +1,62 @@
package dialog
import (
"strings"
"github.com/charmbracelet/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/commands"
"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"
)
type helpDialog struct {
width int
height int
modal *modal.Modal
commands []commands.Command
width int
height int
modal *modal.Modal
app *app.App
commandsComponent commandsComponent.CommandsComponent
viewport viewport.Model
}
func (h *helpDialog) Init() tea.Cmd {
return nil
return tea.Batch(
h.commandsComponent.Init(),
h.viewport.Init(),
)
}
func (h *helpDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
h.width = msg.Width
h.height = msg.Height
// Set viewport size with some padding for the modal
h.viewport = viewport.New(viewport.WithWidth(msg.Width-4), viewport.WithHeight(msg.Height-6))
h.commandsComponent.SetSize(msg.Width-4, msg.Height-6)
}
return h, nil
// Update commands component first to get the latest content
_, cmdCmd := h.commandsComponent.Update(msg)
cmds = append(cmds, cmdCmd)
// Update viewport content
h.viewport.SetContent(h.commandsComponent.View())
// Update viewport
var vpCmd tea.Cmd
h.viewport, vpCmd = h.viewport.Update(msg)
cmds = append(cmds, vpCmd)
return h, tea.Batch(cmds...)
}
func (h *helpDialog) View() string {
t := theme.CurrentTheme()
keyStyle := lipgloss.NewStyle().
Background(t.BackgroundElement()).
Foreground(t.Text()).
Bold(true)
descStyle := lipgloss.NewStyle().
Background(t.BackgroundElement()).
Foreground(t.TextMuted())
contentStyle := lipgloss.NewStyle().
PaddingLeft(1).Background(t.BackgroundElement())
lines := []string{}
for _, b := range h.commands {
// Only interested in slash commands
if b.Trigger == "" {
continue
}
content := keyStyle.Render("/" + b.Trigger)
content += descStyle.Render(" " + b.Description)
// for i, key := range b.Keybindings {
// if i == 0 {
// keyString := " (" + key.Key + ")"
// space := max(h.width-lipgloss.Width(content)-lipgloss.Width(keyString), 0)
// spacer := strings.Repeat(" ", space)
// content += descStyle.Render(spacer)
// content += descStyle.Render(keyString)
// }
// }
lines = append(lines, contentStyle.Render(content))
}
return strings.Join(lines, "\n")
h.commandsComponent.SetBackgroundColor(t.BackgroundElement())
return h.viewport.View()
}
func (h *helpDialog) Render(background string) string {
@@ -80,9 +71,16 @@ type HelpDialog interface {
layout.Modal
}
func NewHelpDialog(commands []commands.Command) HelpDialog {
func NewHelpDialog(app *app.App) HelpDialog {
vp := viewport.New(viewport.WithHeight(12))
return &helpDialog{
commands: commands,
app: app,
commandsComponent: commandsComponent.New(app,
commandsComponent.WithBackground(theme.CurrentTheme().BackgroundElement()),
commandsComponent.WithShowAll(true),
commandsComponent.WithKeybinds(true),
),
modal: modal.New(modal.WithTitle("Help")),
viewport: vp,
}
}
@@ -5,8 +5,8 @@ package image
import (
"bytes"
"fmt"
"image"
"github.com/atotto/clipboard"
"image"
)
func GetImageFromClipboard() ([]byte, string, error) {
@@ -28,8 +28,6 @@ func GetImageFromClipboard() ([]byte, string, error) {
}
func binaryToImage(data []byte) ([]byte, error) {
reader := bytes.NewReader(data)
img, _, err := image.Decode(reader)
@@ -40,7 +38,6 @@ func binaryToImage(data []byte) ([]byte, error) {
return ImageToBytes(img)
}
func min(a, b int) int {
if a < b {
return a
+19 -19
View File
@@ -109,11 +109,11 @@ func PlaceOverlay(
// 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)
rightStyle := getStyleAtPosition(bgLine, pos+1+fgLineWidth)
// Left border - combine background from original with border foreground
leftSeq := combineStyles(leftStyle, options.borderColor)
if leftSeq != "" {
@@ -172,17 +172,17 @@ type ansiStyle struct {
// 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) {
@@ -222,7 +222,7 @@ func parseANSISequence(seq string) ansiStyle {
}
i++
}
return style
}
@@ -231,32 +231,32 @@ 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, ";"))
}
@@ -264,10 +264,10 @@ func combineStyles(bgStyle ansiStyle, fgColor *compat.AdaptiveColor) string {
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
@@ -275,7 +275,7 @@ func getStyleAtPosition(s string, targetPos int) ansiStyle {
// 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
@@ -286,7 +286,7 @@ func getStyleAtPosition(s string, targetPos int) ansiStyle {
if len(parsedStyle.attrs) > 0 {
currentStyle.attrs = parsedStyle.attrs
}
i += match[1]
} else if i < len(s) {
// Regular character
@@ -298,7 +298,7 @@ func getStyleAtPosition(s string, targetPos int) ansiStyle {
visualPos++
}
}
return currentStyle
}
@@ -133,4 +133,3 @@ func TestLoadThemesFromDirectories(t *testing.T) {
t.Error("Override theme not properly loaded")
}
}
+8 -8
View File
@@ -48,11 +48,11 @@
"light": "nord10"
},
"text": {
"dark": "nord4",
"dark": "nord6",
"light": "nord0"
},
"textMuted": {
"dark": "nord3",
"dark": "#8B95A7",
"light": "nord1"
},
"background": {
@@ -64,7 +64,7 @@
"light": "nord5"
},
"backgroundElement": {
"dark": "nord1",
"dark": "nord2",
"light": "nord4"
},
"border": {
@@ -88,11 +88,11 @@
"light": "nord11"
},
"diffContext": {
"dark": "nord3",
"dark": "#8B95A7",
"light": "nord3"
},
"diffHunkHeader": {
"dark": "nord3",
"dark": "#8B95A7",
"light": "nord3"
},
"diffHighlightAdded": {
@@ -148,7 +148,7 @@
"light": "nord14"
},
"markdownBlockQuote": {
"dark": "nord3",
"dark": "#8B95A7",
"light": "nord3"
},
"markdownEmph": {
@@ -160,7 +160,7 @@
"light": "nord13"
},
"markdownHorizontalRule": {
"dark": "nord3",
"dark": "#8B95A7",
"light": "nord3"
},
"markdownListItem": {
@@ -184,7 +184,7 @@
"light": "nord0"
},
"syntaxComment": {
"dark": "nord3",
"dark": "#8B95A7",
"light": "nord3"
},
"syntaxKeyword": {
+4 -3
View File
@@ -117,7 +117,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
updated, cmd := a.completions.Update(
app.CompletionDialogTriggerdMsg{
app.CompletionDialogTriggeredMsg{
InitialValue: initialValue,
},
)
@@ -178,7 +178,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// 7. Fallback to editor. This is for other characters
// likek backspace, tab, etc.
// like backspace, tab, etc.
updatedEditor, cmd := a.editor.Update(msg)
a.editor = updatedEditor.(chat.EditorComponent)
return a, cmd
@@ -189,6 +189,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
updated, cmd := a.messages.Update(msg)
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
return a, tea.Batch(cmds...)
case tea.BackgroundColorMsg:
styles.Terminal = &styles.TerminalInfo{
BackgroundIsDark: msg.IsDark(),
@@ -363,7 +364,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
}
switch command.Name {
case commands.AppHelpCommand:
helpDialog := dialog.NewHelpDialog(a.app.Commands.Sorted())
helpDialog := dialog.NewHelpDialog(a.app)
a.modal = helpDialog
case commands.EditorOpenCommand:
if a.app.IsBusy() {
+1 -1
View File
@@ -603,7 +603,7 @@ func (a *MessageInfo_Metadata_Tool_AdditionalProperties) UnmarshalJSON(b []byte)
var fieldVal interface{}
err := json.Unmarshal(fieldBuf, &fieldVal)
if err != nil {
return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err)
return fmt.Errorf("error unmarshalling field %s: %w", fieldName, err)
}
a.AdditionalProperties[fieldName] = fieldVal
}
+2 -2
View File
@@ -444,7 +444,7 @@ function MarkdownPart(props: MarkdownPartProps) {
{...rest}
>
<MarkdownView
data-elment-markdown
data-element-markdown
markdown={local.text}
ref={(el) => (divEl = el)}
/>
@@ -726,7 +726,7 @@ export default function Share(props: {
for (let i = 0; i < messages().length; i++) {
const msg = messages()[i]
// TODO: Cleaup
// TODO: Cleanup
// const system = result.messages.length === 0 && msg.role === "system"
const assistant = msg.metadata?.assistant
+2 -2
View File
@@ -659,12 +659,12 @@
}
&[data-expanded="true"] {
[data-elment-markdown] {
[data-element-markdown] {
display: block;
}
}
&[data-expanded="false"] {
[data-elment-markdown] {
[data-element-markdown] {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;