Browse Source

feat(tui): file viewer, select messages

adamdottv 7 months ago
parent
commit
c82a060eca

+ 1 - 1
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"),
     type: z.literal("match"),
     data: z.object({
     data: z.object({
       path: z.object({
       path: z.object({

+ 130 - 4
packages/opencode/src/server/server.ts

@@ -14,6 +14,8 @@ import { NamedError } from "../util/error"
 import { ModelsDev } from "../provider/models"
 import { ModelsDev } from "../provider/models"
 import { Ripgrep } from "../file/ripgrep"
 import { Ripgrep } from "../file/ripgrep"
 import { Config } from "../config/config"
 import { Config } from "../config/config"
+import { File } from "../file"
+import { LSP } from "../lsp"
 
 
 const ERRORS = {
 const ERRORS = {
   400: {
   400: {
@@ -73,7 +75,7 @@ export namespace Server {
           documentation: {
           documentation: {
             info: {
             info: {
               title: "opencode",
               title: "opencode",
-              version: "0.0.2",
+              version: "0.0.3",
               description: "opencode api",
               description: "opencode api",
             },
             },
             openapi: "3.0.0",
             openapi: "3.0.0",
@@ -492,12 +494,44 @@ export namespace Server {
         },
         },
       )
       )
       .get(
       .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({
         describeRoute({
-          description: "Search for files",
+          description: "Find files",
           responses: {
           responses: {
             200: {
             200: {
-              description: "Search for files",
+              description: "File paths",
               content: {
               content: {
                 "application/json": {
                 "application/json": {
                   schema: resolver(z.string().array()),
                   schema: resolver(z.string().array()),
@@ -523,6 +557,98 @@ export namespace Server {
           return c.json(result)
           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
     return result
   }
   }

+ 1 - 1
packages/tui/go.mod

@@ -15,7 +15,7 @@ require (
 	github.com/muesli/reflow v0.3.0
 	github.com/muesli/reflow v0.3.0
 	github.com/muesli/termenv v0.16.0
 	github.com/muesli/termenv v0.16.0
 	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
 	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
 	github.com/tidwall/gjson v1.14.4
 	rsc.io/qr v0.2.0
 	rsc.io/qr v0.2.0
 )
 )

+ 2 - 2
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/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 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
 github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 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/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 v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=

+ 19 - 5
packages/tui/internal/app/app.go

@@ -20,9 +20,6 @@ import (
 	"github.com/sst/opencode/internal/util"
 	"github.com/sst/opencode/internal/util"
 )
 )
 
 
-var RootPath string
-var CwdPath string
-
 type App struct {
 type App struct {
 	Info      opencode.App
 	Info      opencode.App
 	Version   string
 	Version   string
@@ -38,6 +35,7 @@ type App struct {
 }
 }
 
 
 type SessionSelectedMsg = *opencode.Session
 type SessionSelectedMsg = *opencode.Session
+type SessionLoadedMsg struct{}
 type ModelSelectedMsg struct {
 type ModelSelectedMsg struct {
 	Provider opencode.Provider
 	Provider opencode.Provider
 	Model    opencode.Model
 	Model    opencode.Model
@@ -54,6 +52,9 @@ type CompletionDialogTriggeredMsg struct {
 type OptimisticMessageAddedMsg struct {
 type OptimisticMessageAddedMsg struct {
 	Message opencode.Message
 	Message opencode.Message
 }
 }
+type FileRenderedMsg struct {
+	FilePath string
+}
 
 
 func New(
 func New(
 	ctx context.Context,
 	ctx context.Context,
@@ -61,8 +62,8 @@ func New(
 	appInfo opencode.App,
 	appInfo opencode.App,
 	httpClient *opencode.Client,
 	httpClient *opencode.Client,
 ) (*App, error) {
 ) (*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)
 	configInfo, err := httpClient.Config.Get(ctx)
 	if err != nil {
 	if err != nil {
@@ -125,6 +126,19 @@ func New(
 	return app, nil
 	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 {
 func (a *App) InitializeProvider() tea.Cmd {
 	return func() tea.Msg {
 	return func() tea.Msg {
 		providersResponse, err := a.Client.Config.Providers(context.Background())
 		providersResponse, err := a.Client.Config.Providers(context.Background())

+ 45 - 14
packages/tui/internal/commands/command.go

@@ -80,13 +80,15 @@ const (
 	ToolDetailsCommand          CommandName = "tool_details"
 	ToolDetailsCommand          CommandName = "tool_details"
 	ModelListCommand            CommandName = "model_list"
 	ModelListCommand            CommandName = "model_list"
 	ThemeListCommand            CommandName = "theme_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"
 	ProjectInitCommand          CommandName = "project_init"
 	InputClearCommand           CommandName = "input_clear"
 	InputClearCommand           CommandName = "input_clear"
 	InputPasteCommand           CommandName = "input_paste"
 	InputPasteCommand           CommandName = "input_paste"
 	InputSubmitCommand          CommandName = "input_submit"
 	InputSubmitCommand          CommandName = "input_submit"
 	InputNewlineCommand         CommandName = "input_newline"
 	InputNewlineCommand         CommandName = "input_newline"
-	HistoryPreviousCommand      CommandName = "history_previous"
-	HistoryNextCommand          CommandName = "history_next"
 	MessagesPageUpCommand       CommandName = "messages_page_up"
 	MessagesPageUpCommand       CommandName = "messages_page_up"
 	MessagesPageDownCommand     CommandName = "messages_page_down"
 	MessagesPageDownCommand     CommandName = "messages_page_down"
 	MessagesHalfPageUpCommand   CommandName = "messages_half_page_up"
 	MessagesHalfPageUpCommand   CommandName = "messages_half_page_up"
@@ -95,6 +97,9 @@ const (
 	MessagesNextCommand         CommandName = "messages_next"
 	MessagesNextCommand         CommandName = "messages_next"
 	MessagesFirstCommand        CommandName = "messages_first"
 	MessagesFirstCommand        CommandName = "messages_first"
 	MessagesLastCommand         CommandName = "messages_last"
 	MessagesLastCommand         CommandName = "messages_last"
+	MessagesLayoutToggleCommand CommandName = "messages_layout_toggle"
+	MessagesCopyCommand         CommandName = "messages_copy"
+	MessagesRevertCommand       CommandName = "messages_revert"
 	AppExitCommand              CommandName = "app_exit"
 	AppExitCommand              CommandName = "app_exit"
 )
 )
 
 
@@ -184,6 +189,27 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
 			Keybindings: parseBindings("<leader>t"),
 			Keybindings: parseBindings("<leader>t"),
 			Trigger:     "themes",
 			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,
 			Name:        ProjectInitCommand,
 			Description: "create/update AGENTS.md",
 			Description: "create/update AGENTS.md",
@@ -210,16 +236,6 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
 			Description: "insert newline",
 			Description: "insert newline",
 			Keybindings: parseBindings("shift+enter", "ctrl+j"),
 			Keybindings: parseBindings("shift+enter", "ctrl+j"),
 		},
 		},
-		// {
-		// 	Name:        HistoryPreviousCommand,
-		// 	Description: "previous prompt",
-		// 	Keybindings: parseBindings("up"),
-		// },
-		// {
-		// 	Name:        HistoryNextCommand,
-		// 	Description: "next prompt",
-		// 	Keybindings: parseBindings("down"),
-		// },
 		{
 		{
 			Name:        MessagesPageUpCommand,
 			Name:        MessagesPageUpCommand,
 			Description: "page up",
 			Description: "page up",
@@ -243,12 +259,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
 		{
 		{
 			Name:        MessagesPreviousCommand,
 			Name:        MessagesPreviousCommand,
 			Description: "previous message",
 			Description: "previous message",
-			Keybindings: parseBindings("ctrl+alt+k"),
+			Keybindings: parseBindings("ctrl+up"),
 		},
 		},
 		{
 		{
 			Name:        MessagesNextCommand,
 			Name:        MessagesNextCommand,
 			Description: "next message",
 			Description: "next message",
-			Keybindings: parseBindings("ctrl+alt+j"),
+			Keybindings: parseBindings("ctrl+down"),
 		},
 		},
 		{
 		{
 			Name:        MessagesFirstCommand,
 			Name:        MessagesFirstCommand,
@@ -260,6 +276,21 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
 			Description: "last message",
 			Description: "last message",
 			Keybindings: parseBindings("ctrl+alt+g"),
 			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,
 			Name:        AppExitCommand,
 			Description: "exit the app",
 			Description: "exit the app",

+ 0 - 7
packages/tui/internal/completions/commands.go

@@ -25,13 +25,6 @@ func (c *CommandCompletionProvider) GetId() string {
 	return "commands"
 	return "commands"
 }
 }
 
 
-func (c *CommandCompletionProvider) GetEntry() dialog.CompletionItemI {
-	return dialog.NewCompletionItem(dialog.CompletionItem{
-		Title: "Commands",
-		Value: "commands",
-	})
-}
-
 func (c *CommandCompletionProvider) GetEmptyMessage() string {
 func (c *CommandCompletionProvider) GetEmptyMessage() string {
 	return "no matching commands"
 	return "no matching commands"
 }
 }

+ 71 - 27
packages/tui/internal/completions/files-folders.go

@@ -2,64 +2,108 @@ package completions
 
 
 import (
 import (
 	"context"
 	"context"
+	"log/slog"
+	"sort"
+	"strconv"
+	"strings"
 
 
 	"github.com/sst/opencode-sdk-go"
 	"github.com/sst/opencode-sdk-go"
 	"github.com/sst/opencode/internal/app"
 	"github.com/sst/opencode/internal/app"
 	"github.com/sst/opencode/internal/components/dialog"
 	"github.com/sst/opencode/internal/components/dialog"
+	"github.com/sst/opencode/internal/styles"
+	"github.com/sst/opencode/internal/theme"
 )
 )
 
 
 type filesAndFoldersContextGroup struct {
 type filesAndFoldersContextGroup struct {
-	app    *app.App
-	prefix string
+	app      *app.App
+	prefix   string
+	gitFiles []dialog.CompletionItemI
 }
 }
 
 
 func (cg *filesAndFoldersContextGroup) GetId() string {
 func (cg *filesAndFoldersContextGroup) GetId() string {
 	return cg.prefix
 	return cg.prefix
 }
 }
 
 
-func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
-	return dialog.NewCompletionItem(dialog.CompletionItem{
-		Title: "Files & Folders",
-		Value: "files",
-	})
-}
-
 func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
 func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
 	return "no matching files"
 	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) {
 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 {
 	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
 	return items, nil
 }
 }
 
 
 func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
 func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
-	return &filesAndFoldersContextGroup{
+	cg := &filesAndFoldersContextGroup{
 		app:    app,
 		app:    app,
 		prefix: "file",
 		prefix: "file",
 	}
 	}
+	cg.gitFiles = cg.getGitFiles()
+	return cg
 }
 }

+ 10 - 85
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/dialog"
 	"github.com/sst/opencode/internal/components/textarea"
 	"github.com/sst/opencode/internal/components/textarea"
 	"github.com/sst/opencode/internal/image"
 	"github.com/sst/opencode/internal/image"
-	"github.com/sst/opencode/internal/layout"
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/theme"
 	"github.com/sst/opencode/internal/theme"
 	"github.com/sst/opencode/internal/util"
 	"github.com/sst/opencode/internal/util"
@@ -21,10 +20,8 @@ import (
 
 
 type EditorComponent interface {
 type EditorComponent interface {
 	tea.Model
 	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
 	Lines() int
 	Value() string
 	Value() string
 	Focused() bool
 	Focused() bool
@@ -34,19 +31,13 @@ type EditorComponent interface {
 	Clear() (tea.Model, tea.Cmd)
 	Clear() (tea.Model, tea.Cmd)
 	Paste() (tea.Model, tea.Cmd)
 	Paste() (tea.Model, tea.Cmd)
 	Newline() (tea.Model, tea.Cmd)
 	Newline() (tea.Model, tea.Cmd)
-	Previous() (tea.Model, tea.Cmd)
-	Next() (tea.Model, tea.Cmd)
 	SetInterruptKeyInDebounce(inDebounce bool)
 	SetInterruptKeyInDebounce(inDebounce bool)
 }
 }
 
 
 type editorComponent struct {
 type editorComponent struct {
 	app                    *app.App
 	app                    *app.App
-	width, height          int
 	textarea               textarea.Model
 	textarea               textarea.Model
 	attachments            []app.Attachment
 	attachments            []app.Attachment
-	history                []string
-	historyIndex           int
-	currentMessage         string
 	spinner                spinner.Model
 	spinner                spinner.Model
 	interruptKeyInDebounce bool
 	interruptKeyInDebounce bool
 }
 }
@@ -106,7 +97,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, tea.Batch(cmds...)
 	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()
 	t := theme.CurrentTheme()
 	base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
 	base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
 	muted := styles.NewStyle().Foreground(t.TextMuted()).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)
 		Bold(true)
 	prompt := promptStyle.Render(">")
 	prompt := promptStyle.Render(">")
 
 
+	m.textarea.SetWidth(width - 6)
 	textarea := lipgloss.JoinHorizontal(
 	textarea := lipgloss.JoinHorizontal(
 		lipgloss.Top,
 		lipgloss.Top,
 		prompt,
 		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)
 		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("")
 	spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
 
 
 	info := hint + spacer + model
 	info := hint + spacer + model
@@ -157,19 +149,18 @@ func (m *editorComponent) Content(width int, align lipgloss.Position) string {
 	return content
 	return content
 }
 }
 
 
-func (m *editorComponent) View(width int, align lipgloss.Position) string {
+func (m *editorComponent) View(width int) string {
 	if m.Lines() > 1 {
 	if m.Lines() > 1 {
-		t := theme.CurrentTheme()
 		return lipgloss.Place(
 		return lipgloss.Place(
 			width,
 			width,
-			m.height,
-			align,
+			5,
+			lipgloss.Center,
 			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 {
 func (m *editorComponent) Focused() bool {
@@ -184,16 +175,6 @@ func (m *editorComponent) Blur() {
 	m.textarea.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 {
 func (m *editorComponent) Lines() int {
 	return m.textarea.LineCount()
 	return m.textarea.LineCount()
 }
 }
@@ -219,16 +200,6 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
 	cmds = append(cmds, cmd)
 	cmds = append(cmds, cmd)
 
 
 	attachments := m.attachments
 	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
 	m.attachments = nil
 
 
 	cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments}))
 	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
 	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) {
 func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
 	m.interruptKeyInDebounce = inDebounce
 	m.interruptKeyInDebounce = inDebounce
 }
 }
@@ -336,7 +265,6 @@ func createTextArea(existing *textarea.Model) textarea.Model {
 	ta.Prompt = " "
 	ta.Prompt = " "
 	ta.ShowLineNumbers = false
 	ta.ShowLineNumbers = false
 	ta.CharLimit = -1
 	ta.CharLimit = -1
-	ta.SetWidth(layout.Current.Container.Width - 6)
 
 
 	if existing != nil {
 	if existing != nil {
 		ta.SetValue(existing.Value())
 		ta.SetValue(existing.Value())
@@ -368,9 +296,6 @@ func NewEditorComponent(app *app.App) EditorComponent {
 	return &editorComponent{
 	return &editorComponent{
 		app:                    app,
 		app:                    app,
 		textarea:               ta,
 		textarea:               ta,
-		history:                []string{},
-		historyIndex:           0,
-		currentMessage:         "",
 		spinner:                s,
 		spinner:                s,
 		interruptKeyInDebounce: false,
 		interruptKeyInDebounce: false,
 	}
 	}

+ 121 - 158
packages/tui/internal/components/chat/message.go

@@ -3,65 +3,46 @@ package chat
 import (
 import (
 	"encoding/json"
 	"encoding/json"
 	"fmt"
 	"fmt"
-	"path/filepath"
 	"slices"
 	"slices"
 	"strings"
 	"strings"
 	"time"
 	"time"
-	"unicode"
 
 
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/lipgloss/v2/compat"
 	"github.com/charmbracelet/lipgloss/v2/compat"
-	"github.com/charmbracelet/x/ansi"
 	"github.com/sst/opencode-sdk-go"
 	"github.com/sst/opencode-sdk-go"
 	"github.com/sst/opencode/internal/app"
 	"github.com/sst/opencode/internal/app"
+	"github.com/sst/opencode/internal/commands"
 	"github.com/sst/opencode/internal/components/diff"
 	"github.com/sst/opencode/internal/components/diff"
 	"github.com/sst/opencode/internal/layout"
 	"github.com/sst/opencode/internal/layout"
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/theme"
 	"github.com/sst/opencode/internal/theme"
+	"github.com/sst/opencode/internal/util"
 	"github.com/tidwall/gjson"
 	"github.com/tidwall/gjson"
 	"golang.org/x/text/cases"
 	"golang.org/x/text/cases"
 	"golang.org/x/text/language"
 	"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 {
 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)
 type renderingOption func(*blockRenderer)
 
 
+func WithTextColor(color compat.AdaptiveColor) renderingOption {
+	return func(c *blockRenderer) {
+		c.textColor = color
+	}
+}
+
 func WithNoBorder() renderingOption {
 func WithNoBorder() renderingOption {
 	return func(c *blockRenderer) {
 	return func(c *blockRenderer) {
 		c.border = false
 		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 {
 func WithMarginTop(padding int) renderingOption {
 	return func(c *blockRenderer) {
 	return func(c *blockRenderer) {
 		c.marginTop = padding
 		c.marginTop = padding
@@ -120,13 +108,15 @@ func WithPaddingBottom(padding int) renderingOption {
 }
 }
 
 
 func renderContentBlock(
 func renderContentBlock(
+	app *app.App,
 	content string,
 	content string,
+	highlight bool,
 	width int,
 	width int,
-	align lipgloss.Position,
 	options ...renderingOption,
 	options ...renderingOption,
 ) string {
 ) string {
 	t := theme.CurrentTheme()
 	t := theme.CurrentTheme()
 	renderer := &blockRenderer{
 	renderer := &blockRenderer{
+		textColor:     t.TextMuted(),
 		border:        true,
 		border:        true,
 		paddingTop:    1,
 		paddingTop:    1,
 		paddingBottom: 1,
 		paddingBottom: 1,
@@ -143,7 +133,7 @@ func renderContentBlock(
 	}
 	}
 
 
 	style := styles.NewStyle().
 	style := styles.NewStyle().
-		Foreground(t.TextMuted()).
+		Foreground(renderer.textColor).
 		Background(t.BackgroundPanel()).
 		Background(t.BackgroundPanel()).
 		Width(width).
 		Width(width).
 		PaddingTop(renderer.paddingTop).
 		PaddingTop(renderer.paddingTop).
@@ -161,21 +151,32 @@ func renderContentBlock(
 			BorderLeftBackground(t.Background()).
 			BorderLeftBackground(t.Background()).
 			BorderRightForeground(t.BackgroundPanel()).
 			BorderRightForeground(t.BackgroundPanel()).
 			BorderRightBackground(t.Background())
 			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 = 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 {
 	if renderer.marginTop > 0 {
 		for range renderer.marginTop {
 		for range renderer.marginTop {
 			content = "\n" + content
 			content = "\n" + content
@@ -186,16 +187,44 @@ func renderContentBlock(
 			content = content + "\n"
 			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
 	return content
 }
 }
 
 
 func renderText(
 func renderText(
+	app *app.App,
 	message opencode.Message,
 	message opencode.Message,
 	text string,
 	text string,
 	author string,
 	author string,
 	showToolDetails bool,
 	showToolDetails bool,
+	highlight bool,
 	width int,
 	width int,
-	align lipgloss.Position,
 	toolCalls ...opencode.ToolInvocationPart,
 	toolCalls ...opencode.ToolInvocationPart,
 ) string {
 ) string {
 	t := theme.CurrentTheme()
 	t := theme.CurrentTheme()
@@ -206,17 +235,20 @@ func renderText(
 		timestamp = timestamp[12:]
 		timestamp = timestamp[12:]
 	}
 	}
 	info := fmt.Sprintf("%s (%s)", author, timestamp)
 	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 {
 	if message.Role == opencode.MessageRoleUser {
 		messageStyle = messageStyle.Width(width - 6)
 		messageStyle = messageStyle.Width(width - 6)
 	}
 	}
 
 
 	content := messageStyle.Render(text)
 	content := messageStyle.Render(text)
 	if message.Role == opencode.MessageRoleAssistant {
 	if message.Role == opencode.MessageRoleAssistant {
-		content = toMarkdown(text, width, t.BackgroundPanel())
+		content = util.ToMarkdown(text, width, backgroundColor)
 	}
 	}
 
 
 	if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 {
 	if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 {
@@ -242,16 +274,19 @@ func renderText(
 	switch message.Role {
 	switch message.Role {
 	case opencode.MessageRoleUser:
 	case opencode.MessageRoleUser:
 		return renderContentBlock(
 		return renderContentBlock(
+			app,
 			content,
 			content,
+			highlight,
 			width,
 			width,
-			align,
-			WithBorderColor(t.Secondary()),
+			WithTextColor(t.Text()),
+			WithBorderColorRight(t.Secondary()),
 		)
 		)
 	case opencode.MessageRoleAssistant:
 	case opencode.MessageRoleAssistant:
 		return renderContentBlock(
 		return renderContentBlock(
+			app,
 			content,
 			content,
+			highlight,
 			width,
 			width,
-			align,
 			WithBorderColor(t.Accent()),
 			WithBorderColor(t.Accent()),
 		)
 		)
 	}
 	}
@@ -259,10 +294,11 @@ func renderText(
 }
 }
 
 
 func renderToolDetails(
 func renderToolDetails(
+	app *app.App,
 	toolCall opencode.ToolInvocationPart,
 	toolCall opencode.ToolInvocationPart,
 	messageMetadata opencode.MessageMetadata,
 	messageMetadata opencode.MessageMetadata,
+	highlight bool,
 	width int,
 	width int,
-	align lipgloss.Position,
 ) string {
 ) string {
 	ignoredTools := []string{"todoread"}
 	ignoredTools := []string{"todoread"}
 	if slices.Contains(ignoredTools, toolCall.ToolInvocation.ToolName) {
 	if slices.Contains(ignoredTools, toolCall.ToolInvocation.ToolName) {
@@ -282,7 +318,7 @@ func renderToolDetails(
 
 
 	if toolCall.ToolInvocation.State == "partial-call" {
 	if toolCall.ToolInvocation.State == "partial-call" {
 		title := renderToolTitle(toolCall, messageMetadata, width)
 		title := renderToolTitle(toolCall, messageMetadata, width)
-		return renderContentBlock(title, width, align)
+		return renderContentBlock(app, title, highlight, width)
 	}
 	}
 
 
 	toolArgsMap := make(map[string]any)
 	toolArgsMap := make(map[string]any)
@@ -301,6 +337,10 @@ func renderToolDetails(
 	body := ""
 	body := ""
 	finished := result != nil && *result != ""
 	finished := result != nil && *result != ""
 	t := theme.CurrentTheme()
 	t := theme.CurrentTheme()
+	backgroundColor := t.BackgroundPanel()
+	if highlight {
+		backgroundColor = t.BackgroundElement()
+	}
 
 
 	switch toolCall.ToolInvocation.ToolName {
 	switch toolCall.ToolInvocation.ToolName {
 	case "read":
 	case "read":
@@ -308,7 +348,7 @@ func renderToolDetails(
 		if preview != nil && toolArgsMap["filePath"] != nil {
 		if preview != nil && toolArgsMap["filePath"] != nil {
 			filename := toolArgsMap["filePath"].(string)
 			filename := toolArgsMap["filePath"].(string)
 			body = preview.(string)
 			body = preview.(string)
-			body = renderFile(filename, body, width, WithTruncate(6))
+			body = util.RenderFile(filename, body, width, util.WithTruncate(6))
 		}
 		}
 	case "edit":
 	case "edit":
 		if filename, ok := toolArgsMap["filePath"].(string); ok {
 		if filename, ok := toolArgsMap["filePath"].(string); ok {
@@ -321,38 +361,28 @@ func renderToolDetails(
 					patch,
 					patch,
 					diff.WithWidth(width-2),
 					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 = 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 != "" {
 				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 := renderToolTitle(toolCall, messageMetadata, width)
-				title = renderContentBlock(title, width, align)
+				title = style.Render(title)
 				content := title + "\n" + body
 				content := title + "\n" + body
+				content = renderContentBlock(app, content, highlight, width, WithPadding(0))
 				return content
 				return content
 			}
 			}
 		}
 		}
 	case "write":
 	case "write":
 		if filename, ok := toolArgsMap["filePath"].(string); ok {
 		if filename, ok := toolArgsMap["filePath"].(string); ok {
 			if content, ok := toolArgsMap["content"].(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 != "" {
 				if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
 					body += "\n\n" + diagnostics
 					body += "\n\n" + diagnostics
 				}
 				}
@@ -363,14 +393,14 @@ func renderToolDetails(
 		if stdout != nil {
 		if stdout != nil {
 			command := toolArgsMap["command"].(string)
 			command := toolArgsMap["command"].(string)
 			body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
 			body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
-			body = toMarkdown(body, width, t.BackgroundPanel())
+			body = util.ToMarkdown(body, width, backgroundColor)
 		}
 		}
 	case "webfetch":
 	case "webfetch":
 		if format, ok := toolArgsMap["format"].(string); ok && result != nil {
 		if format, ok := toolArgsMap["format"].(string); ok && result != nil {
 			body = *result
 			body = *result
-			body = truncateHeight(body, 10)
+			body = util.TruncateHeight(body, 10)
 			if format == "html" || format == "markdown" {
 			if format == "html" || format == "markdown" {
-				body = toMarkdown(body, width, t.BackgroundPanel())
+				body = util.ToMarkdown(body, width, backgroundColor)
 			}
 			}
 		}
 		}
 	case "todowrite":
 	case "todowrite":
@@ -389,7 +419,7 @@ func renderToolDetails(
 					body += fmt.Sprintf("- [ ] %s\n", content)
 					body += fmt.Sprintf("- [ ] %s\n", content)
 				}
 				}
 			}
 			}
-			body = toMarkdown(body, width, t.BackgroundPanel())
+			body = util.ToMarkdown(body, width, backgroundColor)
 		}
 		}
 	case "task":
 	case "task":
 		summary := metadata.JSON.ExtraFields["summary"]
 		summary := metadata.JSON.ExtraFields["summary"]
@@ -424,7 +454,7 @@ func renderToolDetails(
 			result = &empty
 			result = &empty
 		}
 		}
 		body = *result
 		body = *result
-		body = truncateHeight(body, 10)
+		body = util.TruncateHeight(body, 10)
 	}
 	}
 
 
 	error := ""
 	error := ""
@@ -437,18 +467,18 @@ func renderToolDetails(
 	if error != "" {
 	if error != "" {
 		body = styles.NewStyle().
 		body = styles.NewStyle().
 			Foreground(t.Error()).
 			Foreground(t.Error()).
-			Background(t.BackgroundPanel()).
+			Background(backgroundColor).
 			Render(error)
 			Render(error)
 	}
 	}
 
 
 	if body == "" && error == "" && result != nil {
 	if body == "" && error == "" && result != nil {
 		body = *result
 		body = *result
-		body = truncateHeight(body, 10)
+		body = util.TruncateHeight(body, 10)
 	}
 	}
 
 
 	title := renderToolTitle(toolCall, messageMetadata, width)
 	title := renderToolTitle(toolCall, messageMetadata, width)
 	content := title + "\n\n" + body
 	content := title + "\n\n" + body
-	return renderContentBlock(content, width, align)
+	return renderContentBlock(app, content, highlight, width)
 }
 }
 
 
 func renderToolName(name string) string {
 func renderToolName(name string) string {
@@ -505,7 +535,7 @@ func renderToolTitle(
 		title = fmt.Sprintf("%s %s", title, toolArgs)
 		title = fmt.Sprintf("%s %s", title, toolArgs)
 	case "edit", "write":
 	case "edit", "write":
 		if filename, ok := toolArgsMap["filePath"].(string); ok {
 		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":
 	case "bash", "task":
 		if description, ok := toolArgsMap["description"].(string); ok {
 		if description, ok := toolArgsMap["description"].(string); ok {
@@ -551,50 +581,6 @@ func renderToolAction(name string) string {
 	return "Working..."
 	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 {
 func renderArgs(args *map[string]any, titleKey string) string {
 	if args == nil || len(*args) == 0 {
 	if args == nil || len(*args) == 0 {
 		return ""
 		return ""
@@ -614,7 +600,7 @@ func renderArgs(args *map[string]any, titleKey string) string {
 			continue
 			continue
 		}
 		}
 		if key == "filePath" || key == "path" {
 		if key == "filePath" || key == "path" {
-			value = relative(value.(string))
+			value = util.Relative(value.(string))
 		}
 		}
 		if key == titleKey {
 		if key == titleKey {
 			title = fmt.Sprintf("%s", value)
 			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, ", "))
 	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
 // Diagnostic represents an LSP diagnostic
 type Diagnostic struct {
 type Diagnostic struct {
 	Range struct {
 	Range struct {

+ 134 - 82
packages/tui/internal/components/chat/messages.go

@@ -9,7 +9,6 @@ import (
 	"github.com/sst/opencode-sdk-go"
 	"github.com/sst/opencode-sdk-go"
 	"github.com/sst/opencode/internal/app"
 	"github.com/sst/opencode/internal/app"
 	"github.com/sst/opencode/internal/components/dialog"
 	"github.com/sst/opencode/internal/components/dialog"
-	"github.com/sst/opencode/internal/layout"
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/theme"
 	"github.com/sst/opencode/internal/theme"
 	"github.com/sst/opencode/internal/util"
 	"github.com/sst/opencode/internal/util"
@@ -17,73 +16,99 @@ import (
 
 
 type MessagesComponent interface {
 type MessagesComponent interface {
 	tea.Model
 	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)
 	PageUp() (tea.Model, tea.Cmd)
 	PageDown() (tea.Model, tea.Cmd)
 	PageDown() (tea.Model, tea.Cmd)
 	HalfPageUp() (tea.Model, tea.Cmd)
 	HalfPageUp() (tea.Model, tea.Cmd)
 	HalfPageDown() (tea.Model, tea.Cmd)
 	HalfPageDown() (tea.Model, tea.Cmd)
 	First() (tea.Model, tea.Cmd)
 	First() (tea.Model, tea.Cmd)
 	Last() (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
 	ToolDetailsVisible() bool
+	Selected() string
 }
 }
 
 
 type messagesComponent struct {
 type messagesComponent struct {
-	width, height   int
+	width           int
 	app             *app.App
 	app             *app.App
 	viewport        viewport.Model
 	viewport        viewport.Model
-	attachments     viewport.Model
 	cache           *MessageCache
 	cache           *MessageCache
 	rendering       bool
 	rendering       bool
 	showToolDetails bool
 	showToolDetails bool
 	tail            bool
 	tail            bool
+	partCount       int
+	lineCount       int
+	selectedPart    int
+	selectedText    string
 }
 }
 type renderFinishedMsg struct{}
 type renderFinishedMsg struct{}
+type selectedMessagePartChangedMsg struct {
+	part int
+}
+
 type ToggleToolDetailsMsg struct{}
 type ToggleToolDetailsMsg struct{}
 
 
 func (m *messagesComponent) Init() tea.Cmd {
 func (m *messagesComponent) Init() tea.Cmd {
 	return tea.Batch(m.viewport.Init())
 	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) {
 func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	var cmds []tea.Cmd
-	switch msg.(type) {
+	switch msg := msg.(type) {
 	case app.SendMsg:
 	case app.SendMsg:
 		m.viewport.GotoBottom()
 		m.viewport.GotoBottom()
 		m.tail = true
 		m.tail = true
+		m.selectedPart = -1
 		return m, nil
 		return m, nil
 	case app.OptimisticMessageAddedMsg:
 	case app.OptimisticMessageAddedMsg:
-		m.renderView()
+		m.renderView(m.width)
 		if m.tail {
 		if m.tail {
 			m.viewport.GotoBottom()
 			m.viewport.GotoBottom()
 		}
 		}
 		return m, nil
 		return m, nil
 	case dialog.ThemeSelectedMsg:
 	case dialog.ThemeSelectedMsg:
 		m.cache.Clear()
 		m.cache.Clear()
+		m.rendering = true
 		return m, m.Reload()
 		return m, m.Reload()
 	case ToggleToolDetailsMsg:
 	case ToggleToolDetailsMsg:
 		m.showToolDetails = !m.showToolDetails
 		m.showToolDetails = !m.showToolDetails
+		m.rendering = true
 		return m, m.Reload()
 		return m, m.Reload()
-	case app.SessionSelectedMsg:
+	case app.SessionLoadedMsg:
 		m.cache.Clear()
 		m.cache.Clear()
 		m.tail = true
 		m.tail = true
+		m.rendering = true
 		return m, m.Reload()
 		return m, m.Reload()
 	case app.SessionClearedMsg:
 	case app.SessionClearedMsg:
 		m.cache.Clear()
 		m.cache.Clear()
-		cmd := m.Reload()
-		return m, cmd
+		m.rendering = true
+		return m, m.Reload()
 	case renderFinishedMsg:
 	case renderFinishedMsg:
 		m.rendering = false
 		m.rendering = false
 		if m.tail {
 		if m.tail {
 			m.viewport.GotoBottom()
 			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...)
 	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")
 	measure := util.Measure("messages.renderView")
 	defer measure("messageCount", len(m.app.Messages))
 	defer measure("messageCount", len(m.app.Messages))
 
 
 	t := theme.CurrentTheme()
 	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 content string
 		var cached bool
 		var cached bool
-		blocks := make([]string, 0)
 
 
 		switch message.Role {
 		switch message.Role {
 		case opencode.MessageRoleUser:
 		case opencode.MessageRoleUser:
 			for _, part := range message.Parts {
 			for _, part := range message.Parts {
 				switch part := part.AsUnion().(type) {
 				switch part := part.AsUnion().(type) {
 				case opencode.TextPart:
 				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)
 					content, cached = m.cache.Get(key)
 					if !cached {
 					if !cached {
 						content = renderText(
 						content = renderText(
+							m.app,
 							message,
 							message,
 							part.Text,
 							part.Text,
 							m.app.Info.User,
 							m.app.Info.User,
 							m.showToolDetails,
 							m.showToolDetails,
+							m.partCount == m.selectedPart,
 							width,
 							width,
-							align,
 						)
 						)
 						m.cache.Set(key, content)
 						m.cache.Set(key, content)
 					}
 					}
 					if content != "" {
 					if content != "" {
+						if m.selectedPart == m.partCount {
+							m.viewport.SetYOffset(m.lineCount - 4)
+							m.selectedText = part.Text
+						}
 						blocks = append(blocks, content)
 						blocks = append(blocks, content)
+						m.partCount++
+						m.lineCount += lipgloss.Height(content) + 1
 					}
 					}
 				}
 				}
 			}
 			}
@@ -162,33 +188,41 @@ func (m *messagesComponent) renderView() {
 					}
 					}
 
 
 					if finished {
 					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)
 						content, cached = m.cache.Get(key)
 						if !cached {
 						if !cached {
 							content = renderText(
 							content = renderText(
+								m.app,
 								message,
 								message,
 								p.Text,
 								p.Text,
 								message.Metadata.Assistant.ModelID,
 								message.Metadata.Assistant.ModelID,
 								m.showToolDetails,
 								m.showToolDetails,
+								m.partCount == m.selectedPart,
 								width,
 								width,
-								align,
 								toolCallParts...,
 								toolCallParts...,
 							)
 							)
 							m.cache.Set(key, content)
 							m.cache.Set(key, content)
 						}
 						}
 					} else {
 					} else {
 						content = renderText(
 						content = renderText(
+							m.app,
 							message,
 							message,
 							p.Text,
 							p.Text,
 							message.Metadata.Assistant.ModelID,
 							message.Metadata.Assistant.ModelID,
 							m.showToolDetails,
 							m.showToolDetails,
+							m.partCount == m.selectedPart,
 							width,
 							width,
-							align,
 							toolCallParts...,
 							toolCallParts...,
 						)
 						)
 					}
 					}
 					if content != "" {
 					if content != "" {
+						if m.selectedPart == m.partCount {
+							m.viewport.SetYOffset(m.lineCount - 4)
+							m.selectedText = p.Text
+						}
 						blocks = append(blocks, content)
 						blocks = append(blocks, content)
+						m.partCount++
+						m.lineCount += lipgloss.Height(content) + 1
 					}
 					}
 				case opencode.ToolInvocationPart:
 				case opencode.ToolInvocationPart:
 					if !m.showToolDetails {
 					if !m.showToolDetails {
@@ -199,29 +233,38 @@ func (m *messagesComponent) renderView() {
 						key := m.cache.GenerateKey(message.ID,
 						key := m.cache.GenerateKey(message.ID,
 							part.ToolInvocation.ToolCallID,
 							part.ToolInvocation.ToolCallID,
 							m.showToolDetails,
 							m.showToolDetails,
-							layout.Current.Viewport.Width,
+							width,
+							m.partCount == m.selectedPart,
 						)
 						)
 						content, cached = m.cache.Get(key)
 						content, cached = m.cache.Get(key)
 						if !cached {
 						if !cached {
 							content = renderToolDetails(
 							content = renderToolDetails(
+								m.app,
 								part,
 								part,
 								message.Metadata,
 								message.Metadata,
+								m.partCount == m.selectedPart,
 								width,
 								width,
-								align,
 							)
 							)
 							m.cache.Set(key, content)
 							m.cache.Set(key, content)
 						}
 						}
 					} else {
 					} else {
 						// if the tool call isn't finished, don't cache
 						// if the tool call isn't finished, don't cache
 						content = renderToolDetails(
 						content = renderToolDetails(
+							m.app,
 							part,
 							part,
 							message.Metadata,
 							message.Metadata,
+							m.partCount == m.selectedPart,
 							width,
 							width,
-							align,
 						)
 						)
 					}
 					}
 					if content != "" {
 					if content != "" {
+						if m.selectedPart == m.partCount {
+							m.viewport.SetYOffset(m.lineCount - 4)
+							m.selectedText = ""
+						}
 						blocks = append(blocks, content)
 						blocks = append(blocks, content)
+						m.partCount++
+						m.lineCount += lipgloss.Height(content) + 1
 					}
 					}
 				}
 				}
 			}
 			}
@@ -240,41 +283,33 @@ func (m *messagesComponent) renderView() {
 
 
 		if error != "" {
 		if error != "" {
 			error = renderContentBlock(
 			error = renderContentBlock(
+				m.app,
 				error,
 				error,
+				false,
 				width,
 				width,
-				align,
 				WithBorderColor(t.Error()),
 				WithBorderColor(t.Error()),
 			)
 			)
 			blocks = append(blocks, 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 == "" {
 	if m.app.Session.ID == "" {
 		return ""
 		return ""
 	}
 	}
 
 
 	t := theme.CurrentTheme()
 	t := theme.CurrentTheme()
-	width := layout.Current.Container.Width
 	base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
 	base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
 	muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
 	muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
 	headerLines := []string{}
 	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 != "" {
 	if m.app.Session.Share.URL != "" {
 		headerLines = append(headerLines, muted(m.app.Session.Share.URL))
 		headerLines = append(headerLines, muted(m.app.Session.Share.URL))
 	} else {
 	} else {
@@ -297,31 +332,29 @@ func (m *messagesComponent) header() string {
 	return "\n" + header + "\n"
 	return "\n" + header + "\n"
 }
 }
 
 
-func (m *messagesComponent) View() string {
+func (m *messagesComponent) View(width, height int) string {
 	t := theme.CurrentTheme()
 	t := theme.CurrentTheme()
 	if m.rendering {
 	if m.rendering {
 		return lipgloss.Place(
 		return lipgloss.Place(
-			m.width,
-			m.height+1,
+			width,
+			height,
 			lipgloss.Center,
 			lipgloss.Center,
 			lipgloss.Center,
 			lipgloss.Center,
 			styles.NewStyle().Background(t.Background()).Render("Loading session..."),
 			styles.NewStyle().Background(t.Background()).Render("Loading session..."),
 			styles.WhitespaceStyle(t.Background()),
 			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().
 	return styles.NewStyle().
 		Background(t.Background()).
 		Background(t.Background()).
 		Render(header + "\n" + m.viewport.View())
 		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
 		return nil
 	}
 	}
 	// Clear cache on resize since width affects rendering
 	// 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.cache.Clear()
 	}
 	}
 	m.width = width
 	m.width = width
-	m.height = height
 	m.viewport.SetWidth(width)
 	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
 	return nil
 }
 }
 
 
-func (m *messagesComponent) GetSize() (int, int) {
-	return m.width, m.height
-}
-
 func (m *messagesComponent) Reload() tea.Cmd {
 func (m *messagesComponent) Reload() tea.Cmd {
-	m.rendering = true
 	return func() tea.Msg {
 	return func() tea.Msg {
-		m.renderView()
+		m.renderView(m.width)
 		return renderFinishedMsg{}
 		return renderFinishedMsg{}
 	}
 	}
 }
 }
@@ -370,16 +394,45 @@ func (m *messagesComponent) HalfPageDown() (tea.Model, tea.Cmd) {
 	return m, nil
 	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) {
 func (m *messagesComponent) First() (tea.Model, tea.Cmd) {
-	m.viewport.GotoTop()
+	m.selectedPart = 0
 	m.tail = false
 	m.tail = false
-	return m, nil
+	return m, util.CmdHandler(selectedMessagePartChangedMsg{
+		part: m.selectedPart,
+	})
 }
 }
 
 
 func (m *messagesComponent) Last() (tea.Model, tea.Cmd) {
 func (m *messagesComponent) Last() (tea.Model, tea.Cmd) {
-	m.viewport.GotoBottom()
+	m.selectedPart = m.partCount - 1
 	m.tail = true
 	m.tail = true
-	return m, nil
+	return m, util.CmdHandler(selectedMessagePartChangedMsg{
+		part: m.selectedPart,
+	})
 }
 }
 
 
 func (m *messagesComponent) ToolDetailsVisible() bool {
 func (m *messagesComponent) ToolDetailsVisible() bool {
@@ -388,15 +441,14 @@ func (m *messagesComponent) ToolDetailsVisible() bool {
 
 
 func NewMessagesComponent(app *app.App) MessagesComponent {
 func NewMessagesComponent(app *app.App) MessagesComponent {
 	vp := viewport.New()
 	vp := viewport.New()
-	attachments := viewport.New()
 	vp.KeyMap = viewport.KeyMap{}
 	vp.KeyMap = viewport.KeyMap{}
 
 
 	return &messagesComponent{
 	return &messagesComponent{
 		app:             app,
 		app:             app,
 		viewport:        vp,
 		viewport:        vp,
-		attachments:     attachments,
 		showToolDetails: true,
 		showToolDetails: true,
 		cache:           NewMessageCache(),
 		cache:           NewMessageCache(),
 		tail:            true,
 		tail:            true,
+		selectedPart:    -1,
 	}
 	}
 }
 }

+ 0 - 4
packages/tui/internal/components/commands/commands.go

@@ -34,10 +34,6 @@ func (c *commandsComponent) SetSize(width, height int) tea.Cmd {
 	return nil
 	return nil
 }
 }
 
 
-func (c *commandsComponent) GetSize() (int, int) {
-	return c.width, c.height
-}
-
 func (c *commandsComponent) SetBackgroundColor(color compat.AdaptiveColor) {
 func (c *commandsComponent) SetBackgroundColor(color compat.AdaptiveColor) {
 	c.background = &color
 	c.background = &color
 }
 }

+ 0 - 5
packages/tui/internal/components/dialog/complete.go

@@ -41,7 +41,6 @@ func (ci *CompletionItem) Render(selected bool, width int) string {
 	title := itemStyle.Render(
 	title := itemStyle.Render(
 		ci.DisplayValue(),
 		ci.DisplayValue(),
 	)
 	)
-
 	return title
 	return title
 }
 }
 
 
@@ -59,7 +58,6 @@ func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
 
 
 type CompletionProvider interface {
 type CompletionProvider interface {
 	GetId() string
 	GetId() string
-	GetEntry() CompletionItemI
 	GetChildEntries(query string) ([]CompletionItemI, error)
 	GetChildEntries(query string) ([]CompletionItemI, error)
 	GetEmptyMessage() string
 	GetEmptyMessage() string
 }
 }
@@ -175,9 +173,6 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			cmds = append(cmds, c.pseudoSearchTextArea.Focus())
 			cmds = append(cmds, c.pseudoSearchTextArea.Focus())
 			return c, tea.Batch(cmds...)
 			return c, tea.Batch(cmds...)
 		}
 		}
-	case tea.WindowSizeMsg:
-		c.width = msg.Width
-		c.height = msg.Height
 	}
 	}
 
 
 	return c, tea.Batch(cmds...)
 	return c, tea.Batch(cmds...)

+ 235 - 0
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),
+		),
+	}
+}

+ 15 - 44
packages/tui/internal/components/diff/diff.go

@@ -73,44 +73,6 @@ type linePair struct {
 	right *DiffLine
 	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
 // UnifiedConfig configures the rendering of unified diffs
 type UnifiedConfig struct {
 type UnifiedConfig struct {
 	Width int
 	Width int
@@ -122,13 +84,22 @@ type UnifiedOption func(*UnifiedConfig)
 // NewUnifiedConfig creates a UnifiedConfig with default values
 // NewUnifiedConfig creates a UnifiedConfig with default values
 func NewUnifiedConfig(opts ...UnifiedOption) UnifiedConfig {
 func NewUnifiedConfig(opts ...UnifiedOption) UnifiedConfig {
 	config := UnifiedConfig{
 	config := UnifiedConfig{
-		Width: 80, // Default width for unified view
+		Width: 80,
 	}
 	}
-
 	for _, opt := range opts {
 	for _, opt := range opts {
 		opt(&config)
 		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
 	return config
 }
 }
 
 
@@ -907,7 +878,7 @@ func RenderUnifiedHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
 }
 }
 
 
 // RenderSideBySideHunk formats a hunk for side-by-side display
 // 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
 	// Apply options to create the configuration
 	config := NewSideBySideConfig(opts...)
 	config := NewSideBySideConfig(opts...)
 
 
@@ -922,10 +893,10 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
 	pairs := pairLines(hunkCopy.Lines)
 	pairs := pairLines(hunkCopy.Lines)
 
 
 	// Calculate column width
 	// Calculate column width
-	colWidth := config.TotalWidth / 2
+	colWidth := config.Width / 2
 
 
 	leftWidth := colWidth
 	leftWidth := colWidth
-	rightWidth := config.TotalWidth - colWidth
+	rightWidth := config.Width - colWidth
 	var sb strings.Builder
 	var sb strings.Builder
 
 
 	util.WriteStringsPar(&sb, pairs, func(p linePair) string {
 	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
 // 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)
 	diffResult, err := ParseUnifiedDiff(diffText)
 	if err != nil {
 	if err != nil {
 		return "", err
 		return "", err

+ 281 - 0
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()
+}

+ 2 - 2
packages/tui/internal/components/modal/modal.go

@@ -135,11 +135,11 @@ func (m *Modal) Render(contentView string, background string) string {
 	col := (bgWidth - modalWidth) / 2
 	col := (bgWidth - modalWidth) / 2
 
 
 	return layout.PlaceOverlay(
 	return layout.PlaceOverlay(
-		col,
+		col-1, // TODO: whyyyyy
 		row,
 		row,
 		modalView,
 		modalView,
 		background,
 		background,
 		layout.WithOverlayBorder(),
 		layout.WithOverlayBorder(),
-		layout.WithOverlayBorderColor(t.Primary()),
+		layout.WithOverlayBorderColor(t.BorderActive()),
 	)
 	)
 }
 }

+ 2 - 0
packages/tui/internal/config/config.go

@@ -21,6 +21,8 @@ type State struct {
 	Provider           string       `toml:"provider"`
 	Provider           string       `toml:"provider"`
 	Model              string       `toml:"model"`
 	Model              string       `toml:"model"`
 	RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
 	RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
+	MessagesRight      bool         `toml:"messages_right"`
+	SplitDiff          bool         `toml:"split_diff"`
 }
 }
 
 
 func NewState() *State {
 func NewState() *State {

+ 97 - 26
packages/tui/internal/layout/flex.go

@@ -4,7 +4,9 @@ import (
 	"strings"
 	"strings"
 
 
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/lipgloss/v2/compat"
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/styles"
+	"github.com/sst/opencode/internal/theme"
 )
 )
 
 
 type Direction int
 type Direction int
@@ -34,11 +36,13 @@ const (
 )
 )
 
 
 type FlexOptions struct {
 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 {
 type FlexItem struct {
@@ -53,6 +57,12 @@ func Render(opts FlexOptions, items ...FlexItem) string {
 		return ""
 		return ""
 	}
 	}
 
 
+	t := theme.CurrentTheme()
+	if opts.Background == nil {
+		background := t.Background()
+		opts.Background = &background
+	}
+
 	// Calculate dimensions for each item
 	// Calculate dimensions for each item
 	mainAxisSize := opts.Width
 	mainAxisSize := opts.Width
 	crossAxisSize := opts.Height
 	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
 	// Calculate available space for grow items
-	availableSpace := max(mainAxisSize-totalFixedSize, 0)
+	availableSpace := max(mainAxisSize-totalFixedSize-totalGapSize, 0)
 
 
 	// Calculate size for each grow item
 	// Calculate size for each grow item
 	growItemSize := 0
 	growItemSize := 0
@@ -108,6 +124,7 @@ func Render(opts FlexOptions, items ...FlexItem) string {
 			// For row direction, constrain width and handle height alignment
 			// For row direction, constrain width and handle height alignment
 			if itemSize > 0 {
 			if itemSize > 0 {
 				view = styles.NewStyle().
 				view = styles.NewStyle().
+					Background(*opts.Background).
 					Width(itemSize).
 					Width(itemSize).
 					Height(crossAxisSize).
 					Height(crossAxisSize).
 					Render(view)
 					Render(view)
@@ -116,31 +133,65 @@ func Render(opts FlexOptions, items ...FlexItem) string {
 			// Apply cross-axis alignment
 			// Apply cross-axis alignment
 			switch opts.Align {
 			switch opts.Align {
 			case AlignCenter:
 			case AlignCenter:
-				view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Center, view)
+				view = lipgloss.PlaceVertical(
+					crossAxisSize,
+					lipgloss.Center,
+					view,
+					styles.WhitespaceStyle(*opts.Background),
+				)
 			case AlignEnd:
 			case AlignEnd:
-				view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Bottom, view)
+				view = lipgloss.PlaceVertical(
+					crossAxisSize,
+					lipgloss.Bottom,
+					view,
+					styles.WhitespaceStyle(*opts.Background),
+				)
 			case AlignStart:
 			case AlignStart:
-				view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Top, view)
+				view = lipgloss.PlaceVertical(
+					crossAxisSize,
+					lipgloss.Top,
+					view,
+					styles.WhitespaceStyle(*opts.Background),
+				)
 			case AlignStretch:
 			case AlignStretch:
 				// Already stretched by Height setting above
 				// Already stretched by Height setting above
 			}
 			}
 		} else {
 		} else {
 			// For column direction, constrain height and handle width alignment
 			// For column direction, constrain height and handle width alignment
 			if itemSize > 0 {
 			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
 			// Apply cross-axis alignment
 			switch opts.Align {
 			switch opts.Align {
 			case AlignCenter:
 			case AlignCenter:
-				view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Center, view)
+				view = lipgloss.PlaceHorizontal(
+					crossAxisSize,
+					lipgloss.Center,
+					view,
+					styles.WhitespaceStyle(*opts.Background),
+				)
 			case AlignEnd:
 			case AlignEnd:
-				view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Right, view)
+				view = lipgloss.PlaceHorizontal(
+					crossAxisSize,
+					lipgloss.Right,
+					view,
+					styles.WhitespaceStyle(*opts.Background),
+				)
 			case AlignStart:
 			case AlignStart:
-				view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Left, view)
+				view = lipgloss.PlaceHorizontal(
+					crossAxisSize,
+					lipgloss.Left,
+					view,
+					styles.WhitespaceStyle(*opts.Background),
+				)
 			case AlignStretch:
 			case AlignStretch:
 				// Already stretched by Width setting above
 				// 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
 	totalActualSize := 0
 	for _, size := range actualSizes {
 	for _, size := range actualSizes {
 		totalActualSize += size
 		totalActualSize += size
 	}
 	}
+	if len(items) > 1 && opts.Gap > 0 {
+		totalActualSize += opts.Gap * (len(items) - 1)
+	}
 
 
 	// Apply justification
 	// Apply justification
 	remainingSpace := max(mainAxisSize-totalActualSize, 0)
 	remainingSpace := max(mainAxisSize-totalActualSize, 0)
@@ -191,12 +245,17 @@ func Render(opts FlexOptions, items ...FlexItem) string {
 	// Build the final layout
 	// Build the final layout
 	var parts []string
 	var parts []string
 
 
+	spaceStyle := styles.NewStyle().Background(*opts.Background)
 	// Add space before if needed
 	// Add space before if needed
 	if spaceBefore > 0 {
 	if spaceBefore > 0 {
 		if opts.Direction == Row {
 		if opts.Direction == Row {
-			parts = append(parts, strings.Repeat(" ", spaceBefore))
+			space := strings.Repeat(" ", spaceBefore)
+			parts = append(parts, spaceStyle.Render(space))
 		} else {
 		} 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)
 		parts = append(parts, view)
 
 
 		// Add space between items (not after the last one)
 		// 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
 	// Add space after if needed
 	if spaceAfter > 0 {
 	if spaceAfter > 0 {
 		if opts.Direction == Row {
 		if opts.Direction == Row {
-			parts = append(parts, strings.Repeat(" ", spaceAfter))
+			space := strings.Repeat(" ", spaceAfter)
+			parts = append(parts, spaceStyle.Render(space))
 		} else {
 		} else {
-			parts = append(parts, strings.Repeat("\n", spaceAfter))
+			// For vertical layout, add empty lines as separate parts
+			for range spaceAfter {
+				parts = append(parts, "")
+			}
 		}
 		}
 	}
 	}
 
 

+ 41 - 0
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
+}

+ 90 - 0
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)
+			}
+		})
+	}
+}

+ 303 - 102
packages/tui/internal/tui/tui.go

@@ -19,6 +19,7 @@ import (
 	"github.com/sst/opencode/internal/components/chat"
 	"github.com/sst/opencode/internal/components/chat"
 	cmdcomp "github.com/sst/opencode/internal/components/commands"
 	cmdcomp "github.com/sst/opencode/internal/components/commands"
 	"github.com/sst/opencode/internal/components/dialog"
 	"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/modal"
 	"github.com/sst/opencode/internal/components/status"
 	"github.com/sst/opencode/internal/components/status"
 	"github.com/sst/opencode/internal/components/toast"
 	"github.com/sst/opencode/internal/components/toast"
@@ -40,6 +41,7 @@ const (
 )
 )
 
 
 const interruptDebounceTimeout = 1 * time.Second
 const interruptDebounceTimeout = 1 * time.Second
+const fileViewerFullWidthCutoff = 200
 
 
 type appModel struct {
 type appModel struct {
 	width, height        int
 	width, height        int
@@ -56,6 +58,12 @@ type appModel struct {
 	toastManager         *toast.ToastManager
 	toastManager         *toast.ToastManager
 	interruptKeyState    InterruptKeyState
 	interruptKeyState    InterruptKeyState
 	lastScroll           time.Time
 	lastScroll           time.Time
+	messagesRight        bool
+	fileViewer           fileviewer.Model
+	lastMouse            tea.Mouse
+	fileViewerStart      int
+	fileViewerEnd        int
+	fileViewerHit        bool
 }
 }
 
 
 func (a appModel) Init() tea.Cmd {
 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.status.Init())
 	cmds = append(cmds, a.completions.Init())
 	cmds = append(cmds, a.completions.Init())
 	cmds = append(cmds, a.toastManager.Init())
 	cmds = append(cmds, a.toastManager.Init())
+	cmds = append(cmds, a.fileViewer.Init())
 
 
 	// Check if we should show the init dialog
 	// Check if we should show the init dialog
 	cmds = append(cmds, func() tea.Msg {
 	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) {
 func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmd tea.Cmd
 	var cmds []tea.Cmd
 	var cmds []tea.Cmd
 
 
 	switch msg := msg.(type) {
 	switch msg := msg.(type) {
@@ -112,10 +122,20 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if a.modal != nil {
 		if a.modal != nil {
 			switch keyString {
 			switch keyString {
 			// Escape always closes current modal
 			// Escape always closes current modal
-			case "esc", "ctrl+c":
+			case "esc":
 				cmd := a.modal.Close()
 				cmd := a.modal.Close()
 				a.modal = nil
 				a.modal = nil
 				return a, cmd
 				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
 			// 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 {
 		if a.modal != nil {
 			return a, 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...)
 		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:
 	case tea.BackgroundColorMsg:
 		styles.Terminal = &styles.TerminalInfo{
 		styles.Terminal = &styles.TerminalInfo{
 			Background:       msg.Color,
 			Background:       msg.Color,
@@ -266,6 +304,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 			}
 		}
 		}
 	case modal.CloseModalMsg:
 	case modal.CloseModalMsg:
+		a.editor.Focus()
 		var cmd tea.Cmd
 		var cmd tea.Cmd
 		if a.modal != nil {
 		if a.modal != nil {
 			cmd = a.modal.Close()
 			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)
 			slog.Error("Server error", "name", err.Name, "message", err.Data.Message)
 			return a, toast.NewErrorToast(err.Data.Message, toast.WithTitle(string(err.Name)))
 			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:
 	case tea.WindowSizeMsg:
 		msg.Height -= 2 // Make space for the status bar
 		msg.Height -= 2 // Make space for the status bar
 		a.width, a.height = msg.Width, msg.Height
 		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{
 		layout.Current = &layout.LayoutInfo{
 			Viewport: layout.Dimensions{
 			Viewport: layout.Dimensions{
 				Width:  a.width,
 				Width:  a.width,
 				Height: a.height,
 				Height: a.height,
 			},
 			},
 			Container: layout.Dimensions{
 			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:
 	case app.SessionSelectedMsg:
 		messages, err := a.app.ListMessages(context.Background(), msg.ID)
 		messages, err := a.app.ListMessages(context.Background(), msg.ID)
 		if err != nil {
 		if err != nil {
@@ -373,6 +437,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 		}
 		a.app.Session = msg
 		a.app.Session = msg
 		a.app.Messages = messages
 		a.app.Messages = messages
+		return a, util.CmdHandler(app.SessionLoadedMsg{})
 	case app.ModelSelectedMsg:
 	case app.ModelSelectedMsg:
 		a.app.Provider = &msg.Provider
 		a.app.Provider = &msg.Provider
 		a.app.Model = &msg.Model
 		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
 		// Reset interrupt key state after timeout
 		a.interruptKeyState = InterruptKeyIdle
 		a.interruptKeyState = InterruptKeyIdle
 		a.editor.SetInterruptKeyInDebounce(false)
 		a.editor.SetInterruptKeyInDebounce(false)
+	case dialog.FindSelectedMsg:
+		return a.openFile(msg.FilePath)
 	}
 	}
 
 
-	// update status bar
 	s, cmd := a.status.Update(msg)
 	s, cmd := a.status.Update(msg)
 	cmds = append(cmds, cmd)
 	cmds = append(cmds, cmd)
 	a.status = s.(status.StatusComponent)
 	a.status = s.(status.StatusComponent)
 
 
-	// update editor
 	u, cmd := a.editor.Update(msg)
 	u, cmd := a.editor.Update(msg)
 	a.editor = u.(chat.EditorComponent)
 	a.editor = u.(chat.EditorComponent)
 	cmds = append(cmds, cmd)
 	cmds = append(cmds, cmd)
 
 
-	// update messages
 	u, cmd = a.messages.Update(msg)
 	u, cmd = a.messages.Update(msg)
 	a.messages = u.(chat.MessagesComponent)
 	a.messages = u.(chat.MessagesComponent)
 	cmds = append(cmds, cmd)
 	cmds = append(cmds, cmd)
 
 
-	// update modal
 	if a.modal != nil {
 	if a.modal != nil {
 		u, cmd := a.modal.Update(msg)
 		u, cmd := a.modal.Update(msg)
 		a.modal = u.(layout.Modal)
 		a.modal = u.(layout.Modal)
@@ -425,86 +488,95 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		cmds = append(cmds, cmd)
 		cmds = append(cmds, cmd)
 	}
 	}
 
 
+	fv, cmd := a.fileViewer.Update(msg)
+	a.fileViewer = fv
+	cmds = append(cmds, cmd)
+
 	return a, tea.Batch(cmds...)
 	return a, tea.Batch(cmds...)
 }
 }
 
 
 func (a appModel) View() string {
 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 {
 	if a.modal != nil {
 		mainLayout = a.modal.Render(mainLayout)
 		mainLayout = a.modal.Render(mainLayout)
 	}
 	}
 	mainLayout = a.toastManager.RenderOverlay(mainLayout)
 	mainLayout = a.toastManager.RenderOverlay(mainLayout)
+
 	if theme.CurrentThemeUsesAnsiColors() {
 	if theme.CurrentThemeUsesAnsiColors() {
 		mainLayout = util.ConvertRGBToAnsi16Colors(mainLayout)
 		mainLayout = util.ConvertRGBToAnsi16Colors(mainLayout)
 	}
 	}
 	return mainLayout + "\n" + a.status.View()
 	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()
 	t := theme.CurrentTheme()
 	baseStyle := styles.NewStyle().Background(t.Background())
 	baseStyle := styles.NewStyle().Background(t.Background())
 	base := baseStyle.Render
 	base := baseStyle.Render
@@ -536,7 +608,7 @@ func (a appModel) home() string {
 
 
 	logoAndVersion := strings.Join([]string{logo, version}, "\n")
 	logoAndVersion := strings.Join([]string{logo, version}, "\n")
 	logoAndVersion = lipgloss.PlaceHorizontal(
 	logoAndVersion = lipgloss.PlaceHorizontal(
-		a.width,
+		width,
 		lipgloss.Center,
 		lipgloss.Center,
 		logoAndVersion,
 		logoAndVersion,
 		styles.WhitespaceStyle(t.Background()),
 		styles.WhitespaceStyle(t.Background()),
@@ -547,13 +619,15 @@ func (a appModel) home() string {
 		cmdcomp.WithLimit(6),
 		cmdcomp.WithLimit(6),
 	)
 	)
 	cmds := lipgloss.PlaceHorizontal(
 	cmds := lipgloss.PlaceHorizontal(
-		a.width,
+		width,
 		lipgloss.Center,
 		lipgloss.Center,
 		commandsView.View(),
 		commandsView.View(),
 		styles.WhitespaceStyle(t.Background()),
 		styles.WhitespaceStyle(t.Background()),
 	)
 	)
 
 
 	lines := []string{}
 	lines := []string{}
+	lines = append(lines, "")
+	lines = append(lines, "")
 	lines = append(lines, logoAndVersion)
 	lines = append(lines, logoAndVersion)
 	lines = append(lines, "")
 	lines = append(lines, "")
 	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, base("config ")+muted(config))
 	// lines = append(lines, "")
 	// lines = append(lines, "")
 	lines = append(lines, cmds)
 	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,
 		lipgloss.Center,
 		lipgloss.Center,
 		baseStyle.Render(strings.Join(lines, "\n")),
 		baseStyle.Render(strings.Join(lines, "\n")),
 		styles.WhitespaceStyle(t.Background()),
 		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) {
 func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
+	var cmd tea.Cmd
 	cmds := []tea.Cmd{
 	cmds := []tea.Cmd{
 		util.CmdHandler(commands.CommandExecutedMsg(command)),
 		util.CmdHandler(commands.CommandExecutedMsg(command)),
 	}
 	}
@@ -676,6 +832,22 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
 	case commands.ThemeListCommand:
 	case commands.ThemeListCommand:
 		themeDialog := dialog.NewThemeDialog()
 		themeDialog := dialog.NewThemeDialog()
 		a.modal = themeDialog
 		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:
 	case commands.ProjectInitCommand:
 		cmds = append(cmds, a.app.InitializeProject(context.Background()))
 		cmds = append(cmds, a.app.InitializeProject(context.Background()))
 	case commands.InputClearCommand:
 	case commands.InputClearCommand:
@@ -697,20 +869,6 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
 		updated, cmd := a.editor.Newline()
 		updated, cmd := a.editor.Newline()
 		a.editor = updated.(chat.EditorComponent)
 		a.editor = updated.(chat.EditorComponent)
 		cmds = append(cmds, cmd)
 		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:
 	case commands.MessagesFirstCommand:
 		updated, cmd := a.messages.First()
 		updated, cmd := a.messages.First()
 		a.messages = updated.(chat.MessagesComponent)
 		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)
 		a.messages = updated.(chat.MessagesComponent)
 		cmds = append(cmds, cmd)
 		cmds = append(cmds, cmd)
 	case commands.MessagesPageUpCommand:
 	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:
 	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:
 	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)
 		a.messages = updated.(chat.MessagesComponent)
 		cmds = append(cmds, cmd)
 		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)
 		a.messages = updated.(chat.MessagesComponent)
 		cmds = append(cmds, cmd)
 		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:
 	case commands.AppExitCommand:
 		return a, tea.Quit
 		return a, tea.Quit
 	}
 	}
@@ -776,6 +975,8 @@ func NewModel(app *app.App) tea.Model {
 		showCompletionDialog: false,
 		showCompletionDialog: false,
 		toastManager:         toast.NewToastManager(),
 		toastManager:         toast.NewToastManager(),
 		interruptKeyState:    InterruptKeyIdle,
 		interruptKeyState:    InterruptKeyIdle,
+		fileViewer:           fileviewer.New(app),
+		messagesRight:        app.State.MessagesRight,
 	}
 	}
 
 
 	return model
 	return model

+ 109 - 0
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")
+}

+ 8 - 1
stainless.yml

@@ -51,9 +51,16 @@ resources:
       get: get /app
       get: get /app
       init: post /app/init
       init: post /app/init
 
 
+  find:
+    methods:
+      text: get /find
+      files: get /find/file
+      symbols: get /find/symbol
+
   file:
   file:
     methods:
     methods:
-      search: get /file
+      read: get /file
+      status: get /file/status
 
 
   config:
   config:
     models:
     models: