mirror of
https://github.com/sst/opencode.git
synced 2025-07-07 16:14:59 +00:00
feat(tui): file viewer, select messages
This commit is contained in:
parent
63e783ef79
commit
c82a060eca
24 changed files with 1720 additions and 573 deletions
|
@ -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({
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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=
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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...)
|
||||
|
|
235
packages/tui/internal/components/dialog/find.go
Normal file
235
packages/tui/internal/components/dialog/find.go
Normal 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),
|
||||
),
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
281
packages/tui/internal/components/fileviewer/fileviewer.go
Normal file
281
packages/tui/internal/components/fileviewer/fileviewer.go
Normal 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()
|
||||
}
|
|
@ -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()),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
41
packages/tui/internal/layout/flex_example_test.go
Normal file
41
packages/tui/internal/layout/flex_example_test.go
Normal 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
|
||||
}
|
90
packages/tui/internal/layout/flex_test.go
Normal file
90
packages/tui/internal/layout/flex_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
|
||||
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,
|
||||
a.fileViewer, cmd = a.fileViewer.SetFile(
|
||||
filepath,
|
||||
response.Content,
|
||||
response.Type == "patch",
|
||||
)
|
||||
}
|
||||
|
||||
return mainLayout
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
func (a appModel) home() string {
|
||||
func (a appModel) home(width int) string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.NewStyle().Background(t.Background())
|
||||
base := baseStyle.Render
|
||||
|
@ -536,7 +608,7 @@ func (a appModel) home() string {
|
|||
|
||||
logoAndVersion := strings.Join([]string{logo, version}, "\n")
|
||||
logoAndVersion = lipgloss.PlaceHorizontal(
|
||||
a.width,
|
||||
width,
|
||||
lipgloss.Center,
|
||||
logoAndVersion,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
|
@ -547,13 +619,15 @@ func (a appModel) home() string {
|
|||
cmdcomp.WithLimit(6),
|
||||
)
|
||||
cmds := lipgloss.PlaceHorizontal(
|
||||
a.width,
|
||||
width,
|
||||
lipgloss.Center,
|
||||
commandsView.View(),
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
|
||||
lines := []string{}
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, logoAndVersion)
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "")
|
||||
|
@ -561,18 +635,100 @@ func (a appModel) home() string {
|
|||
// lines = append(lines, base("config ")+muted(config))
|
||||
// lines = append(lines, "")
|
||||
lines = append(lines, cmds)
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "")
|
||||
|
||||
return lipgloss.Place(
|
||||
a.width,
|
||||
a.height-5,
|
||||
mainHeight := lipgloss.Height(strings.Join(lines, "\n"))
|
||||
|
||||
editorWidth := min(width, 80)
|
||||
editorView := a.editor.View(editorWidth)
|
||||
editorView = lipgloss.PlaceHorizontal(
|
||||
width,
|
||||
lipgloss.Center,
|
||||
editorView,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
lines = append(lines, editorView)
|
||||
|
||||
editorLines := a.editor.Lines()
|
||||
|
||||
mainLayout := lipgloss.Place(
|
||||
width,
|
||||
a.height,
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
baseStyle.Render(strings.Join(lines, "\n")),
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
|
||||
editorX := (width - editorWidth) / 2
|
||||
editorY := (a.height / 2) + (mainHeight / 2) - 2
|
||||
|
||||
if editorLines > 1 {
|
||||
mainLayout = layout.PlaceOverlay(
|
||||
editorX,
|
||||
editorY,
|
||||
a.editor.Content(editorWidth),
|
||||
mainLayout,
|
||||
)
|
||||
}
|
||||
|
||||
if a.showCompletionDialog {
|
||||
a.completions.SetWidth(editorWidth)
|
||||
overlay := a.completions.View()
|
||||
overlayHeight := lipgloss.Height(overlay)
|
||||
|
||||
mainLayout = layout.PlaceOverlay(
|
||||
editorX,
|
||||
editorY-overlayHeight+1,
|
||||
overlay,
|
||||
mainLayout,
|
||||
)
|
||||
}
|
||||
|
||||
return mainLayout
|
||||
}
|
||||
|
||||
func (a appModel) chat(width int) string {
|
||||
editorView := a.editor.View(width)
|
||||
lines := a.editor.Lines()
|
||||
messagesView := a.messages.View(width, a.height-5)
|
||||
|
||||
editorWidth := lipgloss.Width(editorView)
|
||||
editorHeight := max(lines, 5)
|
||||
|
||||
mainLayout := messagesView + "\n" + editorView
|
||||
editorX := (a.width - editorWidth) / 2
|
||||
|
||||
if lines > 1 {
|
||||
editorY := a.height - editorHeight
|
||||
mainLayout = layout.PlaceOverlay(
|
||||
editorX,
|
||||
editorY,
|
||||
a.editor.Content(width),
|
||||
mainLayout,
|
||||
)
|
||||
}
|
||||
|
||||
if a.showCompletionDialog {
|
||||
a.completions.SetWidth(editorWidth)
|
||||
overlay := a.completions.View()
|
||||
overlayHeight := lipgloss.Height(overlay)
|
||||
editorY := a.height - editorHeight + 1
|
||||
|
||||
mainLayout = layout.PlaceOverlay(
|
||||
editorX,
|
||||
editorY-overlayHeight,
|
||||
overlay,
|
||||
mainLayout,
|
||||
)
|
||||
}
|
||||
|
||||
return mainLayout
|
||||
}
|
||||
|
||||
func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
cmds := []tea.Cmd{
|
||||
util.CmdHandler(commands.CommandExecutedMsg(command)),
|
||||
}
|
||||
|
@ -676,6 +832,22 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
|
|||
case commands.ThemeListCommand:
|
||||
themeDialog := dialog.NewThemeDialog()
|
||||
a.modal = themeDialog
|
||||
case commands.FileListCommand:
|
||||
a.editor.Blur()
|
||||
provider := completions.NewFileAndFolderContextGroup(a.app)
|
||||
findDialog := dialog.NewFindDialog(provider)
|
||||
findDialog.SetWidth(layout.Current.Container.Width - 8)
|
||||
a.modal = findDialog
|
||||
case commands.FileCloseCommand:
|
||||
a.fileViewer, cmd = a.fileViewer.Clear()
|
||||
cmds = append(cmds, cmd)
|
||||
case commands.FileDiffToggleCommand:
|
||||
a.fileViewer, cmd = a.fileViewer.ToggleDiff()
|
||||
a.app.State.SplitDiff = a.fileViewer.DiffStyle() == fileviewer.DiffStyleSplit
|
||||
a.app.SaveState()
|
||||
cmds = append(cmds, cmd)
|
||||
case commands.FileSearchCommand:
|
||||
return a, nil
|
||||
case commands.ProjectInitCommand:
|
||||
cmds = append(cmds, a.app.InitializeProject(context.Background()))
|
||||
case commands.InputClearCommand:
|
||||
|
@ -697,20 +869,6 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
|
|||
updated, cmd := a.editor.Newline()
|
||||
a.editor = updated.(chat.EditorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
case commands.HistoryPreviousCommand:
|
||||
if a.showCompletionDialog {
|
||||
return a, nil
|
||||
}
|
||||
updated, cmd := a.editor.Previous()
|
||||
a.editor = updated.(chat.EditorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
case commands.HistoryNextCommand:
|
||||
if a.showCompletionDialog {
|
||||
return a, nil
|
||||
}
|
||||
updated, cmd := a.editor.Next()
|
||||
a.editor = updated.(chat.EditorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
case commands.MessagesFirstCommand:
|
||||
updated, cmd := a.messages.First()
|
||||
a.messages = updated.(chat.MessagesComponent)
|
||||
|
@ -720,21 +878,62 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
|
|||
a.messages = updated.(chat.MessagesComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
case commands.MessagesPageUpCommand:
|
||||
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
|
||||
|
|
109
packages/tui/internal/util/file.go
Normal file
109
packages/tui/internal/util/file.go
Normal 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")
|
||||
}
|
|
@ -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:
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue