diff options
| author | David Hill <[email protected]> | 2025-10-30 12:16:15 +0000 |
|---|---|---|
| committer | David Hill <[email protected]> | 2025-10-30 12:16:15 +0000 |
| commit | d7277fd305d3f8782b73bae9702cf27f04e30a4a (patch) | |
| tree | e6c2d703aa5f9da827002644a6888a11b6258e11 /packages/tui | |
| parent | bf0cbf2bfa65c3430d1c5ff5a60a0044e29ee022 (diff) | |
| parent | 5ae73637d33204e134526a17f26cea0b6b0e544b (diff) | |
| download | opencode-d7277fd305d3f8782b73bae9702cf27f04e30a4a.tar.gz opencode-d7277fd305d3f8782b73bae9702cf27f04e30a4a.zip | |
Merge branch 'dev' of https://github.com/sst/opencode into dev
Diffstat (limited to 'packages/tui')
| -rw-r--r-- | packages/tui/cmd/opencode/main.go | 7 | ||||
| -rw-r--r-- | packages/tui/internal/components/chat/message.go | 12 | ||||
| -rw-r--r-- | packages/tui/internal/components/chat/messages.go | 2 | ||||
| -rw-r--r-- | packages/tui/internal/decoders/decoder.go | 118 | ||||
| -rw-r--r-- | packages/tui/internal/decoders/decoder_test.go | 194 | ||||
| -rw-r--r-- | packages/tui/internal/tui/tui.go | 14 |
6 files changed, 343 insertions, 4 deletions
diff --git a/packages/tui/cmd/opencode/main.go b/packages/tui/cmd/opencode/main.go index 22841fc89..3a7d1848a 100644 --- a/packages/tui/cmd/opencode/main.go +++ b/packages/tui/cmd/opencode/main.go @@ -13,9 +13,11 @@ import ( flag "github.com/spf13/pflag" "github.com/sst/opencode-sdk-go" "github.com/sst/opencode-sdk-go/option" + "github.com/sst/opencode-sdk-go/packages/ssestream" "github.com/sst/opencode/internal/api" "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/clipboard" + "github.com/sst/opencode/internal/decoders" "github.com/sst/opencode/internal/tui" "github.com/sst/opencode/internal/util" "golang.org/x/sync/errgroup" @@ -61,6 +63,11 @@ func main() { } } + // Register custom SSE decoder to handle large events (>32MB) + // This is a workaround for the bufio.Scanner token size limit in the auto-generated SDK + // See: packages/tui/internal/decoders/decoder.go + ssestream.RegisterDecoder("text/event-stream", decoders.NewUnboundedDecoder) + httpClient := opencode.NewClient( option.WithBaseURL(url), ) diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go index fc5a21ad1..801545a88 100644 --- a/packages/tui/internal/components/chat/message.go +++ b/packages/tui/internal/components/chat/message.go @@ -504,7 +504,11 @@ func renderToolDetails( base := styles.NewStyle().Background(backgroundColor) text := base.Foreground(t.Text()).Bold(true).Render muted := base.Foreground(t.TextMuted()).Render - permissionContent = "Permission required to run this tool:\n\n" + if permission.Type == "doom-loop" { + permissionContent = permission.Title + "\n\n" + } else { + permissionContent = "Permission required to run this tool:\n\n" + } permissionContent += text( "enter ", ) + muted( @@ -642,9 +646,9 @@ func renderToolDetails( for _, item := range todos.([]any) { todo := item.(map[string]any) content := todo["content"] - if content == nil { - continue - } + if content == nil { + continue + } switch todo["status"] { case "completed": body += fmt.Sprintf("- [x] %s\n", content) diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index a09e809f0..3d52b84e5 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -656,6 +656,8 @@ func (m *messagesComponent) renderView() tea.Cmd { case nil: case opencode.AssistantMessageErrorMessageOutputLengthError: error = "Message output length exceeded" + case opencode.AssistantMessageErrorAPIError: + error = err.Data.Message case opencode.ProviderAuthError: error = err.Data.Message case opencode.MessageAbortedError: diff --git a/packages/tui/internal/decoders/decoder.go b/packages/tui/internal/decoders/decoder.go new file mode 100644 index 000000000..efb699202 --- /dev/null +++ b/packages/tui/internal/decoders/decoder.go @@ -0,0 +1,118 @@ +package decoders + +import ( + "bufio" + "bytes" + "io" + + "github.com/sst/opencode-sdk-go/packages/ssestream" +) + +// UnboundedDecoder is an SSE decoder that uses bufio.Reader instead of bufio.Scanner +// to avoid the 32MB token size limit. This is a workaround for large SSE events until +// the upstream Stainless SDK is fixed. +// +// This decoder handles SSE events of unlimited size by reading line-by-line with +// bufio.Reader.ReadBytes('\n'), which dynamically grows the buffer as needed. +type UnboundedDecoder struct { + reader *bufio.Reader + closer io.ReadCloser + evt ssestream.Event + err error +} + +// NewUnboundedDecoder creates a new unbounded SSE decoder with a 1MB initial buffer size +func NewUnboundedDecoder(rc io.ReadCloser) ssestream.Decoder { + reader := bufio.NewReaderSize(rc, 1024*1024) // 1MB initial buffer + return &UnboundedDecoder{ + reader: reader, + closer: rc, + } +} + +// Next reads and decodes the next SSE event from the stream +func (d *UnboundedDecoder) Next() bool { + if d.err != nil { + return false + } + + event := "" + data := bytes.NewBuffer(nil) + + for { + line, err := d.reader.ReadBytes('\n') + if err != nil { + if err == io.EOF && len(line) == 0 { + return false + } + if err != io.EOF { + d.err = err + return false + } + } + + // Remove trailing newline characters + line = bytes.TrimRight(line, "\r\n") + + // Empty line indicates end of event + if len(line) == 0 { + if data.Len() > 0 || event != "" { + d.evt = ssestream.Event{ + Type: event, + Data: data.Bytes(), + } + return true + } + continue + } + + // Skip comments (lines starting with ':') + if line[0] == ':' { + continue + } + + // Parse field + name, value, found := bytes.Cut(line, []byte(":")) + if !found { + // Field with no value + continue + } + + // Remove leading space from value + if len(value) > 0 && value[0] == ' ' { + value = value[1:] + } + + switch string(name) { + case "": + // An empty line in the form ": something" is a comment and should be ignored + continue + case "event": + event = string(value) + case "data": + _, d.err = data.Write(value) + if d.err != nil { + return false + } + _, d.err = data.WriteRune('\n') + if d.err != nil { + return false + } + } + } +} + +// Event returns the current event +func (d *UnboundedDecoder) Event() ssestream.Event { + return d.evt +} + +// Close closes the underlying reader +func (d *UnboundedDecoder) Close() error { + return d.closer.Close() +} + +// Err returns any error that occurred during decoding +func (d *UnboundedDecoder) Err() error { + return d.err +} diff --git a/packages/tui/internal/decoders/decoder_test.go b/packages/tui/internal/decoders/decoder_test.go new file mode 100644 index 000000000..e5ad1d55a --- /dev/null +++ b/packages/tui/internal/decoders/decoder_test.go @@ -0,0 +1,194 @@ +package decoders + +import ( + "bytes" + "io" + "strings" + "testing" +) + +func TestUnboundedDecoder_SmallEvent(t *testing.T) { + data := "event: test\ndata: hello world\n\n" + rc := io.NopCloser(strings.NewReader(data)) + decoder := NewUnboundedDecoder(rc) + + if !decoder.Next() { + t.Fatal("Expected Next() to return true") + } + + evt := decoder.Event() + if evt.Type != "test" { + t.Errorf("Expected event type 'test', got '%s'", evt.Type) + } + if string(evt.Data) != "hello world\n" { + t.Errorf("Expected data 'hello world\\n', got '%s'", string(evt.Data)) + } + + if decoder.Next() { + t.Error("Expected Next() to return false at end of stream") + } + + if err := decoder.Err(); err != nil { + t.Errorf("Expected no error, got %v", err) + } +} + +func TestUnboundedDecoder_LargeEvent(t *testing.T) { + // Create a large event (50MB) + size := 50 * 1024 * 1024 + largeData := strings.Repeat("x", size) + + var buf bytes.Buffer + buf.WriteString("event: large\n") + buf.WriteString("data: ") + buf.WriteString(largeData) + buf.WriteString("\n\n") + + rc := io.NopCloser(&buf) + decoder := NewUnboundedDecoder(rc) + + if !decoder.Next() { + t.Fatal("Expected Next() to return true") + } + + evt := decoder.Event() + if evt.Type != "large" { + t.Errorf("Expected event type 'large', got '%s'", evt.Type) + } + + expectedData := largeData + "\n" + if string(evt.Data) != expectedData { + t.Errorf("Data size mismatch: expected %d, got %d", len(expectedData), len(evt.Data)) + } + + if decoder.Next() { + t.Error("Expected Next() to return false at end of stream") + } + + if err := decoder.Err(); err != nil { + t.Errorf("Expected no error, got %v", err) + } +} + +func TestUnboundedDecoder_MultipleEvents(t *testing.T) { + data := "event: first\ndata: data1\n\nevent: second\ndata: data2\n\n" + rc := io.NopCloser(strings.NewReader(data)) + decoder := NewUnboundedDecoder(rc) + + // First event + if !decoder.Next() { + t.Fatal("Expected Next() to return true for first event") + } + evt := decoder.Event() + if evt.Type != "first" { + t.Errorf("Expected event type 'first', got '%s'", evt.Type) + } + if string(evt.Data) != "data1\n" { + t.Errorf("Expected data 'data1\\n', got '%s'", string(evt.Data)) + } + + // Second event + if !decoder.Next() { + t.Fatal("Expected Next() to return true for second event") + } + evt = decoder.Event() + if evt.Type != "second" { + t.Errorf("Expected event type 'second', got '%s'", evt.Type) + } + if string(evt.Data) != "data2\n" { + t.Errorf("Expected data 'data2\\n', got '%s'", string(evt.Data)) + } + + // No more events + if decoder.Next() { + t.Error("Expected Next() to return false at end of stream") + } + + if err := decoder.Err(); err != nil { + t.Errorf("Expected no error, got %v", err) + } +} + +func TestUnboundedDecoder_MultilineData(t *testing.T) { + data := "event: multiline\ndata: line1\ndata: line2\ndata: line3\n\n" + rc := io.NopCloser(strings.NewReader(data)) + decoder := NewUnboundedDecoder(rc) + + if !decoder.Next() { + t.Fatal("Expected Next() to return true") + } + + evt := decoder.Event() + if evt.Type != "multiline" { + t.Errorf("Expected event type 'multiline', got '%s'", evt.Type) + } + + expectedData := "line1\nline2\nline3\n" + if string(evt.Data) != expectedData { + t.Errorf("Expected data '%s', got '%s'", expectedData, string(evt.Data)) + } +} + +func TestUnboundedDecoder_Comments(t *testing.T) { + data := ": this is a comment\nevent: test\n: another comment\ndata: hello\n\n" + rc := io.NopCloser(strings.NewReader(data)) + decoder := NewUnboundedDecoder(rc) + + if !decoder.Next() { + t.Fatal("Expected Next() to return true") + } + + evt := decoder.Event() + if evt.Type != "test" { + t.Errorf("Expected event type 'test', got '%s'", evt.Type) + } + if string(evt.Data) != "hello\n" { + t.Errorf("Expected data 'hello\\n', got '%s'", string(evt.Data)) + } +} + +func TestUnboundedDecoder_NoEventType(t *testing.T) { + data := "data: hello\n\n" + rc := io.NopCloser(strings.NewReader(data)) + decoder := NewUnboundedDecoder(rc) + + if !decoder.Next() { + t.Fatal("Expected Next() to return true") + } + + evt := decoder.Event() + if evt.Type != "" { + t.Errorf("Expected empty event type, got '%s'", evt.Type) + } + if string(evt.Data) != "hello\n" { + t.Errorf("Expected data 'hello\\n', got '%s'", string(evt.Data)) + } +} + +func BenchmarkUnboundedDecoder_LargeEvent(b *testing.B) { + // Create a 10MB event for benchmarking + size := 10 * 1024 * 1024 + largeData := strings.Repeat("x", size) + + var buf bytes.Buffer + buf.WriteString("event: bench\n") + buf.WriteString("data: ") + buf.WriteString(largeData) + buf.WriteString("\n\n") + + data := buf.Bytes() + + b.ResetTimer() + b.SetBytes(int64(len(data))) + + for i := 0; i < b.N; i++ { + rc := io.NopCloser(bytes.NewReader(data)) + decoder := NewUnboundedDecoder(rc) + + if !decoder.Next() { + b.Fatal("Expected Next() to return true") + } + + _ = decoder.Event() + } +} diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 279443674..3a0bc3730 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -656,12 +656,26 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case opencode.EventListResponseEventSessionError: switch err := msg.Properties.Error.AsUnion().(type) { case nil: + // No error details provided case opencode.ProviderAuthError: slog.Error("Failed to authenticate with provider", "error", err.Data.Message) return a, toast.NewErrorToast("Provider error: " + err.Data.Message) case opencode.UnknownError: slog.Error("Server error", "name", err.Name, "message", err.Data.Message) return a, toast.NewErrorToast(err.Data.Message, toast.WithTitle(string(err.Name))) + case opencode.EventListResponseEventSessionErrorPropertiesErrorAPIError: + slog.Error("API error", "message", err.Data.Message, "statusCode", err.Data.StatusCode) + return a, toast.NewErrorToast(err.Data.Message, toast.WithTitle(string(err.Name))) + case opencode.MessageAbortedError: + // Message was aborted - this is expected when user cancels, so just log it + slog.Debug("Message aborted", "message", err.Data.Message) + case opencode.EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthError: + slog.Error("Message output length error") + return a, toast.NewErrorToast("Message output length exceeded limit") + default: + // Handle any unhandled error types + slog.Error("Unhandled session error type", "type", fmt.Sprintf("%T", err)) + return a, toast.NewErrorToast("An unexpected error occurred") } case opencode.EventListResponseEventSessionCompacted: if msg.Properties.SessionID == a.app.Session.ID { |
