Compare commits

...

13 Commits

Author SHA1 Message Date
adamdottv c389e0ed43 fix(tui): redundant tool calls in each message in collapsed mode 2025-07-03 10:42:27 -05:00
Dax Raad 204801052a flag for disabling file watcher 2025-07-03 10:37:08 -04:00
Dax Raad 2528d8cb88 increase max retries to 10 2025-07-03 10:32:55 -04:00
adamdottv 6b73ffd1c1 fix(tui): include orphaned tool calls 2025-07-03 09:32:44 -05:00
adamdottv 0eadc50a33 fix(tui): selected message visuals 2025-07-03 09:03:04 -05:00
Dax Raad aeea84a877 fix webdomain 2025-07-03 09:58:25 -04:00
GitHub Action a54c5c6298 Update download stats 2025-07-03 2025-07-03 12:26:51 +00:00
adamdottv 8825cd3811 feat(tui): unshare command 2025-07-03 07:09:09 -05:00
adamdottv 3d9a5d9970 fix(tui): always show status bar 2025-07-03 06:53:05 -05:00
adamdottv 1f9e195fa6 fix(tui): better highlight visuals 2025-07-03 06:49:37 -05:00
Craig Andrews 73c012c76c fix: simplify parallel map using channels (#582) 2025-07-03 05:43:10 -05:00
Lev 2ace57404b fix: properly handle utf-8 in diff highlighting (#585) 2025-07-03 05:42:40 -05:00
Dax Raad 8c4b5e088b do not install gopls if go is not installed 2025-07-02 23:59:08 -04:00
16 changed files with 210 additions and 128 deletions
+1
View File
@@ -6,3 +6,4 @@
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
+3
View File
@@ -9,6 +9,9 @@ const bucket = new sst.cloudflare.Bucket("Bucket")
export const api = new sst.cloudflare.Worker("Api", {
domain: `api.${domain}`,
handler: "packages/function/src/api.ts",
environment: {
WEB_DOMAIN: domain,
},
url: true,
link: [bucket],
transform: {
+2 -1
View File
@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto"
type Env = {
SYNC_SERVER: DurableObjectNamespace<SyncServer>
Bucket: R2Bucket
WEB_DOMAIN: string
}
export class SyncServer extends DurableObject<Env> {
@@ -127,7 +128,7 @@ export default {
return new Response(
JSON.stringify({
secret,
url: "https://opencode.ai/s/" + short,
url: `https://${env.WEB_DOMAIN}/s/${short}`,
}),
{
headers: { "Content-Type": "application/json" },
+33 -30
View File
@@ -3,6 +3,7 @@ import { Bus } from "../bus"
import fs from "fs"
import { App } from "../app/app"
import { Log } from "../util/log"
import { Flag } from "../flag/flag"
export namespace FileWatcher {
const log = Log.create({ service: "file.watcher" })
@@ -16,37 +17,39 @@ export namespace FileWatcher {
}),
),
}
const state = App.state(
"file.watcher",
() => {
const app = App.use()
try {
const watcher = fs.watch(
app.info.path.cwd,
{ recursive: true },
(event, file) => {
log.info("change", { file, event })
if (!file) return
// for some reason async local storage is lost here
// https://github.com/oven-sh/bun/issues/20754
App.provideExisting(app, async () => {
Bus.publish(Event.Updated, {
file,
event,
})
})
},
)
return { watcher }
} catch {
return {}
}
},
async (state) => {
state.watcher?.close()
},
)
export function init() {
App.state(
"file.watcher",
() => {
const app = App.use()
try {
const watcher = fs.watch(
app.info.path.cwd,
{ recursive: true },
(event, file) => {
log.info("change", { file, event })
if (!file) return
// for some reason async local storage is lost here
// https://github.com/oven-sh/bun/issues/20754
App.provideExisting(app, async () => {
Bus.publish(Event.Updated, {
file,
event,
})
})
},
)
return { watcher }
} catch {
return {}
}
},
async (state) => {
state.watcher?.close()
},
)()
if (Flag.OPENCODE_DISABLE_WATCHER) return
state()
}
}
+1
View File
@@ -1,5 +1,6 @@
export namespace Flag {
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
export const OPENCODE_DISABLE_WATCHER = truthy("OPENCODE_DISABLE_WATCHER")
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
+1
View File
@@ -57,6 +57,7 @@ export namespace LSPServer {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})
if (!bin) {
if (!Bun.which("go")) return
log.info("installing gopls")
const proc = Bun.spawn({
cmd: ["go", "install", "golang.org/x/tools/gopls@latest"],
+1
View File
@@ -583,6 +583,7 @@ export namespace Session {
// return step
// },
toolCallStreaming: true,
maxRetries: 10,
maxTokens: Math.max(0, model.info.limit.output) || undefined,
abortSignal: abort.signal,
maxSteps: 1000,
+4 -1
View File
@@ -67,9 +67,12 @@ export namespace Share {
}
export async function remove(id: string) {
const share = await Session.getShare(id).catch(() => {})
if (!share) return
const { secret } = share
return fetch(`${URL}/share_delete`, {
method: "POST",
body: JSON.stringify({ id }),
body: JSON.stringify({ id, secret }),
}).then((x) => x.json())
}
}
+8 -1
View File
@@ -75,6 +75,7 @@ const (
SessionNewCommand CommandName = "session_new"
SessionListCommand CommandName = "session_list"
SessionShareCommand CommandName = "session_share"
SessionUnshareCommand CommandName = "session_unshare"
SessionInterruptCommand CommandName = "session_interrupt"
SessionCompactCommand CommandName = "session_compact"
ToolDetailsCommand CommandName = "tool_details"
@@ -160,6 +161,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Keybindings: parseBindings("<leader>s"),
Trigger: "share",
},
{
Name: SessionUnshareCommand,
Description: "unshare session",
Keybindings: parseBindings("<leader>u"),
Trigger: "unshare",
},
{
Name: SessionInterruptCommand,
Description: "interrupt session",
@@ -289,7 +296,7 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
{
Name: MessagesRevertCommand,
Description: "revert message",
Keybindings: parseBindings("<leader>u"),
Keybindings: parseBindings("<leader>r"),
},
{
Name: AppExitCommand,
@@ -162,18 +162,16 @@ func renderContentBlock(
if highlight {
style = style.
BorderLeftBackground(t.Primary()).
BorderLeftForeground(t.Primary()).
BorderRightForeground(t.Primary()).
BorderRightBackground(t.Primary())
BorderLeftForeground(borderColor).
BorderRightForeground(borderColor)
}
}
if highlight {
style = style.
Foreground(t.Text()).
Bold(true).
Background(t.BackgroundElement())
Background(t.BackgroundElement()).
Bold(true)
}
content = style.Render(content)
@@ -211,7 +209,7 @@ func renderContentBlock(
)
header = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(header)
content = "\n\n\n" + header + "\n\n" + content + "\n\n"
content = "\n\n\n" + header + "\n\n" + content + "\n\n\n"
}
return content
@@ -229,7 +227,9 @@ func renderText(
) string {
t := theme.CurrentTheme()
timestamp := time.UnixMilli(int64(message.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
timestamp := time.UnixMilli(int64(message.Metadata.Time.Created)).
Local().
Format("02 Jan 2006 03:04 PM")
if time.Now().Format("02 Jan 2006") == timestamp[:11] {
// don't show the date if it's today
timestamp = timestamp[12:]
@@ -338,8 +338,10 @@ func renderToolDetails(
finished := result != nil && *result != ""
t := theme.CurrentTheme()
backgroundColor := t.BackgroundPanel()
borderColor := t.BackgroundPanel()
if highlight {
backgroundColor = t.BackgroundElement()
borderColor = t.BorderActive()
}
switch toolCall.ToolInvocation.ToolName {
@@ -362,7 +364,11 @@ func renderToolDetails(
diff.WithWidth(width-2),
)
body = strings.TrimSpace(formattedDiff)
style := styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Padding(1, 2).Width(width - 4)
style := styles.NewStyle().
Background(backgroundColor).
Foreground(t.TextMuted()).
Padding(1, 2).
Width(width - 4)
if highlight {
style = style.Foreground(t.Text()).Bold(true)
}
@@ -375,7 +381,14 @@ func renderToolDetails(
title := renderToolTitle(toolCall, messageMetadata, width)
title = style.Render(title)
content := title + "\n" + body
content = renderContentBlock(app, content, highlight, width, WithPadding(0))
content = renderContentBlock(
app,
content,
highlight,
width,
WithPadding(0),
WithBorderColor(borderColor),
)
return content
}
}
@@ -478,7 +491,7 @@ func renderToolDetails(
title := renderToolTitle(toolCall, messageMetadata, width)
content := title + "\n\n" + body
return renderContentBlock(app, content, highlight, width)
return renderContentBlock(app, content, highlight, width, WithBorderColor(borderColor))
}
func renderToolName(name string) string {
@@ -489,8 +502,8 @@ func renderToolName(name string) string {
return "Plan"
default:
normalizedName := name
if strings.HasPrefix(name, "opencode_") {
normalizedName = strings.TrimPrefix(name, "opencode_")
if after, ok := strings.CutPrefix(name, "opencode_"); ok {
normalizedName = after
}
return cases.Title(language.Und).String(normalizedName)
}
@@ -651,7 +664,10 @@ func renderDiagnostics(metadata opencode.MessageMetadataTool, filePath string) s
}
line := diag.Range.Start.Line + 1 // 1-based
column := diag.Range.Start.Character + 1 // 1-based
errorDiagnostics = append(errorDiagnostics, fmt.Sprintf("Error [%d:%d] %s", line, column, diag.Message))
errorDiagnostics = append(
errorDiagnostics,
fmt.Sprintf("Error [%d:%d] %s", line, column, diag.Message),
)
}
if len(errorDiagnostics) == 0 {
return ""
@@ -80,15 +80,11 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.showToolDetails = !m.showToolDetails
m.rendering = true
return m, m.Reload()
case app.SessionLoadedMsg:
case app.SessionLoadedMsg, app.SessionClearedMsg:
m.cache.Clear()
m.tail = true
m.rendering = true
return m, m.Reload()
case app.SessionClearedMsg:
m.cache.Clear()
m.rendering = true
return m, m.Reload()
case renderFinishedMsg:
m.rendering = false
if m.tail {
@@ -129,6 +125,8 @@ func (m *messagesComponent) renderView(width int) {
m.partCount = 0
m.lineCount = 0
orphanedToolCalls := make([]opencode.ToolInvocationPart, 0)
for _, message := range m.app.Messages {
var content string
var cached bool
@@ -153,30 +151,39 @@ func (m *messagesComponent) renderView(width int) {
m.cache.Set(key, content)
}
if content != "" {
if m.selectedPart == m.partCount {
m.viewport.SetYOffset(m.lineCount - 4)
m.selectedText = part.Text
}
m = m.updateSelected(content, part.Text)
blocks = append(blocks, content)
m.partCount++
m.lineCount += lipgloss.Height(content) + 1
}
}
}
case opencode.MessageRoleAssistant:
for i, p := range message.Parts {
hasTextPart := false
for partIndex, p := range message.Parts {
switch part := p.AsUnion().(type) {
case opencode.TextPart:
hasTextPart = true
finished := message.Metadata.Time.Completed > 0
remainingParts := message.Parts[i+1:]
remainingParts := message.Parts[partIndex+1:]
toolCallParts := make([]opencode.ToolInvocationPart, 0)
// sometimes tool calls happen without an assistant message
// these should be included in this assistant message as well
if len(orphanedToolCalls) > 0 {
toolCallParts = append(toolCallParts, orphanedToolCalls...)
orphanedToolCalls = make([]opencode.ToolInvocationPart, 0)
}
remaining := true
for _, part := range remainingParts {
if !remaining {
break
}
switch part := part.AsUnion().(type) {
case opencode.TextPart:
// we only want tool calls associated with the current text part.
// if we hit another text part, we're done.
break
remaining = false
case opencode.ToolInvocationPart:
toolCallParts = append(toolCallParts, part)
if part.ToolInvocation.State != "result" {
@@ -216,16 +223,14 @@ func (m *messagesComponent) renderView(width int) {
)
}
if content != "" {
if m.selectedPart == m.partCount {
m.viewport.SetYOffset(m.lineCount - 4)
m.selectedText = p.Text
}
m = m.updateSelected(content, p.Text)
blocks = append(blocks, content)
m.partCount++
m.lineCount += lipgloss.Height(content) + 1
}
case opencode.ToolInvocationPart:
if !m.showToolDetails {
if !hasTextPart {
orphanedToolCalls = append(orphanedToolCalls, part)
}
continue
}
@@ -258,13 +263,8 @@ func (m *messagesComponent) renderView(width int) {
)
}
if content != "" {
if m.selectedPart == m.partCount {
m.viewport.SetYOffset(m.lineCount - 4)
m.selectedText = ""
}
m = m.updateSelected(content, "")
blocks = append(blocks, content)
m.partCount++
m.lineCount += lipgloss.Height(content) + 1
}
}
}
@@ -295,9 +295,20 @@ func (m *messagesComponent) renderView(width int) {
}
m.viewport.SetContent("\n" + strings.Join(blocks, "\n\n"))
if m.selectedPart == m.partCount-1 {
if m.selectedPart == m.partCount {
m.viewport.GotoBottom()
}
}
func (m *messagesComponent) updateSelected(content string, selectedText string) *messagesComponent {
if m.selectedPart == m.partCount {
m.viewport.SetYOffset(m.lineCount - (m.viewport.Height() / 2) + 4)
m.selectedText = selectedText
}
m.partCount++
m.lineCount += lipgloss.Height(content) + 1
return m
}
func (m *messagesComponent) header(width int) string {
@@ -309,9 +320,12 @@ func (m *messagesComponent) header(width int) string {
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, width-6, t.Background()))
headerLines = append(
headerLines,
util.ToMarkdown("# "+m.app.Session.Title, width-6, t.Background()),
)
if m.app.Session.Share.URL != "" {
headerLines = append(headerLines, muted(m.app.Session.Share.URL))
headerLines = append(headerLines, muted(m.app.Session.Share.URL+" /unshare"))
} else {
headerLines = append(headerLines, base("/share")+muted(" to create a shareable link"))
}
@@ -340,7 +354,7 @@ func (m *messagesComponent) View(width, height int) string {
height,
lipgloss.Center,
lipgloss.Center,
styles.NewStyle().Background(t.Background()).Render("Loading session..."),
styles.NewStyle().Background(t.Background()).Render(""),
styles.WhitespaceStyle(t.Background()),
)
}
@@ -10,6 +10,7 @@ import (
"strconv"
"strings"
"sync"
"unicode/utf8"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters"
@@ -575,7 +576,10 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
ansiSequences[visibleIdx] = lastAnsiSeq
}
visibleIdx++
i++
// Properly advance by UTF-8 rune, not byte
_, size := utf8.DecodeRuneInString(content[i:])
i += size
}
// Apply highlighting
@@ -622,8 +626,9 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
}
}
// Get current character
char := string(content[i])
// Get current character (properly handle UTF-8)
r, size := utf8.DecodeRuneInString(content[i:])
char := string(r)
if inSelection {
// Get the current styling
@@ -657,7 +662,7 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
}
currentPos++
i++
i += size
}
return sb.String()
@@ -37,7 +37,11 @@ func (m statusComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m statusComponent) logo() string {
t := theme.CurrentTheme()
base := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement()).Render
emphasis := styles.NewStyle().Foreground(t.Text()).Background(t.BackgroundElement()).Bold(true).Render
emphasis := styles.NewStyle().
Foreground(t.Text()).
Background(t.BackgroundElement()).
Bold(true).
Render
open := base("open")
code := emphasis("code ")
@@ -72,19 +76,16 @@ func formatTokensAndCost(tokens float64, contextWindow float64, cost float64) st
formattedCost := fmt.Sprintf("$%.2f", cost)
percentage := (float64(tokens) / float64(contextWindow)) * 100
return fmt.Sprintf("Context: %s (%d%%), Cost: %s", formattedTokens, int(percentage), formattedCost)
return fmt.Sprintf(
"Context: %s (%d%%), Cost: %s",
formattedTokens,
int(percentage),
formattedCost,
)
}
func (m statusComponent) View() string {
t := theme.CurrentTheme()
if m.app.Session.ID == "" {
return styles.NewStyle().
Background(t.Background()).
Width(m.width).
Height(2).
Render("")
}
logo := m.logo()
cwd := styles.NewStyle().
+17 -5
View File
@@ -41,7 +41,7 @@ const (
)
const interruptDebounceTimeout = 1 * time.Second
const fileViewerFullWidthCutoff = 200
const fileViewerFullWidthCutoff = 160
type appModel struct {
width, height int
@@ -111,18 +111,19 @@ func isScrollRelatedInput(keyString string) bool {
if len(keyString) == 0 {
return false
}
for _, char := range keyString {
charStr := string(char)
if !BUGGED_SCROLL_KEYS[charStr] {
return false
}
}
if len(keyString) > 3 && (keyString[len(keyString)-1] == 'M' || keyString[len(keyString)-1] == 'm') {
if len(keyString) > 3 &&
(keyString[len(keyString)-1] == 'M' || keyString[len(keyString)-1] == 'm') {
return true
}
return len(keyString) > 1
}
@@ -826,6 +827,17 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
shareUrl := response.Share.URL
cmds = append(cmds, tea.SetClipboard(shareUrl))
cmds = append(cmds, toast.NewSuccessToast("Share URL copied to clipboard!"))
case commands.SessionUnshareCommand:
if a.app.Session.ID == "" {
return a, nil
}
_, err := a.app.Client.Session.Unshare(context.Background(), a.app.Session.ID)
if err != nil {
slog.Error("Failed to unshare session", "error", err)
return a, toast.NewErrorToast("Failed to unshare session")
}
a.app.Session.Share.URL = ""
cmds = append(cmds, toast.NewSuccessToast("Session unshared successfully"))
case commands.SessionInterruptCommand:
if a.app.Session.ID == "" {
return a, nil
+22 -32
View File
@@ -2,49 +2,39 @@ package util
import (
"strings"
"sync"
)
// MapReducePar performs a parallel map-reduce operation on a slice of items.
// It applies a function to each item in the slice concurrently,
// and combines the results serially using a reducer returned from
// each one of the functions, allowing the use of closures.
func MapReducePar[a, b any](items []a, init b, fn func(a) func(b) b) b {
itemCount := len(items)
locks := make([]*sync.Mutex, itemCount)
mapped := make([]func(b) b, itemCount)
func mapParallel[in, out any](items []in, fn func(in) out) chan out {
mapChans := make([]chan out, 0, len(items))
for i, value := range items {
lock := &sync.Mutex{}
lock.Lock()
locks[i] = lock
for _, v := range items {
ch := make(chan out)
mapChans = append(mapChans, ch)
go func() {
defer lock.Unlock()
mapped[i] = fn(value)
defer close(ch)
ch <- fn(v)
}()
}
result := init
for i := range itemCount {
locks[i].Lock()
defer locks[i].Unlock()
f := mapped[i]
if f != nil {
result = f(result)
}
}
resultChan := make(chan out)
return result
go func() {
defer close(resultChan)
for _, ch := range mapChans {
v := <-ch
resultChan <- v
}
}()
return resultChan
}
// WriteStringsPar allows to iterate over a list and compute strings in parallel,
// yet write them in order.
func WriteStringsPar[a any](sb *strings.Builder, items []a, fn func(a) string) {
MapReducePar(items, sb, func(item a) func(*strings.Builder) *strings.Builder {
str := fn(item)
return func(sbdr *strings.Builder) *strings.Builder {
sbdr.WriteString(str)
return sbdr
}
})
ch := mapParallel(items, fn)
for v := range ch {
sb.WriteString(v)
}
}
@@ -0,0 +1,23 @@
package util_test
import (
"strconv"
"strings"
"testing"
"time"
"github.com/sst/opencode/internal/util"
)
func TestWriteStringsPar(t *testing.T) {
items := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
sb := strings.Builder{}
util.WriteStringsPar(&sb, items, func(i int) string {
// sleep for the inverse duration so that later items finish first
time.Sleep(time.Duration(10-i) * time.Millisecond)
return strconv.Itoa(i)
})
if sb.String() != "0123456789" {
t.Fatalf("expected 0123456789, got %s", sb.String())
}
}