diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 39ae5b59..a975d34b 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -32,7 +32,7 @@ export namespace Ripgrep { }), }) - const Match = z.object({ + export const Match = z.object({ type: z.literal("match"), data: z.object({ path: z.object({ diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index d636e5c6..df645cd8 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -14,6 +14,8 @@ import { NamedError } from "../util/error" import { ModelsDev } from "../provider/models" import { Ripgrep } from "../file/ripgrep" import { Config } from "../config/config" +import { File } from "../file" +import { LSP } from "../lsp" const ERRORS = { 400: { @@ -73,7 +75,7 @@ export namespace Server { documentation: { info: { title: "opencode", - version: "0.0.2", + version: "0.0.3", description: "opencode api", }, openapi: "3.0.0", @@ -492,12 +494,44 @@ export namespace Server { }, ) .get( - "/file", + "/find", describeRoute({ - description: "Search for files", + description: "Find text in files", responses: { 200: { - description: "Search for files", + description: "Matches", + content: { + "application/json": { + schema: resolver(Ripgrep.Match.shape.data.array()), + }, + }, + }, + }, + }), + zValidator( + "query", + z.object({ + pattern: z.string(), + }), + ), + async (c) => { + const app = App.info() + const pattern = c.req.valid("query").pattern + const result = await Ripgrep.search({ + cwd: app.path.cwd, + pattern, + limit: 10, + }) + return c.json(result) + }, + ) + .get( + "/find/file", + describeRoute({ + description: "Find files", + responses: { + 200: { + description: "File paths", content: { "application/json": { schema: resolver(z.string().array()), @@ -523,6 +557,98 @@ export namespace Server { return c.json(result) }, ) + .get( + "/find/symbol", + describeRoute({ + description: "Find workspace symbols", + responses: { + 200: { + description: "Symbols", + content: { + "application/json": { + schema: resolver(z.unknown().array()), + }, + }, + }, + }, + }), + zValidator( + "query", + z.object({ + query: z.string(), + }), + ), + async (c) => { + const query = c.req.valid("query").query + const result = await LSP.workspaceSymbol(query) + return c.json(result) + }, + ) + .get( + "/file", + describeRoute({ + description: "Read a file", + responses: { + 200: { + description: "File content", + content: { + "application/json": { + schema: resolver( + z.object({ + type: z.enum(["raw", "patch"]), + content: z.string(), + }), + ), + }, + }, + }, + }, + }), + zValidator( + "query", + z.object({ + path: z.string(), + }), + ), + async (c) => { + const path = c.req.valid("query").path + const content = await File.read(path) + log.info("read file", { + path, + content: content.content, + }) + return c.json(content) + }, + ) + .get( + "/file/status", + describeRoute({ + description: "Get file status", + responses: { + 200: { + description: "File status", + content: { + "application/json": { + schema: resolver( + z + .object({ + file: z.string(), + added: z.number().int(), + removed: z.number().int(), + status: z.enum(["added", "deleted", "modified"]), + }) + .array(), + ), + }, + }, + }, + }, + }), + async (c) => { + const content = await File.status() + return c.json(content) + }, + ) return result } diff --git a/packages/tui/go.mod b/packages/tui/go.mod index 0ea1f9da..6cd1bae6 100644 --- a/packages/tui/go.mod +++ b/packages/tui/go.mod @@ -15,7 +15,7 @@ require ( github.com/muesli/reflow v0.3.0 github.com/muesli/termenv v0.16.0 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 - github.com/sst/opencode-sdk-go v0.1.0-alpha.7 + github.com/sst/opencode-sdk-go v0.1.0-alpha.8 github.com/tidwall/gjson v1.14.4 rsc.io/qr v0.2.0 ) diff --git a/packages/tui/go.sum b/packages/tui/go.sum index 159f2b20..ac6981f2 100644 --- a/packages/tui/go.sum +++ b/packages/tui/go.sum @@ -181,8 +181,8 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/sst/opencode-sdk-go v0.1.0-alpha.7 h1:trfzTMn9o/h2fxE4z+BtJPZvCTdVHjwgXnAH/rTAx0I= -github.com/sst/opencode-sdk-go v0.1.0-alpha.7/go.mod h1:uagorfAHZsVy6vf0xY6TlQraM4uCILdZ5tKKhl1oToM= +github.com/sst/opencode-sdk-go v0.1.0-alpha.8 h1:Tp7nbckbMCwAA/ieVZeeZCp79xXtrPMaWLRk5mhNwrw= +github.com/sst/opencode-sdk-go v0.1.0-alpha.8/go.mod h1:uagorfAHZsVy6vf0xY6TlQraM4uCILdZ5tKKhl1oToM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 2369b196..9b341c19 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -20,9 +20,6 @@ import ( "github.com/sst/opencode/internal/util" ) -var RootPath string -var CwdPath string - type App struct { Info opencode.App Version string @@ -38,6 +35,7 @@ type App struct { } type SessionSelectedMsg = *opencode.Session +type SessionLoadedMsg struct{} type ModelSelectedMsg struct { Provider opencode.Provider Model opencode.Model @@ -54,6 +52,9 @@ type CompletionDialogTriggeredMsg struct { type OptimisticMessageAddedMsg struct { Message opencode.Message } +type FileRenderedMsg struct { + FilePath string +} func New( ctx context.Context, @@ -61,8 +62,8 @@ func New( appInfo opencode.App, httpClient *opencode.Client, ) (*App, error) { - RootPath = appInfo.Path.Root - CwdPath = appInfo.Path.Cwd + util.RootPath = appInfo.Path.Root + util.CwdPath = appInfo.Path.Cwd configInfo, err := httpClient.Config.Get(ctx) if err != nil { @@ -125,6 +126,19 @@ func New( return app, nil } +func (a *App) Key(commandName commands.CommandName) string { + t := theme.CurrentTheme() + base := styles.NewStyle().Background(t.Background()).Foreground(t.Text()).Bold(true).Render + muted := styles.NewStyle().Background(t.Background()).Foreground(t.TextMuted()).Faint(true).Render + command := a.Commands[commandName] + kb := command.Keybindings[0] + key := kb.Key + if kb.RequiresLeader { + key = a.Config.Keybinds.Leader + " " + kb.Key + } + return base(key) + muted(" "+command.Description) +} + func (a *App) InitializeProvider() tea.Cmd { return func() tea.Msg { providersResponse, err := a.Client.Config.Providers(context.Background()) diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go index 4ef45883..f9b447b5 100644 --- a/packages/tui/internal/commands/command.go +++ b/packages/tui/internal/commands/command.go @@ -80,13 +80,15 @@ const ( ToolDetailsCommand CommandName = "tool_details" ModelListCommand CommandName = "model_list" ThemeListCommand CommandName = "theme_list" + FileListCommand CommandName = "file_list" + FileCloseCommand CommandName = "file_close" + FileSearchCommand CommandName = "file_search" + FileDiffToggleCommand CommandName = "file_diff_toggle" ProjectInitCommand CommandName = "project_init" InputClearCommand CommandName = "input_clear" InputPasteCommand CommandName = "input_paste" InputSubmitCommand CommandName = "input_submit" InputNewlineCommand CommandName = "input_newline" - HistoryPreviousCommand CommandName = "history_previous" - HistoryNextCommand CommandName = "history_next" MessagesPageUpCommand CommandName = "messages_page_up" MessagesPageDownCommand CommandName = "messages_page_down" MessagesHalfPageUpCommand CommandName = "messages_half_page_up" @@ -95,6 +97,9 @@ const ( MessagesNextCommand CommandName = "messages_next" MessagesFirstCommand CommandName = "messages_first" MessagesLastCommand CommandName = "messages_last" + MessagesLayoutToggleCommand CommandName = "messages_layout_toggle" + MessagesCopyCommand CommandName = "messages_copy" + MessagesRevertCommand CommandName = "messages_revert" AppExitCommand CommandName = "app_exit" ) @@ -184,6 +189,27 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry { Keybindings: parseBindings("t"), Trigger: "themes", }, + { + Name: FileListCommand, + Description: "list files", + Keybindings: parseBindings("f"), + Trigger: "files", + }, + { + Name: FileCloseCommand, + Description: "close file", + Keybindings: parseBindings("esc"), + }, + { + Name: FileSearchCommand, + Description: "search file", + Keybindings: parseBindings("/"), + }, + { + Name: FileDiffToggleCommand, + Description: "split/unified diff", + Keybindings: parseBindings("v"), + }, { Name: ProjectInitCommand, Description: "create/update AGENTS.md", @@ -210,16 +236,6 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry { Description: "insert newline", Keybindings: parseBindings("shift+enter", "ctrl+j"), }, - // { - // Name: HistoryPreviousCommand, - // Description: "previous prompt", - // Keybindings: parseBindings("up"), - // }, - // { - // Name: HistoryNextCommand, - // Description: "next prompt", - // Keybindings: parseBindings("down"), - // }, { Name: MessagesPageUpCommand, Description: "page up", @@ -243,12 +259,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry { { Name: MessagesPreviousCommand, Description: "previous message", - Keybindings: parseBindings("ctrl+alt+k"), + Keybindings: parseBindings("ctrl+up"), }, { Name: MessagesNextCommand, Description: "next message", - Keybindings: parseBindings("ctrl+alt+j"), + Keybindings: parseBindings("ctrl+down"), }, { Name: MessagesFirstCommand, @@ -260,6 +276,21 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry { Description: "last message", Keybindings: parseBindings("ctrl+alt+g"), }, + { + Name: MessagesLayoutToggleCommand, + Description: "toggle layout", + Keybindings: parseBindings("m"), + }, + { + Name: MessagesCopyCommand, + Description: "copy message", + Keybindings: parseBindings("y"), + }, + { + Name: MessagesRevertCommand, + Description: "revert message", + Keybindings: parseBindings("u"), + }, { Name: AppExitCommand, Description: "exit the app", diff --git a/packages/tui/internal/completions/commands.go b/packages/tui/internal/completions/commands.go index 21a26cbc..c73923e8 100644 --- a/packages/tui/internal/completions/commands.go +++ b/packages/tui/internal/completions/commands.go @@ -25,13 +25,6 @@ func (c *CommandCompletionProvider) GetId() string { return "commands" } -func (c *CommandCompletionProvider) GetEntry() dialog.CompletionItemI { - return dialog.NewCompletionItem(dialog.CompletionItem{ - Title: "Commands", - Value: "commands", - }) -} - func (c *CommandCompletionProvider) GetEmptyMessage() string { return "no matching commands" } diff --git a/packages/tui/internal/completions/files-folders.go b/packages/tui/internal/completions/files-folders.go index 6fb4316f..cb7a7453 100644 --- a/packages/tui/internal/completions/files-folders.go +++ b/packages/tui/internal/completions/files-folders.go @@ -2,64 +2,108 @@ package completions import ( "context" + "log/slog" + "sort" + "strconv" + "strings" "github.com/sst/opencode-sdk-go" "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/components/dialog" + "github.com/sst/opencode/internal/styles" + "github.com/sst/opencode/internal/theme" ) type filesAndFoldersContextGroup struct { - app *app.App - prefix string + app *app.App + prefix string + gitFiles []dialog.CompletionItemI } func (cg *filesAndFoldersContextGroup) GetId() string { return cg.prefix } -func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI { - return dialog.NewCompletionItem(dialog.CompletionItem{ - Title: "Files & Folders", - Value: "files", - }) -} - func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string { return "no matching files" } -func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) { - files, err := cg.app.Client.File.Search( - context.Background(), - opencode.FileSearchParams{Query: opencode.F(query)}, - ) - if err != nil { - return []string{}, err +func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI { + t := theme.CurrentTheme() + items := make([]dialog.CompletionItemI, 0) + base := styles.NewStyle().Background(t.BackgroundElement()) + green := base.Foreground(t.Success()).Render + red := base.Foreground(t.Error()).Render + + status, _ := cg.app.Client.File.Status(context.Background()) + if status != nil { + files := *status + sort.Slice(files, func(i, j int) bool { + return files[i].Added+files[i].Removed > files[j].Added+files[j].Removed + }) + + for _, file := range files { + title := file.File + if file.Added > 0 { + title += green(" +" + strconv.Itoa(int(file.Added))) + } + if file.Removed > 0 { + title += red(" -" + strconv.Itoa(int(file.Removed))) + } + item := dialog.NewCompletionItem(dialog.CompletionItem{ + Title: title, + Value: file.File, + }) + items = append(items, item) + } } - return *files, nil + + return items } func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) { - matches, err := cg.getFiles(query) - if err != nil { - return nil, err + items := make([]dialog.CompletionItemI, 0) + + query = strings.TrimSpace(query) + if query == "" { + items = append(items, cg.gitFiles...) } - items := make([]dialog.CompletionItemI, 0, len(matches)) - for _, file := range matches { - item := dialog.NewCompletionItem(dialog.CompletionItem{ - Title: file, - Value: file, - }) - items = append(items, item) + files, err := cg.app.Client.Find.Files( + context.Background(), + opencode.FindFilesParams{Query: opencode.F(query)}, + ) + if err != nil { + slog.Error("Failed to get completion items", "error", err) + } + + for _, file := range *files { + exists := false + for _, existing := range cg.gitFiles { + if existing.GetValue() == file { + if query != "" { + items = append(items, existing) + } + exists = true + } + } + if !exists { + item := dialog.NewCompletionItem(dialog.CompletionItem{ + Title: file, + Value: file, + }) + items = append(items, item) + } } return items, nil } func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider { - return &filesAndFoldersContextGroup{ + cg := &filesAndFoldersContextGroup{ app: app, prefix: "file", } + cg.gitFiles = cg.getGitFiles() + return cg } diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index b4abd0f8..669ef47d 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -13,7 +13,6 @@ import ( "github.com/sst/opencode/internal/components/dialog" "github.com/sst/opencode/internal/components/textarea" "github.com/sst/opencode/internal/image" - "github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/util" @@ -21,10 +20,8 @@ import ( type EditorComponent interface { tea.Model - // tea.ViewModel - SetSize(width, height int) tea.Cmd - View(width int, align lipgloss.Position) string - Content(width int, align lipgloss.Position) string + View(width int) string + Content(width int) string Lines() int Value() string Focused() bool @@ -34,19 +31,13 @@ type EditorComponent interface { Clear() (tea.Model, tea.Cmd) Paste() (tea.Model, tea.Cmd) Newline() (tea.Model, tea.Cmd) - Previous() (tea.Model, tea.Cmd) - Next() (tea.Model, tea.Cmd) SetInterruptKeyInDebounce(inDebounce bool) } type editorComponent struct { app *app.App - width, height int textarea textarea.Model attachments []app.Attachment - history []string - historyIndex int - currentMessage string spinner spinner.Model interruptKeyInDebounce bool } @@ -106,7 +97,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m *editorComponent) Content(width int, align lipgloss.Position) string { +func (m *editorComponent) Content(width int) string { t := theme.CurrentTheme() base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render @@ -115,6 +106,7 @@ func (m *editorComponent) Content(width int, align lipgloss.Position) string { Bold(true) prompt := promptStyle.Render(">") + m.textarea.SetWidth(width - 6) textarea := lipgloss.JoinHorizontal( lipgloss.Top, prompt, @@ -147,7 +139,7 @@ func (m *editorComponent) Content(width int, align lipgloss.Position) string { model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name) } - space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint) + space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint) spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("") info := hint + spacer + model @@ -157,19 +149,18 @@ func (m *editorComponent) Content(width int, align lipgloss.Position) string { return content } -func (m *editorComponent) View(width int, align lipgloss.Position) string { +func (m *editorComponent) View(width int) string { if m.Lines() > 1 { - t := theme.CurrentTheme() return lipgloss.Place( width, - m.height, - align, + 5, + lipgloss.Center, lipgloss.Center, "", - styles.WhitespaceStyle(t.Background()), + styles.WhitespaceStyle(theme.CurrentTheme().Background()), ) } - return m.Content(width, align) + return m.Content(width) } func (m *editorComponent) Focused() bool { @@ -184,16 +175,6 @@ func (m *editorComponent) Blur() { m.textarea.Blur() } -func (m *editorComponent) GetSize() (width, height int) { - return m.width, m.height -} - -func (m *editorComponent) SetSize(width, height int) tea.Cmd { - m.width = width - m.height = height - return nil -} - func (m *editorComponent) Lines() int { return m.textarea.LineCount() } @@ -219,16 +200,6 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) attachments := m.attachments - - // Save to history if not empty and not a duplicate of the last entry - if value != "" { - if len(m.history) == 0 || m.history[len(m.history)-1] != value { - m.history = append(m.history, value) - } - m.historyIndex = len(m.history) - m.currentMessage = "" - } - m.attachments = nil cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments})) @@ -261,48 +232,6 @@ func (m *editorComponent) Newline() (tea.Model, tea.Cmd) { return m, nil } -func (m *editorComponent) Previous() (tea.Model, tea.Cmd) { - currentLine := m.textarea.Line() - - // Only navigate history if we're at the first line - if currentLine == 0 && len(m.history) > 0 { - // Save current message if we're just starting to navigate - if m.historyIndex == len(m.history) { - m.currentMessage = m.textarea.Value() - } - - // Go to previous message in history - if m.historyIndex > 0 { - m.historyIndex-- - m.textarea.SetValue(m.history[m.historyIndex]) - } - return m, nil - } - return m, nil -} - -func (m *editorComponent) Next() (tea.Model, tea.Cmd) { - currentLine := m.textarea.Line() - value := m.textarea.Value() - lines := strings.Split(value, "\n") - totalLines := len(lines) - - // Only navigate history if we're at the last line - if currentLine == totalLines-1 { - if m.historyIndex < len(m.history)-1 { - // Go to next message in history - m.historyIndex++ - m.textarea.SetValue(m.history[m.historyIndex]) - } else if m.historyIndex == len(m.history)-1 { - // Return to the current message being composed - m.historyIndex = len(m.history) - m.textarea.SetValue(m.currentMessage) - } - return m, nil - } - return m, nil -} - func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) { m.interruptKeyInDebounce = inDebounce } @@ -336,7 +265,6 @@ func createTextArea(existing *textarea.Model) textarea.Model { ta.Prompt = " " ta.ShowLineNumbers = false ta.CharLimit = -1 - ta.SetWidth(layout.Current.Container.Width - 6) if existing != nil { ta.SetValue(existing.Value()) @@ -368,9 +296,6 @@ func NewEditorComponent(app *app.App) EditorComponent { return &editorComponent{ app: app, textarea: ta, - history: []string{}, - historyIndex: 0, - currentMessage: "", spinner: s, interruptKeyInDebounce: false, } diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go index 8e4cbc1a..4ef73856 100644 --- a/packages/tui/internal/components/chat/message.go +++ b/packages/tui/internal/components/chat/message.go @@ -3,65 +3,46 @@ package chat import ( "encoding/json" "fmt" - "path/filepath" "slices" "strings" "time" - "unicode" "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/lipgloss/v2/compat" - "github.com/charmbracelet/x/ansi" "github.com/sst/opencode-sdk-go" "github.com/sst/opencode/internal/app" + "github.com/sst/opencode/internal/commands" "github.com/sst/opencode/internal/components/diff" "github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/theme" + "github.com/sst/opencode/internal/util" "github.com/tidwall/gjson" "golang.org/x/text/cases" "golang.org/x/text/language" ) -func toMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string { - r := styles.GetMarkdownRenderer(width-7, backgroundColor) - content = strings.ReplaceAll(content, app.RootPath+"/", "") - rendered, _ := r.Render(content) - lines := strings.Split(rendered, "\n") - - if len(lines) > 0 { - firstLine := lines[0] - cleaned := ansi.Strip(firstLine) - nospace := strings.ReplaceAll(cleaned, " ", "") - if nospace == "" { - lines = lines[1:] - } - if len(lines) > 0 { - lastLine := lines[len(lines)-1] - cleaned = ansi.Strip(lastLine) - nospace = strings.ReplaceAll(cleaned, " ", "") - if nospace == "" { - lines = lines[:len(lines)-1] - } - } - } - content = strings.Join(lines, "\n") - return strings.TrimSuffix(content, "\n") -} - type blockRenderer struct { - border bool - borderColor *compat.AdaptiveColor - paddingTop int - paddingBottom int - paddingLeft int - paddingRight int - marginTop int - marginBottom int + textColor compat.AdaptiveColor + border bool + borderColor *compat.AdaptiveColor + borderColorRight bool + paddingTop int + paddingBottom int + paddingLeft int + paddingRight int + marginTop int + marginBottom int } type renderingOption func(*blockRenderer) +func WithTextColor(color compat.AdaptiveColor) renderingOption { + return func(c *blockRenderer) { + c.textColor = color + } +} + func WithNoBorder() renderingOption { return func(c *blockRenderer) { c.border = false @@ -74,6 +55,13 @@ func WithBorderColor(color compat.AdaptiveColor) renderingOption { } } +func WithBorderColorRight(color compat.AdaptiveColor) renderingOption { + return func(c *blockRenderer) { + c.borderColorRight = true + c.borderColor = &color + } +} + func WithMarginTop(padding int) renderingOption { return func(c *blockRenderer) { c.marginTop = padding @@ -120,13 +108,15 @@ func WithPaddingBottom(padding int) renderingOption { } func renderContentBlock( + app *app.App, content string, + highlight bool, width int, - align lipgloss.Position, options ...renderingOption, ) string { t := theme.CurrentTheme() renderer := &blockRenderer{ + textColor: t.TextMuted(), border: true, paddingTop: 1, paddingBottom: 1, @@ -143,7 +133,7 @@ func renderContentBlock( } style := styles.NewStyle(). - Foreground(t.TextMuted()). + Foreground(renderer.textColor). Background(t.BackgroundPanel()). Width(width). PaddingTop(renderer.paddingTop). @@ -161,21 +151,32 @@ func renderContentBlock( BorderLeftBackground(t.Background()). BorderRightForeground(t.BackgroundPanel()). BorderRightBackground(t.Background()) + + if renderer.borderColorRight { + style = style. + BorderLeftBackground(t.Background()). + BorderLeftForeground(t.BackgroundPanel()). + BorderRightForeground(borderColor). + BorderRightBackground(t.Background()) + } + + if highlight { + style = style. + BorderLeftBackground(t.Primary()). + BorderLeftForeground(t.Primary()). + BorderRightForeground(t.Primary()). + BorderRightBackground(t.Primary()) + } + } + + if highlight { + style = style. + Foreground(t.Text()). + Bold(true). + Background(t.BackgroundElement()) } content = style.Render(content) - content = lipgloss.PlaceHorizontal( - width, - lipgloss.Left, - content, - styles.WhitespaceStyle(t.Background()), - ) - content = lipgloss.PlaceHorizontal( - layout.Current.Viewport.Width, - align, - content, - styles.WhitespaceStyle(t.Background()), - ) if renderer.marginTop > 0 { for range renderer.marginTop { content = "\n" + content @@ -186,16 +187,44 @@ func renderContentBlock( content = content + "\n" } } + + if highlight { + copy := app.Key(commands.MessagesCopyCommand) + // revert := app.Key(commands.MessagesRevertCommand) + + background := t.Background() + header := layout.Render( + layout.FlexOptions{ + Background: &background, + Direction: layout.Row, + Justify: layout.JustifyCenter, + Align: layout.AlignStretch, + Width: width - 2, + Gap: 5, + }, + layout.FlexItem{ + View: copy, + }, + // layout.FlexItem{ + // View: revert, + // }, + ) + header = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(header) + + content = "\n\n\n" + header + "\n\n" + content + "\n\n" + } + return content } func renderText( + app *app.App, message opencode.Message, text string, author string, showToolDetails bool, + highlight bool, width int, - align lipgloss.Position, toolCalls ...opencode.ToolInvocationPart, ) string { t := theme.CurrentTheme() @@ -206,17 +235,20 @@ func renderText( timestamp = timestamp[12:] } info := fmt.Sprintf("%s (%s)", author, timestamp) + info = styles.NewStyle().Foreground(t.TextMuted()).Render(info) - messageStyle := styles.NewStyle(). - Background(t.BackgroundPanel()). - Foreground(t.Text()) + backgroundColor := t.BackgroundPanel() + if highlight { + backgroundColor = t.BackgroundElement() + } + messageStyle := styles.NewStyle().Background(backgroundColor) if message.Role == opencode.MessageRoleUser { messageStyle = messageStyle.Width(width - 6) } content := messageStyle.Render(text) if message.Role == opencode.MessageRoleAssistant { - content = toMarkdown(text, width, t.BackgroundPanel()) + content = util.ToMarkdown(text, width, backgroundColor) } if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 { @@ -242,16 +274,19 @@ func renderText( switch message.Role { case opencode.MessageRoleUser: return renderContentBlock( + app, content, + highlight, width, - align, - WithBorderColor(t.Secondary()), + WithTextColor(t.Text()), + WithBorderColorRight(t.Secondary()), ) case opencode.MessageRoleAssistant: return renderContentBlock( + app, content, + highlight, width, - align, WithBorderColor(t.Accent()), ) } @@ -259,10 +294,11 @@ func renderText( } func renderToolDetails( + app *app.App, toolCall opencode.ToolInvocationPart, messageMetadata opencode.MessageMetadata, + highlight bool, width int, - align lipgloss.Position, ) string { ignoredTools := []string{"todoread"} if slices.Contains(ignoredTools, toolCall.ToolInvocation.ToolName) { @@ -282,7 +318,7 @@ func renderToolDetails( if toolCall.ToolInvocation.State == "partial-call" { title := renderToolTitle(toolCall, messageMetadata, width) - return renderContentBlock(title, width, align) + return renderContentBlock(app, title, highlight, width) } toolArgsMap := make(map[string]any) @@ -301,6 +337,10 @@ func renderToolDetails( body := "" finished := result != nil && *result != "" t := theme.CurrentTheme() + backgroundColor := t.BackgroundPanel() + if highlight { + backgroundColor = t.BackgroundElement() + } switch toolCall.ToolInvocation.ToolName { case "read": @@ -308,7 +348,7 @@ func renderToolDetails( if preview != nil && toolArgsMap["filePath"] != nil { filename := toolArgsMap["filePath"].(string) body = preview.(string) - body = renderFile(filename, body, width, WithTruncate(6)) + body = util.RenderFile(filename, body, width, util.WithTruncate(6)) } case "edit": if filename, ok := toolArgsMap["filePath"].(string); ok { @@ -321,38 +361,28 @@ func renderToolDetails( patch, diff.WithWidth(width-2), ) - formattedDiff = strings.TrimSpace(formattedDiff) - formattedDiff = styles.NewStyle(). - BorderStyle(lipgloss.ThickBorder()). - BorderBackground(t.Background()). - BorderForeground(t.BackgroundPanel()). - BorderLeft(true). - BorderRight(true). - Render(formattedDiff) - body = strings.TrimSpace(formattedDiff) - body = renderContentBlock( - body, - width, - align, - WithNoBorder(), - WithPadding(0), - ) + style := styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Padding(1, 2).Width(width - 4) + if highlight { + style = style.Foreground(t.Text()).Bold(true) + } if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" { - body += "\n" + renderContentBlock(diagnostics, width, align) + diagnostics = style.Render(diagnostics) + body += "\n" + diagnostics } title := renderToolTitle(toolCall, messageMetadata, width) - title = renderContentBlock(title, width, align) + title = style.Render(title) content := title + "\n" + body + content = renderContentBlock(app, content, highlight, width, WithPadding(0)) return content } } case "write": if filename, ok := toolArgsMap["filePath"].(string); ok { if content, ok := toolArgsMap["content"].(string); ok { - body = renderFile(filename, content, width) + body = util.RenderFile(filename, content, width) if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" { body += "\n\n" + diagnostics } @@ -363,14 +393,14 @@ func renderToolDetails( if stdout != nil { command := toolArgsMap["command"].(string) body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout) - body = toMarkdown(body, width, t.BackgroundPanel()) + body = util.ToMarkdown(body, width, backgroundColor) } case "webfetch": if format, ok := toolArgsMap["format"].(string); ok && result != nil { body = *result - body = truncateHeight(body, 10) + body = util.TruncateHeight(body, 10) if format == "html" || format == "markdown" { - body = toMarkdown(body, width, t.BackgroundPanel()) + body = util.ToMarkdown(body, width, backgroundColor) } } case "todowrite": @@ -389,7 +419,7 @@ func renderToolDetails( body += fmt.Sprintf("- [ ] %s\n", content) } } - body = toMarkdown(body, width, t.BackgroundPanel()) + body = util.ToMarkdown(body, width, backgroundColor) } case "task": summary := metadata.JSON.ExtraFields["summary"] @@ -424,7 +454,7 @@ func renderToolDetails( result = &empty } body = *result - body = truncateHeight(body, 10) + body = util.TruncateHeight(body, 10) } error := "" @@ -437,18 +467,18 @@ func renderToolDetails( if error != "" { body = styles.NewStyle(). Foreground(t.Error()). - Background(t.BackgroundPanel()). + Background(backgroundColor). Render(error) } if body == "" && error == "" && result != nil { body = *result - body = truncateHeight(body, 10) + body = util.TruncateHeight(body, 10) } title := renderToolTitle(toolCall, messageMetadata, width) content := title + "\n\n" + body - return renderContentBlock(content, width, align) + return renderContentBlock(app, content, highlight, width) } func renderToolName(name string) string { @@ -505,7 +535,7 @@ func renderToolTitle( title = fmt.Sprintf("%s %s", title, toolArgs) case "edit", "write": if filename, ok := toolArgsMap["filePath"].(string); ok { - title = fmt.Sprintf("%s %s", title, relative(filename)) + title = fmt.Sprintf("%s %s", title, util.Relative(filename)) } case "bash", "task": if description, ok := toolArgsMap["description"].(string); ok { @@ -551,50 +581,6 @@ func renderToolAction(name string) string { return "Working..." } -type fileRenderer struct { - filename string - content string - height int -} - -type fileRenderingOption func(*fileRenderer) - -func WithTruncate(height int) fileRenderingOption { - return func(c *fileRenderer) { - c.height = height - } -} - -func renderFile( - filename string, - content string, - width int, - options ...fileRenderingOption) string { - t := theme.CurrentTheme() - renderer := &fileRenderer{ - filename: filename, - content: content, - } - for _, option := range options { - option(renderer) - } - - lines := []string{} - for line := range strings.SplitSeq(content, "\n") { - line = strings.TrimRightFunc(line, unicode.IsSpace) - line = strings.ReplaceAll(line, "\t", " ") - lines = append(lines, line) - } - content = strings.Join(lines, "\n") - - if renderer.height > 0 { - content = truncateHeight(content, renderer.height) - } - content = fmt.Sprintf("```%s\n%s\n```", extension(renderer.filename), content) - content = toMarkdown(content, width, t.BackgroundPanel()) - return content -} - func renderArgs(args *map[string]any, titleKey string) string { if args == nil || len(*args) == 0 { return "" @@ -614,7 +600,7 @@ func renderArgs(args *map[string]any, titleKey string) string { continue } if key == "filePath" || key == "path" { - value = relative(value.(string)) + value = util.Relative(value.(string)) } if key == titleKey { title = fmt.Sprintf("%s", value) @@ -628,29 +614,6 @@ func renderArgs(args *map[string]any, titleKey string) string { return fmt.Sprintf("%s (%s)", title, strings.Join(parts, ", ")) } -func truncateHeight(content string, height int) string { - lines := strings.Split(content, "\n") - if len(lines) > height { - return strings.Join(lines[:height], "\n") - } - return content -} - -func relative(path string) string { - path = strings.TrimPrefix(path, app.CwdPath+"/") - return strings.TrimPrefix(path, app.RootPath+"/") -} - -func extension(path string) string { - ext := filepath.Ext(path) - if ext == "" { - ext = "" - } else { - ext = strings.ToLower(ext[1:]) - } - return ext -} - // Diagnostic represents an LSP diagnostic type Diagnostic struct { Range struct { diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index fbe05d70..a0105ec4 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -9,7 +9,6 @@ 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" @@ -17,73 +16,99 @@ import ( type MessagesComponent interface { tea.Model - tea.ViewModel - // View(width int) string - SetSize(width, height int) tea.Cmd + View(width, height int) string + SetWidth(width int) tea.Cmd PageUp() (tea.Model, tea.Cmd) PageDown() (tea.Model, tea.Cmd) HalfPageUp() (tea.Model, tea.Cmd) HalfPageDown() (tea.Model, tea.Cmd) First() (tea.Model, tea.Cmd) Last() (tea.Model, tea.Cmd) - // Previous() (tea.Model, tea.Cmd) - // Next() (tea.Model, tea.Cmd) + Previous() (tea.Model, tea.Cmd) + Next() (tea.Model, tea.Cmd) ToolDetailsVisible() bool + Selected() string } type messagesComponent struct { - width, height int + width int app *app.App viewport viewport.Model - attachments viewport.Model cache *MessageCache rendering bool showToolDetails bool tail bool + partCount int + lineCount int + selectedPart int + selectedText string } type renderFinishedMsg struct{} +type selectedMessagePartChangedMsg struct { + part int +} + type ToggleToolDetailsMsg struct{} func (m *messagesComponent) Init() tea.Cmd { return tea.Batch(m.viewport.Init()) } +func (m *messagesComponent) Selected() string { + return m.selectedText +} + func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd - switch msg.(type) { + switch msg := msg.(type) { case app.SendMsg: m.viewport.GotoBottom() m.tail = true + m.selectedPart = -1 return m, nil case app.OptimisticMessageAddedMsg: - m.renderView() + m.renderView(m.width) if m.tail { m.viewport.GotoBottom() } return m, nil case dialog.ThemeSelectedMsg: m.cache.Clear() + m.rendering = true return m, m.Reload() case ToggleToolDetailsMsg: m.showToolDetails = !m.showToolDetails + m.rendering = true return m, m.Reload() - case app.SessionSelectedMsg: + case app.SessionLoadedMsg: m.cache.Clear() m.tail = true + m.rendering = true return m, m.Reload() case app.SessionClearedMsg: m.cache.Clear() - cmd := m.Reload() - return m, cmd + m.rendering = true + return m, m.Reload() case renderFinishedMsg: m.rendering = false if m.tail { m.viewport.GotoBottom() } - case opencode.EventListResponseEventSessionUpdated, opencode.EventListResponseEventMessageUpdated: - m.renderView() - if m.tail { - m.viewport.GotoBottom() + case selectedMessagePartChangedMsg: + return m, m.Reload() + case opencode.EventListResponseEventSessionUpdated: + if msg.Properties.Info.ID == m.app.Session.ID { + m.renderView(m.width) + if m.tail { + m.viewport.GotoBottom() + } + } + case opencode.EventListResponseEventMessageUpdated: + if msg.Properties.Info.Metadata.SessionID == m.app.Session.ID { + m.renderView(m.width) + if m.tail { + m.viewport.GotoBottom() + } } } @@ -95,45 +120,46 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m *messagesComponent) renderView() { - if m.width == 0 { - return - } - +func (m *messagesComponent) renderView(width int) { measure := util.Measure("messages.renderView") defer measure("messageCount", len(m.app.Messages)) t := theme.CurrentTheme() + blocks := make([]string, 0) + m.partCount = 0 + m.lineCount = 0 - align := lipgloss.Center - width := layout.Current.Container.Width - - sb := strings.Builder{} - util.MapReducePar(m.app.Messages, &sb, func(message opencode.Message) func(*strings.Builder) *strings.Builder { + for _, message := range m.app.Messages { var content string var cached bool - blocks := make([]string, 0) switch message.Role { case opencode.MessageRoleUser: for _, part := range message.Parts { switch part := part.AsUnion().(type) { case opencode.TextPart: - key := m.cache.GenerateKey(message.ID, part.Text, layout.Current.Viewport.Width) + key := m.cache.GenerateKey(message.ID, part.Text, width, m.selectedPart == m.partCount) content, cached = m.cache.Get(key) if !cached { content = renderText( + m.app, message, part.Text, m.app.Info.User, m.showToolDetails, + m.partCount == m.selectedPart, width, - align, ) m.cache.Set(key, content) } if content != "" { + if m.selectedPart == m.partCount { + m.viewport.SetYOffset(m.lineCount - 4) + m.selectedText = part.Text + } blocks = append(blocks, content) + m.partCount++ + m.lineCount += lipgloss.Height(content) + 1 } } } @@ -162,33 +188,41 @@ func (m *messagesComponent) renderView() { } if finished { - key := m.cache.GenerateKey(message.ID, p.Text, layout.Current.Viewport.Width, m.showToolDetails) + key := m.cache.GenerateKey(message.ID, p.Text, width, m.showToolDetails, m.selectedPart == m.partCount) content, cached = m.cache.Get(key) if !cached { content = renderText( + m.app, message, p.Text, message.Metadata.Assistant.ModelID, m.showToolDetails, + m.partCount == m.selectedPart, width, - align, toolCallParts..., ) m.cache.Set(key, content) } } else { content = renderText( + m.app, message, p.Text, message.Metadata.Assistant.ModelID, m.showToolDetails, + m.partCount == m.selectedPart, width, - align, toolCallParts..., ) } if content != "" { + if m.selectedPart == m.partCount { + m.viewport.SetYOffset(m.lineCount - 4) + m.selectedText = p.Text + } blocks = append(blocks, content) + m.partCount++ + m.lineCount += lipgloss.Height(content) + 1 } case opencode.ToolInvocationPart: if !m.showToolDetails { @@ -199,29 +233,38 @@ func (m *messagesComponent) renderView() { key := m.cache.GenerateKey(message.ID, part.ToolInvocation.ToolCallID, m.showToolDetails, - layout.Current.Viewport.Width, + width, + m.partCount == m.selectedPart, ) content, cached = m.cache.Get(key) if !cached { content = renderToolDetails( + m.app, part, message.Metadata, + m.partCount == m.selectedPart, width, - align, ) m.cache.Set(key, content) } } else { // if the tool call isn't finished, don't cache content = renderToolDetails( + m.app, part, message.Metadata, + m.partCount == m.selectedPart, width, - align, ) } if content != "" { + if m.selectedPart == m.partCount { + m.viewport.SetYOffset(m.lineCount - 4) + m.selectedText = "" + } blocks = append(blocks, content) + m.partCount++ + m.lineCount += lipgloss.Height(content) + 1 } } } @@ -240,41 +283,33 @@ func (m *messagesComponent) renderView() { if error != "" { error = renderContentBlock( + m.app, error, + false, width, - align, WithBorderColor(t.Error()), ) blocks = append(blocks, error) + m.lineCount += lipgloss.Height(error) + 1 } + } - str := strings.Join(blocks, "\n\n") - return func(sbdr *strings.Builder) *strings.Builder { - if sbdr.Len() > 0 && str != "" { - sbdr.WriteString("\n\n") - } - sbdr.WriteString(str) - return sbdr - } - }) - - content := sb.String() - - m.viewport.SetHeight(m.height - lipgloss.Height(m.header()) + 1) - m.viewport.SetContent("\n" + content) + m.viewport.SetContent("\n" + strings.Join(blocks, "\n\n")) + if m.selectedPart == m.partCount-1 { + m.viewport.GotoBottom() + } } -func (m *messagesComponent) header() string { +func (m *messagesComponent) header(width int) string { if m.app.Session.ID == "" { return "" } t := theme.CurrentTheme() - width := layout.Current.Container.Width base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render headerLines := []string{} - headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width-6, t.Background())) + headerLines = append(headerLines, util.ToMarkdown("# "+m.app.Session.Title, width-6, t.Background())) if m.app.Session.Share.URL != "" { headerLines = append(headerLines, muted(m.app.Session.Share.URL)) } else { @@ -297,31 +332,29 @@ func (m *messagesComponent) header() string { return "\n" + header + "\n" } -func (m *messagesComponent) View() string { +func (m *messagesComponent) View(width, height int) string { t := theme.CurrentTheme() if m.rendering { return lipgloss.Place( - m.width, - m.height+1, + width, + height, lipgloss.Center, lipgloss.Center, styles.NewStyle().Background(t.Background()).Render("Loading session..."), styles.WhitespaceStyle(t.Background()), ) } - header := lipgloss.PlaceHorizontal( - m.width, - lipgloss.Center, - m.header(), - styles.WhitespaceStyle(t.Background()), - ) + header := m.header(width) + m.viewport.SetWidth(width) + m.viewport.SetHeight(height - lipgloss.Height(header)) + return styles.NewStyle(). Background(t.Background()). Render(header + "\n" + m.viewport.View()) } -func (m *messagesComponent) SetSize(width, height int) tea.Cmd { - if m.width == width && m.height == height { +func (m *messagesComponent) SetWidth(width int) tea.Cmd { + if m.width == width { return nil } // Clear cache on resize since width affects rendering @@ -329,23 +362,14 @@ func (m *messagesComponent) SetSize(width, height int) tea.Cmd { m.cache.Clear() } m.width = width - m.height = height m.viewport.SetWidth(width) - m.viewport.SetHeight(height - lipgloss.Height(m.header())) - m.attachments.SetWidth(width + 40) - m.attachments.SetHeight(3) - m.renderView() + m.renderView(width) return nil } -func (m *messagesComponent) GetSize() (int, int) { - return m.width, m.height -} - func (m *messagesComponent) Reload() tea.Cmd { - m.rendering = true return func() tea.Msg { - m.renderView() + m.renderView(m.width) return renderFinishedMsg{} } } @@ -370,16 +394,45 @@ func (m *messagesComponent) HalfPageDown() (tea.Model, tea.Cmd) { return m, nil } -func (m *messagesComponent) First() (tea.Model, tea.Cmd) { - m.viewport.GotoTop() +func (m *messagesComponent) Previous() (tea.Model, tea.Cmd) { m.tail = false - return m, nil + if m.selectedPart < 0 { + m.selectedPart = m.partCount + } + m.selectedPart-- + if m.selectedPart < 0 { + m.selectedPart = 0 + } + return m, util.CmdHandler(selectedMessagePartChangedMsg{ + part: m.selectedPart, + }) +} + +func (m *messagesComponent) Next() (tea.Model, tea.Cmd) { + m.tail = false + m.selectedPart++ + if m.selectedPart >= m.partCount { + m.selectedPart = m.partCount + } + return m, util.CmdHandler(selectedMessagePartChangedMsg{ + part: m.selectedPart, + }) +} + +func (m *messagesComponent) First() (tea.Model, tea.Cmd) { + m.selectedPart = 0 + m.tail = false + return m, util.CmdHandler(selectedMessagePartChangedMsg{ + part: m.selectedPart, + }) } func (m *messagesComponent) Last() (tea.Model, tea.Cmd) { - m.viewport.GotoBottom() + m.selectedPart = m.partCount - 1 m.tail = true - return m, nil + return m, util.CmdHandler(selectedMessagePartChangedMsg{ + part: m.selectedPart, + }) } func (m *messagesComponent) ToolDetailsVisible() bool { @@ -388,15 +441,14 @@ func (m *messagesComponent) ToolDetailsVisible() bool { func NewMessagesComponent(app *app.App) MessagesComponent { vp := viewport.New() - attachments := viewport.New() vp.KeyMap = viewport.KeyMap{} return &messagesComponent{ app: app, viewport: vp, - attachments: attachments, showToolDetails: true, cache: NewMessageCache(), tail: true, + selectedPart: -1, } } diff --git a/packages/tui/internal/components/commands/commands.go b/packages/tui/internal/components/commands/commands.go index dbd00149..f3080b38 100644 --- a/packages/tui/internal/components/commands/commands.go +++ b/packages/tui/internal/components/commands/commands.go @@ -34,10 +34,6 @@ func (c *commandsComponent) SetSize(width, height int) tea.Cmd { return nil } -func (c *commandsComponent) GetSize() (int, int) { - return c.width, c.height -} - func (c *commandsComponent) SetBackgroundColor(color compat.AdaptiveColor) { c.background = &color } diff --git a/packages/tui/internal/components/dialog/complete.go b/packages/tui/internal/components/dialog/complete.go index 68e65614..f204d910 100644 --- a/packages/tui/internal/components/dialog/complete.go +++ b/packages/tui/internal/components/dialog/complete.go @@ -41,7 +41,6 @@ func (ci *CompletionItem) Render(selected bool, width int) string { title := itemStyle.Render( ci.DisplayValue(), ) - return title } @@ -59,7 +58,6 @@ func NewCompletionItem(completionItem CompletionItem) CompletionItemI { type CompletionProvider interface { GetId() string - GetEntry() CompletionItemI GetChildEntries(query string) ([]CompletionItemI, error) GetEmptyMessage() string } @@ -175,9 +173,6 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, c.pseudoSearchTextArea.Focus()) return c, tea.Batch(cmds...) } - case tea.WindowSizeMsg: - c.width = msg.Width - c.height = msg.Height } return c, tea.Batch(cmds...) diff --git a/packages/tui/internal/components/dialog/find.go b/packages/tui/internal/components/dialog/find.go new file mode 100644 index 00000000..3ca0d105 --- /dev/null +++ b/packages/tui/internal/components/dialog/find.go @@ -0,0 +1,235 @@ +package dialog + +import ( + "log/slog" + + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/bubbles/v2/textinput" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/sst/opencode/internal/components/list" + "github.com/sst/opencode/internal/components/modal" + "github.com/sst/opencode/internal/layout" + "github.com/sst/opencode/internal/styles" + "github.com/sst/opencode/internal/theme" + "github.com/sst/opencode/internal/util" +) + +type FindSelectedMsg struct { + FilePath string +} + +type FindDialogCloseMsg struct{} + +type FindDialog interface { + layout.Modal + tea.Model + tea.ViewModel + SetWidth(width int) + SetHeight(height int) + IsEmpty() bool + SetProvider(provider CompletionProvider) +} + +type findDialogComponent struct { + query string + completionProvider CompletionProvider + width, height int + modal *modal.Modal + textInput textinput.Model + list list.List[CompletionItemI] +} + +type findDialogKeyMap struct { + Select key.Binding + Cancel key.Binding +} + +var findDialogKeys = findDialogKeyMap{ + Select: key.NewBinding( + key.WithKeys("enter"), + ), + Cancel: key.NewBinding( + key.WithKeys("esc"), + ), +} + +func (f *findDialogComponent) Init() tea.Cmd { + return textinput.Blink +} + +func (f *findDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + switch msg := msg.(type) { + case []CompletionItemI: + f.list.SetItems(msg) + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c": + if f.textInput.Value() == "" { + return f, nil + } + f.textInput.SetValue("") + return f.update(msg) + } + + switch { + case key.Matches(msg, findDialogKeys.Select): + item, i := f.list.GetSelectedItem() + if i == -1 { + return f, nil + } + return f, f.selectFile(item) + case key.Matches(msg, findDialogKeys.Cancel): + return f, f.Close() + default: + f.textInput, cmd = f.textInput.Update(msg) + cmds = append(cmds, cmd) + + f, cmd = f.update(msg) + cmds = append(cmds, cmd) + } + } + + return f, tea.Batch(cmds...) +} + +func (f *findDialogComponent) update(msg tea.Msg) (*findDialogComponent, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + query := f.textInput.Value() + if query != f.query { + f.query = query + cmd = func() tea.Msg { + items, err := f.completionProvider.GetChildEntries(query) + if err != nil { + slog.Error("Failed to get completion items", "error", err) + } + return items + } + cmds = append(cmds, cmd) + } + + u, cmd := f.list.Update(msg) + f.list = u.(list.List[CompletionItemI]) + cmds = append(cmds, cmd) + + return f, tea.Batch(cmds...) +} + +func (f *findDialogComponent) View() string { + t := theme.CurrentTheme() + f.textInput.SetWidth(f.width - 8) + f.list.SetMaxWidth(f.width - 4) + inputView := f.textInput.View() + inputView = styles.NewStyle(). + Background(t.BackgroundPanel()). + Height(1). + Width(f.width-4). + Padding(0, 0). + Render(inputView) + + listView := f.list.View() + return styles.NewStyle().Height(12).Render(inputView + "\n" + listView) +} + +func (f *findDialogComponent) SetWidth(width int) { + f.width = width + if width > 4 { + f.textInput.SetWidth(width - 4) + f.list.SetMaxWidth(width - 4) + } +} + +func (f *findDialogComponent) SetHeight(height int) { + f.height = height +} + +func (f *findDialogComponent) IsEmpty() bool { + return f.list.IsEmpty() +} + +func (f *findDialogComponent) SetProvider(provider CompletionProvider) { + f.completionProvider = provider + f.list.SetEmptyMessage(" " + provider.GetEmptyMessage()) + f.list.SetItems([]CompletionItemI{}) +} + +func (f *findDialogComponent) selectFile(item CompletionItemI) tea.Cmd { + return tea.Sequence( + f.Close(), + util.CmdHandler(FindSelectedMsg{ + FilePath: item.GetValue(), + }), + ) +} + +func (f *findDialogComponent) Render(background string) string { + return f.modal.Render(f.View(), background) +} + +func (f *findDialogComponent) Close() tea.Cmd { + f.textInput.Reset() + f.textInput.Blur() + return util.CmdHandler(modal.CloseModalMsg{}) +} + +func createTextInput(existing *textinput.Model) textinput.Model { + t := theme.CurrentTheme() + bgColor := t.BackgroundPanel() + textColor := t.Text() + textMutedColor := t.TextMuted() + + ti := textinput.New() + + ti.Styles.Blurred.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss() + ti.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() + ti.Styles.Focused.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss() + ti.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() + ti.Styles.Cursor.Color = t.Primary() + ti.VirtualCursor = true + + ti.Prompt = " " + ti.CharLimit = -1 + ti.Focus() + + if existing != nil { + ti.SetValue(existing.Value()) + ti.SetWidth(existing.Width()) + } + + return ti +} + +func NewFindDialog(completionProvider CompletionProvider) FindDialog { + ti := createTextInput(nil) + + li := list.NewListComponent( + []CompletionItemI{}, + 10, // max visible items + completionProvider.GetEmptyMessage(), + false, + ) + + // Load initial items + go func() { + items, err := completionProvider.GetChildEntries("") + if err != nil { + slog.Error("Failed to get completion items", "error", err) + } + li.SetItems(items) + }() + + return &findDialogComponent{ + query: "", + completionProvider: completionProvider, + textInput: ti, + list: li, + modal: modal.New( + modal.WithTitle("Find Files"), + modal.WithMaxWidth(80), + ), + } +} diff --git a/packages/tui/internal/components/diff/diff.go b/packages/tui/internal/components/diff/diff.go index 3d0e41fc..02c2c31e 100644 --- a/packages/tui/internal/components/diff/diff.go +++ b/packages/tui/internal/components/diff/diff.go @@ -73,44 +73,6 @@ type linePair struct { right *DiffLine } -// ------------------------------------------------------------------------- -// Side-by-Side Configuration -// ------------------------------------------------------------------------- - -// SideBySideConfig configures the rendering of side-by-side diffs -type SideBySideConfig struct { - TotalWidth int -} - -// SideBySideOption modifies a SideBySideConfig -type SideBySideOption func(*SideBySideConfig) - -// NewSideBySideConfig creates a SideBySideConfig with default values -func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig { - config := SideBySideConfig{ - TotalWidth: 160, // Default width for side-by-side view - } - - for _, opt := range opts { - opt(&config) - } - - return config -} - -// WithTotalWidth sets the total width for side-by-side view -func WithTotalWidth(width int) SideBySideOption { - return func(s *SideBySideConfig) { - if width > 0 { - s.TotalWidth = width - } - } -} - -// ------------------------------------------------------------------------- -// Unified Configuration -// ------------------------------------------------------------------------- - // UnifiedConfig configures the rendering of unified diffs type UnifiedConfig struct { Width int @@ -122,13 +84,22 @@ type UnifiedOption func(*UnifiedConfig) // NewUnifiedConfig creates a UnifiedConfig with default values func NewUnifiedConfig(opts ...UnifiedOption) UnifiedConfig { config := UnifiedConfig{ - Width: 80, // Default width for unified view + Width: 80, } - for _, opt := range opts { opt(&config) } + return config +} +// NewSideBySideConfig creates a SideBySideConfig with default values +func NewSideBySideConfig(opts ...UnifiedOption) UnifiedConfig { + config := UnifiedConfig{ + Width: 160, + } + for _, opt := range opts { + opt(&config) + } return config } @@ -907,7 +878,7 @@ func RenderUnifiedHunk(fileName string, h Hunk, opts ...UnifiedOption) string { } // RenderSideBySideHunk formats a hunk for side-by-side display -func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string { +func RenderSideBySideHunk(fileName string, h Hunk, opts ...UnifiedOption) string { // Apply options to create the configuration config := NewSideBySideConfig(opts...) @@ -922,10 +893,10 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str pairs := pairLines(hunkCopy.Lines) // Calculate column width - colWidth := config.TotalWidth / 2 + colWidth := config.Width / 2 leftWidth := colWidth - rightWidth := config.TotalWidth - colWidth + rightWidth := config.Width - colWidth var sb strings.Builder util.WriteStringsPar(&sb, pairs, func(p linePair) string { @@ -963,7 +934,7 @@ func FormatUnifiedDiff(filename string, diffText string, opts ...UnifiedOption) } // FormatDiff creates a side-by-side formatted view of a diff -func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (string, error) { +func FormatDiff(filename string, diffText string, opts ...UnifiedOption) (string, error) { diffResult, err := ParseUnifiedDiff(diffText) if err != nil { return "", err diff --git a/packages/tui/internal/components/fileviewer/fileviewer.go b/packages/tui/internal/components/fileviewer/fileviewer.go new file mode 100644 index 00000000..6627bc3f --- /dev/null +++ b/packages/tui/internal/components/fileviewer/fileviewer.go @@ -0,0 +1,281 @@ +package fileviewer + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/v2/viewport" + tea "github.com/charmbracelet/bubbletea/v2" + + "github.com/sst/opencode/internal/app" + "github.com/sst/opencode/internal/commands" + "github.com/sst/opencode/internal/components/dialog" + "github.com/sst/opencode/internal/components/diff" + "github.com/sst/opencode/internal/layout" + "github.com/sst/opencode/internal/styles" + "github.com/sst/opencode/internal/theme" + "github.com/sst/opencode/internal/util" +) + +type DiffStyle int + +const ( + DiffStyleSplit DiffStyle = iota + DiffStyleUnified +) + +type Model struct { + app *app.App + width, height int + viewport viewport.Model + filename *string + content *string + isDiff *bool + diffStyle DiffStyle +} + +type fileRenderedMsg struct { + content string +} + +func New(app *app.App) Model { + vp := viewport.New() + m := Model{ + app: app, + viewport: vp, + diffStyle: DiffStyleUnified, + } + if app.State.SplitDiff { + m.diffStyle = DiffStyleSplit + } + return m +} + +func (m Model) Init() tea.Cmd { + return m.viewport.Init() +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case fileRenderedMsg: + m.viewport.SetContent(msg.content) + return m, util.CmdHandler(app.FileRenderedMsg{ + FilePath: *m.filename, + }) + case dialog.ThemeSelectedMsg: + return m, m.render() + case tea.KeyMsg: + switch msg.String() { + // TODO + } + } + + vp, cmd := m.viewport.Update(msg) + m.viewport = vp + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m Model) View() string { + if !m.HasFile() { + return "" + } + + header := *m.filename + header = styles.NewStyle(). + Padding(1, 2). + Width(m.width). + Background(theme.CurrentTheme().BackgroundElement()). + Foreground(theme.CurrentTheme().Text()). + Render(header) + + t := theme.CurrentTheme() + + close := m.app.Key(commands.FileCloseCommand) + diffToggle := m.app.Key(commands.FileDiffToggleCommand) + if m.isDiff == nil || *m.isDiff == false { + diffToggle = "" + } + layoutToggle := m.app.Key(commands.MessagesLayoutToggleCommand) + + background := t.Background() + footer := layout.Render( + layout.FlexOptions{ + Background: &background, + Direction: layout.Row, + Justify: layout.JustifyCenter, + Align: layout.AlignStretch, + Width: m.width - 2, + Gap: 5, + }, + layout.FlexItem{ + View: close, + }, + layout.FlexItem{ + View: layoutToggle, + }, + layout.FlexItem{ + View: diffToggle, + }, + ) + footer = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(footer) + + return header + "\n" + m.viewport.View() + "\n" + footer +} + +func (m *Model) Clear() (Model, tea.Cmd) { + m.filename = nil + m.content = nil + m.isDiff = nil + return *m, m.render() +} + +func (m *Model) ToggleDiff() (Model, tea.Cmd) { + switch m.diffStyle { + case DiffStyleSplit: + m.diffStyle = DiffStyleUnified + default: + m.diffStyle = DiffStyleSplit + } + return *m, m.render() +} + +func (m *Model) DiffStyle() DiffStyle { + return m.diffStyle +} + +func (m Model) HasFile() bool { + return m.filename != nil && m.content != nil +} + +func (m Model) Filename() string { + if m.filename == nil { + return "" + } + return *m.filename +} + +func (m *Model) SetSize(width, height int) (Model, tea.Cmd) { + if m.width != width || m.height != height { + m.width = width + m.height = height + m.viewport.SetWidth(width) + m.viewport.SetHeight(height - 4) + return *m, m.render() + } + return *m, nil +} + +func (m *Model) SetFile(filename string, content string, isDiff bool) (Model, tea.Cmd) { + m.filename = &filename + m.content = &content + m.isDiff = &isDiff + return *m, m.render() +} + +func (m *Model) render() tea.Cmd { + if m.filename == nil || m.content == nil { + m.viewport.SetContent("") + return nil + } + + return func() tea.Msg { + t := theme.CurrentTheme() + var rendered string + + if m.isDiff != nil && *m.isDiff { + diffResult := "" + var err error + if m.diffStyle == DiffStyleSplit { + diffResult, err = diff.FormatDiff( + *m.filename, + *m.content, + diff.WithWidth(m.width), + ) + } else if m.diffStyle == DiffStyleUnified { + diffResult, err = diff.FormatUnifiedDiff( + *m.filename, + *m.content, + diff.WithWidth(m.width), + ) + } + if err != nil { + rendered = styles.NewStyle(). + Foreground(t.Error()). + Render(fmt.Sprintf("Error rendering diff: %v", err)) + } else { + rendered = strings.TrimRight(diffResult, "\n") + } + } else { + rendered = util.RenderFile( + *m.filename, + *m.content, + m.width, + ) + } + + rendered = styles.NewStyle(). + Width(m.width). + Background(t.BackgroundPanel()). + Render(rendered) + + return fileRenderedMsg{ + content: rendered, + } + } +} + +func (m *Model) ScrollTo(line int) { + m.viewport.SetYOffset(line) +} + +func (m *Model) ScrollToBottom() { + m.viewport.GotoBottom() +} + +func (m *Model) ScrollToTop() { + m.viewport.GotoTop() +} + +func (m *Model) PageUp() (Model, tea.Cmd) { + m.viewport.ViewUp() + return *m, nil +} + +func (m *Model) PageDown() (Model, tea.Cmd) { + m.viewport.ViewDown() + return *m, nil +} + +func (m *Model) HalfPageUp() (Model, tea.Cmd) { + m.viewport.HalfViewUp() + return *m, nil +} + +func (m *Model) HalfPageDown() (Model, tea.Cmd) { + m.viewport.HalfViewDown() + return *m, nil +} + +func (m Model) AtTop() bool { + return m.viewport.AtTop() +} + +func (m Model) AtBottom() bool { + return m.viewport.AtBottom() +} + +func (m Model) ScrollPercent() float64 { + return m.viewport.ScrollPercent() +} + +func (m Model) TotalLineCount() int { + return m.viewport.TotalLineCount() +} + +func (m Model) VisibleLineCount() int { + return m.viewport.VisibleLineCount() +} diff --git a/packages/tui/internal/components/modal/modal.go b/packages/tui/internal/components/modal/modal.go index 6bce6424..aa81a83e 100644 --- a/packages/tui/internal/components/modal/modal.go +++ b/packages/tui/internal/components/modal/modal.go @@ -135,11 +135,11 @@ func (m *Modal) Render(contentView string, background string) string { col := (bgWidth - modalWidth) / 2 return layout.PlaceOverlay( - col, + col-1, // TODO: whyyyyy row, modalView, background, layout.WithOverlayBorder(), - layout.WithOverlayBorderColor(t.Primary()), + layout.WithOverlayBorderColor(t.BorderActive()), ) } diff --git a/packages/tui/internal/config/config.go b/packages/tui/internal/config/config.go index 502f5531..3dd6fcf5 100644 --- a/packages/tui/internal/config/config.go +++ b/packages/tui/internal/config/config.go @@ -21,6 +21,8 @@ type State struct { Provider string `toml:"provider"` Model string `toml:"model"` RecentlyUsedModels []ModelUsage `toml:"recently_used_models"` + MessagesRight bool `toml:"messages_right"` + SplitDiff bool `toml:"split_diff"` } func NewState() *State { diff --git a/packages/tui/internal/layout/flex.go b/packages/tui/internal/layout/flex.go index c7d9ee1b..5b10a952 100644 --- a/packages/tui/internal/layout/flex.go +++ b/packages/tui/internal/layout/flex.go @@ -4,7 +4,9 @@ import ( "strings" "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/lipgloss/v2/compat" "github.com/sst/opencode/internal/styles" + "github.com/sst/opencode/internal/theme" ) type Direction int @@ -34,11 +36,13 @@ const ( ) type FlexOptions struct { - Direction Direction - Justify Justify - Align Align - Width int - Height int + Background *compat.AdaptiveColor + Direction Direction + Justify Justify + Align Align + Width int + Height int + Gap int } type FlexItem struct { @@ -53,6 +57,12 @@ func Render(opts FlexOptions, items ...FlexItem) string { return "" } + t := theme.CurrentTheme() + if opts.Background == nil { + background := t.Background() + opts.Background = &background + } + // Calculate dimensions for each item mainAxisSize := opts.Width crossAxisSize := opts.Height @@ -72,8 +82,14 @@ func Render(opts FlexOptions, items ...FlexItem) string { } } + // Account for gaps between items + totalGapSize := 0 + if len(items) > 1 && opts.Gap > 0 { + totalGapSize = opts.Gap * (len(items) - 1) + } + // Calculate available space for grow items - availableSpace := max(mainAxisSize-totalFixedSize, 0) + availableSpace := max(mainAxisSize-totalFixedSize-totalGapSize, 0) // Calculate size for each grow item growItemSize := 0 @@ -108,6 +124,7 @@ func Render(opts FlexOptions, items ...FlexItem) string { // For row direction, constrain width and handle height alignment if itemSize > 0 { view = styles.NewStyle(). + Background(*opts.Background). Width(itemSize). Height(crossAxisSize). Render(view) @@ -116,31 +133,65 @@ func Render(opts FlexOptions, items ...FlexItem) string { // Apply cross-axis alignment switch opts.Align { case AlignCenter: - view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Center, view) + view = lipgloss.PlaceVertical( + crossAxisSize, + lipgloss.Center, + view, + styles.WhitespaceStyle(*opts.Background), + ) case AlignEnd: - view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Bottom, view) + view = lipgloss.PlaceVertical( + crossAxisSize, + lipgloss.Bottom, + view, + styles.WhitespaceStyle(*opts.Background), + ) case AlignStart: - view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Top, view) + view = lipgloss.PlaceVertical( + crossAxisSize, + lipgloss.Top, + view, + styles.WhitespaceStyle(*opts.Background), + ) case AlignStretch: // Already stretched by Height setting above } } else { // For column direction, constrain height and handle width alignment if itemSize > 0 { - view = styles.NewStyle(). - Height(itemSize). - Width(crossAxisSize). - Render(view) + style := styles.NewStyle(). + Background(*opts.Background). + Height(itemSize) + // Only set width for stretch alignment + if opts.Align == AlignStretch { + style = style.Width(crossAxisSize) + } + view = style.Render(view) } // Apply cross-axis alignment switch opts.Align { case AlignCenter: - view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Center, view) + view = lipgloss.PlaceHorizontal( + crossAxisSize, + lipgloss.Center, + view, + styles.WhitespaceStyle(*opts.Background), + ) case AlignEnd: - view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Right, view) + view = lipgloss.PlaceHorizontal( + crossAxisSize, + lipgloss.Right, + view, + styles.WhitespaceStyle(*opts.Background), + ) case AlignStart: - view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Left, view) + view = lipgloss.PlaceHorizontal( + crossAxisSize, + lipgloss.Left, + view, + styles.WhitespaceStyle(*opts.Background), + ) case AlignStretch: // Already stretched by Width setting above } @@ -154,11 +205,14 @@ func Render(opts FlexOptions, items ...FlexItem) string { } } - // Calculate total actual size + // Calculate total actual size including gaps totalActualSize := 0 for _, size := range actualSizes { totalActualSize += size } + if len(items) > 1 && opts.Gap > 0 { + totalActualSize += opts.Gap * (len(items) - 1) + } // Apply justification remainingSpace := max(mainAxisSize-totalActualSize, 0) @@ -191,12 +245,17 @@ func Render(opts FlexOptions, items ...FlexItem) string { // Build the final layout var parts []string + spaceStyle := styles.NewStyle().Background(*opts.Background) // Add space before if needed if spaceBefore > 0 { if opts.Direction == Row { - parts = append(parts, strings.Repeat(" ", spaceBefore)) + space := strings.Repeat(" ", spaceBefore) + parts = append(parts, spaceStyle.Render(space)) } else { - parts = append(parts, strings.Repeat("\n", spaceBefore)) + // For vertical layout, add empty lines as separate parts + for range spaceBefore { + parts = append(parts, "") + } } } @@ -205,11 +264,19 @@ func Render(opts FlexOptions, items ...FlexItem) string { parts = append(parts, view) // Add space between items (not after the last one) - if i < len(sizedViews)-1 && spaceBetween > 0 { - if opts.Direction == Row { - parts = append(parts, strings.Repeat(" ", spaceBetween)) - } else { - parts = append(parts, strings.Repeat("\n", spaceBetween)) + if i < len(sizedViews)-1 { + // Add gap first, then any additional spacing from justification + totalSpacing := opts.Gap + spaceBetween + if totalSpacing > 0 { + if opts.Direction == Row { + space := strings.Repeat(" ", totalSpacing) + parts = append(parts, spaceStyle.Render(space)) + } else { + // For vertical layout, add empty lines as separate parts + for range totalSpacing { + parts = append(parts, "") + } + } } } } @@ -217,9 +284,13 @@ func Render(opts FlexOptions, items ...FlexItem) string { // Add space after if needed if spaceAfter > 0 { if opts.Direction == Row { - parts = append(parts, strings.Repeat(" ", spaceAfter)) + space := strings.Repeat(" ", spaceAfter) + parts = append(parts, spaceStyle.Render(space)) } else { - parts = append(parts, strings.Repeat("\n", spaceAfter)) + // For vertical layout, add empty lines as separate parts + for range spaceAfter { + parts = append(parts, "") + } } } diff --git a/packages/tui/internal/layout/flex_example_test.go b/packages/tui/internal/layout/flex_example_test.go new file mode 100644 index 00000000..a03346eb --- /dev/null +++ b/packages/tui/internal/layout/flex_example_test.go @@ -0,0 +1,41 @@ +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 new file mode 100644 index 00000000..cad38dc8 --- /dev/null +++ b/packages/tui/internal/layout/flex_test.go @@ -0,0 +1,90 @@ +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 0beb6af7..f28d145d 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -19,6 +19,7 @@ import ( "github.com/sst/opencode/internal/components/chat" cmdcomp "github.com/sst/opencode/internal/components/commands" "github.com/sst/opencode/internal/components/dialog" + "github.com/sst/opencode/internal/components/fileviewer" "github.com/sst/opencode/internal/components/modal" "github.com/sst/opencode/internal/components/status" "github.com/sst/opencode/internal/components/toast" @@ -40,6 +41,7 @@ const ( ) const interruptDebounceTimeout = 1 * time.Second +const fileViewerFullWidthCutoff = 200 type appModel struct { width, height int @@ -56,6 +58,12 @@ type appModel struct { toastManager *toast.ToastManager interruptKeyState InterruptKeyState lastScroll time.Time + messagesRight bool + fileViewer fileviewer.Model + lastMouse tea.Mouse + fileViewerStart int + fileViewerEnd int + fileViewerHit bool } func (a appModel) Init() tea.Cmd { @@ -71,6 +79,7 @@ func (a appModel) Init() tea.Cmd { cmds = append(cmds, a.status.Init()) cmds = append(cmds, a.completions.Init()) cmds = append(cmds, a.toastManager.Init()) + cmds = append(cmds, a.fileViewer.Init()) // Check if we should show the init dialog cmds = append(cmds, func() tea.Msg { @@ -99,6 +108,7 @@ var BUGGED_SCROLL_KEYS = map[string]bool{ } func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd var cmds []tea.Cmd switch msg := msg.(type) { @@ -112,10 +122,20 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.modal != nil { switch keyString { // Escape always closes current modal - case "esc", "ctrl+c": + case "esc": cmd := a.modal.Close() a.modal = nil return a, cmd + case "ctrl+c": + // give the modal a chance to handle the ctrl+c + updatedModal, cmd := a.modal.Update(msg) + a.modal = updatedModal.(layout.Modal) + if cmd != nil { + return a, cmd + } + cmd = a.modal.Close() + a.modal = nil + return a, cmd } // Pass all other key presses to the modal @@ -246,10 +266,28 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.modal != nil { return a, nil } - updated, cmd := a.messages.Update(msg) - a.messages = updated.(chat.MessagesComponent) - cmds = append(cmds, cmd) + + var cmd tea.Cmd + if a.fileViewerHit { + a.fileViewer, cmd = a.fileViewer.Update(msg) + cmds = append(cmds, cmd) + } else { + updated, cmd := a.messages.Update(msg) + a.messages = updated.(chat.MessagesComponent) + cmds = append(cmds, cmd) + } + return a, tea.Batch(cmds...) + case tea.MouseMotionMsg: + a.lastMouse = msg.Mouse() + a.fileViewerHit = a.fileViewer.HasFile() && + a.lastMouse.X > a.fileViewerStart && + a.lastMouse.X < a.fileViewerEnd + case tea.MouseClickMsg: + a.lastMouse = msg.Mouse() + a.fileViewerHit = a.fileViewer.HasFile() && + a.lastMouse.X > a.fileViewerStart && + a.lastMouse.X < a.fileViewerEnd case tea.BackgroundColorMsg: styles.Terminal = &styles.TerminalInfo{ Background: msg.Color, @@ -266,6 +304,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } case modal.CloseModalMsg: + a.editor.Focus() var cmd tea.Cmd if a.modal != nil { cmd = a.modal.Close() @@ -349,22 +388,47 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { slog.Error("Server error", "name", err.Name, "message", err.Data.Message) return a, toast.NewErrorToast(err.Data.Message, toast.WithTitle(string(err.Name))) } + case opencode.EventListResponseEventFileWatcherUpdated: + if a.fileViewer.HasFile() { + if a.fileViewer.Filename() == msg.Properties.File { + return a.openFile(msg.Properties.File) + } + } case tea.WindowSizeMsg: msg.Height -= 2 // Make space for the status bar a.width, a.height = msg.Width, msg.Height + container := min(a.width, 84) + if a.fileViewer.HasFile() { + if a.width < fileViewerFullWidthCutoff { + container = a.width + } else { + container = min(min(a.width, max(a.width/2, 50)), 84) + } + } layout.Current = &layout.LayoutInfo{ Viewport: layout.Dimensions{ Width: a.width, Height: a.height, }, Container: layout.Dimensions{ - Width: min(a.width, 80), + Width: container, }, } - // Update child component sizes - messagesHeight := a.height - 6 // Leave room for editor and status bar - a.messages.SetSize(a.width, messagesHeight) - a.editor.SetSize(min(a.width, 80), 5) + mainWidth := layout.Current.Container.Width + a.messages.SetWidth(mainWidth - 4) + + sideWidth := a.width - mainWidth + if a.width < fileViewerFullWidthCutoff { + sideWidth = a.width + } + a.fileViewerStart = mainWidth + a.fileViewerEnd = a.fileViewerStart + sideWidth + if a.messagesRight { + a.fileViewerStart = 0 + a.fileViewerEnd = sideWidth + } + a.fileViewer, cmd = a.fileViewer.SetSize(sideWidth, layout.Current.Viewport.Height) + cmds = append(cmds, cmd) case app.SessionSelectedMsg: messages, err := a.app.ListMessages(context.Background(), msg.ID) if err != nil { @@ -373,6 +437,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } a.app.Session = msg a.app.Messages = messages + return a, util.CmdHandler(app.SessionLoadedMsg{}) case app.ModelSelectedMsg: a.app.Provider = &msg.Provider a.app.Model = &msg.Model @@ -395,24 +460,22 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Reset interrupt key state after timeout a.interruptKeyState = InterruptKeyIdle a.editor.SetInterruptKeyInDebounce(false) + case dialog.FindSelectedMsg: + return a.openFile(msg.FilePath) } - // update status bar s, cmd := a.status.Update(msg) cmds = append(cmds, cmd) a.status = s.(status.StatusComponent) - // update editor u, cmd := a.editor.Update(msg) a.editor = u.(chat.EditorComponent) cmds = append(cmds, cmd) - // update messages u, cmd = a.messages.Update(msg) a.messages = u.(chat.MessagesComponent) cmds = append(cmds, cmd) - // update modal if a.modal != nil { u, cmd := a.modal.Update(msg) a.modal = u.(layout.Modal) @@ -425,86 +488,95 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) } + fv, cmd := a.fileViewer.Update(msg) + a.fileViewer = fv + cmds = append(cmds, cmd) + return a, tea.Batch(cmds...) } func (a appModel) View() string { - mainLayout := a.chat(layout.Current.Container.Width, lipgloss.Center) + t := theme.CurrentTheme() + + var mainLayout string + mainWidth := layout.Current.Container.Width - 4 + if a.app.Session.ID == "" { + mainLayout = a.home(mainWidth) + } else { + mainLayout = a.chat(mainWidth) + } + mainLayout = styles.NewStyle(). + Background(t.Background()). + Padding(0, 2). + Render(mainLayout) + + mainHeight := lipgloss.Height(mainLayout) + + if a.fileViewer.HasFile() { + file := a.fileViewer.View() + baseStyle := styles.NewStyle().Background(t.BackgroundPanel()) + sidePanel := baseStyle.Height(mainHeight).Render(file) + if a.width >= fileViewerFullWidthCutoff { + if a.messagesRight { + mainLayout = lipgloss.JoinHorizontal( + lipgloss.Top, + sidePanel, + mainLayout, + ) + } else { + mainLayout = lipgloss.JoinHorizontal( + lipgloss.Top, + mainLayout, + sidePanel, + ) + } + } else { + mainLayout = sidePanel + } + } else { + mainLayout = lipgloss.PlaceHorizontal( + a.width, + lipgloss.Center, + mainLayout, + styles.WhitespaceStyle(t.Background()), + ) + } + + mainStyle := styles.NewStyle().Background(t.Background()) + mainLayout = mainStyle.Render(mainLayout) + if a.modal != nil { mainLayout = a.modal.Render(mainLayout) } mainLayout = a.toastManager.RenderOverlay(mainLayout) + if theme.CurrentThemeUsesAnsiColors() { mainLayout = util.ConvertRGBToAnsi16Colors(mainLayout) } return mainLayout + "\n" + a.status.View() } -func (a appModel) chat(width int, align lipgloss.Position) string { - editorView := a.editor.View(width, align) - lines := a.editor.Lines() - messagesView := a.messages.View() - if a.app.Session.ID == "" { - messagesView = a.home() - } - editorHeight := max(lines, 5) - - t := theme.CurrentTheme() - centeredEditorView := lipgloss.PlaceHorizontal( - a.width, - align, - editorView, - styles.WhitespaceStyle(t.Background()), - ) - - mainLayout := layout.Render( - layout.FlexOptions{ - Direction: layout.Column, - Width: a.width, - Height: a.height, - }, - layout.FlexItem{ - View: messagesView, - Grow: true, - }, - layout.FlexItem{ - View: centeredEditorView, - FixedSize: 5, +func (a appModel) openFile(filepath string) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + response, err := a.app.Client.File.Read( + context.Background(), + opencode.FileReadParams{ + Path: opencode.F(filepath), }, ) - - if lines > 1 { - editorWidth := min(a.width, 80) - editorX := (a.width - editorWidth) / 2 - editorY := a.height - editorHeight - mainLayout = layout.PlaceOverlay( - editorX, - editorY, - a.editor.Content(width, align), - mainLayout, - ) + if err != nil { + slog.Error("Failed to read file", "error", err) + return a, toast.NewErrorToast("Failed to read file") } - - if a.showCompletionDialog { - editorWidth := min(a.width, 80) - editorX := (a.width - editorWidth) / 2 - a.completions.SetWidth(editorWidth) - overlay := a.completions.View() - overlayHeight := lipgloss.Height(overlay) - editorY := a.height - editorHeight + 1 - - mainLayout = layout.PlaceOverlay( - editorX, - editorY-overlayHeight, - overlay, - mainLayout, - ) - } - - return mainLayout + a.fileViewer, cmd = a.fileViewer.SetFile( + filepath, + response.Content, + response.Type == "patch", + ) + return a, cmd } -func (a appModel) home() string { +func (a appModel) home(width int) string { t := theme.CurrentTheme() baseStyle := styles.NewStyle().Background(t.Background()) base := baseStyle.Render @@ -536,7 +608,7 @@ func (a appModel) home() string { logoAndVersion := strings.Join([]string{logo, version}, "\n") logoAndVersion = lipgloss.PlaceHorizontal( - a.width, + width, lipgloss.Center, logoAndVersion, styles.WhitespaceStyle(t.Background()), @@ -547,13 +619,15 @@ func (a appModel) home() string { cmdcomp.WithLimit(6), ) cmds := lipgloss.PlaceHorizontal( - a.width, + width, lipgloss.Center, commandsView.View(), styles.WhitespaceStyle(t.Background()), ) lines := []string{} + lines = append(lines, "") + lines = append(lines, "") lines = append(lines, logoAndVersion) lines = append(lines, "") lines = append(lines, "") @@ -561,18 +635,100 @@ func (a appModel) home() string { // lines = append(lines, base("config ")+muted(config)) // lines = append(lines, "") lines = append(lines, cmds) + lines = append(lines, "") + lines = append(lines, "") - return lipgloss.Place( - a.width, - a.height-5, + mainHeight := lipgloss.Height(strings.Join(lines, "\n")) + + editorWidth := min(width, 80) + editorView := a.editor.View(editorWidth) + editorView = lipgloss.PlaceHorizontal( + width, + lipgloss.Center, + editorView, + styles.WhitespaceStyle(t.Background()), + ) + lines = append(lines, editorView) + + editorLines := a.editor.Lines() + + mainLayout := lipgloss.Place( + width, + a.height, lipgloss.Center, lipgloss.Center, baseStyle.Render(strings.Join(lines, "\n")), styles.WhitespaceStyle(t.Background()), ) + + editorX := (width - editorWidth) / 2 + editorY := (a.height / 2) + (mainHeight / 2) - 2 + + if editorLines > 1 { + mainLayout = layout.PlaceOverlay( + editorX, + editorY, + a.editor.Content(editorWidth), + mainLayout, + ) + } + + if a.showCompletionDialog { + a.completions.SetWidth(editorWidth) + overlay := a.completions.View() + overlayHeight := lipgloss.Height(overlay) + + mainLayout = layout.PlaceOverlay( + editorX, + editorY-overlayHeight+1, + overlay, + mainLayout, + ) + } + + return mainLayout +} + +func (a appModel) chat(width int) string { + editorView := a.editor.View(width) + lines := a.editor.Lines() + messagesView := a.messages.View(width, a.height-5) + + editorWidth := lipgloss.Width(editorView) + editorHeight := max(lines, 5) + + mainLayout := messagesView + "\n" + editorView + editorX := (a.width - editorWidth) / 2 + + if lines > 1 { + editorY := a.height - editorHeight + mainLayout = layout.PlaceOverlay( + editorX, + editorY, + a.editor.Content(width), + mainLayout, + ) + } + + if a.showCompletionDialog { + a.completions.SetWidth(editorWidth) + overlay := a.completions.View() + overlayHeight := lipgloss.Height(overlay) + editorY := a.height - editorHeight + 1 + + mainLayout = layout.PlaceOverlay( + editorX, + editorY-overlayHeight, + overlay, + mainLayout, + ) + } + + return mainLayout } func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) { + var cmd tea.Cmd cmds := []tea.Cmd{ util.CmdHandler(commands.CommandExecutedMsg(command)), } @@ -676,6 +832,22 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) case commands.ThemeListCommand: themeDialog := dialog.NewThemeDialog() a.modal = themeDialog + case commands.FileListCommand: + a.editor.Blur() + provider := completions.NewFileAndFolderContextGroup(a.app) + findDialog := dialog.NewFindDialog(provider) + findDialog.SetWidth(layout.Current.Container.Width - 8) + a.modal = findDialog + case commands.FileCloseCommand: + a.fileViewer, cmd = a.fileViewer.Clear() + cmds = append(cmds, cmd) + case commands.FileDiffToggleCommand: + a.fileViewer, cmd = a.fileViewer.ToggleDiff() + a.app.State.SplitDiff = a.fileViewer.DiffStyle() == fileviewer.DiffStyleSplit + a.app.SaveState() + cmds = append(cmds, cmd) + case commands.FileSearchCommand: + return a, nil case commands.ProjectInitCommand: cmds = append(cmds, a.app.InitializeProject(context.Background())) case commands.InputClearCommand: @@ -697,20 +869,6 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) updated, cmd := a.editor.Newline() a.editor = updated.(chat.EditorComponent) cmds = append(cmds, cmd) - case commands.HistoryPreviousCommand: - if a.showCompletionDialog { - return a, nil - } - updated, cmd := a.editor.Previous() - a.editor = updated.(chat.EditorComponent) - cmds = append(cmds, cmd) - case commands.HistoryNextCommand: - if a.showCompletionDialog { - return a, nil - } - updated, cmd := a.editor.Next() - a.editor = updated.(chat.EditorComponent) - cmds = append(cmds, cmd) case commands.MessagesFirstCommand: updated, cmd := a.messages.First() a.messages = updated.(chat.MessagesComponent) @@ -720,21 +878,62 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) a.messages = updated.(chat.MessagesComponent) cmds = append(cmds, cmd) case commands.MessagesPageUpCommand: - updated, cmd := a.messages.PageUp() - a.messages = updated.(chat.MessagesComponent) - cmds = append(cmds, cmd) + if a.fileViewer.HasFile() { + a.fileViewer, cmd = a.fileViewer.PageUp() + cmds = append(cmds, cmd) + } else { + updated, cmd := a.messages.PageUp() + a.messages = updated.(chat.MessagesComponent) + cmds = append(cmds, cmd) + } case commands.MessagesPageDownCommand: - updated, cmd := a.messages.PageDown() - a.messages = updated.(chat.MessagesComponent) - cmds = append(cmds, cmd) + if a.fileViewer.HasFile() { + a.fileViewer, cmd = a.fileViewer.PageDown() + cmds = append(cmds, cmd) + } else { + updated, cmd := a.messages.PageDown() + a.messages = updated.(chat.MessagesComponent) + cmds = append(cmds, cmd) + } case commands.MessagesHalfPageUpCommand: - updated, cmd := a.messages.HalfPageUp() - a.messages = updated.(chat.MessagesComponent) - cmds = append(cmds, cmd) + if a.fileViewer.HasFile() { + a.fileViewer, cmd = a.fileViewer.HalfPageUp() + cmds = append(cmds, cmd) + } else { + updated, cmd := a.messages.HalfPageUp() + a.messages = updated.(chat.MessagesComponent) + cmds = append(cmds, cmd) + } case commands.MessagesHalfPageDownCommand: - updated, cmd := a.messages.HalfPageDown() + if a.fileViewer.HasFile() { + a.fileViewer, cmd = a.fileViewer.HalfPageDown() + cmds = append(cmds, cmd) + } else { + updated, cmd := a.messages.HalfPageDown() + a.messages = updated.(chat.MessagesComponent) + cmds = append(cmds, cmd) + } + case commands.MessagesPreviousCommand: + updated, cmd := a.messages.Previous() a.messages = updated.(chat.MessagesComponent) cmds = append(cmds, cmd) + case commands.MessagesNextCommand: + updated, cmd := a.messages.Next() + a.messages = updated.(chat.MessagesComponent) + cmds = append(cmds, cmd) + case commands.MessagesLayoutToggleCommand: + a.messagesRight = !a.messagesRight + a.app.State.MessagesRight = a.messagesRight + a.app.SaveState() + case commands.MessagesCopyCommand: + selected := a.messages.Selected() + if selected != "" { + cmd = tea.SetClipboard(selected) + cmds = append(cmds, cmd) + cmd = toast.NewSuccessToast("Message copied to clipboard") + cmds = append(cmds, cmd) + } + case commands.MessagesRevertCommand: case commands.AppExitCommand: return a, tea.Quit } @@ -776,6 +975,8 @@ func NewModel(app *app.App) tea.Model { showCompletionDialog: false, toastManager: toast.NewToastManager(), interruptKeyState: InterruptKeyIdle, + fileViewer: fileviewer.New(app), + messagesRight: app.State.MessagesRight, } return model diff --git a/packages/tui/internal/util/file.go b/packages/tui/internal/util/file.go new file mode 100644 index 00000000..2c0987dc --- /dev/null +++ b/packages/tui/internal/util/file.go @@ -0,0 +1,109 @@ +package util + +import ( + "fmt" + "path/filepath" + "strings" + "unicode" + + "github.com/charmbracelet/lipgloss/v2/compat" + "github.com/charmbracelet/x/ansi" + "github.com/sst/opencode/internal/styles" + "github.com/sst/opencode/internal/theme" +) + +var RootPath string +var CwdPath string + +type fileRenderer struct { + filename string + content string + height int +} + +type fileRenderingOption func(*fileRenderer) + +func WithTruncate(height int) fileRenderingOption { + return func(c *fileRenderer) { + c.height = height + } +} + +func RenderFile( + filename string, + content string, + width int, + options ...fileRenderingOption) string { + t := theme.CurrentTheme() + renderer := &fileRenderer{ + filename: filename, + content: content, + } + for _, option := range options { + option(renderer) + } + + lines := []string{} + for line := range strings.SplitSeq(content, "\n") { + line = strings.TrimRightFunc(line, unicode.IsSpace) + line = strings.ReplaceAll(line, "\t", " ") + lines = append(lines, line) + } + content = strings.Join(lines, "\n") + + if renderer.height > 0 { + content = TruncateHeight(content, renderer.height) + } + content = fmt.Sprintf("```%s\n%s\n```", Extension(renderer.filename), content) + content = ToMarkdown(content, width, t.BackgroundPanel()) + return content +} + +func TruncateHeight(content string, height int) string { + lines := strings.Split(content, "\n") + if len(lines) > height { + return strings.Join(lines[:height], "\n") + } + return content +} + +func Relative(path string) string { + path = strings.TrimPrefix(path, CwdPath+"/") + return strings.TrimPrefix(path, RootPath+"/") +} + +func Extension(path string) string { + ext := filepath.Ext(path) + if ext == "" { + ext = "" + } else { + ext = strings.ToLower(ext[1:]) + } + return ext +} + +func ToMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string { + r := styles.GetMarkdownRenderer(width-7, backgroundColor) + content = strings.ReplaceAll(content, RootPath+"/", "") + rendered, _ := r.Render(content) + lines := strings.Split(rendered, "\n") + + if len(lines) > 0 { + firstLine := lines[0] + cleaned := ansi.Strip(firstLine) + nospace := strings.ReplaceAll(cleaned, " ", "") + if nospace == "" { + lines = lines[1:] + } + if len(lines) > 0 { + lastLine := lines[len(lines)-1] + cleaned = ansi.Strip(lastLine) + nospace = strings.ReplaceAll(cleaned, " ", "") + if nospace == "" { + lines = lines[:len(lines)-1] + } + } + } + content = strings.Join(lines, "\n") + return strings.TrimSuffix(content, "\n") +} diff --git a/stainless.yml b/stainless.yml index 23e0be23..f8d654fb 100644 --- a/stainless.yml +++ b/stainless.yml @@ -51,9 +51,16 @@ resources: get: get /app init: post /app/init + find: + methods: + text: get /find + files: get /find/file + symbols: get /find/symbol + file: methods: - search: get /file + read: get /file + status: get /file/status config: models: