feat(tui): file viewer, select messages

This commit is contained in:
adamdottv 2025-07-02 16:08:06 -05:00
parent 63e783ef79
commit c82a060eca
No known key found for this signature in database
GPG key ID: 9CB48779AF150E75
24 changed files with 1720 additions and 573 deletions

View file

@ -32,7 +32,7 @@ export namespace Ripgrep {
}),
})
const Match = z.object({
export const Match = z.object({
type: z.literal("match"),
data: z.object({
path: z.object({

View file

@ -14,6 +14,8 @@ import { NamedError } from "../util/error"
import { ModelsDev } from "../provider/models"
import { Ripgrep } from "../file/ripgrep"
import { Config } from "../config/config"
import { File } from "../file"
import { LSP } from "../lsp"
const ERRORS = {
400: {
@ -73,7 +75,7 @@ export namespace Server {
documentation: {
info: {
title: "opencode",
version: "0.0.2",
version: "0.0.3",
description: "opencode api",
},
openapi: "3.0.0",
@ -492,12 +494,44 @@ export namespace Server {
},
)
.get(
"/file",
"/find",
describeRoute({
description: "Search for files",
description: "Find text in files",
responses: {
200: {
description: "Search for files",
description: "Matches",
content: {
"application/json": {
schema: resolver(Ripgrep.Match.shape.data.array()),
},
},
},
},
}),
zValidator(
"query",
z.object({
pattern: z.string(),
}),
),
async (c) => {
const app = App.info()
const pattern = c.req.valid("query").pattern
const result = await Ripgrep.search({
cwd: app.path.cwd,
pattern,
limit: 10,
})
return c.json(result)
},
)
.get(
"/find/file",
describeRoute({
description: "Find files",
responses: {
200: {
description: "File paths",
content: {
"application/json": {
schema: resolver(z.string().array()),
@ -523,6 +557,98 @@ export namespace Server {
return c.json(result)
},
)
.get(
"/find/symbol",
describeRoute({
description: "Find workspace symbols",
responses: {
200: {
description: "Symbols",
content: {
"application/json": {
schema: resolver(z.unknown().array()),
},
},
},
},
}),
zValidator(
"query",
z.object({
query: z.string(),
}),
),
async (c) => {
const query = c.req.valid("query").query
const result = await LSP.workspaceSymbol(query)
return c.json(result)
},
)
.get(
"/file",
describeRoute({
description: "Read a file",
responses: {
200: {
description: "File content",
content: {
"application/json": {
schema: resolver(
z.object({
type: z.enum(["raw", "patch"]),
content: z.string(),
}),
),
},
},
},
},
}),
zValidator(
"query",
z.object({
path: z.string(),
}),
),
async (c) => {
const path = c.req.valid("query").path
const content = await File.read(path)
log.info("read file", {
path,
content: content.content,
})
return c.json(content)
},
)
.get(
"/file/status",
describeRoute({
description: "Get file status",
responses: {
200: {
description: "File status",
content: {
"application/json": {
schema: resolver(
z
.object({
file: z.string(),
added: z.number().int(),
removed: z.number().int(),
status: z.enum(["added", "deleted", "modified"]),
})
.array(),
),
},
},
},
},
}),
async (c) => {
const content = await File.status()
return c.json(content)
},
)
return result
}

View file

@ -15,7 +15,7 @@ require (
github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.16.0
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
github.com/sst/opencode-sdk-go v0.1.0-alpha.7
github.com/sst/opencode-sdk-go v0.1.0-alpha.8
github.com/tidwall/gjson v1.14.4
rsc.io/qr v0.2.0
)

View file

@ -181,8 +181,8 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/sst/opencode-sdk-go v0.1.0-alpha.7 h1:trfzTMn9o/h2fxE4z+BtJPZvCTdVHjwgXnAH/rTAx0I=
github.com/sst/opencode-sdk-go v0.1.0-alpha.7/go.mod h1:uagorfAHZsVy6vf0xY6TlQraM4uCILdZ5tKKhl1oToM=
github.com/sst/opencode-sdk-go v0.1.0-alpha.8 h1:Tp7nbckbMCwAA/ieVZeeZCp79xXtrPMaWLRk5mhNwrw=
github.com/sst/opencode-sdk-go v0.1.0-alpha.8/go.mod h1:uagorfAHZsVy6vf0xY6TlQraM4uCILdZ5tKKhl1oToM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=

View file

@ -20,9 +20,6 @@ import (
"github.com/sst/opencode/internal/util"
)
var RootPath string
var CwdPath string
type App struct {
Info opencode.App
Version string
@ -38,6 +35,7 @@ type App struct {
}
type SessionSelectedMsg = *opencode.Session
type SessionLoadedMsg struct{}
type ModelSelectedMsg struct {
Provider opencode.Provider
Model opencode.Model
@ -54,6 +52,9 @@ type CompletionDialogTriggeredMsg struct {
type OptimisticMessageAddedMsg struct {
Message opencode.Message
}
type FileRenderedMsg struct {
FilePath string
}
func New(
ctx context.Context,
@ -61,8 +62,8 @@ func New(
appInfo opencode.App,
httpClient *opencode.Client,
) (*App, error) {
RootPath = appInfo.Path.Root
CwdPath = appInfo.Path.Cwd
util.RootPath = appInfo.Path.Root
util.CwdPath = appInfo.Path.Cwd
configInfo, err := httpClient.Config.Get(ctx)
if err != nil {
@ -125,6 +126,19 @@ func New(
return app, nil
}
func (a *App) Key(commandName commands.CommandName) string {
t := theme.CurrentTheme()
base := styles.NewStyle().Background(t.Background()).Foreground(t.Text()).Bold(true).Render
muted := styles.NewStyle().Background(t.Background()).Foreground(t.TextMuted()).Faint(true).Render
command := a.Commands[commandName]
kb := command.Keybindings[0]
key := kb.Key
if kb.RequiresLeader {
key = a.Config.Keybinds.Leader + " " + kb.Key
}
return base(key) + muted(" "+command.Description)
}
func (a *App) InitializeProvider() tea.Cmd {
return func() tea.Msg {
providersResponse, err := a.Client.Config.Providers(context.Background())

View file

@ -80,13 +80,15 @@ const (
ToolDetailsCommand CommandName = "tool_details"
ModelListCommand CommandName = "model_list"
ThemeListCommand CommandName = "theme_list"
FileListCommand CommandName = "file_list"
FileCloseCommand CommandName = "file_close"
FileSearchCommand CommandName = "file_search"
FileDiffToggleCommand CommandName = "file_diff_toggle"
ProjectInitCommand CommandName = "project_init"
InputClearCommand CommandName = "input_clear"
InputPasteCommand CommandName = "input_paste"
InputSubmitCommand CommandName = "input_submit"
InputNewlineCommand CommandName = "input_newline"
HistoryPreviousCommand CommandName = "history_previous"
HistoryNextCommand CommandName = "history_next"
MessagesPageUpCommand CommandName = "messages_page_up"
MessagesPageDownCommand CommandName = "messages_page_down"
MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
@ -95,6 +97,9 @@ const (
MessagesNextCommand CommandName = "messages_next"
MessagesFirstCommand CommandName = "messages_first"
MessagesLastCommand CommandName = "messages_last"
MessagesLayoutToggleCommand CommandName = "messages_layout_toggle"
MessagesCopyCommand CommandName = "messages_copy"
MessagesRevertCommand CommandName = "messages_revert"
AppExitCommand CommandName = "app_exit"
)
@ -184,6 +189,27 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Keybindings: parseBindings("<leader>t"),
Trigger: "themes",
},
{
Name: FileListCommand,
Description: "list files",
Keybindings: parseBindings("<leader>f"),
Trigger: "files",
},
{
Name: FileCloseCommand,
Description: "close file",
Keybindings: parseBindings("esc"),
},
{
Name: FileSearchCommand,
Description: "search file",
Keybindings: parseBindings("<leader>/"),
},
{
Name: FileDiffToggleCommand,
Description: "split/unified diff",
Keybindings: parseBindings("<leader>v"),
},
{
Name: ProjectInitCommand,
Description: "create/update AGENTS.md",
@ -210,16 +236,6 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Description: "insert newline",
Keybindings: parseBindings("shift+enter", "ctrl+j"),
},
// {
// Name: HistoryPreviousCommand,
// Description: "previous prompt",
// Keybindings: parseBindings("up"),
// },
// {
// Name: HistoryNextCommand,
// Description: "next prompt",
// Keybindings: parseBindings("down"),
// },
{
Name: MessagesPageUpCommand,
Description: "page up",
@ -243,12 +259,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
{
Name: MessagesPreviousCommand,
Description: "previous message",
Keybindings: parseBindings("ctrl+alt+k"),
Keybindings: parseBindings("ctrl+up"),
},
{
Name: MessagesNextCommand,
Description: "next message",
Keybindings: parseBindings("ctrl+alt+j"),
Keybindings: parseBindings("ctrl+down"),
},
{
Name: MessagesFirstCommand,
@ -260,6 +276,21 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Description: "last message",
Keybindings: parseBindings("ctrl+alt+g"),
},
{
Name: MessagesLayoutToggleCommand,
Description: "toggle layout",
Keybindings: parseBindings("<leader>m"),
},
{
Name: MessagesCopyCommand,
Description: "copy message",
Keybindings: parseBindings("<leader>y"),
},
{
Name: MessagesRevertCommand,
Description: "revert message",
Keybindings: parseBindings("<leader>u"),
},
{
Name: AppExitCommand,
Description: "exit the app",

View file

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

View file

@ -2,64 +2,108 @@ package completions
import (
"context"
"log/slog"
"sort"
"strconv"
"strings"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type filesAndFoldersContextGroup struct {
app *app.App
prefix string
gitFiles []dialog.CompletionItemI
}
func (cg *filesAndFoldersContextGroup) GetId() string {
return cg.prefix
}
func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
return dialog.NewCompletionItem(dialog.CompletionItem{
Title: "Files & Folders",
Value: "files",
})
}
func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
return "no matching files"
}
func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) {
files, err := cg.app.Client.File.Search(
context.Background(),
opencode.FileSearchParams{Query: opencode.F(query)},
)
if err != nil {
return []string{}, err
func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI {
t := theme.CurrentTheme()
items := make([]dialog.CompletionItemI, 0)
base := styles.NewStyle().Background(t.BackgroundElement())
green := base.Foreground(t.Success()).Render
red := base.Foreground(t.Error()).Render
status, _ := cg.app.Client.File.Status(context.Background())
if status != nil {
files := *status
sort.Slice(files, func(i, j int) bool {
return files[i].Added+files[i].Removed > files[j].Added+files[j].Removed
})
for _, file := range files {
title := file.File
if file.Added > 0 {
title += green(" +" + strconv.Itoa(int(file.Added)))
}
return *files, nil
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 items
}
func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
matches, err := cg.getFiles(query)
if err != nil {
return nil, err
items := make([]dialog.CompletionItemI, 0)
query = strings.TrimSpace(query)
if query == "" {
items = append(items, cg.gitFiles...)
}
items := make([]dialog.CompletionItemI, 0, len(matches))
for _, file := range matches {
files, err := cg.app.Client.Find.Files(
context.Background(),
opencode.FindFilesParams{Query: opencode.F(query)},
)
if err != nil {
slog.Error("Failed to get completion items", "error", err)
}
for _, file := range *files {
exists := false
for _, existing := range cg.gitFiles {
if existing.GetValue() == file {
if query != "" {
items = append(items, existing)
}
exists = true
}
}
if !exists {
item := dialog.NewCompletionItem(dialog.CompletionItem{
Title: file,
Value: file,
})
items = append(items, item)
}
}
return items, nil
}
func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
return &filesAndFoldersContextGroup{
cg := &filesAndFoldersContextGroup{
app: app,
prefix: "file",
}
cg.gitFiles = cg.getGitFiles()
return cg
}

View file

@ -13,7 +13,6 @@ import (
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/textarea"
"github.com/sst/opencode/internal/image"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
@ -21,10 +20,8 @@ import (
type EditorComponent interface {
tea.Model
// tea.ViewModel
SetSize(width, height int) tea.Cmd
View(width int, align lipgloss.Position) string
Content(width int, align lipgloss.Position) string
View(width int) string
Content(width int) string
Lines() int
Value() string
Focused() bool
@ -34,19 +31,13 @@ type EditorComponent interface {
Clear() (tea.Model, tea.Cmd)
Paste() (tea.Model, tea.Cmd)
Newline() (tea.Model, tea.Cmd)
Previous() (tea.Model, tea.Cmd)
Next() (tea.Model, tea.Cmd)
SetInterruptKeyInDebounce(inDebounce bool)
}
type editorComponent struct {
app *app.App
width, height int
textarea textarea.Model
attachments []app.Attachment
history []string
historyIndex int
currentMessage string
spinner spinner.Model
interruptKeyInDebounce bool
}
@ -106,7 +97,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
func (m *editorComponent) Content(width int, align lipgloss.Position) string {
func (m *editorComponent) Content(width int) string {
t := theme.CurrentTheme()
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
@ -115,6 +106,7 @@ func (m *editorComponent) Content(width int, align lipgloss.Position) string {
Bold(true)
prompt := promptStyle.Render(">")
m.textarea.SetWidth(width - 6)
textarea := lipgloss.JoinHorizontal(
lipgloss.Top,
prompt,
@ -147,7 +139,7 @@ func (m *editorComponent) Content(width int, align lipgloss.Position) string {
model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
}
space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
info := hint + spacer + model
@ -157,19 +149,18 @@ func (m *editorComponent) Content(width int, align lipgloss.Position) string {
return content
}
func (m *editorComponent) View(width int, align lipgloss.Position) string {
func (m *editorComponent) View(width int) string {
if m.Lines() > 1 {
t := theme.CurrentTheme()
return lipgloss.Place(
width,
m.height,
align,
5,
lipgloss.Center,
lipgloss.Center,
"",
styles.WhitespaceStyle(t.Background()),
styles.WhitespaceStyle(theme.CurrentTheme().Background()),
)
}
return m.Content(width, align)
return m.Content(width)
}
func (m *editorComponent) Focused() bool {
@ -184,16 +175,6 @@ func (m *editorComponent) Blur() {
m.textarea.Blur()
}
func (m *editorComponent) GetSize() (width, height int) {
return m.width, m.height
}
func (m *editorComponent) SetSize(width, height int) tea.Cmd {
m.width = width
m.height = height
return nil
}
func (m *editorComponent) Lines() int {
return m.textarea.LineCount()
}
@ -219,16 +200,6 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
attachments := m.attachments
// Save to history if not empty and not a duplicate of the last entry
if value != "" {
if len(m.history) == 0 || m.history[len(m.history)-1] != value {
m.history = append(m.history, value)
}
m.historyIndex = len(m.history)
m.currentMessage = ""
}
m.attachments = nil
cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments}))
@ -261,48 +232,6 @@ func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
return m, nil
}
func (m *editorComponent) Previous() (tea.Model, tea.Cmd) {
currentLine := m.textarea.Line()
// Only navigate history if we're at the first line
if currentLine == 0 && len(m.history) > 0 {
// Save current message if we're just starting to navigate
if m.historyIndex == len(m.history) {
m.currentMessage = m.textarea.Value()
}
// Go to previous message in history
if m.historyIndex > 0 {
m.historyIndex--
m.textarea.SetValue(m.history[m.historyIndex])
}
return m, nil
}
return m, nil
}
func (m *editorComponent) Next() (tea.Model, tea.Cmd) {
currentLine := m.textarea.Line()
value := m.textarea.Value()
lines := strings.Split(value, "\n")
totalLines := len(lines)
// Only navigate history if we're at the last line
if currentLine == totalLines-1 {
if m.historyIndex < len(m.history)-1 {
// Go to next message in history
m.historyIndex++
m.textarea.SetValue(m.history[m.historyIndex])
} else if m.historyIndex == len(m.history)-1 {
// Return to the current message being composed
m.historyIndex = len(m.history)
m.textarea.SetValue(m.currentMessage)
}
return m, nil
}
return m, nil
}
func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
m.interruptKeyInDebounce = inDebounce
}
@ -336,7 +265,6 @@ func createTextArea(existing *textarea.Model) textarea.Model {
ta.Prompt = " "
ta.ShowLineNumbers = false
ta.CharLimit = -1
ta.SetWidth(layout.Current.Container.Width - 6)
if existing != nil {
ta.SetValue(existing.Value())
@ -368,9 +296,6 @@ func NewEditorComponent(app *app.App) EditorComponent {
return &editorComponent{
app: app,
textarea: ta,
history: []string{},
historyIndex: 0,
currentMessage: "",
spinner: s,
interruptKeyInDebounce: false,
}

View file

@ -3,55 +3,30 @@ package chat
import (
"encoding/json"
"fmt"
"path/filepath"
"slices"
"strings"
"time"
"unicode"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/charmbracelet/x/ansi"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/diff"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"github.com/tidwall/gjson"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
func toMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string {
r := styles.GetMarkdownRenderer(width-7, backgroundColor)
content = strings.ReplaceAll(content, app.RootPath+"/", "")
rendered, _ := r.Render(content)
lines := strings.Split(rendered, "\n")
if len(lines) > 0 {
firstLine := lines[0]
cleaned := ansi.Strip(firstLine)
nospace := strings.ReplaceAll(cleaned, " ", "")
if nospace == "" {
lines = lines[1:]
}
if len(lines) > 0 {
lastLine := lines[len(lines)-1]
cleaned = ansi.Strip(lastLine)
nospace = strings.ReplaceAll(cleaned, " ", "")
if nospace == "" {
lines = lines[:len(lines)-1]
}
}
}
content = strings.Join(lines, "\n")
return strings.TrimSuffix(content, "\n")
}
type blockRenderer struct {
textColor compat.AdaptiveColor
border bool
borderColor *compat.AdaptiveColor
borderColorRight bool
paddingTop int
paddingBottom int
paddingLeft int
@ -62,6 +37,12 @@ type blockRenderer struct {
type renderingOption func(*blockRenderer)
func WithTextColor(color compat.AdaptiveColor) renderingOption {
return func(c *blockRenderer) {
c.textColor = color
}
}
func WithNoBorder() renderingOption {
return func(c *blockRenderer) {
c.border = false
@ -74,6 +55,13 @@ func WithBorderColor(color compat.AdaptiveColor) renderingOption {
}
}
func WithBorderColorRight(color compat.AdaptiveColor) renderingOption {
return func(c *blockRenderer) {
c.borderColorRight = true
c.borderColor = &color
}
}
func WithMarginTop(padding int) renderingOption {
return func(c *blockRenderer) {
c.marginTop = padding
@ -120,13 +108,15 @@ func WithPaddingBottom(padding int) renderingOption {
}
func renderContentBlock(
app *app.App,
content string,
highlight bool,
width int,
align lipgloss.Position,
options ...renderingOption,
) string {
t := theme.CurrentTheme()
renderer := &blockRenderer{
textColor: t.TextMuted(),
border: true,
paddingTop: 1,
paddingBottom: 1,
@ -143,7 +133,7 @@ func renderContentBlock(
}
style := styles.NewStyle().
Foreground(t.TextMuted()).
Foreground(renderer.textColor).
Background(t.BackgroundPanel()).
Width(width).
PaddingTop(renderer.paddingTop).
@ -161,21 +151,32 @@ func renderContentBlock(
BorderLeftBackground(t.Background()).
BorderRightForeground(t.BackgroundPanel()).
BorderRightBackground(t.Background())
if renderer.borderColorRight {
style = style.
BorderLeftBackground(t.Background()).
BorderLeftForeground(t.BackgroundPanel()).
BorderRightForeground(borderColor).
BorderRightBackground(t.Background())
}
if highlight {
style = style.
BorderLeftBackground(t.Primary()).
BorderLeftForeground(t.Primary()).
BorderRightForeground(t.Primary()).
BorderRightBackground(t.Primary())
}
}
if highlight {
style = style.
Foreground(t.Text()).
Bold(true).
Background(t.BackgroundElement())
}
content = style.Render(content)
content = lipgloss.PlaceHorizontal(
width,
lipgloss.Left,
content,
styles.WhitespaceStyle(t.Background()),
)
content = lipgloss.PlaceHorizontal(
layout.Current.Viewport.Width,
align,
content,
styles.WhitespaceStyle(t.Background()),
)
if renderer.marginTop > 0 {
for range renderer.marginTop {
content = "\n" + content
@ -186,16 +187,44 @@ func renderContentBlock(
content = content + "\n"
}
}
if highlight {
copy := app.Key(commands.MessagesCopyCommand)
// revert := app.Key(commands.MessagesRevertCommand)
background := t.Background()
header := layout.Render(
layout.FlexOptions{
Background: &background,
Direction: layout.Row,
Justify: layout.JustifyCenter,
Align: layout.AlignStretch,
Width: width - 2,
Gap: 5,
},
layout.FlexItem{
View: copy,
},
// layout.FlexItem{
// View: revert,
// },
)
header = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(header)
content = "\n\n\n" + header + "\n\n" + content + "\n\n"
}
return content
}
func renderText(
app *app.App,
message opencode.Message,
text string,
author string,
showToolDetails bool,
highlight bool,
width int,
align lipgloss.Position,
toolCalls ...opencode.ToolInvocationPart,
) string {
t := theme.CurrentTheme()
@ -206,17 +235,20 @@ func renderText(
timestamp = timestamp[12:]
}
info := fmt.Sprintf("%s (%s)", author, timestamp)
info = styles.NewStyle().Foreground(t.TextMuted()).Render(info)
messageStyle := styles.NewStyle().
Background(t.BackgroundPanel()).
Foreground(t.Text())
backgroundColor := t.BackgroundPanel()
if highlight {
backgroundColor = t.BackgroundElement()
}
messageStyle := styles.NewStyle().Background(backgroundColor)
if message.Role == opencode.MessageRoleUser {
messageStyle = messageStyle.Width(width - 6)
}
content := messageStyle.Render(text)
if message.Role == opencode.MessageRoleAssistant {
content = toMarkdown(text, width, t.BackgroundPanel())
content = util.ToMarkdown(text, width, backgroundColor)
}
if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 {
@ -242,16 +274,19 @@ func renderText(
switch message.Role {
case opencode.MessageRoleUser:
return renderContentBlock(
app,
content,
highlight,
width,
align,
WithBorderColor(t.Secondary()),
WithTextColor(t.Text()),
WithBorderColorRight(t.Secondary()),
)
case opencode.MessageRoleAssistant:
return renderContentBlock(
app,
content,
highlight,
width,
align,
WithBorderColor(t.Accent()),
)
}
@ -259,10 +294,11 @@ func renderText(
}
func renderToolDetails(
app *app.App,
toolCall opencode.ToolInvocationPart,
messageMetadata opencode.MessageMetadata,
highlight bool,
width int,
align lipgloss.Position,
) string {
ignoredTools := []string{"todoread"}
if slices.Contains(ignoredTools, toolCall.ToolInvocation.ToolName) {
@ -282,7 +318,7 @@ func renderToolDetails(
if toolCall.ToolInvocation.State == "partial-call" {
title := renderToolTitle(toolCall, messageMetadata, width)
return renderContentBlock(title, width, align)
return renderContentBlock(app, title, highlight, width)
}
toolArgsMap := make(map[string]any)
@ -301,6 +337,10 @@ func renderToolDetails(
body := ""
finished := result != nil && *result != ""
t := theme.CurrentTheme()
backgroundColor := t.BackgroundPanel()
if highlight {
backgroundColor = t.BackgroundElement()
}
switch toolCall.ToolInvocation.ToolName {
case "read":
@ -308,7 +348,7 @@ func renderToolDetails(
if preview != nil && toolArgsMap["filePath"] != nil {
filename := toolArgsMap["filePath"].(string)
body = preview.(string)
body = renderFile(filename, body, width, WithTruncate(6))
body = util.RenderFile(filename, body, width, util.WithTruncate(6))
}
case "edit":
if filename, ok := toolArgsMap["filePath"].(string); ok {
@ -321,38 +361,28 @@ func renderToolDetails(
patch,
diff.WithWidth(width-2),
)
formattedDiff = strings.TrimSpace(formattedDiff)
formattedDiff = styles.NewStyle().
BorderStyle(lipgloss.ThickBorder()).
BorderBackground(t.Background()).
BorderForeground(t.BackgroundPanel()).
BorderLeft(true).
BorderRight(true).
Render(formattedDiff)
body = strings.TrimSpace(formattedDiff)
body = renderContentBlock(
body,
width,
align,
WithNoBorder(),
WithPadding(0),
)
style := styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Padding(1, 2).Width(width - 4)
if highlight {
style = style.Foreground(t.Text()).Bold(true)
}
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
body += "\n" + renderContentBlock(diagnostics, width, align)
diagnostics = style.Render(diagnostics)
body += "\n" + diagnostics
}
title := renderToolTitle(toolCall, messageMetadata, width)
title = renderContentBlock(title, width, align)
title = style.Render(title)
content := title + "\n" + body
content = renderContentBlock(app, content, highlight, width, WithPadding(0))
return content
}
}
case "write":
if filename, ok := toolArgsMap["filePath"].(string); ok {
if content, ok := toolArgsMap["content"].(string); ok {
body = renderFile(filename, content, width)
body = util.RenderFile(filename, content, width)
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
body += "\n\n" + diagnostics
}
@ -363,14 +393,14 @@ func renderToolDetails(
if stdout != nil {
command := toolArgsMap["command"].(string)
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
body = toMarkdown(body, width, t.BackgroundPanel())
body = util.ToMarkdown(body, width, backgroundColor)
}
case "webfetch":
if format, ok := toolArgsMap["format"].(string); ok && result != nil {
body = *result
body = truncateHeight(body, 10)
body = util.TruncateHeight(body, 10)
if format == "html" || format == "markdown" {
body = toMarkdown(body, width, t.BackgroundPanel())
body = util.ToMarkdown(body, width, backgroundColor)
}
}
case "todowrite":
@ -389,7 +419,7 @@ func renderToolDetails(
body += fmt.Sprintf("- [ ] %s\n", content)
}
}
body = toMarkdown(body, width, t.BackgroundPanel())
body = util.ToMarkdown(body, width, backgroundColor)
}
case "task":
summary := metadata.JSON.ExtraFields["summary"]
@ -424,7 +454,7 @@ func renderToolDetails(
result = &empty
}
body = *result
body = truncateHeight(body, 10)
body = util.TruncateHeight(body, 10)
}
error := ""
@ -437,18 +467,18 @@ func renderToolDetails(
if error != "" {
body = styles.NewStyle().
Foreground(t.Error()).
Background(t.BackgroundPanel()).
Background(backgroundColor).
Render(error)
}
if body == "" && error == "" && result != nil {
body = *result
body = truncateHeight(body, 10)
body = util.TruncateHeight(body, 10)
}
title := renderToolTitle(toolCall, messageMetadata, width)
content := title + "\n\n" + body
return renderContentBlock(content, width, align)
return renderContentBlock(app, content, highlight, width)
}
func renderToolName(name string) string {
@ -505,7 +535,7 @@ func renderToolTitle(
title = fmt.Sprintf("%s %s", title, toolArgs)
case "edit", "write":
if filename, ok := toolArgsMap["filePath"].(string); ok {
title = fmt.Sprintf("%s %s", title, relative(filename))
title = fmt.Sprintf("%s %s", title, util.Relative(filename))
}
case "bash", "task":
if description, ok := toolArgsMap["description"].(string); ok {
@ -551,50 +581,6 @@ func renderToolAction(name string) string {
return "Working..."
}
type fileRenderer struct {
filename string
content string
height int
}
type fileRenderingOption func(*fileRenderer)
func WithTruncate(height int) fileRenderingOption {
return func(c *fileRenderer) {
c.height = height
}
}
func renderFile(
filename string,
content string,
width int,
options ...fileRenderingOption) string {
t := theme.CurrentTheme()
renderer := &fileRenderer{
filename: filename,
content: content,
}
for _, option := range options {
option(renderer)
}
lines := []string{}
for line := range strings.SplitSeq(content, "\n") {
line = strings.TrimRightFunc(line, unicode.IsSpace)
line = strings.ReplaceAll(line, "\t", " ")
lines = append(lines, line)
}
content = strings.Join(lines, "\n")
if renderer.height > 0 {
content = truncateHeight(content, renderer.height)
}
content = fmt.Sprintf("```%s\n%s\n```", extension(renderer.filename), content)
content = toMarkdown(content, width, t.BackgroundPanel())
return content
}
func renderArgs(args *map[string]any, titleKey string) string {
if args == nil || len(*args) == 0 {
return ""
@ -614,7 +600,7 @@ func renderArgs(args *map[string]any, titleKey string) string {
continue
}
if key == "filePath" || key == "path" {
value = relative(value.(string))
value = util.Relative(value.(string))
}
if key == titleKey {
title = fmt.Sprintf("%s", value)
@ -628,29 +614,6 @@ func renderArgs(args *map[string]any, titleKey string) string {
return fmt.Sprintf("%s (%s)", title, strings.Join(parts, ", "))
}
func truncateHeight(content string, height int) string {
lines := strings.Split(content, "\n")
if len(lines) > height {
return strings.Join(lines[:height], "\n")
}
return content
}
func relative(path string) string {
path = strings.TrimPrefix(path, app.CwdPath+"/")
return strings.TrimPrefix(path, app.RootPath+"/")
}
func extension(path string) string {
ext := filepath.Ext(path)
if ext == "" {
ext = ""
} else {
ext = strings.ToLower(ext[1:])
}
return ext
}
// Diagnostic represents an LSP diagnostic
type Diagnostic struct {
Range struct {

View file

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

View file

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

View file

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

View file

@ -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),
),
}
}

View file

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

View file

@ -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()
}

View file

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

View file

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

View file

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

View file

@ -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
}

View file

@ -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)
}
})
}
}

View file

@ -19,6 +19,7 @@ import (
"github.com/sst/opencode/internal/components/chat"
cmdcomp "github.com/sst/opencode/internal/components/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/fileviewer"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/components/status"
"github.com/sst/opencode/internal/components/toast"
@ -40,6 +41,7 @@ const (
)
const interruptDebounceTimeout = 1 * time.Second
const fileViewerFullWidthCutoff = 200
type appModel struct {
width, height int
@ -56,6 +58,12 @@ type appModel struct {
toastManager *toast.ToastManager
interruptKeyState InterruptKeyState
lastScroll time.Time
messagesRight bool
fileViewer fileviewer.Model
lastMouse tea.Mouse
fileViewerStart int
fileViewerEnd int
fileViewerHit bool
}
func (a appModel) Init() tea.Cmd {
@ -71,6 +79,7 @@ func (a appModel) Init() tea.Cmd {
cmds = append(cmds, a.status.Init())
cmds = append(cmds, a.completions.Init())
cmds = append(cmds, a.toastManager.Init())
cmds = append(cmds, a.fileViewer.Init())
// Check if we should show the init dialog
cmds = append(cmds, func() tea.Msg {
@ -99,6 +108,7 @@ var BUGGED_SCROLL_KEYS = map[string]bool{
}
func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
switch msg := msg.(type) {
@ -112,10 +122,20 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.modal != nil {
switch keyString {
// Escape always closes current modal
case "esc", "ctrl+c":
case "esc":
cmd := a.modal.Close()
a.modal = nil
return a, cmd
case "ctrl+c":
// give the modal a chance to handle the ctrl+c
updatedModal, cmd := a.modal.Update(msg)
a.modal = updatedModal.(layout.Modal)
if cmd != nil {
return a, cmd
}
cmd = a.modal.Close()
a.modal = nil
return a, cmd
}
// Pass all other key presses to the modal
@ -246,10 +266,28 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.modal != nil {
return a, nil
}
var cmd tea.Cmd
if a.fileViewerHit {
a.fileViewer, cmd = a.fileViewer.Update(msg)
cmds = append(cmds, cmd)
} else {
updated, cmd := a.messages.Update(msg)
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
}
return a, tea.Batch(cmds...)
case tea.MouseMotionMsg:
a.lastMouse = msg.Mouse()
a.fileViewerHit = a.fileViewer.HasFile() &&
a.lastMouse.X > a.fileViewerStart &&
a.lastMouse.X < a.fileViewerEnd
case tea.MouseClickMsg:
a.lastMouse = msg.Mouse()
a.fileViewerHit = a.fileViewer.HasFile() &&
a.lastMouse.X > a.fileViewerStart &&
a.lastMouse.X < a.fileViewerEnd
case tea.BackgroundColorMsg:
styles.Terminal = &styles.TerminalInfo{
Background: msg.Color,
@ -266,6 +304,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
case modal.CloseModalMsg:
a.editor.Focus()
var cmd tea.Cmd
if a.modal != nil {
cmd = a.modal.Close()
@ -349,22 +388,47 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
slog.Error("Server error", "name", err.Name, "message", err.Data.Message)
return a, toast.NewErrorToast(err.Data.Message, toast.WithTitle(string(err.Name)))
}
case opencode.EventListResponseEventFileWatcherUpdated:
if a.fileViewer.HasFile() {
if a.fileViewer.Filename() == msg.Properties.File {
return a.openFile(msg.Properties.File)
}
}
case tea.WindowSizeMsg:
msg.Height -= 2 // Make space for the status bar
a.width, a.height = msg.Width, msg.Height
container := min(a.width, 84)
if a.fileViewer.HasFile() {
if a.width < fileViewerFullWidthCutoff {
container = a.width
} else {
container = min(min(a.width, max(a.width/2, 50)), 84)
}
}
layout.Current = &layout.LayoutInfo{
Viewport: layout.Dimensions{
Width: a.width,
Height: a.height,
},
Container: layout.Dimensions{
Width: min(a.width, 80),
Width: container,
},
}
// Update child component sizes
messagesHeight := a.height - 6 // Leave room for editor and status bar
a.messages.SetSize(a.width, messagesHeight)
a.editor.SetSize(min(a.width, 80), 5)
mainWidth := layout.Current.Container.Width
a.messages.SetWidth(mainWidth - 4)
sideWidth := a.width - mainWidth
if a.width < fileViewerFullWidthCutoff {
sideWidth = a.width
}
a.fileViewerStart = mainWidth
a.fileViewerEnd = a.fileViewerStart + sideWidth
if a.messagesRight {
a.fileViewerStart = 0
a.fileViewerEnd = sideWidth
}
a.fileViewer, cmd = a.fileViewer.SetSize(sideWidth, layout.Current.Viewport.Height)
cmds = append(cmds, cmd)
case app.SessionSelectedMsg:
messages, err := a.app.ListMessages(context.Background(), msg.ID)
if err != nil {
@ -373,6 +437,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
a.app.Session = msg
a.app.Messages = messages
return a, util.CmdHandler(app.SessionLoadedMsg{})
case app.ModelSelectedMsg:
a.app.Provider = &msg.Provider
a.app.Model = &msg.Model
@ -395,24 +460,22 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Reset interrupt key state after timeout
a.interruptKeyState = InterruptKeyIdle
a.editor.SetInterruptKeyInDebounce(false)
case dialog.FindSelectedMsg:
return a.openFile(msg.FilePath)
}
// update status bar
s, cmd := a.status.Update(msg)
cmds = append(cmds, cmd)
a.status = s.(status.StatusComponent)
// update editor
u, cmd := a.editor.Update(msg)
a.editor = u.(chat.EditorComponent)
cmds = append(cmds, cmd)
// update messages
u, cmd = a.messages.Update(msg)
a.messages = u.(chat.MessagesComponent)
cmds = append(cmds, cmd)
// update modal
if a.modal != nil {
u, cmd := a.modal.Update(msg)
a.modal = u.(layout.Modal)
@ -425,86 +488,95 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
}
fv, cmd := a.fileViewer.Update(msg)
a.fileViewer = fv
cmds = append(cmds, cmd)
return a, tea.Batch(cmds...)
}
func (a appModel) View() string {
mainLayout := a.chat(layout.Current.Container.Width, lipgloss.Center)
t := theme.CurrentTheme()
var mainLayout string
mainWidth := layout.Current.Container.Width - 4
if a.app.Session.ID == "" {
mainLayout = a.home(mainWidth)
} else {
mainLayout = a.chat(mainWidth)
}
mainLayout = styles.NewStyle().
Background(t.Background()).
Padding(0, 2).
Render(mainLayout)
mainHeight := lipgloss.Height(mainLayout)
if a.fileViewer.HasFile() {
file := a.fileViewer.View()
baseStyle := styles.NewStyle().Background(t.BackgroundPanel())
sidePanel := baseStyle.Height(mainHeight).Render(file)
if a.width >= fileViewerFullWidthCutoff {
if a.messagesRight {
mainLayout = lipgloss.JoinHorizontal(
lipgloss.Top,
sidePanel,
mainLayout,
)
} else {
mainLayout = lipgloss.JoinHorizontal(
lipgloss.Top,
mainLayout,
sidePanel,
)
}
} else {
mainLayout = sidePanel
}
} else {
mainLayout = lipgloss.PlaceHorizontal(
a.width,
lipgloss.Center,
mainLayout,
styles.WhitespaceStyle(t.Background()),
)
}
mainStyle := styles.NewStyle().Background(t.Background())
mainLayout = mainStyle.Render(mainLayout)
if a.modal != nil {
mainLayout = a.modal.Render(mainLayout)
}
mainLayout = a.toastManager.RenderOverlay(mainLayout)
if theme.CurrentThemeUsesAnsiColors() {
mainLayout = util.ConvertRGBToAnsi16Colors(mainLayout)
}
return mainLayout + "\n" + a.status.View()
}
func (a appModel) chat(width int, align lipgloss.Position) string {
editorView := a.editor.View(width, align)
lines := a.editor.Lines()
messagesView := a.messages.View()
if a.app.Session.ID == "" {
messagesView = a.home()
}
editorHeight := max(lines, 5)
t := theme.CurrentTheme()
centeredEditorView := lipgloss.PlaceHorizontal(
a.width,
align,
editorView,
styles.WhitespaceStyle(t.Background()),
)
mainLayout := layout.Render(
layout.FlexOptions{
Direction: layout.Column,
Width: a.width,
Height: a.height,
},
layout.FlexItem{
View: messagesView,
Grow: true,
},
layout.FlexItem{
View: centeredEditorView,
FixedSize: 5,
func (a appModel) openFile(filepath string) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
response, err := a.app.Client.File.Read(
context.Background(),
opencode.FileReadParams{
Path: opencode.F(filepath),
},
)
if lines > 1 {
editorWidth := min(a.width, 80)
editorX := (a.width - editorWidth) / 2
editorY := a.height - editorHeight
mainLayout = layout.PlaceOverlay(
editorX,
editorY,
a.editor.Content(width, align),
mainLayout,
if err != nil {
slog.Error("Failed to read file", "error", err)
return a, toast.NewErrorToast("Failed to read file")
}
a.fileViewer, cmd = a.fileViewer.SetFile(
filepath,
response.Content,
response.Type == "patch",
)
return a, cmd
}
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
}
func (a appModel) home() string {
func (a appModel) home(width int) string {
t := theme.CurrentTheme()
baseStyle := styles.NewStyle().Background(t.Background())
base := baseStyle.Render
@ -536,7 +608,7 @@ func (a appModel) home() string {
logoAndVersion := strings.Join([]string{logo, version}, "\n")
logoAndVersion = lipgloss.PlaceHorizontal(
a.width,
width,
lipgloss.Center,
logoAndVersion,
styles.WhitespaceStyle(t.Background()),
@ -547,13 +619,15 @@ func (a appModel) home() string {
cmdcomp.WithLimit(6),
)
cmds := lipgloss.PlaceHorizontal(
a.width,
width,
lipgloss.Center,
commandsView.View(),
styles.WhitespaceStyle(t.Background()),
)
lines := []string{}
lines = append(lines, "")
lines = append(lines, "")
lines = append(lines, logoAndVersion)
lines = append(lines, "")
lines = append(lines, "")
@ -561,18 +635,100 @@ func (a appModel) home() string {
// lines = append(lines, base("config ")+muted(config))
// lines = append(lines, "")
lines = append(lines, cmds)
lines = append(lines, "")
lines = append(lines, "")
return lipgloss.Place(
a.width,
a.height-5,
mainHeight := lipgloss.Height(strings.Join(lines, "\n"))
editorWidth := min(width, 80)
editorView := a.editor.View(editorWidth)
editorView = lipgloss.PlaceHorizontal(
width,
lipgloss.Center,
editorView,
styles.WhitespaceStyle(t.Background()),
)
lines = append(lines, editorView)
editorLines := a.editor.Lines()
mainLayout := lipgloss.Place(
width,
a.height,
lipgloss.Center,
lipgloss.Center,
baseStyle.Render(strings.Join(lines, "\n")),
styles.WhitespaceStyle(t.Background()),
)
editorX := (width - editorWidth) / 2
editorY := (a.height / 2) + (mainHeight / 2) - 2
if editorLines > 1 {
mainLayout = layout.PlaceOverlay(
editorX,
editorY,
a.editor.Content(editorWidth),
mainLayout,
)
}
if a.showCompletionDialog {
a.completions.SetWidth(editorWidth)
overlay := a.completions.View()
overlayHeight := lipgloss.Height(overlay)
mainLayout = layout.PlaceOverlay(
editorX,
editorY-overlayHeight+1,
overlay,
mainLayout,
)
}
return mainLayout
}
func (a appModel) chat(width int) string {
editorView := a.editor.View(width)
lines := a.editor.Lines()
messagesView := a.messages.View(width, a.height-5)
editorWidth := lipgloss.Width(editorView)
editorHeight := max(lines, 5)
mainLayout := messagesView + "\n" + editorView
editorX := (a.width - editorWidth) / 2
if lines > 1 {
editorY := a.height - editorHeight
mainLayout = layout.PlaceOverlay(
editorX,
editorY,
a.editor.Content(width),
mainLayout,
)
}
if a.showCompletionDialog {
a.completions.SetWidth(editorWidth)
overlay := a.completions.View()
overlayHeight := lipgloss.Height(overlay)
editorY := a.height - editorHeight + 1
mainLayout = layout.PlaceOverlay(
editorX,
editorY-overlayHeight,
overlay,
mainLayout,
)
}
return mainLayout
}
func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
cmds := []tea.Cmd{
util.CmdHandler(commands.CommandExecutedMsg(command)),
}
@ -676,6 +832,22 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
case commands.ThemeListCommand:
themeDialog := dialog.NewThemeDialog()
a.modal = themeDialog
case commands.FileListCommand:
a.editor.Blur()
provider := completions.NewFileAndFolderContextGroup(a.app)
findDialog := dialog.NewFindDialog(provider)
findDialog.SetWidth(layout.Current.Container.Width - 8)
a.modal = findDialog
case commands.FileCloseCommand:
a.fileViewer, cmd = a.fileViewer.Clear()
cmds = append(cmds, cmd)
case commands.FileDiffToggleCommand:
a.fileViewer, cmd = a.fileViewer.ToggleDiff()
a.app.State.SplitDiff = a.fileViewer.DiffStyle() == fileviewer.DiffStyleSplit
a.app.SaveState()
cmds = append(cmds, cmd)
case commands.FileSearchCommand:
return a, nil
case commands.ProjectInitCommand:
cmds = append(cmds, a.app.InitializeProject(context.Background()))
case commands.InputClearCommand:
@ -697,20 +869,6 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
updated, cmd := a.editor.Newline()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case commands.HistoryPreviousCommand:
if a.showCompletionDialog {
return a, nil
}
updated, cmd := a.editor.Previous()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case commands.HistoryNextCommand:
if a.showCompletionDialog {
return a, nil
}
updated, cmd := a.editor.Next()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case commands.MessagesFirstCommand:
updated, cmd := a.messages.First()
a.messages = updated.(chat.MessagesComponent)
@ -720,21 +878,62 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesPageUpCommand:
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:
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:
if a.fileViewer.HasFile() {
a.fileViewer, cmd = a.fileViewer.HalfPageUp()
cmds = append(cmds, cmd)
} else {
updated, cmd := a.messages.HalfPageUp()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
}
case commands.MessagesHalfPageDownCommand:
if a.fileViewer.HasFile() {
a.fileViewer, cmd = a.fileViewer.HalfPageDown()
cmds = append(cmds, cmd)
} else {
updated, cmd := a.messages.HalfPageDown()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
}
case commands.MessagesPreviousCommand:
updated, cmd := a.messages.Previous()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesNextCommand:
updated, cmd := a.messages.Next()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesLayoutToggleCommand:
a.messagesRight = !a.messagesRight
a.app.State.MessagesRight = a.messagesRight
a.app.SaveState()
case commands.MessagesCopyCommand:
selected := a.messages.Selected()
if selected != "" {
cmd = tea.SetClipboard(selected)
cmds = append(cmds, cmd)
cmd = toast.NewSuccessToast("Message copied to clipboard")
cmds = append(cmds, cmd)
}
case commands.MessagesRevertCommand:
case commands.AppExitCommand:
return a, tea.Quit
}
@ -776,6 +975,8 @@ func NewModel(app *app.App) tea.Model {
showCompletionDialog: false,
toastManager: toast.NewToastManager(),
interruptKeyState: InterruptKeyIdle,
fileViewer: fileviewer.New(app),
messagesRight: app.State.MessagesRight,
}
return model

View file

@ -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")
}

View file

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