summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authoradamdottv <[email protected]>2025-07-02 16:08:06 -0500
committeradamdottv <[email protected]>2025-07-02 16:08:11 -0500
commitc82a060eca41b990b4dd89cecffb874b2133af6f (patch)
treec3bb84aee2e621da9feb18f866c88fc51da0e7c1
parent63e783ef795d91c745733b945247e917f1683d31 (diff)
downloadopencode-c82a060eca41b990b4dd89cecffb874b2133af6f.tar.gz
opencode-c82a060eca41b990b4dd89cecffb874b2133af6f.zip
feat(tui): file viewer, select messages
-rw-r--r--packages/opencode/src/file/ripgrep.ts2
-rw-r--r--packages/opencode/src/server/server.ts134
-rw-r--r--packages/tui/go.mod2
-rw-r--r--packages/tui/go.sum4
-rw-r--r--packages/tui/internal/app/app.go24
-rw-r--r--packages/tui/internal/commands/command.go59
-rw-r--r--packages/tui/internal/completions/commands.go7
-rw-r--r--packages/tui/internal/completions/files-folders.go98
-rw-r--r--packages/tui/internal/components/chat/editor.go95
-rw-r--r--packages/tui/internal/components/chat/message.go279
-rw-r--r--packages/tui/internal/components/chat/messages.go216
-rw-r--r--packages/tui/internal/components/commands/commands.go4
-rw-r--r--packages/tui/internal/components/dialog/complete.go5
-rw-r--r--packages/tui/internal/components/dialog/find.go235
-rw-r--r--packages/tui/internal/components/diff/diff.go59
-rw-r--r--packages/tui/internal/components/fileviewer/fileviewer.go281
-rw-r--r--packages/tui/internal/components/modal/modal.go4
-rw-r--r--packages/tui/internal/config/config.go2
-rw-r--r--packages/tui/internal/layout/flex.go123
-rw-r--r--packages/tui/internal/layout/flex_example_test.go41
-rw-r--r--packages/tui/internal/layout/flex_test.go90
-rw-r--r--packages/tui/internal/tui/tui.go405
-rw-r--r--packages/tui/internal/util/file.go109
-rw-r--r--stainless.yml9
24 files changed, 1717 insertions, 570 deletions
diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts
index 39ae5b593..a975d34b5 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 d636e5c64..df645cd85 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: "Find text in files",
+ responses: {
+ 200: {
+ 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: "Search for files",
+ description: "Find files",
responses: {
200: {
- description: "Search for files",
+ 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 0ea1f9da5..6cd1bae65 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 159f2b209..ac6981f28 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 2369b196a..9b341c19b 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 4ef458832..f9b447b55 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"
)
@@ -185,6 +190,27 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Trigger: "themes",
},
{
+ Name: FileListCommand,
+ Description: "list files",
+ Keybindings: parseBindings("<leader>f"),
+ Trigger: "files",
+ },
+ {
+ Name: FileCloseCommand,
+ Description: "close file",
+ Keybindings: parseBindings("esc"),
+ },
+ {
+ Name: FileSearchCommand,
+ Description: "search file",
+ Keybindings: parseBindings("<leader>/"),
+ },
+ {
+ Name: FileDiffToggleCommand,
+ Description: "split/unified diff",
+ Keybindings: parseBindings("<leader>v"),
+ },
+ {
Name: ProjectInitCommand,
Description: "create/update AGENTS.md",
Keybindings: parseBindings("<leader>i"),
@@ -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,
@@ -261,6 +277,21 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Keybindings: parseBindings("ctrl+alt+g"),
},
{
+ Name: MessagesLayoutToggleCommand,
+ Description: "toggle layout",
+ Keybindings: parseBindings("<leader>m"),
+ },
+ {
+ Name: MessagesCopyCommand,
+ Description: "copy message",
+ Keybindings: parseBindings("<leader>y"),
+ },
+ {
+ Name: MessagesRevertCommand,
+ Description: "revert message",
+ Keybindings: parseBindings("<leader>u"),
+ },
+ {
Name: AppExitCommand,
Description: "exit the app",
Keybindings: parseBindings("ctrl+c", "<leader>q"),
diff --git a/packages/tui/internal/completions/commands.go b/packages/tui/internal/completions/commands.go
index 21a26cbc8..c73923e8e 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 6fb4316fc..cb7a74539 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)
+ items := make([]dialog.CompletionItemI, 0)
+
+ query = strings.TrimSpace(query)
+ if query == "" {
+ items = append(items, cg.gitFiles...)
+ }
+
+ files, err := cg.app.Client.Find.Files(
+ context.Background(),
+ opencode.FindFilesParams{Query: opencode.F(query)},
+ )
if err != nil {
- return nil, err
+ slog.Error("Failed to get completion items", "error", err)
}
- items := make([]dialog.CompletionItemI, 0, len(matches))
- for _, file := range matches {
- item := dialog.NewCompletionItem(dialog.CompletionItem{
- Title: file,
- Value: file,
- })
- items = append(items, item)
+ 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 b4abd0f89..669ef47d0 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 8e4cbc1af..4ef738569 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 fbe05d70d..a0105ec42 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) Previous() (tea.Model, tea.Cmd) {
+ m.tail = false
+ 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.viewport.GotoTop()
+ m.selectedPart = 0
m.tail = false
- return m, nil
+ 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 dbd001497..f3080b38f 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 68e656145..f204d910c 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 000000000..3ca0d105b
--- /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 3d0e41fc3..02c2c31e9 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 000000000..6627bc3f0
--- /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 6bce64247..aa81a83e7 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 502f55310..3dd6fcf59 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 c7d9ee1bc..5b10a9523 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 000000000..a03346eb7
--- /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 000000000..cad38dc8f
--- /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 0beb6af79..f28d145dc 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, "")
+
+ mainHeight := lipgloss.Height(strings.Join(lines, "\n"))
- return lipgloss.Place(
- a.width,
- a.height-5,
+ 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()
+ 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:
+ 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.MessagesHalfPageDownCommand:
- updated, cmd := a.messages.HalfPageDown()
+ 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 000000000..2c0987dcf
--- /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 23e0be234..f8d654fb4 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: