Compare commits

..
6 Commits
Author SHA1 Message Date
adamdottv 8825cd3811 feat(tui): unshare command
publish / publish (push) Has been cancelled
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 AndrewsandGitHub 73c012c76c fix: simplify parallel map using channels (#582) 2025-07-03 05:43:10 -05:00
LevandGitHub 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
publish / publish (push) Has been cancelled
2025-07-02 23:59:08 -04:00
10 changed files with 130 additions and 69 deletions
+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"],
+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 ""
@@ -309,9 +309,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"))
}
@@ -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())
}
}