From 4a0be45d3d685ad952f51ef875c798ec4b3061de Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 4 Jul 2025 10:22:45 -0500 Subject: [PATCH 01/37] chore: document `instructions` configuration option (#670) --- packages/web/src/content/docs/docs/rules.mdx | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/web/src/content/docs/docs/rules.mdx b/packages/web/src/content/docs/docs/rules.mdx index aed08535..b7818d71 100644 --- a/packages/web/src/content/docs/docs/rules.mdx +++ b/packages/web/src/content/docs/docs/rules.mdx @@ -73,3 +73,29 @@ So when opencode starts, it looks for: 2. **Global file** by checking `~/.config/opencode/AGENTS.md` If you have both global and project-specific rules, opencode will combine them together. + +--- + +## Custom Instructions + +You can also specify custom instruction files using the `instructions` configuration in your `opencode.json` or global `~/.config/opencode/config.json`: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "instructions": [".cursor/rules/*.md"] +} +``` + +You can specify multiple files like `CONTRIBUTING.md` and `docs/guidelines.md`, and use glob patterns to match multiple files. + +For example, to reuse your existing Cursor rules: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "instructions": [".cursor/rules/*.md"] +} +``` + +All instruction files are combined with your `AGENTS.md` files. From f13b0af4912ba062d89b1599281982455de54662 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Fri, 4 Jul 2025 19:24:13 +0400 Subject: [PATCH 02/37] docs: Fix invalid json in the mcp example config (#645) --- packages/web/src/content/docs/docs/mcp-servers.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web/src/content/docs/docs/mcp-servers.mdx b/packages/web/src/content/docs/docs/mcp-servers.mdx index 72c33e8a..0496e31c 100644 --- a/packages/web/src/content/docs/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/docs/mcp-servers.mdx @@ -30,6 +30,7 @@ Add a local MCP servers under `mcp.localmcp`. "enabled": true, "environment": { "MY_ENV_VAR": "my_env_var_value" + } } } } From 163e23a68b4a21e8939f4d280594fc084d3ea4de Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 4 Jul 2025 11:32:12 -0400 Subject: [PATCH 03/37] removed banned command concept --- packages/opencode/src/tool/bash.ts | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 3ef44bd5..620a8c8d 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -4,25 +4,6 @@ import DESCRIPTION from "./bash.txt" import { App } from "../app/app" const MAX_OUTPUT_LENGTH = 30000 -const BANNED_COMMANDS = [ - "alias", - "curl", - "curlie", - "wget", - "axel", - "aria2c", - "nc", - "telnet", - "lynx", - "w3m", - "links", - "httpie", - "xh", - "http-prompt", - "chrome", - "firefox", - "safari", -] const DEFAULT_TIMEOUT = 1 * 60 * 1000 const MAX_TIMEOUT = 10 * 60 * 1000 @@ -45,8 +26,6 @@ export const BashTool = Tool.define({ }), async execute(params, ctx) { const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT) - if (BANNED_COMMANDS.some((item) => params.command.startsWith(item))) - throw new Error(`Command '${params.command}' is not allowed`) const process = Bun.spawn({ cmd: ["bash", "-c", params.command], From 891ed6ebc006703d5a26f89ecc85bd86f9b2133e Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Thu, 3 Jul 2025 16:04:45 -0500 Subject: [PATCH 04/37] fix(tui): slower startup due to file.status --- packages/tui/internal/completions/files-folders.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/tui/internal/completions/files-folders.go b/packages/tui/internal/completions/files-folders.go index 8d6b9958..ec298af9 100644 --- a/packages/tui/internal/completions/files-folders.go +++ b/packages/tui/internal/completions/files-folders.go @@ -16,12 +16,11 @@ import ( type filesAndFoldersContextGroup struct { app *app.App - prefix string gitFiles []dialog.CompletionItemI } func (cg *filesAndFoldersContextGroup) GetId() string { - return cg.prefix + return "files" } func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string { @@ -107,9 +106,10 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries( func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider { cg := &filesAndFoldersContextGroup{ - app: app, - prefix: "file", + app: app, } - cg.gitFiles = cg.getGitFiles() + go func() { + cg.gitFiles = cg.getGitFiles() + }() return cg } From f9abc7c84f2544f5844d795bf835064114734817 Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Fri, 4 Jul 2025 10:29:40 -0500 Subject: [PATCH 05/37] feat(tui): file attachments --- packages/tui/go.mod | 1 + packages/tui/go.sum | 2 + packages/tui/internal/app/app.go | 67 +- .../tui/internal/components/chat/editor.go | 104 ++- .../internal/components/dialog/complete.go | 15 +- .../tui/internal/components/dialog/find.go | 4 +- .../tui/internal/components/dialog/models.go | 8 +- packages/tui/internal/components/list/list.go | 14 +- .../tui/internal/components/modal/modal.go | 4 +- packages/tui/internal/components/qr/qr.go | 2 +- .../internal/components/textarea/textarea.go | 812 +++++++++++++----- .../tui/internal/layout/flex_example_test.go | 41 - packages/tui/internal/layout/flex_test.go | 90 -- packages/tui/internal/tui/tui.go | 37 +- 14 files changed, 794 insertions(+), 407 deletions(-) delete mode 100644 packages/tui/internal/layout/flex_example_test.go delete mode 100644 packages/tui/internal/layout/flex_test.go diff --git a/packages/tui/go.mod b/packages/tui/go.mod index 043d9fcd..74047af1 100644 --- a/packages/tui/go.mod +++ b/packages/tui/go.mod @@ -37,6 +37,7 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/goccy/go-yaml v1.17.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/invopop/yaml v0.3.1 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect diff --git a/packages/tui/go.sum b/packages/tui/go.sum index 29548273..fdc5bbb0 100644 --- a/packages/tui/go.sum +++ b/packages/tui/go.sum @@ -92,6 +92,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 6b59acae..469857ab 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -44,7 +44,7 @@ type SessionClearedMsg struct{} type CompactSessionMsg struct{} type SendMsg struct { Text string - Attachments []Attachment + Attachments []opencode.FilePartParam } type OptimisticMessageAddedMsg struct { Message opencode.Message @@ -217,13 +217,6 @@ func getDefaultModel( return nil } -type Attachment struct { - FilePath string - FileName string - MimeType string - Content []byte -} - func (a *App) IsBusy() bool { if len(a.Messages) == 0 { return false @@ -296,24 +289,40 @@ func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) { return session, nil } -func (a *App) SendChatMessage(ctx context.Context, text string, attachments []Attachment) tea.Cmd { +func (a *App) SendChatMessage( + ctx context.Context, + text string, + attachments []opencode.FilePartParam, +) (*App, tea.Cmd) { var cmds []tea.Cmd if a.Session.ID == "" { session, err := a.CreateSession(ctx) if err != nil { - return toast.NewErrorToast(err.Error()) + return a, toast.NewErrorToast(err.Error()) } a.Session = session cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session))) } + optimisticParts := []opencode.MessagePart{{ + Type: opencode.MessagePartTypeText, + Text: text, + }} + if len(attachments) > 0 { + for _, attachment := range attachments { + optimisticParts = append(optimisticParts, opencode.MessagePart{ + Type: opencode.MessagePartTypeFile, + Filename: attachment.Filename.Value, + MediaType: attachment.MediaType.Value, + URL: attachment.URL.Value, + }) + } + } + optimisticMessage := opencode.Message{ - ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()), - Role: opencode.MessageRoleUser, - Parts: []opencode.MessagePart{{ - Type: opencode.MessagePartTypeText, - Text: text, - }}, + ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()), + Role: opencode.MessageRoleUser, + Parts: optimisticParts, Metadata: opencode.MessageMetadata{ SessionID: a.Session.ID, Time: opencode.MessageMetadataTime{ @@ -326,13 +335,25 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage})) cmds = append(cmds, func() tea.Msg { + parts := []opencode.MessagePartUnionParam{ + opencode.TextPartParam{ + Type: opencode.F(opencode.TextPartTypeText), + Text: opencode.F(text), + }, + } + if len(attachments) > 0 { + for _, attachment := range attachments { + parts = append(parts, opencode.FilePartParam{ + MediaType: attachment.MediaType, + Type: attachment.Type, + URL: attachment.URL, + Filename: attachment.Filename, + }) + } + } + _, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{ - Parts: opencode.F([]opencode.MessagePartUnionParam{ - opencode.TextPartParam{ - Type: opencode.F(opencode.TextPartTypeText), - Text: opencode.F(text), - }, - }), + Parts: opencode.F(parts), ProviderID: opencode.F(a.Provider.ID), ModelID: opencode.F(a.Model.ID), }) @@ -346,7 +367,7 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At // The actual response will come through SSE // For now, just return success - return tea.Batch(cmds...) + return a, tea.Batch(cmds...) } func (a *App) Cancel(ctx context.Context, sessionID string) error { diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index 669ef47d..595fd4d5 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -3,11 +3,14 @@ package chat import ( "fmt" "log/slog" + "path/filepath" "strings" "github.com/charmbracelet/bubbles/v2/spinner" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" + "github.com/google/uuid" + "github.com/sst/opencode-sdk-go" "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/commands" "github.com/sst/opencode/internal/components/dialog" @@ -37,7 +40,6 @@ type EditorComponent interface { type editorComponent struct { app *app.App textarea textarea.Model - attachments []app.Attachment spinner spinner.Model interruptKeyInDebounce bool } @@ -66,17 +68,43 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.spinner = createSpinner() return m, tea.Batch(m.spinner.Tick, m.textarea.Focus()) case dialog.CompletionSelectedMsg: - if msg.IsCommand { + switch msg.ProviderID { + case "commands": commandName := strings.TrimPrefix(msg.CompletionValue, "/") updated, cmd := m.Clear() m = updated.(*editorComponent) cmds = append(cmds, cmd) cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)]))) return m, tea.Batch(cmds...) - } else { - existingValue := m.textarea.Value() + case "files": + atIndex := m.textarea.LastRuneIndex('@') + if atIndex == -1 { + // Should not happen, but as a fallback, just insert. + m.textarea.InsertString(msg.CompletionValue + " ") + return m, nil + } - // Replace the current token (after last space) + // The range to replace is from the '@' up to the current cursor position. + // Replace the search term (e.g., "@search") with an empty string first. + cursorCol := m.textarea.CursorColumn() + m.textarea.ReplaceRange(atIndex, cursorCol, "") + + // Now, insert the attachment at the position where the '@' was. + // The cursor is now at `atIndex` after the replacement. + filePath := msg.CompletionValue + fileName := filepath.Base(filePath) + attachment := &textarea.Attachment{ + ID: uuid.NewString(), + Display: "@" + fileName, + URL: fmt.Sprintf("file://%s", filePath), + Filename: fileName, + MediaType: "text/plain", + } + m.textarea.InsertAttachment(attachment) + m.textarea.InsertString(" ") + return m, nil + default: + existingValue := m.textarea.Value() lastSpaceIndex := strings.LastIndex(existingValue, " ") if lastSpaceIndex == -1 { m.textarea.SetValue(msg.CompletionValue + " ") @@ -128,7 +156,15 @@ func (m *editorComponent) Content(width int) string { if m.app.IsBusy() { keyText := m.getInterruptKeyText() if m.interruptKeyInDebounce { - hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText+" again") + muted(" interrupt") + hint = muted( + "working", + ) + m.spinner.View() + muted( + " ", + ) + base( + keyText+" again", + ) + muted( + " interrupt", + ) } else { hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt") } @@ -195,14 +231,23 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) { } var cmds []tea.Cmd + + attachments := m.textarea.GetAttachments() + fileParts := make([]opencode.FilePartParam, 0) + for _, attachment := range attachments { + fileParts = append(fileParts, opencode.FilePartParam{ + Type: opencode.F(opencode.FilePartTypeFile), + MediaType: opencode.F(attachment.MediaType), + URL: opencode.F(attachment.URL), + Filename: opencode.F(attachment.Filename), + }) + } + updated, cmd := m.Clear() m = updated.(*editorComponent) cmds = append(cmds, cmd) - attachments := m.attachments - m.attachments = nil - - cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments})) + cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: fileParts})) return m, tea.Batch(cmds...) } @@ -212,18 +257,23 @@ func (m *editorComponent) Clear() (tea.Model, tea.Cmd) { } func (m *editorComponent) Paste() (tea.Model, tea.Cmd) { - imageBytes, text, err := image.GetImageFromClipboard() + _, text, err := image.GetImageFromClipboard() if err != nil { slog.Error(err.Error()) return m, nil } - if len(imageBytes) != 0 { - attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments)) - attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"} - m.attachments = append(m.attachments, attachment) - } else { - m.textarea.SetValue(m.textarea.Value() + text) - } + // if len(imageBytes) != 0 { + // attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments)) + // attachment := app.Attachment{ + // FilePath: attachmentName, + // FileName: attachmentName, + // Content: imageBytes, + // MimeType: "image/png", + // } + // m.attachments = append(m.attachments, attachment) + // } else { + m.textarea.SetValue(m.textarea.Value() + text) + // } return m, nil } @@ -254,12 +304,26 @@ func createTextArea(existing *textarea.Model) textarea.Model { ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss() - ta.Styles.Blurred.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss() + ta.Styles.Blurred.Placeholder = styles.NewStyle(). + Foreground(textMutedColor). + Background(bgColor). + Lipgloss() ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss() - ta.Styles.Focused.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss() + ta.Styles.Focused.Placeholder = styles.NewStyle(). + Foreground(textMutedColor). + Background(bgColor). + Lipgloss() ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() + ta.Styles.Attachment = styles.NewStyle(). + Foreground(t.Secondary()). + Background(bgColor). + Lipgloss() + ta.Styles.SelectedAttachment = styles.NewStyle(). + Foreground(t.Text()). + Background(t.Secondary()). + Lipgloss() ta.Styles.Cursor.Color = t.Primary() ta.Prompt = " " diff --git a/packages/tui/internal/components/dialog/complete.go b/packages/tui/internal/components/dialog/complete.go index caf754c7..7ba91dc5 100644 --- a/packages/tui/internal/components/dialog/complete.go +++ b/packages/tui/internal/components/dialog/complete.go @@ -64,7 +64,7 @@ type CompletionProvider interface { type CompletionSelectedMsg struct { SearchString string CompletionValue string - IsCommand bool + ProviderID string } type CompletionDialogCompleteItemMsg struct { @@ -121,9 +121,6 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var query string query = c.pseudoSearchTextArea.Value() - if query != "" { - query = query[1:] - } if query != c.query { c.query = query @@ -183,8 +180,9 @@ func (c *completionDialogComponent) View() string { for _, cmd := range completions { title := cmd.DisplayValue() - if len(title) > maxWidth-4 { - maxWidth = len(title) + 4 + width := lipgloss.Width(title) + if width > maxWidth-4 { + maxWidth = width + 4 } } @@ -213,14 +211,11 @@ func (c *completionDialogComponent) IsEmpty() bool { func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd { value := c.pseudoSearchTextArea.Value() - // Check if this is a command completion - isCommand := c.completionProvider.GetId() == "commands" - return tea.Batch( util.CmdHandler(CompletionSelectedMsg{ SearchString: value, CompletionValue: item.GetValue(), - IsCommand: isCommand, + ProviderID: c.completionProvider.GetId(), }), c.close(), ) diff --git a/packages/tui/internal/components/dialog/find.go b/packages/tui/internal/components/dialog/find.go index 489b9f29..3fc6e599 100644 --- a/packages/tui/internal/components/dialog/find.go +++ b/packages/tui/internal/components/dialog/find.go @@ -124,7 +124,7 @@ func (f *findDialogComponent) View() string { f.list.SetMaxWidth(f.width - 4) inputView := f.textInput.View() inputView = styles.NewStyle(). - Background(t.BackgroundPanel()). + Background(t.BackgroundElement()). Height(1). Width(f.width-4). Padding(0, 0). @@ -171,7 +171,7 @@ func (f *findDialogComponent) Close() tea.Cmd { func createTextInput(existing *textinput.Model) textinput.Model { t := theme.CurrentTheme() - bgColor := t.BackgroundPanel() + bgColor := t.BackgroundElement() textColor := t.Text() textMutedColor := t.TextMuted() diff --git a/packages/tui/internal/components/dialog/models.go b/packages/tui/internal/components/dialog/models.go index 4ebf572e..f8cda82a 100644 --- a/packages/tui/internal/components/dialog/models.go +++ b/packages/tui/internal/components/dialog/models.go @@ -56,24 +56,24 @@ func (m ModelItem) Render(selected bool, width int) string { displayText := fmt.Sprintf("%s (%s)", m.ModelName, m.ProviderName) return styles.NewStyle(). Background(t.Primary()). - Foreground(t.BackgroundElement()). + Foreground(t.BackgroundPanel()). Width(width). PaddingLeft(1). Render(displayText) } else { modelStyle := styles.NewStyle(). Foreground(t.Text()). - Background(t.BackgroundElement()) + Background(t.BackgroundPanel()) providerStyle := styles.NewStyle(). Foreground(t.TextMuted()). - Background(t.BackgroundElement()) + Background(t.BackgroundPanel()) modelPart := modelStyle.Render(m.ModelName) providerPart := providerStyle.Render(fmt.Sprintf(" (%s)", m.ProviderName)) combinedText := modelPart + providerPart return styles.NewStyle(). - Background(t.BackgroundElement()). + Background(t.BackgroundPanel()). PaddingLeft(1). Render(combinedText) } diff --git a/packages/tui/internal/components/list/list.go b/packages/tui/internal/components/list/list.go index a7ea3458..16bc73ca 100644 --- a/packages/tui/internal/components/list/list.go +++ b/packages/tui/internal/components/list/list.go @@ -158,7 +158,12 @@ func (c *listComponent[T]) View() string { return strings.Join(listItems, "\n") } -func NewListComponent[T ListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[T] { +func NewListComponent[T ListItem]( + items []T, + maxVisibleItems int, + fallbackMsg string, + useAlphaNumericKeys bool, +) List[T] { return &listComponent[T]{ fallbackMsg: fallbackMsg, items: items, @@ -194,7 +199,12 @@ func (s StringItem) Render(selected bool, width int) string { } // NewStringList creates a new list component with string items -func NewStringList(items []string, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[StringItem] { +func NewStringList( + items []string, + maxVisibleItems int, + fallbackMsg string, + useAlphaNumericKeys bool, +) List[StringItem] { stringItems := make([]StringItem, len(items)) for i, item := range items { stringItems[i] = StringItem(item) diff --git a/packages/tui/internal/components/modal/modal.go b/packages/tui/internal/components/modal/modal.go index aa81a83e..5c2fbf8b 100644 --- a/packages/tui/internal/components/modal/modal.go +++ b/packages/tui/internal/components/modal/modal.go @@ -90,7 +90,7 @@ func (m *Modal) Render(contentView string, background string) string { innerWidth := outerWidth - 4 - baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement()) + baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()) var finalContent string if m.title != "" { @@ -140,6 +140,6 @@ func (m *Modal) Render(contentView string, background string) string { modalView, background, layout.WithOverlayBorder(), - layout.WithOverlayBorderColor(t.BorderActive()), + layout.WithOverlayBorderColor(t.Primary()), ) } diff --git a/packages/tui/internal/components/qr/qr.go b/packages/tui/internal/components/qr/qr.go index ccf28200..233bcf52 100644 --- a/packages/tui/internal/components/qr/qr.go +++ b/packages/tui/internal/components/qr/qr.go @@ -23,7 +23,7 @@ func Generate(text string) (string, int, error) { } // Create lipgloss style for QR code with theme colors - qrStyle := styles.NewStyleWithColors(t.Text(), t.Background()) + qrStyle := styles.NewStyle().Foreground(t.Text()).Background(t.Background()) var result strings.Builder diff --git a/packages/tui/internal/components/textarea/textarea.go b/packages/tui/internal/components/textarea/textarea.go index 2ca08bb8..c2c92ea7 100644 --- a/packages/tui/internal/components/textarea/textarea.go +++ b/packages/tui/internal/components/textarea/textarea.go @@ -9,6 +9,8 @@ import ( "time" "unicode" + "slices" + "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/v2/cursor" "github.com/charmbracelet/bubbles/v2/key" @@ -17,7 +19,6 @@ import ( "github.com/charmbracelet/x/ansi" rw "github.com/mattn/go-runewidth" "github.com/rivo/uniseg" - "slices" ) const ( @@ -32,6 +33,145 @@ const ( maxLines = 10000 ) +// Attachment represents a special object within the text, distinct from regular characters. +type Attachment struct { + ID string // A unique identifier for this attachment instance + Display string // e.g., "@filename.txt" + URL string + Filename string + MediaType string +} + +// Helper functions for converting between runes and any slices + +// runesToInterfaces converts a slice of runes to a slice of interfaces +func runesToInterfaces(runes []rune) []any { + result := make([]any, len(runes)) + for i, r := range runes { + result[i] = r + } + return result +} + +// interfacesToRunes converts a slice of interfaces to a slice of runes (for display purposes) +func interfacesToRunes(items []any) []rune { + var result []rune + for _, item := range items { + switch val := item.(type) { + case rune: + result = append(result, val) + case *Attachment: + result = append(result, []rune(val.Display)...) + } + } + return result +} + +// copyInterfaceSlice creates a copy of an any slice +func copyInterfaceSlice(src []any) []any { + dst := make([]any, len(src)) + copy(dst, src) + return dst +} + +// interfacesToString converts a slice of interfaces to a string for display +func interfacesToString(items []any) string { + var s strings.Builder + for _, item := range items { + switch val := item.(type) { + case rune: + s.WriteRune(val) + case *Attachment: + s.WriteString(val.Display) + } + } + return s.String() +} + +// isAttachmentAtCursor checks if the cursor is positioned on or immediately after an attachment. +// This allows for proper highlighting even when the cursor is technically at the position +// after the attachment object in the underlying slice. +func (m Model) isAttachmentAtCursor() (*Attachment, int, int) { + if m.row >= len(m.value) { + return nil, -1, -1 + } + + row := m.value[m.row] + col := m.col + + if col < 0 || col > len(row) { + return nil, -1, -1 + } + + // Check if the cursor is at the same index as an attachment. + if col < len(row) { + if att, ok := row[col].(*Attachment); ok { + return att, col, col + } + } + + // Check if the cursor is immediately after an attachment. This is a common + // state, for example, after just inserting one. + if col > 0 && col <= len(row) { + if att, ok := row[col-1].(*Attachment); ok { + return att, col - 1, col - 1 + } + } + + return nil, -1, -1 +} + +// renderLineWithAttachments renders a line with proper attachment highlighting +func (m Model) renderLineWithAttachments( + items []any, + style lipgloss.Style, +) string { + var s strings.Builder + currentAttachment, _, _ := m.isAttachmentAtCursor() + + for _, item := range items { + switch val := item.(type) { + case rune: + s.WriteString(style.Render(string(val))) + case *Attachment: + // Check if this is the attachment the cursor is currently on + if currentAttachment != nil && currentAttachment.ID == val.ID { + // Cursor is on this attachment, highlight it + s.WriteString(m.Styles.SelectedAttachment.Render(val.Display)) + } else { + s.WriteString(m.Styles.Attachment.Render(val.Display)) + } + } + } + return s.String() +} + +// getRuneAt safely gets a rune at a specific position, returns 0 if not a rune +func getRuneAt(items []any, index int) rune { + if index < 0 || index >= len(items) { + return 0 + } + if r, ok := items[index].(rune); ok { + return r + } + return 0 +} + +// isSpaceAt checks if the item at index is a space rune +func isSpaceAt(items []any, index int) bool { + r := getRuneAt(items, index) + return r != 0 && unicode.IsSpace(r) +} + +// setRuneAt safely sets a rune at a specific position if it's a rune +func setRuneAt(items []any, index int, r rune) { + if index >= 0 && index < len(items) { + if _, ok := items[index].(rune); ok { + items[index] = r + } + } +} + // Internal messages for clipboard operations. type ( pasteMsg string @@ -70,30 +210,96 @@ type KeyMap struct { // upon the textarea. func DefaultKeyMap() KeyMap { return KeyMap{ - CharacterForward: key.NewBinding(key.WithKeys("right", "ctrl+f"), key.WithHelp("right", "character forward")), - CharacterBackward: key.NewBinding(key.WithKeys("left", "ctrl+b"), key.WithHelp("left", "character backward")), - WordForward: key.NewBinding(key.WithKeys("alt+right", "alt+f"), key.WithHelp("alt+right", "word forward")), - WordBackward: key.NewBinding(key.WithKeys("alt+left", "alt+b"), key.WithHelp("alt+left", "word backward")), - LineNext: key.NewBinding(key.WithKeys("down", "ctrl+n"), key.WithHelp("down", "next line")), - LinePrevious: key.NewBinding(key.WithKeys("up", "ctrl+p"), key.WithHelp("up", "previous line")), - DeleteWordBackward: key.NewBinding(key.WithKeys("alt+backspace", "ctrl+w"), key.WithHelp("alt+backspace", "delete word backward")), - DeleteWordForward: key.NewBinding(key.WithKeys("alt+delete", "alt+d"), key.WithHelp("alt+delete", "delete word forward")), - DeleteAfterCursor: key.NewBinding(key.WithKeys("ctrl+k"), key.WithHelp("ctrl+k", "delete after cursor")), - DeleteBeforeCursor: key.NewBinding(key.WithKeys("ctrl+u"), key.WithHelp("ctrl+u", "delete before cursor")), - InsertNewline: key.NewBinding(key.WithKeys("enter", "ctrl+m"), key.WithHelp("enter", "insert newline")), - DeleteCharacterBackward: key.NewBinding(key.WithKeys("backspace", "ctrl+h"), key.WithHelp("backspace", "delete character backward")), - DeleteCharacterForward: key.NewBinding(key.WithKeys("delete", "ctrl+d"), key.WithHelp("delete", "delete character forward")), - LineStart: key.NewBinding(key.WithKeys("home", "ctrl+a"), key.WithHelp("home", "line start")), - LineEnd: key.NewBinding(key.WithKeys("end", "ctrl+e"), key.WithHelp("end", "line end")), - Paste: key.NewBinding(key.WithKeys("ctrl+v"), key.WithHelp("ctrl+v", "paste")), - InputBegin: key.NewBinding(key.WithKeys("alt+<", "ctrl+home"), key.WithHelp("alt+<", "input begin")), - InputEnd: key.NewBinding(key.WithKeys("alt+>", "ctrl+end"), key.WithHelp("alt+>", "input end")), + CharacterForward: key.NewBinding( + key.WithKeys("right", "ctrl+f"), + key.WithHelp("right", "character forward"), + ), + CharacterBackward: key.NewBinding( + key.WithKeys("left", "ctrl+b"), + key.WithHelp("left", "character backward"), + ), + WordForward: key.NewBinding( + key.WithKeys("alt+right", "alt+f"), + key.WithHelp("alt+right", "word forward"), + ), + WordBackward: key.NewBinding( + key.WithKeys("alt+left", "alt+b"), + key.WithHelp("alt+left", "word backward"), + ), + LineNext: key.NewBinding( + key.WithKeys("down", "ctrl+n"), + key.WithHelp("down", "next line"), + ), + LinePrevious: key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("up", "previous line"), + ), + DeleteWordBackward: key.NewBinding( + key.WithKeys("alt+backspace", "ctrl+w"), + key.WithHelp("alt+backspace", "delete word backward"), + ), + DeleteWordForward: key.NewBinding( + key.WithKeys("alt+delete", "alt+d"), + key.WithHelp("alt+delete", "delete word forward"), + ), + DeleteAfterCursor: key.NewBinding( + key.WithKeys("ctrl+k"), + key.WithHelp("ctrl+k", "delete after cursor"), + ), + DeleteBeforeCursor: key.NewBinding( + key.WithKeys("ctrl+u"), + key.WithHelp("ctrl+u", "delete before cursor"), + ), + InsertNewline: key.NewBinding( + key.WithKeys("enter", "ctrl+m"), + key.WithHelp("enter", "insert newline"), + ), + DeleteCharacterBackward: key.NewBinding( + key.WithKeys("backspace", "ctrl+h"), + key.WithHelp("backspace", "delete character backward"), + ), + DeleteCharacterForward: key.NewBinding( + key.WithKeys("delete", "ctrl+d"), + key.WithHelp("delete", "delete character forward"), + ), + LineStart: key.NewBinding( + key.WithKeys("home", "ctrl+a"), + key.WithHelp("home", "line start"), + ), + LineEnd: key.NewBinding( + key.WithKeys("end", "ctrl+e"), + key.WithHelp("end", "line end"), + ), + Paste: key.NewBinding( + key.WithKeys("ctrl+v"), + key.WithHelp("ctrl+v", "paste"), + ), + InputBegin: key.NewBinding( + key.WithKeys("alt+<", "ctrl+home"), + key.WithHelp("alt+<", "input begin"), + ), + InputEnd: key.NewBinding( + key.WithKeys("alt+>", "ctrl+end"), + key.WithHelp("alt+>", "input end"), + ), - CapitalizeWordForward: key.NewBinding(key.WithKeys("alt+c"), key.WithHelp("alt+c", "capitalize word forward")), - LowercaseWordForward: key.NewBinding(key.WithKeys("alt+l"), key.WithHelp("alt+l", "lowercase word forward")), - UppercaseWordForward: key.NewBinding(key.WithKeys("alt+u"), key.WithHelp("alt+u", "uppercase word forward")), + CapitalizeWordForward: key.NewBinding( + key.WithKeys("alt+c"), + key.WithHelp("alt+c", "capitalize word forward"), + ), + LowercaseWordForward: key.NewBinding( + key.WithKeys("alt+l"), + key.WithHelp("alt+l", "lowercase word forward"), + ), + UppercaseWordForward: key.NewBinding( + key.WithKeys("alt+u"), + key.WithHelp("alt+u", "uppercase word forward"), + ), - TransposeCharacterBackward: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("ctrl+t", "transpose character backward")), + TransposeCharacterBackward: key.NewBinding( + key.WithKeys("ctrl+t"), + key.WithHelp("ctrl+t", "transpose character backward"), + ), } } @@ -160,9 +366,11 @@ type CursorStyle struct { // states. The appropriate styles will be chosen based on the focus state of // the textarea. type Styles struct { - Focused StyleState - Blurred StyleState - Cursor CursorStyle + Focused StyleState + Blurred StyleState + Cursor CursorStyle + Attachment lipgloss.Style + SelectedAttachment lipgloss.Style } // StyleState that will be applied to the text area. @@ -217,13 +425,22 @@ func (s StyleState) computedText() lipgloss.Style { // line is the input to the text wrapping function. This is stored in a struct // so that it can be hashed and memoized. type line struct { - runes []rune - width int + content []any // Contains runes and *Attachment + width int } // Hash returns a hash of the line. func (w line) Hash() string { - v := fmt.Sprintf("%s:%d", string(w.runes), w.width) + var s strings.Builder + for _, item := range w.content { + switch v := item.(type) { + case rune: + s.WriteRune(v) + case *Attachment: + s.WriteString(v.ID) + } + } + v := fmt.Sprintf("%s:%d", s.String(), w.width) return fmt.Sprintf("%x", sha256.Sum256([]byte(v))) } @@ -232,7 +449,7 @@ type Model struct { Err error // General settings. - cache *MemoCache[line, [][]rune] + cache *MemoCache[line, [][]any] // Prompt is printed at the beginning of each line. // @@ -295,14 +512,14 @@ type Model struct { // if there are more lines than the permitted height. height int - // Underlying text value. - value [][]rune + // Underlying text value. Contains either rune or *Attachment types. + value [][]any // focus indicates whether user input focus should be on this input // component. When false, ignore keyboard input and hide the cursor. focus bool - // Cursor column. + // Cursor column (slice index). col int // Cursor row. @@ -328,14 +545,14 @@ func New() Model { MaxWidth: defaultMaxWidth, Prompt: lipgloss.ThickBorder().Left + " ", Styles: styles, - cache: NewMemoCache[line, [][]rune](maxLines), + cache: NewMemoCache[line, [][]any](maxLines), EndOfBufferCharacter: ' ', ShowLineNumbers: true, VirtualCursor: true, virtualCursor: cur, KeyMap: DefaultKeyMap(), - value: make([][]rune, minHeight, maxLines), + value: make([][]any, minHeight, maxLines), focus: false, col: 0, row: 0, @@ -354,25 +571,40 @@ func DefaultStyles(isDark bool) Styles { var s Styles s.Focused = StyleState{ - Base: lipgloss.NewStyle(), - CursorLine: lipgloss.NewStyle().Background(lightDark(lipgloss.Color("255"), lipgloss.Color("0"))), - CursorLineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("240"), lipgloss.Color("240"))), - EndOfBuffer: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))), - LineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), - Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), - Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), - Text: lipgloss.NewStyle(), + Base: lipgloss.NewStyle(), + CursorLine: lipgloss.NewStyle(). + Background(lightDark(lipgloss.Color("255"), lipgloss.Color("0"))), + CursorLineNumber: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("240"), lipgloss.Color("240"))), + EndOfBuffer: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))), + LineNumber: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), + Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), + Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), + Text: lipgloss.NewStyle(), } s.Blurred = StyleState{ - Base: lipgloss.NewStyle(), - CursorLine: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))), - CursorLineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), - EndOfBuffer: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))), - LineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), - Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), - Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), - Text: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))), + Base: lipgloss.NewStyle(), + CursorLine: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))), + CursorLineNumber: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), + EndOfBuffer: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))), + LineNumber: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), + Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), + Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), + Text: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))), } + s.Attachment = lipgloss.NewStyle(). + Background(lipgloss.Color("11")). + Foreground(lipgloss.Color("0")) + s.SelectedAttachment = lipgloss.NewStyle(). + Background(lipgloss.Color("11")). + Foreground(lipgloss.Color("0")) s.Cursor = CursorStyle{ Color: lipgloss.Color("7"), Shape: tea.CursorBlock, @@ -429,6 +661,75 @@ func (m *Model) InsertRune(r rune) { m.insertRunesFromUserInput([]rune{r}) } +// InsertAttachment inserts an attachment at the cursor position. +func (m *Model) InsertAttachment(att *Attachment) { + if m.CharLimit > 0 { + availSpace := m.CharLimit - m.Length() + // If the char limit's been reached, cancel. + if availSpace <= 0 { + return + } + } + + // Insert the attachment at the current cursor position + m.value[m.row] = append( + m.value[m.row][:m.col], + append([]any{att}, m.value[m.row][m.col:]...)...) + m.col++ + m.SetCursorColumn(m.col) +} + +// ReplaceRange replaces text from startCol to endCol on the current row with the given string. +// This preserves attachments outside the replaced range. +func (m *Model) ReplaceRange(startCol, endCol int, replacement string) { + if m.row >= len(m.value) || startCol < 0 || endCol < startCol { + return + } + + // Ensure bounds are within the current row + rowLen := len(m.value[m.row]) + startCol = max(0, min(startCol, rowLen)) + endCol = max(startCol, min(endCol, rowLen)) + + // Create new row content: before + replacement + after + before := m.value[m.row][:startCol] + after := m.value[m.row][endCol:] + replacementRunes := runesToInterfaces([]rune(replacement)) + + // Combine the parts + newRow := make([]any, 0, len(before)+len(replacementRunes)+len(after)) + newRow = append(newRow, before...) + newRow = append(newRow, replacementRunes...) + newRow = append(newRow, after...) + + m.value[m.row] = newRow + + // Position cursor at end of replacement + m.col = startCol + len(replacementRunes) + m.SetCursorColumn(m.col) +} + +// CurrentRowLength returns the length of the current row. +func (m *Model) CurrentRowLength() int { + if m.row >= len(m.value) { + return 0 + } + return len(m.value[m.row]) +} + +// GetAttachments returns all attachments in the textarea. +func (m Model) GetAttachments() []*Attachment { + var attachments []*Attachment + for _, row := range m.value { + for _, item := range row { + if att, ok := item.(*Attachment); ok { + attachments = append(attachments, att) + } + } + } + return attachments +} + // insertRunesFromUserInput inserts runes at the current cursor position. func (m *Model) insertRunesFromUserInput(runes []rune) { // Clean up any special characters in the input provided by the @@ -481,23 +782,22 @@ func (m *Model) insertRunesFromUserInput(runes []rune) { // Save the remainder of the original line at the current // cursor position. - tail := make([]rune, len(m.value[m.row][m.col:])) - copy(tail, m.value[m.row][m.col:]) + tail := copyInterfaceSlice(m.value[m.row][m.col:]) // Paste the first line at the current cursor position. - m.value[m.row] = append(m.value[m.row][:m.col], lines[0]...) + m.value[m.row] = append(m.value[m.row][:m.col], runesToInterfaces(lines[0])...) m.col += len(lines[0]) if numExtraLines := len(lines) - 1; numExtraLines > 0 { // Add the new lines. // We try to reuse the slice if there's already space. - var newGrid [][]rune + var newGrid [][]any if cap(m.value) >= len(m.value)+numExtraLines { // Can reuse the extra space. newGrid = m.value[:len(m.value)+numExtraLines] } else { // No space left; need a new slice. - newGrid = make([][]rune, len(m.value)+numExtraLines) + newGrid = make([][]any, len(m.value)+numExtraLines) copy(newGrid, m.value[:m.row+1]) } // Add all the rows that were after the cursor in the original @@ -507,7 +807,7 @@ func (m *Model) insertRunesFromUserInput(runes []rune) { // Insert all the new lines in the middle. for _, l := range lines[1:] { m.row++ - m.value[m.row] = l + m.value[m.row] = runesToInterfaces(l) m.col = len(l) } } @@ -526,7 +826,14 @@ func (m Model) Value() string { var v strings.Builder for _, l := range m.value { - v.WriteString(string(l)) + for _, item := range l { + switch val := item.(type) { + case rune: + v.WriteRune(val) + case *Attachment: + v.WriteString(val.Display) + } + } v.WriteByte('\n') } @@ -537,7 +844,14 @@ func (m Model) Value() string { func (m *Model) Length() int { var l int for _, row := range m.value { - l += uniseg.StringWidth(string(row)) + for _, item := range row { + switch val := item.(type) { + case rune: + l += rw.RuneWidth(val) + case *Attachment: + l += uniseg.StringWidth(val.Display) + } + } } // We add len(m.value) to include the newline characters. return l + len(m.value) - 1 @@ -553,6 +867,29 @@ func (m Model) Line() int { return m.row } +// CursorColumn returns the cursor's column position (slice index). +func (m Model) CursorColumn() int { + return m.col +} + +// LastRuneIndex returns the index of the last occurrence of a rune on the current line, +// searching backwards from the current cursor position. +// Returns -1 if the rune is not found before the cursor. +func (m Model) LastRuneIndex(r rune) int { + if m.row >= len(m.value) { + return -1 + } + // Iterate backwards from just before the cursor position + for i := m.col - 1; i >= 0; i-- { + if i < len(m.value[m.row]) { + if item, ok := m.value[m.row][i].(rune); ok && item == r { + return i + } + } + } + return -1 +} + func (m *Model) Newline() { if m.MaxHeight > 0 && len(m.value) >= m.MaxHeight { return @@ -561,6 +898,39 @@ func (m *Model) Newline() { m.splitLine(m.row, m.col) } +// mapVisualOffsetToSliceIndex converts a visual column offset to a slice index. +// This is used to maintain the cursor's horizontal position when moving vertically. +func (m *Model) mapVisualOffsetToSliceIndex(row int, charOffset int) int { + if row < 0 || row >= len(m.value) { + return 0 + } + + offset := 0 + // Find the slice index that corresponds to the visual offset. + for i, item := range m.value[row] { + var itemWidth int + switch v := item.(type) { + case rune: + itemWidth = rw.RuneWidth(v) + case *Attachment: + itemWidth = uniseg.StringWidth(v.Display) + } + + // If the target offset falls within the current item, this is our index. + if offset+itemWidth > charOffset { + // Decide whether to stick with the previous index or move to the current + // one based on which is closer to the target offset. + if (charOffset - offset) > ((offset + itemWidth) - charOffset) { + return i + 1 + } + return i + } + offset += itemWidth + } + + return len(m.value[row]) +} + // CursorDown moves the cursor down by one line. // Returns whether or not the cursor blink should be reset. func (m *Model) CursorDown() { @@ -569,31 +939,15 @@ func (m *Model) CursorDown() { m.lastCharOffset = charOffset if li.RowOffset+1 >= li.Height && m.row < len(m.value)-1 { + // Move to the next model line m.row++ - m.col = 0 - } else { - // Move the cursor to the start of the next line so that we can get - // the line information. We need to add 2 columns to account for the - // trailing space wrapping. - const trailingSpace = 2 - m.col = min(li.StartColumn+li.Width+trailingSpace, len(m.value[m.row])-1) - } - - nli := m.LineInfo() - m.col = nli.StartColumn - - if nli.Width <= 0 { - return - } - - offset := 0 - for offset < charOffset { - if m.row >= len(m.value) || m.col >= len(m.value[m.row]) || offset >= nli.CharWidth-1 { - break - } - offset += rw.RuneWidth(m.value[m.row][m.col]) - m.col++ + m.col = m.mapVisualOffsetToSliceIndex(m.row, charOffset) + } else if li.RowOffset+1 < li.Height { + // Move to the next wrapped line within the same model line + startOfNextWrappedLine := li.StartColumn + li.Width + m.col = startOfNextWrappedLine + m.mapVisualOffsetToSliceIndex(m.row, charOffset) } + m.SetCursorColumn(m.col) } // CursorUp moves the cursor up by one line. @@ -603,32 +957,24 @@ func (m *Model) CursorUp() { m.lastCharOffset = charOffset if li.RowOffset <= 0 && m.row > 0 { + // Move to the previous model line m.row-- - m.col = len(m.value[m.row]) - } else { - // Move the cursor to the end of the previous line. - // This can be done by moving the cursor to the start of the line and - // then subtracting 2 to account for the trailing space we keep on - // soft-wrapped lines. - const trailingSpace = 2 - m.col = li.StartColumn - trailingSpace - } - - nli := m.LineInfo() - m.col = nli.StartColumn - - if nli.Width <= 0 { - return - } - - offset := 0 - for offset < charOffset { - if m.col >= len(m.value[m.row]) || offset >= nli.CharWidth-1 { - break + m.col = m.mapVisualOffsetToSliceIndex(m.row, charOffset) + } else if li.RowOffset > 0 { + // Move to the previous wrapped line within the same model line + // To do this, we need to find the start of the previous wrapped line. + prevLineInfo := m.LineInfo() + // prevLineStart := 0 + if prevLineInfo.RowOffset > 0 { + // This is complex, so we'll approximate by moving to the start of the current wrapped line + // and then letting characterLeft handle it. A more precise calculation would + // require re-wrapping to find the previous line's start. + // For now, a simpler approach: + m.col = li.StartColumn - 1 } - offset += rw.RuneWidth(m.value[m.row][m.col]) - m.col++ + m.col = m.mapVisualOffsetToSliceIndex(m.row, charOffset) } + m.SetCursorColumn(m.col) } // SetCursorColumn moves the cursor to the given position. If the position is @@ -680,7 +1026,7 @@ func (m *Model) Blur() { // Reset sets the input to its default state with no input. func (m *Model) Reset() { - m.value = make([][]rune, minHeight, maxLines) + m.value = make([][]any, minHeight, maxLines) m.col = 0 m.row = 0 m.SetCursorColumn(0) @@ -741,7 +1087,7 @@ func (m *Model) deleteWordLeft() { oldCol := m.col //nolint:ifshort m.SetCursorColumn(m.col - 1) - for unicode.IsSpace(m.value[m.row][m.col]) { + for isSpaceAt(m.value[m.row], m.col) { if m.col <= 0 { break } @@ -750,7 +1096,7 @@ func (m *Model) deleteWordLeft() { } for m.col > 0 { - if !unicode.IsSpace(m.value[m.row][m.col]) { + if !isSpaceAt(m.value[m.row], m.col) { m.SetCursorColumn(m.col - 1) } else { if m.col > 0 { @@ -776,13 +1122,13 @@ func (m *Model) deleteWordRight() { oldCol := m.col - for m.col < len(m.value[m.row]) && unicode.IsSpace(m.value[m.row][m.col]) { + for m.col < len(m.value[m.row]) && isSpaceAt(m.value[m.row], m.col) { // ignore series of whitespace after cursor m.SetCursorColumn(m.col + 1) } for m.col < len(m.value[m.row]) { - if !unicode.IsSpace(m.value[m.row][m.col]) { + if !isSpaceAt(m.value[m.row], m.col) { m.SetCursorColumn(m.col + 1) } else { break @@ -832,13 +1178,13 @@ func (m *Model) characterLeft(insideLine bool) { func (m *Model) wordLeft() { for { m.characterLeft(true /* insideLine */) - if m.col < len(m.value[m.row]) && !unicode.IsSpace(m.value[m.row][m.col]) { + if m.col < len(m.value[m.row]) && !isSpaceAt(m.value[m.row], m.col) { break } } for m.col > 0 { - if unicode.IsSpace(m.value[m.row][m.col-1]) { + if isSpaceAt(m.value[m.row], m.col-1) { break } m.SetCursorColumn(m.col - 1) @@ -854,7 +1200,7 @@ func (m *Model) wordRight() { func (m *Model) doWordRight(fn func(charIdx int, pos int)) { // Skip spaces forward. - for m.col >= len(m.value[m.row]) || unicode.IsSpace(m.value[m.row][m.col]) { + for m.col >= len(m.value[m.row]) || isSpaceAt(m.value[m.row], m.col) { if m.row == len(m.value)-1 && m.col == len(m.value[m.row]) { // End of text. break @@ -864,7 +1210,7 @@ func (m *Model) doWordRight(fn func(charIdx int, pos int)) { charIdx := 0 for m.col < len(m.value[m.row]) { - if unicode.IsSpace(m.value[m.row][m.col]) { + if isSpaceAt(m.value[m.row], m.col) { break } fn(charIdx, m.col) @@ -876,14 +1222,18 @@ func (m *Model) doWordRight(fn func(charIdx int, pos int)) { // uppercaseRight changes the word to the right to uppercase. func (m *Model) uppercaseRight() { m.doWordRight(func(_ int, i int) { - m.value[m.row][i] = unicode.ToUpper(m.value[m.row][i]) + if r, ok := m.value[m.row][i].(rune); ok { + m.value[m.row][i] = unicode.ToUpper(r) + } }) } // lowercaseRight changes the word to the right to lowercase. func (m *Model) lowercaseRight() { m.doWordRight(func(_ int, i int) { - m.value[m.row][i] = unicode.ToLower(m.value[m.row][i]) + if r, ok := m.value[m.row][i].(rune); ok { + m.value[m.row][i] = unicode.ToLower(r) + } }) } @@ -891,7 +1241,9 @@ func (m *Model) lowercaseRight() { func (m *Model) capitalizeRight() { m.doWordRight(func(charIdx int, i int) { if charIdx == 0 { - m.value[m.row][i] = unicode.ToTitle(m.value[m.row][i]) + if r, ok := m.value[m.row][i].(rune); ok { + m.value[m.row][i] = unicode.ToTitle(r) + } } }) } @@ -905,34 +1257,39 @@ func (m Model) LineInfo() LineInfo { // m.col and counting the number of runes that we need to skip. var counter int for i, line := range grid { - // We've found the line that we are on - if counter+len(line) == m.col && i+1 < len(grid) { - // We wrap around to the next line if we are at the end of the - // previous line so that we can be at the very beginning of the row - return LineInfo{ - CharOffset: 0, - ColumnOffset: 0, - Height: len(grid), - RowOffset: i + 1, - StartColumn: m.col, - Width: len(grid[i+1]), - CharWidth: uniseg.StringWidth(string(line)), - } - } + start := counter + end := counter + len(line) + + if m.col >= start && m.col <= end { + // This is the wrapped line the cursor is on. + + // Special case: if the cursor is at the end of a wrapped line, + // and there's another wrapped line after it, the cursor should + // be considered at the beginning of the next line. + if m.col == end && i < len(grid)-1 { + nextLine := grid[i+1] + return LineInfo{ + CharOffset: 0, + ColumnOffset: 0, + Height: len(grid), + RowOffset: i + 1, + StartColumn: end, + Width: len(nextLine), + CharWidth: uniseg.StringWidth(interfacesToString(nextLine)), + } + } - if counter+len(line) >= m.col { return LineInfo{ - CharOffset: uniseg.StringWidth(string(line[:max(0, m.col-counter)])), - ColumnOffset: m.col - counter, + CharOffset: uniseg.StringWidth(interfacesToString(line[:max(0, m.col-start)])), + ColumnOffset: m.col - start, Height: len(grid), RowOffset: i, - StartColumn: counter, + StartColumn: start, Width: len(line), - CharWidth: uniseg.StringWidth(string(line)), + CharWidth: uniseg.StringWidth(interfacesToString(line)), } } - - counter += len(line) + counter = end } return LineInfo{} } @@ -1060,12 +1417,15 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { var cmds []tea.Cmd + if m.row >= len(m.value) { + m.value = append(m.value, make([]any, 0)) + } if m.value[m.row] == nil { - m.value[m.row] = make([]rune, 0) + m.value[m.row] = make([]any, 0) } if m.MaxHeight > 0 && m.MaxHeight != m.cache.Capacity() { - m.cache = NewMemoCache[line, [][]rune](m.MaxHeight) + m.cache = NewMemoCache[line, [][]any](m.MaxHeight) } switch msg := msg.(type) { @@ -1093,11 +1453,9 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.mergeLineAbove(m.row) break } - if len(m.value[m.row]) > 0 { - m.value[m.row] = append(m.value[m.row][:max(0, m.col-1)], m.value[m.row][m.col:]...) - if m.col > 0 { - m.SetCursorColumn(m.col - 1) - } + if len(m.value[m.row]) > 0 && m.col > 0 { + m.value[m.row] = slices.Delete(m.value[m.row], m.col-1, m.col) + m.SetCursorColumn(m.col - 1) } case key.Matches(msg, m.KeyMap.DeleteCharacterForward): if len(m.value[m.row]) > 0 && m.col < len(m.value[m.row]) { @@ -1154,7 +1512,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.transposeLeft() default: - m.insertRunesFromUserInput([]rune(msg.Text)) + m.insertRunesFromUserInput([]rune{msg.Code}) } case pasteMsg: @@ -1226,7 +1584,8 @@ func (m Model) View() string { widestLineNumber = lnw } - strwidth := uniseg.StringWidth(string(wrappedLine)) + wrappedLineStr := interfacesToString(wrappedLine) + strwidth := uniseg.StringWidth(wrappedLineStr) padding := m.width - strwidth // If the trailing space causes the line to be wider than the // width, we should not draw it to the screen since it will result @@ -1236,22 +1595,46 @@ func (m Model) View() string { // The character causing the line to be wider than the width is // guaranteed to be a space since any other character would // have been wrapped. - wrappedLine = []rune(strings.TrimSuffix(string(wrappedLine), " ")) + wrappedLineStr = strings.TrimSuffix(wrappedLineStr, " ") padding -= m.width - strwidth } + if m.row == l && lineInfo.RowOffset == wl { - s.WriteString(style.Render(string(wrappedLine[:lineInfo.ColumnOffset]))) + // Render the part of the line before the cursor + s.WriteString( + m.renderLineWithAttachments( + wrappedLine[:lineInfo.ColumnOffset], + style, + ), + ) + if m.col >= len(line) && lineInfo.CharOffset >= m.width { m.virtualCursor.SetChar(" ") s.WriteString(m.virtualCursor.View()) + } else if lineInfo.ColumnOffset < len(wrappedLine) { + // Render the item under the cursor + item := wrappedLine[lineInfo.ColumnOffset] + if att, ok := item.(*Attachment); ok { + // Item at cursor is an attachment. Render it with the selection style. + // This becomes the "cursor" visually. + s.WriteString(m.Styles.SelectedAttachment.Render(att.Display)) + } else { + // Item at cursor is a rune. Render it with the virtual cursor. + m.virtualCursor.SetChar(string(item.(rune))) + s.WriteString(style.Render(m.virtualCursor.View())) + } + + // Render the part of the line after the cursor + s.WriteString(m.renderLineWithAttachments(wrappedLine[lineInfo.ColumnOffset+1:], style)) } else { - m.virtualCursor.SetChar(string(wrappedLine[lineInfo.ColumnOffset])) + // Cursor is at the end of the line + m.virtualCursor.SetChar(" ") s.WriteString(style.Render(m.virtualCursor.View())) - s.WriteString(style.Render(string(wrappedLine[lineInfo.ColumnOffset+1:]))) } } else { - s.WriteString(style.Render(string(wrappedLine))) + s.WriteString(m.renderLineWithAttachments(wrappedLine, style)) } + s.WriteString(style.Render(strings.Repeat(" ", max(0, padding)))) s.WriteRune('\n') newLines++ @@ -1443,12 +1826,12 @@ func (m Model) Cursor() *tea.Cursor { return c } -func (m Model) memoizedWrap(runes []rune, width int) [][]rune { - input := line{runes: runes, width: width} +func (m Model) memoizedWrap(content []any, width int) [][]any { + input := line{content: content, width: width} if v, ok := m.cache.Get(input); ok { return v } - v := wrap(runes, width) + v := wrapInterfaces(content, width) m.cache.Set(input, v) return v } @@ -1514,8 +1897,7 @@ func (m *Model) splitLine(row, col int) { // the cursor, take the content after the cursor and make it the content of // the line underneath, and shift the remaining lines down by one head, tailSrc := m.value[row][:col], m.value[row][col:] - tail := make([]rune, len(tailSrc)) - copy(tail, tailSrc) + tail := copyInterfaceSlice(tailSrc) m.value = append(m.value[:row+1], m.value[row:]...) @@ -1535,66 +1917,84 @@ func Paste() tea.Msg { return pasteMsg(str) } -func wrap(runes []rune, width int) [][]rune { +func wrapInterfaces(content []any, width int) [][]any { + if width <= 0 { + return [][]any{content} + } + var ( - lines = [][]rune{{}} - word = []rune{} - row int - spaces int + lines = [][]any{{}} + word = []any{} + wordW int + lineW int + spaceW int + inSpaces bool ) - // Word wrap the runes - for _, r := range runes { - if unicode.IsSpace(r) { - spaces++ - } else { - word = append(word, r) + for _, item := range content { + itemW := 0 + isSpace := false + + if r, ok := item.(rune); ok { + if unicode.IsSpace(r) { + isSpace = true + } + itemW = rw.RuneWidth(r) + } else if att, ok := item.(*Attachment); ok { + itemW = uniseg.StringWidth(att.Display) } - if spaces > 0 { //nolint:nestif - if uniseg.StringWidth(string(lines[row]))+uniseg.StringWidth(string(word))+spaces > width { - row++ - lines = append(lines, []rune{}) - lines[row] = append(lines[row], word...) - lines[row] = append(lines[row], repeatSpaces(spaces)...) - spaces = 0 - word = nil - } else { - lines[row] = append(lines[row], word...) - lines[row] = append(lines[row], repeatSpaces(spaces)...) - spaces = 0 - word = nil - } - } else { - // If the last character is a double-width rune, then we may not be able to add it to this line - // as it might cause us to go past the width. - lastCharLen := rw.RuneWidth(word[len(word)-1]) - if uniseg.StringWidth(string(word))+lastCharLen > width { - // If the current line has any content, let's move to the next - // line because the current word fills up the entire line. - if len(lines[row]) > 0 { - row++ - lines = append(lines, []rune{}) + if isSpace { + if !inSpaces { + // End of a word + if lineW > 0 && lineW+wordW > width { + lines = append(lines, word) + lineW = wordW + } else { + lines[len(lines)-1] = append(lines[len(lines)-1], word...) + lineW += wordW } - lines[row] = append(lines[row], word...) word = nil + wordW = 0 } + inSpaces = true + spaceW += itemW + } else { + if inSpaces { + // End of spaces + if lineW > 0 && lineW+spaceW > width { + lines = append(lines, []any{}) + lineW = 0 + } else { + lineW += spaceW + } + // Add spaces to current line + for i := 0; i < spaceW; i++ { + lines[len(lines)-1] = append(lines[len(lines)-1], rune(' ')) + } + spaceW = 0 + } + inSpaces = false + word = append(word, item) + wordW += itemW } } - if uniseg.StringWidth(string(lines[row]))+uniseg.StringWidth(string(word))+spaces >= width { - lines = append(lines, []rune{}) - lines[row+1] = append(lines[row+1], word...) - // We add an extra space at the end of the line to account for the - // trailing space at the end of the previous soft-wrapped lines so that - // behaviour when navigating is consistent and so that we don't need to - // continually add edges to handle the last line of the wrapped input. - spaces++ - lines[row+1] = append(lines[row+1], repeatSpaces(spaces)...) - } else { - lines[row] = append(lines[row], word...) - spaces++ - lines[row] = append(lines[row], repeatSpaces(spaces)...) + // Handle any remaining word/spaces + if wordW > 0 { + if lineW > 0 && lineW+wordW > width { + lines = append(lines, word) + } else { + lines[len(lines)-1] = append(lines[len(lines)-1], word...) + } + } + if spaceW > 0 { + if lineW > 0 && lineW+spaceW > width { + lines = append(lines, []any{}) + } + for i := 0; i < spaceW; i++ { + lines[len(lines)-1] = append(lines[len(lines)-1], rune(' ')) + } } return lines diff --git a/packages/tui/internal/layout/flex_example_test.go b/packages/tui/internal/layout/flex_example_test.go deleted file mode 100644 index a03346eb..00000000 --- a/packages/tui/internal/layout/flex_example_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package layout_test - -import ( - "fmt" - "github.com/sst/opencode/internal/layout" -) - -func ExampleRender_withGap() { - // Create a horizontal layout with 3px gap between items - result := layout.Render( - layout.FlexOptions{ - Direction: layout.Row, - Width: 30, - Height: 1, - Gap: 3, - }, - layout.FlexItem{View: "Item1"}, - layout.FlexItem{View: "Item2"}, - layout.FlexItem{View: "Item3"}, - ) - fmt.Println(result) - // Output: Item1 Item2 Item3 -} - -func ExampleRender_withGapAndJustify() { - // Create a horizontal layout with gap and space-between justification - result := layout.Render( - layout.FlexOptions{ - Direction: layout.Row, - Width: 30, - Height: 1, - Gap: 2, - Justify: layout.JustifySpaceBetween, - }, - layout.FlexItem{View: "A"}, - layout.FlexItem{View: "B"}, - layout.FlexItem{View: "C"}, - ) - fmt.Println(result) - // Output: A B C -} diff --git a/packages/tui/internal/layout/flex_test.go b/packages/tui/internal/layout/flex_test.go deleted file mode 100644 index cad38dc8..00000000 --- a/packages/tui/internal/layout/flex_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package layout - -import ( - "strings" - "testing" -) - -func TestFlexGap(t *testing.T) { - tests := []struct { - name string - opts FlexOptions - items []FlexItem - expected string - }{ - { - name: "Row with gap", - opts: FlexOptions{ - Direction: Row, - Width: 20, - Height: 1, - Gap: 2, - }, - items: []FlexItem{ - {View: "A"}, - {View: "B"}, - {View: "C"}, - }, - expected: "A B C", - }, - { - name: "Column with gap", - opts: FlexOptions{ - Direction: Column, - Width: 1, - Height: 5, - Gap: 1, - Align: AlignStart, - }, - items: []FlexItem{ - {View: "A", FixedSize: 1}, - {View: "B", FixedSize: 1}, - {View: "C", FixedSize: 1}, - }, - expected: "A\n \nB\n \nC", - }, - { - name: "Row with gap and justify space between", - opts: FlexOptions{ - Direction: Row, - Width: 15, - Height: 1, - Gap: 1, - Justify: JustifySpaceBetween, - }, - items: []FlexItem{ - {View: "A"}, - {View: "B"}, - {View: "C"}, - }, - expected: "A B C", - }, - { - name: "No gap specified", - opts: FlexOptions{ - Direction: Row, - Width: 10, - Height: 1, - }, - items: []FlexItem{ - {View: "A"}, - {View: "B"}, - {View: "C"}, - }, - expected: "ABC", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := Render(tt.opts, tt.items...) - // Trim any trailing spaces for comparison - result = strings.TrimRight(result, " ") - expected := strings.TrimRight(tt.expected, " ") - - if result != expected { - t.Errorf("Render() = %q, want %q", result, expected) - } - }) - } -} diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 29235229..150a1b26 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -52,7 +52,9 @@ type appModel struct { messages chat.MessagesComponent completions dialog.CompletionDialog commandProvider dialog.CompletionProvider + fileProvider dialog.CompletionProvider showCompletionDialog bool + fileCompletionActive bool leaderBinding *key.Binding isLeaderSequence bool toastManager *toast.ToastManager @@ -180,11 +182,33 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { !a.showCompletionDialog && a.editor.Value() == "" { a.showCompletionDialog = true + a.fileCompletionActive = false updated, cmd := a.editor.Update(msg) a.editor = updated.(chat.EditorComponent) cmds = append(cmds, cmd) + // Set command provider for command completion + a.completions = dialog.NewCompletionDialogComponent(a.commandProvider) + updated, cmd = a.completions.Update(msg) + a.completions = updated.(dialog.CompletionDialog) + cmds = append(cmds, cmd) + + return a, tea.Sequence(cmds...) + } + + // Handle file completions trigger + if keyString == "@" && + !a.showCompletionDialog { + a.showCompletionDialog = true + a.fileCompletionActive = true + + updated, cmd := a.editor.Update(msg) + a.editor = updated.(chat.EditorComponent) + cmds = append(cmds, cmd) + + // Set file provider for file completion + a.completions = dialog.NewCompletionDialogComponent(a.fileProvider) updated, cmd = a.completions.Update(msg) a.completions = updated.(dialog.CompletionDialog) cmds = append(cmds, cmd) @@ -194,7 +218,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.showCompletionDialog { switch keyString { - case "tab", "enter", "esc", "ctrl+c": + case "tab", "enter", "esc", "ctrl+c", "up", "down": updated, cmd := a.completions.Update(msg) a.completions = updated.(dialog.CompletionDialog) cmds = append(cmds, cmd) @@ -326,10 +350,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, toast.NewErrorToast(msg.Error()) case app.SendMsg: a.showCompletionDialog = false - cmd := a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments) + a.app, cmd = a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments) cmds = append(cmds, cmd) case dialog.CompletionDialogCloseMsg: a.showCompletionDialog = false + a.fileCompletionActive = false case opencode.EventListResponseEventInstallationUpdated: return a, toast.NewSuccessToast( "opencode updated to "+msg.Properties.Version+", restart to apply.", @@ -778,11 +803,8 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) return nil } os.Remove(tmpfile.Name()) - // attachments := m.attachments - // m.attachments = nil return app.SendMsg{ - Text: string(content), - Attachments: []app.Attachment{}, // attachments, + Text: string(content), } }) cmds = append(cmds, cmd) @@ -954,6 +976,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) func NewModel(app *app.App) tea.Model { commandProvider := completions.NewCommandCompletionProvider(app) + fileProvider := completions.NewFileAndFolderContextGroup(app) messages := chat.NewMessagesComponent(app) editor := chat.NewEditorComponent(app) @@ -972,9 +995,11 @@ func NewModel(app *app.App) tea.Model { messages: messages, completions: completions, commandProvider: commandProvider, + fileProvider: fileProvider, leaderBinding: leaderBinding, isLeaderSequence: false, showCompletionDialog: false, + fileCompletionActive: false, toastManager: toast.NewToastManager(), interruptKeyState: InterruptKeyIdle, fileViewer: fileviewer.New(app), From 94ef341c9dfd59a070ed4c855e973f99009bcf7e Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Fri, 4 Jul 2025 10:54:53 -0500 Subject: [PATCH 06/37] feat(tui): render attachments --- .../tui/internal/components/chat/message.go | 7 ++- .../tui/internal/components/chat/messages.go | 47 ++++++++++++++++++- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go index 4dde09ea..9e245c8b 100644 --- a/packages/tui/internal/components/chat/message.go +++ b/packages/tui/internal/components/chat/message.go @@ -223,6 +223,7 @@ func renderText( showToolDetails bool, highlight bool, width int, + extra string, toolCalls ...opencode.ToolInvocationPart, ) string { t := theme.CurrentTheme() @@ -269,7 +270,11 @@ func renderText( } } - content = strings.Join([]string{content, info}, "\n") + sections := []string{content, info} + if extra != "" { + sections = append(sections, "\n"+extra) + } + content = strings.Join(sections, "\n") switch message.Role { case opencode.MessageRoleUser: diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index 52288078..d8060c7d 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -9,6 +9,7 @@ import ( "github.com/sst/opencode-sdk-go" "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/components/dialog" + "github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/util" @@ -133,10 +134,49 @@ func (m *messagesComponent) renderView(width int) { switch message.Role { case opencode.MessageRoleUser: - for _, part := range message.Parts { + for partIndex, part := range message.Parts { switch part := part.AsUnion().(type) { case opencode.TextPart: - key := m.cache.GenerateKey(message.ID, part.Text, width, m.selectedPart == m.partCount) + remainingParts := message.Parts[partIndex+1:] + fileParts := make([]opencode.FilePart, 0) + for _, part := range remainingParts { + switch part := part.AsUnion().(type) { + case opencode.FilePart: + fileParts = append(fileParts, part) + } + } + flexItems := []layout.FlexItem{} + if len(fileParts) > 0 { + fileStyle := styles.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Padding(0, 1) + mediaTypeStyle := styles.NewStyle().Background(t.Secondary()).Foreground(t.BackgroundPanel()).Padding(0, 1) + for _, filePart := range fileParts { + mediaType := "" + switch filePart.MediaType { + case "text/plain": + mediaType = "txt" + case "image/png", "image/jpeg", "image/gif", "image/webp": + mediaType = "img" + case "application/pdf": + mediaType = "pdf" + } + + flexItems = append(flexItems, layout.FlexItem{ + View: mediaTypeStyle.Render(mediaType) + fileStyle.Render(filePart.Filename), + }) + } + } + bgColor := t.BackgroundPanel() + files := layout.Render( + layout.FlexOptions{ + Background: &bgColor, + Width: width - 6, + Direction: layout.Row, + Gap: 3, + }, + flexItems..., + ) + + key := m.cache.GenerateKey(message.ID, part.Text, width, m.selectedPart == m.partCount, files) content, cached = m.cache.Get(key) if !cached { content = renderText( @@ -147,6 +187,7 @@ func (m *messagesComponent) renderView(width int) { m.showToolDetails, m.partCount == m.selectedPart, width, + files, ) m.cache.Set(key, content) } @@ -206,6 +247,7 @@ func (m *messagesComponent) renderView(width int) { m.showToolDetails, m.partCount == m.selectedPart, width, + "", toolCallParts..., ) m.cache.Set(key, content) @@ -219,6 +261,7 @@ func (m *messagesComponent) renderView(width int) { m.showToolDetails, m.partCount == m.selectedPart, width, + "", toolCallParts..., ) } From f6108b7be87c06e8fbebb7f52c71ad54438742af Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Fri, 4 Jul 2025 11:13:09 -0500 Subject: [PATCH 07/37] fix(tui): handle pdf and image @ files --- packages/tui/internal/components/chat/editor.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index 595fd4d5..427fcc3c 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -93,12 +93,24 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // The cursor is now at `atIndex` after the replacement. filePath := msg.CompletionValue fileName := filepath.Base(filePath) + extension := filepath.Ext(filePath) + mediaType := "" + switch extension { + case ".jpg": + mediaType = "image/jpeg" + case ".png", ".jpeg", ".gif", ".webp": + mediaType = "image/" + extension[1:] + case ".pdf": + mediaType = "application/pdf" + default: + mediaType = "text/plain" + } attachment := &textarea.Attachment{ ID: uuid.NewString(), Display: "@" + fileName, URL: fmt.Sprintf("file://%s", filePath), Filename: fileName, - MediaType: "text/plain", + MediaType: mediaType, } m.textarea.InsertAttachment(attachment) m.textarea.InsertString(" ") From 32d5db4f0a0b0c1a90ba4301cbf0bb7bc2519613 Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Fri, 4 Jul 2025 11:16:38 -0500 Subject: [PATCH 08/37] fix(tui): markdown wrapping off sometimes --- packages/tui/internal/util/file.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tui/internal/util/file.go b/packages/tui/internal/util/file.go index 2c0987dc..b079f24c 100644 --- a/packages/tui/internal/util/file.go +++ b/packages/tui/internal/util/file.go @@ -83,7 +83,7 @@ func Extension(path string) string { } func ToMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string { - r := styles.GetMarkdownRenderer(width-7, backgroundColor) + r := styles.GetMarkdownRenderer(width-6, backgroundColor) content = strings.ReplaceAll(content, RootPath+"/", "") rendered, _ := r.Render(content) lines := strings.Split(rendered, "\n") From ee01f01271f1e8c04a0efeacad0c36a44fd18515 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 4 Jul 2025 12:16:55 -0400 Subject: [PATCH 09/37] file attachments --- packages/opencode/src/session/index.ts | 64 +++++++++++++++---- .../tui/internal/components/chat/editor.go | 2 +- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 5a2c1b5e..437ce09b 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -1,4 +1,4 @@ -import path from "path" +import path from "node:path" import { App } from "../app/app" import { Identifier } from "../id/id" import { Storage } from "../storage/storage" @@ -15,6 +15,7 @@ import { type UIMessage, type ProviderMetadata, wrapLanguageModel, + type Attachment, } from "ai" import { z, ZodSchema } from "zod" import { Decimal } from "decimal.js" @@ -187,7 +188,6 @@ export namespace Session { export async function unshare(id: string) { const share = await getShare(id) if (!share) return - console.log("share", share) await Storage.remove("session/share/" + id) await update(id, (draft) => { draft.share = undefined @@ -361,6 +361,36 @@ export namespace Session { if (lastSummary) msgs = msgs.filter((msg) => msg.id >= lastSummary.id) const app = App.info() + input.parts = await Promise.all( + input.parts.map(async (part) => { + if (part.type === "file") { + const url = new URL(part.url) + switch (url.protocol) { + case "file:": + let content = await Bun.file( + path.join(app.path.cwd, url.pathname), + ).text() + const range = { + start: url.searchParams.get("start"), + end: url.searchParams.get("end"), + } + if (range.start != null) { + const lines = content.split("\n") + const start = parseInt(range.start) + const end = range.end ? parseInt(range.end) : lines.length + content = lines.slice(start, end).join("\n") + } + return { + type: "file", + url: "data:text/plain;base64," + btoa(content), + mediaType: "text/plain", + filename: part.filename, + } + } + } + return part + }), + ) if (msgs.length === 0 && !session.parentID) { generateText({ maxTokens: input.providerID === "google" ? 1024 : 20, @@ -376,7 +406,7 @@ export namespace Session { { role: "user", content: "", - parts: toParts(input.parts), + ...toParts(input.parts), }, ]), ], @@ -1028,7 +1058,7 @@ function toUIMessage(msg: Message.Info): UIMessage { id: msg.id, role: "assistant", content: "", - parts: toParts(msg.parts), + ...toParts(msg.parts), } } @@ -1037,35 +1067,41 @@ function toUIMessage(msg: Message.Info): UIMessage { id: msg.id, role: "user", content: "", - parts: toParts(msg.parts), + ...toParts(msg.parts), } } throw new Error("not implemented") } -function toParts(parts: Message.MessagePart[]): UIMessage["parts"] { - const result: UIMessage["parts"] = [] +function toParts(parts: Message.MessagePart[]) { + const result: { + parts: UIMessage["parts"] + experimental_attachments: Attachment[] + } = { + parts: [], + experimental_attachments: [], + } for (const part of parts) { switch (part.type) { case "text": - result.push({ type: "text", text: part.text }) + result.parts.push({ type: "text", text: part.text }) break case "file": - result.push({ - type: "file", - data: part.url, - mimeType: part.mediaType, + result.experimental_attachments.push({ + url: part.url, + contentType: part.mediaType, + name: part.filename, }) break case "tool-invocation": - result.push({ + result.parts.push({ type: "tool-invocation", toolInvocation: part.toolInvocation, }) break case "step-start": - result.push({ + result.parts.push({ type: "step-start", }) break diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index 427fcc3c..99925e16 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -108,7 +108,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { attachment := &textarea.Attachment{ ID: uuid.NewString(), Display: "@" + fileName, - URL: fmt.Sprintf("file://%s", filePath), + URL: fmt.Sprintf("file://./%s", filePath), Filename: fileName, MediaType: mediaType, } From b8d276a0494457dd59cd74ae57813ad23e432563 Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Fri, 4 Jul 2025 11:42:22 -0500 Subject: [PATCH 10/37] fix(tui): full paths for attachments --- packages/tui/internal/components/chat/editor.go | 7 ++++--- packages/tui/internal/components/chat/messages.go | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index 99925e16..1516c0c2 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -109,7 +109,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ID: uuid.NewString(), Display: "@" + fileName, URL: fmt.Sprintf("file://./%s", filePath), - Filename: fileName, + Filename: filePath, MediaType: mediaType, } m.textarea.InsertAttachment(attachment) @@ -238,7 +238,8 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) { } if len(value) > 0 && value[len(value)-1] == '\\' { // If the last character is a backslash, remove it and add a newline - m.textarea.SetValue(value[:len(value)-1] + "\n") + m.textarea.ReplaceRange(len(value)-1, len(value), "") + m.textarea.InsertString("\n") return m, nil } @@ -284,7 +285,7 @@ func (m *editorComponent) Paste() (tea.Model, tea.Cmd) { // } // m.attachments = append(m.attachments, attachment) // } else { - m.textarea.SetValue(m.textarea.Value() + text) + m.textarea.InsertString(text) // } return m, nil } diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index d8060c7d..49fdf723 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -156,10 +156,11 @@ func (m *messagesComponent) renderView(width int) { mediaType = "txt" case "image/png", "image/jpeg", "image/gif", "image/webp": mediaType = "img" + mediaTypeStyle = mediaTypeStyle.Background(t.Accent()) case "application/pdf": mediaType = "pdf" + mediaTypeStyle = mediaTypeStyle.Background(t.Primary()) } - flexItems = append(flexItems, layout.FlexItem{ View: mediaTypeStyle.Render(mediaType) + fileStyle.Render(filePart.Filename), }) @@ -170,8 +171,7 @@ func (m *messagesComponent) renderView(width int) { layout.FlexOptions{ Background: &bgColor, Width: width - 6, - Direction: layout.Row, - Gap: 3, + Direction: layout.Column, }, flexItems..., ) From 06dba28bd69134535ad4a1482b7bbda9f26f96d6 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 4 Jul 2025 12:50:41 -0400 Subject: [PATCH 11/37] wip: fix media type --- packages/opencode/src/session/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 437ce09b..2afba471 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -374,7 +374,7 @@ export namespace Session { start: url.searchParams.get("start"), end: url.searchParams.get("end"), } - if (range.start != null) { + if (range.start != null && part.mediaType === "text/plain") { const lines = content.split("\n") const start = parseInt(range.start) const end = range.end ? parseInt(range.end) : lines.length @@ -382,8 +382,8 @@ export namespace Session { } return { type: "file", - url: "data:text/plain;base64," + btoa(content), - mediaType: "text/plain", + url: `data:${part.mediaType};base64,` + btoa(content), + mediaType: part.mediaType, filename: part.filename, } } @@ -406,7 +406,7 @@ export namespace Session { { role: "user", content: "", - ...toParts(input.parts), + parts: toParts(input.parts).parts, }, ]), ], From 143fd8e07635274403874479a53f0b124ac5f433 Mon Sep 17 00:00:00 2001 From: Jay V Date: Fri, 4 Jul 2025 13:33:23 -0400 Subject: [PATCH 12/37] docs: share improve markdown rendering of ai responses --- bun.lock | 8 ++-- packages/web/package.json | 1 + packages/web/src/components/MarkdownView.tsx | 32 ++++++++++--- packages/web/src/components/Share.tsx | 9 +--- .../src/components/markdownview.module.css | 48 +++++++++++++++++-- packages/web/src/components/share.module.css | 7 ++- 6 files changed, 78 insertions(+), 27 deletions(-) diff --git a/bun.lock b/bun.lock index a723e36b..0fec03e8 100644 --- a/bun.lock +++ b/bun.lock @@ -31,7 +31,6 @@ "@openauthjs/openauth": "0.4.3", "@standard-schema/spec": "1.0.0", "ai": "catalog:", - "air": "0.4.14", "decimal.js": "10.5.0", "diff": "8.0.2", "env-paths": "3.0.0", @@ -79,6 +78,7 @@ "lang-map": "0.4.0", "luxon": "3.6.1", "marked": "15.0.12", + "marked-shiki": "1.2.0", "rehype-autolink-headings": "7.1.0", "sharp": "0.32.5", "shiki": "3.4.2", @@ -517,8 +517,6 @@ "ai": ["ai@4.3.16", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/react": "1.2.12", "@ai-sdk/ui-utils": "1.2.11", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["react"] }, "sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g=="], - "air": ["air@0.4.14", "", { "dependencies": { "zephyr": "~1.3.5" } }, "sha512-E8bl9LlSGSQqjxxjeGIrpYpf8jVyJplsdK1bTobh61F7ks+3aLeXL4KbGSJIFsiaSSz5ZExLU51DGztmQSlZTQ=="], - "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], "ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], @@ -1055,6 +1053,8 @@ "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "marked-shiki": ["marked-shiki@1.2.0", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-N924hp8veE6Mc91g5/kCNVoTU7TkeJfB2G2XEWb+k1fVA0Bck2T0rVt93d39BlOYH6ohP4Q9BFlPk+UkblhXbg=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "mdast-util-definitions": ["mdast-util-definitions@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ=="], @@ -1703,8 +1703,6 @@ "youch": ["youch@3.3.4", "", { "dependencies": { "cookie": "^0.7.1", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg=="], - "zephyr": ["zephyr@1.3.6", "", {}, "sha512-oYH52DGZzIbXNrkijskaR8YpVKnXAe8jNgH1KirglVBnTFOn6mK9/0SVCxGn+73l0Hjhr4UYNzYkO07LXSWy6w=="], - "zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="], "zod-openapi": ["zod-openapi@4.2.4", "", { "peerDependencies": { "zod": "^3.21.4" } }, "sha512-tsrQpbpqFCXqVXUzi3TPwFhuMtLN3oNZobOtYnK6/5VkXsNdnIgyNr4r8no4wmYluaxzN3F7iS+8xCW8BmMQ8g=="], diff --git a/packages/web/package.json b/packages/web/package.json index 383b979f..c1722b2b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -24,6 +24,7 @@ "lang-map": "0.4.0", "luxon": "3.6.1", "marked": "15.0.12", + "marked-shiki": "1.2.0", "rehype-autolink-headings": "7.1.0", "sharp": "0.32.5", "shiki": "3.4.2", diff --git a/packages/web/src/components/MarkdownView.tsx b/packages/web/src/components/MarkdownView.tsx index 5e21c0d7..7a63bc0c 100644 --- a/packages/web/src/components/MarkdownView.tsx +++ b/packages/web/src/components/MarkdownView.tsx @@ -1,21 +1,39 @@ import { type JSX, splitProps, createResource } from "solid-js" import { marked } from "marked" +import markedShiki from "marked-shiki" +import { codeToHtml } from "shiki" +import { transformerNotationDiff } from "@shikijs/transformers" import styles from "./markdownview.module.css" interface MarkdownViewProps extends JSX.HTMLAttributes { markdown: string } +const markedWithShiki = marked.use( + markedShiki({ + highlight(code, lang) { + return codeToHtml(code, { + lang: lang || "text", + themes: { + light: "github-light", + dark: "github-dark", + }, + transformers: [transformerNotationDiff()], + }) + }, + }), +) + function MarkdownView(props: MarkdownViewProps) { const [local, rest] = splitProps(props, ["markdown"]) - const [html] = createResource(() => local.markdown, async (markdown) => { - return marked.parse(markdown) - }) - - return ( -
+ const [html] = createResource( + () => local.markdown, + async (markdown) => { + return markedWithShiki.parse(markdown) + }, ) + + return
} export default MarkdownView - diff --git a/packages/web/src/components/Share.tsx b/packages/web/src/components/Share.tsx index ff838dab..7f2c45b1 100644 --- a/packages/web/src/components/Share.tsx +++ b/packages/web/src/components/Share.tsx @@ -294,15 +294,11 @@ function ResultsButton(props: ResultsButtonProps) { interface TextPartProps extends JSX.HTMLAttributes { text: string expand?: boolean - invert?: boolean - highlight?: boolean } function TextPart(props: TextPartProps) { const [local, rest] = splitProps(props, [ "text", "expand", - "invert", - "highlight", ]) const [expanded, setExpanded] = createSignal(false) const [overflowed, setOverflowed] = createSignal(false) @@ -332,8 +328,6 @@ function TextPart(props: TextPartProps) { return (
@@ -991,9 +985,9 @@ export default function Share(props: {
@@ -1021,7 +1015,6 @@ export default function Share(props: {
diff --git a/packages/web/src/components/markdownview.module.css b/packages/web/src/components/markdownview.module.css index a4360bde..9524c5cd 100644 --- a/packages/web/src/components/markdownview.module.css +++ b/packages/web/src/components/markdownview.module.css @@ -40,11 +40,17 @@ } pre { - white-space: pre-wrap; - border-radius: 0.25rem; - border: 1px solid rgba(0, 0, 0, 0.2); + --shiki-dark-bg: var(--sl-color-bg-surface) !important; + background-color: var(--sl-color-bg-surface) !important; padding: 0.5rem 0.75rem; + line-height: 1.6; font-size: 0.75rem; + white-space: pre-wrap; + word-break: break-word; + + span { + white-space: break-spaces; + } } code { @@ -61,4 +67,40 @@ } } } + + table { + border-collapse: collapse; + width: 100%; + } + + th, + td { + border: 1px solid var(--sl-color-border); + padding: 0.5rem 0.75rem; + text-align: left; + } + + th { + border-bottom: 1px solid var(--sl-color-border); + } + + /* Remove outer borders */ + table tr:first-child th, + table tr:first-child td { + border-top: none; + } + + table tr:last-child td { + border-bottom: none; + } + + table th:first-child, + table td:first-child { + border-left: none; + } + + table th:last-child, + table td:last-child { + border-right: none; + } } diff --git a/packages/web/src/components/share.module.css b/packages/web/src/components/share.module.css index dafbdd8a..d8eac0e5 100644 --- a/packages/web/src/components/share.module.css +++ b/packages/web/src/components/share.module.css @@ -493,9 +493,8 @@ } } - &[data-highlight="true"] { - background-color: var(--sl-color-blue-low); - } + &[data-background="none"] { background-color: transparent; } + &[data-background="blue"] { background-color: var(--sl-color-blue-low); } &[data-expanded="true"] { pre { @@ -669,7 +668,7 @@ } .message-markdown { - background-color: var(--sl-color-bg-surface); + border: 1px solid var(--sl-color-blue-high); padding: 0.5rem calc(0.5rem + 3px); border-radius: 0.25rem; display: flex; From 994368de15f580d02f54fa244bac6375aece9a46 Mon Sep 17 00:00:00 2001 From: Jay V Date: Fri, 4 Jul 2025 13:53:20 -0400 Subject: [PATCH 13/37] docs: share fix scrolling again --- packages/web/src/components/Share.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/web/src/components/Share.tsx b/packages/web/src/components/Share.tsx index 7f2c45b1..e2e880f6 100644 --- a/packages/web/src/components/Share.tsx +++ b/packages/web/src/components/Share.tsx @@ -601,6 +601,7 @@ export default function Share(props: { messages: Record }) { let lastScrollY = 0 + let hasScrolledToAnchor = false let scrollTimeout: number | undefined let scrollSentinel: HTMLElement | undefined let scrollObserver: IntersectionObserver | undefined @@ -954,9 +955,11 @@ export default function Share(props: { // Wait till all parts are loaded if ( hash !== "" + && !hasScrolledToAnchor && msg.parts.length === partIndex() + 1 && data().messages.length === msgIndex() + 1 ) { + hasScrolledToAnchor = true scrollToAnchor(hash) } }) From 45b139390caa95038266d97c7de7e5b86fda5e7c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 4 Jul 2025 16:20:12 -0400 Subject: [PATCH 14/37] make file attachments work good like --- packages/opencode/src/session/index.ts | 64 +++++++++++++------ .../tui/internal/components/chat/messages.go | 3 + 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 2afba471..7dd0b325 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -362,35 +362,57 @@ export namespace Session { const app = App.info() input.parts = await Promise.all( - input.parts.map(async (part) => { + input.parts.map(async (part): Promise => { if (part.type === "file") { const url = new URL(part.url) switch (url.protocol) { case "file:": - let content = await Bun.file( - path.join(app.path.cwd, url.pathname), - ).text() - const range = { - start: url.searchParams.get("start"), - end: url.searchParams.get("end"), - } - if (range.start != null && part.mediaType === "text/plain") { - const lines = content.split("\n") - const start = parseInt(range.start) - const end = range.end ? parseInt(range.end) : lines.length - content = lines.slice(start, end).join("\n") - } - return { - type: "file", - url: `data:${part.mediaType};base64,` + btoa(content), - mediaType: part.mediaType, - filename: part.filename, + let content = Bun.file(path.join(app.path.cwd, url.pathname)) + + if (part.mediaType === "text/plain") { + let text = await content.text() + const range = { + start: url.searchParams.get("start"), + end: url.searchParams.get("end"), + } + if (range.start != null && part.mediaType === "text/plain") { + const lines = text.split("\n") + const start = parseInt(range.start) + const end = range.end ? parseInt(range.end) : lines.length + text = lines.slice(start, end).join("\n") + } + return [ + { + type: "text", + text: [ + "Called the Read tool on " + url.pathname, + "", + text, + "", + ].join("\n"), + }, + ] } + + return [ + { + type: "text", + text: ["Called the Read tool on " + url.pathname].join("\n"), + }, + { + type: "file", + url: + `data:${part.mediaType};base64,` + + Buffer.from(await content.bytes()).toString("base64url"), + mediaType: part.mediaType, + filename: path.basename(part.filename!), + }, + ] } } - return part + return [part] }), - ) + ).then((x) => x.flat()) if (msgs.length === 0 && !session.parentID) { generateText({ maxTokens: input.providerID === "google" ? 1024 : 20, diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index 49fdf723..3d001130 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -134,6 +134,7 @@ func (m *messagesComponent) renderView(width int) { switch message.Role { case opencode.MessageRoleUser: + userLoop: for partIndex, part := range message.Parts { switch part := part.AsUnion().(type) { case opencode.TextPart: @@ -195,6 +196,8 @@ func (m *messagesComponent) renderView(width int) { m = m.updateSelected(content, part.Text) blocks = append(blocks, content) } + // Only render the first text part + break userLoop } } From 997cb2d945278ea8c37506b96d30024e2fc6a68b Mon Sep 17 00:00:00 2001 From: Timo Clasen Date: Fri, 4 Jul 2025 23:06:57 +0200 Subject: [PATCH 15/37] fix(tui): optimistic rendering (#692) --- packages/tui/internal/components/chat/messages.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index 3d001130..a59b5d79 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -68,11 +68,9 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.selectedPart = -1 return m, nil case app.OptimisticMessageAddedMsg: - m.renderView(m.width) - if m.tail { - m.viewport.GotoBottom() - } - return m, nil + m.tail = true + m.rendering = true + return m, m.Reload() case dialog.ThemeSelectedMsg: m.cache.Clear() m.rendering = true From 85214d7c598959be5d4723625cb4f559645eadc2 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 4 Jul 2025 17:21:36 -0400 Subject: [PATCH 16/37] fix input bar not rendering capital letters --- packages/tui/internal/components/textarea/textarea.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tui/internal/components/textarea/textarea.go b/packages/tui/internal/components/textarea/textarea.go index c2c92ea7..5ff936f1 100644 --- a/packages/tui/internal/components/textarea/textarea.go +++ b/packages/tui/internal/components/textarea/textarea.go @@ -1512,7 +1512,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.transposeLeft() default: - m.insertRunesFromUserInput([]rune{msg.Code}) + m.insertRunesFromUserInput([]rune(msg.Text)) } case pasteMsg: From 107363b1d9f3eec6b180170e428f66162bf622c7 Mon Sep 17 00:00:00 2001 From: Jay V Date: Fri, 4 Jul 2025 17:57:10 -0400 Subject: [PATCH 17/37] docs: fix show more in share page --- packages/web/src/components/Share.tsx | 167 +++++++------------ packages/web/src/components/share.module.css | 2 +- 2 files changed, 62 insertions(+), 107 deletions(-) diff --git a/packages/web/src/components/Share.tsx b/packages/web/src/components/Share.tsx index e2e880f6..ed889790 100644 --- a/packages/web/src/components/Share.tsx +++ b/packages/web/src/components/Share.tsx @@ -243,6 +243,44 @@ function getStatusText(status: [Status, string?]): string { } } +function checkOverflow(getEl: () => HTMLElement | undefined, watch?: () => any) { + const [needsToggle, setNeedsToggle] = createSignal(false) + + function measure() { + const el = getEl() + if (!el) return + setNeedsToggle(el.scrollHeight > el.clientHeight + 1) + } + + onMount(() => { + let raf = 0 + + function probe() { + const el = getEl() + if (el && el.offsetParent !== null && el.getBoundingClientRect().height) { + measure() + } + else { + raf = requestAnimationFrame(probe) + } + } + raf = requestAnimationFrame(probe) + + const ro = new ResizeObserver(measure) + const el = getEl() + if (el) ro.observe(el) + + onCleanup(() => { + cancelAnimationFrame(raf) + ro.disconnect() + }) + }) + + if (watch) createEffect(measure) + + return needsToggle +} + function ProviderIcon(props: { provider: string; size?: number }) { const size = props.size || 16 return ( @@ -296,34 +334,11 @@ interface TextPartProps extends JSX.HTMLAttributes { expand?: boolean } function TextPart(props: TextPartProps) { - const [local, rest] = splitProps(props, [ - "text", - "expand", - ]) - const [expanded, setExpanded] = createSignal(false) - const [overflowed, setOverflowed] = createSignal(false) let preEl: HTMLPreElement | undefined - function checkOverflow() { - if (preEl && !local.expand) { - setOverflowed(preEl.scrollHeight > preEl.clientHeight + 1) - } - } - - onMount(() => { - checkOverflow() - window.addEventListener("resize", checkOverflow) - }) - - createEffect(() => { - local.text - local.expand - setTimeout(checkOverflow, 0) - }) - - onCleanup(() => { - window.removeEventListener("resize", checkOverflow) - }) + const [local, rest] = splitProps(props, ["text", "expand"]) + const [expanded, setExpanded] = createSignal(false) + const overflowed = checkOverflow(() => preEl, () => local.expand) return (
-
 (preEl = el)}>{local.text}
+
{local.text}
{((!local.expand && overflowed()) || expanded()) && ( - ) -} - -interface TextPartProps extends JSX.HTMLAttributes { - text: string - expand?: boolean -} -function TextPart(props: TextPartProps) { - let preEl: HTMLPreElement | undefined - - const [local, rest] = splitProps(props, ["text", "expand"]) - const [expanded, setExpanded] = createSignal(false) - const overflowed = checkOverflow(() => preEl, () => local.expand) - - return ( -
-
{local.text}
- {((!local.expand && overflowed()) || expanded()) && ( - - )} -
- ) -} - -interface ErrorPartProps extends JSX.HTMLAttributes { - expand?: boolean -} -function ErrorPart(props: ErrorPartProps) { - let preEl: HTMLDivElement | undefined - - const [local, rest] = splitProps(props, ["expand", "children"]) - const [expanded, setExpanded] = createSignal(false) - const overflowed = checkOverflow(() => preEl, () => local.expand) - - return ( -
-
- {local.children} -
- {((!local.expand && overflowed()) || expanded()) && ( - - )} -
- ) -} - -interface MarkdownPartProps extends JSX.HTMLAttributes { - text: string - expand?: boolean - highlight?: boolean -} -function MarkdownPart(props: MarkdownPartProps) { - let divEl: HTMLDivElement | undefined - - const [local, rest] = splitProps(props, ["text", "expand", "highlight"]) - const [expanded, setExpanded] = createSignal(false) - const overflowed = checkOverflow(() => divEl, () => local.expand) - - return ( -
- (divEl = el)} - /> - {((!local.expand && overflowed()) || expanded()) && ( - - )} -
- ) -} - -interface TerminalPartProps extends JSX.HTMLAttributes { - command: string - error?: string - result?: string - desc?: string - expand?: boolean -} -function TerminalPart(props: TerminalPartProps) { - const [local, rest] = splitProps(props, [ - "command", - "error", - "result", - "desc", - "expand", - ]) - let preEl: HTMLDivElement | undefined - - const [expanded, setExpanded] = createSignal(false) - const overflowed = checkOverflow( - () => { - if (!preEl) return - return preEl.getElementsByTagName("pre")[0] - }, - () => local.expand - ) - - return ( -
-
-
- {local.desc} -
-
- - - - - - - - - -
-
- {((!local.expand && overflowed()) || expanded()) && ( - - )} -
- ) -} - -function ToolFooter(props: { time: number }) { - return props.time > MIN_DURATION ? ( - - {formatDuration(props.time)} - - ) : ( -
- ) -} - -interface AnchorProps extends JSX.HTMLAttributes { - id: string -} -function AnchorIcon(props: AnchorProps) { - const [local, rest] = splitProps(props, ["id", "children"]) - const [copied, setCopied] = createSignal(false) - - return ( - - ) -} - export default function Share(props: { id: string api: string info: Session.Info - messages: Record + messages: Record }) { let lastScrollY = 0 let hasScrolledToAnchor = false @@ -571,14 +85,10 @@ export default function Share(props: { const [store, setStore] = createStore<{ info?: Session.Info - messages: Record + messages: Record }>({ info: props.info, messages: props.messages }) - const messages = createMemo(() => - Object.values(store.messages).toSorted((a, b) => a.id?.localeCompare(b.id)), - ) - const [connectionStatus, setConnectionStatus] = createSignal< - [Status, string?] - >(["disconnected", "Disconnected"]) + const messages = createMemo(() => Object.values(store.messages).toSorted((a, b) => a.id?.localeCompare(b.id))) + const [connectionStatus, setConnectionStatus] = createSignal<[Status, string?]>(["disconnected", "Disconnected"]) onMount(() => { const apiUrl = props.api @@ -653,10 +163,7 @@ export default function Share(props: { // Try to reconnect after 2 seconds clearTimeout(reconnectTimer) - reconnectTimer = window.setTimeout( - setupWebSocket, - 2000, - ) as unknown as number + reconnectTimer = window.setTimeout(setupWebSocket, 2000) as unknown as number } } @@ -754,7 +261,7 @@ export default function Share(props: { rootDir: undefined as string | undefined, created: undefined as number | undefined, completed: undefined as number | undefined, - messages: [] as Message.Info[], + messages: [] as MessageV2.Info[], models: {} as Record, cost: 0, tokens: { @@ -766,46 +273,41 @@ export default function Share(props: { result.created = props.info.time.created - for (let i = 0; i < messages().length; i++) { - const msg = messages()[i] - - const assistant = msg.metadata?.assistant + const msgs = messages() + for (let i = 0; i < msgs.length; i++) { + const msg = "metadata" in msgs[i] ? fromV1(msgs[i] as Message.Info) : (msgs[i] as MessageV2.Info) result.messages.push(msg) - if (assistant) { - result.cost += assistant.cost - result.tokens.input += assistant.tokens.input - result.tokens.output += assistant.tokens.output - result.tokens.reasoning += assistant.tokens.reasoning + if (msg.role === "assistant") { + result.cost += msg.cost + result.tokens.input += msg.tokens.input + result.tokens.output += msg.tokens.output + result.tokens.reasoning += msg.tokens.reasoning - result.models[`${assistant.providerID} ${assistant.modelID}`] = [ - assistant.providerID, - assistant.modelID, - ] + result.models[`${msg.providerID} ${msg.modelID}`] = [msg.providerID, msg.modelID] - if (assistant.path?.root) { - result.rootDir = assistant.path.root + if (msg.path.root) { + result.rootDir = msg.path.root } - if (msg.metadata?.time.completed) { - result.completed = msg.metadata?.time.completed + if (msg.time.completed) { + result.completed = msg.time.completed } } } + console.log(result.messages) return result }) return ( -
-
-
-

{store.info?.title}

-
-
-
    -
  • -
    +
    +
    +

    {store.info?.title}

    +
    +
      +
    • +
      @@ -815,11 +317,11 @@ export default function Share(props: { {Object.values(data().models).length > 0 ? ( {([provider, model]) => ( -
    • -
      +
    • +
      - {model} + {model}
    • )} @@ -830,1086 +332,52 @@ export default function Share(props: { )}
    -
    - {data().created ? ( - - {DateTime.fromMillis(data().created || 0).toLocaleString( - DateTime.DATETIME_MED, - )} - - ) : ( - - Started at — - - )} +
    + {DateTime.fromMillis(data().created || 0).toLocaleString(DateTime.DATETIME_MED)}
    - 0} - fallback={

    Waiting for messages...

    } - > + 0} fallback={

    Waiting for messages...

    }>
    {(msg, msgIndex) => ( - + { + if (x.type === "step-start" && index > 0) return false + if (x.type === "tool" && x.tool === "todoread") return false + if (x.type === "text" && !x.text) return false + if (x.type === "tool" && (x.state.status === "pending" || x.state.status === "running")) + return false + return true + })} + > {(part, partIndex) => { - if ( - (part.type === "step-start" && - (partIndex() > 0 || !msg.metadata?.assistant)) || - (msg.role === "assistant" && - part.type === "tool-invocation" && - part.toolInvocation.toolName === "todoread") + const last = createMemo( + () => data().messages.length === msgIndex() + 1 && msg.parts.length === partIndex() + 1, ) - return null - - const anchor = createMemo( - () => `${msg.id}-${partIndex()}`, - ) - const [showResults, setShowResults] = - createSignal(false) - const isLastPart = createMemo( - () => - data().messages.length === msgIndex() + 1 && - msg.parts.length === partIndex() + 1, - ) - const toolData = createMemo(() => { - if ( - msg.role !== "assistant" || - part.type !== "tool-invocation" - ) - return {} - - const metadata = - msg.metadata?.tool[part.toolInvocation.toolCallId] - const args = part.toolInvocation.args - const result = - part.toolInvocation.state === "result" && - part.toolInvocation.result - const duration = DateTime.fromMillis( - metadata?.time.end || 0, - ) - .diff( - DateTime.fromMillis(metadata?.time.start || 0), - ) - .toMillis() - - return { metadata, args, result, duration } - }) onMount(() => { const hash = window.location.hash.slice(1) // Wait till all parts are loaded if ( - hash !== "" - && !hasScrolledToAnchor - && msg.parts.length === partIndex() + 1 - && data().messages.length === msgIndex() + 1 + hash !== "" && + !hasScrolledToAnchor && + msg.parts.length === partIndex() + 1 && + data().messages.length === msgIndex() + 1 ) { hasScrolledToAnchor = true scrollToAnchor(hash) } }) - return ( - - {/* User text */} - - {(part) => ( -
    -
    - - - -
    -
    -
    - -
    -
    - )} -
    - {/* AI text */} - - {(part) => ( -
    -
    - - - -
    -
    -
    - - - - {DateTime.fromMillis( - data().completed || 0, - ).toLocaleString(DateTime.DATETIME_MED)} - - -
    -
    - )} -
    - {/* AI model */} - - {(assistant) => { - return ( -
    -
    - - - -
    -
    -
    -
    -
    - - {assistant().providerID} - -
    - - {assistant().modelID} - -
    -
    -
    - ) - }} -
    - - {/* Grep tool */} - - {(_part) => { - const matches = () => - toolData()?.metadata?.matches - const splitArgs = () => { - const { pattern, ...rest } = toolData()?.args - return { pattern, rest } - } - - return ( -
    -
    - - - -
    -
    -
    -
    -
    - Grep - - “{splitArgs().pattern}” - -
    - 0 - } - > -
    - - {([name, value]) => ( - <> -
    -
    {name}
    -
    {value}
    - - )} -
    -
    -
    - - 0}> -
    - - setShowResults((e) => !e) - } - /> - - - -
    -
    - -
    - -
    -
    -
    -
    - -
    -
    - ) - }} -
    - {/* Glob tool */} - - {(_part) => { - const count = () => toolData()?.metadata?.count - const pattern = () => toolData()?.args.pattern - - return ( -
    -
    - - - -
    -
    -
    -
    -
    - Glob - “{pattern()}” -
    - - 0}> -
    - - setShowResults((e) => !e) - } - /> - - - -
    -
    - -
    - -
    -
    -
    -
    - -
    -
    - ) - }} -
    - {/* LS tool */} - - {(_part) => { - const path = createMemo(() => - toolData()?.args?.path !== data().rootDir - ? stripWorkingDirectory( - toolData()?.args?.path, - data().rootDir, - ) - : toolData()?.args?.path, - ) - - return ( -
    -
    - - - -
    -
    -
    -
    -
    - LS - - {path()} - -
    - - -
    - - setShowResults((e) => !e) - } - /> - - - -
    -
    -
    -
    - -
    -
    - ) - }} -
    - {/* Read tool */} - - {(_part) => { - const filePath = createMemo(() => - stripWorkingDirectory( - toolData()?.args?.filePath, - data().rootDir, - ), - ) - const hasError = () => - toolData()?.metadata?.error - const preview = () => - toolData()?.metadata?.preview - - return ( -
    -
    - - - -
    -
    -
    -
    -
    - Read - - {filePath()} - -
    - - -
    - - {formatErrorString( - toolData()?.result, - )} - -
    -
    - {/* Always try to show CodeBlock if preview is available (even if empty string) */} - -
    - - setShowResults((e) => !e) - } - /> - -
    - -
    -
    -
    -
    - {/* Fallback to TextPart if preview is not a string (e.g. undefined) AND result exists */} - -
    - - setShowResults((e) => !e) - } - /> - - - -
    -
    -
    -
    - -
    -
    - ) - }} -
    - {/* Write tool */} - - {(_part) => { - const filePath = createMemo(() => - stripWorkingDirectory( - toolData()?.args?.filePath, - data().rootDir, - ), - ) - const hasError = () => - toolData()?.metadata?.error - const content = () => toolData()?.args?.content - const diagnostics = createMemo(() => - getDiagnostics( - toolData()?.metadata?.diagnostics, - toolData()?.args.filePath, - ), - ) - - return ( -
    -
    - - - -
    -
    -
    -
    -
    - Write - - {filePath()} - -
    - 0}> - {diagnostics()} - - - -
    - - {formatErrorString( - toolData()?.result, - )} - -
    -
    - -
    - - setShowResults((e) => !e) - } - /> - -
    - -
    -
    -
    -
    -
    -
    - -
    -
    - ) - }} -
    - {/* Edit tool */} - - {(_part) => { - const diff = () => toolData()?.metadata?.diff - const message = () => - toolData()?.metadata?.message - const hasError = () => - toolData()?.metadata?.error - const filePath = createMemo(() => - stripWorkingDirectory( - toolData()?.args.filePath, - data().rootDir, - ), - ) - const diagnostics = createMemo(() => - getDiagnostics( - toolData()?.metadata?.diagnostics, - toolData()?.args.filePath, - ), - ) - - return ( -
    -
    - - - -
    -
    -
    -
    -
    - Edit - - {filePath()} - -
    - - -
    - - {formatErrorString(message())} - -
    -
    - -
    - -
    -
    -
    - 0}> - {diagnostics()} - -
    - -
    -
    - ) - }} -
    - {/* Bash tool */} - - {(_part) => { - const command = () => - toolData()?.metadata?.title - const desc = () => - toolData()?.metadata?.description - const result = () => - toolData()?.metadata?.stdout - const error = () => toolData()?.metadata?.stderr - - return ( -
    -
    - - - -
    -
    -
    - {command() && ( -
    - -
    - )} - -
    -
    - ) - }} -
    - {/* Todo write */} - - {(_part) => { - const todos = createMemo(() => - sortTodosByStatus( - toolData()?.args?.todos ?? [], - ), - ) - const starting = () => - todos().every((t) => t.status === "pending") - const finished = () => - todos().every((t) => t.status === "completed") - - return ( -
    -
    - - - -
    -
    -
    -
    -
    - - - - Creating plan - - - Completing plan - - - -
    - 0}> -
      - - {(todo) => ( -
    • - - {todo.content} -
    • - )} -
      -
    -
    -
    - -
    -
    - ) - }} -
    - {/* Fetch tool */} - - {(_part) => { - const url = () => toolData()?.args.url - const format = () => toolData()?.args.format - const hasError = () => - toolData()?.metadata?.error - - return ( -
    -
    - - - -
    -
    -
    -
    -
    - Fetch - {url()} -
    - - -
    - - {formatErrorString( - toolData()?.result, - )} - -
    -
    - -
    - - setShowResults((e) => !e) - } - /> - -
    - -
    -
    -
    -
    -
    -
    - -
    -
    - ) - }} -
    - {/* Tool call */} - - {(part) => { - return ( -
    -
    - - - -
    -
    -
    -
    -
    - {part().toolInvocation.toolName} -
    -
    - - {(arg) => ( - <> -
    -
    {arg[0]}
    -
    {arg[1]}
    - - )} -
    -
    - - -
    - - setShowResults((e) => !e) - } - /> - - - -
    -
    - - - -
    -
    - -
    -
    - ) - }} -
    - {/* Fallback */} - -
    -
    - - - } - > - - - - - - - - - -
    -
    -
    -
    -
    - - {part.type} - -
    - -
    -
    -
    -
    -
    - ) + return }}
    @@ -1934,19 +402,11 @@ export default function Share(props: {
  • Input Tokens - {data().tokens.input ? ( - {data().tokens.input} - ) : ( - - )} + {data().tokens.input ? {data().tokens.input} : }
  • Output Tokens - {data().tokens.output ? ( - {data().tokens.output} - ) : ( - - )} + {data().tokens.output ? {data().tokens.output} : }
  • Reasoning Tokens @@ -1972,10 +432,7 @@ export default function Share(props: { "overflow-y": "auto", }} > - 0} - fallback={

    Waiting for messages...

    } - > + 0} fallback={

    Waiting for messages...

    }>
      {(msg) => ( @@ -2003,9 +460,7 @@ export default function Share(props: {
) } + +export function fromV1(v1: Message.Info): MessageV2.Info { + if (v1.role === "assistant") { + const result: MessageV2.Assistant = { + id: v1.id, + sessionID: v1.metadata.sessionID, + role: "assistant", + time: { + created: v1.metadata.time.created, + completed: v1.metadata.time.completed, + }, + cost: v1.metadata.assistant!.cost, + path: v1.metadata.assistant!.path, + summary: v1.metadata.assistant!.summary, + tokens: v1.metadata.assistant!.tokens, + modelID: v1.metadata.assistant!.modelID, + providerID: v1.metadata.assistant!.providerID, + system: v1.metadata.assistant!.system, + error: v1.metadata.error, + parts: v1.parts.flatMap((part): MessageV2.AssistantPart[] => { + if (part.type === "text") { + return [ + { + type: "text", + text: part.text, + }, + ] + } + if (part.type === "step-start") { + return [ + { + type: "step-start", + }, + ] + } + if (part.type === "tool-invocation") { + return [ + { + type: "tool", + id: part.toolInvocation.toolCallId, + tool: part.toolInvocation.toolName, + state: (() => { + if (part.toolInvocation.state === "partial-call") { + return { + status: "pending", + } + } + + const { title, time, ...metadata } = v1.metadata.tool[part.toolInvocation.toolCallId] + if (part.toolInvocation.state === "call") { + return { + status: "running", + input: part.toolInvocation.args, + time: { + start: time.start, + }, + } + } + + if (part.toolInvocation.state === "result") { + return { + status: "completed", + input: part.toolInvocation.args, + output: part.toolInvocation.result, + title, + time, + metadata, + } + } + throw new Error("unknown tool invocation state") + })(), + }, + ] + } + return [] + }), + } + return result + } + + if (v1.role === "user") { + const result: MessageV2.User = { + id: v1.id, + sessionID: v1.metadata.sessionID, + role: "user", + time: { + created: v1.metadata.time.created, + }, + parts: v1.parts.flatMap((part): MessageV2.UserPart[] => { + if (part.type === "text") { + return [ + { + type: "text", + text: part.text, + }, + ] + } + if (part.type === "file") { + return [ + { + type: "file", + mime: part.mediaType, + filename: part.filename, + url: part.url, + }, + ] + } + return [] + }), + } + return result + } + + throw new Error("unknown message type") +} diff --git a/packages/web/src/components/codeblock.module.css b/packages/web/src/components/codeblock.module.css index ddd88ef1..53120120 100644 --- a/packages/web/src/components/codeblock.module.css +++ b/packages/web/src/components/codeblock.module.css @@ -8,4 +8,3 @@ } } } - diff --git a/packages/web/src/components/diffview.module.css b/packages/web/src/components/diffview.module.css deleted file mode 100644 index a748c5d0..00000000 --- a/packages/web/src/components/diffview.module.css +++ /dev/null @@ -1,121 +0,0 @@ -.diff { - display: flex; - flex-direction: column; - border: 1px solid var(--sl-color-divider); - background-color: var(--sl-color-bg-surface); - border-radius: 0.25rem; -} - -.desktopView { - display: block; -} - -.mobileView { - display: none; -} - -.mobileBlock { - display: flex; - flex-direction: column; -} - -.row { - display: grid; - grid-template-columns: 1fr 1fr; - align-items: stretch; -} - -.beforeColumn, -.afterColumn { - display: flex; - flex-direction: column; - overflow-x: visible; - min-width: 0; - align-items: stretch; -} - -.beforeColumn { - border-right: 1px solid var(--sl-color-divider); -} - -.diff > .row:first-child [data-section="cell"]:first-child { - padding-top: 0.5rem; -} - -.diff > .row:last-child [data-section="cell"]:last-child { - padding-bottom: 0.5rem; -} - -[data-section="cell"] { - position: relative; - flex: 1; - display: flex; - flex-direction: column; - - width: 100%; - padding: 0.1875rem 0.5rem 0.1875rem 2.2ch; - margin: 0; - - &[data-display-mobile="true"] { - display: none; - } - - pre { - --shiki-dark-bg: var(--sl-color-bg-surface) !important; - background-color: var(--sl-color-bg-surface) !important; - - white-space: pre-wrap; - word-break: break-word; - - code > span:empty::before { - content: "\00a0"; - white-space: pre; - display: inline-block; - width: 0; - } - } -} - -[data-diff-type="removed"] { - background-color: var(--sl-color-red-low); - - pre { - --shiki-dark-bg: var(--sl-color-red-low) !important; - background-color: var(--sl-color-red-low) !important; - } - - &::before { - content: "-"; - position: absolute; - left: 0.5ch; - user-select: none; - color: var(--sl-color-red-high); - } -} - -[data-diff-type="added"] { - background-color: var(--sl-color-green-low); - - pre { - --shiki-dark-bg: var(--sl-color-green-low) !important; - background-color: var(--sl-color-green-low) !important; - } - - &::before { - content: "+"; - position: absolute; - left: 0.6ch; - user-select: none; - color: var(--sl-color-green-high); - } -} - -@media (max-width: 40rem) { - .desktopView { - display: none; - } - - .mobileView { - display: block; - } -} diff --git a/packages/web/src/components/icons/custom.tsx b/packages/web/src/components/icons/custom.tsx index b4e32d0c..be1e2b4d 100644 --- a/packages/web/src/components/icons/custom.tsx +++ b/packages/web/src/components/icons/custom.tsx @@ -39,7 +39,12 @@ export function IconGemini(props: JSX.SvgSVGAttributes) { export function IconOpencode(props: JSX.SvgSVGAttributes) { return ( - + ) diff --git a/packages/web/src/components/icons/index.tsx b/packages/web/src/components/icons/index.tsx index a788d8f4..62445611 100644 --- a/packages/web/src/components/icons/index.tsx +++ b/packages/web/src/components/icons/index.tsx @@ -3,12 +3,7 @@ import { type JSX } from "solid-js" export function IconAcademicCap(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconAdjustmentsHorizontal( - props: JSX.SvgSVGAttributes, -) { +export function IconAdjustmentsHorizontal(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconAdjustmentsVertical( - props: JSX.SvgSVGAttributes, -) { +export function IconAdjustmentsVertical(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconArchiveBoxArrowDown( - props: JSX.SvgSVGAttributes, -) { +export function IconArchiveBoxArrowDown(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconArchiveBoxXMark( - props: JSX.SvgSVGAttributes, -) { +export function IconArchiveBoxXMark(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { ) } -export function IconArrowDownCircle( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowDownCircle(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { ) } -export function IconArrowDownOnSquareStack( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowDownOnSquareStack(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconArrowDownOnSquare( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowDownOnSquare(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconArrowDownTray(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconArrowDown(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconArrowLeftCircle( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowLeftCircle(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconArrowLeftOnRectangle( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowLeftOnRectangle(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconArrowLongDown(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconArrowLongLeft(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconArrowLongRight(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconArrowLongUp(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconArrowPathRoundedSquare( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowPathRoundedSquare(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { ) } -export function IconArrowRightCircle( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowRightCircle(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconArrowRightOnRectangle( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowRightOnRectangle(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconArrowSmallDown(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconArrowSmallLeft(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconArrowSmallRight( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowSmallRight(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { ) } -export function IconArrowTopRightOnSquare( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowTopRightOnSquare(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconArrowTrendingDown( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowTrendingDown(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconArrowTrendingUp( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowTrendingUp(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconArrowUpLeft(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconArrowUpOnSquareStack( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowUpOnSquareStack(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconArrowUpOnSquare( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowUpOnSquare(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconArrowUpTray(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconArrowUp(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconArrowUturnDown(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconArrowUturnLeft(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconArrowUturnRight( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowUturnRight(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { ) } -export function IconArrowsPointingIn( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowsPointingIn(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconArrowsPointingOut( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowsPointingOut(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconArrowsRightLeft( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowsRightLeft(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconAtSymbol(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBackspace(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBackward(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBanknotes(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBars2(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconBars3BottomLeft( - props: JSX.SvgSVGAttributes, -) { +export function IconBars3BottomLeft(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconBars3BottomRight( - props: JSX.SvgSVGAttributes, -) { +export function IconBars3BottomRight(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconBars3CenterLeft( - props: JSX.SvgSVGAttributes, -) { +export function IconBars3CenterLeft(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconBars4(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBarsArrowDown(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBarsArrowUp(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBattery0(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBattery100(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBattery50(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBeaker(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBellAlert(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBellSlash(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBellSnooze(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBell(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBoltSlash(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBolt(props: JSX.SvgSVGAttributes) { return ( - + ) { export function IconBoltSolid(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBookOpen(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBookmarkSlash(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBookmarkSquare(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBookmark(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBriefcase(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBugAnt(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconBuildingLibrary( - props: JSX.SvgSVGAttributes, -) { +export function IconBuildingLibrary(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconBuildingOffice2( - props: JSX.SvgSVGAttributes, -) { +export function IconBuildingOffice2(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { ) } -export function IconBuildingStorefront( - props: JSX.SvgSVGAttributes, -) { +export function IconBuildingStorefront(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconCalculator(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCalendarDays(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCalendar(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCamera(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconChartBarSquare(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconChartBar(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconChartPie(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconChatBubbleBottomCenterText( - props: JSX.SvgSVGAttributes, -) { +export function IconChatBubbleBottomCenterText(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconChatBubbleBottomCenter( - props: JSX.SvgSVGAttributes, -) { +export function IconChatBubbleBottomCenter(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconChatBubbleLeftEllipsis( - props: JSX.SvgSVGAttributes, -) { +export function IconChatBubbleLeftEllipsis(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconChatBubbleLeftRight( - props: JSX.SvgSVGAttributes, -) { +export function IconChatBubbleLeftRight(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { ) } -export function IconChatBubbleOvalLeftEllipsis( - props: JSX.SvgSVGAttributes, -) { +export function IconChatBubbleOvalLeftEllipsis(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconChatBubbleOvalLeft( - props: JSX.SvgSVGAttributes, -) { +export function IconChatBubbleOvalLeft(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconCheckCircle(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCheck(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconChevronDoubleDown( - props: JSX.SvgSVGAttributes, -) { +export function IconChevronDoubleDown(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconChevronDoubleLeft( - props: JSX.SvgSVGAttributes, -) { +export function IconChevronDoubleLeft(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconChevronDoubleRight( - props: JSX.SvgSVGAttributes, -) { +export function IconChevronDoubleRight(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconChevronDoubleUp( - props: JSX.SvgSVGAttributes, -) { +export function IconChevronDoubleUp(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconChevronLeft(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconChevronRight(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconChevronUpDown(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconChevronUp(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCircleStack(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconClipboardDocumentCheck( - props: JSX.SvgSVGAttributes, -) { +export function IconClipboardDocumentCheck(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconClipboardDocumentList( - props: JSX.SvgSVGAttributes, -) { +export function IconClipboardDocumentList(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconClipboardDocument( - props: JSX.SvgSVGAttributes, -) { +export function IconClipboardDocument(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconClock(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCloudArrowDown(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCloudArrowUp(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCloud(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconCodeBracketSquare( - props: JSX.SvgSVGAttributes, -) { +export function IconCodeBracketSquare(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconCog6Tooth(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCog8Tooth(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCog(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCommandLine(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconComputerDesktop( - props: JSX.SvgSVGAttributes, -) { +export function IconComputerDesktop(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconCreditCard(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconCubeTransparent( - props: JSX.SvgSVGAttributes, -) { +export function IconCubeTransparent(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { ) } -export function IconCurrencyBangladeshi( - props: JSX.SvgSVGAttributes, -) { +export function IconCurrencyBangladeshi(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconCurrencyEuro(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCurrencyPound(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCurrencyRupee(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCurrencyYen(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconCursorArrowRays( - props: JSX.SvgSVGAttributes, -) { +export function IconCursorArrowRays(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconCursorArrowRipple( - props: JSX.SvgSVGAttributes, -) { +export function IconCursorArrowRipple(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconDevicePhoneMobile( - props: JSX.SvgSVGAttributes, -) { +export function IconDevicePhoneMobile(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { ) } -export function IconDocumentArrowDown( - props: JSX.SvgSVGAttributes, -) { +export function IconDocumentArrowDown(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconDocumentArrowUp( - props: JSX.SvgSVGAttributes, -) { +export function IconDocumentArrowUp(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconDocumentChartBar( - props: JSX.SvgSVGAttributes, -) { +export function IconDocumentChartBar(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { ) } -export function IconDocumentDuplicate( - props: JSX.SvgSVGAttributes, -) { +export function IconDocumentDuplicate(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconDocumentMagnifyingGlass( - props: JSX.SvgSVGAttributes, -) { +export function IconDocumentMagnifyingGlass(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconDocumentPlus(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconDocumentText(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconDocument(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconEllipsisHorizontalCircle( - props: JSX.SvgSVGAttributes, -) { +export function IconEllipsisHorizontalCircle(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconEllipsisHorizontal( - props: JSX.SvgSVGAttributes, -) { +export function IconEllipsisHorizontal(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconEllipsisVertical( - props: JSX.SvgSVGAttributes, -) { +export function IconEllipsisVertical(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconEnvelope(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconEnvelopeSolid(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconExclamationCircle( - props: JSX.SvgSVGAttributes, -) { +export function IconExclamationCircle(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconExclamationTriangle( - props: JSX.SvgSVGAttributes, -) { +export function IconExclamationTriangle(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconEyeSlash(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconEye(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconFaceFrown(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconFaceSmile(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconFilm(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconFingerPrint(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconFire(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconFlag(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconFolderArrowDown( - props: JSX.SvgSVGAttributes, -) { +export function IconFolderArrowDown(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconFolderOpen(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconFolderPlus(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconFolder(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconForward(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconFunnel(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconGif(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconGiftTop(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconGift(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconGlobeAlt(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconGlobeAmericas(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconGlobeAsiaAustralia( - props: JSX.SvgSVGAttributes, -) { +export function IconGlobeAsiaAustralia(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconGlobeEuropeAfrica( - props: JSX.SvgSVGAttributes, -) { +export function IconGlobeEuropeAfrica(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconHandThumbDown(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconHandThumbUp(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconHashtag(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconHeart(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconHomeModern(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconHome(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconIdentification(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconInboxArrowDown(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconInboxStack(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconInbox(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconInformationCircle( - props: JSX.SvgSVGAttributes, -) { +export function IconInformationCircle(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconLanguage(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconLifebuoy(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconLightBulb(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconLink(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconListBullet(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconLockClosed(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconLockOpen(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconMagnifyingGlassCircle( - props: JSX.SvgSVGAttributes, -) { +export function IconMagnifyingGlassCircle(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconMagnifyingGlassMinus( - props: JSX.SvgSVGAttributes, -) { +export function IconMagnifyingGlassMinus(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconMagnifyingGlassPlus( - props: JSX.SvgSVGAttributes, -) { +export function IconMagnifyingGlassPlus(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconMagnifyingGlass( - props: JSX.SvgSVGAttributes, -) { +export function IconMagnifyingGlass(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconMap(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconMegaphone(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconMicrophone(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconMinusCircle(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconMinusSmall(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconMinus(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconMoon(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconMusicalNote(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconNewspaper(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconNoSymbol(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPaintBrush(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPaperAirplane(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPaperClip(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPauseCircle(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPause(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPencilSquare(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPencil(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconPhoneArrowDownLeft( - props: JSX.SvgSVGAttributes, -) { +export function IconPhoneArrowDownLeft(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconPhoneArrowUpRight( - props: JSX.SvgSVGAttributes, -) { +export function IconPhoneArrowUpRight(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconPhone(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPhoto(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPlayCircle(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPlayPause(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPlay(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPlusCircle(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPlusSmall(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPlus(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPower(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconPresentationChartBar( - props: JSX.SvgSVGAttributes, -) { +export function IconPresentationChartBar(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconPresentationChartLine( - props: JSX.SvgSVGAttributes, -) { +export function IconPresentationChartLine(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconPuzzlePiece(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconQrCode(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconQuestionMarkCircle( - props: JSX.SvgSVGAttributes, -) { +export function IconQuestionMarkCircle(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconRadio(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconReceiptPercent(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconReceiptRefund(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconRectangleGroup(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconRectangleStack(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconRocketLaunch(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconRss(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconScale(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconScissors(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconServerStack(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconServer(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconShare(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconShieldCheck(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconShieldExclamation( - props: JSX.SvgSVGAttributes, -) { +export function IconShieldExclamation(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconShoppingCart(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSignalSlash(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSignal(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSparkles(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSpeakerWave(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSpeakerXMark(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSquare2Stack(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSquare3Stack3d(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSquares2x2(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSquaresPlus(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconStar(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconStopCircle(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconStop(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSun(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSwatch(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconTableCells(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconTag(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconTicket(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconTrash(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconTrophy(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconTruck(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconTv(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconUserCircle(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconUserGroup(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconUserMinus(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconUserPlus(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconUser(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconUsers(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconVariable(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconVideoCameraSlash( - props: JSX.SvgSVGAttributes, -) { +export function IconVideoCameraSlash(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconViewColumns(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconViewfinderCircle( - props: JSX.SvgSVGAttributes, -) { +export function IconViewfinderCircle(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconWifi(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconWindow(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconWrenchScrewdriver( - props: JSX.SvgSVGAttributes, -) { +export function IconWrenchScrewdriver(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconXCircle(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconXMark(props: JSX.SvgSVGAttributes) { return ( - + ) { // index export function IconCommand(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconLetter(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconMultiSelect(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSettings(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSingleSelect(props: JSX.SvgSVGAttributes) { return ( - + *:last-child { - margin-bottom: 0; - } - - pre { - --shiki-dark-bg: var(--sl-color-bg-surface) !important; - background-color: var(--sl-color-bg-surface) !important; - padding: 0.5rem 0.75rem; - line-height: 1.6; - font-size: 0.75rem; - white-space: pre-wrap; - word-break: break-word; - - span { - white-space: break-spaces; - } - } - - code { - font-weight: 500; - - &:not(pre code) { - &::before { - content: "`"; - font-weight: 700; - } - &::after { - content: "`"; - font-weight: 700; - } - } - } - - table { - border-collapse: collapse; - width: 100%; - } - - th, - td { - border: 1px solid var(--sl-color-border); - padding: 0.5rem 0.75rem; - text-align: left; - } - - th { - border-bottom: 1px solid var(--sl-color-border); - } - - /* Remove outer borders */ - table tr:first-child th, - table tr:first-child td { - border-top: none; - } - - table tr:last-child td { - border-bottom: none; - } - - table th:first-child, - table td:first-child { - border-left: none; - } - - table th:last-child, - table td:last-child { - border-right: none; - } -} diff --git a/packages/web/src/components/share.module.css b/packages/web/src/components/share.module.css index c339d2b8..14680736 100644 --- a/packages/web/src/components/share.module.css +++ b/packages/web/src/components/share.module.css @@ -15,76 +15,42 @@ --lg-tool-width: 56rem; --term-icon: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2060%2016'%20preserveAspectRatio%3D'xMidYMid%20meet'%3E%3Ccircle%20cx%3D'8'%20cy%3D'8'%20r%3D'8'%2F%3E%3Ccircle%20cx%3D'30'%20cy%3D'8'%20r%3D'8'%2F%3E%3Ccircle%20cx%3D'52'%20cy%3D'8'%20r%3D'8'%2F%3E%3C%2Fsvg%3E"); -} -[data-element-button-text] { - cursor: pointer; - appearance: none; - background-color: transparent; - border: none; - padding: 0; - color: var(--sl-color-text-secondary); - - &:hover { - color: var(--sl-color-text); - } - - &[data-element-button-more] { + [data-component="header"] { display: flex; - align-items: center; - gap: 0.125rem; - - span[data-button-icon] { - line-height: 1; - opacity: 0.85; - svg { - display: block; - } - } - } -} - -[data-element-label] { - text-transform: uppercase; - letter-spacing: -0.5px; - color: var(--sl-color-text-dimmed); -} - -.header { - display: flex; - flex-direction: column; - gap: 1rem; - - @media (max-width: 30rem) { + flex-direction: column; gap: 1rem; - } - [data-section="title"] { - h1 { - font-size: 2.75rem; - font-weight: 500; - line-height: 1.2; - letter-spacing: -0.05em; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; - overflow: hidden; - - @media (max-width: 30rem) { - font-size: 1.75rem; - line-height: 1.25; - -webkit-line-clamp: 3; - } + @media (max-width: 30rem) { + gap: 1rem; } } - [data-section="row"] { + [data-component="header-title"] { + font-size: 2.75rem; + font-weight: 500; + line-height: 1.2; + letter-spacing: -0.05em; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + overflow: hidden; + + @media (max-width: 30rem) { + font-size: 1.75rem; + line-height: 1.25; + -webkit-line-clamp: 3; + } + } + + [data-component="header-details"] { display: flex; flex-direction: column; gap: 0.5rem; } - [data-section="stats"] { + [data-component="header-stats"] { list-style-type: none; padding: 0; margin: 0; @@ -92,41 +58,62 @@ gap: 0.5rem 0.875rem; flex-wrap: wrap; - li { + [data-slot="item"] { display: flex; align-items: center; - gap: 0.5rem; + gap: 0.3125rem; font-size: 0.875rem; span[data-placeholder] { color: var(--sl-color-text-dimmed); } } + + [data-slot="icon"] { + flex: 0 0 auto; + color: var(--sl-color-text-dimmed); + opacity: 0.85; + + svg { + display: block; + } + } + + [data-slot="model"] { + color: var(--sl-color-text); + } } - [data-section="stats"] { - li { - gap: 0.3125rem; + [data-component="header-time"] { + color: var(--sl-color-text-dimmed); + font-size: 0.875rem; + } - [data-stat-icon] { - flex: 0 0 auto; - color: var(--sl-color-text-dimmed); + [data-component="text-button"] { + cursor: pointer; + appearance: none; + background-color: transparent; + border: none; + padding: 0; + color: var(--sl-color-text-secondary); + + &:hover { + color: var(--sl-color-text); + } + + &[data-element-button-more] { + display: flex; + align-items: center; + gap: 0.125rem; + + span[data-button-icon] { + line-height: 1; opacity: 0.85; + svg { display: block; } } - - span[data-stat-model] { - color: var(--sl-color-text); - } - } - } - - [data-section="time"] { - span { - color: var(--sl-color-text-dimmed); - font-size: 0.875rem; } } } @@ -170,10 +157,12 @@ svg:nth-child(3) { display: none; } + &:hover { svg:nth-child(1) { display: none; } + svg:nth-child(2) { display: block; } @@ -213,12 +202,14 @@ opacity: 1; visibility: visible; } + a, a:hover { svg:nth-child(1), svg:nth-child(2) { display: none; } + svg:nth-child(3) { display: block; } @@ -264,7 +255,7 @@ } b { - color: var(--sl-color-text); + color: var(--sl-color-text); word-break: break-all; font-weight: 500; } @@ -348,8 +339,7 @@ } [data-part-type="tool-grep"] { - &:not(:has([data-part-tool-args])) - > [data-section="content"] > [data-part-tool-body] { + &:not(:has([data-part-tool-args])) > [data-section="content"] > [data-part-tool-body] { gap: 0.5rem; } } @@ -374,6 +364,7 @@ } } } + [data-part-type="summary"] { & > [data-section="decoration"] { span:first-child { @@ -388,15 +379,19 @@ &[data-status="connected"] { background-color: var(--sl-color-green); } + &[data-status="connecting"] { background-color: var(--sl-color-orange); } + &[data-status="disconnected"] { background-color: var(--sl-color-divider); } + &[data-status="reconnecting"] { background-color: var(--sl-color-orange); } + &[data-status="error"] { background-color: var(--sl-color-red); } @@ -493,14 +488,20 @@ } } - &[data-background="none"] { background-color: transparent; } - &[data-background="blue"] { background-color: var(--sl-color-blue-low); } + &[data-background="none"] { + background-color: transparent; + } + + &[data-background="blue"] { + background-color: var(--sl-color-blue-low); + } &[data-expanded="true"] { pre { display: block; } } + &[data-expanded="false"] { pre { display: -webkit-box; @@ -536,20 +537,25 @@ span { margin-right: 0.25rem; + &:last-child { margin-right: 0; } } + span[data-color="red"] { color: var(--sl-color-red); } + span[data-color="dimmed"] { color: var(--sl-color-text-dimmed); } + span[data-marker="label"] { text-transform: uppercase; letter-spacing: -0.5px; } + span[data-separator] { margin-right: 0.375rem; } @@ -561,6 +567,7 @@ display: block; } } + &[data-expanded="false"] { [data-section="content"] { display: -webkit-box; @@ -575,7 +582,6 @@ padding: 2px 0; font-size: 0.75rem; } - } .message-terminal { @@ -611,7 +617,7 @@ } &::before { - content: ''; + content: ""; position: absolute; pointer-events: none; top: 8px; @@ -651,6 +657,7 @@ display: block; } } + &[data-expanded="false"] { pre { display: -webkit-box; @@ -693,6 +700,7 @@ display: block; } } + &[data-expanded="false"] { [data-element-markdown] { display: -webkit-box; @@ -750,10 +758,14 @@ &[data-status="pending"] { color: var(--sl-color-text); } + &[data-status="in_progress"] { color: var(--sl-color-text); - & > span { border-color: var(--sl-color-orange); } + & > span { + border-color: var(--sl-color-orange); + } + & > span::before { content: ""; position: absolute; @@ -764,10 +776,14 @@ box-shadow: inset 1rem 1rem var(--sl-color-orange-low); } } + &[data-status="completed"] { color: var(--sl-color-text-secondary); - & > span { border-color: var(--sl-color-green-low); } + & > span { + border-color: var(--sl-color-green-low); + } + & > span::before { content: ""; position: absolute; @@ -798,7 +814,9 @@ display: flex; align-items: center; justify-content: center; - transition: all 0.15s ease, opacity 0.5s ease; + transition: + all 0.15s ease, + opacity 0.5s ease; z-index: 100; appearance: none; opacity: 1; diff --git a/packages/web/src/components/share/common.tsx b/packages/web/src/components/share/common.tsx new file mode 100644 index 00000000..9f5221de --- /dev/null +++ b/packages/web/src/components/share/common.tsx @@ -0,0 +1,60 @@ +import { createSignal, onCleanup, splitProps } from "solid-js" +import type { JSX } from "solid-js/jsx-runtime" +import { IconCheckCircle, IconHashtag } from "../icons" + +interface AnchorProps extends JSX.HTMLAttributes { + id: string +} +export function AnchorIcon(props: AnchorProps) { + const [local, rest] = splitProps(props, ["id", "children"]) + const [copied, setCopied] = createSignal(false) + + return ( + + ) +} + +export function createOverflow() { + const [overflow, setOverflow] = createSignal(false) + return { + get status() { + return overflow() + }, + ref(el: HTMLElement) { + const ro = new ResizeObserver(() => { + if (el.scrollHeight > el.clientHeight + 1) { + setOverflow(true) + } + return + }) + ro.observe(el) + + onCleanup(() => { + ro.disconnect() + }) + }, + } +} diff --git a/packages/web/src/components/share/content-code.module.css b/packages/web/src/components/share/content-code.module.css new file mode 100644 index 00000000..b95f936d --- /dev/null +++ b/packages/web/src/components/share/content-code.module.css @@ -0,0 +1,25 @@ +.root { + max-width: var(--md-tool-width); + border: 1px solid var(--sl-color-divider); + background-color: var(--sl-color-bg-surface); + border-radius: 0.25rem; + padding: 0.5rem calc(0.5rem + 3px); + + &[data-flush="true"] { + border: none; + background-color: transparent; + padding: 0; + } + + pre { + --shiki-dark-bg: var(--sl-color-bg-surface) !important; + line-height: 1.6; + font-size: 0.75rem; + white-space: pre-wrap; + word-break: break-word; + + span { + white-space: break-spaces; + } + } +} diff --git a/packages/web/src/components/share/content-code.tsx b/packages/web/src/components/share/content-code.tsx new file mode 100644 index 00000000..b8c4f2cc --- /dev/null +++ b/packages/web/src/components/share/content-code.tsx @@ -0,0 +1,32 @@ +import { type JSX, splitProps, createResource, Suspense } from "solid-js" +import { codeToHtml } from "shiki" +import style from "./content-code.module.css" +import { transformerNotationDiff } from "@shikijs/transformers" + +interface Props { + code: string + lang?: string + flush?: boolean +} +export function ContentCode(props: Props) { + const [html] = createResource( + () => [props.code, props.lang], + async ([code, lang]) => { + // TODO: For testing delays + // await new Promise((resolve) => setTimeout(resolve, 3000)) + return (await codeToHtml(code || "", { + lang: lang || "text", + themes: { + light: "github-light", + dark: "github-dark", + }, + transformers: [transformerNotationDiff()], + })) as string + }, + ) + return ( + +
+ + ) +} diff --git a/packages/web/src/components/share/content-diff.module.css b/packages/web/src/components/share/content-diff.module.css new file mode 100644 index 00000000..718ae369 --- /dev/null +++ b/packages/web/src/components/share/content-diff.module.css @@ -0,0 +1,125 @@ +.root { + display: flex; + flex-direction: column; + border: 1px solid var(--sl-color-divider); + background-color: var(--sl-color-bg-surface); + border-radius: 0.25rem; + + [data-component="desktop"] { + display: block; + } + + [data-component="mobile"] { + display: none; + } + + [data-component="diff-block"] { + display: flex; + flex-direction: column; + } + + [data-component="diff-row"] { + display: grid; + grid-template-columns: 1fr 1fr; + align-items: stretch; + + [data-slot="before"], + [data-slot="after"] { + position: relative; + display: flex; + flex-direction: column; + overflow-x: visible; + min-width: 0; + align-items: stretch; + padding: 0 1rem; + + &[data-diff-type="removed"] { + background-color: var(--sl-color-red-low); + + pre { + --shiki-dark-bg: var(--sl-color-red-low) !important; + background-color: var(--sl-color-red-low) !important; + } + + &::before { + content: "-"; + position: absolute; + left: 0.5ch; + top: 1px; + user-select: none; + color: var(--sl-color-red-high); + } + } + + &[data-diff-type="added"] { + background-color: var(--sl-color-green-low); + + pre { + --shiki-dark-bg: var(--sl-color-green-low) !important; + background-color: var(--sl-color-green-low) !important; + } + + &::before { + content: "+"; + position: absolute; + user-select: none; + color: var(--sl-color-green-high); + left: 0.5ch; + top: 1px; + } + } + } + + [data-slot="before"] { + border-right: 1px solid var(--sl-color-divider); + } + } + + .diff > .row:first-child [data-section="cell"]:first-child { + padding-top: 0.5rem; + } + + .diff > .row:last-child [data-section="cell"]:last-child { + padding-bottom: 0.5rem; + } + + [data-section="cell"] { + position: relative; + flex: 1; + display: flex; + flex-direction: column; + + width: 100%; + padding: 0.1875rem 0.5rem 0.1875rem 2.2ch; + margin: 0; + + &[data-display-mobile="true"] { + display: none; + } + + pre { + --shiki-dark-bg: var(--sl-color-bg-surface) !important; + background-color: var(--sl-color-bg-surface) !important; + + white-space: pre-wrap; + word-break: break-word; + + code > span:empty::before { + content: "\00a0"; + white-space: pre; + display: inline-block; + width: 0; + } + } + } + + @media (max-width: 40rem) { + [data-slot="desktop"] { + display: none; + } + + [data-slot="mobile"] { + display: block; + } + } +} diff --git a/packages/web/src/components/DiffView.tsx b/packages/web/src/components/share/content-diff.tsx similarity index 67% rename from packages/web/src/components/DiffView.tsx rename to packages/web/src/components/share/content-diff.tsx index 66dd7f0f..894145c3 100644 --- a/packages/web/src/components/DiffView.tsx +++ b/packages/web/src/components/share/content-diff.tsx @@ -1,7 +1,7 @@ import { type Component, createMemo } from "solid-js" import { parsePatch } from "diff" -import CodeBlock from "./CodeBlock" -import styles from "./diffview.module.css" +import { ContentCode } from "./content-code" +import styles from "./content-diff.module.css" type DiffRow = { left: string @@ -9,14 +9,12 @@ type DiffRow = { type: "added" | "removed" | "unchanged" | "modified" } -interface DiffViewProps { +interface Props { diff: string lang?: string - class?: string } -const DiffView: Component = (props) => { - +export function ContentDiff(props: Props) { const rows = createMemo(() => { const diffRows: DiffRow[] = [] @@ -33,20 +31,20 @@ const DiffView: Component = (props) => { const content = line.slice(1) const prefix = line[0] - if (prefix === '-') { + if (prefix === "-") { // Look ahead for consecutive additions to pair with removals const removals: string[] = [content] let j = i + 1 // Collect all consecutive removals - while (j < lines.length && lines[j][0] === '-') { + while (j < lines.length && lines[j][0] === "-") { removals.push(lines[j].slice(1)) j++ } // Collect all consecutive additions that follow const additions: string[] = [] - while (j < lines.length && lines[j][0] === '+') { + while (j < lines.length && lines[j][0] === "+") { additions.push(lines[j].slice(1)) j++ } @@ -62,39 +60,39 @@ const DiffView: Component = (props) => { diffRows.push({ left: removals[k], right: additions[k], - type: "modified" + type: "modified", }) } else if (hasLeft) { // Pure removal diffRows.push({ left: removals[k], right: "", - type: "removed" + type: "removed", }) } else if (hasRight) { // Pure addition - only create if we actually have content diffRows.push({ left: "", right: additions[k], - type: "added" + type: "added", }) } } i = j - } else if (prefix === '+') { + } else if (prefix === "+") { // Standalone addition (not paired with removal) diffRows.push({ left: "", right: content, - type: "added" + type: "added", }) i++ - } else if (prefix === ' ') { + } else if (prefix === " ") { diffRows.push({ left: content, right: content, - type: "unchanged" + type: "unchanged", }) i++ } else { @@ -112,7 +110,7 @@ const DiffView: Component = (props) => { }) const mobileRows = createMemo(() => { - const mobileBlocks: { type: 'removed' | 'added' | 'unchanged', lines: string[] }[] = [] + const mobileBlocks: { type: "removed" | "added" | "unchanged"; lines: string[] }[] = [] const currentRows = rows() let i = 0 @@ -121,15 +119,15 @@ const DiffView: Component = (props) => { const addedLines: string[] = [] // Collect consecutive modified/removed/added rows - while (i < currentRows.length && - (currentRows[i].type === 'modified' || - currentRows[i].type === 'removed' || - currentRows[i].type === 'added')) { + while ( + i < currentRows.length && + (currentRows[i].type === "modified" || currentRows[i].type === "removed" || currentRows[i].type === "added") + ) { const row = currentRows[i] - if (row.left && (row.type === 'removed' || row.type === 'modified')) { + if (row.left && (row.type === "removed" || row.type === "modified")) { removedLines.push(row.left) } - if (row.right && (row.type === 'added' || row.type === 'modified')) { + if (row.right && (row.type === "added" || row.type === "modified")) { addedLines.push(row.right) } i++ @@ -137,17 +135,17 @@ const DiffView: Component = (props) => { // Add grouped blocks if (removedLines.length > 0) { - mobileBlocks.push({ type: 'removed', lines: removedLines }) + mobileBlocks.push({ type: "removed", lines: removedLines }) } if (addedLines.length > 0) { - mobileBlocks.push({ type: 'added', lines: addedLines }) + mobileBlocks.push({ type: "added", lines: addedLines }) } // Add unchanged rows as-is - if (i < currentRows.length && currentRows[i].type === 'unchanged') { + if (i < currentRows.length && currentRows[i].type === "unchanged") { mobileBlocks.push({ - type: 'unchanged', - lines: [currentRows[i].left] + type: "unchanged", + lines: [currentRows[i].left], }) i++ } @@ -157,40 +155,29 @@ const DiffView: Component = (props) => { }) return ( -
-
+
+
{rows().map((r) => ( -
-
- +
+
+
-
- +
+
))}
-
+
{mobileRows().map((block) => ( -
+
{block.lines.map((line) => ( - ))}
@@ -200,8 +187,6 @@ const DiffView: Component = (props) => { ) } -export default DiffView - // const testDiff = `--- combined_before.txt 2025-06-24 16:38:08 // +++ combined_after.txt 2025-06-24 16:38:12 // @@ -1,21 +1,25 @@ @@ -210,12 +195,12 @@ export default DiffView // -old content // +added line // +new content -// +// // -removed empty line below // +added empty line above -// +// // - tab indented -// -trailing spaces +// -trailing spaces // -very long line that will definitely wrap in most editors and cause potential alignment issues when displayed in a two column diff view // -unicode content: 🚀 ✨ 中文 // -mixed content with tabs and spaces @@ -226,14 +211,14 @@ export default DiffView // +different unicode: 🎉 💻 日本語 // +normalized content with consistent spacing // +newline to content -// +// // -content to remove -// -whitespace only: +// -whitespace only: // -multiple // -consecutive // -deletions // -single deletion -// + +// + // +single addition // +first addition // +second addition diff --git a/packages/web/src/components/share/content-markdown.module.css b/packages/web/src/components/share/content-markdown.module.css new file mode 100644 index 00000000..da1aa112 --- /dev/null +++ b/packages/web/src/components/share/content-markdown.module.css @@ -0,0 +1,140 @@ +.root { + border: 1px solid var(--sl-color-blue-high); + padding: 0.5rem calc(0.5rem + 3px); + border-radius: 0.25rem; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 1rem; + align-self: flex-start; + max-width: var(--md-tool-width); + + &[data-highlight="true"] { + background-color: var(--sl-color-blue-low); + } + + [data-slot="expand-button"] { + flex: 0 0 auto; + padding: 2px 0; + font-size: 0.75rem; + } + + [data-slot="markdown"] { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + overflow: hidden; + + [data-expanded] & { + display: block; + } + + font-size: 0.875rem; + line-height: 1.5; + + p, + blockquote, + ul, + ol, + dl, + table, + pre { + margin-bottom: 1rem; + } + + strong { + font-weight: 600; + } + + ol { + list-style-position: inside; + padding-left: 0.75rem; + } + + ul { + padding-left: 1.5rem; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + font-size: 0.875rem; + font-weight: 600; + margin-bottom: 0.5rem; + } + + & > *:last-child { + margin-bottom: 0; + } + + pre { + --shiki-dark-bg: var(--sl-color-bg-surface) !important; + background-color: var(--sl-color-bg-surface) !important; + padding: 0.5rem 0.75rem; + line-height: 1.6; + font-size: 0.75rem; + white-space: pre-wrap; + word-break: break-word; + + span { + white-space: break-spaces; + } + } + + code { + font-weight: 500; + + &:not(pre code) { + &::before { + content: "`"; + font-weight: 700; + } + + &::after { + content: "`"; + font-weight: 700; + } + } + } + + table { + border-collapse: collapse; + width: 100%; + } + + th, + td { + border: 1px solid var(--sl-color-border); + padding: 0.5rem 0.75rem; + text-align: left; + } + + th { + border-bottom: 1px solid var(--sl-color-border); + } + + /* Remove outer borders */ + table tr:first-child th, + table tr:first-child td { + border-top: none; + } + + table tr:last-child td { + border-bottom: none; + } + + table th:first-child, + table td:first-child { + border-left: none; + } + + table th:last-child, + table td:last-child { + border-right: none; + } + } +} diff --git a/packages/web/src/components/share/content-markdown.tsx b/packages/web/src/components/share/content-markdown.tsx new file mode 100644 index 00000000..f7927129 --- /dev/null +++ b/packages/web/src/components/share/content-markdown.tsx @@ -0,0 +1,65 @@ +import style from "./content-markdown.module.css" +import { createResource, createSignal } from "solid-js" +import { createOverflow } from "./common" +import { transformerNotationDiff } from "@shikijs/transformers" +import { marked } from "marked" +import markedShiki from "marked-shiki" +import { codeToHtml } from "shiki" + +const markedWithShiki = marked.use( + markedShiki({ + highlight(code, lang) { + return codeToHtml(code, { + lang: lang || "text", + themes: { + light: "github-light", + dark: "github-dark", + }, + transformers: [transformerNotationDiff()], + }) + }, + }), +) + +interface Props { + text: string + expand?: boolean + highlight?: boolean +} +export function ContentMarkdown(props: Props) { + const [html] = createResource( + () => strip(props.text), + async (markdown) => { + return markedWithShiki.parse(markdown) + }, + ) + const [expanded, setExpanded] = createSignal(false) + const overflow = createOverflow() + + return ( +
+
+ + {!props.expand && overflow.status && ( + + )} +
+ ) +} + +function strip(text: string): string { + const wrappedRe = /^\s*<([A-Za-z]\w*)>\s*([\s\S]*?)\s*<\/\1>\s*$/ + const match = text.match(wrappedRe) + return match ? match[2] : text +} diff --git a/packages/web/src/components/share/content-text.module.css b/packages/web/src/components/share/content-text.module.css new file mode 100644 index 00000000..f8d0b0b9 --- /dev/null +++ b/packages/web/src/components/share/content-text.module.css @@ -0,0 +1,57 @@ +.root { + color: var(--sl-color-text); + background-color: var(--sl-color-bg-surface); + padding: 0.5rem calc(0.5rem + 3px); + border-radius: 0.25rem; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 1rem; + align-self: flex-start; + max-width: var(--md-tool-width); + font-size: 0.875rem; + + &[data-compact] { + font-size: 0.75rem; + color: var(--sl-color-text-dimmed); + } + + [data-slot="text"] { + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + overflow: hidden; + + [data-expanded] & { + display: block; + } + } + + [data-slot="expand-button"] { + flex: 0 0 auto; + padding: 2px 0; + font-size: 0.75rem; + } + + &[data-theme="invert"] { + background-color: var(--sl-color-blue-high); + color: var(--sl-color-text-invert); + + [data-slot="expand-button"] { + opacity: 0.85; + color: var(--sl-color-text-invert); + + &:hover { + opacity: 1; + } + } + } + + &[data-theme="blue"] { + background-color: var(--sl-color-blue-low); + } +} diff --git a/packages/web/src/components/share/content-text.tsx b/packages/web/src/components/share/content-text.tsx new file mode 100644 index 00000000..c52e0dfc --- /dev/null +++ b/packages/web/src/components/share/content-text.tsx @@ -0,0 +1,35 @@ +import style from "./content-text.module.css" +import { createSignal } from "solid-js" +import { createOverflow } from "./common" + +interface Props { + text: string + expand?: boolean + compact?: boolean +} +export function ContentText(props: Props) { + const [expanded, setExpanded] = createSignal(false) + const overflow = createOverflow() + + return ( +
+
+        {props.text}
+      
+ {((!props.expand && overflow.status) || expanded()) && ( + + )} +
+ ) +} diff --git a/packages/web/src/components/share/part.module.css b/packages/web/src/components/share/part.module.css new file mode 100644 index 00000000..9145cddf --- /dev/null +++ b/packages/web/src/components/share/part.module.css @@ -0,0 +1,375 @@ +.root { + display: flex; + gap: 0.625rem; + + [data-component="decoration"] { + flex: 0 0 auto; + display: flex; + flex-direction: column; + gap: 0.625rem; + align-items: center; + justify-content: flex-start; + + [data-slot="anchor"] { + position: relative; + + a:first-child { + display: block; + flex: 0 0 auto; + width: 18px; + opacity: 0.65; + + svg { + color: var(--sl-color-text-secondary); + display: block; + + &:nth-child(3) { + color: var(--sl-color-green-high); + } + } + + svg:nth-child(2), + svg:nth-child(3) { + display: none; + } + + &:hover { + svg:nth-child(1) { + display: none; + } + + svg:nth-child(2) { + display: block; + } + } + } + + [data-copied] & { + a, + a:hover { + svg:nth-child(1), + svg:nth-child(2) { + display: none; + } + + svg:nth-child(3) { + display: block; + } + } + } + } + + [data-slot="bar"] { + width: 3px; + height: 100%; + border-radius: 1px; + background-color: var(--sl-color-hairline); + } + + [data-slot="tooltip"] { + position: absolute; + top: 50%; + left: calc(100% + 12px); + transform: translate(0, -50%); + line-height: 1.1; + padding: 0.375em 0.5em calc(0.375em + 2px); + background: var(--sl-color-white); + color: var(--sl-color-text-invert); + font-size: 0.6875rem; + border-radius: 7px; + white-space: nowrap; + + z-index: 1; + opacity: 0; + visibility: hidden; + + &::after { + content: ""; + position: absolute; + top: 50%; + left: -15px; + transform: translateY(-50%); + border: 8px solid transparent; + border-right-color: var(--sl-color-white); + } + + [data-copied] & { + opacity: 1; + visibility: visible; + } + } + } + + [data-component="content"] { + display: flex; + flex-direction: column; + gap: 1rem; + flex-grow: 1; + } + + [data-component="spacer"] { + height: 0rem; + } + + [data-component="content-footer"] { + align-self: flex-start; + font-size: 0.75rem; + color: var(--sl-color-text-dimmed); + } + + [data-component="step-start"] { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.375rem; + padding-bottom: 1rem; + + [data-slot="provider"] { + line-height: 18px; + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: -0.5px; + color: var(--sl-color-text-secondary); + } + + [data-slot="model"] { + line-height: 1.5; + } + } + + [data-component="button-text"] { + cursor: pointer; + appearance: none; + background-color: transparent; + border: none; + padding: 0; + color: var(--sl-color-text-secondary); + font-size: 0.75rem; + + &:hover { + color: var(--sl-color-text); + } + + &[data-more] { + display: flex; + align-items: center; + gap: 0.125rem; + + span[data-slot="icon"] { + line-height: 1; + opacity: 0.85; + + svg { + display: block; + } + } + } + } + + [data-component="tool"] { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.375rem; + padding-bottom: 1rem; + } + + [data-component="tool-title"] { + line-height: 18px; + font-size: 0.875rem; + color: var(--sl-color-text-secondary); + max-width: var(--md-tool-width); + display: flex; + align-items: flex-start; + gap: 0.375rem; + + [data-slot="name"] { + text-transform: uppercase; + letter-spacing: -0.5px; + } + + [data-slot="target"] { + color: var(--sl-color-text); + word-break: break-all; + font-weight: 500; + } + } + + [data-component="tool-result"] { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + [data-component="todos"] { + list-style-type: none; + padding: 0; + margin: 0; + width: 100%; + max-width: var(--sm-tool-width); + border: 1px solid var(--sl-color-divider); + border-radius: 0.25rem; + + [data-slot="item"] { + margin: 0; + position: relative; + padding-left: 1.5rem; + font-size: 0.75rem; + padding: 0.375rem 0.625rem 0.375rem 1.75rem; + border-bottom: 1px solid var(--sl-color-divider); + line-height: 1.5; + word-break: break-word; + + &:last-child { + border-bottom: none; + } + + & > span { + position: absolute; + display: inline-block; + left: 0.5rem; + top: calc(0.5rem + 1px); + width: 0.75rem; + height: 0.75rem; + border: 1px solid var(--sl-color-divider); + border-radius: 0.15rem; + + &::before { + } + } + + &[data-status="pending"] { + color: var(--sl-color-text); + } + + &[data-status="in_progress"] { + color: var(--sl-color-text); + + & > span { + border-color: var(--sl-color-orange); + } + + & > span::before { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: calc(0.75rem - 2px - 4px); + height: calc(0.75rem - 2px - 4px); + box-shadow: inset 1rem 1rem var(--sl-color-orange-low); + } + } + + &[data-status="completed"] { + color: var(--sl-color-text-secondary); + + & > span { + border-color: var(--sl-color-green-low); + } + + & > span::before { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: calc(0.75rem - 2px - 4px); + height: calc(0.75rem - 2px - 4px); + box-shadow: inset 1rem 1rem var(--sl-color-green); + + transform-origin: bottom left; + clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); + } + } + } + } + + [data-component="terminal"] { + width: 100%; + max-width: var(--sm-tool-width); + + [data-slot="body"] { + display: flex; + flex-direction: column; + border: 1px solid var(--sl-color-divider); + border-radius: 0.25rem; + overflow: hidden; + } + + [data-slot="header"] { + position: relative; + border-bottom: 1px solid var(--sl-color-divider); + width: 100%; + height: 1.625rem; + text-align: center; + padding: 0 3.25rem; + + > span { + max-width: min(100%, 140ch); + display: inline-block; + white-space: nowrap; + overflow: hidden; + line-height: 1.625rem; + font-size: 0.75rem; + text-overflow: ellipsis; + color: var(--sl-color-text-dimmed); + } + + &::before { + content: ""; + position: absolute; + pointer-events: none; + top: 8px; + left: 10px; + width: 2rem; + height: 0.5rem; + line-height: 0; + background-color: var(--sl-color-hairline); + mask-image: var(--term-icon); + mask-repeat: no-repeat; + } + } + + [data-slot="content"] { + display: flex; + flex-direction: column; + padding: 0.5rem calc(0.5rem + 3px); + + pre { + --shiki-dark-bg: var(--sl-color-bg) !important; + background-color: var(--sl-color-bg) !important; + line-height: 1.6; + font-size: 0.75rem; + white-space: pre-wrap; + word-break: break-word; + } + } + } + + [data-component="tool-args"] { + display: inline-grid; + align-items: center; + grid-template-columns: max-content max-content minmax(0, 1fr); + max-width: var(--md-tool-width); + gap: 0.25rem 0.375rem; + + & > div:nth-child(3n + 1) { + width: 8px; + height: 2px; + border-radius: 1px; + background: var(--sl-color-divider); + } + + & > div:nth-child(3n + 2), + & > div:nth-child(3n + 3) { + font-size: 0.75rem; + line-height: 1.5; + } + + & > div:nth-child(3n + 3) { + padding-left: 0.125rem; + word-break: break-word; + color: var(--sl-color-text-secondary); + } + } +} diff --git a/packages/web/src/components/share/part.tsx b/packages/web/src/components/share/part.tsx new file mode 100644 index 00000000..3ee2c61a --- /dev/null +++ b/packages/web/src/components/share/part.tsx @@ -0,0 +1,664 @@ +import { createMemo, createSignal, For, Match, Show, Switch, type JSX, type ParentProps } from "solid-js" +import { + IconCheckCircle, + IconChevronDown, + IconChevronRight, + IconHashtag, + IconSparkles, + IconGlobeAlt, + IconDocument, + IconQueueList, + IconCommandLine, + IconDocumentPlus, + IconPencilSquare, + IconRectangleStack, + IconMagnifyingGlass, + IconDocumentMagnifyingGlass, +} from "../icons" +import styles from "./part.module.css" +import type { MessageV2 } from "opencode/session/message-v2" +import { ContentText } from "./content-text" +import { ContentMarkdown } from "./content-markdown" +import { DateTime } from "luxon" +import CodeBlock from "../CodeBlock" +import map from "lang-map" +import type { Diagnostic } from "vscode-languageserver-types" + +import { ContentCode } from "./content-code" +import { ContentDiff } from "./content-diff" + +export interface PartProps { + index: number + message: MessageV2.Info + part: MessageV2.AssistantPart | MessageV2.UserPart + last: boolean +} + +export function Part(props: PartProps) { + const [copied, setCopied] = createSignal(false) + const id = createMemo(() => props.message.id + "-" + props.index) + + return ( +
+ +
+ {props.message.role === "user" && props.part.type === "text" && ( + <> + + + )} + {props.message.role === "assistant" && props.part.type === "text" && ( + <> + + {props.last && props.message.role === "assistant" && props.message.time.completed && ( +
+ {DateTime.fromMillis(props.message.time.completed).toLocaleString(DateTime.DATETIME_MED)} +
+ )} + + + )} + {props.part.type === "step-start" && props.message.role === "assistant" && ( +
+
{props.message.providerID}
+
{props.message.modelID}
+
+ )} + {props.part.type === "tool" && + props.part.state.status === "completed" && + props.message.role === "assistant" && ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ )} +
+
+ ) +} + +type ToolProps = { + id: MessageV2.ToolPart["id"] + tool: MessageV2.ToolPart["tool"] + state: MessageV2.ToolStateCompleted + message: MessageV2.Assistant + isLastPart?: boolean +} + +interface Todo { + id: string + content: string + status: "pending" | "in_progress" | "completed" + priority: "low" | "medium" | "high" +} + +function stripWorkingDirectory(filePath?: string, workingDir?: string) { + if (filePath === undefined || workingDir === undefined) return filePath + + const prefix = workingDir.endsWith("/") ? workingDir : workingDir + "/" + + if (filePath === workingDir) { + return "" + } + + if (filePath.startsWith(prefix)) { + return filePath.slice(prefix.length) + } + + return filePath +} + +function getShikiLang(filename: string) { + const ext = filename.split(".").pop()?.toLowerCase() ?? "" + const langs = map.languages(ext) + const type = langs?.[0]?.toLowerCase() + + const overrides: Record = { + conf: "shellscript", + } + + return type ? (overrides[type] ?? type) : "plaintext" +} + +function getDiagnostics(diagnosticsByFile: Record, currentFile: string): JSX.Element[] { + const result: JSX.Element[] = [] + + if (diagnosticsByFile === undefined || diagnosticsByFile[currentFile] === undefined) return result + + for (const diags of Object.values(diagnosticsByFile)) { + for (const d of diags) { + if (d.severity !== 1) continue + + const line = d.range.start.line + 1 + const column = d.range.start.character + 1 + + result.push( +
+          
+            Error
+          
+          
+            [{line}:{column}]
+          
+          {d.message}
+        
, + ) + } + } + + return result +} + +function formatErrorString(error: string): JSX.Element { + const errorMarker = "Error: " + const startsWithError = error.startsWith(errorMarker) + + return startsWithError ? ( +
+      
+        Error
+      
+      {error.slice(errorMarker.length)}
+    
+ ) : ( +
+      {error}
+    
+ ) +} + +export function TodoWriteTool(props: ToolProps) { + const priority: Record = { + in_progress: 0, + pending: 1, + completed: 2, + } + const todos = createMemo(() => + ((props.state.input?.todos ?? []) as Todo[]).slice().sort((a, b) => priority[a.status] - priority[b.status]), + ) + const starting = () => todos().every((t: Todo) => t.status === "pending") + const finished = () => todos().every((t: Todo) => t.status === "completed") + + return ( + <> +
+ + + Creating plan + Completing plan + + +
+ 0}> +
    + + {(todo) => ( +
  • + + {todo.content} +
  • + )} +
    +
+
+ + ) +} + +export function GrepTool(props: ToolProps) { + return ( + <> +
+ Grep + “{props.state.input.pattern}” +
+
+ + 0}> + + + + + + + + +
+ + ) +} + +export function ListTool(props: ToolProps) { + const path = createMemo(() => + props.state.input?.path !== props.message.path.cwd + ? stripWorkingDirectory(props.state.input?.path, props.message.path.cwd) + : props.state.input?.path, + ) + + return ( + <> +
+ LS + + {path()} + +
+
+ + + + + + + +
+ + ) +} + +export function WebFetchTool(props: ToolProps) { + return ( + <> +
+ Fetch + {props.state.input.url} +
+
+ + +
{formatErrorString(props.state.output)}
+
+ + + + + +
+
+ + ) +} + +export function ReadTool(props: ToolProps) { + const filePath = createMemo(() => stripWorkingDirectory(props.state.input?.filePath, props.message.path.cwd)) + + return ( + <> +
+ Read + + {filePath()} + +
+
+ + +
{formatErrorString(props.state.output)}
+
+ + + + + + + + + + +
+
+ + ) +} + +export function WriteTool(props: ToolProps) { + const filePath = createMemo(() => stripWorkingDirectory(props.state.input?.filePath, props.message.path.cwd)) + const diagnostics = createMemo(() => getDiagnostics(props.state.metadata?.diagnostics, props.state.input.filePath)) + + return ( + <> +
+ Write + + {filePath()} + +
+ 0}> +
{diagnostics()}
+
+
+ + +
{formatErrorString(props.state.output)}
+
+ + + + + +
+
+ + ) +} + +export function EditTool(props: ToolProps) { + const filePath = createMemo(() => stripWorkingDirectory(props.state.input.filePath, props.message.path.cwd)) + const diagnostics = createMemo(() => getDiagnostics(props.state.metadata?.diagnostics, props.state.input.filePath)) + + return ( + <> +
+ Edit + + {filePath()} + +
+
+ + +
{formatErrorString(props.state.metadata?.message || "")}
+
+ +
+ +
+
+
+
+ 0}> +
{diagnostics()}
+
+ + ) +} + +export function BashTool(props: ToolProps) { + return ( + <> +
+
+
+ {props.state.metadata.description} +
+
+ + +
+
+
+ + ) +} + +export function GlobTool(props: ToolProps) { + return ( + <> +
+ Glob + “{props.state.input.pattern}” +
+ + 0}> +
+ + + +
+
+ + + +
+ + ) +} + +interface ResultsButtonProps extends ParentProps { + showCopy?: string + hideCopy?: string +} +function ResultsButton(props: ResultsButtonProps) { + const [show, setShow] = createSignal(false) + + return ( + <> + + {props.children} + + ) +} + +export function Spacer() { + return
+} + +function Footer(props: ParentProps<{ title: string }>) { + return ( +
+ {props.children} +
+ ) +} + +export function FallbackTool(props: ToolProps) { + return ( + <> +
+ {props.tool} +
+
+ + {(arg) => ( + <> +
+
{arg[0]}
+
{arg[1]}
+ + )} +
+
+ + +
+ + + +
+
+
+ + ) +} + +// Converts nested objects/arrays into [path, value] pairs. +// E.g. {a:{b:{c:1}}, d:[{e:2}, 3]} => [["a.b.c",1], ["d[0].e",2], ["d[1]",3]] +function flattenToolArgs(obj: any, prefix: string = ""): Array<[string, any]> { + const entries: Array<[string, any]> = [] + + for (const [key, value] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${key}` : key + + if (value !== null && typeof value === "object") { + if (Array.isArray(value)) { + value.forEach((item, index) => { + const arrayPath = `${path}[${index}]` + if (item !== null && typeof item === "object") { + entries.push(...flattenToolArgs(item, arrayPath)) + } else { + entries.push([arrayPath, item]) + } + }) + } else { + entries.push(...flattenToolArgs(value, path)) + } + } else { + entries.push([path, value]) + } + } + + return entries +} diff --git a/packages/web/src/content/docs/docs/cli.mdx b/packages/web/src/content/docs/docs/cli.mdx index 49d343be..57e59521 100644 --- a/packages/web/src/content/docs/docs/cli.mdx +++ b/packages/web/src/content/docs/docs/cli.mdx @@ -39,12 +39,12 @@ opencode run Explain the use of context in Go #### Flags -| Flag | Short | Description | -| ----------------- | ----- | --------------------- | -| `--continue` | `-c` | Continue the last session | -| `--session` | `-s` | Session ID to continue | -| `--share` | | Share the session | -| `--model` | `-m` | Model to use in the form of provider/model | +| Flag | Short | Description | +| ------------ | ----- | ------------------------------------------ | +| `--continue` | `-c` | Continue the last session | +| `--session` | `-s` | Session ID to continue | +| `--share` | | Share the session | +| `--model` | `-m` | Model to use in the form of provider/model | --- @@ -122,8 +122,8 @@ opencode upgrade v0.1.48 The opencode CLI takes the following flags. -| Flag | Short | Description | -| ----------------- | ----- | --------------------- | -| `--help` | `-h` | Display help | -| `--version` | | Print version number | -| `--print-logs` | | Print logs to stderr | +| Flag | Short | Description | +| -------------- | ----- | -------------------- | +| `--help` | `-h` | Display help | +| `--version` | | Print version number | +| `--print-logs` | | Print logs to stderr | diff --git a/packages/web/src/content/docs/docs/config.mdx b/packages/web/src/content/docs/docs/config.mdx index d88749c6..40583ea0 100644 --- a/packages/web/src/content/docs/docs/config.mdx +++ b/packages/web/src/content/docs/docs/config.mdx @@ -39,7 +39,7 @@ You can configure the providers and models you want to use in your opencode conf ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "provider": { }, + "provider": {}, "model": "" } ``` @@ -70,7 +70,7 @@ You can customize your keybinds through the `keybinds` option. ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "keybinds": { } + "keybinds": {} } ``` @@ -85,7 +85,7 @@ You can configure MCP servers you want to use through the `mcp` option. ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "mcp": { } + "mcp": {} } ``` @@ -105,6 +105,7 @@ You can disable providers that are loaded automatically through the `disabled_pr ``` The `disabled_providers` option accepts an array of provider IDs. When a provider is disabled: + - It won't be loaded even if environment variables are set - It won't be loaded even if API keys are configured through `opencode auth login` - The provider's models won't appear in the model selection list diff --git a/packages/web/src/content/docs/docs/index.mdx b/packages/web/src/content/docs/docs/index.mdx index b39ce452..9ea95844 100644 --- a/packages/web/src/content/docs/docs/index.mdx +++ b/packages/web/src/content/docs/docs/index.mdx @@ -3,7 +3,7 @@ title: Intro description: Get started with opencode. --- -import { Tabs, TabItem } from '@astrojs/starlight/components'; +import { Tabs, TabItem } from "@astrojs/starlight/components" [**opencode**](/) is an AI coding agent built for the terminal. It features: @@ -21,26 +21,10 @@ import { Tabs, TabItem } from '@astrojs/starlight/components'; ## Install - - ```bash - npm install -g opencode-ai - ``` - - - ```bash - bun install -g opencode-ai - ``` - - - ```bash - pnpm install -g opencode-ai - ``` - - - ```bash - yarn global add opencode-ai - ``` - + ```bash npm install -g opencode-ai ``` + ```bash bun install -g opencode-ai ``` + ```bash pnpm install -g opencode-ai ``` + ```bash yarn global add opencode-ai ``` You can also install the opencode binary through the following. diff --git a/packages/web/src/content/docs/docs/rules.mdx b/packages/web/src/content/docs/docs/rules.mdx index b7818d71..b1b55b02 100644 --- a/packages/web/src/content/docs/docs/rules.mdx +++ b/packages/web/src/content/docs/docs/rules.mdx @@ -31,17 +31,20 @@ You can also just create this file manually. Here's an example of some things yo This is an SST v3 monorepo with TypeScript. The project uses bun workspaces for package management. ## Project Structure + - `packages/` - Contains all workspace packages (functions, core, web, etc.) - `infra/` - Infrastructure definitions split by service (storage.ts, api.ts, web.ts) - `sst.config.ts` - Main SST configuration with dynamic imports ## Code Standards + - Use TypeScript with strict mode enabled - Shared code goes in `packages/core/` with proper exports configuration - Functions go in `packages/functions/` - Infrastructure should be split into logical files in `infra/` ## Monorepo Conventions + - Import shared modules using workspace names: `@my-app/core/example` ``` diff --git a/packages/web/src/content/docs/docs/themes.mdx b/packages/web/src/content/docs/docs/themes.mdx index da612284..12559153 100644 --- a/packages/web/src/content/docs/docs/themes.mdx +++ b/packages/web/src/content/docs/docs/themes.mdx @@ -13,18 +13,18 @@ By default, opencode uses our own `opencode` theme. opencode comes with several built-in themes. -| Name | Description | -| --- | --- | -| `system` | Adapts to your terminal's background color | -| `tokyonight` | Based on the Tokyonight theme | -| `everforest` | Based on the Everforest theme | -| `ayu` | Based on the Ayu dark theme | -| `catppuccin` | Based on the Catppuccin theme | -| `gruvbox` | Based on the Gruvbox theme | -| `kanagawa` | Based on the Kanagawa theme | -| `nord` | Based on the Nord theme | -| `matrix` | Hacker-style green on black theme | -| `one-dark` | Based on the Atom One Dark theme | +| Name | Description | +| ------------ | ------------------------------------------ | +| `system` | Adapts to your terminal's background color | +| `tokyonight` | Based on the Tokyonight theme | +| `everforest` | Based on the Everforest theme | +| `ayu` | Based on the Ayu dark theme | +| `catppuccin` | Based on the Catppuccin theme | +| `gruvbox` | Based on the Gruvbox theme | +| `kanagawa` | Based on the Kanagawa theme | +| `nord` | Based on the Nord theme | +| `matrix` | Hacker-style green on black theme | +| `one-dark` | Based on the Atom One Dark theme | And more, we are constantly adding new themes. @@ -61,7 +61,7 @@ You can select a theme by bringing up the theme select with the `/theme` command ## Custom themes -opencode supports a flexible JSON-based theme system that allows users to create and customize themes easily. +opencode supports a flexible JSON-based theme system that allows users to create and customize themes easily. --- diff --git a/packages/web/src/types/lang-map.d.ts b/packages/web/src/types/lang-map.d.ts index b21d2a00..6df26d6a 100644 --- a/packages/web/src/types/lang-map.d.ts +++ b/packages/web/src/types/lang-map.d.ts @@ -2,9 +2,9 @@ declare module "lang-map" { /** Returned by calling `map()` */ export interface MapReturn { /** All extensions keyed by language name */ - extensions: Record; + extensions: Record /** All languages keyed by file-extension */ - languages: Record; + languages: Record } /** @@ -14,14 +14,14 @@ declare module "lang-map" { * const { extensions, languages } = map(); * ``` */ - function map(): MapReturn; + function map(): MapReturn /** Static method: get extensions for a given language */ namespace map { - function extensions(language: string): string[]; + function extensions(language: string): string[] /** Static method: get languages for a given extension */ - function languages(extension: string): string[]; + function languages(extension: string): string[] } - export = map; + export = map } diff --git a/packages/web/sst-env.d.ts b/packages/web/sst-env.d.ts index b6a7e906..0397645b 100644 --- a/packages/web/sst-env.d.ts +++ b/packages/web/sst-env.d.ts @@ -6,4 +6,4 @@ /// import "sst" -export {} \ No newline at end of file +export {} diff --git a/scripts/stats.ts b/scripts/stats.ts index b30e57d9..2abe7e1c 100755 --- a/scripts/stats.ts +++ b/scripts/stats.ts @@ -26,13 +26,9 @@ async function fetchNpmDownloads(packageName: string): Promise { // Use a range from 2020 to current year + 5 years to ensure it works forever const currentYear = new Date().getFullYear() const endYear = currentYear + 5 - const response = await fetch( - `https://api.npmjs.org/downloads/range/2020-01-01:${endYear}-12-31/${packageName}`, - ) + const response = await fetch(`https://api.npmjs.org/downloads/range/2020-01-01:${endYear}-12-31/${packageName}`) if (!response.ok) { - console.warn( - `Failed to fetch npm downloads for ${packageName}: ${response.status}`, - ) + console.warn(`Failed to fetch npm downloads for ${packageName}: ${response.status}`) return 0 } const data: NpmDownloadsRange = await response.json() @@ -53,9 +49,7 @@ async function fetchReleases(): Promise { const response = await fetch(url) if (!response.ok) { - throw new Error( - `GitHub API error: ${response.status} ${response.statusText}`, - ) + throw new Error(`GitHub API error: ${response.status} ${response.statusText}`) } const batch: Release[] = await response.json() @@ -115,11 +109,7 @@ async function save(githubTotal: number, npmDownloads: number) { for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i].trim() - if ( - line.startsWith("|") && - !line.includes("Date") && - !line.includes("---") - ) { + if (line.startsWith("|") && !line.includes("Date") && !line.includes("---")) { const match = line.match( /\|\s*[\d-]+\s*\|\s*([\d,]+)\s*(?:\([^)]*\))?\s*\|\s*([\d,]+)\s*(?:\([^)]*\))?\s*\|\s*([\d,]+)\s*(?:\([^)]*\))?\s*\|/, ) @@ -147,11 +137,7 @@ async function save(githubTotal: number, npmDownloads: number) { ? ` (${githubChange.toLocaleString()})` : " (+0)" const npmChangeStr = - npmChange > 0 - ? ` (+${npmChange.toLocaleString()})` - : npmChange < 0 - ? ` (${npmChange.toLocaleString()})` - : " (+0)" + npmChange > 0 ? ` (+${npmChange.toLocaleString()})` : npmChange < 0 ? ` (${npmChange.toLocaleString()})` : " (+0)" const totalChangeStr = totalChange > 0 ? ` (+${totalChange.toLocaleString()})` @@ -182,9 +168,7 @@ const { total: githubTotal, stats } = calculate(releases) console.log("Fetching npm all-time downloads for opencode-ai...\n") const npmDownloads = await fetchNpmDownloads("opencode-ai") -console.log( - `Fetched npm all-time downloads: ${npmDownloads.toLocaleString()}\n`, -) +console.log(`Fetched npm all-time downloads: ${npmDownloads.toLocaleString()}\n`) await save(githubTotal, npmDownloads) @@ -202,24 +186,18 @@ console.log("-".repeat(60)) stats .sort((a, b) => b.downloads - a.downloads) .forEach((release) => { - console.log( - `${release.tag.padEnd(15)} ${release.downloads.toLocaleString().padStart(10)} downloads`, - ) + console.log(`${release.tag.padEnd(15)} ${release.downloads.toLocaleString().padStart(10)} downloads`) if (release.assets.length > 1) { release.assets .sort((a, b) => b.downloads - a.downloads) .forEach((asset) => { - console.log( - ` └─ ${asset.name.padEnd(25)} ${asset.downloads.toLocaleString().padStart(8)}`, - ) + console.log(` └─ ${asset.name.padEnd(25)} ${asset.downloads.toLocaleString().padStart(8)}`) }) } }) console.log("-".repeat(60)) -console.log( - `GitHub Total: ${githubTotal.toLocaleString()} downloads across ${releases.length} releases`, -) +console.log(`GitHub Total: ${githubTotal.toLocaleString()} downloads across ${releases.length} releases`) console.log(`npm Total: ${npmDownloads.toLocaleString()} downloads`) console.log(`Combined Total: ${totalDownloads.toLocaleString()} downloads`) diff --git a/sst-env.d.ts b/sst-env.d.ts index 627d74a5..45c07b66 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -5,20 +5,20 @@ declare module "sst" { export interface Resource { - "Api": { - "type": "sst.cloudflare.Worker" - "url": string + Api: { + type: "sst.cloudflare.Worker" + url: string } - "Bucket": { - "type": "sst.cloudflare.Bucket" + Bucket: { + type: "sst.cloudflare.Bucket" } - "Web": { - "type": "sst.cloudflare.Astro" - "url": string + Web: { + type: "sst.cloudflare.Astro" + url: string } } } /// import "sst" -export {} \ No newline at end of file +export {} diff --git a/stainless.yml b/stainless.yml index f8d654fb..941c4f38 100644 --- a/stainless.yml +++ b/stainless.yml @@ -78,16 +78,19 @@ resources: models: session: Session message: Message - toolCall: ToolCall - toolPartialCall: ToolPartialCall - toolResult: ToolResult textPart: TextPart - reasoningPart: ReasoningPart - toolInvocationPart: ToolInvocationPart - sourceUrlPart: SourceUrlPart filePart: FilePart + toolPart: ToolPart stepStartPart: StepStartPart - messagePart: MessagePart + assistantMessage: AssistantMessage + assistantMessagePart: AssistantMessagePart + userMessage: UserMessage + userMessagePart: UserMessagePart + toolStatePending: ToolStatePending + toolStateRunning: ToolStateRunning + toolStateCompleted: ToolStateCompleted + toolStateError: ToolStateError + methods: list: get /session create: post /session From b478e5655ccbc22a1b86093f64abc4b4a0d7f4f0 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 7 Jul 2025 16:12:47 -0400 Subject: [PATCH 26/37] fix interrupt --- packages/opencode/src/session/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index b3567a5c..4c50d51d 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -708,6 +708,8 @@ export namespace Session { } await updateMessage(next) } + next.time.completed = Date.now() + await updateMessage(next) return next } From 661b74def671bb4c604d54162bad9230aa3472c0 Mon Sep 17 00:00:00 2001 From: Jay V Date: Mon, 7 Jul 2025 16:13:24 -0400 Subject: [PATCH 27/37] docs: debug info --- packages/web/astro.config.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs index 63b93b9d..05866c5d 100644 --- a/packages/web/astro.config.mjs +++ b/packages/web/astro.config.mjs @@ -10,6 +10,8 @@ import rehypeAutolinkHeadings from "rehype-autolink-headings" const github = "https://github.com/sst/opencode" +console.log(process.env.SST_STAGE) + // https://astro.build/config export default defineConfig({ site: config.url, From 7cfa297a78a549ac45b98c3126bc2c1d6a5a22ac Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 7 Jul 2025 16:24:22 -0400 Subject: [PATCH 28/37] wip: model and prompt flags for tui --- packages/opencode/src/cli/cmd/tui.ts | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index f0ec4a53..886863a1 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -15,10 +15,21 @@ export const TuiCommand = cmd({ command: "$0 [project]", describe: "start opencode tui", builder: (yargs) => - yargs.positional("project", { - type: "string", - describe: "path to start opencode in", - }), + yargs + .positional("project", { + type: "string", + describe: "path to start opencode in", + }) + .option("model", { + type: "string", + alias: ["m"], + describe: "model to use in the format of provider/model", + }) + .option("prompt", { + alias: ["p"], + type: "string", + describe: "prompt to use", + }), handler: async (args) => { while (true) { const cwd = args.project ? path.resolve(args.project) : process.cwd() @@ -60,7 +71,11 @@ export const TuiCommand = cmd({ cmd, }) const proc = Bun.spawn({ - cmd: [...cmd, ...process.argv.slice(2)], + cmd: [ + ...cmd, + ...(args.model ? ["--model", args.model] : []), + ...(args.prompt ? ["--prompt", args.prompt] : []), + ], cwd, stdout: "inherit", stderr: "inherit", From 9253a3ca9e561bb44e08d634295706ddade6f00e Mon Sep 17 00:00:00 2001 From: Jay V Date: Mon, 7 Jul 2025 16:25:39 -0400 Subject: [PATCH 29/37] docs: debug --- infra/app.ts | 1 + packages/web/astro.config.mjs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/infra/app.ts b/infra/app.ts index 834936b7..f585748a 100644 --- a/infra/app.ts +++ b/infra/app.ts @@ -39,6 +39,7 @@ new sst.cloudflare.x.Astro("Web", { domain, path: "packages/web", environment: { + SST_STAGE: $app.stage, VITE_API_URL: api.url, }, }) diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs index 05866c5d..742d5295 100644 --- a/packages/web/astro.config.mjs +++ b/packages/web/astro.config.mjs @@ -10,7 +10,7 @@ import rehypeAutolinkHeadings from "rehype-autolink-headings" const github = "https://github.com/sst/opencode" -console.log(process.env.SST_STAGE) +console.log("stage", process.env.SST_STAGE) // https://astro.build/config export default defineConfig({ From c51de945a5620d77ccb25652c732d259035a8cf7 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 7 Jul 2025 16:29:04 -0400 Subject: [PATCH 30/37] Add stdin support to run command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow piping content to opencode run when no message arguments are provided, enabling standard Unix pipe patterns for better CLI integration. 🤖 Generated with [opencode](https://opencode.ai) Co-Authored-By: opencode --- packages/opencode/src/cli/cmd/run.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 2d0262aa..be271ceb 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -54,7 +54,14 @@ export const RunCommand = cmd({ }) }, handler: async (args) => { - const message = args.message.join(" ") + let message = args.message.join(" ") + + // Read from stdin if no message provided and stdin is available + if (!message && !process.stdin.isTTY) { + message = await Bun.stdin.text() + message = message.trim() + } + await bootstrap({ cwd: process.cwd() }, async () => { const session = await (async () => { if (args.continue) { From facd851b119f3570a00769a2cb8755e5d245fdff Mon Sep 17 00:00:00 2001 From: Jay V Date: Mon, 7 Jul 2025 16:31:10 -0400 Subject: [PATCH 31/37] docs: dynamic domain --- infra/app.ts | 1 + packages/web/astro.config.mjs | 7 ++++--- packages/web/config.mjs | 2 +- packages/web/src/components/Head.astro | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/infra/app.ts b/infra/app.ts index f585748a..caaea0e9 100644 --- a/infra/app.ts +++ b/infra/app.ts @@ -39,6 +39,7 @@ new sst.cloudflare.x.Astro("Web", { domain, path: "packages/web", environment: { + // For astro config SST_STAGE: $app.stage, VITE_API_URL: api.url, }, diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs index 742d5295..538784ac 100644 --- a/packages/web/astro.config.mjs +++ b/packages/web/astro.config.mjs @@ -9,12 +9,13 @@ import { rehypeHeadingIds } from "@astrojs/markdown-remark" import rehypeAutolinkHeadings from "rehype-autolink-headings" const github = "https://github.com/sst/opencode" - -console.log("stage", process.env.SST_STAGE) +const stage = process.env.SST_STAGE || "dev" // https://astro.build/config export default defineConfig({ - site: config.url, + site: stage === "production" + ? `https://${config.domain}` + : `https://${stage}.${config.domain}`, output: "server", adapter: cloudflare({ imageService: "passthrough", diff --git a/packages/web/config.mjs b/packages/web/config.mjs index f4c2fe99..f0ae3cb6 100644 --- a/packages/web/config.mjs +++ b/packages/web/config.mjs @@ -1,5 +1,5 @@ export default { - url: "https://opencode.ai", + domain: "opencode.ai", socialCard: "https://social-cards.sst.dev", github: "https://github.com/sst/opencode", discord: "https://discord.gg/opencode", diff --git a/packages/web/src/components/Head.astro b/packages/web/src/components/Head.astro index f6166f58..9ebf734c 100644 --- a/packages/web/src/components/Head.astro +++ b/packages/web/src/components/Head.astro @@ -13,7 +13,7 @@ const { const isDocs = slug.startsWith("docs") let encodedTitle = ''; -let ogImage = `${config.url}/social-share.png`; +let ogImage = `https://${config.domain}/social-share.png`; let truncatedDesc = ''; if (isDocs) { From da909d9684ca7eec64858b9f394fa41e36f947fc Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 7 Jul 2025 16:32:48 -0400 Subject: [PATCH 32/37] append piped stdin to prompt --- packages/opencode/src/cli/cmd/run.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index be271ceb..0c232634 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -56,11 +56,7 @@ export const RunCommand = cmd({ handler: async (args) => { let message = args.message.join(" ") - // Read from stdin if no message provided and stdin is available - if (!message && !process.stdin.isTTY) { - message = await Bun.stdin.text() - message = message.trim() - } + if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) await bootstrap({ cwd: process.cwd() }, async () => { const session = await (async () => { From 0f93ecd564c87cefba40b779c9f35d0930719b67 Mon Sep 17 00:00:00 2001 From: Jay V Date: Mon, 7 Jul 2025 16:36:52 -0400 Subject: [PATCH 33/37] docs: canonical url --- packages/web/astro.config.mjs | 5 +---- packages/web/config.mjs | 6 +++++- packages/web/src/components/Head.astro | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs index 538784ac..63b93b9d 100644 --- a/packages/web/astro.config.mjs +++ b/packages/web/astro.config.mjs @@ -9,13 +9,10 @@ import { rehypeHeadingIds } from "@astrojs/markdown-remark" import rehypeAutolinkHeadings from "rehype-autolink-headings" const github = "https://github.com/sst/opencode" -const stage = process.env.SST_STAGE || "dev" // https://astro.build/config export default defineConfig({ - site: stage === "production" - ? `https://${config.domain}` - : `https://${stage}.${config.domain}`, + site: config.url, output: "server", adapter: cloudflare({ imageService: "passthrough", diff --git a/packages/web/config.mjs b/packages/web/config.mjs index f0ae3cb6..5e4c571d 100644 --- a/packages/web/config.mjs +++ b/packages/web/config.mjs @@ -1,5 +1,9 @@ +const stage = process.env.SST_STAGE || "dev" + export default { - domain: "opencode.ai", + url: stage === "production" + ? "https://opencode.ai" + : `https://${stage}.opencode.ai`, socialCard: "https://social-cards.sst.dev", github: "https://github.com/sst/opencode", discord: "https://discord.gg/opencode", diff --git a/packages/web/src/components/Head.astro b/packages/web/src/components/Head.astro index 9ebf734c..f6166f58 100644 --- a/packages/web/src/components/Head.astro +++ b/packages/web/src/components/Head.astro @@ -13,7 +13,7 @@ const { const isDocs = slug.startsWith("docs") let encodedTitle = ''; -let ogImage = `https://${config.domain}/social-share.png`; +let ogImage = `${config.url}/social-share.png`; let truncatedDesc = ''; if (isDocs) { From 27f7e02f12a1f0291d141686ecdedb72127a6523 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 7 Jul 2025 16:41:28 -0400 Subject: [PATCH 34/37] run: truncate prompt --- packages/opencode/src/cli/cmd/run.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 0c232634..453b273d 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -81,7 +81,8 @@ export const RunCommand = cmd({ UI.empty() UI.println(UI.logo()) UI.empty() - UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message) + const displayMessage = message.length > 300 ? message.slice(0, 300) + "..." : message + UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", displayMessage) UI.empty() const cfg = await Config.get() From 0d50c867ff16686d47101fa6d29e07539fe40d8f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 7 Jul 2025 17:05:16 -0400 Subject: [PATCH 35/37] fix mcp tools corrupting session --- opencode.json | 6 ++++++ packages/opencode/src/session/index.ts | 22 ++++++++++++++-------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/opencode.json b/opencode.json index 57b94008..ff206980 100644 --- a/opencode.json +++ b/opencode.json @@ -1,5 +1,11 @@ { "$schema": "https://opencode.ai/config.json", + "mcp": { + "weather": { + "type": "local", + "command": ["opencode", "x", "@h1deya/mcp-server-weather"] + } + }, "experimental": { "hook": { "file_edited": { diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 4c50d51d..4e24fa51 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -496,14 +496,20 @@ export namespace Session { const execute = item.execute if (!execute) continue item.execute = async (args, opts) => { - try { - const result = await execute(args, opts) - return result.content - .filter((x: any) => x.type === "text") - .map((x: any) => x.text) - .join("\n\n") - } catch (e: any) { - return e.toString() + const result = await execute(args, opts) + const output = result.content + .filter((x: any) => x.type === "text") + .map((x: any) => x.text) + .join("\n\n") + + return { + output, + } + } + item.toModelOutput = (result) => { + return { + type: "text", + value: result.output, } } tools[key] = item From 9948fcf1b6e6cea328085bdf3ad96ab05a139f52 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 7 Jul 2025 17:39:52 -0400 Subject: [PATCH 36/37] fix crash when running on new project --- packages/opencode/src/storage/storage.ts | 30 +++++++++++++----------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index ccafb34d..7093fb25 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -17,21 +17,23 @@ export namespace Storage { const MIGRATIONS: Migration[] = [ async (dir: string) => { - const files = new Bun.Glob("session/message/*/*.json").scanSync({ - cwd: dir, - absolute: true, - }) - for (const file of files) { - const content = await Bun.file(file).json() - if (!content.metadata) continue - log.info("migrating to v2 message", { file }) - try { - const result = MessageV2.fromV1(content) - await Bun.write(file, JSON.stringify(result, null, 2)) - } catch (e) { - await fs.rename(file, file.replace("storage", "broken")) + try { + const files = new Bun.Glob("session/message/*/*.json").scanSync({ + cwd: dir, + absolute: true, + }) + for (const file of files) { + const content = await Bun.file(file).json() + if (!content.metadata) continue + log.info("migrating to v2 message", { file }) + try { + const result = MessageV2.fromV1(content) + await Bun.write(file, JSON.stringify(result, null, 2)) + } catch (e) { + await fs.rename(file, file.replace("storage", "broken")) + } } - } + } catch {} }, ] From a272b58fe988addc5c0d18bbaba2b09fac1d9fef Mon Sep 17 00:00:00 2001 From: Jay V Date: Mon, 7 Jul 2025 17:41:44 -0400 Subject: [PATCH 37/37] docs: intro --- packages/web/src/content/docs/docs/index.mdx | 24 ++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/web/src/content/docs/docs/index.mdx b/packages/web/src/content/docs/docs/index.mdx index 9ea95844..0484e9b3 100644 --- a/packages/web/src/content/docs/docs/index.mdx +++ b/packages/web/src/content/docs/docs/index.mdx @@ -21,10 +21,26 @@ import { Tabs, TabItem } from "@astrojs/starlight/components" ## Install - ```bash npm install -g opencode-ai ``` - ```bash bun install -g opencode-ai ``` - ```bash pnpm install -g opencode-ai ``` - ```bash yarn global add opencode-ai ``` + + ```bash + npm install -g opencode-ai + ``` + + + ```bash + bun install -g opencode-ai + ``` + + + ```bash + pnpm install -g opencode-ai + ``` + + + ```bash + yarn global add opencode-ai + ``` + You can also install the opencode binary through the following.